Compare commits
33 Commits
feat/realt
...
feat/webau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4914ef5226 | ||
|
|
b4b574e328 | ||
|
|
8173f5fca1 | ||
|
|
6496c9aeda | ||
|
|
1260551690 | ||
|
|
fc733d2b9e | ||
|
|
1e625f7557 | ||
|
|
46a96b9caa | ||
|
|
fe7013562b | ||
|
|
fdb3cf3f58 | ||
|
|
538a2a144a | ||
|
|
06282b584f | ||
|
|
ecddffa7b2 | ||
|
|
964a74c73b | ||
|
|
f0f09138bd | ||
|
|
46ceae1a93 | ||
|
|
a65647a7de | ||
|
|
93dd365fda | ||
|
|
350e72dede | ||
|
|
e3b5c59949 | ||
|
|
61f0480b57 | ||
|
|
04c2a5abe7 | ||
|
|
dfcbc23b8b | ||
|
|
47a5b0a4d6 | ||
|
|
e9e2917042 | ||
|
|
a0c4ddaf9e | ||
|
|
05a4f6cc45 | ||
|
|
3a60fa1966 | ||
|
|
8ea085ee25 | ||
|
|
1e1b865f4f | ||
|
|
1ab5bc425d | ||
|
|
091d4f3192 | ||
|
|
1cb1c9196d |
30
.env.example
30
.env.example
@@ -20,6 +20,11 @@ DOMAIN_CLIENT=http://localhost:3080
|
||||
DOMAIN_SERVER=http://localhost:3080
|
||||
|
||||
NO_INDEX=true
|
||||
# Use the address that is at most n number of hops away from the Express application.
|
||||
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
|
||||
# A value of 0 means that the first untrusted address would be req.socket.remoteAddress, i.e. there is no reverse proxy.
|
||||
# Defaulted to 1.
|
||||
TRUST_PROXY=1
|
||||
|
||||
#===============#
|
||||
# JSON Logging #
|
||||
@@ -292,6 +297,10 @@ MEILI_NO_ANALYTICS=true
|
||||
MEILI_HOST=http://0.0.0.0:7700
|
||||
MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
|
||||
|
||||
# Optional: Disable indexing, useful in a multi-node setup
|
||||
# where only one instance should perform an index sync.
|
||||
# MEILI_NO_SYNC=true
|
||||
|
||||
#==================================================#
|
||||
# Speech to Text & Text to Speech #
|
||||
#==================================================#
|
||||
@@ -389,7 +398,7 @@ FACEBOOK_CALLBACK_URL=/oauth/facebook/callback
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GITHUB_CALLBACK_URL=/oauth/github/callback
|
||||
# GitHub Eenterprise
|
||||
# GitHub Enterprise
|
||||
# GITHUB_ENTERPRISE_BASE_URL=
|
||||
# GITHUB_ENTERPRISE_USER_AGENT=
|
||||
|
||||
@@ -405,6 +414,10 @@ APPLE_KEY_ID=
|
||||
APPLE_PRIVATE_KEY_PATH=
|
||||
APPLE_CALLBACK_URL=/oauth/apple/callback
|
||||
|
||||
# PassKeys
|
||||
PASSKEY_ENABLED=true
|
||||
RP_ID=localhost
|
||||
|
||||
# OpenID
|
||||
OPENID_CLIENT_ID=
|
||||
OPENID_CLIENT_SECRET=
|
||||
@@ -495,6 +508,16 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# Google tag manager id
|
||||
#ANALYTICS_GTM_ID=user provided google tag manager id
|
||||
|
||||
#===============#
|
||||
# REDIS Options #
|
||||
#===============#
|
||||
|
||||
# REDIS_URI=10.10.10.10:6379
|
||||
# USE_REDIS=true
|
||||
|
||||
# USE_REDIS_CLUSTER=true
|
||||
# REDIS_CA=/path/to/ca.crt
|
||||
|
||||
#==================================================#
|
||||
# Others #
|
||||
#==================================================#
|
||||
@@ -502,9 +525,6 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
|
||||
# NODE_ENV=
|
||||
|
||||
# REDIS_URI=
|
||||
# USE_REDIS=
|
||||
|
||||
# E2E_USER_EMAIL=
|
||||
# E2E_USER_PASSWORD=
|
||||
|
||||
@@ -527,4 +547,4 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
#=====================================================#
|
||||
# OpenWeather #
|
||||
#=====================================================#
|
||||
OPENWEATHER_API_KEY=
|
||||
OPENWEATHER_API_KEY=
|
||||
|
||||
42
.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml
vendored
Normal file
42
.github/ISSUE_TEMPLATE/LOCIZE_TRANSLATION_ACCESS_REQUEST.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: Locize Translation Access Request
|
||||
description: Request access to an additional language in Locize for LibreChat translations.
|
||||
title: "Locize Access Request: "
|
||||
labels: ["🌍 i18n", "🔑 access request"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for your interest in contributing to LibreChat translations!
|
||||
Please fill out the form below to request access to an additional language in **Locize**.
|
||||
|
||||
**🔗 Available Languages:** [View the list here](https://www.librechat.ai/docs/translation)
|
||||
|
||||
**📌 Note:** Ensure that the requested language is supported before submitting your request.
|
||||
- type: input
|
||||
id: account_name
|
||||
attributes:
|
||||
label: Locize Account Name
|
||||
description: Please provide your Locize account name (e.g., John Doe).
|
||||
placeholder: e.g., John Doe
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: language_requested
|
||||
attributes:
|
||||
label: Language Code (ISO 639-1)
|
||||
description: |
|
||||
Enter the **ISO 639-1** language code for the language you want to translate into.
|
||||
Example: `es` for Spanish, `zh-Hant` for Traditional Chinese.
|
||||
|
||||
**🔗 Reference:** [Available Languages](https://www.librechat.ai/docs/translation)
|
||||
placeholder: e.g., es
|
||||
validations:
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: agreement
|
||||
attributes:
|
||||
label: Agreement
|
||||
description: By submitting this request, you confirm that you will contribute responsibly and adhere to the project guidelines.
|
||||
options:
|
||||
- label: I agree to use my access solely for contributing to LibreChat translations.
|
||||
required: true
|
||||
50
.github/ISSUE_TEMPLATE/QUESTION.yml
vendored
50
.github/ISSUE_TEMPLATE/QUESTION.yml
vendored
@@ -1,50 +0,0 @@
|
||||
name: Question
|
||||
description: Ask your question
|
||||
title: "[Question]: "
|
||||
labels: ["❓ question"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill this!
|
||||
- type: textarea
|
||||
id: what-is-your-question
|
||||
attributes:
|
||||
label: What is your question?
|
||||
description: Please give as many details as possible
|
||||
placeholder: Please give as many details as possible
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: more-details
|
||||
attributes:
|
||||
label: More Details
|
||||
description: Please provide more details if needed.
|
||||
placeholder: Please provide more details if needed.
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: What is the main subject of your question?
|
||||
multiple: true
|
||||
options:
|
||||
- Documentation
|
||||
- Installation
|
||||
- UI
|
||||
- Endpoints
|
||||
- User System/OAuth
|
||||
- Other
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem. You can drag and drop, paste images directly here or link to them.
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: Code of Conduct
|
||||
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md)
|
||||
options:
|
||||
- label: I agree to follow this project's Code of Conduct
|
||||
required: true
|
||||
60
.github/configuration-release.json
vendored
Normal file
60
.github/configuration-release.json
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"title": "### ✨ New Features",
|
||||
"labels": ["feat"]
|
||||
},
|
||||
{
|
||||
"title": "### 🌍 Internationalization",
|
||||
"labels": ["i18n"]
|
||||
},
|
||||
{
|
||||
"title": "### 👐 Accessibility",
|
||||
"labels": ["a11y"]
|
||||
},
|
||||
{
|
||||
"title": "### 🔧 Fixes",
|
||||
"labels": ["Fix", "fix"]
|
||||
},
|
||||
{
|
||||
"title": "### ⚙️ Other Changes",
|
||||
"labels": ["ci", "style", "docs", "refactor", "chore"]
|
||||
}
|
||||
],
|
||||
"ignore_labels": [
|
||||
"🔁 duplicate",
|
||||
"📊 analytics",
|
||||
"🌱 good first issue",
|
||||
"🔍 investigation",
|
||||
"🙏 help wanted",
|
||||
"❌ invalid",
|
||||
"❓ question",
|
||||
"🚫 wontfix",
|
||||
"🚀 release",
|
||||
"version"
|
||||
],
|
||||
"base_branches": ["main"],
|
||||
"sort": {
|
||||
"order": "ASC",
|
||||
"on_property": "mergedAt"
|
||||
},
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:",
|
||||
"target": "$1",
|
||||
"flags": "i",
|
||||
"on_property": "title",
|
||||
"method": "match"
|
||||
},
|
||||
{
|
||||
"pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*",
|
||||
"target": "version",
|
||||
"flags": "i",
|
||||
"on_property": "title",
|
||||
"method": "match"
|
||||
}
|
||||
],
|
||||
"template": "## [#{{TO_TAG}}] - #{{TO_TAG_DATE}}\n\nChanges from #{{FROM_TAG}} to #{{TO_TAG}}.\n\n#{{CHANGELOG}}\n\n[See full release details][release-#{{TO_TAG}}]\n\n[release-#{{TO_TAG}}]: https://github.com/#{{OWNER}}/#{{REPO}}/releases/tag/#{{TO_TAG}}\n\n---",
|
||||
"pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})",
|
||||
"empty_template": "- no changes"
|
||||
}
|
||||
68
.github/configuration-unreleased.json
vendored
Normal file
68
.github/configuration-unreleased.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"title": "### ✨ New Features",
|
||||
"labels": ["feat"]
|
||||
},
|
||||
{
|
||||
"title": "### 🌍 Internationalization",
|
||||
"labels": ["i18n"]
|
||||
},
|
||||
{
|
||||
"title": "### 👐 Accessibility",
|
||||
"labels": ["a11y"]
|
||||
},
|
||||
{
|
||||
"title": "### 🔧 Fixes",
|
||||
"labels": ["Fix", "fix"]
|
||||
},
|
||||
{
|
||||
"title": "### ⚙️ Other Changes",
|
||||
"labels": ["ci", "style", "docs", "refactor", "chore"]
|
||||
}
|
||||
],
|
||||
"ignore_labels": [
|
||||
"🔁 duplicate",
|
||||
"📊 analytics",
|
||||
"🌱 good first issue",
|
||||
"🔍 investigation",
|
||||
"🙏 help wanted",
|
||||
"❌ invalid",
|
||||
"❓ question",
|
||||
"🚫 wontfix",
|
||||
"🚀 release",
|
||||
"version",
|
||||
"action"
|
||||
],
|
||||
"base_branches": ["main"],
|
||||
"sort": {
|
||||
"order": "ASC",
|
||||
"on_property": "mergedAt"
|
||||
},
|
||||
"label_extractor": [
|
||||
{
|
||||
"pattern": "^(?:[^A-Za-z0-9]*)(feat|fix|chore|docs|refactor|ci|style|a11y|i18n)\\s*:",
|
||||
"target": "$1",
|
||||
"flags": "i",
|
||||
"on_property": "title",
|
||||
"method": "match"
|
||||
},
|
||||
{
|
||||
"pattern": "^(?:[^A-Za-z0-9]*)(v\\d+\\.\\d+\\.\\d+(?:-rc\\d+)?).*",
|
||||
"target": "version",
|
||||
"flags": "i",
|
||||
"on_property": "title",
|
||||
"method": "match"
|
||||
},
|
||||
{
|
||||
"pattern": "^(?:[^A-Za-z0-9]*)(action)\\b.*",
|
||||
"target": "action",
|
||||
"flags": "i",
|
||||
"on_property": "title",
|
||||
"method": "match"
|
||||
}
|
||||
],
|
||||
"template": "## [Unreleased]\n\n#{{CHANGELOG}}\n\n---",
|
||||
"pr_template": "- #{{TITLE}} by **@#{{AUTHOR}}** in [##{{NUMBER}}](#{{URL}})",
|
||||
"empty_template": "- no changes"
|
||||
}
|
||||
94
.github/workflows/generate-release-changelog-pr.yml
vendored
Normal file
94
.github/workflows/generate-release-changelog-pr.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
name: Generate Release Changelog PR
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
generate-release-changelog-pr:
|
||||
permissions:
|
||||
contents: write # Needed for pushing commits and creating branches.
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 1. Checkout the repository (with full history).
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# 2. Generate the release changelog using our custom configuration.
|
||||
- name: Generate Release Changelog
|
||||
id: generate_release
|
||||
uses: mikepenz/release-changelog-builder-action@v5.1.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
configuration: ".github/configuration-release.json"
|
||||
owner: ${{ github.repository_owner }}
|
||||
repo: ${{ github.event.repository.name }}
|
||||
outputFile: CHANGELOG-release.md
|
||||
|
||||
# 3. Update the main CHANGELOG.md:
|
||||
# - If it doesn't exist, create it with a basic header.
|
||||
# - Remove the "Unreleased" section (if present).
|
||||
# - Prepend the new release changelog above previous releases.
|
||||
# - Remove all temporary files before committing.
|
||||
- name: Update CHANGELOG.md
|
||||
run: |
|
||||
# Determine the release tag, e.g. "v1.2.3"
|
||||
TAG=${GITHUB_REF##*/}
|
||||
echo "Using release tag: $TAG"
|
||||
|
||||
# Ensure CHANGELOG.md exists; if not, create a basic header.
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "# Changelog" > CHANGELOG.md
|
||||
echo "" >> CHANGELOG.md
|
||||
echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md
|
||||
echo "" >> CHANGELOG.md
|
||||
fi
|
||||
|
||||
echo "Updating CHANGELOG.md…"
|
||||
|
||||
# Remove the "Unreleased" section (from "## [Unreleased]" until the first occurrence of '---') if it exists.
|
||||
if grep -q "^## \[Unreleased\]" CHANGELOG.md; then
|
||||
awk '/^## \[Unreleased\]/{flag=1} flag && /^---/{flag=0; next} !flag' CHANGELOG.md > CHANGELOG.cleaned
|
||||
else
|
||||
cp CHANGELOG.md CHANGELOG.cleaned
|
||||
fi
|
||||
|
||||
# Split the cleaned file into:
|
||||
# - header.md: content before the first release header ("## [v...").
|
||||
# - tail.md: content from the first release header onward.
|
||||
awk '/^## \[v/{exit} {print}' CHANGELOG.cleaned > header.md
|
||||
awk 'f{print} /^## \[v/{f=1; print}' CHANGELOG.cleaned > tail.md
|
||||
|
||||
# Combine header, the new release changelog, and the tail.
|
||||
echo "Combining updated changelog parts..."
|
||||
cat header.md CHANGELOG-release.md > CHANGELOG.md.new
|
||||
echo "" >> CHANGELOG.md.new
|
||||
cat tail.md >> CHANGELOG.md.new
|
||||
|
||||
mv CHANGELOG.md.new CHANGELOG.md
|
||||
|
||||
# Remove temporary files.
|
||||
rm -f CHANGELOG.cleaned header.md tail.md CHANGELOG-release.md
|
||||
|
||||
echo "Final CHANGELOG.md content:"
|
||||
cat CHANGELOG.md
|
||||
|
||||
# 4. Create (or update) the Pull Request with the updated CHANGELOG.md.
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
sign-commits: true
|
||||
commit-message: "chore: update CHANGELOG for release ${GITHUB_REF##*/}"
|
||||
base: main
|
||||
branch: "changelog/${GITHUB_REF##*/}"
|
||||
reviewers: danny-avila
|
||||
title: "chore: update CHANGELOG for release ${GITHUB_REF##*/}"
|
||||
body: |
|
||||
**Description**:
|
||||
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${GITHUB_REF##*/} above previous releases.
|
||||
106
.github/workflows/generate-unreleased-changelog-pr.yml
vendored
Normal file
106
.github/workflows/generate-unreleased-changelog-pr.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
name: Generate Unreleased Changelog PR
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * 1" # Runs every Monday at 00:00 UTC
|
||||
|
||||
jobs:
|
||||
generate-unreleased-changelog-pr:
|
||||
permissions:
|
||||
contents: write # Needed for pushing commits and creating branches.
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# 1. Checkout the repository on main.
|
||||
- name: Checkout Repository on Main
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
# 4. Get the latest version tag.
|
||||
- name: Get Latest Tag
|
||||
id: get_latest_tag
|
||||
run: |
|
||||
LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "none")
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
echo "tag=$LATEST_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# 5. Generate the Unreleased changelog.
|
||||
- name: Generate Unreleased Changelog
|
||||
id: generate_unreleased
|
||||
uses: mikepenz/release-changelog-builder-action@v5.1.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
configuration: ".github/configuration-unreleased.json"
|
||||
owner: ${{ github.repository_owner }}
|
||||
repo: ${{ github.event.repository.name }}
|
||||
outputFile: CHANGELOG-unreleased.md
|
||||
fromTag: ${{ steps.get_latest_tag.outputs.tag }}
|
||||
toTag: main
|
||||
|
||||
# 7. Update CHANGELOG.md with the new Unreleased section.
|
||||
- name: Update CHANGELOG.md
|
||||
id: update_changelog
|
||||
run: |
|
||||
# Create CHANGELOG.md if it doesn't exist.
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "# Changelog" > CHANGELOG.md
|
||||
echo "" >> CHANGELOG.md
|
||||
echo "All notable changes to this project will be documented in this file." >> CHANGELOG.md
|
||||
echo "" >> CHANGELOG.md
|
||||
fi
|
||||
|
||||
echo "Updating CHANGELOG.md…"
|
||||
|
||||
# Extract content before the "## [Unreleased]" (or first version header if missing).
|
||||
if grep -q "^## \[Unreleased\]" CHANGELOG.md; then
|
||||
awk '/^## \[Unreleased\]/{exit} {print}' CHANGELOG.md > CHANGELOG_TMP.md
|
||||
else
|
||||
awk '/^## \[v/{exit} {print}' CHANGELOG.md > CHANGELOG_TMP.md
|
||||
fi
|
||||
|
||||
# Append the generated Unreleased changelog.
|
||||
echo "" >> CHANGELOG_TMP.md
|
||||
cat CHANGELOG-unreleased.md >> CHANGELOG_TMP.md
|
||||
echo "" >> CHANGELOG_TMP.md
|
||||
|
||||
# Append the remainder of the original changelog (starting from the first version header).
|
||||
awk 'f{print} /^## \[v/{f=1; print}' CHANGELOG.md >> CHANGELOG_TMP.md
|
||||
|
||||
# Replace the old file with the updated file.
|
||||
mv CHANGELOG_TMP.md CHANGELOG.md
|
||||
|
||||
# Remove the temporary generated file.
|
||||
rm -f CHANGELOG-unreleased.md
|
||||
|
||||
echo "Final CHANGELOG.md:"
|
||||
cat CHANGELOG.md
|
||||
|
||||
# 8. Check if CHANGELOG.md has any updates.
|
||||
- name: Check for CHANGELOG.md changes
|
||||
id: changelog_changes
|
||||
run: |
|
||||
if git diff --quiet CHANGELOG.md; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# 9. Create (or update) the Pull Request only if there are changes.
|
||||
- name: Create Pull Request
|
||||
if: steps.changelog_changes.outputs.has_changes == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
base: main
|
||||
branch: "changelog/unreleased-update"
|
||||
sign-commits: true
|
||||
commit-message: "action: update Unreleased changelog"
|
||||
title: "action: update Unreleased changelog"
|
||||
body: |
|
||||
**Description**:
|
||||
- This PR updates the Unreleased section in CHANGELOG.md.
|
||||
- It compares the current main branch with the latest version tag (determined as ${{ steps.get_latest_tag.outputs.tag }}),
|
||||
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.
|
||||
13
.github/workflows/i18n-unused-keys.yml
vendored
13
.github/workflows/i18n-unused-keys.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "client/src/**"
|
||||
- "api/**"
|
||||
|
||||
jobs:
|
||||
detect-unused-i18n-keys:
|
||||
@@ -21,7 +22,7 @@ jobs:
|
||||
|
||||
# Define paths
|
||||
I18N_FILE="client/src/locales/en/translation.json"
|
||||
SOURCE_DIR="client/src"
|
||||
SOURCE_DIRS=("client/src" "api")
|
||||
|
||||
# Check if translation file exists
|
||||
if [[ ! -f "$I18N_FILE" ]]; then
|
||||
@@ -37,7 +38,15 @@ jobs:
|
||||
|
||||
# Check if each key is used in the source code
|
||||
for KEY in $KEYS; do
|
||||
if ! grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$SOURCE_DIR"; then
|
||||
FOUND=false
|
||||
for DIR in "${SOURCE_DIRS[@]}"; do
|
||||
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
|
||||
FOUND=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$FOUND" == false ]]; then
|
||||
UNUSED_KEYS+=("$KEY")
|
||||
fi
|
||||
done
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -100,10 +100,13 @@ auth.json
|
||||
/images
|
||||
|
||||
!client/src/components/Nav/SettingsTabs/Data/
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# User uploads
|
||||
uploads/
|
||||
|
||||
# owner
|
||||
release/
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
# Apple Private Key
|
||||
*.p8
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 LibreChat
|
||||
Copyright (c) 2025 LibreChat
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -51,7 +51,7 @@ class GoogleClient extends BaseClient {
|
||||
|
||||
const serviceKey = creds[AuthKeys.GOOGLE_SERVICE_KEY] ?? {};
|
||||
this.serviceKey =
|
||||
serviceKey && typeof serviceKey === 'string' ? JSON.parse(serviceKey) : serviceKey ?? {};
|
||||
serviceKey && typeof serviceKey === 'string' ? JSON.parse(serviceKey) : (serviceKey ?? {});
|
||||
/** @type {string | null | undefined} */
|
||||
this.project_id = this.serviceKey.project_id;
|
||||
this.client_email = this.serviceKey.client_email;
|
||||
@@ -73,6 +73,8 @@ class GoogleClient extends BaseClient {
|
||||
* @type {string} */
|
||||
this.outputTokensKey = 'output_tokens';
|
||||
this.visionMode = VisionModes.generative;
|
||||
/** @type {string} */
|
||||
this.systemMessage;
|
||||
if (options.skipSetOptions) {
|
||||
return;
|
||||
}
|
||||
@@ -184,7 +186,7 @@ class GoogleClient extends BaseClient {
|
||||
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
|
||||
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
|
||||
}
|
||||
this.options.promptPrefix = promptPrefix;
|
||||
this.systemMessage = promptPrefix;
|
||||
this.initializeClient();
|
||||
return this;
|
||||
}
|
||||
@@ -314,7 +316,7 @@ class GoogleClient extends BaseClient {
|
||||
}
|
||||
|
||||
this.augmentedPrompt = await this.contextHandlers.createContext();
|
||||
this.options.promptPrefix = this.augmentedPrompt + this.options.promptPrefix;
|
||||
this.systemMessage = this.augmentedPrompt + this.systemMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,8 +363,8 @@ class GoogleClient extends BaseClient {
|
||||
throw new Error('[GoogleClient] PaLM 2 and Codey models are no longer supported.');
|
||||
}
|
||||
|
||||
if (this.options.promptPrefix) {
|
||||
const instructionsTokenCount = this.getTokenCount(this.options.promptPrefix);
|
||||
if (this.systemMessage) {
|
||||
const instructionsTokenCount = this.getTokenCount(this.systemMessage);
|
||||
|
||||
this.maxContextTokens = this.maxContextTokens - instructionsTokenCount;
|
||||
if (this.maxContextTokens < 0) {
|
||||
@@ -417,8 +419,8 @@ class GoogleClient extends BaseClient {
|
||||
],
|
||||
};
|
||||
|
||||
if (this.options.promptPrefix) {
|
||||
payload.instances[0].context = this.options.promptPrefix;
|
||||
if (this.systemMessage) {
|
||||
payload.instances[0].context = this.systemMessage;
|
||||
}
|
||||
|
||||
logger.debug('[GoogleClient] buildMessages', payload);
|
||||
@@ -464,7 +466,7 @@ class GoogleClient extends BaseClient {
|
||||
identityPrefix = `${identityPrefix}\nYou are ${this.options.modelLabel}`;
|
||||
}
|
||||
|
||||
let promptPrefix = (this.options.promptPrefix ?? '').trim();
|
||||
let promptPrefix = (this.systemMessage ?? '').trim();
|
||||
|
||||
if (identityPrefix) {
|
||||
promptPrefix = `${identityPrefix}${promptPrefix}`;
|
||||
@@ -639,7 +641,7 @@ class GoogleClient extends BaseClient {
|
||||
let error;
|
||||
try {
|
||||
if (!EXCLUDED_GENAI_MODELS.test(modelName) && !this.project_id) {
|
||||
/** @type {GenAI} */
|
||||
/** @type {GenerativeModel} */
|
||||
const client = this.client;
|
||||
/** @type {GenerateContentRequest} */
|
||||
const requestOptions = {
|
||||
@@ -648,7 +650,7 @@ class GoogleClient extends BaseClient {
|
||||
generationConfig: googleGenConfigSchema.parse(this.modelOptions),
|
||||
};
|
||||
|
||||
const promptPrefix = (this.options.promptPrefix ?? '').trim();
|
||||
const promptPrefix = (this.systemMessage ?? '').trim();
|
||||
if (promptPrefix.length) {
|
||||
requestOptions.systemInstruction = {
|
||||
parts: [
|
||||
@@ -663,7 +665,17 @@ class GoogleClient extends BaseClient {
|
||||
/** @type {GenAIUsageMetadata} */
|
||||
let usageMetadata;
|
||||
|
||||
const result = await client.generateContentStream(requestOptions);
|
||||
abortController.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
logger.warn('[GoogleClient] Request was aborted', abortController.signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
const result = await client.generateContentStream(requestOptions, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
for await (const chunk of result.stream) {
|
||||
usageMetadata = !usageMetadata
|
||||
? chunk?.usageMetadata
|
||||
|
||||
@@ -2,7 +2,7 @@ const { z } = require('zod');
|
||||
const axios = require('axios');
|
||||
const { Ollama } = require('ollama');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const { deriveBaseURL } = require('~/utils');
|
||||
const { deriveBaseURL, logAxiosError } = require('~/utils');
|
||||
const { sleep } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
@@ -68,7 +68,7 @@ class OllamaClient {
|
||||
} catch (error) {
|
||||
const logMessage =
|
||||
'Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn\'t start with `ollama` (case-insensitive).';
|
||||
logger.error(logMessage, error);
|
||||
logAxiosError({ message: logMessage, error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const {
|
||||
ImageDetail,
|
||||
EModelEndpoint,
|
||||
resolveHeaders,
|
||||
KnownEndpoints,
|
||||
openAISettings,
|
||||
ImageDetailCost,
|
||||
CohereConstants,
|
||||
@@ -116,11 +117,7 @@ class OpenAIClient extends BaseClient {
|
||||
|
||||
const { reverseProxyUrl: reverseProxy } = this.options;
|
||||
|
||||
if (
|
||||
!this.useOpenRouter &&
|
||||
reverseProxy &&
|
||||
reverseProxy.includes('https://openrouter.ai/api/v1')
|
||||
) {
|
||||
if (!this.useOpenRouter && reverseProxy && reverseProxy.includes(KnownEndpoints.openrouter)) {
|
||||
this.useOpenRouter = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -282,4 +282,47 @@ describe('formatAgentMessages', () => {
|
||||
// Additional check to ensure the consecutive assistant messages were combined
|
||||
expect(result[1].content).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should skip THINK type content parts', () => {
|
||||
const payload = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Initial response' },
|
||||
{ type: ContentTypes.THINK, [ContentTypes.THINK]: 'Reasoning about the problem...' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final answer' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatAgentMessages(payload);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(AIMessage);
|
||||
expect(result[0].content).toEqual('Initial response\nFinal answer');
|
||||
});
|
||||
|
||||
it('should join TEXT content as string when THINK content type is present', () => {
|
||||
const payload = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: ContentTypes.THINK, [ContentTypes.THINK]: 'Analyzing the problem...' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'First part of response' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Second part of response' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Final part of response' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const result = formatAgentMessages(payload);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBeInstanceOf(AIMessage);
|
||||
expect(typeof result[0].content).toBe('string');
|
||||
expect(result[0].content).toBe(
|
||||
'First part of response\nSecond part of response\nFinal part of response',
|
||||
);
|
||||
expect(result[0].content).not.toContain('Analyzing the problem...');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,7 @@ const formatAgentMessages = (payload) => {
|
||||
let currentContent = [];
|
||||
let lastAIMessage = null;
|
||||
|
||||
let hasReasoning = false;
|
||||
for (const part of message.content) {
|
||||
if (part.type === ContentTypes.TEXT && part.tool_call_ids) {
|
||||
/*
|
||||
@@ -207,11 +208,25 @@ const formatAgentMessages = (payload) => {
|
||||
content: output || '',
|
||||
}),
|
||||
);
|
||||
} else if (part.type === ContentTypes.THINK) {
|
||||
hasReasoning = true;
|
||||
continue;
|
||||
} else {
|
||||
currentContent.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasReasoning) {
|
||||
currentContent = currentContent
|
||||
.reduce((acc, curr) => {
|
||||
if (curr.type === ContentTypes.TEXT) {
|
||||
return `${acc}${curr[ContentTypes.TEXT]}\n`;
|
||||
}
|
||||
return acc;
|
||||
}, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
if (currentContent.length > 0) {
|
||||
messages.push(new AIMessage({ content: currentContent }));
|
||||
}
|
||||
|
||||
@@ -106,18 +106,21 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
|
||||
|
||||
const formattedResults = validResults
|
||||
.flatMap((result) =>
|
||||
result.data.map(([docInfo, relevanceScore]) => ({
|
||||
result.data.map(([docInfo, distance]) => ({
|
||||
filename: docInfo.metadata.source.split('/').pop(),
|
||||
content: docInfo.page_content,
|
||||
relevanceScore,
|
||||
distance,
|
||||
})),
|
||||
)
|
||||
.sort((a, b) => b.relevanceScore - a.relevanceScore);
|
||||
// TODO: results should be sorted by relevance, not distance
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
// TODO: make this configurable
|
||||
.slice(0, 10);
|
||||
|
||||
const formattedString = formattedResults
|
||||
.map(
|
||||
(result) =>
|
||||
`File: ${result.filename}\nRelevance: ${result.relevanceScore.toFixed(4)}\nContent: ${
|
||||
`File: ${result.filename}\nRelevance: ${1.0 - result.distance.toFixed(4)}\nContent: ${
|
||||
result.content
|
||||
}\n`,
|
||||
)
|
||||
|
||||
4
api/cache/index.js
vendored
4
api/cache/index.js
vendored
@@ -1,5 +1,7 @@
|
||||
const keyvFiles = require('./keyvFiles');
|
||||
const getLogStores = require('./getLogStores');
|
||||
const logViolation = require('./logViolation');
|
||||
const mongoUserStore = require('./mongoUserStore');
|
||||
const mongoChallengeStore = require('./mongoChallengeStore');
|
||||
|
||||
module.exports = { ...keyvFiles, getLogStores, logViolation };
|
||||
module.exports = { ...keyvFiles, getLogStores, logViolation, mongoUserStore, mongoChallengeStore };
|
||||
72
api/cache/keyvRedis.js
vendored
72
api/cache/keyvRedis.js
vendored
@@ -1,15 +1,81 @@
|
||||
const fs = require('fs');
|
||||
const ioredis = require('ioredis');
|
||||
const KeyvRedis = require('@keyv/redis');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const logger = require('~/config/winston');
|
||||
|
||||
const { REDIS_URI, USE_REDIS } = process.env;
|
||||
const { REDIS_URI, USE_REDIS, USE_REDIS_CLUSTER, REDIS_CA, REDIS_KEY_PREFIX, REDIS_MAX_LISTENERS } =
|
||||
process.env;
|
||||
|
||||
let keyvRedis;
|
||||
const redis_prefix = REDIS_KEY_PREFIX || '';
|
||||
const redis_max_listeners = REDIS_MAX_LISTENERS || 10;
|
||||
|
||||
function mapURI(uri) {
|
||||
const regex =
|
||||
/^(?:(?<scheme>\w+):\/\/)?(?:(?<user>[^:@]+)(?::(?<password>[^@]+))?@)?(?<host>[\w.-]+)(?::(?<port>\d{1,5}))?$/;
|
||||
const match = uri.match(regex);
|
||||
|
||||
if (match) {
|
||||
const { scheme, user, password, host, port } = match.groups;
|
||||
|
||||
return {
|
||||
scheme: scheme || 'none',
|
||||
user: user || null,
|
||||
password: password || null,
|
||||
host: host || null,
|
||||
port: port || null,
|
||||
};
|
||||
} else {
|
||||
const parts = uri.split(':');
|
||||
if (parts.length === 2) {
|
||||
return {
|
||||
scheme: 'none',
|
||||
user: null,
|
||||
password: null,
|
||||
host: parts[0],
|
||||
port: parts[1],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
scheme: 'none',
|
||||
user: null,
|
||||
password: null,
|
||||
host: uri,
|
||||
port: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (REDIS_URI && isEnabled(USE_REDIS)) {
|
||||
keyvRedis = new KeyvRedis(REDIS_URI, { useRedisSets: false });
|
||||
let redisOptions = null;
|
||||
let keyvOpts = {
|
||||
useRedisSets: false,
|
||||
keyPrefix: redis_prefix,
|
||||
};
|
||||
|
||||
if (REDIS_CA) {
|
||||
const ca = fs.readFileSync(REDIS_CA);
|
||||
redisOptions = { tls: { ca } };
|
||||
}
|
||||
|
||||
if (isEnabled(USE_REDIS_CLUSTER)) {
|
||||
const hosts = REDIS_URI.split(',').map((item) => {
|
||||
var value = mapURI(item);
|
||||
|
||||
return {
|
||||
host: value.host,
|
||||
port: value.port,
|
||||
};
|
||||
});
|
||||
const cluster = new ioredis.Cluster(hosts, { redisOptions });
|
||||
keyvRedis = new KeyvRedis(cluster, keyvOpts);
|
||||
} else {
|
||||
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
|
||||
}
|
||||
keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err));
|
||||
keyvRedis.setMaxListeners(20);
|
||||
keyvRedis.setMaxListeners(redis_max_listeners);
|
||||
logger.info(
|
||||
'[Optional] Redis initialized. Note: Redis support is experimental. If you have issues, disable it. Cache needs to be flushed for values to refresh.',
|
||||
);
|
||||
|
||||
35
api/cache/mongoChallengeStore.js
vendored
Normal file
35
api/cache/mongoChallengeStore.js
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
const ChallengeStore = require('~/models/ChallengeStore');
|
||||
|
||||
class MongoChallengeStore {
|
||||
async get(userId) {
|
||||
try {
|
||||
const challenge = await ChallengeStore.findOne({ userId }).lean().exec();
|
||||
return challenge ? challenge.challenge : undefined;
|
||||
} catch (error) {
|
||||
console.error(`❌ Error fetching challenge for userId ${userId}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async save(userId, challenge) {
|
||||
try {
|
||||
await ChallengeStore.findOneAndUpdate(
|
||||
{ userId },
|
||||
{ challenge, createdAt: new Date() },
|
||||
{ upsert: true, new: true, setDefaultsOnInsert: true },
|
||||
).exec();
|
||||
} catch (error) {
|
||||
console.error(`❌ Error saving challenge for userId ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(userId) {
|
||||
try {
|
||||
await ChallengeStore.deleteOne({ userId }).exec();
|
||||
} catch (error) {
|
||||
console.error(`❌ Error deleting challenge for userId ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MongoChallengeStore;
|
||||
55
api/cache/mongoUserStore.js
vendored
Normal file
55
api/cache/mongoUserStore.js
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
const User = require('~/models');
|
||||
|
||||
class MongoUserStore {
|
||||
async get(identifier, byID = false) {
|
||||
let user;
|
||||
if (byID) {
|
||||
user = await User.getUserById(identifier);
|
||||
} else {
|
||||
user = await User.findUser({ email: identifier });
|
||||
}
|
||||
if (user) {
|
||||
return {
|
||||
id: user._id.toString(),
|
||||
email: user.email,
|
||||
passkeys: user.passkeys,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async save(user) {
|
||||
if (!user.id) {
|
||||
const createdUser = await User.createUser(
|
||||
{
|
||||
email: user.email,
|
||||
username: user.email,
|
||||
passkeys: user.passkeys,
|
||||
},
|
||||
/* disableTTL */ true,
|
||||
/* returnUser */ true,
|
||||
);
|
||||
return {
|
||||
id: createdUser._id.toString(),
|
||||
email: createdUser.email,
|
||||
passkeys: createdUser.passkeys,
|
||||
};
|
||||
} else {
|
||||
const updatedUser = await User.updateUser(user.id, {
|
||||
email: user.email,
|
||||
username: user.email,
|
||||
passkeys: user.passkeys,
|
||||
});
|
||||
if (!updatedUser) {
|
||||
throw new Error('Failed to update user');
|
||||
}
|
||||
return {
|
||||
id: updatedUser._id.toString(),
|
||||
email: updatedUser.email,
|
||||
passkeys: updatedUser.passkeys,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = MongoUserStore;
|
||||
@@ -1,9 +1,11 @@
|
||||
const { MeiliSearch } = require('meilisearch');
|
||||
const Conversation = require('~/models/schema/convoSchema');
|
||||
const Message = require('~/models/schema/messageSchema');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const searchEnabled = process.env?.SEARCH?.toLowerCase() === 'true';
|
||||
const searchEnabled = isEnabled(process.env.SEARCH);
|
||||
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
|
||||
let currentTimeout = null;
|
||||
|
||||
class MeiliSearchClient {
|
||||
@@ -23,8 +25,7 @@ class MeiliSearchClient {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async function indexSync(req, res, next) {
|
||||
async function indexSync() {
|
||||
if (!searchEnabled) {
|
||||
return;
|
||||
}
|
||||
@@ -33,10 +34,15 @@ async function indexSync(req, res, next) {
|
||||
const client = MeiliSearchClient.getInstance();
|
||||
|
||||
const { status } = await client.health();
|
||||
if (status !== 'available' || !process.env.SEARCH) {
|
||||
if (status !== 'available') {
|
||||
throw new Error('Meilisearch not available');
|
||||
}
|
||||
|
||||
if (indexingDisabled === true) {
|
||||
logger.info('[indexSync] Indexing is disabled, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
const messageCount = await Message.countDocuments();
|
||||
const convoCount = await Conversation.countDocuments();
|
||||
const messages = await client.index('messages').getStats();
|
||||
@@ -71,7 +77,6 @@ async function indexSync(req, res, next) {
|
||||
logger.info('[indexSync] Meilisearch not configured, search will be disabled.');
|
||||
} else {
|
||||
logger.error('[indexSync] error', err);
|
||||
// res.status(500).json({ error: 'Server error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,11 +97,22 @@ const updateAgent = async (searchParameter, updateData) => {
|
||||
const addAgentResourceFile = async ({ agent_id, tool_resource, file_id }) => {
|
||||
const searchParameter = { id: agent_id };
|
||||
|
||||
// build the update to push or create the file ids set
|
||||
const fileIdsPath = `tool_resources.${tool_resource}.file_ids`;
|
||||
|
||||
await Agent.updateOne(
|
||||
{
|
||||
id: agent_id,
|
||||
[`${fileIdsPath}`]: { $exists: false },
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
[`${fileIdsPath}`]: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const updateData = { $addToSet: { [fileIdsPath]: file_id } };
|
||||
|
||||
// return the updated agent or throw if no agent matches
|
||||
const updatedAgent = await updateAgent(searchParameter, updateData);
|
||||
if (updatedAgent) {
|
||||
return updatedAgent;
|
||||
@@ -290,6 +301,7 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
Agent,
|
||||
getAgent,
|
||||
loadAgent,
|
||||
createAgent,
|
||||
|
||||
160
api/models/Agent.spec.js
Normal file
160
api/models/Agent.spec.js
Normal file
@@ -0,0 +1,160 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent');
|
||||
|
||||
describe('Agent Resource File Operations', () => {
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await Agent.deleteMany({});
|
||||
});
|
||||
|
||||
const createBasicAgent = async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const agent = await Agent.create({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
return agent;
|
||||
};
|
||||
|
||||
test('should handle concurrent file additions', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const fileIds = Array.from({ length: 10 }, () => uuidv4());
|
||||
|
||||
// Concurrent additions
|
||||
const additionPromises = fileIds.map((fileId) =>
|
||||
addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(additionPromises);
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(10);
|
||||
expect(new Set(updatedAgent.tool_resources.test_tool.file_ids).size).toBe(10);
|
||||
});
|
||||
|
||||
test('should handle concurrent additions and removals', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const initialFileIds = Array.from({ length: 5 }, () => uuidv4());
|
||||
|
||||
await Promise.all(
|
||||
initialFileIds.map((fileId) =>
|
||||
addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const newFileIds = Array.from({ length: 5 }, () => uuidv4());
|
||||
const operations = [
|
||||
...newFileIds.map((fileId) =>
|
||||
addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: fileId,
|
||||
}),
|
||||
),
|
||||
...initialFileIds.map((fileId) =>
|
||||
removeAgentResourceFiles({
|
||||
agent_id: agent.id,
|
||||
files: [{ tool_resource: 'test_tool', file_id: fileId }],
|
||||
}),
|
||||
),
|
||||
];
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('should initialize array when adding to non-existent tool resource', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const fileId = uuidv4();
|
||||
|
||||
const updatedAgent = await addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'new_tool',
|
||||
file_id: fileId,
|
||||
});
|
||||
|
||||
expect(updatedAgent.tool_resources.new_tool.file_ids).toBeDefined();
|
||||
expect(updatedAgent.tool_resources.new_tool.file_ids).toHaveLength(1);
|
||||
expect(updatedAgent.tool_resources.new_tool.file_ids[0]).toBe(fileId);
|
||||
});
|
||||
|
||||
test('should handle rapid sequential modifications to same tool resource', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const fileId = uuidv4();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: 'test_tool',
|
||||
file_id: `${fileId}_${i}`,
|
||||
});
|
||||
|
||||
if (i % 2 === 0) {
|
||||
await removeAgentResourceFiles({
|
||||
agent_id: agent.id,
|
||||
files: [{ tool_resource: 'test_tool', file_id: `${fileId}_${i}` }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
expect(updatedAgent.tool_resources.test_tool.file_ids).toBeDefined();
|
||||
expect(Array.isArray(updatedAgent.tool_resources.test_tool.file_ids)).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle multiple tool resources concurrently', async () => {
|
||||
const agent = await createBasicAgent();
|
||||
const toolResources = ['tool1', 'tool2', 'tool3'];
|
||||
const operations = [];
|
||||
|
||||
toolResources.forEach((tool) => {
|
||||
const fileIds = Array.from({ length: 5 }, () => uuidv4());
|
||||
fileIds.forEach((fileId) => {
|
||||
operations.push(
|
||||
addAgentResourceFile({
|
||||
agent_id: agent.id,
|
||||
tool_resource: tool,
|
||||
file_id: fileId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await Promise.all(operations);
|
||||
|
||||
const updatedAgent = await Agent.findOne({ id: agent.id });
|
||||
toolResources.forEach((tool) => {
|
||||
expect(updatedAgent.tool_resources[tool].file_ids).toBeDefined();
|
||||
expect(updatedAgent.tool_resources[tool].file_ids).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,41 @@
|
||||
const { logger } = require('~/config');
|
||||
// const { Categories } = require('./schema/categories');
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: 'idea',
|
||||
label: 'com_ui_idea',
|
||||
value: 'idea',
|
||||
},
|
||||
{
|
||||
label: 'travel',
|
||||
label: 'com_ui_travel',
|
||||
value: 'travel',
|
||||
},
|
||||
{
|
||||
label: 'teach_or_explain',
|
||||
label: 'com_ui_teach_or_explain',
|
||||
value: 'teach_or_explain',
|
||||
},
|
||||
{
|
||||
label: 'write',
|
||||
label: 'com_ui_write',
|
||||
value: 'write',
|
||||
},
|
||||
{
|
||||
label: 'shop',
|
||||
label: 'com_ui_shop',
|
||||
value: 'shop',
|
||||
},
|
||||
{
|
||||
label: 'code',
|
||||
label: 'com_ui_code',
|
||||
value: 'code',
|
||||
},
|
||||
{
|
||||
label: 'misc',
|
||||
label: 'com_ui_misc',
|
||||
value: 'misc',
|
||||
},
|
||||
{
|
||||
label: 'roleplay',
|
||||
label: 'com_ui_roleplay',
|
||||
value: 'roleplay',
|
||||
},
|
||||
{
|
||||
label: 'finance',
|
||||
label: 'com_ui_finance',
|
||||
value: 'finance',
|
||||
},
|
||||
];
|
||||
|
||||
6
api/models/ChallengeStore.js
Normal file
6
api/models/ChallengeStore.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const mongoose = require('mongoose');
|
||||
const challengeSchema = require('~/models/schema/challengeSchema');
|
||||
|
||||
const ChallengeStore = mongoose.model('Challenge', challengeSchema);
|
||||
|
||||
module.exports = ChallengeStore;
|
||||
22
api/models/schema/challengeSchema.js
Normal file
22
api/models/schema/challengeSchema.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const challengeSchema = mongoose.Schema({
|
||||
userId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
},
|
||||
challenge: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
createdAt: {
|
||||
type: Date,
|
||||
default: Date.now,
|
||||
index: {
|
||||
expires: '5m',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = challengeSchema;
|
||||
@@ -39,6 +39,19 @@ const Session = mongoose.Schema({
|
||||
},
|
||||
});
|
||||
|
||||
const backupCodeSchema = mongoose.Schema({
|
||||
codeHash: { type: String, required: true },
|
||||
used: { type: Boolean, default: false },
|
||||
usedAt: { type: Date, default: null },
|
||||
});
|
||||
|
||||
const passkeySchema = mongoose.Schema({
|
||||
id: { type: String, required: true },
|
||||
publicKey: { type: Buffer, required: true },
|
||||
counter: { type: Number, default: 0 },
|
||||
transports: { type: [String], default: [] },
|
||||
});
|
||||
|
||||
/** @type {MongooseSchema<MongoUser>} */
|
||||
const userSchema = mongoose.Schema(
|
||||
{
|
||||
@@ -117,9 +130,18 @@ const userSchema = mongoose.Schema(
|
||||
unique: true,
|
||||
sparse: true,
|
||||
},
|
||||
passkeys: {
|
||||
type: [passkeySchema],
|
||||
default: [],
|
||||
},
|
||||
plugins: {
|
||||
type: Array,
|
||||
default: [],
|
||||
},
|
||||
totpSecret: {
|
||||
type: String,
|
||||
},
|
||||
backupCodes: {
|
||||
type: [backupCodeSchema],
|
||||
},
|
||||
refreshToken: {
|
||||
type: [Session],
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@langchain/google-genai": "^0.1.7",
|
||||
"@langchain/google-vertexai": "^0.1.8",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.0.5",
|
||||
"@librechat/agents": "^2.1.2",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "1.7.8",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -65,6 +65,7 @@
|
||||
"firebase": "^11.0.2",
|
||||
"googleapis": "^126.0.1",
|
||||
"handlebars": "^4.7.7",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ioredis": "^5.3.2",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
@@ -96,6 +97,7 @@
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-ldapauth": "^3.0.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"passport-simple-webauthn2": "^3.2.0",
|
||||
"sharp": "^0.32.6",
|
||||
"tiktoken": "^1.0.15",
|
||||
"traverse": "^0.6.7",
|
||||
|
||||
@@ -61,7 +61,7 @@ const refreshController = async (req, res) => {
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const user = await getUserById(payload.id, '-password -__v');
|
||||
const user = await getUserById(payload.id, '-password -__v -totpSecret');
|
||||
if (!user) {
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
|
||||
119
api/server/controllers/TwoFactorController.js
Normal file
119
api/server/controllers/TwoFactorController.js
Normal file
@@ -0,0 +1,119 @@
|
||||
const {
|
||||
verifyTOTP,
|
||||
verifyBackupCode,
|
||||
generateTOTPSecret,
|
||||
generateBackupCodes,
|
||||
getTOTPSecret,
|
||||
} = require('~/server/services/twoFactorService');
|
||||
const { updateUser, getUserById } = require('~/models');
|
||||
const { logger } = require('~/config');
|
||||
const { encryptV2 } = require('~/server/utils/crypto');
|
||||
|
||||
const enable2FAController = async (req, res) => {
|
||||
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
||||
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const secret = generateTOTPSecret();
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
|
||||
const encryptedSecret = await encryptV2(secret);
|
||||
const user = await updateUser(userId, { totpSecret: encryptedSecret, backupCodes: codeObjects });
|
||||
|
||||
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${user.email}?secret=${secret}&issuer=${safeAppTitle}`;
|
||||
|
||||
res.status(200).json({
|
||||
otpauthUrl,
|
||||
backupCodes: plainCodes,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[enable2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const verify2FAController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId);
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
// Retrieve the plain TOTP secret using getTOTPSecret.
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
|
||||
if (token && (await verifyTOTP(secret, token))) {
|
||||
return res.status(200).json();
|
||||
} else if (backupCode) {
|
||||
const verified = await verifyBackupCode({ user, backupCode });
|
||||
if (verified) {
|
||||
return res.status(200).json();
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(400).json({ message: 'Invalid token.' });
|
||||
} catch (err) {
|
||||
logger.error('[verify2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const confirm2FAController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token } = req.body;
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
// Retrieve the plain TOTP secret using getTOTPSecret.
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
|
||||
if (await verifyTOTP(secret, token)) {
|
||||
return res.status(200).json();
|
||||
}
|
||||
|
||||
return res.status(400).json({ message: 'Invalid token.' });
|
||||
} catch (err) {
|
||||
logger.error('[confirm2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const disable2FAController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
await updateUser(userId, { totpSecret: null, backupCodes: [] });
|
||||
res.status(200).json();
|
||||
} catch (err) {
|
||||
logger.error('[disable2FAController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const regenerateBackupCodesController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
await updateUser(userId, { backupCodes: codeObjects });
|
||||
res.status(200).json({
|
||||
backupCodes: plainCodes,
|
||||
backupCodesHash: codeObjects,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[regenerateBackupCodesController]', err);
|
||||
res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
enable2FAController,
|
||||
verify2FAController,
|
||||
confirm2FAController,
|
||||
disable2FAController,
|
||||
regenerateBackupCodesController,
|
||||
};
|
||||
@@ -19,7 +19,9 @@ const { Transaction } = require('~/models/Transaction');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
res.status(200).send(req.user);
|
||||
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
|
||||
delete userData.totpSecret;
|
||||
res.status(200).send(userData);
|
||||
};
|
||||
|
||||
const getTermsStatusController = async (req, res) => {
|
||||
|
||||
@@ -199,6 +199,22 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU
|
||||
aggregateContent({ event, data });
|
||||
},
|
||||
},
|
||||
[GraphEvents.ON_REASONING_DELTA]: {
|
||||
/**
|
||||
* Handle ON_REASONING_DELTA event.
|
||||
* @param {string} event - The event name.
|
||||
* @param {StreamEventData} data - The event data.
|
||||
* @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata.
|
||||
*/
|
||||
handle: (event, data, metadata) => {
|
||||
if (metadata?.last_agent_index === metadata?.agent_index) {
|
||||
sendEvent(res, { event, data });
|
||||
} else if (!metadata?.hide_sequential_outputs) {
|
||||
sendEvent(res, { event, data });
|
||||
}
|
||||
aggregateContent({ event, data });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return handlers;
|
||||
|
||||
@@ -20,11 +20,6 @@ const {
|
||||
bedrockOutputParser,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
extractBaseURL,
|
||||
// constructAzureURL,
|
||||
// genAzureChatCompletion,
|
||||
} = require('~/utils');
|
||||
const {
|
||||
formatMessage,
|
||||
formatAgentMessages,
|
||||
@@ -477,19 +472,6 @@ class AgentClient extends BaseClient {
|
||||
abortController = new AbortController();
|
||||
}
|
||||
|
||||
const baseURL = extractBaseURL(this.completionsUrl);
|
||||
logger.debug('[api/server/controllers/agents/client.js] chatCompletion', {
|
||||
baseURL,
|
||||
payload,
|
||||
});
|
||||
|
||||
// if (this.useOpenRouter) {
|
||||
// opts.defaultHeaders = {
|
||||
// 'HTTP-Referer': 'https://librechat.ai',
|
||||
// 'X-Title': 'LibreChat',
|
||||
// };
|
||||
// }
|
||||
|
||||
// if (this.options.headers) {
|
||||
// opts.defaultHeaders = { ...opts.defaultHeaders, ...this.options.headers };
|
||||
// }
|
||||
@@ -626,7 +608,7 @@ class AgentClient extends BaseClient {
|
||||
let systemContent = [
|
||||
systemMessage,
|
||||
agent.instructions ?? '',
|
||||
i !== 0 ? agent.additional_instructions ?? '' : '',
|
||||
i !== 0 ? (agent.additional_instructions ?? '') : '',
|
||||
]
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { Run, Providers } = require('@librechat/agents');
|
||||
const { providerEndpointMap } = require('librechat-data-provider');
|
||||
const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* @typedef {import('@librechat/agents').t} t
|
||||
@@ -7,6 +7,7 @@ const { providerEndpointMap } = require('librechat-data-provider');
|
||||
* @typedef {import('@librechat/agents').StreamEventData} StreamEventData
|
||||
* @typedef {import('@librechat/agents').EventHandler} EventHandler
|
||||
* @typedef {import('@librechat/agents').GraphEvents} GraphEvents
|
||||
* @typedef {import('@librechat/agents').LLMConfig} LLMConfig
|
||||
* @typedef {import('@librechat/agents').IState} IState
|
||||
*/
|
||||
|
||||
@@ -32,6 +33,7 @@ async function createRun({
|
||||
streamUsage = true,
|
||||
}) {
|
||||
const provider = providerEndpointMap[agent.provider] ?? agent.provider;
|
||||
/** @type {LLMConfig} */
|
||||
const llmConfig = Object.assign(
|
||||
{
|
||||
provider,
|
||||
@@ -41,6 +43,11 @@ async function createRun({
|
||||
agent.model_parameters,
|
||||
);
|
||||
|
||||
/** @type {'reasoning_content' | 'reasoning'} */
|
||||
let reasoningKey;
|
||||
if (llmConfig.configuration?.baseURL?.includes(KnownEndpoints.openrouter)) {
|
||||
reasoningKey = 'reasoning';
|
||||
}
|
||||
if (/o1(?!-(?:mini|preview)).*$/.test(llmConfig.model)) {
|
||||
llmConfig.streaming = false;
|
||||
llmConfig.disableStreaming = true;
|
||||
@@ -50,6 +57,7 @@ async function createRun({
|
||||
const graphConfig = {
|
||||
signal,
|
||||
llmConfig,
|
||||
reasoningKey,
|
||||
tools: agent.tools,
|
||||
instructions: agent.instructions,
|
||||
additional_instructions: agent.additional_instructions,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { generate2FATempToken } = require('~/server/services/twoFactorService');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
@@ -7,7 +8,12 @@ const loginController = async (req, res) => {
|
||||
return res.status(400).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
const { password: _, __v, ...user } = req.user;
|
||||
if (req.user.backupCodes != null && req.user.backupCodes.length > 0) {
|
||||
const tempToken = generate2FATempToken(req.user._id);
|
||||
return res.status(200).json({ twoFAPending: true, tempToken });
|
||||
}
|
||||
|
||||
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
|
||||
user.id = user._id.toString();
|
||||
|
||||
const token = await setAuthTokens(req.user._id, res);
|
||||
|
||||
58
api/server/controllers/auth/TwoFactorAuthController.js
Normal file
58
api/server/controllers/auth/TwoFactorAuthController.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { verifyTOTP, verifyBackupCode, getTOTPSecret } = require('~/server/services/twoFactorService');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { getUserById } = require('~/models/userMethods');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const verify2FA = async (req, res) => {
|
||||
try {
|
||||
const { tempToken, token, backupCode } = req.body;
|
||||
if (!tempToken) {
|
||||
return res.status(400).json({ message: 'Missing temporary token' });
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(tempToken, process.env.JWT_SECRET);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ message: 'Invalid or expired temporary token' });
|
||||
}
|
||||
|
||||
const user = await getUserById(payload.userId);
|
||||
// Ensure that the user exists and has backup codes (i.e. 2FA enabled)
|
||||
if (!user || !(user.backupCodes && user.backupCodes.length > 0)) {
|
||||
return res.status(400).json({ message: '2FA is not enabled for this user' });
|
||||
}
|
||||
|
||||
// Use the new getTOTPSecret function to retrieve (and decrypt if necessary) the TOTP secret.
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
|
||||
let verified = false;
|
||||
if (token && (await verifyTOTP(secret, token))) {
|
||||
verified = true;
|
||||
} else if (backupCode) {
|
||||
verified = await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
|
||||
}
|
||||
|
||||
// Prepare user data for response.
|
||||
// If the user is a plain object (from lean queries), we create a shallow copy.
|
||||
const userData = user.toObject ? user.toObject() : { ...user };
|
||||
// Remove sensitive fields.
|
||||
delete userData.password;
|
||||
delete userData.__v;
|
||||
delete userData.totpSecret;
|
||||
userData.id = user._id.toString();
|
||||
|
||||
const authToken = await setAuthTokens(user._id, res);
|
||||
return res.status(200).json({ token: authToken, user: userData });
|
||||
} catch (err) {
|
||||
logger.error('[verify2FA]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { verify2FA };
|
||||
@@ -21,11 +21,14 @@ const AppService = require('./services/AppService');
|
||||
const staticCache = require('./utils/staticCache');
|
||||
const noIndex = require('./middleware/noIndex');
|
||||
const routes = require('./routes');
|
||||
const { mongoUserStore, mongoChallengeStore } = require('~/cache');
|
||||
const { WebAuthnStrategy } = require('passport-simple-webauthn2');
|
||||
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = process.env ?? {};
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
||||
|
||||
const port = Number(PORT) || 3080;
|
||||
const host = HOST || 'localhost';
|
||||
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
||||
|
||||
const startServer = async () => {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
@@ -53,7 +56,7 @@ const startServer = async () => {
|
||||
app.use(staticCache(app.locals.paths.dist));
|
||||
app.use(staticCache(app.locals.paths.fonts));
|
||||
app.use(staticCache(app.locals.paths.assets));
|
||||
app.set('trust proxy', 1); /* trust first proxy */
|
||||
app.set('trust proxy', trusted_proxy);
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
|
||||
@@ -77,11 +80,29 @@ const startServer = async () => {
|
||||
passport.use(ldapLogin);
|
||||
}
|
||||
|
||||
/* Passkey (WebAuthn) Strategy */
|
||||
if (process.env.PASSKEY_ENABLED) {
|
||||
|
||||
const userStore = new mongoUserStore();
|
||||
const challengeStore = new mongoChallengeStore();
|
||||
|
||||
passport.use(
|
||||
new WebAuthnStrategy({
|
||||
rpID: process.env.RP_ID || 'localhost',
|
||||
rpName: process.env.APP_TITLE || 'LibreChat',
|
||||
userStore,
|
||||
challengeStore,
|
||||
debug: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
|
||||
configureSocialLogins(app);
|
||||
}
|
||||
|
||||
app.use('/oauth', routes.oauth);
|
||||
app.use('/webauthn', routes.authWebAuthn);
|
||||
/* API Endpoints */
|
||||
app.use('/api/auth', routes.auth);
|
||||
app.use('/api/actions', routes.actions);
|
||||
@@ -145,6 +166,18 @@ process.on('uncaughtException', (err) => {
|
||||
logger.error('There was an uncaught error:', err);
|
||||
}
|
||||
|
||||
if (err.message.includes('abort')) {
|
||||
logger.warn('There was an uncatchable AbortController error.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.message.includes('GoogleGenerativeAI')) {
|
||||
logger.warn(
|
||||
'\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.message.includes('fetch failed')) {
|
||||
if (messageCount === 0) {
|
||||
logger.warn('Meilisearch error, search will be disabled');
|
||||
|
||||
@@ -7,6 +7,13 @@ const {
|
||||
} = require('~/server/controllers/AuthController');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { logoutController } = require('~/server/controllers/auth/LogoutController');
|
||||
const { verify2FA } = require('~/server/controllers/auth/TwoFactorAuthController');
|
||||
const {
|
||||
enable2FAController,
|
||||
verify2FAController,
|
||||
disable2FAController,
|
||||
regenerateBackupCodesController, confirm2FAController,
|
||||
} = require('~/server/controllers/TwoFactorController');
|
||||
const {
|
||||
checkBan,
|
||||
loginLimiter,
|
||||
@@ -50,4 +57,11 @@ router.post(
|
||||
);
|
||||
router.post('/resetPassword', checkBan, validatePasswordReset, resetPasswordController);
|
||||
|
||||
router.get('/2fa/enable', requireJwtAuth, enable2FAController);
|
||||
router.post('/2fa/verify', requireJwtAuth, verify2FAController);
|
||||
router.post('/2fa/verify-temp', checkBan, verify2FA);
|
||||
router.post('/2fa/confirm', requireJwtAuth, confirm2FAController);
|
||||
router.post('/2fa/disable', requireJwtAuth, disable2FAController);
|
||||
router.post('/2fa/backup/regenerate', requireJwtAuth, regenerateBackupCodesController);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
44
api/server/routes/authWebAuthn.js
Normal file
44
api/server/routes/authWebAuthn.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const router = express.Router();
|
||||
|
||||
router.get(
|
||||
'/register',
|
||||
passport.authenticate('webauthn', { session: false }),
|
||||
(req, res) => {
|
||||
res.json(req.user);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/register',
|
||||
passport.authenticate('webauthn', { session: false, failureRedirect: '/login' }),
|
||||
(req, res) => {
|
||||
res.json({ user: req.user });
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/login',
|
||||
passport.authenticate('webauthn', { session: false }),
|
||||
(req, res) => {
|
||||
res.json(req.user);
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/login',
|
||||
passport.authenticate('webauthn', { session: false, failureRedirect: '/login' }),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const token = await setAuthTokens(req.user.id, res);
|
||||
res.status(200).json({ token, user: req.user });
|
||||
} catch (err) {
|
||||
console.error('[WebAuthn Login Callback]', err);
|
||||
res.status(500).json({ message: 'Something went wrong during login' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@@ -51,6 +51,7 @@ router.get('/', async function (req, res) {
|
||||
!!process.env.APPLE_TEAM_ID &&
|
||||
!!process.env.APPLE_KEY_ID &&
|
||||
!!process.env.APPLE_PRIVATE_KEY_PATH,
|
||||
passkeyLoginEnabled: !!process.env.PASSKEY_ENABLED && !!process.env.RP_ID,
|
||||
openidLoginEnabled:
|
||||
!!process.env.OPENID_CLIENT_ID &&
|
||||
!!process.env.OPENID_CLIENT_SECRET &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const authWebAuthn = require('./authWebAuthn');
|
||||
const assistants = require('./assistants');
|
||||
const categories = require('./categories');
|
||||
const tokenizer = require('./tokenizer');
|
||||
@@ -55,5 +56,6 @@ module.exports = {
|
||||
assistants,
|
||||
categories,
|
||||
staticRoute,
|
||||
authWebAuthn,
|
||||
banner,
|
||||
};
|
||||
|
||||
@@ -22,12 +22,14 @@ const { getAgent } = require('~/models/Agent');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const providerConfigMap = {
|
||||
[Providers.OLLAMA]: initCustom,
|
||||
[Providers.DEEPSEEK]: initCustom,
|
||||
[Providers.OPENROUTER]: initCustom,
|
||||
[EModelEndpoint.openAI]: initOpenAI,
|
||||
[EModelEndpoint.google]: initGoogle,
|
||||
[EModelEndpoint.azureOpenAI]: initOpenAI,
|
||||
[EModelEndpoint.anthropic]: initAnthropic,
|
||||
[EModelEndpoint.bedrock]: getBedrockOptions,
|
||||
[EModelEndpoint.google]: initGoogle,
|
||||
[Providers.OLLAMA]: initCustom,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -100,8 +102,10 @@ const initializeAgentOptions = async ({
|
||||
|
||||
const provider = agent.provider;
|
||||
let getOptions = providerConfigMap[provider];
|
||||
|
||||
if (!getOptions) {
|
||||
if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) {
|
||||
agent.provider = provider.toLowerCase();
|
||||
getOptions = providerConfigMap[agent.provider];
|
||||
} else if (!getOptions) {
|
||||
const customEndpointConfig = await getCustomEndpointConfig(provider);
|
||||
if (!customEndpointConfig) {
|
||||
throw new Error(`Provider ${provider} not supported`);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { KnownEndpoints } = require('librechat-data-provider');
|
||||
const { sanitizeModelName, constructAzureURL } = require('~/utils');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
@@ -57,10 +58,9 @@ function getLLMConfig(apiKey, options = {}) {
|
||||
|
||||
/** @type {OpenAIClientOptions['configuration']} */
|
||||
const configOptions = {};
|
||||
|
||||
// Handle OpenRouter or custom reverse proxy
|
||||
if (useOpenRouter || reverseProxyUrl === 'https://openrouter.ai/api/v1') {
|
||||
configOptions.baseURL = 'https://openrouter.ai/api/v1';
|
||||
if (useOpenRouter || (reverseProxyUrl && reverseProxyUrl.includes(KnownEndpoints.openrouter))) {
|
||||
llmConfig.include_reasoning = true;
|
||||
configOptions.baseURL = reverseProxyUrl;
|
||||
configOptions.defaultHeaders = Object.assign(
|
||||
{
|
||||
'HTTP-Referer': 'https://librechat.ai',
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
const { getCodeBaseURL } = require('@librechat/agents');
|
||||
const { logAxiosError } = require('~/utils');
|
||||
|
||||
const MAX_FILE_SIZE = 150 * 1024 * 1024;
|
||||
|
||||
@@ -78,7 +79,11 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = ''
|
||||
|
||||
return `${fileIdentifier}?entity_id=${entity_id}`;
|
||||
} catch (error) {
|
||||
throw new Error(`Error uploading file: ${error.message}`);
|
||||
logAxiosError({
|
||||
message: `Error uploading code environment file: ${error.message}`,
|
||||
error,
|
||||
});
|
||||
throw new Error(`Error uploading code environment file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { convertImage } = require('~/server/services/Files/images/convert');
|
||||
const { createFile, getFiles, updateFile } = require('~/models/File');
|
||||
const { logAxiosError } = require('~/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
@@ -85,7 +86,10 @@ const processCodeOutput = async ({
|
||||
/** Note: `messageId` & `toolCallId` are not part of file DB schema; message object records associated file ID */
|
||||
return Object.assign(file, { messageId, toolCallId });
|
||||
} catch (error) {
|
||||
logger.error('Error downloading file:', error);
|
||||
logAxiosError({
|
||||
message: 'Error downloading code environment file',
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -135,7 +139,10 @@ async function getSessionInfo(fileIdentifier, apiKey) {
|
||||
|
||||
return response.data.find((file) => file.name.startsWith(path))?.lastModified;
|
||||
} catch (error) {
|
||||
logger.error(`Error fetching session info: ${error.message}`, error);
|
||||
logAxiosError({
|
||||
message: `Error fetching session info: ${error.message}`,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -202,7 +209,7 @@ const primeFiles = async (options, apiKey) => {
|
||||
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(
|
||||
FileSources.execute_code,
|
||||
);
|
||||
const stream = await getDownloadStream(file.filepath);
|
||||
const stream = await getDownloadStream(options.req, file.filepath);
|
||||
const fileIdentifier = await uploadCodeEnvFile({
|
||||
req: options.req,
|
||||
stream,
|
||||
|
||||
@@ -224,10 +224,11 @@ async function uploadFileToFirebase({ req, file, file_id }) {
|
||||
/**
|
||||
* Retrieves a readable stream for a file from Firebase storage.
|
||||
*
|
||||
* @param {ServerRequest} _req
|
||||
* @param {string} filepath - The filepath.
|
||||
* @returns {Promise<ReadableStream>} A readable stream of the file.
|
||||
*/
|
||||
async function getFirebaseFileStream(filepath) {
|
||||
async function getFirebaseFileStream(_req, filepath) {
|
||||
try {
|
||||
const storage = getFirebaseStorage();
|
||||
if (!storage) {
|
||||
|
||||
@@ -175,6 +175,17 @@ const isValidPath = (req, base, subfolder, filepath) => {
|
||||
return normalizedFilepath.startsWith(normalizedBase);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} filepath
|
||||
*/
|
||||
const unlinkFile = async (filepath) => {
|
||||
try {
|
||||
await fs.promises.unlink(filepath);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting file:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a file from the filesystem. This function takes a file object, constructs the full path, and
|
||||
* verifies the path's validity before deleting the file. If the path is invalid, an error is thrown.
|
||||
@@ -217,7 +228,7 @@ const deleteLocalFile = async (req, file) => {
|
||||
throw new Error(`Invalid file path: ${file.filepath}`);
|
||||
}
|
||||
|
||||
await fs.promises.unlink(filepath);
|
||||
await unlinkFile(filepath);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -233,7 +244,7 @@ const deleteLocalFile = async (req, file) => {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
|
||||
await fs.promises.unlink(filepath);
|
||||
await unlinkFile(filepath);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -275,11 +286,31 @@ async function uploadLocalFile({ req, file, file_id }) {
|
||||
/**
|
||||
* Retrieves a readable stream for a file from local storage.
|
||||
*
|
||||
* @param {ServerRequest} req - The request object from Express
|
||||
* @param {string} filepath - The filepath.
|
||||
* @returns {ReadableStream} A readable stream of the file.
|
||||
*/
|
||||
function getLocalFileStream(filepath) {
|
||||
function getLocalFileStream(req, filepath) {
|
||||
try {
|
||||
if (filepath.includes('/uploads/')) {
|
||||
const basePath = filepath.split('/uploads/')[1];
|
||||
|
||||
if (!basePath) {
|
||||
logger.warn(`Invalid base path: ${filepath}`);
|
||||
throw new Error(`Invalid file path: ${filepath}`);
|
||||
}
|
||||
|
||||
const fullPath = path.join(req.app.locals.paths.uploads, basePath);
|
||||
const uploadsDir = req.app.locals.paths.uploads;
|
||||
|
||||
const rel = path.relative(uploadsDir, fullPath);
|
||||
if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) {
|
||||
logger.warn(`Invalid relative file path: ${filepath}`);
|
||||
throw new Error(`Invalid file path: ${filepath}`);
|
||||
}
|
||||
|
||||
return fs.createReadStream(fullPath);
|
||||
}
|
||||
return fs.createReadStream(filepath);
|
||||
} catch (error) {
|
||||
logger.error('Error getting local file stream:', error);
|
||||
|
||||
@@ -37,7 +37,14 @@ const deleteVectors = async (req, file) => {
|
||||
error,
|
||||
message: 'Error deleting vectors',
|
||||
});
|
||||
throw new Error(error.message || 'An error occurred during file deletion.');
|
||||
if (
|
||||
error.response &&
|
||||
error.response.status !== 404 &&
|
||||
(error.response.status < 200 || error.response.status >= 300)
|
||||
) {
|
||||
logger.warn('Error deleting vectors, file will not be deleted');
|
||||
throw new Error(error.message || 'An error occurred during file deletion.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -347,8 +347,8 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true })
|
||||
req.app.locals.imageOutputType
|
||||
}`;
|
||||
}
|
||||
|
||||
const filepath = await saveBuffer({ userId: req.user.id, fileName: filename, buffer });
|
||||
const fileName = `${file_id}-${filename}`;
|
||||
const filepath = await saveBuffer({ userId: req.user.id, fileName, buffer });
|
||||
return await createFile(
|
||||
{
|
||||
user: req.user.id,
|
||||
@@ -801,8 +801,7 @@ async function saveBase64Image(
|
||||
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' },
|
||||
) {
|
||||
const file_id = _file_id ?? v4();
|
||||
|
||||
let filename = _filename;
|
||||
let filename = `${file_id}-${_filename}`;
|
||||
const { buffer: inputBuffer, type } = base64ToBuffer(url);
|
||||
if (!path.extname(_filename)) {
|
||||
const extension = mime.getExtension(type);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const axios = require('axios');
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider');
|
||||
const { inputSchema, logAxiosError, extractBaseURL, processModelData } = require('~/utils');
|
||||
@@ -57,7 +58,7 @@ const fetchModels = async ({
|
||||
return models;
|
||||
}
|
||||
|
||||
if (name && name.toLowerCase().startsWith('ollama')) {
|
||||
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
|
||||
return await OllamaClient.fetchModels(baseURL);
|
||||
}
|
||||
|
||||
|
||||
238
api/server/services/twoFactorService.js
Normal file
238
api/server/services/twoFactorService.js
Normal file
@@ -0,0 +1,238 @@
|
||||
const { sign } = require('jsonwebtoken');
|
||||
const { webcrypto } = require('node:crypto');
|
||||
const { hashBackupCode, decryptV2 } = require('~/server/utils/crypto');
|
||||
const { updateUser } = require('~/models/userMethods');
|
||||
|
||||
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* Encodes a Buffer into a Base32 string using the RFC 4648 alphabet.
|
||||
*
|
||||
* @param {Buffer} buffer - The buffer to encode.
|
||||
* @returns {string} The Base32 encoded string.
|
||||
*/
|
||||
const encodeBase32 = (buffer) => {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = '';
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) {
|
||||
output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes a Base32-encoded string back into a Buffer.
|
||||
*
|
||||
* @param {string} base32Str - The Base32-encoded string.
|
||||
* @returns {Buffer} The decoded buffer.
|
||||
*/
|
||||
const decodeBase32 = (base32Str) => {
|
||||
const cleaned = base32Str.replace(/=+$/, '').toUpperCase();
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const output = [];
|
||||
for (const char of cleaned) {
|
||||
const idx = BASE32_ALPHABET.indexOf(char);
|
||||
if (idx === -1) {
|
||||
continue;
|
||||
}
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
output.push((value >>> (bits - 8)) & 0xff);
|
||||
bits -= 8;
|
||||
}
|
||||
}
|
||||
return Buffer.from(output);
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a temporary token for 2FA verification.
|
||||
* The token is signed with the JWT_SECRET and expires in 5 minutes.
|
||||
*
|
||||
* @param {string} userId - The unique identifier of the user.
|
||||
* @returns {string} The signed JWT token.
|
||||
*/
|
||||
const generate2FATempToken = (userId) =>
|
||||
sign({ userId, twoFAPending: true }, process.env.JWT_SECRET, { expiresIn: '5m' });
|
||||
|
||||
/**
|
||||
* Generates a TOTP secret.
|
||||
* Creates 10 random bytes using WebCrypto and encodes them into a Base32 string.
|
||||
*
|
||||
* @returns {string} A Base32-encoded secret for TOTP.
|
||||
*/
|
||||
const generateTOTPSecret = () => {
|
||||
const randomArray = new Uint8Array(10);
|
||||
webcrypto.getRandomValues(randomArray);
|
||||
return encodeBase32(Buffer.from(randomArray));
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a Time-based One-Time Password (TOTP) based on the provided secret and time.
|
||||
* This implementation uses a 30-second time step and produces a 6-digit code.
|
||||
*
|
||||
* @param {string} secret - The Base32-encoded TOTP secret.
|
||||
* @param {number} [forTime=Date.now()] - The time (in milliseconds) for which to generate the TOTP.
|
||||
* @returns {Promise<string>} A promise that resolves to the 6-digit TOTP code.
|
||||
*/
|
||||
const generateTOTP = async (secret, forTime = Date.now()) => {
|
||||
const timeStep = 30; // seconds
|
||||
const counter = Math.floor(forTime / 1000 / timeStep);
|
||||
const counterBuffer = new ArrayBuffer(8);
|
||||
const counterView = new DataView(counterBuffer);
|
||||
// Write counter into the last 4 bytes (big-endian)
|
||||
counterView.setUint32(4, counter, false);
|
||||
|
||||
// Decode the secret into an ArrayBuffer
|
||||
const keyBuffer = decodeBase32(secret);
|
||||
const keyArrayBuffer = keyBuffer.buffer.slice(
|
||||
keyBuffer.byteOffset,
|
||||
keyBuffer.byteOffset + keyBuffer.byteLength,
|
||||
);
|
||||
|
||||
// Import the key for HMAC-SHA1 signing
|
||||
const cryptoKey = await webcrypto.subtle.importKey(
|
||||
'raw',
|
||||
keyArrayBuffer,
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign'],
|
||||
);
|
||||
|
||||
// Generate HMAC signature
|
||||
const signatureBuffer = await webcrypto.subtle.sign('HMAC', cryptoKey, counterBuffer);
|
||||
const hmac = new Uint8Array(signatureBuffer);
|
||||
|
||||
// Dynamic truncation as per RFC 4226
|
||||
const offset = hmac[hmac.length - 1] & 0xf;
|
||||
const slice = hmac.slice(offset, offset + 4);
|
||||
const view = new DataView(slice.buffer, slice.byteOffset, slice.byteLength);
|
||||
const binaryCode = view.getUint32(0, false) & 0x7fffffff;
|
||||
const code = (binaryCode % 1000000).toString().padStart(6, '0');
|
||||
return code;
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a provided TOTP token against the secret.
|
||||
* It allows for a ±1 time-step window to account for slight clock discrepancies.
|
||||
*
|
||||
* @param {string} secret - The Base32-encoded TOTP secret.
|
||||
* @param {string} token - The TOTP token provided by the user.
|
||||
* @returns {Promise<boolean>} A promise that resolves to true if the token is valid; otherwise, false.
|
||||
*/
|
||||
const verifyTOTP = async (secret, token) => {
|
||||
const timeStepMS = 30 * 1000;
|
||||
const currentTime = Date.now();
|
||||
for (let offset = -1; offset <= 1; offset++) {
|
||||
const expected = await generateTOTP(secret, currentTime + offset * timeStepMS);
|
||||
if (expected === token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates backup codes for two-factor authentication.
|
||||
* Each backup code is an 8-character hexadecimal string along with its SHA-256 hash.
|
||||
* The plain codes are returned for one-time download, while the hashed objects are meant for secure storage.
|
||||
*
|
||||
* @param {number} [count=10] - The number of backup codes to generate.
|
||||
* @returns {Promise<{ plainCodes: string[], codeObjects: Array<{ codeHash: string, used: boolean, usedAt: Date | null }> }>}
|
||||
* A promise that resolves to an object containing both plain backup codes and their corresponding code objects.
|
||||
*/
|
||||
const generateBackupCodes = async (count = 10) => {
|
||||
const plainCodes = [];
|
||||
const codeObjects = [];
|
||||
const encoder = new TextEncoder();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const randomArray = new Uint8Array(4);
|
||||
webcrypto.getRandomValues(randomArray);
|
||||
const code = Array.from(randomArray)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join(''); // 8-character hex code
|
||||
plainCodes.push(code);
|
||||
|
||||
// Compute SHA-256 hash of the code using WebCrypto
|
||||
const codeBuffer = encoder.encode(code);
|
||||
const hashBuffer = await webcrypto.subtle.digest('SHA-256', codeBuffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const codeHash = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
codeObjects.push({ codeHash, used: false, usedAt: null });
|
||||
}
|
||||
return { plainCodes, codeObjects };
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a backup code for a user and updates its status as used if valid.
|
||||
*
|
||||
* @param {Object} params - The parameters object.
|
||||
* @param {TUser | undefined} [params.user] - The user object containing backup codes.
|
||||
* @param {string | undefined} [params.backupCode] - The backup code to verify.
|
||||
* @returns {Promise<boolean>} A promise that resolves to true if the backup code is valid and updated; otherwise, false.
|
||||
*/
|
||||
const verifyBackupCode = async ({ user, backupCode }) => {
|
||||
if (!backupCode || !user || !Array.isArray(user.backupCodes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hashedInput = await hashBackupCode(backupCode.trim());
|
||||
const matchingCode = user.backupCodes.find(
|
||||
(codeObj) => codeObj.codeHash === hashedInput && !codeObj.used,
|
||||
);
|
||||
|
||||
if (matchingCode) {
|
||||
const updatedBackupCodes = user.backupCodes.map((codeObj) =>
|
||||
codeObj.codeHash === hashedInput && !codeObj.used
|
||||
? { ...codeObj, used: true, usedAt: new Date() }
|
||||
: codeObj,
|
||||
);
|
||||
|
||||
await updateUser(user._id, { backupCodes: updatedBackupCodes });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves and, if necessary, decrypts a stored TOTP secret.
|
||||
* If the secret contains a colon, it is assumed to be in the format "iv:encryptedData" and will be decrypted.
|
||||
* If the secret is exactly 16 characters long, it is assumed to be a legacy plain secret.
|
||||
*
|
||||
* @param {string|null} storedSecret - The stored TOTP secret (which may be encrypted).
|
||||
* @returns {Promise<string|null>} A promise that resolves to the plain TOTP secret, or null if none is provided.
|
||||
*/
|
||||
const getTOTPSecret = async (storedSecret) => {
|
||||
if (!storedSecret) { return null; }
|
||||
// Check for a colon marker (encrypted secrets are stored as "iv:encryptedData")
|
||||
if (storedSecret.includes(':')) {
|
||||
return await decryptV2(storedSecret);
|
||||
}
|
||||
// If it's exactly 16 characters, assume it's already plain (legacy secret)
|
||||
if (storedSecret.length === 16) {
|
||||
return storedSecret;
|
||||
}
|
||||
// Fallback in case it doesn't meet our criteria.
|
||||
return storedSecret;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
verifyTOTP,
|
||||
generateTOTP,
|
||||
getTOTPSecret,
|
||||
verifyBackupCode,
|
||||
generateTOTPSecret,
|
||||
generateBackupCodes,
|
||||
generate2FATempToken,
|
||||
};
|
||||
@@ -112,4 +112,25 @@ async function getRandomValues(length) {
|
||||
return Buffer.from(randomValues).toString('hex');
|
||||
}
|
||||
|
||||
module.exports = { encrypt, decrypt, encryptV2, decryptV2, hashToken, getRandomValues };
|
||||
/**
|
||||
* Computes SHA-256 hash for the given input using WebCrypto
|
||||
* @param {string} input
|
||||
* @returns {Promise<string>} - Hex hash string
|
||||
*/
|
||||
const hashBackupCode = async (input) => {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(input);
|
||||
const hashBuffer = await webcrypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
encryptV2,
|
||||
decryptV2,
|
||||
hashToken,
|
||||
hashBackupCode,
|
||||
getRandomValues,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ const getProfileDetails = ({ profile }) => ({
|
||||
id: profile.id,
|
||||
avatarUrl: profile.photos[0].value,
|
||||
username: profile.name.givenName,
|
||||
name: `${profile.name.givenName} ${profile.name.familyName}`,
|
||||
name: `${profile.name.givenName}${profile.name.familyName ? ` ${profile.name.familyName}` : ''}`,
|
||||
emailVerified: profile.emails[0].verified,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const jwtLogin = async () =>
|
||||
},
|
||||
async (payload, done) => {
|
||||
try {
|
||||
const user = await getUserById(payload?.id, '-password -__v');
|
||||
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
|
||||
if (user) {
|
||||
user.id = user._id.toString();
|
||||
if (!user.role) {
|
||||
|
||||
@@ -5,40 +5,32 @@ const { logger } = require('~/config');
|
||||
*
|
||||
* @param {Object} options - The options object.
|
||||
* @param {string} options.message - The custom message to be logged.
|
||||
* @param {Error} options.error - The Axios error object.
|
||||
* @param {import('axios').AxiosError} options.error - The Axios error object.
|
||||
*/
|
||||
const logAxiosError = ({ message, error }) => {
|
||||
const timedOutMessage = 'Cannot read properties of undefined (reading \'status\')';
|
||||
if (error.response) {
|
||||
logger.error(
|
||||
`${message} The request was made and the server responded with a status code that falls out of the range of 2xx: ${
|
||||
error.message ? error.message : ''
|
||||
}. Error response data:\n`,
|
||||
{
|
||||
headers: error.response?.headers,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
},
|
||||
);
|
||||
} else if (error.request) {
|
||||
logger.error(
|
||||
`${message} The request was made but no response was received: ${
|
||||
error.message ? error.message : ''
|
||||
}. Error Request:\n`,
|
||||
{
|
||||
request: error.request,
|
||||
},
|
||||
);
|
||||
} else if (error?.message?.includes(timedOutMessage)) {
|
||||
logger.error(
|
||||
`${message}\nThe request either timed out or was unsuccessful. Error message:\n`,
|
||||
error,
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
`${message}\nSomething happened in setting up the request. Error message:\n`,
|
||||
error,
|
||||
);
|
||||
try {
|
||||
if (error.response?.status) {
|
||||
const { status, headers, data } = error.response;
|
||||
logger.error(`${message} The server responded with status ${status}: ${error.message}`, {
|
||||
status,
|
||||
headers,
|
||||
data,
|
||||
});
|
||||
} else if (error.request) {
|
||||
const { method, url } = error.config || {};
|
||||
logger.error(
|
||||
`${message} No response received for ${method ? method.toUpperCase() : ''} ${url || ''}: ${error.message}`,
|
||||
{ requestInfo: { method, url } },
|
||||
);
|
||||
} else if (error?.message?.includes('Cannot read properties of undefined (reading \'status\')')) {
|
||||
logger.error(
|
||||
`${message} It appears the request timed out or was unsuccessful: ${error.message}`,
|
||||
);
|
||||
} else {
|
||||
logger.error(`${message} An error occurred while setting up the request: ${error.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error in logAxiosError: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-progress": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
@@ -64,6 +65,8 @@
|
||||
"framer-motion": "^11.5.4",
|
||||
"html-to-image": "^1.11.11",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.3",
|
||||
"input-otp": "^1.4.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"librechat-data-provider": "*",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -83,7 +86,7 @@
|
||||
"react-i18next": "^15.4.0",
|
||||
"react-lazy-load-image-component": "^1.6.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^2.1.1",
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.11.2",
|
||||
"react-speech-recognition": "^3.10.0",
|
||||
"react-textarea-autosize": "^8.4.0",
|
||||
|
||||
@@ -5,6 +5,8 @@ import SocialLoginRender from './SocialLoginRender';
|
||||
import { ThemeSelector } from '~/components/ui';
|
||||
import { Banner } from '../Banners';
|
||||
import Footer from './Footer';
|
||||
import { useState } from 'react';
|
||||
import PasskeyAuth from '~/components/Auth/PasskeyAuth';
|
||||
|
||||
const ErrorRender = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="mt-16 flex justify-center">
|
||||
@@ -57,6 +59,12 @@ function AuthLayout({
|
||||
return null;
|
||||
};
|
||||
|
||||
// Determine the mode from the URL: if the pathname contains "register" then mode is "register", else "login"
|
||||
const mode = pathname.includes('register') ? 'register' : 'login';
|
||||
|
||||
// Local state to toggle between the default form (children) and the passkey view.
|
||||
const [showPasskey, setShowPasskey] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col bg-white dark:bg-gray-900">
|
||||
<Banner />
|
||||
@@ -84,9 +92,19 @@ function AuthLayout({
|
||||
{header}
|
||||
</h1>
|
||||
)}
|
||||
{children}
|
||||
{(pathname.includes('login') || pathname.includes('register')) && (
|
||||
<SocialLoginRender startupConfig={startupConfig} />
|
||||
{showPasskey ? (
|
||||
<PasskeyAuth mode={mode} onBack={() => setShowPasskey(false)} />
|
||||
) : (
|
||||
<>
|
||||
{children}
|
||||
{!pathname.includes('2fa') && (pathname.includes('login') || pathname.includes('register')) && (
|
||||
<SocialLoginRender
|
||||
startupConfig={startupConfig}
|
||||
mode={mode}
|
||||
onPasskeyClick={() => setShowPasskey(true)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -166,9 +166,7 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
type="submit"
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
|
||||
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
|
||||
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
|
||||
transition-colors hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
|
||||
283
client/src/components/Auth/PasskeyAuth.tsx
Normal file
283
client/src/components/Auth/PasskeyAuth.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TranslationKeys, useLocalize } from '~/hooks';
|
||||
|
||||
type PasskeyAuthProps = {
|
||||
mode: 'login' | 'register';
|
||||
onBack?: () => void;
|
||||
};
|
||||
|
||||
const PasskeyAuth: React.FC<PasskeyAuthProps> = ({ mode, onBack }) => {
|
||||
const localize = useLocalize();
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Utility for showing errors using localized keys
|
||||
const alertError = (key: TranslationKeys, error: any) => {
|
||||
console.error(`${localize(key)} error:`, error);
|
||||
alert(
|
||||
`${localize(key)}: ${error.message}. ${localize('com_auth_passkey_try_again')}`
|
||||
);
|
||||
};
|
||||
|
||||
// Convert login challenge options from the server
|
||||
const processLoginOptions = (options: any) => {
|
||||
options.challenge = base64URLToArrayBuffer(options.challenge);
|
||||
if (options.allowCredentials) {
|
||||
options.allowCredentials = options.allowCredentials.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64URLToArrayBuffer(cred.id),
|
||||
}));
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
// Convert registration challenge options from the server
|
||||
const processRegistrationOptions = (options: any) => {
|
||||
options.challenge = base64URLToArrayBuffer(options.challenge);
|
||||
options.user.id = base64URLToArrayBuffer(options.user.id);
|
||||
if (options.excludeCredentials) {
|
||||
options.excludeCredentials = options.excludeCredentials.map((cred: any) => ({
|
||||
...cred,
|
||||
id: base64URLToArrayBuffer(cred.id),
|
||||
}));
|
||||
}
|
||||
return options;
|
||||
};
|
||||
|
||||
// Format the authentication response from navigator.credentials.get()
|
||||
const getAuthenticationResponse = (credential: PublicKeyCredential) => ({
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
authenticatorData: arrayBufferToBase64URL(
|
||||
(credential.response as any).authenticatorData
|
||||
),
|
||||
clientDataJSON: arrayBufferToBase64URL(
|
||||
(credential.response as any).clientDataJSON
|
||||
),
|
||||
signature: arrayBufferToBase64URL(
|
||||
(credential.response as any).signature
|
||||
),
|
||||
userHandle: (credential.response as any).userHandle
|
||||
? arrayBufferToBase64URL((credential.response as any).userHandle)
|
||||
: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Format the registration response from navigator.credentials.create()
|
||||
const getRegistrationResponse = (credential: PublicKeyCredential) => ({
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64URL(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64URL(
|
||||
(credential.response as any).clientDataJSON
|
||||
),
|
||||
attestationObject: arrayBufferToBase64URL(
|
||||
(credential.response as any).attestationObject
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// --- PASSKEY LOGIN FLOW ---
|
||||
async function handlePasskeyLogin() {
|
||||
if (!email) {
|
||||
// (You may wish to replace this literal with a localized string if available.)
|
||||
return alert('Email is required for login.');
|
||||
}
|
||||
if (typeof PublicKeyCredential === 'undefined') {
|
||||
alert(localize('com_auth_passkey_not_supported'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const challengeResponse = await fetch(
|
||||
`/webauthn/login?email=${encodeURIComponent(email)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (!challengeResponse.ok) {
|
||||
const errorData = await challengeResponse.json();
|
||||
throw new Error(
|
||||
errorData.error || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
let options = await challengeResponse.json();
|
||||
options = processLoginOptions(options);
|
||||
|
||||
const credential = (await navigator.credentials.get({
|
||||
publicKey: options,
|
||||
})) as PublicKeyCredential;
|
||||
if (!credential) {
|
||||
throw new Error(localize('com_auth_passkey_no_credentials'));
|
||||
}
|
||||
|
||||
const authenticationResponse = getAuthenticationResponse(credential);
|
||||
const loginCallbackResponse = await fetch('/webauthn/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, credential: authenticationResponse }),
|
||||
});
|
||||
const result = await loginCallbackResponse.json();
|
||||
if (result.user) {
|
||||
// alert(localize('com_auth_passkey_login_success'));
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
throw new Error(
|
||||
result.error || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alertError('com_auth_passkey_failed', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// --- PASSKEY REGISTRATION FLOW ---
|
||||
async function handlePasskeyRegister() {
|
||||
if (!email) {
|
||||
// (You may wish to replace this literal with a localized string if available.)
|
||||
return alert('Email is required for registration.');
|
||||
}
|
||||
if (typeof PublicKeyCredential === 'undefined') {
|
||||
alert(localize('com_auth_passkey_not_supported'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const challengeResponse = await fetch(
|
||||
`/webauthn/register?email=${encodeURIComponent(email)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
if (!challengeResponse.ok) {
|
||||
const errorData = await challengeResponse.json();
|
||||
throw new Error(
|
||||
errorData.error || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
let options = await challengeResponse.json();
|
||||
options = processRegistrationOptions(options);
|
||||
|
||||
const credential = (await navigator.credentials.create({
|
||||
publicKey: options,
|
||||
})) as PublicKeyCredential;
|
||||
if (!credential) {
|
||||
throw new Error(localize('com_auth_passkey_create_error'));
|
||||
}
|
||||
|
||||
const registrationResponse = getRegistrationResponse(credential);
|
||||
const registerCallbackResponse = await fetch('/webauthn/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, credential: registrationResponse }),
|
||||
});
|
||||
const result = await registerCallbackResponse.json();
|
||||
if (result.user) {
|
||||
// alert(localize('com_auth_passkey_register_success'));
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
throw new Error(
|
||||
result.error || localize('com_auth_passkey_error')
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alertError('com_auth_passkey_registration_failed', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (mode === 'login') {
|
||||
await handlePasskeyLogin();
|
||||
} else {
|
||||
await handlePasskeyRegister();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
id="passkey-email"
|
||||
autoComplete="email"
|
||||
aria-label={localize('com_auth_email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
placeholder=" "
|
||||
/>
|
||||
<label
|
||||
htmlFor="passkey-email"
|
||||
className="
|
||||
absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200
|
||||
peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100
|
||||
peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500
|
||||
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
|
||||
"
|
||||
>
|
||||
{localize('com_auth_email_address')}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{loading
|
||||
? localize('com_auth_loading')
|
||||
: localize(
|
||||
mode === 'login'
|
||||
? 'com_auth_passkey_login_success'
|
||||
: 'com_auth_passkey_register_success'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{onBack && (
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="text-sm font-medium text-blue-600 hover:underline"
|
||||
>
|
||||
{localize(
|
||||
mode === 'login'
|
||||
? 'com_auth_back_to_login'
|
||||
: 'com_auth_back_to_register',
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasskeyAuth;
|
||||
|
||||
// Utility functions for base64url conversion
|
||||
function base64URLToArrayBuffer(base64url: string): ArrayBuffer {
|
||||
const padding = '='.repeat((4 - (base64url.length % 4)) % 4);
|
||||
const base64 = (base64url + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(base64);
|
||||
return Uint8Array.from(binary, (c) => c.charCodeAt(0)).buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64URL(buffer: ArrayBuffer): string {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
@@ -1,22 +1,36 @@
|
||||
import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components';
|
||||
|
||||
import {
|
||||
GoogleIcon,
|
||||
FacebookIcon,
|
||||
OpenIDIcon,
|
||||
GithubIcon,
|
||||
DiscordIcon,
|
||||
AppleIcon,
|
||||
PasskeyIcon,
|
||||
} from '~/components';
|
||||
import SocialButton from './SocialButton';
|
||||
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
import { TStartupConfig } from 'librechat-data-provider';
|
||||
import React from 'react';
|
||||
|
||||
function SocialLoginRender({
|
||||
startupConfig,
|
||||
}: {
|
||||
type SocialLoginRenderProps = {
|
||||
startupConfig: TStartupConfig | null | undefined;
|
||||
}) {
|
||||
mode: 'login' | 'register';
|
||||
onPasskeyClick?: () => void;
|
||||
};
|
||||
|
||||
function SocialLoginRender({ startupConfig, mode, onPasskeyClick }: SocialLoginRenderProps) {
|
||||
const localize = useLocalize();
|
||||
|
||||
if (!startupConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Compute the passkey label based on mode.
|
||||
const passkeyLabel =
|
||||
mode === 'register'
|
||||
? localize('com_auth_passkey_register')
|
||||
: localize('com_auth_passkey_login');
|
||||
|
||||
const providerComponents = {
|
||||
discord: startupConfig.discordLoginEnabled && (
|
||||
<SocialButton
|
||||
@@ -107,10 +121,25 @@ function SocialLoginRender({
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{startupConfig.socialLogins?.map((provider) => providerComponents[provider] || null)}
|
||||
{startupConfig.passkeyLoginEnabled && (
|
||||
|
||||
<div className="mt-2 flex gap-x-2">
|
||||
<button
|
||||
aria-label={passkeyLabel}
|
||||
className="flex w-full items-center space-x-3 rounded-2xl border border-border-light bg-surface-primary px-5 py-3 text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
|
||||
data-testid="passkey"
|
||||
type="button"
|
||||
onClick={onPasskeyClick}
|
||||
>
|
||||
<PasskeyIcon />
|
||||
<p>{passkeyLabel}</p>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default SocialLoginRender;
|
||||
export default SocialLoginRender;
|
||||
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
176
client/src/components/Auth/TwoFactorScreen.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot, Label } from '~/components';
|
||||
import { useVerifyTwoFactorTempMutation } from '~/data-provider';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface VerifyPayload {
|
||||
tempToken: string;
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
}
|
||||
|
||||
type TwoFactorFormInputs = {
|
||||
token?: string;
|
||||
backupCode?: string;
|
||||
};
|
||||
|
||||
const TwoFactorScreen: React.FC = React.memo(() => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const tempTokenRaw = searchParams.get('tempToken');
|
||||
const tempToken = tempTokenRaw !== null && tempTokenRaw !== '' ? tempTokenRaw : '';
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<TwoFactorFormInputs>();
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const [useBackup, setUseBackup] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { mutate: verifyTempMutate } = useVerifyTwoFactorTempMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.token != null && result.token !== '') {
|
||||
window.location.href = '/';
|
||||
}
|
||||
},
|
||||
onMutate: () => {
|
||||
setIsLoading(true);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
setIsLoading(false);
|
||||
const err = error as { response?: { data?: { message?: unknown } } };
|
||||
const errorMsg =
|
||||
typeof err.response?.data?.message === 'string'
|
||||
? err.response.data.message
|
||||
: 'Error verifying 2FA';
|
||||
showToast({ message: errorMsg, status: 'error' });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(data: TwoFactorFormInputs) => {
|
||||
const payload: VerifyPayload = { tempToken };
|
||||
if (useBackup && data.backupCode != null && data.backupCode !== '') {
|
||||
payload.backupCode = data.backupCode;
|
||||
} else if (data.token != null && data.token !== '') {
|
||||
payload.token = data.token;
|
||||
}
|
||||
verifyTempMutate(payload);
|
||||
},
|
||||
[tempToken, useBackup, verifyTempMutate],
|
||||
);
|
||||
|
||||
const toggleBackupOn = useCallback(() => {
|
||||
setUseBackup(true);
|
||||
}, []);
|
||||
|
||||
const toggleBackupOff = useCallback(() => {
|
||||
setUseBackup(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Label className="flex justify-center break-keep text-center text-sm text-text-primary">
|
||||
{localize('com_auth_two_factor')}
|
||||
</Label>
|
||||
{!useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="token"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.token && <span className="text-sm text-red-500">{errors.token.message}</span>}
|
||||
</div>
|
||||
)}
|
||||
{useBackup && (
|
||||
<div className="my-4 flex justify-center text-text-primary">
|
||||
<Controller
|
||||
name="backupCode"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<InputOTP
|
||||
maxLength={8}
|
||||
value={value != null ? value : ''}
|
||||
onChange={onChange}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
)}
|
||||
/>
|
||||
{errors.backupCode && (
|
||||
<span className="text-sm text-red-500">{errors.backupCode.message}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="submit"
|
||||
aria-label={localize('com_auth_continue')}
|
||||
data-testid="login-button"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:opacity-80 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{isLoading ? localize('com_auth_email_verifying_ellipsis') : localize('com_ui_verify')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center">
|
||||
{!useBackup ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOn}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleBackupOff}
|
||||
className="inline-flex p-1 text-sm font-medium text-green-600 transition-colors hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
|
||||
>
|
||||
{localize('com_ui_use_2fa_code')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default TwoFactorScreen;
|
||||
@@ -4,3 +4,4 @@ export { default as ResetPassword } from './ResetPassword';
|
||||
export { default as VerifyEmail } from './VerifyEmail';
|
||||
export { default as ApiErrorWatcher } from './ApiErrorWatcher';
|
||||
export { default as RequestPasswordReset } from './RequestPasswordReset';
|
||||
export { default as TwoFactorScreen } from './TwoFactorScreen';
|
||||
|
||||
@@ -55,7 +55,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
|
||||
let statusText: string;
|
||||
if (!status) {
|
||||
statusText = text ?? localize('com_endpoint_import');
|
||||
statusText = text ?? localize('com_ui_import');
|
||||
} else if (status === 'success') {
|
||||
statusText = successText ?? localize('com_ui_upload_success');
|
||||
} else {
|
||||
@@ -72,12 +72,12 @@ const FileUpload: React.FC<FileUploadProps> = ({
|
||||
)}
|
||||
>
|
||||
<FileUp className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
<span className="flex text-xs ">{statusText}</span>
|
||||
<span className="flex text-xs">{statusText}</span>
|
||||
<input
|
||||
id={`file-upload-${id}`}
|
||||
value=""
|
||||
type="file"
|
||||
className={cn('hidden ', className)}
|
||||
className={cn('hidden', className)}
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { tConvoUpdateSchema, EModelEndpoint, isParamEndpoint } from 'librechat-data-provider';
|
||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||
import {
|
||||
EModelEndpoint,
|
||||
isParamEndpoint,
|
||||
isAgentsEndpoint,
|
||||
tConvoUpdateSchema,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
|
||||
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
|
||||
import { PluginStoreDialog, TooltipAnchor } from '~/components';
|
||||
@@ -42,7 +47,6 @@ export default function HeaderOptions({
|
||||
if (endpoint && noSettings[endpoint]) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, noSettings]);
|
||||
|
||||
const saveAsPreset = () => {
|
||||
@@ -67,7 +71,7 @@ export default function HeaderOptions({
|
||||
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
|
||||
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
||||
<div className="z-[61] flex w-full items-center justify-center gap-2">
|
||||
{interfaceConfig?.modelSelect === true && (
|
||||
{interfaceConfig?.modelSelect === true && !isAgentsEndpoint(endpoint) && (
|
||||
<ModelSelect
|
||||
conversation={conversation}
|
||||
setOption={setOption}
|
||||
|
||||
@@ -80,7 +80,7 @@ function AccountSettings() {
|
||||
!isNaN(parseFloat(balanceQuery.data)) && (
|
||||
<>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">
|
||||
{localize('com_nav_balance')}: ${parseFloat(balanceQuery.data).toFixed(2)}
|
||||
{localize('com_nav_balance')}: {parseFloat(balanceQuery.data).toFixed(2)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
|
||||
@@ -2,18 +2,39 @@ import React from 'react';
|
||||
import DisplayUsernameMessages from './DisplayUsernameMessages';
|
||||
import DeleteAccount from './DeleteAccount';
|
||||
import Avatar from './Avatar';
|
||||
import PassKeys from './PassKeys';
|
||||
import EnableTwoFactorItem from './TwoFactorAuthentication';
|
||||
import BackupCodesItem from './BackupCodesItem';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
|
||||
function Account() {
|
||||
const user = useAuthContext();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-1 text-sm text-text-primary">
|
||||
<div className="pb-3">
|
||||
<DisplayUsernameMessages />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<Avatar />
|
||||
</div>
|
||||
{user?.user?.provider === 'local' && (
|
||||
<>
|
||||
<div className="pb-3">
|
||||
<EnableTwoFactorItem />
|
||||
</div>
|
||||
{Array.isArray(user.user?.backupCodes) && user.user?.backupCodes.length > 0 && (
|
||||
<div className="pb-3">
|
||||
<BackupCodesItem />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="pb-3">
|
||||
<DeleteAccount />
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<DisplayUsernameMessages />
|
||||
<PassKeys />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,7 +47,7 @@ function Avatar() {
|
||||
const { mutate: uploadAvatar, isLoading: isUploading } = useUploadAvatarMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
setUser((prev) => ({ ...prev, avatar: data.url } as TUser));
|
||||
setUser((prev) => ({ ...prev, avatar: data.url }) as TUser);
|
||||
openButtonRef.current?.click();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -133,9 +133,11 @@ function Avatar() {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{localize('com_nav_profile_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef} className="btn btn-neutral relative">
|
||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
<OGDialogTrigger ref={openButtonRef}>
|
||||
<Button variant="outline">
|
||||
<FileImage className="mr-2 flex w-[22px] items-center stroke-1" />
|
||||
<span>{localize('com_nav_change_picture')}</span>
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useState } from 'react';
|
||||
import { RefreshCcw, ShieldX } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { TBackupCode, TRegenerateBackupCodesResponse, type TUser } from 'librechat-data-provider';
|
||||
import {
|
||||
OGDialog,
|
||||
OGDialogContent,
|
||||
OGDialogTitle,
|
||||
OGDialogTrigger,
|
||||
Button,
|
||||
Label,
|
||||
Spinner,
|
||||
TooltipAnchor,
|
||||
} from '~/components';
|
||||
import { useRegenerateBackupCodesMutation } from '~/data-provider';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import store from '~/store';
|
||||
|
||||
const BackupCodesItem: React.FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const { showToast } = useToastContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
|
||||
const { mutate: regenerateBackupCodes, isLoading } = useRegenerateBackupCodesMutation();
|
||||
|
||||
const fetchBackupCodes = (auto: boolean = false) => {
|
||||
regenerateBackupCodes(undefined, {
|
||||
onSuccess: (data: TRegenerateBackupCodesResponse) => {
|
||||
const newBackupCodes: TBackupCode[] = data.backupCodesHash.map((codeHash) => ({
|
||||
codeHash,
|
||||
used: false,
|
||||
usedAt: null,
|
||||
}));
|
||||
|
||||
setUser((prev) => ({ ...prev, backupCodes: newBackupCodes }) as TUser);
|
||||
showToast({
|
||||
message: localize('com_ui_backup_codes_regenerated'),
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
// Trigger file download only when user explicitly clicks the button.
|
||||
if (!auto && newBackupCodes.length) {
|
||||
const codesString = data.backupCodes.join('\n');
|
||||
const blob = new Blob([codesString], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
},
|
||||
onError: () =>
|
||||
showToast({
|
||||
message: localize('com_ui_backup_codes_regenerate_error'),
|
||||
status: 'error',
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
fetchBackupCodes(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog open={isDialogOpen} onOpenChange={setDialogOpen}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Label className="font-light">{localize('com_ui_backup_codes')}</Label>
|
||||
</div>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button aria-label="Show Backup Codes" variant="outline">
|
||||
{localize('com_ui_show')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-lg">
|
||||
<OGDialogTitle className="mb-6 text-2xl font-semibold">
|
||||
{localize('com_ui_backup_codes')}
|
||||
</OGDialogTitle>
|
||||
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="mt-4"
|
||||
>
|
||||
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{user?.backupCodes.map((code, index) => {
|
||||
const isUsed = code.used;
|
||||
const description = `Backup code number ${index + 1}, ${
|
||||
isUsed
|
||||
? `used on ${code.usedAt ? new Date(code.usedAt).toLocaleDateString() : 'an unknown date'}`
|
||||
: 'not used yet'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={code.codeHash}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={description}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
onFocus={() => {
|
||||
const announcement = new CustomEvent('announce', {
|
||||
detail: { message: description },
|
||||
});
|
||||
document.dispatchEvent(announcement);
|
||||
}}
|
||||
className={`flex flex-col rounded-xl border p-4 backdrop-blur-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary ${
|
||||
isUsed
|
||||
? 'border-red-200 bg-red-50/80 dark:border-red-800 dark:bg-red-900/20'
|
||||
: 'border-green-200 bg-green-50/80 dark:border-green-800 dark:bg-green-900/20'
|
||||
} `}
|
||||
>
|
||||
<div className="flex items-center justify-between" aria-hidden="true">
|
||||
<span className="text-sm font-medium text-text-secondary">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<TooltipAnchor
|
||||
description={
|
||||
code.usedAt ? new Date(code.usedAt).toLocaleDateString() : ''
|
||||
}
|
||||
disabled={!isUsed}
|
||||
focusable={false}
|
||||
className={isUsed ? 'cursor-pointer' : 'cursor-default'}
|
||||
render={
|
||||
<span
|
||||
className={`rounded-full px-3 py-1 text-sm font-medium ${
|
||||
isUsed
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||
}`}
|
||||
>
|
||||
{isUsed ? localize('com_ui_used') : localize('com_ui_not_used')}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-12 flex justify-center">
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spinner className="mr-2" />
|
||||
) : (
|
||||
<RefreshCcw className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{isLoading
|
||||
? localize('com_ui_regenerating')
|
||||
: localize('com_ui_regenerate_backup')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<ShieldX className="h-12 w-12 text-text-primary" />
|
||||
<p className="text-lg text-text-secondary">{localize('com_ui_no_backup_codes')}</p>
|
||||
<Button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isLoading}
|
||||
variant="default"
|
||||
className="px-8 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading && <Spinner className="mr-2" />}
|
||||
{localize('com_ui_generate_backup')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(BackupCodesItem);
|
||||
@@ -57,7 +57,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
</div>
|
||||
<OGDialogContent className="w-11/12 max-w-2xl">
|
||||
<OGDialogContent className="w-11/12 max-w-md">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="text-lg font-medium leading-6">
|
||||
{localize('com_nav_delete_account_confirm')}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { LockIcon, UnlockIcon } from 'lucide-react';
|
||||
import { Label, Button } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
interface DisableTwoFactorToggleProps {
|
||||
enabled: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DisableTwoFactorToggle: React.FC<DisableTwoFactorToggleProps> = ({
|
||||
enabled,
|
||||
onChange,
|
||||
disabled,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light"> {localize('com_nav_2fa')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant={enabled ? 'destructive' : 'outline'}
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
{enabled ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_enable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
71
client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx
Normal file
71
client/src/components/Nav/SettingsTabs/Account/PassKeys.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Label } from '~/components/ui';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import type { TPasskey } from 'librechat-data-provider';
|
||||
|
||||
export default function PassKeys() {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const [isPasskeyModalOpen, setPasskeyModalOpen] = useState(false);
|
||||
|
||||
if (!user?.passkeys?.length) {
|
||||
return null; // Don't render if no passkeys
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="font-light">{localize('com_nav_passkeys')}</Label>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setPasskeyModalOpen(true)}
|
||||
className="ml-4 transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
{localize('com_nav_view_passkeys')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Passkey Modal */}
|
||||
<OGDialog open={isPasskeyModalOpen} onOpenChange={setPasskeyModalOpen}>
|
||||
<OGDialogContent className="w-11/12 max-w-lg">
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="text-lg font-medium leading-6">
|
||||
{localize('com_nav_passkeys')}
|
||||
</OGDialogTitle>
|
||||
</OGDialogHeader>
|
||||
<div className="mt-4 space-y-4">
|
||||
{user.passkeys.map((passkey: TPasskey) => (
|
||||
<div key={passkey.id} className="rounded-lg border p-3 bg-gray-50 dark:bg-gray-800">
|
||||
<p className="text-sm">
|
||||
<strong>{localize('com_nav_settings_passkey_label_id')}</strong> {passkey.id}
|
||||
</p>
|
||||
<p className="text-sm break-all">
|
||||
<strong>{localize('com_nav_settings_passkey_label_public_key')}</strong> {Buffer.from(passkey.publicKey).toString('base64')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<strong>{localize('com_nav_settings_passkey_label_usage_counter')}</strong> {passkey.counter}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<strong>{localize('com_nav_settings_passkey_label_transports')}</strong> {passkey.transports.length > 0 ? passkey.transports.join(', ') : localize('com_nav_settings_passkey_none')}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setPasskeyModalOpen(false)}
|
||||
className="transition-colors duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
>
|
||||
{localize('com_ui_close')}
|
||||
</Button>
|
||||
</div>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { SmartphoneIcon } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import type { TUser, TVerify2FARequest } from 'librechat-data-provider';
|
||||
import { OGDialog, OGDialogContent, OGDialogHeader, OGDialogTitle, Progress } from '~/components';
|
||||
import { SetupPhase, QRPhase, VerifyPhase, BackupPhase, DisablePhase } from './TwoFactorPhases';
|
||||
import { DisableTwoFactorToggle } from './DisableTwoFactorToggle';
|
||||
import { useAuthContext, useLocalize } from '~/hooks';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
import {
|
||||
useConfirmTwoFactorMutation,
|
||||
useDisableTwoFactorMutation,
|
||||
useEnableTwoFactorMutation,
|
||||
useVerifyTwoFactorMutation,
|
||||
} from '~/data-provider';
|
||||
|
||||
export type Phase = 'setup' | 'qr' | 'verify' | 'backup' | 'disable';
|
||||
|
||||
const phaseVariants = {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1, transition: { duration: 0.3, ease: 'easeOut' } },
|
||||
exit: { opacity: 0, scale: 0.95, transition: { duration: 0.3, ease: 'easeIn' } },
|
||||
};
|
||||
|
||||
const TwoFactorAuthentication: React.FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { user } = useAuthContext();
|
||||
const setUser = useSetRecoilState(store.user);
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const [secret, setSecret] = useState<string>('');
|
||||
const [otpauthUrl, setOtpauthUrl] = useState<string>('');
|
||||
const [downloaded, setDownloaded] = useState<boolean>(false);
|
||||
const [disableToken, setDisableToken] = useState<string>('');
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [isDialogOpen, setDialogOpen] = useState<boolean>(false);
|
||||
const [verificationToken, setVerificationToken] = useState<string>('');
|
||||
const [phase, setPhase] = useState<Phase>(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup');
|
||||
|
||||
const { mutate: confirm2FAMutate } = useConfirmTwoFactorMutation();
|
||||
const { mutate: enable2FAMutate, isLoading: isGenerating } = useEnableTwoFactorMutation();
|
||||
const { mutate: verify2FAMutate, isLoading: isVerifying } = useVerifyTwoFactorMutation();
|
||||
const { mutate: disable2FAMutate, isLoading: isDisabling } = useDisableTwoFactorMutation();
|
||||
|
||||
const steps = ['Setup', 'Scan QR', 'Verify', 'Backup'];
|
||||
const phasesLabel: Record<Phase, string> = {
|
||||
setup: 'Setup',
|
||||
qr: 'Scan QR',
|
||||
verify: 'Verify',
|
||||
backup: 'Backup',
|
||||
disable: '',
|
||||
};
|
||||
|
||||
const currentStep = steps.indexOf(phasesLabel[phase]);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
if (Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && otpauthUrl) {
|
||||
disable2FAMutate(undefined, {
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
}
|
||||
|
||||
setOtpauthUrl('');
|
||||
setSecret('');
|
||||
setBackupCodes([]);
|
||||
setVerificationToken('');
|
||||
setDisableToken('');
|
||||
setPhase(Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? 'disable' : 'setup');
|
||||
setDownloaded(false);
|
||||
}, [user, otpauthUrl, disable2FAMutate, localize, showToast]);
|
||||
|
||||
const handleGenerateQRCode = useCallback(() => {
|
||||
enable2FAMutate(undefined, {
|
||||
onSuccess: ({ otpauthUrl, backupCodes }) => {
|
||||
setOtpauthUrl(otpauthUrl);
|
||||
setSecret(otpauthUrl.split('secret=')[1].split('&')[0]);
|
||||
setBackupCodes(backupCodes);
|
||||
setPhase('qr');
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_generate_error'), status: 'error' }),
|
||||
});
|
||||
}, [enable2FAMutate, localize, showToast]);
|
||||
|
||||
const handleVerify = useCallback(() => {
|
||||
if (!verificationToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
verify2FAMutate(
|
||||
{ token: verificationToken },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_verified') });
|
||||
confirm2FAMutate(
|
||||
{ token: verificationToken },
|
||||
{
|
||||
onSuccess: () => setPhase('backup'),
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
},
|
||||
);
|
||||
}, [verificationToken, verify2FAMutate, confirm2FAMutate, localize, showToast]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (!backupCodes.length) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'backup-codes.txt';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setDownloaded(true);
|
||||
}, [backupCodes]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
setPhase('disable');
|
||||
showToast({ message: localize('com_ui_2fa_enabled') });
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
backupCodes: backupCodes.map((code) => ({
|
||||
code,
|
||||
codeHash: code,
|
||||
used: false,
|
||||
usedAt: null,
|
||||
})),
|
||||
}) as TUser,
|
||||
);
|
||||
}, [setUser, localize, showToast, backupCodes]);
|
||||
|
||||
const handleDisableVerify = useCallback(
|
||||
(token: string, useBackup: boolean) => {
|
||||
// Validate: if not using backup, ensure token has at least 6 digits;
|
||||
// if using backup, ensure backup code has at least 8 characters.
|
||||
if (!useBackup && token.trim().length < 6) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (useBackup && token.trim().length < 8) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: TVerify2FARequest = {};
|
||||
if (useBackup) {
|
||||
payload.backupCode = token.trim();
|
||||
} else {
|
||||
payload.token = token.trim();
|
||||
}
|
||||
|
||||
verify2FAMutate(payload, {
|
||||
onSuccess: () => {
|
||||
disable2FAMutate(undefined, {
|
||||
onSuccess: () => {
|
||||
showToast({ message: localize('com_ui_2fa_disabled') });
|
||||
setDialogOpen(false);
|
||||
setUser(
|
||||
(prev) =>
|
||||
({
|
||||
...prev,
|
||||
totpSecret: '',
|
||||
backupCodes: [],
|
||||
}) as TUser,
|
||||
);
|
||||
setPhase('setup');
|
||||
setOtpauthUrl('');
|
||||
},
|
||||
onError: () =>
|
||||
showToast({ message: localize('com_ui_2fa_disable_error'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
onError: () => showToast({ message: localize('com_ui_2fa_invalid'), status: 'error' }),
|
||||
});
|
||||
},
|
||||
[disableToken, verify2FAMutate, disable2FAMutate, showToast, localize, setUser],
|
||||
);
|
||||
|
||||
return (
|
||||
<OGDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DisableTwoFactorToggle
|
||||
enabled={Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0}
|
||||
onChange={() => setDialogOpen(true)}
|
||||
disabled={isVerifying || isDisabling || isGenerating}
|
||||
/>
|
||||
|
||||
<OGDialogContent className="w-11/12 max-w-lg p-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={phase}
|
||||
variants={phaseVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
className="space-y-6"
|
||||
>
|
||||
<OGDialogHeader>
|
||||
<OGDialogTitle className="mb-2 flex items-center gap-3 text-2xl font-bold">
|
||||
<SmartphoneIcon className="h-6 w-6 text-primary" />
|
||||
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 ? localize('com_ui_2fa_disable') : localize('com_ui_2fa_setup')}
|
||||
</OGDialogTitle>
|
||||
{Array.isArray(user?.backupCodes) && user?.backupCodes.length > 0 && phase !== 'disable' && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<Progress
|
||||
value={(steps.indexOf(phasesLabel[phase]) / (steps.length - 1)) * 100}
|
||||
className="h-2 rounded-full"
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
{steps.map((step, index) => (
|
||||
<motion.span
|
||||
key={step}
|
||||
animate={{
|
||||
color:
|
||||
currentStep >= index ? 'var(--text-primary)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
className="font-medium"
|
||||
>
|
||||
{step}
|
||||
</motion.span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</OGDialogHeader>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{phase === 'setup' && (
|
||||
<SetupPhase
|
||||
isGenerating={isGenerating}
|
||||
onGenerate={handleGenerateQRCode}
|
||||
onNext={() => setPhase('qr')}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'qr' && (
|
||||
<QRPhase
|
||||
secret={secret}
|
||||
otpauthUrl={otpauthUrl}
|
||||
onNext={() => setPhase('verify')}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'verify' && (
|
||||
<VerifyPhase
|
||||
token={verificationToken}
|
||||
onTokenChange={setVerificationToken}
|
||||
isVerifying={isVerifying}
|
||||
onNext={handleVerify}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'backup' && (
|
||||
<BackupPhase
|
||||
backupCodes={backupCodes}
|
||||
onDownload={handleDownload}
|
||||
downloaded={downloaded}
|
||||
onNext={handleConfirm}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{phase === 'disable' && (
|
||||
<DisablePhase
|
||||
onDisable={handleDisableVerify}
|
||||
isDisabling={isDisabling}
|
||||
onError={(error) => showToast({ message: error.message, status: 'error' })}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TwoFactorAuthentication);
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button, Label } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface BackupPhaseProps {
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
backupCodes: string[];
|
||||
onDownload: () => void;
|
||||
downloaded: boolean;
|
||||
}
|
||||
|
||||
export const BackupPhase: React.FC<BackupPhaseProps> = ({
|
||||
backupCodes,
|
||||
onDownload,
|
||||
downloaded,
|
||||
onNext,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<Label className="break-keep text-sm">{localize('com_ui_download_backup_tooltip')}</Label>
|
||||
<div className="grid grid-cols-2 gap-4 rounded-xl bg-surface-secondary p-6">
|
||||
{backupCodes.map((code, index) => (
|
||||
<motion.div
|
||||
key={code}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="rounded-lg bg-surface-tertiary p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="hidden text-sm text-text-secondary sm:inline">#{index + 1}</span>
|
||||
<span className="font-mono text-lg">{code}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={onDownload} className="flex-1 gap-2">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{localize('com_ui_download_backup')}</span>
|
||||
</Button>
|
||||
<Button onClick={onNext} disabled={!downloaded} className="flex-1">
|
||||
{localize('com_ui_complete_setup')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp';
|
||||
import {
|
||||
Button,
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
InputOTPSeparator,
|
||||
Spinner,
|
||||
} from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface DisablePhaseProps {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
onDisable: (token: string, useBackup: boolean) => void;
|
||||
isDisabling: boolean;
|
||||
}
|
||||
|
||||
export const DisablePhase: React.FC<DisablePhaseProps> = ({ onDisable, isDisabling }) => {
|
||||
const localize = useLocalize();
|
||||
const [token, setToken] = useState('');
|
||||
const [useBackup, setUseBackup] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={token}
|
||||
onChange={setToken}
|
||||
maxLength={useBackup ? 8 : 6}
|
||||
pattern={useBackup ? REGEXP_ONLY_DIGITS_AND_CHARS : REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
{useBackup ? (
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
<InputOTPSlot index={6} />
|
||||
<InputOTPSlot index={7} />
|
||||
</InputOTPGroup>
|
||||
) : (
|
||||
<>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</>
|
||||
)}
|
||||
</InputOTP>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => onDisable(token, useBackup)}
|
||||
disabled={isDisabling || token.length !== (useBackup ? 8 : 6)}
|
||||
className="w-full rounded-xl px-6 py-3 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isDisabling === true && <Spinner className="mr-2" />}
|
||||
{isDisabling ? localize('com_ui_disabling') : localize('com_ui_2fa_disable')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setUseBackup(!useBackup)}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{useBackup ? localize('com_ui_use_2fa_code') : localize('com_ui_use_backup_code')}
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Input, Button, Label } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface QRPhaseProps {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
onNext: () => void;
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const QRPhase: React.FC<QRPhaseProps> = ({ secret, otpauthUrl, onNext }) => {
|
||||
const localize = useLocalize();
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
await navigator.clipboard.writeText(secret);
|
||||
setIsCopying(true);
|
||||
setTimeout(() => setIsCopying(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="rounded-2xl bg-white p-4 shadow-lg"
|
||||
>
|
||||
<QRCodeSVG value={otpauthUrl} size={240} />
|
||||
</motion.div>
|
||||
<div className="w-full space-y-3">
|
||||
<Label className="text-sm font-medium text-text-secondary">
|
||||
{localize('com_ui_secret_key')}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={secret} readOnly className="font-mono text-lg tracking-wider" />
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
className={cn('h-auto shrink-0', isCopying ? 'cursor-default' : '')}
|
||||
>
|
||||
{isCopying ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={onNext} className="w-full">
|
||||
{localize('com_ui_continue')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { QrCode } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button, Spinner } from '~/components';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface SetupPhaseProps {
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
isGenerating: boolean;
|
||||
onGenerate: () => void;
|
||||
}
|
||||
|
||||
export const SetupPhase: React.FC<SetupPhaseProps> = ({ isGenerating, onGenerate, onNext }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-6">
|
||||
<div className="rounded-xl bg-surface-secondary p-6">
|
||||
<h3 className="mb-4 flex justify-center text-lg font-medium">
|
||||
{localize('com_ui_2fa_account_security')}
|
||||
</h3>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onGenerate}
|
||||
className="flex w-full"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? <Spinner className="size-5" /> : <QrCode className="size-5" />}
|
||||
{isGenerating ? localize('com_ui_generating') : localize('com_ui_generate_qrcode')}
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button, InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from '~/components';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const fadeAnimation = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: -20 },
|
||||
transition: { duration: 0.2 },
|
||||
};
|
||||
|
||||
interface VerifyPhaseProps {
|
||||
token: string;
|
||||
onTokenChange: (value: string) => void;
|
||||
isVerifying: boolean;
|
||||
onNext: () => void;
|
||||
onError: (error: Error) => void;
|
||||
}
|
||||
|
||||
export const VerifyPhase: React.FC<VerifyPhaseProps> = ({
|
||||
token,
|
||||
onTokenChange,
|
||||
isVerifying,
|
||||
onNext,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<motion.div {...fadeAnimation} className="space-y-8">
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
value={token}
|
||||
onChange={onTokenChange}
|
||||
maxLength={6}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
className="gap-2"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<InputOTPSlot key={i} index={i} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
<InputOTPSeparator />
|
||||
<InputOTPGroup>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<InputOTPSlot key={i + 3} index={i + 3} />
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<Button onClick={onNext} disabled={isVerifying || token.length !== 6} className="w-full">
|
||||
{localize('com_ui_verify')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './BackupPhase';
|
||||
export * from './QRPhase';
|
||||
export * from './VerifyPhase';
|
||||
export * from './SetupPhase';
|
||||
export * from './DisablePhase';
|
||||
@@ -82,7 +82,7 @@ function ImportConversations() {
|
||||
onClick={handleImportClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!allowImport}
|
||||
aria-label={localize('com_ui_import_conversation')}
|
||||
aria-label={localize('com_ui_import')}
|
||||
className="btn btn-neutral relative"
|
||||
>
|
||||
{allowImport ? (
|
||||
@@ -90,7 +90,7 @@ function ImportConversations() {
|
||||
) : (
|
||||
<Spinner className="mr-1 w-4" />
|
||||
)}
|
||||
<span>{localize('com_ui_import_conversation')}</span>
|
||||
<span>{localize('com_ui_import')}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -270,9 +270,7 @@ export default function SharedLinks() {
|
||||
|
||||
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
|
||||
<button className="btn btn-neutral relative">
|
||||
{localize('com_nav_shared_links_manage')}
|
||||
</button>
|
||||
<Button variant="outline">{localize('com_ui_manage')}</Button>
|
||||
</OGDialogTrigger>
|
||||
|
||||
<OGDialogContent
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function ArchivedChats() {
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<Button variant="outline" aria-label="Archived chats">
|
||||
{localize('com_nav_archived_chats_manage')}
|
||||
{localize('com_ui_manage')}
|
||||
</Button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogTemplate
|
||||
|
||||
@@ -51,15 +51,17 @@ export const LangSelector = ({
|
||||
const languageOptions = [
|
||||
{ value: 'auto', label: localize('com_nav_lang_auto') },
|
||||
{ value: 'en-US', label: localize('com_nav_lang_english') },
|
||||
{ value: 'zh-CN', label: localize('com_nav_lang_chinese') },
|
||||
{ value: 'zh-Hans', label: localize('com_nav_lang_chinese') },
|
||||
{ value: 'zh-Hant', label: localize('com_nav_lang_traditional_chinese') },
|
||||
{ value: 'ar-EG', label: localize('com_nav_lang_arabic') },
|
||||
{ value: 'de-DE', label: localize('com_nav_lang_german') },
|
||||
{ value: 'es-ES', label: localize('com_nav_lang_spanish') },
|
||||
{ value: 'et-EE', label: localize('com_nav_lang_estonian') },
|
||||
{ value: 'fr-FR', label: localize('com_nav_lang_french') },
|
||||
{ value: 'it-IT', label: localize('com_nav_lang_italian') },
|
||||
{ value: 'pl-PL', label: localize('com_nav_lang_polish') },
|
||||
{ value: 'pt-BR', label: localize('com_nav_lang_brazilian_portuguese') },
|
||||
{ value: 'pt-PT', label: localize('com_nav_lang_portuguese') },
|
||||
{ value: 'ru-RU', label: localize('com_nav_lang_russian') },
|
||||
{ value: 'ja-JP', label: localize('com_nav_lang_japanese') },
|
||||
{ value: 'sv-SE', label: localize('com_nav_lang_swedish') },
|
||||
|
||||
@@ -44,7 +44,7 @@ const Command = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light">
|
||||
<div className="rounded-xl border border-border-light shadow-md">
|
||||
<h3 className="flex h-10 items-center gap-1 pl-4 text-sm text-text-secondary">
|
||||
<SquareSlash className="icon-sm" aria-hidden="true" />
|
||||
<Input
|
||||
|
||||
@@ -41,7 +41,7 @@ const Description = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light">
|
||||
<div className="rounded-xl border border-border-light shadow-md">
|
||||
<h3 className="flex h-10 items-center gap-1 pl-4 text-sm text-text-secondary">
|
||||
<Info className="icon-sm" aria-hidden="true" />
|
||||
<Input
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function List({
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full bg-transparent px-3"
|
||||
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
|
||||
onClick={() => navigate('/d/prompts/new')}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden />
|
||||
|
||||
@@ -81,7 +81,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
||||
<div
|
||||
role="button"
|
||||
className={cn(
|
||||
'w-full flex-1 overflow-auto rounded-b-xl border border-border-light p-2 transition-all duration-150 sm:p-4',
|
||||
'w-full flex-1 overflow-auto rounded-b-xl border border-border-light p-2 shadow-md transition-all duration-150 sm:p-4',
|
||||
{
|
||||
'cursor-pointer bg-surface-primary hover:bg-surface-secondary active:bg-surface-tertiary':
|
||||
!isEditing,
|
||||
@@ -105,6 +105,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
|
||||
isEditing ? (
|
||||
<TextareaAutosize
|
||||
{...field}
|
||||
autoFocus
|
||||
className="w-full resize-none overflow-y-auto rounded bg-transparent text-sm text-text-primary focus:outline-none sm:text-base"
|
||||
minRows={3}
|
||||
maxRows={14}
|
||||
|
||||
@@ -237,7 +237,6 @@ const PromptForm = () => {
|
||||
payload: { name: groupName, category: value },
|
||||
})
|
||||
}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="mt-2 flex flex-row items-center justify-center gap-x-2 lg:mt-0">
|
||||
{hasShareAccess && <SharePrompt group={group} disabled={isLoadingGroup} />}
|
||||
@@ -349,7 +348,7 @@ const PromptForm = () => {
|
||||
{isLoadingPrompts ? (
|
||||
<Skeleton className="h-96" aria-live="polite" />
|
||||
) : (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="mb-2 flex h-full flex-col gap-4">
|
||||
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
|
||||
<PromptVariables promptText={promptText} />
|
||||
<Description
|
||||
|
||||
@@ -28,7 +28,7 @@ const PromptVariables = ({
|
||||
}, [promptText]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-light bg-transparent p-4 shadow-md ">
|
||||
<div className="rounded-xl border border-border-light bg-transparent p-4 shadow-md">
|
||||
<h3 className="flex items-center gap-2 py-2 text-lg font-semibold text-text-primary">
|
||||
<Variable className="icon-sm" aria-hidden="true" />
|
||||
{localize('com_ui_variables')}
|
||||
@@ -71,7 +71,7 @@ const PromptVariables = ({
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-text-primary text-sm font-medium">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{localize('com_ui_dropdown_variables')}
|
||||
</span>
|
||||
<span className="text-sm text-text-secondary">
|
||||
|
||||
@@ -74,6 +74,7 @@ export default function AgentSwitcher({ isCollapsed }: SwitcherProps) {
|
||||
ariaLabel={'agent'}
|
||||
setValue={onSelect}
|
||||
items={agentOptions}
|
||||
iconClassName="assistant-item"
|
||||
SelectIcon={
|
||||
<Icon
|
||||
isCreatedByUser={false}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Plus } from 'lucide-react';
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
|
||||
import { Controller, useWatch, useForm, FormProvider } from 'react-hook-form';
|
||||
@@ -211,34 +212,54 @@ export default function AgentPanel({
|
||||
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
|
||||
aria-label="Agent configuration form"
|
||||
>
|
||||
<div className="mt-2 flex w-full flex-wrap gap-2">
|
||||
<Controller
|
||||
name="agent"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AgentSelect
|
||||
reset={reset}
|
||||
value={field.value}
|
||||
agentQuery={agentQuery}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
selectedAgentId={current_agent_id ?? null}
|
||||
createMutation={create}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{/* Select Button */}
|
||||
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="w-full">
|
||||
<Controller
|
||||
name="agent"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AgentSelect
|
||||
reset={reset}
|
||||
value={field.value}
|
||||
agentQuery={agentQuery}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
selectedAgentId={current_agent_id ?? null}
|
||||
createMutation={create}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Create + Select Button */}
|
||||
{agent_id && (
|
||||
<Button
|
||||
variant="submit"
|
||||
disabled={!agent_id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAgent();
|
||||
}}
|
||||
aria-label="Select agent"
|
||||
>
|
||||
{localize('com_ui_select')}
|
||||
</Button>
|
||||
<div className="flex w-full gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-center"
|
||||
onClick={() => {
|
||||
reset(defaultAgentFormValues);
|
||||
setCurrentAgentId(undefined);
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{localize('com_ui_create') +
|
||||
' ' +
|
||||
localize('com_ui_new') +
|
||||
' ' +
|
||||
localize('com_ui_agent')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="submit"
|
||||
disabled={!agent_id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelectAgent();
|
||||
}}
|
||||
aria-label={localize('com_ui_select') + ' ' + localize('com_ui_agent')}
|
||||
>
|
||||
{localize('com_ui_select')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!canEditAgent && (
|
||||
|
||||
@@ -4,10 +4,16 @@ import { Skeleton } from '~/components/ui';
|
||||
export default function AgentPanelSkeleton() {
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden">
|
||||
{/* Agent Select and Button */}
|
||||
<div className="mt-1 flex w-full gap-2">
|
||||
<Skeleton className="h-[40px] w-4/5 rounded-lg" />
|
||||
<Skeleton className="h-[40px] w-1/5 rounded-lg" />
|
||||
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
{/* Agent Select Dropdown */}
|
||||
<div className="w-full">
|
||||
<Skeleton className="h-[40px] w-full rounded-md" />
|
||||
</div>
|
||||
{/* Create and Select Buttons */}
|
||||
<div className="flex w-full gap-2">
|
||||
<Skeleton className="h-[40px] w-3/4 rounded-md" /> {/* Create Button */}
|
||||
<Skeleton className="h-[40px] w-1/4 rounded-md" /> {/* Select Button */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-auto bg-white px-4 pb-8 pt-3 dark:bg-transparent">
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Plus, EarthIcon } from 'lucide-react';
|
||||
import { EarthIcon } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { AgentCapabilities, defaultAgentFormValues } from 'librechat-data-provider';
|
||||
import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-query';
|
||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||
import type { UseFormReset } from 'react-hook-form';
|
||||
import type { TAgentCapabilities, AgentForm, TAgentOption } from '~/common';
|
||||
import { cn, createDropdownSetter, createProviderOption, processAgentOption } from '~/utils';
|
||||
import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import SelectDropDown from '~/components/ui/SelectDropDown';
|
||||
import { cn, createProviderOption, processAgentOption } from '~/utils';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const keys = new Set(Object.keys(defaultAgentFormValues));
|
||||
const SELECT_ID = 'agent-builder-combobox';
|
||||
|
||||
export default function AgentSelect({
|
||||
reset,
|
||||
@@ -120,6 +121,9 @@ export default function AgentSelect({
|
||||
}
|
||||
|
||||
resetAgentForm(agent);
|
||||
setTimeout(() => {
|
||||
document.getElementById(SELECT_ID)?.focus();
|
||||
}, 5);
|
||||
},
|
||||
[agents, createMutation, setCurrentAgentId, agentQuery.data, resetAgentForm, reset],
|
||||
);
|
||||
@@ -152,51 +156,36 @@ export default function AgentSelect({
|
||||
}, [selectedAgentId, agents, onSelect]);
|
||||
|
||||
const createAgent = localize('com_ui_create') + ' ' + localize('com_ui_agent');
|
||||
const hasAgentValue = !!(typeof currentAgentValue === 'object'
|
||||
? currentAgentValue.value != null && currentAgentValue.value !== ''
|
||||
: typeof currentAgentValue !== 'undefined');
|
||||
|
||||
return (
|
||||
<SelectDropDown
|
||||
value={!hasAgentValue ? createAgent : (currentAgentValue as TAgentOption)}
|
||||
setValue={createDropdownSetter(onSelect)}
|
||||
availableValues={
|
||||
agents ?? [
|
||||
<ControlCombobox
|
||||
selectId={SELECT_ID}
|
||||
containerClassName="px-0"
|
||||
selectedValue={(currentAgentValue?.value ?? '') + ''}
|
||||
displayValue={currentAgentValue?.label ?? ''}
|
||||
selectPlaceholder={createAgent}
|
||||
iconSide="right"
|
||||
searchPlaceholder={localize('com_agents_search_name')}
|
||||
SelectIcon={currentAgentValue?.icon}
|
||||
setValue={onSelect}
|
||||
items={
|
||||
agents?.map((agent) => ({
|
||||
label: agent.name ?? '',
|
||||
value: agent.id ?? '',
|
||||
icon: agent.icon,
|
||||
})) ?? [
|
||||
{
|
||||
label: 'Loading...',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
iconSide="left"
|
||||
optionIconSide="right"
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
showOptionIcon={true}
|
||||
containerClassName="flex-grow"
|
||||
searchClassName="dark:from-gray-850"
|
||||
searchPlaceholder={localize('com_agents_search_name')}
|
||||
optionsClass="hover:bg-gray-20/50 dark:border-gray-700"
|
||||
optionsListClass="rounded-lg shadow-lg dark:bg-gray-850 dark:border-gray-700 dark:last:border"
|
||||
currentValueClass={cn(
|
||||
'text-md font-semibold text-gray-900 dark:text-white',
|
||||
hasAgentValue ? 'text-gray-500' : '',
|
||||
)}
|
||||
className={cn(
|
||||
'rounded-md dark:border-gray-700 dark:bg-gray-850',
|
||||
'z-50 flex h-[40px] w-full flex-none items-center justify-center truncate px-4 hover:cursor-pointer hover:border-green-500 focus:border-gray-400',
|
||||
)}
|
||||
renderOption={() => (
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-2 text-gray-800 dark:text-gray-100">
|
||||
<Plus className="w-[16px]" />
|
||||
</span>
|
||||
<span className={cn('ml-4 flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100')}>
|
||||
{createAgent}
|
||||
</span>
|
||||
</span>
|
||||
'z-50 flex h-[40px] w-full flex-none items-center justify-center truncate rounded-md bg-transparent font-bold',
|
||||
)}
|
||||
ariaLabel={localize('com_ui_agent')}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import React, { useMemo, useEffect } from 'react';
|
||||
import { ChevronLeft, RotateCcw } from 'lucide-react';
|
||||
import { getSettingsKeys } from 'librechat-data-provider';
|
||||
import { useFormContext, useWatch, Controller } from 'react-hook-form';
|
||||
import { getSettingsKeys, alternateName } from 'librechat-data-provider';
|
||||
import type * as t from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentModelPanelProps, StringOption } from '~/common';
|
||||
import { componentMapping } from '~/components/SidePanel/Parameters/components';
|
||||
import { agentSettings } from '~/components/SidePanel/Parameters/settings';
|
||||
import { getEndpointField, cn, cardStyle } from '~/utils';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useGetEndpointsQuery } from '~/data-provider';
|
||||
import { SelectDropDown } from '~/components/ui';
|
||||
import { getEndpointField, cn } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function Parameters({
|
||||
return value ?? '';
|
||||
}, [providerOption]);
|
||||
const models = useMemo(
|
||||
() => (provider ? modelsData[provider] ?? [] : []),
|
||||
() => (provider ? (modelsData[provider] ?? []) : []),
|
||||
[modelsData, provider],
|
||||
);
|
||||
|
||||
@@ -78,8 +78,8 @@ export default function Parameters({
|
||||
|
||||
return (
|
||||
<div className="scrollbar-gutter-stable h-full min-h-[50vh] overflow-auto pb-12 text-sm">
|
||||
<div className="model-panel relative flex flex-col items-center px-16 py-6 text-center">
|
||||
<div className="absolute left-0 top-6">
|
||||
<div className="model-panel relative flex flex-col items-center px-16 py-4 text-center">
|
||||
<div className="absolute left-0 top-4">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-neutral relative"
|
||||
@@ -99,6 +99,7 @@ export default function Parameters({
|
||||
{/* Endpoint aka Provider for Agents */}
|
||||
<div className="mb-4">
|
||||
<label
|
||||
id="provider-label"
|
||||
className="text-token-text-primary model-panel-label mb-2 block font-medium"
|
||||
htmlFor="provider"
|
||||
>
|
||||
@@ -108,38 +109,47 @@ export default function Parameters({
|
||||
name="provider"
|
||||
control={control}
|
||||
rules={{ required: true, minLength: 1 }}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<>
|
||||
<SelectDropDown
|
||||
emptyTitle={true}
|
||||
value={field.value ?? ''}
|
||||
title={localize('com_ui_provider')}
|
||||
placeholder={localize('com_ui_select_provider')}
|
||||
searchPlaceholder={localize('com_ui_select_search_provider')}
|
||||
setValue={field.onChange}
|
||||
availableValues={providers}
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'flex h-9 w-full flex-none items-center justify-center border-none px-4 hover:cursor-pointer',
|
||||
(field.value === undefined || field.value === '') &&
|
||||
'border-2 border-yellow-400',
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
const value =
|
||||
typeof field.value === 'string'
|
||||
? field.value
|
||||
: ((field.value as StringOption)?.value ?? '');
|
||||
const display =
|
||||
typeof field.value === 'string'
|
||||
? field.value
|
||||
: ((field.value as StringOption)?.label ?? '');
|
||||
|
||||
return (
|
||||
<>
|
||||
<ControlCombobox
|
||||
selectedValue={value}
|
||||
displayValue={alternateName[display] ?? display}
|
||||
selectPlaceholder={localize('com_ui_select_provider')}
|
||||
searchPlaceholder={localize('com_ui_select_search_provider')}
|
||||
setValue={field.onChange}
|
||||
items={providers.map((provider) => ({
|
||||
label: typeof provider === 'string' ? provider : provider.label,
|
||||
value: typeof provider === 'string' ? provider : provider.value,
|
||||
}))}
|
||||
className={cn(error ? 'border-2 border-red-500' : '')}
|
||||
ariaLabel={localize('com_ui_provider')}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
{error && (
|
||||
<span className="model-panel-error text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
containerClassName={cn('rounded-md', error ? 'border-red-500 border-2' : '')}
|
||||
/>
|
||||
{error && (
|
||||
<span className="model-panel-error text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Model */}
|
||||
<div className="model-panel-section mb-4">
|
||||
<label
|
||||
id="model-label"
|
||||
className={cn(
|
||||
'text-token-text-primary model-panel-label mb-2 block font-medium',
|
||||
!provider && 'text-gray-500 dark:text-gray-400',
|
||||
@@ -152,35 +162,36 @@ export default function Parameters({
|
||||
name="model"
|
||||
control={control}
|
||||
rules={{ required: true, minLength: 1 }}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<>
|
||||
<SelectDropDown
|
||||
emptyTitle={true}
|
||||
placeholder={
|
||||
provider
|
||||
? localize('com_ui_select_model')
|
||||
: localize('com_ui_select_provider_first')
|
||||
}
|
||||
value={field.value}
|
||||
setValue={field.onChange}
|
||||
availableValues={models}
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
disabled={!provider}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'flex h-[40px] w-full flex-none items-center justify-center border-none px-4',
|
||||
!provider ? 'cursor-not-allowed bg-gray-200' : 'hover:cursor-pointer',
|
||||
render={({ field, fieldState: { error } }) => {
|
||||
return (
|
||||
<>
|
||||
<ControlCombobox
|
||||
selectedValue={field.value || ''}
|
||||
selectPlaceholder={
|
||||
provider
|
||||
? localize('com_ui_select_model')
|
||||
: localize('com_ui_select_provider_first')
|
||||
}
|
||||
searchPlaceholder={localize('com_ui_select_model')}
|
||||
setValue={field.onChange}
|
||||
items={models.map((model) => ({
|
||||
label: model,
|
||||
value: model,
|
||||
}))}
|
||||
disabled={!provider}
|
||||
className={cn('disabled:opacity-50', error ? 'border-2 border-red-500' : '')}
|
||||
ariaLabel={localize('com_ui_model')}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
{provider && error && (
|
||||
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
containerClassName={cn('rounded-md', error ? 'border-red-500 border-2' : '')}
|
||||
/>
|
||||
{provider && error && (
|
||||
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{localize('com_ui_field_required')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +199,6 @@ export default function Parameters({
|
||||
{parameters && (
|
||||
<div className="h-auto max-w-full overflow-x-hidden p-2">
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{' '}
|
||||
{/* This is the parent element containing all settings */}
|
||||
{/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */}
|
||||
{parameters.map((setting) => {
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
|
||||
ariaLabel={'assistant'}
|
||||
setValue={onSelect}
|
||||
items={assistantOptions}
|
||||
iconClassName="assistant-item"
|
||||
SelectIcon={
|
||||
<Icon
|
||||
isCreatedByUser={false}
|
||||
|
||||
12
client/src/components/svg/PasskeyIcon.tsx
Normal file
12
client/src/components/svg/PasskeyIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function PasskeyIcon() {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" id="passKey" className="h-5 w-5">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7.494 8.835c-.38-.071-.76-.142-1.122-.295-1.365-.57-2.164-1.63-2.42-3.124-.176-1.022-.096-2.03.318-2.986C4.86 1.07 5.91.337 7.295.085c.827-.147 1.65-.114 2.449.181 1.198.447 2.002 1.303 2.373 2.563.375 1.27.318 2.544-.248 3.747-.59 1.256-1.612 1.931-2.91 2.197-.11.024-.214.043-.323.067H7.494zm7.438 6.363c-1.541-1.265-2.716-2.872-2.716-5.412h-8.25c-1.731 0-3.134 1.422-3.134 3.182v3.975c0 .88.7 1.588 1.565 1.588h10.965c.866 0 1.565-.713 1.565-1.588v-1.74zm8.236-5.455c0 2.15-1.303 3.985-3.13 4.684l1.042 1.87-1.536 2.054 1.536 2.006L18.39 24V10.49c.637 0 1.15-.537 1.15-1.203s-.513-1.203-1.15-1.203V4.746c2.639 0 4.779 2.235 4.779 4.988zm-.014-.014c0 2.178-1.341 4.028-3.205 4.703l1.127 1.87-1.67 2.053 1.67 2.007-2.692 3.61-1.897-2.026v-7.652c-1.688-.765-2.868-2.52-2.868-4.565 0-2.748 2.135-4.974 4.765-4.974S23.15 6.981 23.15 9.73zm-4.765.761c.637 0 1.15-.537 1.15-1.203s-.513-1.203-1.15-1.203c-.637 0-1.151.537-1.151 1.203s.514 1.203 1.15 1.203z"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export { default as OpenIDIcon } from './OpenIDIcon';
|
||||
export { default as GithubIcon } from './GithubIcon';
|
||||
export { default as DiscordIcon } from './DiscordIcon';
|
||||
export { default as AppleIcon } from './AppleIcon';
|
||||
export { default as PasskeyIcon } from './PasskeyIcon';
|
||||
export { default as AnthropicIcon } from './AnthropicIcon';
|
||||
export { default as SendIcon } from './SendIcon';
|
||||
export { default as LinkIcon } from './LinkIcon';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { matchSorter } from 'match-sorter';
|
||||
import { Search, ChevronDown } from 'lucide-react';
|
||||
import { useMemo, useState, useRef, memo, useEffect } from 'react';
|
||||
import { SelectRenderer } from '@ariakit/react-core/select/select-renderer';
|
||||
import type { OptionWithIcon } from '~/common';
|
||||
@@ -16,6 +16,13 @@ interface ControlComboboxProps {
|
||||
selectPlaceholder?: string;
|
||||
isCollapsed: boolean;
|
||||
SelectIcon?: React.ReactNode;
|
||||
containerClassName?: string;
|
||||
iconClassName?: string;
|
||||
showCarat?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
iconSide?: 'left' | 'right';
|
||||
selectId?: string;
|
||||
}
|
||||
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -28,8 +35,15 @@ function ControlCombobox({
|
||||
ariaLabel,
|
||||
searchPlaceholder,
|
||||
selectPlaceholder,
|
||||
containerClassName,
|
||||
isCollapsed,
|
||||
SelectIcon,
|
||||
showCarat,
|
||||
className,
|
||||
disabled,
|
||||
iconClassName,
|
||||
iconSide = 'left',
|
||||
selectId,
|
||||
}: ControlComboboxProps) {
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -70,28 +84,48 @@ function ControlCombobox({
|
||||
}
|
||||
}, [isCollapsed]);
|
||||
|
||||
const selectIconClassName = cn(
|
||||
'flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
const optionIconClassName = cn(
|
||||
'mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full',
|
||||
iconClassName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-center px-1">
|
||||
<div className={cn('flex w-full items-center justify-center px-1', containerClassName)}>
|
||||
<Ariakit.SelectLabel store={select} className="sr-only">
|
||||
{ariaLabel}
|
||||
</Ariakit.SelectLabel>
|
||||
<Ariakit.Select
|
||||
ref={buttonRef}
|
||||
store={select}
|
||||
id={selectId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
|
||||
'text-text-primary hover:bg-surface-tertiary',
|
||||
'border border-border-light',
|
||||
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-md px-3 py-2 text-sm',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{SelectIcon != null && (
|
||||
<div className="assistant-item flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{SelectIcon}
|
||||
</div>
|
||||
{SelectIcon != null && iconSide === 'left' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
{!isCollapsed && (
|
||||
<span className="flex-grow truncate text-left">{displayValue ?? selectPlaceholder}</span>
|
||||
<>
|
||||
<span className="flex-grow truncate text-left">
|
||||
{displayValue != null
|
||||
? displayValue || selectPlaceholder
|
||||
: selectedValue || selectPlaceholder}
|
||||
</span>
|
||||
{SelectIcon != null && iconSide === 'right' && (
|
||||
<div className={selectIconClassName}>{SelectIcon}</div>
|
||||
)}
|
||||
{showCarat && <ChevronDown className="h-4 w-4 text-text-secondary" />}
|
||||
</>
|
||||
)}
|
||||
</Ariakit.Select>
|
||||
<Ariakit.SelectPopover
|
||||
@@ -126,12 +160,13 @@ function ControlCombobox({
|
||||
)}
|
||||
render={<Ariakit.SelectItem value={value} />}
|
||||
>
|
||||
{icon != null && (
|
||||
<div className="assistant-item mr-2 flex h-5 w-5 items-center justify-center overflow-hidden rounded-full">
|
||||
{icon}
|
||||
</div>
|
||||
{icon != null && iconSide === 'left' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
<span className="flex-grow truncate text-left">{label}</span>
|
||||
{icon != null && iconSide === 'right' && (
|
||||
<div className={optionIconClassName}>{icon}</div>
|
||||
)}
|
||||
</Ariakit.ComboboxItem>
|
||||
)}
|
||||
</SelectRenderer>
|
||||
|
||||
68
client/src/components/ui/InputOTP.tsx
Normal file
68
client/src/components/ui/InputOTP.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as React from 'react';
|
||||
import { OTPInput, OTPInputContext } from 'input-otp';
|
||||
import { Minus } from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
'flex items-center gap-2 has-[:disabled]:opacity-50',
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn('disabled:cursor-not-allowed', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = 'InputOTP';
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center', className)} {...props} />
|
||||
));
|
||||
InputOTPGroup.displayName = 'InputOTPGroup';
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-md relative flex h-11 w-11 items-center justify-center border-y border-r border-input shadow-sm transition-all first:rounded-l-xl first:border-l last:rounded-r-xl',
|
||||
isActive && 'z-10 ring-1 ring-ring',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = 'InputOTPSlot';
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
React.ComponentPropsWithoutRef<'div'>
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = 'InputOTPSeparator';
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
22
client/src/components/ui/Progress.tsx
Normal file
22
client/src/components/ui/Progress.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import * as ProgressPrimitive from '@radix-ui/react-progress';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-primary/20', className)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { Progress };
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Label,
|
||||
Listbox,
|
||||
@@ -82,18 +82,14 @@ function SelectDropDown({
|
||||
}
|
||||
|
||||
let title = _title;
|
||||
|
||||
if (emptyTitle) {
|
||||
title = '';
|
||||
} else if (!(title ?? '')) {
|
||||
title = localize('com_ui_model');
|
||||
}
|
||||
|
||||
const values = availableValues ?? [];
|
||||
|
||||
// Detemine if we should to convert this component into a searchable select. If we have enough elements, a search
|
||||
// input will appear near the top of the menu, allowing correct filtering of different model menu items. This will
|
||||
// reset once the component is unmounted (as per a normal search)
|
||||
// Enable searchable select if enough items are provided.
|
||||
const [filteredValues, searchRender] = useMultiSearch<string[] | Option[]>({
|
||||
availableOptions: values,
|
||||
placeholder: searchPlaceholder,
|
||||
@@ -103,26 +99,35 @@ function SelectDropDown({
|
||||
});
|
||||
const hasSearchRender = searchRender != null;
|
||||
const options = hasSearchRender ? filteredValues : values;
|
||||
|
||||
const renderIcon = showOptionIcon && value != null && (value as OptionWithIcon).icon != null;
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center gap-2 ', containerClassName ?? '')}>
|
||||
<div className={cn('flex items-center justify-center gap-2', containerClassName ?? '')}>
|
||||
<div className={cn('relative w-full', subContainerClassName ?? '')}>
|
||||
<Listbox value={value} onChange={setValue} disabled={disabled}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<ListboxButton
|
||||
ref={buttonRef}
|
||||
data-testid="select-dropdown-button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!open && buttonRef.current) {
|
||||
buttonRef.current.click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left disabled:bg-white dark:border-gray-600 dark:bg-gray-700 sm:text-sm',
|
||||
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:bg-white dark:border-gray-600 dark:bg-gray-700 sm:text-sm',
|
||||
className ?? '',
|
||||
)}
|
||||
>
|
||||
{' '}
|
||||
{showLabel && (
|
||||
<Label
|
||||
className="block text-xs text-gray-700 dark:text-gray-500 "
|
||||
className="block text-xs text-gray-700 dark:text-gray-500"
|
||||
id="headlessui-listbox-label-:r1:"
|
||||
data-headlessui-state=""
|
||||
>
|
||||
@@ -154,11 +159,9 @@ function SelectDropDown({
|
||||
if (!value) {
|
||||
return <span className="text-text-secondary">{placeholder}</span>;
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return value.label ?? '';
|
||||
}
|
||||
|
||||
return value;
|
||||
})()}
|
||||
</span>
|
||||
@@ -171,7 +174,7 @@ function SelectDropDown({
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
className="h-4 w-4 text-gray-400"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -212,17 +215,17 @@ function SelectDropDown({
|
||||
if (!option) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentLabel =
|
||||
typeof option === 'string' ? option : option.label ?? option.value ?? '';
|
||||
const currentValue = typeof option === 'string' ? option : option.value ?? '';
|
||||
typeof option === 'string' ? option : (option.label ?? option.value ?? '');
|
||||
const currentValue = typeof option === 'string' ? option : (option.value ?? '');
|
||||
const currentIcon =
|
||||
typeof option === 'string' ? null : (option.icon as React.ReactNode) ?? null;
|
||||
typeof option === 'string'
|
||||
? null
|
||||
: ((option.icon as React.ReactNode) ?? null);
|
||||
let activeValue: string | number | null | Option = value;
|
||||
if (typeof activeValue !== 'string') {
|
||||
activeValue = activeValue?.value ?? '';
|
||||
}
|
||||
|
||||
return (
|
||||
<ListboxOption
|
||||
key={i}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user