diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 539c642650..0000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,213 +0,0 @@ -module.exports = { - env: { - browser: true, - es2021: true, - node: true, - commonjs: true, - es6: true, - }, - extends: [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:jest/recommended', - 'prettier', - 'plugin:jsx-a11y/recommended', - ], - ignorePatterns: [ - 'client/dist/**/*', - 'client/public/**/*', - 'e2e/playwright-report/**/*', - 'packages/mcp/types/**/*', - 'packages/mcp/dist/**/*', - 'packages/mcp/test_bundle/**/*', - 'api/demo/**/*', - 'packages/data-provider/types/**/*', - 'packages/data-provider/dist/**/*', - 'packages/data-provider/test_bundle/**/*', - 'data-node/**/*', - 'meili_data/**/*', - 'node_modules/**/*', - ], - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, - plugins: ['react', 'react-hooks', '@typescript-eslint', 'import', 'jsx-a11y'], - rules: { - 'react/react-in-jsx-scope': 'off', - '@typescript-eslint/ban-ts-comment': ['error', { 'ts-ignore': 'allow' }], - indent: ['error', 2, { SwitchCase: 1 }], - 'max-len': [ - 'error', - { - code: 120, - ignoreStrings: true, - ignoreTemplateLiterals: true, - ignoreComments: true, - }, - ], - 'linebreak-style': 0, - curly: ['error', 'all'], - semi: ['error', 'always'], - 'object-curly-spacing': ['error', 'always'], - 'no-multiple-empty-lines': ['error', { max: 1 }], - 'no-trailing-spaces': 'error', - 'comma-dangle': ['error', 'always-multiline'], - // "arrow-parens": [2, "as-needed", { requireForBlockBody: true }], - // 'no-plusplus': ['error', { allowForLoopAfterthoughts: true }], - 'no-console': 'off', - 'import/no-cycle': 'error', - 'import/no-self-import': 'error', - 'import/extensions': 'off', - 'no-promise-executor-return': 'off', - 'no-param-reassign': 'off', - 'no-continue': 'off', - 'no-restricted-syntax': 'off', - 'react/prop-types': ['off'], - 'react/display-name': ['off'], - 'no-nested-ternary': 'error', - 'no-unused-vars': ['error', { varsIgnorePattern: '^_' }], - quotes: ['error', 'single'], - }, - overrides: [ - { - files: ['**/*.ts', '**/*.tsx'], - rules: { - 'no-unused-vars': 'off', // off because it conflicts with '@typescript-eslint/no-unused-vars' - 'react/display-name': 'off', - '@typescript-eslint/no-unused-vars': 'warn', - }, - }, - { - files: ['rollup.config.js', '.eslintrc.js', 'jest.config.js'], - env: { - node: true, - }, - }, - { - files: [ - '**/*.test.js', - '**/*.test.jsx', - '**/*.test.ts', - '**/*.test.tsx', - '**/*.spec.js', - '**/*.spec.jsx', - '**/*.spec.ts', - '**/*.spec.tsx', - 'setupTests.js', - ], - env: { - jest: true, - node: true, - }, - rules: { - 'react/display-name': 'off', - 'react/prop-types': 'off', - 'react/no-unescaped-entities': 'off', - }, - }, - { - files: ['**/*.ts', '**/*.tsx'], - parser: '@typescript-eslint/parser', - parserOptions: { - project: './client/tsconfig.json', - }, - plugins: ['@typescript-eslint/eslint-plugin', 'jest'], - extends: [ - 'plugin:@typescript-eslint/eslint-recommended', - 'plugin:@typescript-eslint/recommended', - ], - rules: { - '@typescript-eslint/no-explicit-any': 'error', - '@typescript-eslint/no-unnecessary-condition': 'warn', - '@typescript-eslint/strict-boolean-expressions': 'warn', - }, - }, - { - files: './packages/data-provider/**/*.ts', - overrides: [ - { - files: '**/*.ts', - parser: '@typescript-eslint/parser', - parserOptions: { - project: './packages/data-provider/tsconfig.json', - }, - }, - ], - }, - { - files: './api/demo/**/*.ts', - overrides: [ - { - files: '**/*.ts', - parser: '@typescript-eslint/parser', - parserOptions: { - project: './packages/data-provider/tsconfig.json', - }, - }, - ], - }, - { - files: './packages/mcp/**/*.ts', - overrides: [ - { - files: '**/*.ts', - parser: '@typescript-eslint/parser', - parserOptions: { - project: './packages/mcp/tsconfig.json', - }, - }, - ], - }, - { - files: './config/translations/**/*.ts', - parser: '@typescript-eslint/parser', - parserOptions: { - project: './config/translations/tsconfig.json', - }, - }, - { - files: ['./packages/data-provider/specs/**/*.ts'], - parserOptions: { - project: './packages/data-provider/tsconfig.spec.json', - }, - }, - { - files: ['./api/demo/specs/**/*.ts'], - parserOptions: { - project: './packages/data-provider/tsconfig.spec.json', - }, - }, - { - files: ['./packages/mcp/specs/**/*.ts'], - parserOptions: { - project: './packages/mcp/tsconfig.spec.json', - }, - }, - ], - settings: { - react: { - createClass: 'createReactClass', // Regex for Component Factory to use, - // default to "createReactClass" - pragma: 'React', // Pragma to use, default to "React" - fragment: 'Fragment', // Fragment to use (may be a property of ), default to "Fragment" - version: 'detect', // React version. "detect" automatically picks the version you have installed. - }, - 'import/parsers': { - '@typescript-eslint/parser': ['.ts', '.tsx'], - }, - 'import/resolver': { - typescript: { - project: ['./client/tsconfig.json'], - }, - node: { - project: ['./client/tsconfig.json'], - }, - }, - }, -}; diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index 5c88b9f70d..3a3b828ee1 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -1,12 +1,19 @@ name: Bug Report description: File a bug report title: "[Bug]: " -labels: ["bug"] +labels: ["🐛 bug"] body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! + + Before submitting, please: + - Search existing [Issues and Discussions](https://github.com/danny-avila/LibreChat/discussions) to see if your bug has already been reported + - Use [Discussions](https://github.com/danny-avila/LibreChat/discussions) instead of Issues for: + - General inquiries + - Help with setup + - Questions about whether you're experiencing a bug - type: textarea id: what-happened attributes: @@ -15,6 +22,23 @@ body: placeholder: Please give as many details as possible validations: required: true + - type: textarea + id: version-info + attributes: + label: Version Information + description: | + If using Docker, please run and provide the output of: + ```bash + docker images | grep librechat + ``` + + If running from source, please run and provide the output of: + ```bash + git rev-parse HEAD + ``` + placeholder: Paste the output here + validations: + required: true - type: textarea id: steps-to-reproduce attributes: @@ -39,7 +63,21 @@ body: id: logs attributes: label: Relevant log output - description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. + description: | + Please paste relevant logs that were created when reproducing the error. + + Log locations: + - Docker: Project root directory ./logs + - npm: ./api/logs + + There are two types of logs that can help diagnose the issue: + - debug logs (debug-YYYY-MM-DD.log) + - error logs (error-YYYY-MM-DD.log) + + Error logs contain exact stack traces and are especially helpful, but both can provide valuable information. + Please only include the relevant portions of logs that correspond to when you reproduced the error. + + For UI-related issues, browser console logs can be very helpful. You can provide these as screenshots or paste the text here. render: shell - type: textarea id: screenshots @@ -53,4 +91,4 @@ body: description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct - required: true + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml index d85957fd22..613c9e0a01 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -1,7 +1,7 @@ name: Feature Request description: File a feature request -title: "Enhancement: " -labels: ["enhancement"] +title: "[Enhancement]: " +labels: ["✨ enhancement"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml new file mode 100644 index 0000000000..5fddced9f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml @@ -0,0 +1,33 @@ +name: New Language Request +description: Request to add a new language for LibreChat translations. +title: "New Language Request: " +labels: ["✨ enhancement", "🌍 i18n"] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to submit a new language request! Please fill out the following details so we can review your request. + - type: input + id: language_name + attributes: + label: Language Name + description: Please provide the full name of the language (e.g., Spanish, Mandarin). + placeholder: e.g., Spanish + validations: + required: true + - type: input + id: iso_code + attributes: + label: ISO 639-1 Code + description: Please provide the ISO 639-1 code for the language (e.g., es for Spanish). You can refer to [this list](https://www.w3schools.com/tags/ref_language_codes.asp) for valid codes. + placeholder: e.g., es + validations: + required: true + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md). + options: + - label: I agree to follow this project's Code of Conduct + required: true diff --git a/.github/ISSUE_TEMPLATE/QUESTION.yml b/.github/ISSUE_TEMPLATE/QUESTION.yml index 0669fd6724..c66e6baa3b 100644 --- a/.github/ISSUE_TEMPLATE/QUESTION.yml +++ b/.github/ISSUE_TEMPLATE/QUESTION.yml @@ -1,7 +1,7 @@ name: Question description: Ask your question title: "[Question]: " -labels: ["question"] +labels: ["❓ question"] body: - type: markdown attributes: diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 33316731a1..5bc3d3b2db 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -38,7 +38,7 @@ jobs: - name: Install MCP Package run: npm run build:mcp - + - name: Create empty auth.json file run: | mkdir -p api/data @@ -61,9 +61,4 @@ jobs: run: cd api && npm run test:ci - name: Run librechat-data-provider unit tests - run: cd packages/data-provider && npm run test:ci - - - name: Run linters - uses: wearerequired/lint-action@v2 - with: - eslint: true + run: cd packages/data-provider && npm run test:ci \ No newline at end of file diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml new file mode 100644 index 0000000000..ea1a5f2416 --- /dev/null +++ b/.github/workflows/eslint-ci.yml @@ -0,0 +1,73 @@ +name: ESLint Code Quality Checks + +on: + pull_request: + branches: + - main + - dev + - release/* + paths: + - 'api/**' + - 'client/**' + +jobs: + eslint_checks: + name: Run ESLint Linting + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write + actions: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - name: Install dependencies + run: npm ci + + # Run ESLint on changed files within the api/ and client/ directories. + - name: Run ESLint on changed files + env: + SARIF_ESLINT_IGNORE_SUPPRESSED: "true" + run: | + # Extract the base commit SHA from the pull_request event payload. + BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") + echo "Base commit SHA: $BASE_SHA" + + # Get changed files (only JS/TS files in api/ or client/) + CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | grep -E '^(api|client)/.*\.(js|jsx|ts|tsx)$' || true) + + # Debug output + echo "Changed files:" + echo "$CHANGED_FILES" + + # Ensure there are files to lint before running ESLint + if [[ -z "$CHANGED_FILES" ]]; then + echo "No matching files changed. Skipping ESLint." + echo "UPLOAD_SARIF=false" >> $GITHUB_ENV + exit 0 + fi + + # Set variable to allow SARIF upload + echo "UPLOAD_SARIF=true" >> $GITHUB_ENV + + # Run ESLint + npx eslint --no-error-on-unmatched-pattern \ + --config eslint.config.mjs \ + --format @microsoft/eslint-formatter-sarif \ + --output-file eslint-results.sarif $CHANGED_FILES || true + + - name: Upload analysis results to GitHub + if: env.UPLOAD_SARIF == 'true' + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: eslint-results.sarif + wait-for-processing: true \ No newline at end of file diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml new file mode 100644 index 0000000000..79f95d3b27 --- /dev/null +++ b/.github/workflows/i18n-unused-keys.yml @@ -0,0 +1,84 @@ +name: Detect Unused i18next Strings + +on: + pull_request: + paths: + - "client/src/**" + +jobs: + detect-unused-i18n-keys: + runs-on: ubuntu-latest + permissions: + pull-requests: write # Required for posting PR comments + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Find unused i18next keys + id: find-unused + run: | + echo "🔍 Scanning for unused i18next keys..." + + # Define paths + I18N_FILE="client/src/locales/en/translation.json" + SOURCE_DIR="client/src" + + # Check if translation file exists + if [[ ! -f "$I18N_FILE" ]]; then + echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE" + exit 1 + fi + + # Extract all keys from the JSON file + KEYS=$(jq -r 'keys[]' "$I18N_FILE") + + # Track unused keys + UNUSED_KEYS=() + + # Check if each key is used in the source code + for KEY in $KEYS; do + if ! grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$SOURCE_DIR"; then + UNUSED_KEYS+=("$KEY") + fi + done + + # Output results + if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then + echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:" + echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV + for KEY in "${UNUSED_KEYS[@]}"; do + echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase." + done + else + echo "✅ No unused i18n keys detected!" + echo "unused_keys=[]" >> $GITHUB_ENV + fi + + - name: Post verified comment on PR + if: env.unused_keys != '[]' + run: | + PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") + + # Format the unused keys list correctly, filtering out empty entries + FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- `/;s/$/`/' ) + + COMMENT_BODY=$(cat < used_scripts.txt + else + touch used_scripts.txt + fi + } + + extract_deps_from_scripts "package.json" + mv used_scripts.txt root_used_deps.txt + + extract_deps_from_scripts "client/package.json" + mv used_scripts.txt client_used_deps.txt + + extract_deps_from_scripts "api/package.json" + mv used_scripts.txt api_used_deps.txt + + - name: Extract Dependencies Used in Source Code + id: extract-used-code + run: | + extract_deps_from_code() { + local folder=$1 + local output_file=$2 + if [[ -d "$folder" ]]; then + grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" --include=\*.{js,ts,mjs,cjs} | \ + sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" > "$output_file" + + grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" --include=\*.{js,ts,mjs,cjs} | \ + sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" + + sort -u "$output_file" -o "$output_file" + else + touch "$output_file" + fi + } + + extract_deps_from_code "." root_used_code.txt + extract_deps_from_code "client" client_used_code.txt + extract_deps_from_code "api" api_used_code.txt + + - name: Run depcheck for root package.json + id: check-root + run: | + if [[ -f "package.json" ]]; then + UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") + UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt | sort) || echo "") + echo "ROOT_UNUSED<> $GITHUB_ENV + echo "$UNUSED" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + fi + + - name: Run depcheck for client/package.json + id: check-client + run: | + if [[ -f "client/package.json" ]]; then + chmod -R 755 client + cd client + UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") + UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt | sort) || echo "") + echo "CLIENT_UNUSED<> $GITHUB_ENV + echo "$UNUSED" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + cd .. + fi + + - name: Run depcheck for api/package.json + id: check-api + run: | + if [[ -f "api/package.json" ]]; then + chmod -R 755 api + cd api + UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "") + UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt | sort) || echo "") + echo "API_UNUSED<> $GITHUB_ENV + echo "$UNUSED" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + cd .. + fi + + - name: Post comment on PR if unused dependencies are found + if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != '' + run: | + PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH") + + ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}') + CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}') + API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}') + + COMMENT_BODY=$(cat <

+

+ + Translation Progress + +

+ + # ✨ Features - 🖥️ **UI & Experience** inspired by ChatGPT with enhanced design and features @@ -79,6 +88,9 @@ - English, 中文, Deutsch, Español, Français, Italiano, Polski, Português Brasileiro - Русский, 日本語, Svenska, 한국어, Tiếng Việt, 繁體中文, العربية, Türkçe, Nederlands, עברית +- 🧠 **Reasoning UI**: + - Dynamic Reasoning UI for Chain-of-Thought/Reasoning AI models like DeepSeek-R1 + - 🎨 **Customizable Interface**: - Customizable Dropdown & Interface that adapts to both power users and newcomers @@ -167,6 +179,8 @@ Contributions, suggestions, bug reports and fixes are welcome! For new features, components, or extensions, please open an issue and discuss before sending a PR. +If you'd like to help translate LibreChat into your language, we'd love your contribution! Improving our translations not only makes LibreChat more accessible to users around the world but also enhances the overall user experience. Please check out our [Translation Guide](https://www.librechat.ai/docs/translation). + --- ## 💖 This project exists in its current state thanks to all the people who contribute @@ -174,3 +188,15 @@ For new features, components, or extensions, please open an issue and discuss be + +--- + +## 🎉 Special Thanks + +We thank [Locize](https://locize.com) for their translation management tools that support multiple languages in LibreChat. + +

+ + Locize Logo + +

diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 9334f1c28b..368e7d6e84 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -506,9 +506,8 @@ class OpenAIClient extends BaseClient { if (promptPrefix && this.isOmni === true) { const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user'); if (lastUserMessageIndex !== -1) { - payload[ - lastUserMessageIndex - ].content = `${promptPrefix}\n${payload[lastUserMessageIndex].content}`; + payload[lastUserMessageIndex].content = + `${promptPrefix}\n${payload[lastUserMessageIndex].content}`; } } @@ -1067,14 +1066,36 @@ ${convo} }); } - getStreamText() { + /** + * + * @param {string[]} [intermediateReply] + * @returns {string} + */ + getStreamText(intermediateReply) { if (!this.streamHandler) { - return ''; + return intermediateReply?.join('') ?? ''; + } + + let thinkMatch; + let remainingText; + let reasoningText = ''; + + if (this.streamHandler.reasoningTokens.length > 0) { + reasoningText = this.streamHandler.reasoningTokens.join(''); + thinkMatch = reasoningText.match(/([\s\S]*?)<\/think>/)?.[1]?.trim(); + if (thinkMatch != null && thinkMatch) { + const reasoningTokens = `:::thinking\n${thinkMatch}\n:::\n`; + remainingText = reasoningText.split(/<\/think>/)?.[1]?.trim() || ''; + return `${reasoningTokens}${remainingText}${this.streamHandler.tokens.join('')}`; + } else if (thinkMatch === '') { + remainingText = reasoningText.split(/<\/think>/)?.[1]?.trim() || ''; + return `${remainingText}${this.streamHandler.tokens.join('')}`; + } } const reasoningTokens = - this.streamHandler.reasoningTokens.length > 0 - ? `:::thinking\n${this.streamHandler.reasoningTokens.join('')}\n:::\n` + reasoningText.length > 0 + ? `:::thinking\n${reasoningText.replace('', '').replace('', '').trim()}\n:::\n` : ''; return `${reasoningTokens}${this.streamHandler.tokens.join('')}`; @@ -1314,11 +1335,19 @@ ${convo} streamPromise = new Promise((resolve) => { streamResolve = resolve; }); + /** @type {OpenAI.OpenAI.CompletionCreateParamsStreaming} */ + const params = { + ...modelOptions, + stream: true, + }; + if ( + this.options.endpoint === EModelEndpoint.openAI || + this.options.endpoint === EModelEndpoint.azureOpenAI + ) { + params.stream_options = { include_usage: true }; + } const stream = await openai.beta.chat.completions - .stream({ - ...modelOptions, - stream: true, - }) + .stream(params) .on('abort', () => { /* Do nothing here */ }) @@ -1449,7 +1478,7 @@ ${convo} this.options.context !== 'title' && message.content.startsWith('') ) { - return message.content.replace('', ':::thinking').replace('', ':::'); + return this.getStreamText(); } return message.content; @@ -1458,7 +1487,7 @@ ${convo} err?.message?.includes('abort') || (err instanceof OpenAI.APIError && err?.message?.includes('abort')) ) { - return intermediateReply.join(''); + return this.getStreamText(intermediateReply); } if ( err?.message?.includes( @@ -1473,14 +1502,18 @@ ${convo} (err instanceof OpenAI.OpenAIError && err?.message?.includes('missing finish_reason')) ) { logger.error('[OpenAIClient] Known OpenAI error:', err); - if (intermediateReply.length > 0) { - return intermediateReply.join(''); + if (this.streamHandler && this.streamHandler.reasoningTokens.length) { + return this.getStreamText(); + } else if (intermediateReply.length > 0) { + return this.getStreamText(intermediateReply); } else { throw err; } } else if (err instanceof OpenAI.APIError) { - if (intermediateReply.length > 0) { - return intermediateReply.join(''); + if (this.streamHandler && this.streamHandler.reasoningTokens.length) { + return this.getStreamText(); + } else if (intermediateReply.length > 0) { + return this.getStreamText(intermediateReply); } else { throw err; } diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index b7ff50150e..6592371f02 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -37,6 +37,10 @@ const messages = isRedisEnabled ? new Keyv({ store: keyvRedis, ttl: Time.ONE_MINUTE }) : new Keyv({ namespace: CacheKeys.MESSAGES, ttl: Time.ONE_MINUTE }); +const flows = isRedisEnabled + ? new Keyv({ store: keyvRedis, ttl: Time.TWO_MINUTES }) + : new Keyv({ namespace: CacheKeys.FLOWS, ttl: Time.ONE_MINUTE * 3 }); + const tokenConfig = isRedisEnabled ? new Keyv({ store: keyvRedis, ttl: Time.THIRTY_MINUTES }) : new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: Time.THIRTY_MINUTES }); @@ -88,6 +92,7 @@ const namespaces = { [CacheKeys.MODEL_QUERIES]: modelQueries, [CacheKeys.AUDIO_RUNS]: audioRuns, [CacheKeys.MESSAGES]: messages, + [CacheKeys.FLOWS]: flows, }; /** diff --git a/api/cache/keyvRedis.js b/api/cache/keyvRedis.js index 9501045e4e..d544b50a11 100644 --- a/api/cache/keyvRedis.js +++ b/api/cache/keyvRedis.js @@ -1,6 +1,6 @@ const KeyvRedis = require('@keyv/redis'); -const { logger } = require('~/config'); const { isEnabled } = require('~/server/utils'); +const logger = require('~/config/winston'); const { REDIS_URI, USE_REDIS } = process.env; diff --git a/api/config/index.js b/api/config/index.js index c2b21cfc07..aaf8bb2764 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -1,9 +1,11 @@ const { EventSource } = require('eventsource'); +const { Time, CacheKeys } = require('librechat-data-provider'); const logger = require('./winston'); global.EventSource = EventSource; let mcpManager = null; +let flowManager = null; /** * @returns {Promise} @@ -16,6 +18,21 @@ async function getMCPManager() { return mcpManager; } +/** + * @param {(key: string) => Keyv} getLogStores + * @returns {Promise} + */ +async function getFlowStateManager(getLogStores) { + if (!flowManager) { + const { FlowStateManager } = await import('librechat-mcp'); + flowManager = new FlowStateManager(getLogStores(CacheKeys.FLOWS), { + ttl: Time.ONE_MINUTE * 3, + logger, + }); + } + return flowManager; +} + /** * Sends message data in Server Sent Events format. * @param {ServerResponse} res - The server response. @@ -34,4 +51,5 @@ module.exports = { logger, sendEvent, getMCPManager, + getFlowStateManager, }; diff --git a/api/models/Token.js b/api/models/Token.js index cdd156b6b4..210666ddd7 100644 --- a/api/models/Token.js +++ b/api/models/Token.js @@ -1,5 +1,6 @@ -const tokenSchema = require('./schema/tokenSchema'); const mongoose = require('mongoose'); +const { encryptV2 } = require('~/server/utils/crypto'); +const tokenSchema = require('./schema/tokenSchema'); const { logger } = require('~/config'); /** @@ -7,6 +8,32 @@ const { logger } = require('~/config'); * @type {mongoose.Model} */ const Token = mongoose.model('Token', tokenSchema); +/** + * Fixes the indexes for the Token collection from legacy TTL indexes to the new expiresAt index. + */ +async function fixIndexes() { + try { + const indexes = await Token.collection.indexes(); + logger.debug('Existing Token Indexes:', JSON.stringify(indexes, null, 2)); + const unwantedTTLIndexes = indexes.filter( + (index) => index.key.createdAt === 1 && index.expireAfterSeconds !== undefined, + ); + if (unwantedTTLIndexes.length === 0) { + logger.debug('No unwanted Token indexes found.'); + return; + } + for (const index of unwantedTTLIndexes) { + logger.debug(`Dropping unwanted Token index: ${index.name}`); + await Token.collection.dropIndex(index.name); + logger.debug(`Dropped Token index: ${index.name}`); + } + logger.debug('Token index cleanup completed successfully.'); + } catch (error) { + logger.error('An error occurred while fixing Token indexes:', error); + } +} + +fixIndexes(); /** * Creates a new Token instance. @@ -29,8 +56,7 @@ async function createToken(tokenData) { expiresAt, }; - const newToken = new Token(newTokenData); - return await newToken.save(); + return await Token.create(newTokenData); } catch (error) { logger.debug('An error occurred while creating token:', error); throw error; @@ -42,7 +68,8 @@ async function createToken(tokenData) { * @param {Object} query - The query to match against. * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. * @param {String} query.token - The token value. - * @param {String} query.email - The email of the user. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. * @returns {Promise} The matched Token document, or null if not found. * @throws Will throw an error if the find operation fails. */ @@ -59,6 +86,9 @@ async function findToken(query) { if (query.email) { conditions.push({ email: query.email }); } + if (query.identifier) { + conditions.push({ identifier: query.identifier }); + } const token = await Token.findOne({ $and: conditions, @@ -76,6 +106,8 @@ async function findToken(query) { * @param {Object} query - The query to match against. * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. * @param {String} query.token - The token value. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. * @param {Object} updateData - The data to update the Token with. * @returns {Promise} The updated Token document, or null if not found. * @throws Will throw an error if the update operation fails. @@ -94,14 +126,20 @@ async function updateToken(query, updateData) { * @param {Object} query - The query to match against. * @param {mongoose.Types.ObjectId|String} query.userId - The ID of the user. * @param {String} query.token - The token value. - * @param {String} query.email - The email of the user. + * @param {String} [query.email] - The email of the user. + * @param {String} [query.identifier] - Unique, alternative identifier for the token. * @returns {Promise} The result of the delete operation. * @throws Will throw an error if the delete operation fails. */ async function deleteTokens(query) { try { return await Token.deleteMany({ - $or: [{ userId: query.userId }, { token: query.token }, { email: query.email }], + $or: [ + { userId: query.userId }, + { token: query.token }, + { email: query.email }, + { identifier: query.identifier }, + ], }); } catch (error) { logger.debug('An error occurred while deleting tokens:', error); @@ -109,9 +147,46 @@ async function deleteTokens(query) { } } +/** + * Handles the OAuth token by creating or updating the token. + * @param {object} fields + * @param {string} fields.userId - The user's ID. + * @param {string} fields.token - The full token to store. + * @param {string} fields.identifier - Unique, alternative identifier for the token. + * @param {number} fields.expiresIn - The number of seconds until the token expires. + * @param {object} fields.metadata - Additional metadata to store with the token. + * @param {string} [fields.type="oauth"] - The type of token. Default is 'oauth'. + */ +async function handleOAuthToken({ + token, + userId, + identifier, + expiresIn, + metadata, + type = 'oauth', +}) { + const encrypedToken = await encryptV2(token); + const tokenData = { + type, + userId, + metadata, + identifier, + token: encrypedToken, + expiresIn: parseInt(expiresIn, 10) || 3600, + }; + + const existingToken = await findToken({ userId, identifier }); + if (existingToken) { + return await updateToken({ identifier }, tokenData); + } else { + return await createToken(tokenData); + } +} + module.exports = { - createToken, findToken, + createToken, updateToken, deleteTokens, + handleOAuthToken, }; diff --git a/api/models/schema/agent.js b/api/models/schema/agent.js index 2006859ab6..53e49e1cfd 100644 --- a/api/models/schema/agent.js +++ b/api/models/schema/agent.js @@ -35,6 +35,9 @@ const agentSchema = mongoose.Schema( model_parameters: { type: Object, }, + artifacts: { + type: String, + }, access_level: { type: Number, }, diff --git a/api/models/schema/tokenSchema.js b/api/models/schema/tokenSchema.js index bb223ff450..1b45b2ff33 100644 --- a/api/models/schema/tokenSchema.js +++ b/api/models/schema/tokenSchema.js @@ -10,6 +10,10 @@ const tokenSchema = new Schema({ email: { type: String, }, + type: String, + identifier: { + type: String, + }, token: { type: String, required: true, @@ -23,6 +27,10 @@ const tokenSchema = new Schema({ type: Date, required: true, }, + metadata: { + type: Map, + of: Schema.Types.Mixed, + }, }); tokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); diff --git a/api/package.json b/api/package.json index 10264309c9..8d5a997e6e 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.7.6", + "version": "v0.7.7-rc1", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -45,11 +45,10 @@ "@langchain/google-genai": "^0.1.7", "@langchain/google-vertexai": "^0.1.8", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.0.2", + "@librechat/agents": "^2.0.4", "@waylaidwanderer/fetch-event-source": "^3.0.1", - "axios": "^1.7.7", + "axios": "1.7.8", "bcryptjs": "^2.4.3", - "cheerio": "^1.0.0-rc.12", "cohere-ai": "^7.9.1", "compression": "^1.7.4", "connect-redis": "^7.1.0", @@ -66,7 +65,6 @@ "firebase": "^11.0.2", "googleapis": "^126.0.1", "handlebars": "^4.7.7", - "html": "^1.0.0", "ioredis": "^5.3.2", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.0", @@ -91,7 +89,6 @@ "openid-client": "^5.4.2", "passport": "^0.6.0", "passport-apple": "^2.0.2", - "passport-custom": "^1.1.1", "passport-discord": "^0.1.4", "passport-facebook": "^3.0.0", "passport-github2": "^0.1.12", @@ -99,7 +96,6 @@ "passport-jwt": "^4.0.1", "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", - "pino": "^8.12.1", "sharp": "^0.32.6", "tiktoken": "^1.0.15", "traverse": "^0.6.7", @@ -111,8 +107,8 @@ }, "devDependencies": { "jest": "^29.7.0", - "mongodb-memory-server": "^10.0.0", - "nodemon": "^3.0.1", - "supertest": "^6.3.3" + "mongodb-memory-server": "^10.1.3", + "nodemon": "^3.0.3", + "supertest": "^7.0.0" } } diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js index b952ab0042..55fe2fa717 100644 --- a/api/server/controllers/AskController.js +++ b/api/server/controllers/AskController.js @@ -155,6 +155,8 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { sender, messageId: responseMessageId, parentMessageId: userMessageId ?? parentMessageId, + }).catch((err) => { + logger.error('[AskController] Error in `handleAbortError`', err); }); } }; diff --git a/api/server/controllers/EditController.js b/api/server/controllers/EditController.js index ec618eabcf..2a2f8c28de 100644 --- a/api/server/controllers/EditController.js +++ b/api/server/controllers/EditController.js @@ -140,6 +140,8 @@ const EditController = async (req, res, next, initializeClient) => { sender, messageId: responseMessageId, parentMessageId: userMessageId ?? parentMessageId, + }).catch((err) => { + logger.error('[EditController] Error in `handleAbortError`', err); }); } }; diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 8ceadd977d..288ae8f37f 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -143,6 +143,8 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => { sender, messageId: responseMessageId, parentMessageId: userMessageId ?? parentMessageId, + }).catch((err) => { + logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err); }); } }; diff --git a/api/server/index.js b/api/server/index.js index 7278273600..30d36d9a9f 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -84,6 +84,7 @@ const startServer = async () => { app.use('/oauth', routes.oauth); /* API Endpoints */ app.use('/api/auth', routes.auth); + app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/user', routes.user); app.use('/api/search', routes.search); diff --git a/api/server/routes/actions.js b/api/server/routes/actions.js new file mode 100644 index 0000000000..454f4be6c7 --- /dev/null +++ b/api/server/routes/actions.js @@ -0,0 +1,136 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { getAccessToken } = require('~/server/services/TokenService'); +const { logger, getFlowStateManager } = require('~/config'); +const { getLogStores } = require('~/cache'); + +const router = express.Router(); +const JWT_SECRET = process.env.JWT_SECRET; + +/** + * Handles the OAuth callback and exchanges the authorization code for tokens. + * + * @route GET /actions/:action_id/oauth/callback + * @param {string} req.params.action_id - The ID of the action. + * @param {string} req.query.code - The authorization code returned by the provider. + * @param {string} req.query.state - The state token to verify the authenticity of the request. + * @returns {void} Sends a success message after updating the action with OAuth tokens. + */ +router.get('/:action_id/oauth/callback', async (req, res) => { + const { action_id } = req.params; + const { code, state } = req.query; + + const flowManager = await getFlowStateManager(getLogStores); + let identifier = action_id; + try { + let decodedState; + try { + decodedState = jwt.verify(state, JWT_SECRET); + } catch (err) { + await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter'); + return res.status(400).send('Invalid or expired state parameter'); + } + + if (decodedState.action_id !== action_id) { + await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter'); + return res.status(400).send('Mismatched action ID in state parameter'); + } + + if (!decodedState.user) { + await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter'); + return res.status(400).send('Invalid user ID in state parameter'); + } + identifier = `${decodedState.user}:${action_id}`; + const flowState = await flowManager.getFlowState(identifier, 'oauth'); + if (!flowState) { + throw new Error('OAuth flow not found'); + } + + const tokenData = await getAccessToken({ + code, + userId: decodedState.user, + identifier, + client_url: flowState.metadata.client_url, + redirect_uri: flowState.metadata.redirect_uri, + /** Encrypted values */ + encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id, + encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret, + }); + await flowManager.completeFlow(identifier, 'oauth', tokenData); + res.send(` + + + + Authentication Successful + + + + + +
+

Authentication Successful

+

+ Your authentication was successful. This window will close in + 3 seconds. +

+
+ + + + `); + } catch (error) { + logger.error('Error in OAuth callback:', error); + await flowManager.failFlow(identifier, 'oauth', error); + res.status(500).send('Authentication failed. Please try again.'); + } +}); + +module.exports = router; diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index 5d5456c29f..786f44dd8e 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -1,6 +1,6 @@ const express = require('express'); const { nanoid } = require('nanoid'); -const { actionDelimiter, SystemRoles } = require('librechat-data-provider'); +const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { isActionDomainAllowed } = require('~/server/services/domains'); @@ -51,7 +51,7 @@ router.post('/:agent_id', async (req, res) => { return res.status(400).json({ message: 'No functions provided' }); } - let metadata = await encryptMetadata(_metadata); + let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); const isDomainAllowed = await isActionDomainAllowed(metadata.domain); if (!isDomainAllowed) { return res.status(400).json({ message: 'Domain not allowed' }); @@ -117,10 +117,7 @@ router.post('/:agent_id', async (req, res) => { } /** @type {[Action]} */ - const updatedAction = await updateAction( - { action_id }, - actionUpdateData, - ); + const updatedAction = await updateAction({ action_id }, actionUpdateData); const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; for (let field of sensitiveFields) { diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js index eb65749086..9f4db5d6b8 100644 --- a/api/server/routes/assistants/actions.js +++ b/api/server/routes/assistants/actions.js @@ -1,6 +1,6 @@ const express = require('express'); const { nanoid } = require('nanoid'); -const { actionDelimiter, EModelEndpoint } = require('librechat-data-provider'); +const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); @@ -29,7 +29,7 @@ router.post('/:assistant_id', async (req, res) => { return res.status(400).json({ message: 'No functions provided' }); } - let metadata = await encryptMetadata(_metadata); + let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); const isDomainAllowed = await isActionDomainAllowed(metadata.domain); if (!isDomainAllowed) { return res.status(400).json({ message: 'Domain not allowed' }); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 4aba91e954..4b34029c7b 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -9,6 +9,7 @@ const prompts = require('./prompts'); const balance = require('./balance'); const plugins = require('./plugins'); const bedrock = require('./bedrock'); +const actions = require('./actions'); const search = require('./search'); const models = require('./models'); const convos = require('./convos'); @@ -45,6 +46,7 @@ module.exports = { config, models, plugins, + actions, presets, balance, messages, diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index 712157bf29..660e7aeb0d 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -1,20 +1,28 @@ +const jwt = require('jsonwebtoken'); +const { nanoid } = require('nanoid'); +const { tool } = require('@langchain/core/tools'); +const { GraphEvents, sleep } = require('@librechat/agents'); const { + Time, CacheKeys, + StepTypes, Constants, AuthTypeEnum, actionDelimiter, isImageVisionTool, actionDomainSeparator, } = require('librechat-data-provider'); -const { tool } = require('@langchain/core/tools'); +const { refreshAccessToken } = require('~/server/services/TokenService'); const { isActionDomainAllowed } = require('~/server/services/domains'); +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 { findToken } = require('~/models/Token'); const { logAxiosError } = require('~/utils'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); +const JWT_SECRET = process.env.JWT_SECRET; const toolNameRegex = /^[a-zA-Z0-9_-]+$/; const replaceSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); @@ -115,6 +123,8 @@ async function loadActionSets(searchParams) { * Creates a general tool for an entire action set. * * @param {Object} params - The parameters for loading action sets. + * @param {ServerRequest} params.req + * @param {ServerResponse} params.res * @param {Action} params.action - The action set. Necessary for decrypting authentication values. * @param {ActionRequest} params.requestBuilder - The ActionRequest builder class to execute the API call. * @param {string | undefined} [params.name] - The name of the tool. @@ -122,33 +132,185 @@ async function loadActionSets(searchParams) { * @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. */ -async function createActionTool({ action, requestBuilder, zodSchema, name, description }) { - action.metadata = await decryptMetadata(action.metadata); +async function createActionTool({ + req, + res, + action, + requestBuilder, + zodSchema, + name, + description, +}) { const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain); if (!isDomainAllowed) { return null; } - /** @type {(toolInput: Object | string) => Promise} */ - const _call = async (toolInput) => { - try { - const executor = requestBuilder.createExecutor(); + const encrypted = { + oauth_client_id: action.metadata.oauth_client_id, + oauth_client_secret: action.metadata.oauth_client_secret, + }; + action.metadata = await decryptMetadata(action.metadata); - // Chain the operations + /** @type {(toolInput: Object | string, config: GraphRunnableConfig) => Promise} */ + const _call = async (toolInput, config) => { + try { + /** @type {import('librechat-data-provider').ActionMetadataRuntime} */ + const metadata = action.metadata; + const executor = requestBuilder.createExecutor(); const preparedExecutor = executor.setParams(toolInput); - if (action.metadata.auth && action.metadata.auth.type !== AuthTypeEnum.None) { - await preparedExecutor.setAuth(action.metadata); + if (metadata.auth && metadata.auth.type !== AuthTypeEnum.None) { + try { + const action_id = action.action_id; + const identifier = `${req.user.id}:${action.action_id}`; + if (metadata.auth.type === AuthTypeEnum.OAuth && metadata.auth.authorization_url) { + const requestLogin = async () => { + const { args: _args, stepId, ...toolCall } = config.toolCall ?? {}; + if (!stepId) { + throw new Error('Tool call is missing stepId'); + } + const statePayload = { + nonce: nanoid(), + user: req.user.id, + action_id, + }; + + const stateToken = jwt.sign(statePayload, JWT_SECRET, { expiresIn: '10m' }); + try { + const redirectUri = `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`; + const params = new URLSearchParams({ + client_id: metadata.oauth_client_id, + scope: metadata.auth.scope, + redirect_uri: redirectUri, + access_type: 'offline', + response_type: 'code', + state: stateToken, + }); + + const authURL = `${metadata.auth.authorization_url}?${params.toString()}`; + /** @type {{ id: string; delta: AgentToolCallDelta }} */ + const data = { + id: stepId, + delta: { + type: StepTypes.TOOL_CALLS, + tool_calls: [{ ...toolCall, args: '' }], + auth: authURL, + expires_at: Date.now() + Time.TWO_MINUTES, + }, + }; + const flowManager = await getFlowStateManager(getLogStores); + await flowManager.createFlowWithHandler( + `${identifier}:login`, + 'oauth_login', + async () => { + sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data }); + logger.debug('Sent OAuth login request to client', { action_id, identifier }); + return true; + }, + ); + logger.debug('Waiting for OAuth Authorization response', { action_id, identifier }); + const result = await flowManager.createFlow(identifier, 'oauth', { + state: stateToken, + userId: req.user.id, + client_url: metadata.auth.client_url, + redirect_uri: `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`, + /** Encrypted values */ + encrypted_oauth_client_id: encrypted.oauth_client_id, + encrypted_oauth_client_secret: encrypted.oauth_client_secret, + }); + logger.debug('Received OAuth Authorization response', { action_id, identifier }); + data.delta.auth = undefined; + data.delta.expires_at = undefined; + sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data }); + await sleep(3000); + metadata.oauth_access_token = result.access_token; + metadata.oauth_refresh_token = result.refresh_token; + const expiresAt = new Date(Date.now() + result.expires_in * 1000); + metadata.oauth_token_expires_at = expiresAt.toISOString(); + } catch (error) { + const errorMessage = 'Failed to authenticate OAuth tool'; + logger.error(errorMessage, error); + throw new Error(errorMessage); + } + }; + + const tokenPromises = []; + tokenPromises.push(findToken({ userId: req.user.id, type: 'oauth', identifier })); + tokenPromises.push( + findToken({ + userId: req.user.id, + type: 'oauth_refresh', + identifier: `${identifier}:refresh`, + }), + ); + const [tokenData, refreshTokenData] = await Promise.all(tokenPromises); + + if (tokenData) { + // Valid token exists, add it to metadata for setAuth + metadata.oauth_access_token = await decryptV2(tokenData.token); + if (refreshTokenData) { + metadata.oauth_refresh_token = await decryptV2(refreshTokenData.token); + } + metadata.oauth_token_expires_at = tokenData.expiresAt.toISOString(); + } else if (!refreshTokenData) { + // No tokens exist, need to authenticate + await requestLogin(); + } else if (refreshTokenData) { + // Refresh token is still valid, use it to get new access token + try { + const refresh_token = await decryptV2(refreshTokenData.token); + const refreshTokens = async () => + await refreshAccessToken({ + identifier, + refresh_token, + userId: req.user.id, + client_url: metadata.auth.client_url, + encrypted_oauth_client_id: encrypted.oauth_client_id, + encrypted_oauth_client_secret: encrypted.oauth_client_secret, + }); + const flowManager = await getFlowStateManager(getLogStores); + const refreshData = await flowManager.createFlowWithHandler( + `${identifier}:refresh`, + 'oauth_refresh', + refreshTokens, + ); + metadata.oauth_access_token = refreshData.access_token; + if (refreshData.refresh_token) { + metadata.oauth_refresh_token = refreshData.refresh_token; + } + const expiresAt = new Date(Date.now() + refreshData.expires_in * 1000); + metadata.oauth_token_expires_at = expiresAt.toISOString(); + } catch (error) { + logger.error('Failed to refresh token, requesting new login:', error); + await requestLogin(); + } + } else { + await requestLogin(); + } + } + + await preparedExecutor.setAuth(metadata); + } catch (error) { + if ( + error.message.includes('No access token found') || + error.message.includes('Access token is expired') + ) { + throw error; + } + throw new Error(`Authentication failed: ${error.message}`); + } } - const res = await preparedExecutor.execute(); + const response = await preparedExecutor.execute(); - if (typeof res.data === 'object') { - return JSON.stringify(res.data); + if (typeof response.data === 'object') { + return JSON.stringify(response.data); } - return res.data; + return response.data; } catch (error) { const logMessage = `API call to ${action.metadata.domain} failed`; logAxiosError({ message: logMessage, error }); + throw error; } }; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index d1b6916854..3e03a45125 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -13,6 +13,7 @@ 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 { loadAgentTools } = require('~/server/services/ToolService'); const AgentClient = require('~/server/controllers/agents/client'); @@ -72,6 +73,16 @@ const primeResources = async (_attachments, _tool_resources) => { } }; +/** + * @param {object} params + * @param {ServerRequest} params.req + * @param {ServerResponse} params.res + * @param {Agent} params.agent + * @param {object} [params.endpointOption] + * @param {AgentToolResources} [params.tool_resources] + * @param {boolean} [params.isInitialAgent] + * @returns {Promise} + */ const initializeAgentOptions = async ({ req, res, @@ -82,6 +93,7 @@ const initializeAgentOptions = async ({ }) => { const { tools, toolContextMap } = await loadAgentTools({ req, + res, agent, tool_resources, }); @@ -131,6 +143,13 @@ const initializeAgentOptions = async ({ agent.model_parameters.model = agent.model; } + 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; diff --git a/api/server/services/Endpoints/assistants/build.js b/api/server/services/Endpoints/assistants/build.js index b5eb03a94f..544567dd01 100644 --- a/api/server/services/Endpoints/assistants/build.js +++ b/api/server/services/Endpoints/assistants/build.js @@ -3,7 +3,7 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); const { getAssistant } = require('~/models/Assistant'); const buildOptions = async (endpoint, parsedBody) => { - // eslint-disable-next-line no-unused-vars + const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } = parsedBody; const endpointOption = removeNullishValues({ diff --git a/api/server/services/Endpoints/azureAssistants/build.js b/api/server/services/Endpoints/azureAssistants/build.js index 3785014ca1..54a32e4d3c 100644 --- a/api/server/services/Endpoints/azureAssistants/build.js +++ b/api/server/services/Endpoints/azureAssistants/build.js @@ -3,7 +3,7 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); const { getAssistant } = require('~/models/Assistant'); const buildOptions = async (endpoint, parsedBody) => { - // eslint-disable-next-line no-unused-vars + const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } = parsedBody; const endpointOption = removeNullishValues({ diff --git a/api/server/services/TokenService.js b/api/server/services/TokenService.js new file mode 100644 index 0000000000..ec0f990a47 --- /dev/null +++ b/api/server/services/TokenService.js @@ -0,0 +1,170 @@ +const axios = require('axios'); +const { handleOAuthToken } = require('~/models/Token'); +const { decryptV2 } = require('~/server/utils/crypto'); +const { logAxiosError } = require('~/utils'); +const { logger } = require('~/config'); + +/** + * Processes the access tokens and stores them in the database. + * @param {object} tokenData + * @param {string} tokenData.access_token + * @param {number} tokenData.expires_in + * @param {string} [tokenData.refresh_token] + * @param {number} [tokenData.refresh_token_expires_in] + * @param {object} metadata + * @param {string} metadata.userId + * @param {string} metadata.identifier + * @returns {Promise} + */ +async function processAccessTokens(tokenData, { userId, identifier }) { + const { access_token, expires_in = 3600, refresh_token, refresh_token_expires_in } = tokenData; + if (!access_token) { + logger.error('Access token not found: ', tokenData); + throw new Error('Access token not found'); + } + await handleOAuthToken({ + identifier, + token: access_token, + expiresIn: expires_in, + userId, + }); + + if (refresh_token != null) { + logger.debug('Processing refresh token'); + await handleOAuthToken({ + token: refresh_token, + type: 'oauth_refresh', + userId, + identifier: `${identifier}:refresh`, + expiresIn: refresh_token_expires_in ?? null, + }); + } + logger.debug('Access tokens processed'); +} + +/** + * Refreshes the access token using the refresh token. + * @param {object} fields + * @param {string} fields.userId - The ID of the user. + * @param {string} fields.client_url - The URL of the OAuth provider. + * @param {string} fields.identifier - The identifier for the token. + * @param {string} fields.refresh_token - The refresh token to use. + * @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider. + * @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider. + * @returns {Promise<{ + * access_token: string, + * expires_in: number, + * refresh_token?: string, + * refresh_token_expires_in?: number, + * }>} + */ +const refreshAccessToken = async ({ + userId, + client_url, + identifier, + refresh_token, + encrypted_oauth_client_id, + encrypted_oauth_client_secret, +}) => { + try { + const oauth_client_id = await decryptV2(encrypted_oauth_client_id); + const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); + const params = new URLSearchParams({ + client_id: oauth_client_id, + client_secret: oauth_client_secret, + grant_type: 'refresh_token', + refresh_token, + }); + + const response = await axios({ + method: 'POST', + url: client_url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + data: params.toString(), + }); + await processAccessTokens(response.data, { + userId, + identifier, + }); + logger.debug(`Access token refreshed successfully for ${identifier}`); + return response.data; + } catch (error) { + const message = 'Error refreshing OAuth tokens'; + logAxiosError({ + message, + error, + }); + throw new Error(message); + } +}; + +/** + * Handles the OAuth callback and exchanges the authorization code for tokens. + * @param {object} fields + * @param {string} fields.code - The authorization code returned by the provider. + * @param {string} fields.userId - The ID of the user. + * @param {string} fields.identifier - The identifier for the token. + * @param {string} fields.client_url - The URL of the OAuth provider. + * @param {string} fields.redirect_uri - The redirect URI for the OAuth provider. + * @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider. + * @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider. + * @returns {Promise<{ + * access_token: string, + * expires_in: number, + * refresh_token?: string, + * refresh_token_expires_in?: number, + * }>} + */ +const getAccessToken = async ({ + code, + userId, + identifier, + client_url, + redirect_uri, + encrypted_oauth_client_id, + encrypted_oauth_client_secret, +}) => { + const oauth_client_id = await decryptV2(encrypted_oauth_client_id); + const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); + const params = new URLSearchParams({ + code, + client_id: oauth_client_id, + client_secret: oauth_client_secret, + grant_type: 'authorization_code', + redirect_uri, + }); + + try { + const response = await axios({ + method: 'POST', + url: client_url, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + data: params.toString(), + }); + + await processAccessTokens(response.data, { + userId, + identifier, + }); + logger.debug(`Access tokens successfully created for ${identifier}`); + return response.data; + } catch (error) { + const message = 'Error exchanging OAuth code'; + logAxiosError({ + message, + error, + }); + throw new Error(message); + } +}; + +module.exports = { + getAccessToken, + refreshAccessToken, +}; diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index cf88c0b199..f3e4efb6e3 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -409,11 +409,12 @@ async function processRequiredActions(client, requiredActions) { * Processes the runtime tool calls and returns the tool classes. * @param {Object} params - Run params containing user and request information. * @param {ServerRequest} params.req - The request object. + * @param {ServerResponse} params.res - The request object. * @param {Agent} params.agent - The agent to load tools for. * @param {string | undefined} [params.openAIApiKey] - The OpenAI API key. * @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools. */ -async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) { +async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey }) { if (!agent.tools || agent.tools.length === 0) { return {}; } @@ -546,6 +547,8 @@ async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) { if (requestBuilder) { const tool = await createActionTool({ + req, + res, action: actionSet, requestBuilder, zodSchema, diff --git a/api/server/utils/handleText.js b/api/server/utils/handleText.js index e46584c805..8c681d8f4e 100644 --- a/api/server/utils/handleText.js +++ b/api/server/utils/handleText.js @@ -200,6 +200,7 @@ function generateConfig(key, baseURL, endpoint) { config.capabilities = [ AgentCapabilities.execute_code, AgentCapabilities.file_search, + AgentCapabilities.artifacts, AgentCapabilities.actions, AgentCapabilities.tools, ]; diff --git a/api/typedefs.js b/api/typedefs.js index c88e57719a..bd97bd93fa 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -98,6 +98,12 @@ * @memberof typedefs */ +/** + * @exports LangChainToolCall + * @typedef {import('@langchain/core/messages/tool').ToolCall} LangChainToolCall + * @memberof typedefs + */ + /** * @exports GraphRunnableConfig * @typedef {import('@langchain/core/runnables').RunnableConfig<{ @@ -109,7 +115,9 @@ * agent_index: number; * last_agent_index: number; * hide_sequential_outputs: boolean; - * }>} GraphRunnableConfig + * }> & { + * toolCall?: LangChainToolCall & { stepId?: string }; + * }} GraphRunnableConfig * @memberof typedefs */ @@ -383,6 +391,12 @@ * @memberof typedefs */ +/** + * @exports AgentToolCallDelta + * @typedef {import('librechat-data-provider').Agents.ToolCallDelta} AgentToolCallDelta + * @memberof typedefs + */ + /** Prompts */ /** * @exports TPrompt @@ -947,12 +961,24 @@ * @memberof typedefs */ +/** + * @exports Keyv + * @typedef {import('keyv')} Keyv + * @memberof typedefs + */ + /** * @exports MCPManager * @typedef {import('librechat-mcp').MCPManager} MCPManager * @memberof typedefs */ +/** + * @exports FlowStateManager + * @typedef {import('librechat-mcp').FlowStateManager} FlowStateManager + * @memberof typedefs + */ + /** * @exports LCAvailableTools * @typedef {import('librechat-mcp').LCAvailableTools} LCAvailableTools diff --git a/client/package.json b/client/package.json index 057b150459..993cf30071 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.7.6", + "version": "v0.7.7-rc1", "description": "", "type": "module", "scripts": { @@ -50,11 +50,8 @@ "@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-tabs": "^1.0.3", "@radix-ui/react-toast": "^1.1.5", - "@radix-ui/react-tooltip": "^1.0.6", "@tanstack/react-query": "^4.28.0", "@tanstack/react-table": "^8.11.7", - "@zattoo/use-double-click": "1.2.0", - "axios": "^1.7.7", "class-variance-authority": "^0.6.0", "clsx": "^1.2.1", "copy-to-clipboard": "^3.3.3", @@ -65,7 +62,7 @@ "filenamify": "^6.0.0", "framer-motion": "^11.5.4", "html-to-image": "^1.11.11", - "image-blob-reduce": "^4.1.0", + "i18next": "^24.2.2", "js-cookie": "^3.0.5", "librechat-data-provider": "*", "lodash": "^4.17.21", @@ -79,10 +76,10 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", - "react-error-boundary": "^5.0.0", "react-flip-toolkit": "^7.1.0", "react-gtm-module": "^2.0.11", "react-hook-form": "^7.43.9", + "react-i18next": "^15.4.0", "react-lazy-load-image-component": "^1.6.0", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.1", @@ -103,7 +100,6 @@ "tailwind-merge": "^1.9.1", "tailwindcss-animate": "^1.0.5", "tailwindcss-radix": "^2.8.0", - "url": "^0.11.0", "zod": "^3.22.4" }, "devDependencies": { @@ -116,31 +112,31 @@ "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.4.3", - "@types/jest": "^29.5.2", + "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", "@types/node": "^20.3.0", "@types/react": "^18.2.11", "@types/react-dom": "^18.2.4", "@vitejs/plugin-react": "^4.2.1", - "autoprefixer": "^10.4.13", + "autoprefixer": "^10.4.20", "babel-plugin-replace-ts-export-assignment": "^0.0.2", "babel-plugin-root-import": "^6.6.0", - "babel-plugin-transform-import-meta": "^2.2.1", + "babel-plugin-transform-import-meta": "^2.3.2", "babel-plugin-transform-vite-meta-env": "^1.0.3", - "eslint-plugin-jest": "^27.2.1", + "eslint-plugin-jest": "^28.11.0", "identity-obj-proxy": "^3.0.0", - "jest": "^29.5.0", - "jest-canvas-mock": "^2.5.1", - "jest-environment-jsdom": "^29.5.0", + "jest": "^29.7.0", + "jest-canvas-mock": "^2.5.2", + "jest-environment-jsdom": "^29.7.0", "jest-file-loader": "^1.0.3", "jest-junit": "^16.0.0", "postcss": "^8.4.31", "postcss-loader": "^7.1.0", "postcss-preset-env": "^8.2.0", "tailwindcss": "^3.4.1", - "ts-jest": "^29.1.0", - "typescript": "^5.0.4", - "vite": "^5.4.14", + "ts-jest": "^29.2.5", + "typescript": "^5.3.3", + "vite": "^6.1.0", "vite-plugin-node-polyfills": "^0.17.0", "vite-plugin-pwa": "^0.21.1" } diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index 7f64f07882..a9c24106bc 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -1,4 +1,4 @@ -import { AgentCapabilities } from 'librechat-data-provider'; +import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider'; import type { Agent, AgentProvider, AgentModelParameters } from 'librechat-data-provider'; import type { OptionWithIcon, ExtendedFile } from './types'; @@ -9,8 +9,8 @@ export type TAgentOption = OptionWithIcon & }; export type TAgentCapabilities = { - [AgentCapabilities.execute_code]: boolean; [AgentCapabilities.file_search]: boolean; + [AgentCapabilities.execute_code]: boolean; [AgentCapabilities.end_after_tools]?: boolean; [AgentCapabilities.hide_sequential_outputs]?: boolean; }; @@ -26,4 +26,5 @@ export type AgentForm = { tools?: string[]; provider?: AgentProvider | OptionWithIcon; agent_ids?: string[]; + [AgentCapabilities.artifacts]?: ArtifactModes | string; } & TAgentCapabilities; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index b296dcc090..3d61eccb1c 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -6,6 +6,7 @@ import type { SetterOrUpdater } from 'recoil'; import type * as t from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; import type { LucideIcon } from 'lucide-react'; +import type { TranslationKeys } from '~/hooks'; export type CodeBarProps = { lang: string; @@ -66,7 +67,10 @@ export type GenericSetter = (value: T | ((currentValue: T) => T)) => void; export type LastSelectedModels = Record; -export type LocalizeFunction = (phraseKey: string, ...values: string[]) => string; +export type LocalizeFunction = ( + phraseKey: TranslationKeys, + options?: Record, +) => string; export type ChatFormValues = { text: string }; @@ -85,6 +89,7 @@ export type IconMapProps = { iconURL?: string; context?: 'landing' | 'menu-item' | 'nav' | 'message'; endpoint?: string | null; + endpointType?: string; assistantName?: string; agentName?: string; avatar?: string; diff --git a/client/src/components/Auth/AuthLayout.tsx b/client/src/components/Auth/AuthLayout.tsx index 6df73d2cf9..a7e890517a 100644 --- a/client/src/components/Auth/AuthLayout.tsx +++ b/client/src/components/Auth/AuthLayout.tsx @@ -1,4 +1,4 @@ -import { useLocalize } from '~/hooks'; +import { TranslationKeys, useLocalize } from '~/hooks'; import { BlinkAnimation } from './BlinkAnimation'; import { TStartupConfig } from 'librechat-data-provider'; import SocialLoginRender from './SocialLoginRender'; @@ -33,7 +33,7 @@ function AuthLayout({ startupConfig: TStartupConfig | null | undefined; startupConfigError: unknown | null | undefined; pathname: string; - error: string | null; + error: TranslationKeys | null; }) { const localize = useLocalize(); @@ -65,7 +65,7 @@ function AuthLayout({ {localize('com_ui_logo', diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index 4ae4e03b79..047808ffea 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -6,7 +6,7 @@ import type { TRegisterUser, TError } from 'librechat-data-provider'; import type { TLoginLayoutContext } from '~/common'; import { ErrorMessage } from './ErrorMessage'; import { Spinner } from '~/components/svg'; -import { useLocalize } from '~/hooks'; +import { useLocalize, TranslationKeys } from '~/hooks'; const Registration: React.FC = () => { const navigate = useNavigate(); @@ -56,7 +56,7 @@ const Registration: React.FC = () => { }, }); - const renderInput = (id: string, label: string, type: string, validation: object) => ( + const renderInput = (id: string, label: TranslationKeys, type: string, validation: object) => (
{ : 'com_auth_registration_success_insecure', ) + ' ' + - localize('com_auth_email_verification_redirecting', countdown.toString())} + localize('com_auth_email_verification_redirecting', { 0: countdown.toString() })}
)} {!startupConfigError && !isFetching && ( diff --git a/client/src/components/Auth/VerifyEmail.tsx b/client/src/components/Auth/VerifyEmail.tsx index 0af2f41959..9c143224b2 100644 --- a/client/src/components/Auth/VerifyEmail.tsx +++ b/client/src/components/Auth/VerifyEmail.tsx @@ -84,7 +84,7 @@ function RequestPasswordReset() { {countdown > 0 && (

- {localize('com_auth_email_verification_redirecting', countdown.toString())} + {localize('com_auth_email_verification_redirecting', { 0: countdown.toString() })}

)} {showResendLink && countdown === 0 && ( diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 0ee64ef62b..dbf39ee845 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -28,7 +28,7 @@ function ChatView({ index = 0 }: { index?: number }) { select: useCallback( (data: TMessage[]) => { const dataTree = buildTree({ messages: data, fileMap }); - return dataTree?.length === 0 ? null : dataTree ?? null; + return dataTree?.length === 0 ? null : (dataTree ?? null); }, [fileMap], ), @@ -62,7 +62,7 @@ function ChatView({ index = 0 }: { index?: number }) { - + {content}
diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index 6b9800b472..31e7296af3 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -220,8 +220,10 @@ export default function DataTable({ columns, data }: DataTablePro {localize( 'com_files_number_selected', - `${table.getFilteredSelectedRowModel().rows.length}`, - `${table.getFilteredRowModel().rows.length}`, + { + 0: `${table.getFilteredSelectedRowModel().rows.length}`, + 1: `${table.getFilteredRowModel().rows.length}`, + }, )} diff --git a/client/src/components/Chat/Input/Files/Table/SortFilterHeader.tsx b/client/src/components/Chat/Input/Files/Table/SortFilterHeader.tsx index 5bccd2c6a0..1b9a0cbe42 100644 --- a/client/src/components/Chat/Input/Files/Table/SortFilterHeader.tsx +++ b/client/src/components/Chat/Input/Files/Table/SortFilterHeader.tsx @@ -9,7 +9,7 @@ import { DropdownMenuTrigger, } from '~/components/ui/DropdownMenu'; import { Button } from '~/components/ui/Button'; -import useLocalize from '~/hooks/useLocalize'; +import { useLocalize, TranslationKeys } from '~/hooks'; import { cn } from '~/utils'; interface SortFilterHeaderProps extends React.HTMLAttributes { @@ -78,9 +78,12 @@ export function SortFilterHeader({ {filters && Object.entries(filters).map(([key, values]) => - values.map((value: string | number) => { - const localizedValue = localize(valueMap?.[value] ?? ''); - const filterValue = localizedValue.length ? localizedValue : valueMap?.[value]; + values.map((value?: string | number) => { + const translationKey = valueMap?.[value ?? '']; + const filterValue = + translationKey != null && translationKey.length + ? localize(translationKey as TranslationKeys) + : String(value); if (!filterValue) { return null; } diff --git a/client/src/components/Chat/Input/PopoverButtons.tsx b/client/src/components/Chat/Input/PopoverButtons.tsx index 3b52799445..2143a870e6 100644 --- a/client/src/components/Chat/Input/PopoverButtons.tsx +++ b/client/src/components/Chat/Input/PopoverButtons.tsx @@ -44,8 +44,8 @@ export default function PopoverButtons({ const endpoint = overrideEndpoint ?? endpointType ?? _endpoint ?? ''; const model = overrideModel ?? _model; - const isGenerativeModel = model?.toLowerCase()?.includes('gemini') ?? false; - const isChatModel = (!isGenerativeModel && model?.toLowerCase()?.includes('chat')) ?? false; + const isGenerativeModel = model?.toLowerCase().includes('gemini') ?? false; + const isChatModel = (!isGenerativeModel && model?.toLowerCase().includes('chat')) ?? false; const isTextModel = !isGenerativeModel && !isChatModel && /code|text/.test(model ?? ''); const { showExamples } = optionSettings; diff --git a/client/src/components/Chat/Input/TemporaryChat.tsx b/client/src/components/Chat/Input/TemporaryChat.tsx index 4c9ad898c1..c1f571ba47 100644 --- a/client/src/components/Chat/Input/TemporaryChat.tsx +++ b/client/src/components/Chat/Input/TemporaryChat.tsx @@ -18,7 +18,7 @@ export const TemporaryChat = ({ isTemporaryChat, setIsTemporaryChat }: Temporary
- +
diff --git a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx index d062f4276a..f0918240ba 100644 --- a/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx +++ b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx @@ -7,6 +7,9 @@ import FinishedIcon from './FinishedIcon'; import MarkdownLite from './MarkdownLite'; import store from '~/store'; +const radius = 56.08695652173913; +const circumference = 2 * Math.PI * radius; + export default function CodeAnalyze({ initialProgress = 0.1, code, @@ -22,9 +25,6 @@ export default function CodeAnalyze({ const progress = useProgress(initialProgress); const showAnalysisCode = useRecoilValue(store.showCode); const [showCode, setShowCode] = useState(showAnalysisCode); - - const radius = 56.08695652173913; - const circumference = 2 * Math.PI * radius; const offset = circumference - progress * circumference; const logs = outputs.reduce((acc, output) => { @@ -53,9 +53,10 @@ export default function CodeAnalyze({ setShowCode((prev) => !prev)} - inProgressText="Analyzing" - finishedText="Finished analyzing" + inProgressText={localize('com_ui_analyzing')} + finishedText={localize('com_ui_analyzing_finished')} hasInput={!!code.length} + isExpanded={showCode} />
{showCode && ( diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index ce77e3bfdb..b997060c61 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -50,11 +50,24 @@ const ContentParts = memo( [attachments, messageAttachmentsMap, messageId], ); - const hasReasoningParts = useMemo( - () => content?.some((part) => part?.type === ContentTypes.THINK && part.think) ?? false, - [content], - ); + const hasReasoningParts = useMemo(() => { + const hasThinkPart = content?.some((part) => part?.type === ContentTypes.THINK) ?? false; + const allThinkPartsHaveContent = + content?.every((part) => { + if (part?.type !== ContentTypes.THINK) { + return true; + } + if (typeof part.think === 'string') { + const cleanedContent = part.think.replace(/<\/?think>/g, '').trim(); + return cleanedContent.length > 0; + } + + return false; + }) ?? false; + + return hasThinkPart && allThinkPartsHaveContent; + }, [content]); if (!content) { return null; } diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 09801d92c1..8997d5e822 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -9,14 +9,14 @@ const Files = ({ message }: { message?: TMessage }) => { }, [message?.files]); const otherFiles = useMemo(() => { - return message?.files?.filter((file) => !file.type?.startsWith('image/')) || []; + return message?.files?.filter((file) => !(file.type?.startsWith('image/') === true)) || []; }, [message?.files]); return ( <> {otherFiles.length > 0 && otherFiles.map((file) => )} - {imageFiles && + {imageFiles.length > 0 && imageFiles.map((file) => ( ; }) => { const containerWidth = containerRef.current?.offsetWidth ?? 0; - if (containerWidth === 0 || originalWidth === undefined || originalHeight === undefined) { + if (containerWidth === 0 || originalWidth == null || originalHeight == null) { return { width: 'auto', height: 'auto' }; } const aspectRatio = originalWidth / originalHeight; @@ -35,8 +35,8 @@ const Image = ({ height: number; width: number; placeholderDimensions?: { - height: string; - width: string; + height?: string; + width?: string; }; }) => { const [isLoaded, setIsLoaded] = useState(false); @@ -47,8 +47,8 @@ const Image = ({ const { width: scaledWidth, height: scaledHeight } = useMemo( () => scaleImage({ - originalWidth: Number(placeholderDimensions?.width?.split('px')[0]) ?? width, - originalHeight: Number(placeholderDimensions?.height?.split('px')[0]) ?? height, + originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width), + originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height), containerRef, }), [placeholderDimensions, height, width], diff --git a/client/src/components/Chat/Messages/Content/InProgressCall.tsx b/client/src/components/Chat/Messages/Content/InProgressCall.tsx index c2e9fc35f8..019a3e2d0a 100644 --- a/client/src/components/Chat/Messages/Content/InProgressCall.tsx +++ b/client/src/components/Chat/Messages/Content/InProgressCall.tsx @@ -11,7 +11,7 @@ export default function InProgressCall({ progress: number; children: React.ReactNode; }) { - if ((!isSubmitting && progress < 1) || error) { + if ((!isSubmitting && progress < 1) || error === true) { return ; } diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 21bbe231e6..1547a01d80 100644 --- a/client/src/components/Chat/Messages/Content/MessageContent.tsx +++ b/client/src/components/Chat/Messages/Content/MessageContent.tsx @@ -159,7 +159,9 @@ const MessageContent = ({ return ( <> - {thinkingContent && {thinkingContent}} + {thinkingContent.length > 0 && ( + {thinkingContent} + )} ); } else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 49a15fc71c..93fdab434e 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -4,10 +4,10 @@ import type { TAttachment } from 'librechat-data-provider'; import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; import FinishedIcon from '~/components/Chat/Messages/Content/FinishedIcon'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; +import { useProgress, useLocalize } from '~/hooks'; import { CodeInProgress } from './CodeProgress'; import Attachment from './Attachment'; import LogContent from './LogContent'; -import { useProgress } from '~/hooks'; import store from '~/store'; interface ParsedArgs { @@ -36,6 +36,9 @@ export function useParseArgs(args: string): ParsedArgs { }, [args]); } +const radius = 56.08695652173913; +const circumference = 2 * Math.PI * radius; + export default function ExecuteCode({ initialProgress = 0.1, args, @@ -49,14 +52,12 @@ export default function ExecuteCode({ isSubmitting: boolean; attachments?: TAttachment[]; }) { + const localize = useLocalize(); const showAnalysisCode = useRecoilValue(store.showCode); const [showCode, setShowCode] = useState(showAnalysisCode); const { lang, code } = useParseArgs(args); const progress = useProgress(initialProgress); - - const radius = 56.08695652173913; - const circumference = 2 * Math.PI * radius; const offset = circumference - progress * circumference; return ( @@ -78,9 +79,10 @@ export default function ExecuteCode({ setShowCode((prev) => !prev)} - inProgressText="Analyzing" - finishedText="Finished analyzing" + inProgressText={localize('com_ui_analyzing')} + finishedText={localize('com_ui_analyzing_finished')} hasInput={!!code.length} + isExpanded={showCode} />
{showCode && ( @@ -105,9 +107,7 @@ export default function ExecuteCode({ )}
)} - {attachments?.map((attachment, index) => ( - - ))} + {attachments?.map((attachment, index) => )} ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx index 9a6e3fc99d..0d53fb50ee 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -64,7 +64,7 @@ const LogContent: React.FC = ({ output = '', renderImages, atta } // const expirationText = expiresAt - // ? ` ${localize('com_download_expires', format(expiresAt, 'MM/dd/yy HH:mm'))}` + // ? ` ${localize('com_download_expires', { 0: format(expiresAt, 'MM/dd/yy HH:mm') })}` // : ` ${localize('com_click_to_download')}`; return ( diff --git a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 447bf2f2c4..fd84b03618 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -11,9 +11,16 @@ type ReasoningProps = { const Reasoning = memo(({ reasoning }: ReasoningProps) => { const { isExpanded, nextType } = useMessageContext(); const reasoningText = useMemo(() => { - return reasoning.replace(/^\s*/, '').replace(/\s*<\/think>$/, ''); + return reasoning + .replace(/^\s*/, '') + .replace(/\s*<\/think>$/, '') + .trim(); }, [reasoning]); + if (!reasoningText) { + return null; + } + return (
void; + onClick?: () => void; inProgressText: string; finishedText: string; + authText?: string; hasInput?: boolean; popover?: boolean; + isExpanded?: boolean; }) { + const text = progress < 1 ? (authText ?? inProgressText) : finishedText; return ( )}
diff --git a/client/src/components/Files/VectorStore/VectorStorePreview.tsx b/client/src/components/Files/VectorStore/VectorStorePreview.tsx index 74e7fe8b0a..c1d70107fa 100644 --- a/client/src/components/Files/VectorStore/VectorStorePreview.tsx +++ b/client/src/components/Files/VectorStore/VectorStorePreview.tsx @@ -178,7 +178,7 @@ export default function VectorStorePreview() {  Created At - {vectorStore.createdAt?.toString()} + {vectorStore.createdAt.toString()}
diff --git a/client/src/components/Input/ModelSelect/ModelSelect.tsx b/client/src/components/Input/ModelSelect/ModelSelect.tsx index c4081db6ac..959fd60ecc 100644 --- a/client/src/components/Input/ModelSelect/ModelSelect.tsx +++ b/client/src/components/Input/ModelSelect/ModelSelect.tsx @@ -29,7 +29,7 @@ export default function ModelSelect({ } const { endpoint: _endpoint, endpointType } = conversation; - const models = modelsQuery?.data?.[_endpoint] ?? []; + const models = modelsQuery.data?.[_endpoint] ?? []; const endpoint = endpointType ?? _endpoint; const OptionComponent = multiChatOptions[endpoint]; diff --git a/client/src/components/Input/ModelSelect/TemporaryChat.tsx b/client/src/components/Input/ModelSelect/TemporaryChat.tsx index 1f4168d953..3b0cae4912 100644 --- a/client/src/components/Input/ModelSelect/TemporaryChat.tsx +++ b/client/src/components/Input/ModelSelect/TemporaryChat.tsx @@ -40,13 +40,15 @@ export const TemporaryChat = () => { }; return ( -
-
-
- - {localize('com_ui_temporary_chat')} +
+
+
+
-
+
{ const { expiredAt, endpoint } = json; - return localize('com_error_expired_user_key', endpoint, expiredAt); + return localize('com_error_expired_user_key', { 0: endpoint, 1: expiredAt }); }, [ErrorTypes.INPUT_LENGTH]: (json: TGenericError, localize: LocalizeFunction) => { const { info } = json; - return localize('com_error_input_length', info); + return localize('com_error_input_length', { 0: info }); }, [ErrorTypes.GOOGLE_ERROR]: (json: TGenericError) => { const { info } = json; diff --git a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx index a05643d536..c1de20f1ad 100644 --- a/client/src/components/Nav/Bookmarks/BookmarkNav.tsx +++ b/client/src/components/Nav/Bookmarks/BookmarkNav.tsx @@ -35,9 +35,9 @@ const BookmarkNav: FC = ({ tags, setTags, isSmallScreen }: Boo
{tags.length > 0 ? ( - +
diff --git a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx index fb75b451d9..48f585bdbb 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Avatar.tsx @@ -70,7 +70,7 @@ function Avatar() { const megabytes = fileConfig.avatarSizeLimit != null ? formatBytes(fileConfig.avatarSizeLimit) : 2; showToast({ - message: localize('com_ui_upload_invalid_var', megabytes + ''), + message: localize('com_ui_upload_invalid_var', { 0: megabytes + '' }), status: 'error', }); } diff --git a/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx b/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx index 806ab8fbf4..19f706eded 100644 --- a/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/RevokeKeysButton.tsx @@ -41,7 +41,7 @@ export const RevokeKeysButton = ({ const dialogTitle = all ? localize('com_ui_revoke_keys') - : localize('com_ui_revoke_key_endpoint', endpoint); + : localize('com_ui_revoke_key_endpoint', { 0: endpoint }); const dialogMessage = all ? localize('com_ui_revoke_keys_confirm') diff --git a/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx b/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx index 8683523e4d..b7fa279ece 100755 --- a/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx +++ b/client/src/components/Nav/SettingsTabs/Speech/STT/DecibelSelector.tsx @@ -15,7 +15,7 @@ export default function DecibelSelector() {
{localize('com_nav_db_sensitivity')}
- ({localize('com_endpoint_default_with_num', '-45')}) + ({localize('com_endpoint_default_with_num', { 0: '-45' })})
{localize('com_nav_playback_rate')}
- ({localize('com_endpoint_default_with_num', '1')}) + ({localize('com_endpoint_default_with_num', { 0: '1' })})
{ variant="outline" className="mr-2 h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary sm:m-0" > - +
diff --git a/client/src/components/Prompts/Description.tsx b/client/src/components/Prompts/Description.tsx index 6ea3388af4..d99d7c3b58 100644 --- a/client/src/components/Prompts/Description.tsx +++ b/client/src/components/Prompts/Description.tsx @@ -43,7 +43,7 @@ const Description = ({ return (

- +

+ ); +} diff --git a/client/src/components/SidePanel/Builder/ActionsAuth.tsx b/client/src/components/SidePanel/Builder/ActionsAuth.tsx index 7310685563..c56a489165 100644 --- a/client/src/components/SidePanel/Builder/ActionsAuth.tsx +++ b/client/src/components/SidePanel/Builder/ActionsAuth.tsx @@ -1,144 +1,190 @@ +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import * as RadioGroup from '@radix-ui/react-radio-group'; -import * as DialogPrimitive from '@radix-ui/react-dialog'; import { AuthTypeEnum, AuthorizationTypeEnum, TokenExchangeMethodEnum, } from 'librechat-data-provider'; -import { DialogContent } from '~/components/ui/'; +import { + OGDialog, + OGDialogClose, + OGDialogTitle, + OGDialogHeader, + OGDialogContent, + OGDialogTrigger, +} from '~/components/ui'; +import { TranslationKeys, useLocalize } from '~/hooks'; +import { cn } from '~/utils'; -export default function ActionsAuth({ - setOpenAuthDialog, -}: { - setOpenAuthDialog: React.Dispatch>; -}) { +export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }) { + const localize = useLocalize(); + const [openAuthDialog, setOpenAuthDialog] = useState(false); const { watch, setValue, trigger } = useFormContext(); const type = watch('type'); + return ( - -
-
-
-
-

- Authentication -

+ + +
+
+ +
+
+
+ {localize(`com_ui_${type}` as TranslationKeys)}
+
+
-
-
-
- - setValue('type', value)} - value={type} - role="radiogroup" - aria-required="false" - dir="ltr" - className="flex gap-4" - tabIndex={0} - style={{ outline: 'none' }} - > -
-
- {type === 'none' ? null : type === 'service_http' ? : } - {/* Cancel/Save */} -
- - -
Cancel
-
-
-
- + + ); } const ApiKey = () => { + const localize = useLocalize(); const { register, watch, setValue } = useFormContext(); const authorization_type = watch('authorization_type'); const type = watch('type'); return ( <> - + - + setValue('authorization_type', value)} @@ -147,7 +193,6 @@ const ApiKey = () => { aria-required="true" dir="ltr" className="mb-2 flex gap-6 overflow-hidden rounded-lg" - tabIndex={0} style={{ outline: 'none' }} >
@@ -157,12 +202,14 @@ const ApiKey = () => { role="radio" value={AuthorizationTypeEnum.Basic} id=":rfu:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-500 dark:bg-gray-500" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Basic + {localize('com_ui_basic')}
@@ -172,12 +219,14 @@ const ApiKey = () => { role="radio" value={AuthorizationTypeEnum.Bearer} id=":rg0:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-500 dark:bg-gray-500" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Bearer + {localize('com_ui_bearer')}
@@ -187,20 +236,28 @@ const ApiKey = () => { role="radio" value={AuthorizationTypeEnum.Custom} id=":rg2:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-500 dark:bg-gray-500" - tabIndex={0} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Custom + {localize('com_ui_custom')}
{authorization_type === AuthorizationTypeEnum.Custom && (
- + { }; const OAuth = () => { + const localize = useLocalize(); const { register, watch, setValue } = useFormContext(); const token_exchange_method = watch('token_exchange_method'); const type = watch('type'); + + const inputClasses = cn( + 'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm', + 'border-border-medium bg-surface-primary outline-none', + 'focus:ring-2 focus:ring-ring', + ); + return ( <> - + - + - + - + - + - + setValue('token_exchange_method', value)} @@ -257,7 +324,6 @@ const OAuth = () => { role="radiogroup" aria-required="true" dir="ltr" - tabIndex={0} style={{ outline: 'none' }} >
@@ -267,12 +333,14 @@ const OAuth = () => { role="radio" value={TokenExchangeMethodEnum.DefaultPost} id=":rj1:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-700 dark:bg-gray-700" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Default (POST request) + {localize('com_ui_default_post_request')}
@@ -282,12 +350,14 @@ const OAuth = () => { role="radio" value={TokenExchangeMethodEnum.BasicAuthHeader} id=":rj3:" - className="mr-1 flex h-5 w-5 items-center justify-center rounded-full border border-gray-500 bg-white dark:border-gray-700 dark:bg-gray-700" - tabIndex={-1} + className={cn( + 'mr-1 flex h-5 w-5 items-center justify-center rounded-full border', + 'border-border-heavy bg-surface-primary', + )} > - + - Basic authorization header + {localize('com_ui_basic_auth_header')}
diff --git a/client/src/components/SidePanel/Builder/ActionsInput.tsx b/client/src/components/SidePanel/Builder/ActionsInput.tsx index 0d58daaa44..410df8e9a3 100644 --- a/client/src/components/SidePanel/Builder/ActionsInput.tsx +++ b/client/src/components/SidePanel/Builder/ActionsInput.tsx @@ -15,6 +15,7 @@ import type { } from 'librechat-data-provider'; import type { ActionAuthForm, ActionWithNullableMetadata } from '~/common'; import type { Spec } from './ActionsTable'; +import ActionCallback from '~/components/SidePanel/Builder/ActionCallback'; import { useAssistantsMapContext, useToastContext } from '~/Providers'; import { ActionsTable, columns } from './ActionsTable'; import { useUpdateAction } from '~/data-provider'; @@ -259,8 +260,8 @@ export default function ActionsInput({
{!!data && ( -
-
+
+
@@ -269,6 +270,7 @@ export default function ActionsInput({
)}
+
diff --git a/client/src/components/SidePanel/Builder/ActionsPanel.tsx b/client/src/components/SidePanel/Builder/ActionsPanel.tsx index f3fdd20ded..23071f5c70 100644 --- a/client/src/components/SidePanel/Builder/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Builder/ActionsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { AuthTypeEnum, @@ -8,7 +8,7 @@ import { import { ChevronLeft } from 'lucide-react'; import type { AssistantPanelProps, ActionAuthForm } from '~/common'; import { useAssistantsMapContext, useToastContext } from '~/Providers'; -import { Dialog, DialogTrigger, OGDialog, OGDialogTrigger, Label } from '~/components/ui'; +import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useDeleteAction } from '~/data-provider'; import { TrashIcon } from '~/components/svg'; @@ -29,7 +29,6 @@ export default function ActionsPanel({ const localize = useLocalize(); const { showToast } = useToastContext(); const assistantMap = useAssistantsMapContext(); - const [openAuthDialog, setOpenAuthDialog] = useState(false); const deleteAction = useDeleteAction({ onSuccess: () => { showToast({ @@ -68,7 +67,6 @@ export default function ActionsPanel({ }); const { reset, watch } = methods; - const type = watch('type'); useEffect(() => { if (action?.metadata?.auth) { @@ -162,40 +160,7 @@ export default function ActionsPanel({ Learn more.
*/}
- - -
-
- -
-
-
{type}
-
- -
-
-
- -
+ )} /> diff --git a/client/src/components/SidePanel/Builder/Retrieval.tsx b/client/src/components/SidePanel/Builder/Retrieval.tsx index 79f1b8e542..6a6ce8bbdc 100644 --- a/client/src/components/SidePanel/Builder/Retrieval.tsx +++ b/client/src/components/SidePanel/Builder/Retrieval.tsx @@ -59,7 +59,7 @@ export default function Retrieval({ disabled={isDisabled} onCheckedChange={field.onChange} className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" - value={field?.value?.toString()} + value={field.value.toString()} /> )} /> diff --git a/client/src/components/SidePanel/Files/PanelFileCell.tsx b/client/src/components/SidePanel/Files/PanelFileCell.tsx index 18fcc13ab6..e039b8b257 100644 --- a/client/src/components/SidePanel/Files/PanelFileCell.tsx +++ b/client/src/components/SidePanel/Files/PanelFileCell.tsx @@ -4,12 +4,12 @@ import ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; import FilePreview from '~/components/Chat/Input/Files/FilePreview'; import { getFileType } from '~/utils'; -export default function PanelFileCell({ row }: { row: Row }) { +export default function PanelFileCell({ row }: { row: Row }) { const file = row.original; return (
- {file.type.startsWith('image') ? ( + {file?.type.startsWith('image') === true ? ( }) { alt={file.filename} /> ) : ( - + )}
- {file.filename} + {file?.filename}
diff --git a/client/src/components/SidePanel/Files/PanelTable.tsx b/client/src/components/SidePanel/Files/PanelTable.tsx index 541f5d6efe..51d0c864c1 100644 --- a/client/src/components/SidePanel/Files/PanelTable.tsx +++ b/client/src/components/SidePanel/Files/PanelTable.tsx @@ -279,7 +279,7 @@ export default function DataTable({ columns, data }: DataTablePro onClick={() => setShowFiles(true)} aria-label={localize('com_sidepanel_manage_files')} > - +
@@ -182,7 +182,7 @@ function DynamicTags({ {description && ( )} diff --git a/client/src/components/SidePanel/SidePanel.tsx b/client/src/components/SidePanel/SidePanel.tsx index 51977ab157..7660e8b278 100644 --- a/client/src/components/SidePanel/SidePanel.tsx +++ b/client/src/components/SidePanel/SidePanel.tsx @@ -1,78 +1,58 @@ -import throttle from 'lodash/throttle'; -import { getConfigDefaults } from 'librechat-data-provider'; +import { useState, useCallback, useMemo, memo } from 'react'; import { useUserKeyQuery } from 'librechat-data-provider/react-query'; -import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react'; import type { TEndpointsConfig, TInterfaceConfig } from 'librechat-data-provider'; import type { ImperativePanelHandle } from 'react-resizable-panels'; -import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/components/ui/Resizable'; -import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider'; +import { ResizableHandleAlt, ResizablePanel } from '~/components/ui/Resizable'; import { useMediaQuery, useLocalStorage, useLocalize } from '~/hooks'; import useSideNavLinks from '~/hooks/Nav/useSideNavLinks'; +import { useGetEndpointsQuery } from '~/data-provider'; import NavToggle from '~/components/Nav/NavToggle'; import { cn, getEndpointField } from '~/utils'; import { useChatContext } from '~/Providers'; import Switcher from './Switcher'; import Nav from './Nav'; -interface SidePanelProps { - defaultLayout?: number[] | undefined; - defaultCollapsed?: boolean; - navCollapsedSize?: number; - fullPanelCollapse?: boolean; - artifacts?: React.ReactNode; - children: React.ReactNode; -} - const defaultMinSize = 20; -const defaultInterface = getConfigDefaults().interface; - -const normalizeLayout = (layout: number[]) => { - const sum = layout.reduce((acc, size) => acc + size, 0); - if (Math.abs(sum - 100) < 0.01) { - return layout.map((size) => Number(size.toFixed(2))); - } - - const factor = 100 / sum; - const normalizedLayout = layout.map((size) => Number((size * factor).toFixed(2))); - - const adjustedSum = normalizedLayout.reduce( - (acc, size, index) => (index === layout.length - 1 ? acc : acc + size), - 0, - ); - normalizedLayout[normalizedLayout.length - 1] = Number((100 - adjustedSum).toFixed(2)); - - return normalizedLayout; -}; const SidePanel = ({ - defaultLayout = [97, 3], - defaultCollapsed = false, - fullPanelCollapse = false, + defaultSize, + panelRef, navCollapsedSize = 3, - artifacts, - children, -}: SidePanelProps) => { + hasArtifacts, + minSize, + setMinSize, + collapsedSize, + setCollapsedSize, + isCollapsed, + setIsCollapsed, + fullCollapse, + setFullCollapse, + interfaceConfig, +}: { + defaultSize?: number; + hasArtifacts: boolean; + navCollapsedSize?: number; + minSize: number; + setMinSize: React.Dispatch>; + collapsedSize: number; + setCollapsedSize: React.Dispatch>; + isCollapsed: boolean; + setIsCollapsed: React.Dispatch>; + fullCollapse: boolean; + setFullCollapse: React.Dispatch>; + panelRef: React.RefObject; + interfaceConfig: TInterfaceConfig; +}) => { const localize = useLocalize(); const [isHovering, setIsHovering] = useState(false); - const [minSize, setMinSize] = useState(defaultMinSize); const [newUser, setNewUser] = useLocalStorage('newUser', true); - const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed); - const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse); - const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize); const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); - const { data: startupConfig } = useGetStartupConfig(); - const interfaceConfig = useMemo( - () => (startupConfig?.interface ?? defaultInterface) as Partial, - [startupConfig], - ); const isSmallScreen = useMediaQuery('(max-width: 767px)'); const { conversation } = useChatContext(); const { endpoint } = conversation ?? {}; const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? ''); - const panelRef = useRef(null); - const defaultActive = useMemo(() => { const activePanel = localStorage.getItem('side:active-panel'); return typeof activePanel === 'string' ? activePanel : undefined; @@ -113,46 +93,6 @@ const SidePanel = ({ interfaceConfig, }); - const calculateLayout = useCallback(() => { - if (artifacts == null) { - const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2]; - return [100 - navSize, navSize]; - } else { - const navSize = 0; - const remainingSpace = 100 - navSize; - const newMainSize = Math.floor(remainingSpace / 2); - const artifactsSize = remainingSpace - newMainSize; - return [newMainSize, artifactsSize, navSize]; - } - }, [artifacts, defaultLayout]); - - const currentLayout = useMemo(() => normalizeLayout(calculateLayout()), [calculateLayout]); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const throttledSaveLayout = useCallback( - throttle((sizes: number[]) => { - const normalizedSizes = normalizeLayout(sizes); - localStorage.setItem('react-resizable-panels:layout', JSON.stringify(normalizedSizes)); - }, 350), - [], - ); - - useEffect(() => { - if (isSmallScreen) { - setIsCollapsed(true); - setCollapsedSize(0); - setMinSize(defaultMinSize); - setFullCollapse(true); - localStorage.setItem('fullPanelCollapse', 'true'); - panelRef.current?.collapse(); - return; - } else { - setIsCollapsed(defaultCollapsed); - setCollapsedSize(navCollapsedSize); - setMinSize(defaultMinSize); - } - }, [isSmallScreen, defaultCollapsed, navCollapsedSize, fullPanelCollapse]); - const toggleNavVisible = useCallback(() => { if (newUser) { setNewUser(false); @@ -173,127 +113,84 @@ const SidePanel = ({ } }, [isCollapsed, newUser, setNewUser, navCollapsedSize]); - const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]); - return ( <> - throttledSaveLayout(sizes)} - className="transition-width relative h-full w-full flex-1 overflow-auto bg-presentation" +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className="relative flex w-px items-center justify-center" > - - {children} - - {artifacts != null && ( - <> - - - {artifacts} - - - )} -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - className="relative flex w-px items-center justify-center" - > - -
- {(!isCollapsed || minSize > 0) && !isSmallScreen && !fullCollapse && ( - - )} - { - setIsCollapsed(false); - localStorage.setItem('react-resizable-panels:collapsed', 'false'); - }} - onCollapse={() => { - setIsCollapsed(true); - localStorage.setItem('react-resizable-panels:collapsed', 'true'); - }} + - {interfaceConfig.modelSelect === true && ( -
- -
- )} -
+ {(!isCollapsed || minSize > 0) && !isSmallScreen && !fullCollapse && ( + + )} + + onExpand={() => { + setIsCollapsed(false); + localStorage.setItem('react-resizable-panels:collapsed', 'false'); + }} + onCollapse={() => { + setIsCollapsed(true); + localStorage.setItem('react-resizable-panels:collapsed', 'true'); + }} + className={cn( + 'sidenav hide-scrollbar border-l border-border-light bg-background transition-opacity', + isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]', + (isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse + ? 'hidden min-w-0' + : 'opacity-100', + )} + > + {interfaceConfig.modelSelect === true && ( +
+ +
+ )} +