diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 7f5566fb9791..5d5454e97bef 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-bullseye +FROM node:24.16.0-bullseye RUN useradd -m -s /bin/bash vscode RUN mkdir -p /workspaces && chown -R vscode:vscode /workspaces diff --git a/.do/gitnexus/Dockerfile b/.do/gitnexus/Dockerfile index a1266d5a1332..58a79e087e77 100644 --- a/.do/gitnexus/Dockerfile +++ b/.do/gitnexus/Dockerfile @@ -5,7 +5,7 @@ # startup. A fresh index only requires rsync + container restart — no # image rebuild on every push. -FROM node:24-slim +FROM node:24.16.0-slim ARG GITNEXUS_VERSION=1.5.3 diff --git a/.env.example b/.env.example index 65673c2fcd2e..71173747c343 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,12 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # RUM_AUTH_MODE=publicToken # RUM_PUBLIC_TOKEN= +# Authenticated proxy mode sends browser telemetry to this LibreChat backend first. +# The backend validates the LibreChat session, strips app auth, and forwards to the collector. +# RUM_AUTH_MODE=proxy +# RUM_PROXY_TARGET_URL=http://otel-collector:4318 +# RUM_PROXY_TIMEOUT_MS=10000 + # Optional comma-separated first-party HTTPS origins/URLs that should receive traceparent headers. # Wildcards and non-HTTPS targets are ignored. # RUM_TRACE_PROPAGATION_TARGETS=https://api.example.com diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ae9e6d8e4ba7..6524947ba223 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -26,7 +26,7 @@ Project maintainers have the right and responsibility to remove, edit, or reject ## 1. Development Setup -1. Use Node.js v20.19.0+ or ^22.12.0 or >= 23.0.0. +1. Use Node.js v24.16.0. 2. Run `npm run smart-reinstall` to install dependencies (uses Turborepo). Use `npm run reinstall` for a clean install, or `npm ci` for a fresh lockfile-based install. 3. Build all compiled code: `npm run build`. 4. Setup and run unit tests: diff --git a/.github/scripts/sync-helm-chart-tags.sh b/.github/scripts/sync-helm-chart-tags.sh new file mode 100755 index 000000000000..f54048a1ea9c --- /dev/null +++ b/.github/scripts/sync-helm-chart-tags.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +CHART_PATH="${CHART_PATH:-helm/librechat/Chart.yaml}" +DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}" +BASE_REF="${BASE_REF:-refs/remotes/origin/${DEFAULT_BRANCH}}" +BACKFILL_FROM_VERSION="${BACKFILL_FROM_VERSION:-1.9.0}" +PUSH_TAGS="${PUSH_TAGS:-false}" +TAG_PREFIX="${TAG_PREFIX:-chart-}" +GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" +DISPATCH_WORKFLOW="${DISPATCH_WORKFLOW:-}" +RELEASE_EXISTING_TAG="${RELEASE_EXISTING_TAG:-}" +SEMVER_REGEX='^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?([+][0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$' + +fail() { + printf '::error::%s\n' "$1" >&2 + exit 1 +} + +git_auth_header() { + token="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + printf 'AUTHORIZATION: basic %s' "$token" +} + +git_with_auth() { + if [ -n "${GITHUB_TOKEN:-}" ]; then + git -c "http.extraheader=$(git_auth_header)" "$@" + return + fi + + git "$@" +} + +dispatch_release() { + tag="$1" + + if [ -z "$DISPATCH_WORKFLOW" ]; then + return + fi + + if [ -z "${GITHUB_REPOSITORY:-}" ]; then + fail "GITHUB_REPOSITORY is required to dispatch ${DISPATCH_WORKFLOW}" + fi + + if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + fail "Unexpected repository name: ${GITHUB_REPOSITORY}" + fi + + if [[ ! "$DISPATCH_WORKFLOW" =~ ^[A-Za-z0-9_.-]+[.]ya?ml$ ]]; then + fail "Unexpected workflow file: ${DISPATCH_WORKFLOW}" + fi + + token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [ -z "$token" ]; then + fail "GH_TOKEN or GITHUB_TOKEN is required to dispatch ${DISPATCH_WORKFLOW}" + fi + + command -v gh >/dev/null || + fail "GitHub CLI is required to dispatch ${DISPATCH_WORKFLOW}" + + GH_TOKEN="$token" gh workflow run "$DISPATCH_WORKFLOW" \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$DEFAULT_BRANCH" \ + -f "chart_tag=${tag}" +} + +version_less_than() { + left="${1%%[-+]*}" + right="${2%%[-+]*}" + + IFS=. read -r left_major left_minor left_patch <<<"$left" + IFS=. read -r right_major right_minor right_patch <<<"$right" + + if (( left_major != right_major )); then + (( left_major < right_major )) + return + fi + + if (( left_minor != right_minor )); then + (( left_minor < right_minor )) + return + fi + + (( left_patch < right_patch )) +} + +validate_chart_tag() { + tag="$1" + version="${tag#${TAG_PREFIX}}" + + git check-ref-format "refs/tags/${tag}" >/dev/null || + fail "Refusing to use invalid tag ${tag}" + + if [[ "$tag" != "${TAG_PREFIX}"* || ! "$version" =~ $SEMVER_REGEX ]]; then + fail "Chart tags must use the form ${TAG_PREFIX}, for example ${TAG_PREFIX}2.0.5" + fi +} + +dispatch_existing_tag() { + tag="$1" + + if [ -z "$tag" ]; then + return + fi + + validate_chart_tag "$tag" + + if [ "$PUSH_TAGS" != "true" ]; then + printf 'Would dispatch release workflow for existing %s.\n' "$tag" + return + fi + + if ! git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + fail "Remote tag ${tag} does not exist" + fi + + printf 'Dispatching release workflow for existing %s.\n' "$tag" + dispatch_release "$tag" +} + +chart_version_at() { + git show "${1}:${CHART_PATH}" 2>/dev/null | awk ' + /^version:[[:space:]]*/ { + value = $0 + sub(/^version:[[:space:]]*/, "", value) + sub(/[[:space:]]*#.*/, "", value) + gsub(/^[[:space:]"'\''"]+|[[:space:]"'\''"]+$/, "", value) + print value + exit + } + ' +} + +case "$PUSH_TAGS" in + true | false) ;; + *) fail "PUSH_TAGS must be true or false" ;; +esac + +if [[ ! "$BACKFILL_FROM_VERSION" =~ $SEMVER_REGEX ]]; then + fail "BACKFILL_FROM_VERSION must be a valid SemVer value" +fi + +git rev-parse --verify "${BASE_REF}^{commit}" >/dev/null || + fail "Unable to resolve ${BASE_REF}; fetch ${DEFAULT_BRANCH} before running this script" + +history_file="$(mktemp)" +versions_file="$(mktemp)" +seen_file="$(mktemp)" +missing_file="$(mktemp)" +cleanup() { + rm -f "$history_file" "$versions_file" "$seen_file" "$missing_file" +} +trap cleanup EXIT + +git log --first-parent --reverse --format=%H "$BASE_REF" -- "$CHART_PATH" >"$history_file" + +if [ ! -s "$history_file" ]; then + fail "No history found for ${CHART_PATH} on ${BASE_REF}" +fi + +while IFS= read -r commit; do + version="$(chart_version_at "$commit")" + + if [ -z "$version" ]; then + continue + fi + + if [[ ! "$version" =~ $SEMVER_REGEX ]]; then + fail "${CHART_PATH} has invalid SemVer '${version}' at ${commit}" + fi + + if version_less_than "$version" "$BACKFILL_FROM_VERSION"; then + continue + fi + + if grep -Fqx "$version" "$seen_file"; then + continue + fi + + printf '%s\n' "$version" >>"$seen_file" + printf '%s\t%s\n' "$version" "$commit" >>"$versions_file" +done <"$history_file" + +if [ ! -s "$versions_file" ]; then + fail "No chart versions found in ${CHART_PATH}" +fi + +while IFS="$(printf '\t')" read -r version commit; do + tag="${TAG_PREFIX}${version}" + + validate_chart_tag "$tag" + + if git rev-parse --quiet --verify "refs/tags/${tag}" >/dev/null; then + continue + fi + + printf '%s\t%s\n' "$tag" "$commit" >>"$missing_file" +done <"$versions_file" + +if [ ! -s "$missing_file" ]; then + printf 'All chart versions on %s already have %s tags.\n' "$BASE_REF" "$TAG_PREFIX" + dispatch_existing_tag "$RELEASE_EXISTING_TAG" + exit 0 +fi + +while IFS="$(printf '\t')" read -r tag commit; do + short_commit="$(git rev-parse --short "$commit")" + + if [ "$PUSH_TAGS" != "true" ]; then + printf 'Would create %s at %s.\n' "$tag" "$short_commit" + continue + fi + + if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + printf 'Remote tag %s already exists; dispatching release workflow.\n' "$tag" + dispatch_release "$tag" + continue + fi + + git tag "$tag" "$commit" + + if git_with_auth push origin "refs/tags/${tag}"; then + printf 'Created %s at %s.\n' "$tag" "$short_commit" + dispatch_release "$tag" + continue + fi + + if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + printf 'Remote tag %s was created concurrently; dispatching release workflow.\n' "$tag" + dispatch_release "$tag" + continue + fi + + fail "Failed to push ${tag}" +done <"$missing_file" + +dispatch_existing_tag "$RELEASE_EXISTING_TAG" diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 46366b2df05c..e25e884feeb8 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -20,10 +20,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -35,7 +35,7 @@ jobs: packages/api/node_modules packages/data-provider/node_modules packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -103,10 +103,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -118,7 +118,7 @@ jobs: packages/api/node_modules packages/data-provider/node_modules packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -162,10 +162,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -177,7 +177,7 @@ jobs: packages/api/node_modules packages/data-provider/node_modules packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -231,10 +231,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -246,7 +246,7 @@ jobs: packages/api/node_modules packages/data-provider/node_modules packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -289,10 +289,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -304,7 +304,7 @@ jobs: packages/api/node_modules packages/data-provider/node_modules packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -327,10 +327,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -342,7 +342,7 @@ jobs: packages/api/node_modules packages/data-provider/node_modules packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -375,10 +375,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -390,7 +390,7 @@ jobs: packages/api/node_modules packages/data-provider/node_modules packages/data-schemas/node_modules - key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml index c8f10f388bf0..3e4c5418ae7c 100644 --- a/.github/workflows/cache-integration-tests.yml +++ b/.github/workflows/cache-integration-tests.yml @@ -28,10 +28,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' cache: 'npm' - name: Install Redis tools diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index 881efa0ebea6..e4dc8c562644 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -27,7 +27,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: '24.16.0' - name: Install client dependencies run: cd packages/client && npm ci @@ -77,7 +77,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: '24.16.0' registry-url: 'https://registry.npmjs.org' - name: Install npm with OIDC support diff --git a/.github/workflows/data-provider.yml b/.github/workflows/data-provider.yml index 3a9db4d8e56e..eae746ece94a 100644 --- a/.github/workflows/data-provider.yml +++ b/.github/workflows/data-provider.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' - run: cd packages/data-provider && npm ci - run: cd packages/data-provider && npm run build - name: Pack package @@ -50,7 +50,7 @@ jobs: steps: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' registry-url: 'https://registry.npmjs.org' - name: Install npm with OIDC support diff --git a/.github/workflows/data-schemas.yml b/.github/workflows/data-schemas.yml index 977a6eb4c33e..bb8f90ea8428 100644 --- a/.github/workflows/data-schemas.yml +++ b/.github/workflows/data-schemas.yml @@ -27,7 +27,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: '24.16.0' - name: Install dependencies run: cd packages/data-schemas && npm ci @@ -77,7 +77,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: '24.16.0' registry-url: 'https://registry.npmjs.org' - name: Install npm with OIDC support diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml index 85de6fa18e37..3710f8a02ee6 100644 --- a/.github/workflows/eslint-ci.yml +++ b/.github/workflows/eslint-ci.yml @@ -27,10 +27,10 @@ jobs: with: fetch-depth: 0 - - name: Set up Node.js 20.x + - name: Set up Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' cache: npm - name: Install dependencies diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index 0021124192ed..b84d145bee2a 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -20,10 +20,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -34,7 +34,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -84,10 +84,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -98,7 +98,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -128,10 +128,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -142,7 +142,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' @@ -172,10 +172,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -186,7 +186,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} - name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml index d3b8ca95e228..21c3f6932131 100644 --- a/.github/workflows/gitnexus-index.yml +++ b/.github/workflows/gitnexus-index.yml @@ -136,7 +136,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 24 + node-version: '24.16.0' - name: Install GitNexus CLI working-directory: ${{ runner.temp }} diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml index 9e08c189a71a..9e0308ec727b 100644 --- a/.github/workflows/helmcharts.yml +++ b/.github/workflows/helmcharts.yml @@ -5,18 +5,54 @@ on: push: tags: - "chart-*" + workflow_dispatch: + inputs: + chart_tag: + description: "Existing chart tag to release, for example chart-2.0.5" + required: true + type: string jobs: release: permissions: - contents: write + contents: read packages: write runs-on: ubuntu-latest + env: + CHART_REPOSITORY: ${{ github.repository_owner }}/librechat-chart steps: + - name: Resolve chart tag + id: chart-version + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_CHART_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.chart_tag || '' }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + CHART_TAG="$REF_NAME" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + CHART_TAG="$INPUT_CHART_TAG" + fi + + CHART_VERSION="${CHART_TAG#chart-}" + SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$' + if [[ "$CHART_TAG" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error::Chart tags must use the form chart-, for example chart-2.0.3" + exit 1 + fi + + { + printf 'CHART_REF=refs/tags/%s\n' "$CHART_TAG" + printf 'CHART_TAG=%s\n' "$CHART_TAG" + printf 'CHART_VERSION=%s\n' "$CHART_VERSION" + } >> "$GITHUB_OUTPUT" + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.chart-version.outputs.CHART_REF }} - name: Configure Git run: | @@ -35,20 +71,6 @@ jobs: cd ../librechat-rag-api helm dependency build - - name: Get Chart Version - id: chart-version - env: - REF_NAME: ${{ github.ref_name }} - run: | - set -euo pipefail - CHART_VERSION="${REF_NAME#chart-}" - SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$' - if [[ "$REF_NAME" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then - echo "::error::Chart tags must use the form chart-, for example chart-2.0.3" - exit 1 - fi - printf 'CHART_VERSION=%s\n' "$CHART_VERSION" >> "$GITHUB_OUTPUT" - # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -63,7 +85,7 @@ jobs: uses: appany/helm-oci-chart-releaser@v0.4.2 with: name: librechat - repository: ${{ github.actor }}/librechat-chart + repository: ${{ env.CHART_REPOSITORY }} tag: ${{ steps.chart-version.outputs.CHART_VERSION }} path: helm/librechat registry: ghcr.io @@ -75,7 +97,7 @@ jobs: uses: appany/helm-oci-chart-releaser@v0.4.2 with: name: librechat-rag-api - repository: ${{ github.actor }}/librechat-chart + repository: ${{ env.CHART_REPOSITORY }} tag: ${{ steps.chart-version.outputs.CHART_VERSION }} path: helm/librechat-rag-api registry: ghcr.io diff --git a/.github/workflows/locize-i18n-sync.yml b/.github/workflows/locize-i18n-sync.yml index 18266a18e369..f5b80b865a0c 100644 --- a/.github/workflows/locize-i18n-sync.yml +++ b/.github/workflows/locize-i18n-sync.yml @@ -22,7 +22,7 @@ jobs: - name: Set Up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' - name: Install locize CLI run: npm install -g locize-cli diff --git a/.github/workflows/sync-helm-chart-tags.yml b/.github/workflows/sync-helm-chart-tags.yml new file mode 100644 index 000000000000..bde4c2f49a12 --- /dev/null +++ b/.github/workflows/sync-helm-chart-tags.yml @@ -0,0 +1,85 @@ +name: Sync Helm Chart Tags + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + release_existing_tag: + description: "Existing chart-* tag to dispatch if tag creation succeeded but release dispatch failed" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: sync-helm-chart-tags + cancel-in-progress: false + +jobs: + noop: + name: Ignore non-main push + if: github.event_name == 'push' && github.ref != 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Skip tag sync + run: echo "Helm chart tag sync only runs on main pushes or manual dispatch." + + sync: + name: Sync chart tags + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: write + env: + BASE_REF: refs/remotes/origin/main + BACKFILL_FROM_VERSION: 1.9.0 + CHART_PATH: helm/librechat/Chart.yaml + DEFAULT_BRANCH: main + DISPATCH_WORKFLOW: helmcharts.yml + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_EXISTING_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_existing_tag || '' }} + REPO_DIR: /tmp/librechat-sync + TAG_PREFIX: chart- + steps: + - name: Fetch main and tags + shell: bash + run: | + set -euo pipefail + + if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::Unexpected repository name: $GITHUB_REPOSITORY" + exit 1 + fi + + if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then + echo "::error::Unexpected GitHub server URL: $GITHUB_SERVER_URL" + exit 1 + fi + + rm -rf "$REPO_DIR" + git init "$REPO_DIR" + cd "$REPO_DIR" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + AUTH_HEADER="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + git -c "http.extraheader=AUTHORIZATION: basic ${AUTH_HEADER}" \ + fetch --prune --force --tags origin \ + "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + git checkout --detach "$BASE_REF" + + - name: Create missing chart tags + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUSH_TAGS: "true" + run: | + set -euo pipefail + cd "$REPO_DIR" + .github/scripts/sync-helm-chart-tags.sh diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml index a957bed2e8dd..5401d37d5b55 100644 --- a/.github/workflows/unused-packages.yml +++ b/.github/workflows/unused-packages.yml @@ -20,10 +20,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' cache: 'npm' - name: Install depcheck diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000000..b832e4001dbc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.16.0 diff --git a/CLAUDE.md b/CLAUDE.md index 81362cfc5701..8172a6140562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ Multi-line imports count total character length across all lines. Consolidate va | `npm run frontend:dev` | Start frontend dev server with HMR (port 3090, requires backend running) | | `npm run build:data-provider` | Rebuild `packages/data-provider` after changes | -- Node.js: v20.19.0+ or ^22.12.0 or >= 23.0.0 +- Node.js: v24.16.0 - Database: MongoDB - Backend runs on `http://localhost:3080/`; frontend dev server on `http://localhost:3090/` diff --git a/Dockerfile b/Dockerfile index be5c5b9935ef..6253913262ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # v0.8.6 # Base node image -FROM node:20-alpine AS node +FROM node:24.16.0-alpine AS node RUN apk upgrade --no-cache RUN apk add --no-cache jemalloc diff --git a/Dockerfile.multi b/Dockerfile.multi index 17c88c739de4..745e54b8a4cc 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -10,7 +10,7 @@ ARG BUILD_BRANCH= ARG BUILD_DATE= # Base for all builds -FROM node:20-alpine AS base-min +FROM node:24.16.0-alpine AS base-min ARG NPM_CI_TIMEOUT_SECONDS=1500 ARG NPM_CI_ATTEMPTS=2 RUN apk upgrade --no-cache diff --git a/api/server/controllers/Balance.js b/api/server/controllers/Balance.js index fd9b32e74c29..8df579e5c6f4 100644 --- a/api/server/controllers/Balance.js +++ b/api/server/controllers/Balance.js @@ -1,7 +1,13 @@ const { findBalanceByUser } = require('~/models'); async function balanceController(req, res) { - const balanceData = await findBalanceByUser(req.user.id); + const balanceLocals = res.locals || {}; + + if (balanceLocals.balanceConfigEnabled === false) { + return res.sendStatus(204); + } + + const balanceData = balanceLocals.balanceData ?? (await findBalanceByUser(req.user.id)); if (!balanceData) { return res.status(404).json({ error: 'Balance not found' }); diff --git a/api/server/controllers/Balance.spec.js b/api/server/controllers/Balance.spec.js new file mode 100644 index 000000000000..833f2d8c5498 --- /dev/null +++ b/api/server/controllers/Balance.spec.js @@ -0,0 +1,72 @@ +jest.mock('~/models', () => ({ + findBalanceByUser: jest.fn(), +})); + +const { findBalanceByUser } = require('~/models'); +const balanceController = require('./Balance'); + +describe('balanceController', () => { + const createResponse = () => ({ + locals: {}, + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + sendStatus: jest.fn().mockReturnThis(), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns no content without reading balance when balance config is disabled', async () => { + const req = { + user: { id: 'user-1' }, + }; + const res = createResponse(); + res.locals.balanceConfigEnabled = false; + + await balanceController(req, res); + + expect(findBalanceByUser).not.toHaveBeenCalled(); + expect(res.sendStatus).toHaveBeenCalledWith(204); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('uses balance data attached by middleware without a second read', async () => { + const req = { + user: { id: 'user-1' }, + }; + const res = createResponse(); + res.locals.balanceConfigEnabled = true; + res.locals.balanceData = { + _id: 'balance-1', + user: 'user-1', + tokenCredits: 100, + autoRefillEnabled: false, + }; + + await balanceController(req, res); + + expect(findBalanceByUser).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + user: 'user-1', + tokenCredits: 100, + autoRefillEnabled: false, + }); + }); + + it('returns not found when balance is enabled and no record exists', async () => { + findBalanceByUser.mockResolvedValue(null); + const req = { + user: { id: 'user-1' }, + }; + const res = createResponse(); + res.locals.balanceConfigEnabled = true; + + await balanceController(req, res); + + expect(findBalanceByUser).toHaveBeenCalledWith('user-1'); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Balance not found' }); + }); +}); diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index a50680f3d16f..d58e1feacf77 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -146,6 +146,7 @@ const getMCPTools = async (req, res) => { authField: key, label: value.title || key, description: value.description || '', + sensitive: value.sensitive, })); server.authenticated = false; } diff --git a/api/server/index.js b/api/server/index.js index a36484c87a90..57b9e3e3a3ec 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -220,6 +220,7 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/api/rum', routes.rum); app.use('/metrics', metricsRouter); diff --git a/api/server/routes/__tests__/config.rum.spec.js b/api/server/routes/__tests__/config.rum.spec.js index 867e0a0049e9..2b0a5b20e244 100644 --- a/api/server/routes/__tests__/config.rum.spec.js +++ b/api/server/routes/__tests__/config.rum.spec.js @@ -58,6 +58,7 @@ afterEach(() => { delete process.env.RUM_ENABLED; delete process.env.RUM_PROVIDER; delete process.env.RUM_URL; + delete process.env.RUM_PROXY_TARGET_URL; delete process.env.RUM_SERVICE_NAME; delete process.env.RUM_AUTH_MODE; delete process.env.RUM_PUBLIC_TOKEN; @@ -111,6 +112,38 @@ describe('GET /api/config RUM config', () => { expect(response.body).not.toHaveProperty('rum'); }); + it('includes proxy RUM config when enabled with valid env', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_AUTH_MODE = 'proxy'; + process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.rum).toEqual({ + provider: 'hyperdx', + enabled: true, + url: '/api/rum', + serviceName: 'librechat-web', + authMode: 'proxy', + consoleCapture: false, + disableReplay: true, + advancedNetworkCapture: false, + }); + }); + + it('omits proxy RUM config without a target collector URL', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_AUTH_MODE = 'proxy'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('rum'); + }); + it('omits RUM config when the URL contains credentials', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); process.env.RUM_ENABLED = 'true'; diff --git a/api/server/routes/balance.js b/api/server/routes/balance.js index 87d842888063..709582441952 100644 --- a/api/server/routes/balance.js +++ b/api/server/routes/balance.js @@ -1,8 +1,17 @@ const express = require('express'); +const { createSetBalanceConfig } = require('@librechat/api'); const router = express.Router(); const controller = require('../controllers/Balance'); const { requireJwtAuth } = require('../middleware/'); +const { findBalanceByUser, upsertBalanceFields } = require('~/models'); +const { getAppConfig } = require('~/server/services/Config'); -router.get('/', requireJwtAuth, controller); +const setBalanceConfig = createSetBalanceConfig({ + getAppConfig, + findBalanceByUser, + upsertBalanceFields, +}); + +router.get('/', requireJwtAuth, setBalanceConfig, controller); module.exports = router; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index cab2f92ed1db..6694e47f23ec 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -32,8 +32,10 @@ const auth = require('./auth'); const keys = require('./keys'); const user = require('./user'); const mcp = require('./mcp'); +const rum = require('./rum'); module.exports = { + rum, mcp, auth, adminAuth, diff --git a/api/server/routes/rum.js b/api/server/routes/rum.js new file mode 100644 index 000000000000..b407992e1e8d --- /dev/null +++ b/api/server/routes/rum.js @@ -0,0 +1,22 @@ +const express = require('express'); +const { getRumProxyBodyLimit, isRumProxyEnabled, proxyRumRequest } = require('@librechat/api'); +const { requireJwtAuth } = require('~/server/middleware'); + +const router = express.Router(); +const rawOtlpBody = express.raw({ + limit: getRumProxyBodyLimit(), + type: ['application/x-protobuf', 'application/octet-stream'], +}); + +function requireRumProxyEnabled(_req, res, next) { + if (!isRumProxyEnabled()) { + return res.status(404).json({ message: 'RUM proxy is not configured' }); + } + + return next(); +} + +router.post('/v1/traces', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest); +router.post('/v1/logs', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest); + +module.exports = router; diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index c91475497492..c719a8466576 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -177,7 +177,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`); // Validate and fill out missing values for custom parameters function parseCustomParams(endpointName, customParams) { - const paramEndpoint = customParams.defaultParamsEndpoint; + const paramEndpoint = customParams.defaultParamsEndpoint ?? 'custom'; + customParams.defaultParamsEndpoint = paramEndpoint; customParams.paramDefinitions = customParams.paramDefinitions || []; // Checks if `defaultParamsEndpoint` is a key in `paramSettings`. diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index 3fce8777e304..ff7ee90629c0 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -11,7 +11,7 @@ jest.mock('librechat-data-provider', () => { paramSettings: { foo: {}, bar: {}, - custom: {}, + custom: [], openrouter: [ { key: 'promptCache', @@ -59,6 +59,7 @@ jest.mock('@librechat/data-schemas', () => { const axios = require('axios'); const { loadYaml } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { ReasoningParameterFormat, ReasoningResponseKey } = require('librechat-data-provider'); const loadCustomConfig = require('./loadCustomConfig'); describe('loadCustomConfig', () => { @@ -307,11 +308,28 @@ describe('loadCustomConfig', () => { ); }); - it('throws an error when defaultParamsEndpoint is not provided', async () => { - const malformedCustomParams = { defaultParamsEndpoint: undefined }; - await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow( - 'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, openrouter, google', - ); + it('defaults defaultParamsEndpoint when only reasoningFormat is provided', async () => { + const parsedConfig = await loadCustomParams({ + reasoningFormat: ReasoningParameterFormat.reasoningObject, + }); + + expect(parsedConfig.endpoints.custom[0].customParams).toEqual({ + defaultParamsEndpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.reasoningObject, + paramDefinitions: [], + }); + }); + + it('defaults defaultParamsEndpoint when only reasoningKey is provided', async () => { + const parsedConfig = await loadCustomParams({ + reasoningKey: ReasoningResponseKey.reasoning, + }); + + expect(parsedConfig.endpoints.custom[0].customParams).toEqual({ + defaultParamsEndpoint: 'custom', + reasoningKey: ReasoningResponseKey.reasoning, + paramDefinitions: [], + }); }); it('fills the paramDefinitions with missing values', async () => { diff --git a/api/server/services/Config/rum.js b/api/server/services/Config/rum.js index da7bf5e555ef..c9f36ea80b5f 100644 --- a/api/server/services/Config/rum.js +++ b/api/server/services/Config/rum.js @@ -1,4 +1,4 @@ -const { isEnabled } = require('@librechat/api'); +const { getRumProxyClientUrl, isEnabled, isRumProxyEnabled } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const DEFAULT_RUM_SERVICE_NAME = 'librechat-web'; @@ -80,22 +80,34 @@ function getRumConfig() { } const authMode = process.env.RUM_AUTH_MODE || 'publicToken'; - if (authMode !== 'publicToken') { + if (authMode !== 'publicToken' && authMode !== 'proxy') { logger.warn(`[config] Unsupported RUM auth mode "${authMode}", disabling RUM`); return undefined; } - const rumUrl = process.env.RUM_URL; - const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined; + let rumUrl; + if (authMode === 'proxy') { + rumUrl = getRumProxyClientUrl(); - if (!parsedUrl || !isSafeRumUrl(parsedUrl)) { - logger.warn('[config] Invalid RUM_URL, disabling RUM'); - return undefined; - } + if (!isRumProxyEnabled()) { + logger.warn('[config] RUM proxy mode requires RUM_PROXY_TARGET_URL, disabling RUM'); + return undefined; + } + } else { + rumUrl = process.env.RUM_URL; + const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined; - if (!process.env.RUM_PUBLIC_TOKEN) { - logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM'); - return undefined; + if (!parsedUrl || !isSafeRumUrl(parsedUrl)) { + logger.warn('[config] Invalid RUM_URL, disabling RUM'); + return undefined; + } + + if (!process.env.RUM_PUBLIC_TOKEN) { + logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM'); + return undefined; + } + + rumUrl = parsedUrl.href.replace(/\/$/, ''); } const rawTracePropagationTargets = parseCsvEnv(process.env.RUM_TRACE_PROPAGATION_TARGETS); @@ -123,10 +135,10 @@ function getRumConfig() { return { provider: 'hyperdx', enabled: true, - url: parsedUrl.href.replace(/\/$/, ''), + url: rumUrl, serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME, authMode, - publicToken: process.env.RUM_PUBLIC_TOKEN, + ...(authMode === 'publicToken' ? { publicToken: process.env.RUM_PUBLIC_TOKEN } : {}), ...(tracePropagationTargets.length > 0 ? { tracePropagationTargets } : {}), consoleCapture, disableReplay: parseBooleanEnv(process.env.RUM_DISABLE_REPLAY, true), diff --git a/client/package.json b/client/package.json index 99e5c6af3f3c..d7906026dd3d 100644 --- a/client/package.json +++ b/client/package.json @@ -111,7 +111,7 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-supersub": "^1.0.0", - "sse.js": "^2.5.0", + "sse.js": "^2.8.0", "swr": "^2.3.8", "tailwind-merge": "^1.9.1", "tailwindcss-animate": "^1.0.5", @@ -133,10 +133,10 @@ "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.15", - "@types/node": "^20.19.35", + "@types/node": "^24.12.4", "@types/react": "^18.2.11", "@types/react-dom": "^18.2.4", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.4.20", "babel-plugin-replace-ts-export-assignment": "^0.0.2", "babel-plugin-root-import": "^6.6.0", @@ -155,9 +155,9 @@ "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", - "vite": "^7.3.1", - "vite-plugin-compression2": "^2.2.1", - "vite-plugin-node-polyfills": "^0.25.0", - "vite-plugin-pwa": "^1.2.0" + "vite": "^8.0.16", + "vite-plugin-compression2": "^2.5.3", + "vite-plugin-node-polyfills": "^0.28.0", + "vite-plugin-pwa": "^1.3.0" } } diff --git a/client/src/common/types.ts b/client/src/common/types.ts index d8f86f23f43b..7be7bef749af 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -16,6 +16,8 @@ export function isEphemeralAgent(agentId: string | null | undefined): boolean { export interface ConfigFieldDetail { title: string; description: string; + /** Whether the field holds a secret and should be masked (defaults to masked when omitted). */ + sensitive?: boolean; } export type CodeBarProps = { diff --git a/client/src/components/Artifacts/Mermaid.tsx b/client/src/components/Artifacts/Mermaid.tsx index 5eb55be3aefa..9d54285cb611 100644 --- a/client/src/components/Artifacts/Mermaid.tsx +++ b/client/src/components/Artifacts/Mermaid.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; -import mermaid from 'mermaid'; import { Button } from '@librechat/client'; import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; @@ -11,6 +10,16 @@ interface MermaidDiagramProps { isDarkMode?: boolean; } +let mermaidPromise: Promise | null = null; + +const loadMermaid = () => { + if (!mermaidPromise) { + mermaidPromise = import('mermaid').then((mod) => mod.default); + } + + return mermaidPromise; +}; + const MermaidDiagram: React.FC = ({ content, isDarkMode = true }) => { const mermaidRef = useRef(null); const transformRef = useRef(null); @@ -19,19 +28,23 @@ const MermaidDiagram: React.FC = ({ content, isDarkMode = t const bgColor = isDarkMode ? '#212121' : '#FFFFFF'; useEffect(() => { - mermaid.initialize({ - startOnLoad: false, - theme, - securityLevel: 'sandbox', - flowchart: artifactFlowchartConfig, - }); + let isMounted = true; const renderDiagram = async () => { - if (!mermaidRef.current) { - return; - } - try { + const mermaid = await loadMermaid(); + + mermaid.initialize({ + startOnLoad: false, + theme, + securityLevel: 'sandbox', + flowchart: artifactFlowchartConfig, + }); + + if (!mermaidRef.current) { + return; + } + const { svg } = await mermaid.render('mermaid-diagram', content); mermaidRef.current.innerHTML = svg; @@ -40,7 +53,9 @@ const MermaidDiagram: React.FC = ({ content, isDarkMode = t svgElement.style.width = '100%'; svgElement.style.height = '100%'; } - setIsRendered(true); + if (isMounted) { + setIsRendered(true); + } } catch (error) { console.error('Mermaid rendering error:', error); if (mermaidRef.current) { @@ -50,6 +65,10 @@ const MermaidDiagram: React.FC = ({ content, isDarkMode = t }; renderDiagram(); + + return () => { + isMounted = false; + }; }, [content, theme]); const centerAndFitDiagram = useCallback(() => { diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index c51c2002e32e..3d0a2528d5fd 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useContext } from 'react'; import { useForm } from 'react-hook-form'; import { Turnstile } from '@marsidev/react-turnstile'; -import { ThemeContext, Spinner, Button, isDark } from '@librechat/client'; +import { ThemeContext, SecretInput, Spinner, Button, isDark } from '@librechat/client'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; import type { TAuthContext } from '~/common'; import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider'; @@ -31,6 +31,13 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, const useUsernameLogin = config?.ldap?.username; const validTheme = isDark(theme) ? 'dark' : 'light'; const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey); + const authInputClassName = + 'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500'; + const authSecretInputClassName = `${authInputClassName} h-auto pr-12`; + const authLabelClassName = + 'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4'; + const authSecretButtonClassName = + 'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary'; useEffect(() => { if (error && error.includes('422') && !showResendLink) { @@ -102,13 +109,10 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, : (value) => validateEmail(value, localize('com_auth_email_pattern')), })} aria-invalid={!!errors.email} - className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" + className={authInputClassName} placeholder=" " /> -