diff --git a/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml index d924019b9a9..5fddced9f87 100644 --- a/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml +++ b/.github/ISSUE_TEMPLATE/NEW-LANGUAGE-REQUEST.yml @@ -1,7 +1,7 @@ name: New Language Request description: Request to add a new language for LibreChat translations. title: "New Language Request: " -labels: ["enhancement", "i18n"] +labels: ["✨ enhancement", "🌍 i18n"] body: - type: markdown attributes: @@ -30,4 +30,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 \ No newline at end of file + required: true diff --git a/.github/TRANSLATION.md b/.github/TRANSLATION.md deleted file mode 100644 index 080505048fe..00000000000 --- a/.github/TRANSLATION.md +++ /dev/null @@ -1,70 +0,0 @@ -# LibreChat Translation Guide - -Thank you for your interest in translating LibreChat! We rely on community contributions to make our application accessible to users around the globe. We manage all translations using [Locize](https://locize.com), a powerful translation management system that integrates seamlessly with our project. - -## How Translations Work - -- **Centralized Management:** All translation strings for LibreChat are managed in a single location on Locize. This allows us to keep translations consistent across all parts of the application. -- **Automatic Updates:** Changes made in Locize are automatically synchronized with our project. You can see the current translation progress for each language via the dynamic badges in our GitHub repository. -- **Community Driven:** We welcome contributions in all languages. Your help ensures that more users can enjoy LibreChat in their native language. - -## Getting Started - -### 1. Create a Locize Account - -If you don't already have an account, please register using our invite link: - -[Register at Locize](https://www.locize.app/register?invitation=t1VDfqoRvj8eUkd1JasxxrBCCI4SAqeeofa2YumAgmVDRxkr4vO1jKqNmpaNCv7H) - -This invitation will give you access to our translation project once you’ve created your account. - - -## Adding a New Language - -If you do not see your language listed in our current translation table, please help us expand our language support by following these steps: - -1. **Create a New Issue:** Open a new issue in the GitHub repository. -2. **Use the Template:** When creating your issue, please select the **New Language Request** template. This template will guide you through providing all the necessary details, including: - - The full name of your language (e.g., Spanish, Mandarin). - - The [ISO 639-1](https://www.w3schools.com/tags/ref_language_codes.asp) code for your language (e.g., es for Spanish). -3. **Collaborate with Maintainers:** Our maintainers will review your issue and work with you to integrate the new language. Once approved, your language will appear in the translation progress table, and you can start contributing translations. - - -## Translation Progress - -Below is our current translation progress for some of the supported languages. Feel free to check these badges and help us improve the translations further: - -| Language | Translation Progress Badge | -|---------------------------------------|----------------------------| -| **English (en)** | ![EN Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'en'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=EN:+) | -| **Arabic (ar)** | ![AR Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'ar'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=AR:+) | -| **German (de)** | ![DE Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'de'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=DE:+) | -| **Spanish (es)** | ![ES Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'es'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=ES:+) | -| **Finnish (fi)** | ![FI Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'fi'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=FI:+) | -| **French (fr)** | ![FR Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'fr'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=FR:+) | -| **Hebrew (he)** | ![HE Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'he'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=HE:+) | -| **Indonesian (id)** | ![ID Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'id'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=ID:+) | -| **Italian (it)** | ![IT Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'it'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=IT:+) | -| **Japanese (ja)** | ![JA Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'ja'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=JA:+) | -| **Korean (ko)** | ![KO Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'ko'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=KO:+) | -| **Dutch (nl)** | ![NL Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'nl'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=NL:+) | -| **Polish (pl)** | ![PL Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'pl'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=PL:+) | -| **Portuguese (pt)** | ![PT Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'pt'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=PT:+) | -| **Russian (ru)** | ![RU Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'ru'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=RU:+) | -| **Swedish (sv)** | ![SV Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'sv'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=SV:+) | -| **Turkish (tr)** | ![TR Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'tr'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=TR:+) | -| **Vietnamese (vi)** | ![VI Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'vi'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=VI:+) | -| **Chinese (Simplified) (zh)** | ![ZH Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'zh'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=ZH:+) | -| **Chinese (Traditional) (zh-Hant)** | ![ZH-HANT Badge](https://img.shields.io/badge/dynamic/json.svg?style=for-the-badge&color=2096F3&label=Locize&query=%24.versions%5B'latest'%5D.languages%5B'zh-Hant'%5D.translatedPercentage&url=https://api.locize.app/badgedata/4cb2598b-ed4d-469c-9b04-2ed531a8cb45&suffix=%+translated&link=https://www.locize.com&prefix=ZH-HANT:+) | - ---- - -## Need Help? - -If you have any questions about the translation process or need assistance getting started, please feel free to: - -- Open an issue in this repository. -- Join our [Discord community](https://discord.librechat.ai) to chat with fellow translators and contributors. -- Contact one of the project maintainers directly. - -Your contributions help make LibreChat better for users worldwide. Happy translating! diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml index f8baf09dcf6..ea1a5f24161 100644 --- a/.github/workflows/eslint-ci.yml +++ b/.github/workflows/eslint-ci.yml @@ -1,10 +1,14 @@ name: ESLint Code Quality Checks + on: pull_request: branches: - main - dev - release/* + paths: + - 'api/**' + - 'client/**' jobs: eslint_checks: @@ -29,67 +33,40 @@ jobs: - name: Install dependencies run: npm ci - # Use a paths filter (v3) to detect changes in JavaScript/TypeScript files, - # but only consider files that are added or modified (ignoring deleted files). - - name: Filter changed files for ESLint - id: file_filter - uses: dorny/paths-filter@v3 - with: - filters: | - eslint: - - added|modified: '**/*.js' - - added|modified: '**/*.jsx' - - added|modified: '**/*.ts' - - added|modified: '**/*.tsx' - - # Run ESLint only if relevant files have been added or modified. + # Run ESLint on changed files within the api/ and client/ directories. - name: Run ESLint on changed files - if: steps.file_filter.outputs.eslint == 'true' 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" - - # List files changed between the base commit and current HEAD, - # but only include files that are not deleted (ACMRTUXB: A, C, M, R, T, U, X, B). - CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | grep -E '\.(js|jsx|ts|tsx)$') - echo "Files to lint:" + + # 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" - - # Run ESLint on the 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 - # If no JavaScript/TypeScript files were added or modified, - # create a valid (non-empty) SARIF file containing one run. - - name: Create empty SARIF results file - if: steps.file_filter.outputs.eslint != 'true' - run: | - cat << 'EOF' > eslint-results.sarif - { - "version": "2.1.0", - "$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "runs": [ - { - "tool": { - "driver": { - "name": "ESLint", - "informationUri": "https://eslint.org", - "version": "0.0.0", - "rules": [] - } - }, - "results": [] - } - ] - } - EOF - - name: Upload analysis results to GitHub + if: env.UPLOAD_SARIF == 'true' uses: github/codeql-action/upload-sarif@v3 with: sarif_file: eslint-results.sarif diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml new file mode 100644 index 00000000000..79f95d3b27f --- /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 @@ -179,7 +179,7 @@ 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](.github/TRANSLATION.md). +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). --- @@ -199,4 +199,4 @@ We thank [Locize](https://locize.com) for their translation management tools tha Locize Logo -

\ No newline at end of file +

diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index 9334f1c28b1..368e7d6e84b 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 b7ff50150e2..6592371f027 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 9501045e4e1..d544b50a11e 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 c2b21cfc079..aaf8bb27644 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 cdd156b6b45..210666ddd78 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 2006859ab6a..53e49e1cfd7 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 bb223ff4503..1b45b2ff334 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 10264309c9c..8d5a997e6e4 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 b952ab00426..55fe2fa7177 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 ec618eabcf8..2a2f8c28def 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 8ceadd977d3..288ae8f37f1 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 7278273600f..30d36d9a9fd 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 00000000000..454f4be6c73 --- /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 5d5456c29f4..786f44dd8e6 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 eb657490865..9f4db5d6b8e 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 4aba91e9548..4b34029c7b4 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 712157bf29e..660e7aeb0dc 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) => { + const encrypted = { + oauth_client_id: action.metadata.oauth_client_id, + oauth_client_secret: action.metadata.oauth_client_secret, + }; + action.metadata = await decryptMetadata(action.metadata); + + /** @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(); - - // Chain the operations 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 d1b69168549..3e03a451251 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/TokenService.js b/api/server/services/TokenService.js new file mode 100644 index 00000000000..ec0f990a47d --- /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 cf88c0b199b..f3e4efb6e3a 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 e46584c805b..8c681d8f4e4 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 c88e57719af..bd97bd93fa8 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/src/common/agents-types.ts b/client/src/common/agents-types.ts index 7f64f07882c..a9c24106bcf 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 151314faa82..3d61eccb1cd 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -68,8 +68,8 @@ export type GenericSetter = (value: T | ((currentValue: T) => T)) => void; export type LastSelectedModels = Record; export type LocalizeFunction = ( - phraseKey: TranslationKeys, - options?: Record + phraseKey: TranslationKeys, + options?: Record, ) => string; export type ChatFormValues = { text: string }; @@ -89,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/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 0ee64ef62b6..dbf39ee8453 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/Messages/Content/CodeAnalyze.tsx b/client/src/components/Chat/Messages/Content/CodeAnalyze.tsx index d062f4276ae..f0918240ba6 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 ce77e3bfdb8..b997060c61f 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 09801d92c18..8997d5e822c 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) => ( ; } diff --git a/client/src/components/Chat/Messages/Content/MessageContent.tsx b/client/src/components/Chat/Messages/Content/MessageContent.tsx index 21bbe231e67..1547a01d804 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 49a15fc71c2..93fdab434e2 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/Reasoning.tsx b/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx index 447bf2f2c42..fd84b036186 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 ( - -
Cancel
-
-
- - - ); -} - -const ApiKey = () => { - const { register, watch, setValue } = useFormContext(); - const authorization_type = watch('authorization_type'); - const type = watch('type'); - return ( - <> - - - - setValue('authorization_type', value)} - value={authorization_type} - role="radiogroup" - aria-required="true" - dir="ltr" - className="mb-2 flex gap-6 overflow-hidden rounded-lg" - tabIndex={0} - style={{ outline: 'none' }} - > -
- -
-
- -
-
- -
-
- {authorization_type === AuthorizationTypeEnum.Custom && ( -
- - -
- )} - - ); -}; - -const OAuth = () => { - const { register, watch, setValue } = useFormContext(); - const token_exchange_method = watch('token_exchange_method'); - const type = watch('type'); - return ( - <> - - - - - - - - - - - - setValue('token_exchange_method', value)} - value={token_exchange_method} - role="radiogroup" - aria-required="true" - dir="ltr" - tabIndex={0} - style={{ outline: 'none' }} - > -
- -
-
- -
-
- - ); -}; diff --git a/client/src/components/SidePanel/Agents/ActionsInput.tsx b/client/src/components/SidePanel/Agents/ActionsInput.tsx index a40be180d1f..b2c2b9d51b9 100644 --- a/client/src/components/SidePanel/Agents/ActionsInput.tsx +++ b/client/src/components/SidePanel/Agents/ActionsInput.tsx @@ -14,6 +14,7 @@ import type { } from 'librechat-data-provider'; import type { ActionAuthForm } from '~/common'; import type { Spec } from './ActionsTable'; +import ActionCallback from '~/components/SidePanel/Builder/ActionCallback'; import { ActionsTable, columns } from './ActionsTable'; import { useUpdateAgentAction } from '~/data-provider'; import { useToastContext } from '~/Providers'; @@ -248,8 +249,8 @@ export default function ActionsInput({ {!!data && ( -
-
+
+
@@ -258,6 +259,7 @@ export default function ActionsInput({
)}
+
diff --git a/client/src/components/SidePanel/Agents/ActionsPanel.tsx b/client/src/components/SidePanel/Agents/ActionsPanel.tsx index f6ed9480133..514e1b61eb2 100644 --- a/client/src/components/SidePanel/Agents/ActionsPanel.tsx +++ b/client/src/components/SidePanel/Agents/ActionsPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useForm, FormProvider } from 'react-hook-form'; import { AuthTypeEnum, @@ -7,14 +7,14 @@ import { } from 'librechat-data-provider'; import { ChevronLeft } from 'lucide-react'; import type { AgentPanelProps, ActionAuthForm } from '~/common'; -import { Dialog, DialogTrigger, OGDialog, OGDialogTrigger, Label } from '~/components/ui'; +import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth'; +import { OGDialog, OGDialogTrigger, Label } from '~/components/ui'; import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; import { useDeleteAgentAction } from '~/data-provider'; import useLocalize from '~/hooks/useLocalize'; import { useToastContext } from '~/Providers'; import { TrashIcon } from '~/components/svg'; import ActionsInput from './ActionsInput'; -import ActionsAuth from './ActionsAuth'; import { Panel } from '~/common'; export default function ActionsPanel({ @@ -26,8 +26,6 @@ export default function ActionsPanel({ }: AgentPanelProps) { const localize = useLocalize(); const { showToast } = useToastContext(); - - const [openAuthDialog, setOpenAuthDialog] = useState(false); const deleteAgentAction = useDeleteAgentAction({ onSuccess: () => { showToast({ @@ -65,7 +63,6 @@ export default function ActionsPanel({ }); const { reset, watch } = methods; - const type = watch('type'); useEffect(() => { if (action?.metadata.auth) { @@ -156,40 +153,7 @@ export default function ActionsPanel({ Learn more.
*/}
- - -
-
- -
-
-
{type}
-
- -
-
-
- -
+
diff --git a/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx b/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx index 921c9571ae6..231badf53a3 100644 --- a/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx +++ b/client/src/components/SidePanel/Agents/ActionsTable/Table.tsx @@ -22,7 +22,7 @@ export default function DataTable({ columns, data }: DataTablePro className="border-token-border-light text-token-text-tertiary border-b text-left text-xs" > {headerGroup.headers.map((header, j) => ( - + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index fe90ebd910e..9fc7674158c 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -10,7 +10,7 @@ import { AgentCapabilities, } from 'librechat-data-provider'; import type { TPlugin } from 'librechat-data-provider'; -import type { AgentForm, AgentPanelProps } from '~/common'; +import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common'; import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils'; import { useCreateAgentMutation, useUpdateAgentMutation } from '~/data-provider'; import { useLocalize, useAuthContext, useHasAccess } from '~/hooks'; @@ -26,6 +26,7 @@ import AgentAvatar from './AgentAvatar'; import { Spinner } from '~/components'; import FileSearch from './FileSearch'; import ShareAgent from './ShareAgent'; +import Artifacts from './Artifacts'; import AgentTool from './AgentTool'; import CodeForm from './Code/Form'; import { Panel } from '~/common'; @@ -77,6 +78,10 @@ export default function AgentConfig({ () => agentsConfig?.capabilities.includes(AgentCapabilities.actions), [agentsConfig], ); + const artifactsEnabled = useMemo( + () => agentsConfig?.capabilities.includes(AgentCapabilities.artifacts) ?? false, + [agentsConfig], + ); const fileSearchEnabled = useMemo( () => agentsConfig?.capabilities.includes(AgentCapabilities.file_search) ?? false, [agentsConfig], @@ -150,7 +155,7 @@ export default function AgentConfig({ onSuccess: (data) => { setCurrentAgentId(data.id); showToast({ - message: `${localize('com_assistants_create_success ')} ${ + message: `${localize('com_assistants_create_success')} ${ data.name ?? localize('com_ui_agent') }`, }); @@ -178,18 +183,10 @@ export default function AgentConfig({ }, [agent_id, setActivePanel, showToast, localize]); const providerValue = typeof provider === 'string' ? provider : provider?.value; + let Icon: IconComponentTypes | null | undefined; let endpointType: EModelEndpoint | undefined; let endpointIconURL: string | undefined; let iconKey: string | undefined; - let Icon: - | React.ComponentType< - React.SVGProps & { - endpoint: string; - endpointType: EModelEndpoint | undefined; - iconURL: string | undefined; - } - > - | undefined; if (providerValue !== undefined) { endpointType = getEndpointField(endpointsConfig, providerValue as string, 'type'); @@ -337,7 +334,7 @@ export default function AgentConfig({ - {(codeEnabled || fileSearchEnabled) && ( + {(codeEnabled || fileSearchEnabled || artifactsEnabled) && (
)} {/* Agent Tools & Actions */} diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 0f49dc30e73..cea3265ebc9 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -120,6 +120,7 @@ export default function AgentPanel({ const { name, + artifacts, description, instructions, model: _model, @@ -139,6 +140,7 @@ export default function AgentPanel({ agent_id, data: { name, + artifacts, description, instructions, model, @@ -162,6 +164,7 @@ export default function AgentPanel({ create.mutate({ name, + artifacts, description, instructions, model, @@ -184,7 +187,7 @@ export default function AgentPanel({ const canEditAgent = useMemo(() => { const canEdit = - agentQuery.data?.isCollaborative ?? false + (agentQuery.data?.isCollaborative ?? false) ? true : agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN; diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 5d406c48b32..caeb0457e87 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -55,8 +55,8 @@ export default function AgentSelect({ }; const capabilities: TAgentCapabilities = { - [AgentCapabilities.execute_code]: false, [AgentCapabilities.file_search]: false, + [AgentCapabilities.execute_code]: false, [AgentCapabilities.end_after_tools]: false, [AgentCapabilities.hide_sequential_outputs]: false, }; diff --git a/client/src/components/SidePanel/Agents/Artifacts.tsx b/client/src/components/SidePanel/Agents/Artifacts.tsx new file mode 100644 index 00000000000..2a814cc7f11 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Artifacts.tsx @@ -0,0 +1,124 @@ +import { useFormContext } from 'react-hook-form'; +import { ArtifactModes, AgentCapabilities } from 'librechat-data-provider'; +import type { AgentForm } from '~/common'; +import { + Switch, + HoverCard, + HoverCardPortal, + HoverCardContent, + HoverCardTrigger, +} from '~/components/ui'; +import { useLocalize } from '~/hooks'; +import { CircleHelpIcon } from '~/components/svg'; +import { ESide } from '~/common'; + +export default function Artifacts() { + const localize = useLocalize(); + const methods = useFormContext(); + const { setValue, watch } = methods; + + const artifactsMode = watch(AgentCapabilities.artifacts); + + const handleArtifactsChange = (value: boolean) => { + setValue(AgentCapabilities.artifacts, value ? ArtifactModes.DEFAULT : '', { + shouldDirty: true, + }); + }; + + const handleShadcnuiChange = (value: boolean) => { + setValue(AgentCapabilities.artifacts, value ? ArtifactModes.SHADCNUI : ArtifactModes.DEFAULT, { + shouldDirty: true, + }); + }; + + const handleCustomModeChange = (value: boolean) => { + setValue(AgentCapabilities.artifacts, value ? ArtifactModes.CUSTOM : ArtifactModes.DEFAULT, { + shouldDirty: true, + }); + }; + + const isEnabled = artifactsMode !== undefined && artifactsMode !== ''; + const isCustomEnabled = artifactsMode === ArtifactModes.CUSTOM; + const isShadcnEnabled = artifactsMode === ArtifactModes.SHADCNUI; + + return ( +
+
+ + + +
+
+ + + +
+
+ ); +} + +function SwitchItem({ + id, + label, + checked, + onCheckedChange, + hoverCardText, + disabled = false, +}: { + id: string; + label: string; + checked: boolean; + onCheckedChange: (value: boolean) => void; + hoverCardText: string; + disabled?: boolean; +}) { + return ( + +
+
+
{label}
+ + + +
+ + +
+

{hoverCardText}

+
+
+
+ +
+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/Code/Action.tsx b/client/src/components/SidePanel/Agents/Code/Action.tsx index a2a16d44193..e655101b76e 100644 --- a/client/src/components/SidePanel/Agents/Code/Action.tsx +++ b/client/src/components/SidePanel/Agents/Code/Action.tsx @@ -86,7 +86,7 @@ export default function Action({ authType = '', isToolAuthenticated = false }) { )} - + diff --git a/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx b/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx index 5d827dd81f4..f006d97691c 100644 --- a/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx +++ b/client/src/components/SidePanel/Agents/FileSearchCheckbox.tsx @@ -29,7 +29,7 @@ export default function FileSearchCheckbox() { {...field} checked={field.value} onCheckedChange={field.onChange} - className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" + className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer" value={field.value.toString()} /> )} @@ -38,7 +38,6 @@ export default function FileSearchCheckbox() { type="button" className="flex items-center space-x-2" onClick={() => - setValue(AgentCapabilities.file_search, !getValues(AgentCapabilities.file_search), { shouldDirty: true, }) @@ -51,7 +50,7 @@ export default function FileSearchCheckbox() { {localize('com_agents_enable_file_search')} - + diff --git a/client/src/components/SidePanel/Builder/ActionCallback.tsx b/client/src/components/SidePanel/Builder/ActionCallback.tsx new file mode 100644 index 00000000000..851607f22f5 --- /dev/null +++ b/client/src/components/SidePanel/Builder/ActionCallback.tsx @@ -0,0 +1,63 @@ +import { useState } from 'react'; +import { Copy, CopyCheck } from 'lucide-react'; +import { useFormContext } from 'react-hook-form'; +import { AuthTypeEnum } from 'librechat-data-provider'; +import { useLocalize, useCopyToClipboard } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import { Button } from '~/components/ui'; +import { cn } from '~/utils'; + +export default function ActionCallback({ action_id }: { action_id?: string }) { + const localize = useLocalize(); + const { watch } = useFormContext(); + const { showToast } = useToastContext(); + const [isCopying, setIsCopying] = useState(false); + const callbackURL = `${window.location.protocol}//${window.location.host}/api/actions/${action_id}/oauth/callback`; + const copyLink = useCopyToClipboard({ text: callbackURL }); + + if (!action_id) { + return null; + } + const type = watch('type'); + if (type !== AuthTypeEnum.OAuth) { + return null; + } + return ( +
+ +
+
+
+
+ +
+
+
+ +
+
+
+
+ ); +} diff --git a/client/src/components/SidePanel/Builder/ActionsAuth.tsx b/client/src/components/SidePanel/Builder/ActionsAuth.tsx index 73106855639..c56a4891659 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' }} - > -
- -
-
- -
-
-
-
- + + ); } 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 0d58daaa44b..410df8e9a39 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 f3fdd20dedf..23071f5c709 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}
-
- -
-
-
- -
+ }) { +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/SidePanel.tsx b/client/src/components/SidePanel/SidePanel.tsx index 51977ab1572..7660e8b278d 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} - - + +
+ {(!isCollapsed || minSize > 0) && !isSmallScreen && !fullCollapse && ( + + )} + { + 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', )} -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - className="relative flex w-px items-center justify-center" - > - + {interfaceConfig.modelSelect === true && ( +
-
- {(!isCollapsed || minSize > 0) && !isSmallScreen && !fullCollapse && ( - + > + +
)} - { - 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 && ( -
- -
- )} -