Compare commits

..

9 Commits

168 changed files with 3959 additions and 6406 deletions

View File

@@ -20,11 +20,6 @@ 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 #
@@ -297,10 +292,6 @@ 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 #
#==================================================#
@@ -398,7 +389,7 @@ FACEBOOK_CALLBACK_URL=/oauth/facebook/callback
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GITHUB_CALLBACK_URL=/oauth/github/callback
# GitHub Enterprise
# GitHub Eenterprise
# GITHUB_ENTERPRISE_BASE_URL=
# GITHUB_ENTERPRISE_USER_AGENT=
@@ -414,10 +405,6 @@ 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=
@@ -508,16 +495,6 @@ 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 #
#==================================================#
@@ -525,6 +502,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
# NODE_ENV=
# REDIS_URI=
# USE_REDIS=
# E2E_USER_EMAIL=
# E2E_USER_PASSWORD=
@@ -547,4 +527,4 @@ HELP_AND_FAQ_URL=https://librechat.ai
#=====================================================#
# OpenWeather #
#=====================================================#
OPENWEATHER_API_KEY=
OPENWEATHER_API_KEY=

View File

@@ -1,42 +0,0 @@
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 Normal file
View File

@@ -0,0 +1,50 @@
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

View File

@@ -1,60 +0,0 @@
{
"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"
}

View File

@@ -1,68 +0,0 @@
{
"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"
}

View File

@@ -1,94 +0,0 @@
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.

View File

@@ -1,106 +0,0 @@
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.

View File

@@ -4,7 +4,6 @@ on:
pull_request:
paths:
- "client/src/**"
- "api/**"
jobs:
detect-unused-i18n-keys:
@@ -22,7 +21,7 @@ jobs:
# Define paths
I18N_FILE="client/src/locales/en/translation.json"
SOURCE_DIRS=("client/src" "api")
SOURCE_DIR="client/src"
# Check if translation file exists
if [[ ! -f "$I18N_FILE" ]]; then
@@ -38,15 +37,7 @@ jobs:
# Check if each key is used in the source code
for KEY in $KEYS; do
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
if ! grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$SOURCE_DIR"; then
UNUSED_KEYS+=("$KEY")
fi
done

5
.gitignore vendored
View File

@@ -100,13 +100,10 @@ auth.json
/images
!client/src/components/Nav/SettingsTabs/Data/
!/client/src/@types/i18next.d.ts
# User uploads
uploads/
# owner
release/
# Apple Private Key
*.p8
!/client/src/@types/i18next.d.ts

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 LibreChat
Copyright (c) 2024 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

View File

@@ -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,8 +73,6 @@ class GoogleClient extends BaseClient {
* @type {string} */
this.outputTokensKey = 'output_tokens';
this.visionMode = VisionModes.generative;
/** @type {string} */
this.systemMessage;
if (options.skipSetOptions) {
return;
}
@@ -186,7 +184,7 @@ class GoogleClient extends BaseClient {
if (typeof this.options.artifactsPrompt === 'string' && this.options.artifactsPrompt) {
promptPrefix = `${promptPrefix ?? ''}\n${this.options.artifactsPrompt}`.trim();
}
this.systemMessage = promptPrefix;
this.options.promptPrefix = promptPrefix;
this.initializeClient();
return this;
}
@@ -316,7 +314,7 @@ class GoogleClient extends BaseClient {
}
this.augmentedPrompt = await this.contextHandlers.createContext();
this.systemMessage = this.augmentedPrompt + this.systemMessage;
this.options.promptPrefix = this.augmentedPrompt + this.options.promptPrefix;
}
}
@@ -363,8 +361,8 @@ class GoogleClient extends BaseClient {
throw new Error('[GoogleClient] PaLM 2 and Codey models are no longer supported.');
}
if (this.systemMessage) {
const instructionsTokenCount = this.getTokenCount(this.systemMessage);
if (this.options.promptPrefix) {
const instructionsTokenCount = this.getTokenCount(this.options.promptPrefix);
this.maxContextTokens = this.maxContextTokens - instructionsTokenCount;
if (this.maxContextTokens < 0) {
@@ -419,8 +417,8 @@ class GoogleClient extends BaseClient {
],
};
if (this.systemMessage) {
payload.instances[0].context = this.systemMessage;
if (this.options.promptPrefix) {
payload.instances[0].context = this.options.promptPrefix;
}
logger.debug('[GoogleClient] buildMessages', payload);
@@ -466,7 +464,7 @@ class GoogleClient extends BaseClient {
identityPrefix = `${identityPrefix}\nYou are ${this.options.modelLabel}`;
}
let promptPrefix = (this.systemMessage ?? '').trim();
let promptPrefix = (this.options.promptPrefix ?? '').trim();
if (identityPrefix) {
promptPrefix = `${identityPrefix}${promptPrefix}`;
@@ -641,7 +639,7 @@ class GoogleClient extends BaseClient {
let error;
try {
if (!EXCLUDED_GENAI_MODELS.test(modelName) && !this.project_id) {
/** @type {GenerativeModel} */
/** @type {GenAI} */
const client = this.client;
/** @type {GenerateContentRequest} */
const requestOptions = {
@@ -650,7 +648,7 @@ class GoogleClient extends BaseClient {
generationConfig: googleGenConfigSchema.parse(this.modelOptions),
};
const promptPrefix = (this.systemMessage ?? '').trim();
const promptPrefix = (this.options.promptPrefix ?? '').trim();
if (promptPrefix.length) {
requestOptions.systemInstruction = {
parts: [
@@ -665,17 +663,7 @@ class GoogleClient extends BaseClient {
/** @type {GenAIUsageMetadata} */
let usageMetadata;
abortController.signal.addEventListener(
'abort',
() => {
logger.warn('[GoogleClient] Request was aborted', abortController.signal.reason);
},
{ once: true },
);
const result = await client.generateContentStream(requestOptions, {
signal: abortController.signal,
});
const result = await client.generateContentStream(requestOptions);
for await (const chunk of result.stream) {
usageMetadata = !usageMetadata
? chunk?.usageMetadata

View File

@@ -2,7 +2,7 @@ const { z } = require('zod');
const axios = require('axios');
const { Ollama } = require('ollama');
const { Constants } = require('librechat-data-provider');
const { deriveBaseURL, logAxiosError } = require('~/utils');
const { deriveBaseURL } = 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).';
logAxiosError({ message: logMessage, error });
logger.error(logMessage, error);
return [];
}
}

View File

@@ -7,7 +7,6 @@ const {
ImageDetail,
EModelEndpoint,
resolveHeaders,
KnownEndpoints,
openAISettings,
ImageDetailCost,
CohereConstants,
@@ -117,7 +116,11 @@ class OpenAIClient extends BaseClient {
const { reverseProxyUrl: reverseProxy } = this.options;
if (!this.useOpenRouter && reverseProxy && reverseProxy.includes(KnownEndpoints.openrouter)) {
if (
!this.useOpenRouter &&
reverseProxy &&
reverseProxy.includes('https://openrouter.ai/api/v1')
) {
this.useOpenRouter = true;
}

View File

@@ -282,47 +282,4 @@ 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...');
});
});

View File

@@ -153,7 +153,6 @@ 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) {
/*
@@ -208,25 +207,11 @@ 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 }));
}

View File

@@ -106,21 +106,18 @@ const createFileSearchTool = async ({ req, files, entity_id }) => {
const formattedResults = validResults
.flatMap((result) =>
result.data.map(([docInfo, distance]) => ({
result.data.map(([docInfo, relevanceScore]) => ({
filename: docInfo.metadata.source.split('/').pop(),
content: docInfo.page_content,
distance,
relevanceScore,
})),
)
// TODO: results should be sorted by relevance, not distance
.sort((a, b) => a.distance - b.distance)
// TODO: make this configurable
.slice(0, 10);
.sort((a, b) => b.relevanceScore - a.relevanceScore);
const formattedString = formattedResults
.map(
(result) =>
`File: ${result.filename}\nRelevance: ${1.0 - result.distance.toFixed(4)}\nContent: ${
`File: ${result.filename}\nRelevance: ${result.relevanceScore.toFixed(4)}\nContent: ${
result.content
}\n`,
)

4
api/cache/index.js vendored
View File

@@ -1,7 +1,5 @@
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, mongoUserStore, mongoChallengeStore };
module.exports = { ...keyvFiles, getLogStores, logViolation };

View File

@@ -1,81 +1,15 @@
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, USE_REDIS_CLUSTER, REDIS_CA, REDIS_KEY_PREFIX, REDIS_MAX_LISTENERS } =
process.env;
const { REDIS_URI, USE_REDIS } = 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)) {
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 = new KeyvRedis(REDIS_URI, { useRedisSets: false });
keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err));
keyvRedis.setMaxListeners(redis_max_listeners);
keyvRedis.setMaxListeners(20);
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.',
);

View File

@@ -1,35 +0,0 @@
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;

View File

@@ -1,55 +0,0 @@
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;

View File

@@ -1,11 +1,9 @@
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 = isEnabled(process.env.SEARCH);
const indexingDisabled = isEnabled(process.env.MEILI_NO_SYNC);
const searchEnabled = process.env?.SEARCH?.toLowerCase() === 'true';
let currentTimeout = null;
class MeiliSearchClient {
@@ -25,7 +23,8 @@ class MeiliSearchClient {
}
}
async function indexSync() {
// eslint-disable-next-line no-unused-vars
async function indexSync(req, res, next) {
if (!searchEnabled) {
return;
}
@@ -34,15 +33,10 @@ async function indexSync() {
const client = MeiliSearchClient.getInstance();
const { status } = await client.health();
if (status !== 'available') {
if (status !== 'available' || !process.env.SEARCH) {
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();
@@ -77,6 +71,7 @@ async function indexSync() {
logger.info('[indexSync] Meilisearch not configured, search will be disabled.');
} else {
logger.error('[indexSync] error', err);
// res.status(500).json({ error: 'Server error' });
}
}
}

View File

@@ -97,22 +97,11 @@ 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;
@@ -301,7 +290,6 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
};
module.exports = {
Agent,
getAgent,
loadAgent,
createAgent,

View File

@@ -1,160 +0,0 @@
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);
});
});
});

View File

@@ -1,41 +1,40 @@
const { logger } = require('~/config');
// const { Categories } = require('./schema/categories');
const options = [
{
label: 'com_ui_idea',
label: 'idea',
value: 'idea',
},
{
label: 'com_ui_travel',
label: 'travel',
value: 'travel',
},
{
label: 'com_ui_teach_or_explain',
label: 'teach_or_explain',
value: 'teach_or_explain',
},
{
label: 'com_ui_write',
label: 'write',
value: 'write',
},
{
label: 'com_ui_shop',
label: 'shop',
value: 'shop',
},
{
label: 'com_ui_code',
label: 'code',
value: 'code',
},
{
label: 'com_ui_misc',
label: 'misc',
value: 'misc',
},
{
label: 'com_ui_roleplay',
label: 'roleplay',
value: 'roleplay',
},
{
label: 'com_ui_finance',
label: 'finance',
value: 'finance',
},
];

View File

@@ -1,6 +0,0 @@
const mongoose = require('mongoose');
const challengeSchema = require('~/models/schema/challengeSchema');
const ChallengeStore = mongoose.model('Challenge', challengeSchema);
module.exports = ChallengeStore;

View File

@@ -1,22 +0,0 @@
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;

View File

@@ -39,19 +39,6 @@ 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(
{
@@ -130,18 +117,9 @@ const userSchema = mongoose.Schema(
unique: true,
sparse: true,
},
passkeys: {
type: [passkeySchema],
default: [],
},
plugins: {
type: Array,
},
totpSecret: {
type: String,
},
backupCodes: {
type: [backupCodeSchema],
default: [],
},
refreshToken: {
type: [Session],

View File

@@ -45,7 +45,7 @@
"@langchain/google-genai": "^0.1.7",
"@langchain/google-vertexai": "^0.1.8",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.1.2",
"@librechat/agents": "^2.0.5",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "1.7.8",
"bcryptjs": "^2.4.3",
@@ -65,7 +65,6 @@
"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",
@@ -97,13 +96,14 @@
"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",
"socket.io": "^4.8.1",
"tiktoken": "^1.0.15",
"traverse": "^0.6.7",
"ua-parser-js": "^1.0.36",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"wrtc": "^0.4.7",
"youtube-transcript": "^1.2.1",
"zod": "^3.22.4"
},

View File

@@ -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 -totpSecret');
const user = await getUserById(payload.id, '-password -__v');
if (!user) {
return res.status(401).redirect('/login');
}

View File

@@ -1,119 +0,0 @@
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,
};

View File

@@ -19,9 +19,7 @@ const { Transaction } = require('~/models/Transaction');
const { logger } = require('~/config');
const getUserController = async (req, res) => {
const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user };
delete userData.totpSecret;
res.status(200).send(userData);
res.status(200).send(req.user);
};
const getTermsStatusController = async (req, res) => {

View File

@@ -199,22 +199,6 @@ 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;

View File

@@ -20,6 +20,11 @@ const {
bedrockOutputParser,
removeNullishValues,
} = require('librechat-data-provider');
const {
extractBaseURL,
// constructAzureURL,
// genAzureChatCompletion,
} = require('~/utils');
const {
formatMessage,
formatAgentMessages,
@@ -472,6 +477,19 @@ 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 };
// }
@@ -608,7 +626,7 @@ class AgentClient extends BaseClient {
let systemContent = [
systemMessage,
agent.instructions ?? '',
i !== 0 ? (agent.additional_instructions ?? '') : '',
i !== 0 ? agent.additional_instructions ?? '' : '',
]
.join('\n')
.trim();

View File

@@ -1,5 +1,5 @@
const { Run, Providers } = require('@librechat/agents');
const { providerEndpointMap, KnownEndpoints } = require('librechat-data-provider');
const { providerEndpointMap } = require('librechat-data-provider');
/**
* @typedef {import('@librechat/agents').t} t
@@ -7,7 +7,6 @@ const { providerEndpointMap, KnownEndpoints } = 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
*/
@@ -33,7 +32,6 @@ async function createRun({
streamUsage = true,
}) {
const provider = providerEndpointMap[agent.provider] ?? agent.provider;
/** @type {LLMConfig} */
const llmConfig = Object.assign(
{
provider,
@@ -43,11 +41,6 @@ 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;
@@ -57,7 +50,6 @@ async function createRun({
const graphConfig = {
signal,
llmConfig,
reasoningKey,
tools: agent.tools,
instructions: agent.instructions,
additional_instructions: agent.additional_instructions,

View File

@@ -1,4 +1,3 @@
const { generate2FATempToken } = require('~/server/services/twoFactorService');
const { setAuthTokens } = require('~/server/services/AuthService');
const { logger } = require('~/config');
@@ -8,12 +7,7 @@ const loginController = async (req, res) => {
return res.status(400).json({ message: 'Invalid credentials' });
}
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;
const { password: _, __v, ...user } = req.user;
user.id = user._id.toString();
const token = await setAuthTokens(req.user._id, res);

View File

@@ -1,58 +0,0 @@
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 };

View File

@@ -4,6 +4,7 @@ require('module-alias')({ base: path.resolve(__dirname, '..') });
const cors = require('cors');
const axios = require('axios');
const express = require('express');
const { createServer } = require('http');
const compression = require('compression');
const passport = require('passport');
const mongoSanitize = require('express-mongo-sanitize');
@@ -14,6 +15,7 @@ const { connectDb, indexSync } = require('~/lib/db');
const { isEnabled } = require('~/server/utils');
const { ldapLogin } = require('~/strategies');
const { logger } = require('~/config');
const { SocketIOService } = require('./services/WebSocket/WebSocketServer');
const validateImageRequest = require('./middleware/validateImageRequest');
const errorController = require('./controllers/ErrorController');
const configureSocialLogins = require('./socialLogins');
@@ -21,14 +23,11 @@ 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, TRUST_PROXY } = process.env ?? {};
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION } = 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') {
@@ -39,7 +38,18 @@ const startServer = async () => {
await indexSync();
const app = express();
const server = createServer(app);
app.disable('x-powered-by');
app.use(
cors({
origin: true,
credentials: true,
}),
);
new SocketIOService(server);
await AppService(app);
const indexPath = path.join(app.locals.paths.dist, 'index.html');
@@ -56,7 +66,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', trusted_proxy);
app.set('trust proxy', 1); /* trust first proxy */
app.use(cors());
app.use(cookieParser());
@@ -80,29 +90,11 @@ 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);
@@ -130,6 +122,7 @@ const startServer = async () => {
app.use('/api/agents', routes.agents);
app.use('/api/banner', routes.banner);
app.use('/api/bedrock', routes.bedrock);
app.use('/api/websocket', routes.websocket);
app.use('/api/tags', routes.tags);
@@ -147,7 +140,7 @@ const startServer = async () => {
res.send(updatedIndexHtml);
});
app.listen(port, host, () => {
server.listen(port, host, () => {
if (host == '0.0.0.0') {
logger.info(
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
@@ -155,6 +148,8 @@ const startServer = async () => {
} else {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
}
logger.info(`Socket.IO endpoint: http://${host}:${port}`);
});
};
@@ -166,18 +161,6 @@ 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');

View File

@@ -7,13 +7,6 @@ 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,
@@ -57,11 +50,4 @@ 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;

View File

@@ -1,44 +0,0 @@
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;

View File

@@ -51,7 +51,6 @@ 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 &&

View File

@@ -3,7 +3,7 @@ const router = express.Router();
const { getCustomConfigSpeech } = require('~/server/services/Files/Audio');
router.get('/get', async (req, res) => {
router.get('/', async (req, res) => {
await getCustomConfigSpeech(req, res);
});

View File

@@ -4,6 +4,7 @@ const { createTTSLimiters, createSTTLimiters } = require('~/server/middleware');
const stt = require('./stt');
const tts = require('./tts');
const customConfigSpeech = require('./customConfigSpeech');
const realtime = require('./realtime');
const router = express.Router();
@@ -14,4 +15,6 @@ router.use('/tts', ttsIpLimiter, ttsUserLimiter, tts);
router.use('/config', customConfigSpeech);
router.use('/realtime', realtime);
module.exports = router;

View File

@@ -0,0 +1,10 @@
const express = require('express');
const router = express.Router();
const { getRealtimeConfig } = require('~/server/services/Files/Audio');
router.get('/', async (req, res) => {
await getRealtimeConfig(req, res);
});
module.exports = router;

View File

@@ -1,8 +1,8 @@
const authWebAuthn = require('./authWebAuthn');
const assistants = require('./assistants');
const categories = require('./categories');
const tokenizer = require('./tokenizer');
const endpoints = require('./endpoints');
const websocket = require('./websocket');
const staticRoute = require('./static');
const messages = require('./messages');
const presets = require('./presets');
@@ -16,6 +16,7 @@ const models = require('./models');
const convos = require('./convos');
const config = require('./config');
const agents = require('./agents');
const banner = require('./banner');
const roles = require('./roles');
const oauth = require('./oauth');
const files = require('./files');
@@ -26,7 +27,6 @@ const edit = require('./edit');
const keys = require('./keys');
const user = require('./user');
const ask = require('./ask');
const banner = require('./banner');
module.exports = {
ask,
@@ -40,6 +40,7 @@ module.exports = {
files,
share,
agents,
banner,
bedrock,
convos,
search,
@@ -51,11 +52,10 @@ module.exports = {
presets,
balance,
messages,
websocket,
endpoints,
tokenizer,
assistants,
categories,
staticRoute,
authWebAuthn,
banner,
};

View File

@@ -0,0 +1,19 @@
const express = require('express');
const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
const router = express.Router();
router.get('/', optionalJwtAuth, async (req, res) => {
const isProduction = process.env.NODE_ENV === 'production';
const protocol = isProduction && req.secure ? 'https' : 'http';
const serverDomain = process.env.SERVER_DOMAIN
? process.env.SERVER_DOMAIN.replace(/^https?:\/\//, '')
: req.headers.host;
const socketIoUrl = `${protocol}://${serverDomain}`;
res.json({ url: socketIoUrl });
});
module.exports = router;

View File

@@ -22,14 +22,12 @@ 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,
};
/**
@@ -102,10 +100,8 @@ const initializeAgentOptions = async ({
const provider = agent.provider;
let getOptions = providerConfigMap[provider];
if (!getOptions && providerConfigMap[provider.toLowerCase()] != null) {
agent.provider = provider.toLowerCase();
getOptions = providerConfigMap[agent.provider];
} else if (!getOptions) {
if (!getOptions) {
const customEndpointConfig = await getCustomEndpointConfig(provider);
if (!customEndpointConfig) {
throw new Error(`Provider ${provider} not supported`);

View File

@@ -1,5 +1,4 @@
const { HttpsProxyAgent } = require('https-proxy-agent');
const { KnownEndpoints } = require('librechat-data-provider');
const { sanitizeModelName, constructAzureURL } = require('~/utils');
const { isEnabled } = require('~/server/utils');
@@ -58,9 +57,10 @@ function getLLMConfig(apiKey, options = {}) {
/** @type {OpenAIClientOptions['configuration']} */
const configOptions = {};
if (useOpenRouter || (reverseProxyUrl && reverseProxyUrl.includes(KnownEndpoints.openrouter))) {
llmConfig.include_reasoning = true;
configOptions.baseURL = reverseProxyUrl;
// Handle OpenRouter or custom reverse proxy
if (useOpenRouter || reverseProxyUrl === 'https://openrouter.ai/api/v1') {
configOptions.baseURL = 'https://openrouter.ai/api/v1';
configOptions.defaultHeaders = Object.assign(
{
'HTTP-Referer': 'https://librechat.ai',

View File

@@ -0,0 +1,102 @@
const { extractEnvVariable, RealtimeVoiceProviders } = require('librechat-data-provider');
const { getCustomConfig } = require('~/server/services/Config');
const { logger } = require('~/config');
class RealtimeService {
constructor(customConfig) {
this.customConfig = customConfig;
this.providerStrategies = {
[RealtimeVoiceProviders.OPENAI]: this.openaiProvider.bind(this),
};
}
static async getInstance() {
const customConfig = await getCustomConfig();
if (!customConfig) {
throw new Error('Custom config not found');
}
return new RealtimeService(customConfig);
}
async getProviderSchema() {
const realtimeSchema = this.customConfig.speech.realtime;
if (!realtimeSchema) {
throw new Error('No Realtime schema is set in config');
}
const providers = Object.entries(realtimeSchema).filter(
([, value]) => Object.keys(value).length > 0,
);
if (providers.length !== 1) {
throw new Error(providers.length > 1 ? 'Multiple providers set' : 'No provider set');
}
return providers[0];
}
async openaiProvider(schema, voice) {
const defaultRealtimeUrl = 'https://api.openai.com/v1/realtime';
const allowedVoices = ['alloy', 'ash', 'ballad', 'coral', 'echo', 'sage', 'shimmer', 'verse'];
if (!voice) {
throw new Error('Voice not specified');
}
if (!allowedVoices.includes(voice)) {
throw new Error(`Invalid voice: ${voice}`);
}
const apiKey = extractEnvVariable(schema.apiKey);
if (!apiKey) {
throw new Error('OpenAI API key not configured');
}
const response = await fetch('https://api.openai.com/v1/realtime/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-realtime-preview-2024-12-17',
modalities: ['audio', 'text'],
voice: voice,
}),
});
const token = response.json();
return {
provider: RealtimeVoiceProviders.OPENAI,
token: token,
url: schema.url || defaultRealtimeUrl,
};
}
async getRealtimeConfig(req, res) {
try {
const [provider, schema] = await this.getProviderSchema();
const strategy = this.providerStrategies[provider];
if (!strategy) {
throw new Error(`Unsupported provider: ${provider}`);
}
const voice = req.query.voice;
const config = strategy(schema, voice);
res.json(config);
} catch (error) {
logger.error('[RealtimeService] Config generation failed:', error);
res.status(500).json({ error: error.message });
}
}
}
async function getRealtimeConfig(req, res) {
const service = await RealtimeService.getInstance();
await service.getRealtimeConfig(req, res);
}
module.exports = getRealtimeConfig;

View File

@@ -1,4 +1,5 @@
const getCustomConfigSpeech = require('./getCustomConfigSpeech');
const getRealtimeConfig = require('./getRealtimeConfig');
const TTSService = require('./TTSService');
const STTService = require('./STTService');
const getVoices = require('./getVoices');
@@ -6,6 +7,7 @@ const getVoices = require('./getVoices');
module.exports = {
getVoices,
getCustomConfigSpeech,
getRealtimeConfig,
...STTService,
...TTSService,
};

View File

@@ -2,7 +2,6 @@
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;
@@ -79,11 +78,7 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = ''
return `${fileIdentifier}?entity_id=${entity_id}`;
} catch (error) {
logAxiosError({
message: `Error uploading code environment file: ${error.message}`,
error,
});
throw new Error(`Error uploading code environment file: ${error.message}`);
throw new Error(`Error uploading file: ${error.message}`);
}
}

View File

@@ -12,7 +12,6 @@ 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');
/**
@@ -86,10 +85,7 @@ 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) {
logAxiosError({
message: 'Error downloading code environment file',
error,
});
logger.error('Error downloading file:', error);
}
};
@@ -139,10 +135,7 @@ async function getSessionInfo(fileIdentifier, apiKey) {
return response.data.find((file) => file.name.startsWith(path))?.lastModified;
} catch (error) {
logAxiosError({
message: `Error fetching session info: ${error.message}`,
error,
});
logger.error(`Error fetching session info: ${error.message}`, error);
return null;
}
}
@@ -209,7 +202,7 @@ const primeFiles = async (options, apiKey) => {
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(
FileSources.execute_code,
);
const stream = await getDownloadStream(options.req, file.filepath);
const stream = await getDownloadStream(file.filepath);
const fileIdentifier = await uploadCodeEnvFile({
req: options.req,
stream,

View File

@@ -224,11 +224,10 @@ 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(_req, filepath) {
async function getFirebaseFileStream(filepath) {
try {
const storage = getFirebaseStorage();
if (!storage) {

View File

@@ -175,17 +175,6 @@ 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.
@@ -228,7 +217,7 @@ const deleteLocalFile = async (req, file) => {
throw new Error(`Invalid file path: ${file.filepath}`);
}
await unlinkFile(filepath);
await fs.promises.unlink(filepath);
return;
}
@@ -244,7 +233,7 @@ const deleteLocalFile = async (req, file) => {
throw new Error('Invalid file path');
}
await unlinkFile(filepath);
await fs.promises.unlink(filepath);
};
/**
@@ -286,31 +275,11 @@ 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(req, filepath) {
function getLocalFileStream(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);

View File

@@ -37,14 +37,7 @@ const deleteVectors = async (req, file) => {
error,
message: 'Error deleting vectors',
});
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.');
}
throw new Error(error.message || 'An error occurred during file deletion.');
}
};

View File

@@ -347,8 +347,8 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true })
req.app.locals.imageOutputType
}`;
}
const fileName = `${file_id}-${filename}`;
const filepath = await saveBuffer({ userId: req.user.id, fileName, buffer });
const filepath = await saveBuffer({ userId: req.user.id, fileName: filename, buffer });
return await createFile(
{
user: req.user.id,
@@ -801,7 +801,8 @@ async function saveBase64Image(
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' },
) {
const file_id = _file_id ?? v4();
let filename = `${file_id}-${_filename}`;
let filename = _filename;
const { buffer: inputBuffer, type } = base64ToBuffer(url);
if (!path.extname(_filename)) {
const extension = mime.getExtension(type);

View File

@@ -1,5 +1,4 @@
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');
@@ -58,7 +57,7 @@ const fetchModels = async ({
return models;
}
if (name && name.toLowerCase().startsWith(Providers.OLLAMA)) {
if (name && name.toLowerCase().startsWith('ollama')) {
return await OllamaClient.fetchModels(baseURL);
}

View File

@@ -0,0 +1,193 @@
const { Server } = require('socket.io');
const { RTCPeerConnection, RTCIceCandidate, MediaStream } = require('wrtc');
class WebRTCConnection {
constructor(socket, config) {
this.socket = socket;
this.config = config;
this.peerConnection = null;
this.audioTransceiver = null;
this.pendingCandidates = [];
this.state = 'idle';
this.log = config.log || console.log;
}
async handleOffer(offer) {
try {
if (!this.peerConnection) {
this.peerConnection = new RTCPeerConnection(this.config.rtcConfig);
this.setupPeerConnectionListeners();
}
await this.peerConnection.setRemoteDescription(offer);
const mediaStream = new MediaStream();
this.audioTransceiver = this.peerConnection.addTransceiver('audio', {
direction: 'sendrecv',
streams: [mediaStream],
});
const answer = await this.peerConnection.createAnswer();
await this.peerConnection.setLocalDescription(answer);
this.socket.emit('webrtc-answer', answer);
} catch (error) {
this.log(`Error handling offer: ${error}`, 'error');
this.socket.emit('webrtc-error', {
message: error.message,
code: 'OFFER_ERROR',
});
}
}
setupPeerConnectionListeners() {
if (!this.peerConnection) {
return;
}
this.peerConnection.ontrack = ({ track }) => {
this.log(`Received ${track.kind} track from client`);
if (track.kind === 'audio') {
this.handleIncomingAudio(track);
}
track.onended = () => {
this.log(`${track.kind} track ended`);
};
};
this.peerConnection.onicecandidate = ({ candidate }) => {
if (candidate) {
this.socket.emit('icecandidate', candidate);
}
};
this.peerConnection.onconnectionstatechange = () => {
if (!this.peerConnection) {
return;
}
const state = this.peerConnection.connectionState;
this.log(`Connection state changed to ${state}`);
this.state = state;
if (state === 'failed' || state === 'closed') {
this.cleanup();
}
};
}
handleIncomingAudio(track) {
if (this.peerConnection) {
const stream = new MediaStream([track]);
this.peerConnection.addTrack(track, stream);
}
}
async addIceCandidate(candidate) {
try {
if (this.peerConnection?.remoteDescription) {
if (candidate && candidate.candidate) {
await this.peerConnection.addIceCandidate(new RTCIceCandidate(candidate));
} else {
this.log('Invalid ICE candidate', 'warn');
}
} else {
this.pendingCandidates.push(candidate);
}
} catch (error) {
this.log(`Error adding ICE candidate: ${error}`, 'error');
}
}
cleanup() {
if (this.peerConnection) {
try {
this.peerConnection.close();
} catch (error) {
this.log(`Error closing peer connection: ${error}`, 'error');
}
this.peerConnection = null;
}
this.audioTransceiver = null;
this.pendingCandidates = [];
this.state = 'idle';
}
}
class SocketIOService {
constructor(httpServer, config = {}) {
this.config = {
rtcConfig: {
iceServers: [
{
urls: ['stun:stun.l.google.com:19302', 'stun:stun1.l.google.com:19302'],
},
],
iceCandidatePoolSize: 10,
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require',
},
...config,
};
this.io = new Server(httpServer, {
path: '/socket.io',
cors: {
origin: '*',
methods: ['GET', 'POST'],
},
});
this.connections = new Map();
this.setupSocketHandlers();
}
setupSocketHandlers() {
this.io.on('connection', (socket) => {
this.log(`Client connected: ${socket.id}`);
const rtcConnection = new WebRTCConnection(socket, {
...this.config,
log: this.log.bind(this),
});
this.connections.set(socket.id, rtcConnection);
socket.on('webrtc-offer', (offer) => {
this.log(`Received WebRTC offer from ${socket.id}`);
rtcConnection.handleOffer(offer);
});
socket.on('icecandidate', (candidate) => {
rtcConnection.addIceCandidate(candidate);
});
socket.on('vad-status', (status) => {
this.log(`VAD status from ${socket.id}: ${JSON.stringify(status)}`);
});
socket.on('disconnect', () => {
this.log(`Client disconnected: ${socket.id}`);
rtcConnection.cleanup();
this.connections.delete(socket.id);
});
});
}
log(message, level = 'info') {
const timestamp = new Date().toISOString();
console.log(`[WebRTC ${timestamp}] [${level.toUpperCase()}] ${message}`);
}
shutdown() {
for (const connection of this.connections.values()) {
connection.cleanup();
}
this.connections.clear();
this.io.close();
}
}
module.exports = { SocketIOService };

View File

@@ -1,238 +0,0 @@
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,
};

View File

@@ -112,25 +112,4 @@ async function getRandomValues(length) {
return Buffer.from(randomValues).toString('hex');
}
/**
* 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,
};
module.exports = { encrypt, decrypt, encryptV2, decryptV2, hashToken, getRandomValues };

View File

@@ -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 ? ` ${profile.name.familyName}` : ''}`,
name: `${profile.name.givenName} ${profile.name.familyName}`,
emailVerified: profile.emails[0].verified,
});

View File

@@ -12,7 +12,7 @@ const jwtLogin = async () =>
},
async (payload, done) => {
try {
const user = await getUserById(payload?.id, '-password -__v -totpSecret');
const user = await getUserById(payload?.id, '-password -__v');
if (user) {
user.id = user._id.toString();
if (!user.role) {

View File

@@ -5,32 +5,40 @@ const { logger } = require('~/config');
*
* @param {Object} options - The options object.
* @param {string} options.message - The custom message to be logged.
* @param {import('axios').AxiosError} options.error - The Axios error object.
* @param {Error} options.error - The Axios error object.
*/
const logAxiosError = ({ message, 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}`);
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,
);
}
};

View File

@@ -44,7 +44,6 @@
"@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",
@@ -52,6 +51,7 @@
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.3",
"@radix-ui/react-toast": "^1.1.5",
"@ricky0123/vad-react": "^0.0.28",
"@tanstack/react-query": "^4.28.0",
"@tanstack/react-table": "^8.11.7",
"class-variance-authority": "^0.6.0",
@@ -65,8 +65,6 @@
"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",
@@ -86,7 +84,7 @@
"react-i18next": "^15.4.0",
"react-lazy-load-image-component": "^1.6.0",
"react-markdown": "^9.0.1",
"react-resizable-panels": "^2.1.7",
"react-resizable-panels": "^2.1.1",
"react-router-dom": "^6.11.2",
"react-speech-recognition": "^3.10.0",
"react-textarea-autosize": "^8.4.0",
@@ -100,6 +98,7 @@
"remark-gfm": "^4.0.0",
"remark-math": "^6.0.0",
"remark-supersub": "^1.0.0",
"socket.io-client": "^4.8.1",
"sse.js": "^2.5.0",
"tailwind-merge": "^1.9.1",
"tailwindcss-animate": "^1.0.5",

View File

@@ -48,6 +48,32 @@ export type AudioChunk = {
};
};
export interface RTCMessage {
type:
| 'audio-chunk'
| 'audio-received'
| 'transcription'
| 'llm-response'
| 'tts-chunk'
| 'call-ended'
| 'webrtc-answer'
| 'icecandidate';
payload?: RTCSessionDescriptionInit | RTCIceCandidateInit;
}
export type MessagePayload =
| RTCSessionDescriptionInit
| RTCIceCandidateInit
| { speaking: boolean };
export enum CallState {
IDLE = 'idle',
CONNECTING = 'connecting',
ACTIVE = 'active',
ERROR = 'error',
ENDED = 'ended',
}
export type AssistantListItem = {
id: string;
name: string;

View File

@@ -5,8 +5,6 @@ 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">
@@ -59,12 +57,6 @@ 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 />
@@ -92,19 +84,9 @@ function AuthLayout({
{header}
</h1>
)}
{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)}
/>
)}
</>
{children}
{(pathname.includes('login') || pathname.includes('register')) && (
<SocialLoginRender startupConfig={startupConfig} />
)}
</div>
</div>

View File

@@ -166,7 +166,9 @@ 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 dark:bg-green-600 dark:hover:bg-green-700
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
"
>
{localize('com_auth_continue')}

View File

@@ -1,283 +0,0 @@
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(/=+$/, '');
}

View File

@@ -1,36 +1,22 @@
import {
GoogleIcon,
FacebookIcon,
OpenIDIcon,
GithubIcon,
DiscordIcon,
AppleIcon,
PasskeyIcon,
} from '~/components';
import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components';
import SocialButton from './SocialButton';
import { useLocalize } from '~/hooks';
import { TStartupConfig } from 'librechat-data-provider';
import React from 'react';
type SocialLoginRenderProps = {
function SocialLoginRender({
startupConfig,
}: {
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
@@ -121,25 +107,10 @@ function SocialLoginRender({ startupConfig, mode, onPasskeyClick }: SocialLoginR
)}
<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;

View File

@@ -1,176 +0,0 @@
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;

View File

@@ -4,4 +4,3 @@ 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';

View File

@@ -0,0 +1,241 @@
import React, { useEffect, useRef } from 'react';
import { useRecoilState } from 'recoil';
import {
Phone,
PhoneOff,
AlertCircle,
Mic,
MicOff,
Volume2,
VolumeX,
Activity,
} from 'lucide-react';
import { OGDialog, OGDialogContent, Button } from '~/components';
import { useWebSocket, useCall } from '~/hooks';
import { CallState } from '~/common';
import store from '~/store';
export const Call: React.FC = () => {
const { isConnected } = useWebSocket();
const {
callState,
error,
startCall,
hangUp,
isConnecting,
localStream,
remoteStream,
connectionQuality,
isMuted,
toggleMute,
} = useCall();
const [open, setOpen] = useRecoilState(store.callDialogOpen(0));
const [eventLog, setEventLog] = React.useState<string[]>([]);
const [isAudioEnabled, setIsAudioEnabled] = React.useState(true);
const remoteAudioRef = useRef<HTMLAudioElement>(null);
const logEvent = (message: string) => {
console.log(message);
setEventLog((prev) => [...prev, `${new Date().toISOString()}: ${message}`]);
};
useEffect(() => {
if (remoteAudioRef.current && remoteStream) {
remoteAudioRef.current.srcObject = remoteStream;
remoteAudioRef.current.play().catch((err) => console.error('Error playing audio:', err));
}
}, [remoteStream]);
useEffect(() => {
if (localStream) {
localStream.getAudioTracks().forEach((track) => {
track.enabled = !isMuted;
});
}
}, [localStream, isMuted]);
useEffect(() => {
if (isConnected) {
logEvent('Connected to server.');
} else {
logEvent('Disconnected from server.');
}
}, [isConnected]);
useEffect(() => {
if (error) {
logEvent(`Error: ${error.message} (${error.code})`);
}
}, [error]);
useEffect(() => {
logEvent(`Call state changed to: ${callState}`);
}, [callState]);
const handleStartCall = () => {
logEvent('Attempting to start call...');
startCall();
};
const handleHangUp = () => {
logEvent('Attempting to hang up call...');
hangUp();
};
const handleToggleMute = () => {
toggleMute();
logEvent(`Microphone ${isMuted ? 'unmuted' : 'muted'}`);
};
const toggleAudio = () => {
setIsAudioEnabled((prev) => !prev);
if (remoteAudioRef.current) {
remoteAudioRef.current.muted = !isAudioEnabled;
}
logEvent(`Speaker ${isAudioEnabled ? 'disabled' : 'enabled'}`);
};
const isActive = callState === CallState.ACTIVE;
const isError = callState === CallState.ERROR;
// TESTS
useEffect(() => {
if (remoteAudioRef.current && remoteStream) {
console.log('Setting up remote audio:', {
tracks: remoteStream.getTracks().length,
active: remoteStream.active,
});
remoteAudioRef.current.srcObject = remoteStream;
remoteAudioRef.current.muted = false;
remoteAudioRef.current.volume = 1.0;
const playPromise = remoteAudioRef.current.play();
if (playPromise) {
playPromise.catch((err) => {
console.error('Error playing audio:', err);
// Retry play on user interaction
document.addEventListener(
'click',
() => {
remoteAudioRef.current?.play();
},
{ once: true },
);
});
}
}
}, [remoteStream]);
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogContent className="w-[28rem] p-8">
<div className="flex flex-col items-center gap-6">
{/* Connection Status */}
<div className="flex w-full items-center justify-between">
<div
className={`flex items-center gap-2 rounded-full px-4 py-2 ${
isConnected ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}
>
<div
className={`h-2 w-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}
/>
<span className="text-sm font-medium">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{isActive && (
<div
className={`flex items-center gap-2 rounded-full px-4 py-2 ${
(connectionQuality === 'good' && 'bg-green-100 text-green-700') ||
(connectionQuality === 'poor' && 'bg-yellow-100 text-yellow-700') ||
'bg-gray-100 text-gray-700'
}`}
>
<Activity size={16} />
<span className="text-sm font-medium capitalize">{connectionQuality} Quality</span>
</div>
)}
</div>
{/* Error Display */}
{error && (
<div className="flex w-full items-center gap-2 rounded-md bg-red-100 p-3 text-red-700">
<AlertCircle size={16} />
<span className="text-sm">{error.message}</span>
</div>
)}
{/* Call Controls */}
<div className="flex items-center gap-4">
{isActive && (
<>
<Button
onClick={handleToggleMute}
className={`rounded-full p-3 ${
isMuted ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'
}`}
title={isMuted ? 'Unmute microphone' : 'Mute microphone'}
>
{isMuted ? <MicOff size={20} /> : <Mic size={20} />}
</Button>
<Button
onClick={toggleAudio}
className={`rounded-full p-3 ${
!isAudioEnabled ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-700'
}`}
title={isAudioEnabled ? 'Disable speaker' : 'Enable speaker'}
>
{isAudioEnabled ? <Volume2 size={20} /> : <VolumeX size={20} />}
</Button>
</>
)}
{isActive ? (
<Button
onClick={handleHangUp}
className="flex items-center gap-2 rounded-full bg-red-500 px-6 py-3 text-white hover:bg-red-600"
>
<PhoneOff size={20} />
<span>End Call</span>
</Button>
) : (
<Button
onClick={handleStartCall}
disabled={!isConnected || isError || isConnecting}
className="flex items-center gap-2 rounded-full bg-green-500 px-6 py-3 text-white hover:bg-green-600 disabled:opacity-50"
>
<Phone size={20} />
<span>{isConnecting ? 'Connecting...' : 'Start Call'}</span>
</Button>
)}
</div>
{/* Event Log */}
<h3 className="mb-2 text-lg font-medium">Event Log</h3>
<div className="h-64 overflow-y-auto rounded-md bg-surface-secondary p-2 shadow-inner">
<ul className="space-y-1 text-xs text-text-secondary">
{eventLog.map((log, index) => (
<li key={index} className="font-mono">
{log}
</li>
))}
</ul>
</div>
{/* Hidden Audio Element */}
<audio ref={remoteAudioRef} autoPlay>
<track kind="captions" />
</audio>
</div>
</OGDialogContent>
</OGDialog>
);
};

View File

@@ -0,0 +1,40 @@
import React, { forwardRef } from 'react';
import { TooltipAnchor } from '~/components/ui';
import { SendIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const Button = React.memo(
forwardRef((props: { disabled: boolean }) => {
const localize = useLocalize();
return (
<TooltipAnchor
description={localize('com_nav_call_mode')}
render={
<button
aria-label={localize('com_nav_send_message')}
id="call-button"
disabled={props.disabled}
className={cn(
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
data-testid="call-button"
type="submit"
>
<span className="" data-state="closed">
<SendIcon size={24} />
</span>
</button>
}
></TooltipAnchor>
);
}),
);
const CallButton = React.memo(
forwardRef((props: { disabled: boolean }) => {
return <Button disabled={props.disabled} />;
}),
);
export default CallButton;

View File

@@ -34,6 +34,7 @@ import StreamAudio from './StreamAudio';
import StopButton from './StopButton';
import SendButton from './SendButton';
import Mention from './Mention';
import { Call } from './Call';
import store from '~/store';
const ChatForm = ({ index = 0 }) => {
@@ -156,116 +157,119 @@ const ChatForm = ({ index = 0 }) => {
: `pl-${uploadActive ? '12' : '4'} pr-12`;
return (
<form
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
className={cn(
'mx-auto flex flex-row gap-3 pl-2 transition-all duration-200 last:mb-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-2xl xl:max-w-3xl',
)}
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
commandChar="+"
placeholder="com_ui_add_model_preset"
includeAssistants={false}
/>
)}
{showMentionPopover && (
<Mention
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<TemporaryChat
isTemporaryChat={isTemporaryChat}
setIsTemporaryChat={setIsTemporaryChat}
/>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
<>
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isScrollable}
setIsCollapsed={setIsCollapsed}
/>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
textAreaRef.current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onHeightChange={() => {
if (textAreaRef.current) {
const scrollable = checkIfScrollable(textAreaRef.current);
setIsScrollable(scrollable);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => isCollapsed && setIsCollapsed(false)}
onClick={() => isCollapsed && setIsCollapsed(false)}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
speechClass,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
</>
)}
</FileFormWrapper>
{SpeechToText && (
<AudioRecorder
isRTL={isRTL}
methods={methods}
ask={submitMessage}
<>
<form
onSubmit={methods.handleSubmit((data) => submitMessage(data))}
className={cn(
'mx-auto flex flex-row gap-3 pl-2 transition-all duration-200 last:mb-2',
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-2xl xl:max-w-3xl',
)}
>
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
<div className="flex w-full items-center">
{showPlusPopover && !isAssistantsEndpoint(endpoint) && (
<Mention
setShowMentionPopover={setShowPlusPopover}
newConversation={generateConversation}
textAreaRef={textAreaRef}
disabled={!!disableInputs}
isSubmitting={isSubmitting}
commandChar="+"
placeholder="com_ui_add_model_preset"
includeAssistants={false}
/>
)}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
<div
className={cn(
'mb-[5px] ml-[8px] flex flex-col items-end justify-end',
isRTL && 'order-first mr-[8px]',
{showMentionPopover && (
<Mention
setShowMentionPopover={setShowMentionPopover}
newConversation={newConversation}
textAreaRef={textAreaRef}
/>
)}
style={{ alignSelf: 'flex-end' }}
>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
<PromptsCommand index={index} textAreaRef={textAreaRef} submitPrompt={submitPrompt} />
<div className="transitional-all relative flex w-full flex-grow flex-col overflow-hidden rounded-3xl bg-surface-tertiary text-text-primary duration-200">
<TemporaryChat
isTemporaryChat={isTemporaryChat}
setIsTemporaryChat={setIsTemporaryChat}
/>
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<FileFormWrapper disableInputs={disableInputs}>
{endpoint && (
<>
<CollapseChat
isCollapsed={isCollapsed}
isScrollable={isScrollable}
setIsCollapsed={setIsCollapsed}
/>
<TextareaAutosize
{...registerProps}
ref={(e) => {
ref(e);
textAreaRef.current = e;
}}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onHeightChange={() => {
if (textAreaRef.current) {
const scrollable = checkIfScrollable(textAreaRef.current);
setIsScrollable(scrollable);
}
}}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
id={mainTextareaId}
tabIndex={0}
data-testid="text-input"
rows={1}
onFocus={() => isCollapsed && setIsCollapsed(false)}
onClick={() => isCollapsed && setIsCollapsed(false)}
style={{ height: 44, overflowY: 'auto' }}
className={cn(
baseClasses,
speechClass,
removeFocusRings,
'transition-[max-height] duration-200',
)}
/>
</>
)}
</FileFormWrapper>
{SpeechToText && (
<AudioRecorder
isRTL={isRTL}
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={!!disableInputs}
isSubmitting={isSubmitting}
/>
)
)}
)}
{TextToSpeech && automaticPlayback && <StreamAudio index={index} />}
</div>
<div
className={cn(
'mb-[5px] ml-[8px] flex flex-col items-end justify-end',
isRTL && 'order-first mr-[8px]',
)}
style={{ alignSelf: 'flex-end' }}
>
{(isSubmitting || isSubmittingAdded) && (showStopButton || showStopAdded) ? (
<StopButton stop={handleStopGenerating} setShowStopButton={setShowStopButton} />
) : (
endpoint && (
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={!!(filesLoading || isSubmitting || disableInputs)}
/>
)
)}
</div>
</div>
</div>
</div>
</form>
</form>
<Call />
</>
);
};

View File

@@ -55,7 +55,7 @@ const FileUpload: React.FC<FileUploadProps> = ({
let statusText: string;
if (!status) {
statusText = text ?? localize('com_ui_import');
statusText = text ?? localize('com_endpoint_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}
/>

View File

@@ -1,13 +1,8 @@
import { useRecoilState } from 'recoil';
import { Settings2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Root, Anchor } from '@radix-ui/react-popover';
import {
EModelEndpoint,
isParamEndpoint,
isAgentsEndpoint,
tConvoUpdateSchema,
} from 'librechat-data-provider';
import { useState, useEffect, useMemo } from 'react';
import { tConvoUpdateSchema, EModelEndpoint, isParamEndpoint } from 'librechat-data-provider';
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
import { PluginStoreDialog, TooltipAnchor } from '~/components';
@@ -47,6 +42,7 @@ export default function HeaderOptions({
if (endpoint && noSettings[endpoint]) {
setShowPopover(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [endpoint, noSettings]);
const saveAsPreset = () => {
@@ -71,7 +67,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 && !isAgentsEndpoint(endpoint) && (
{interfaceConfig?.modelSelect === true && (
<ModelSelect
conversation={conversation}
setOption={setOption}

View File

@@ -1,49 +1,104 @@
import React, { forwardRef } from 'react';
import { useWatch } from 'react-hook-form';
import { useSetRecoilState } from 'recoil';
import type { TRealtimeEphemeralTokenResponse } from 'librechat-data-provider';
import type { Control } from 'react-hook-form';
import { TooltipAnchor } from '~/components/ui';
import { SendIcon } from '~/components/svg';
import { useRealtimeEphemeralTokenMutation } from '~/data-provider';
import { TooltipAnchor, SendIcon, CallIcon } from '~/components';
import { useToastContext } from '~/Providers/ToastContext';
import { useLocalize } from '~/hooks';
import store from '~/store';
import { cn } from '~/utils';
type SendButtonProps = {
type ButtonProps = {
disabled: boolean;
control: Control<{ text: string }>;
};
const SubmitButton = React.memo(
forwardRef((props: { disabled: boolean }, ref: React.ForwardedRef<HTMLButtonElement>) => {
const localize = useLocalize();
const ActionButton = forwardRef(
(
props: {
disabled: boolean;
icon: React.ReactNode;
tooltip: string;
testId: string;
onClick?: () => void;
},
ref: React.ForwardedRef<HTMLButtonElement>,
) => {
return (
<TooltipAnchor
description={localize('com_nav_send_message')}
description={props.tooltip}
render={
<button
ref={ref}
aria-label={localize('com_nav_send_message')}
id="send-button"
aria-label={props.tooltip}
id="action-button"
disabled={props.disabled}
className={cn(
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4 transition-all duration-200 disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
'rounded-full bg-text-primary p-2 text-text-primary outline-offset-4',
'transition-all duration-200',
'disabled:cursor-not-allowed disabled:text-text-secondary disabled:opacity-10',
)}
data-testid="send-button"
data-testid={props.testId}
type="submit"
onClick={props.onClick}
>
<span className="" data-state="closed">
<SendIcon size={24} />
{props.icon}
</span>
</button>
}
></TooltipAnchor>
/>
);
}),
},
);
const SendButton = React.memo(
forwardRef((props: SendButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
const data = useWatch({ control: props.control });
return <SubmitButton ref={ref} disabled={props.disabled || !data.text} />;
}),
);
const SendButton = forwardRef((props: ButtonProps, ref: React.ForwardedRef<HTMLButtonElement>) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { text = '' } = useWatch({ control: props.control });
const setCallOpen = useSetRecoilState(store.callDialogOpen(0));
// const { mutate: startCall, isLoading: isProcessing } = useRealtimeEphemeralTokenMutation({
// onSuccess: async (data: TRealtimeEphemeralTokenResponse) => {
// showToast({
// message: 'IT WORKS!!',
// status: 'success',
// });
// },
// onError: (error: unknown) => {
// showToast({
// message: localize('com_nav_audio_process_error', (error as Error).message),
// status: 'error',
// });
// },
// });
const handleClick = () => {
if (text.trim() === '') {
setCallOpen(true);
// startCall({ voice: 'verse' });
}
};
const buttonProps =
text.trim() !== ''
? {
icon: <SendIcon size={24} />,
tooltip: localize('com_nav_send_message'),
testId: 'send-button',
}
: {
icon: <CallIcon size={24} />,
tooltip: localize('com_nav_call'),
testId: 'call-button',
onClick: handleClick,
};
return <ActionButton ref={ref} disabled={props.disabled} {...buttonProps} />;
});
SendButton.displayName = 'SendButton';
export default SendButton;

View File

@@ -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 />
</>

View File

@@ -2,39 +2,18 @@ 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">
<PassKeys />
<DisplayUsernameMessages />
</div>
</div>
);

View File

@@ -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,11 +133,9 @@ function Avatar() {
>
<div className="flex items-center justify-between">
<span>{localize('com_nav_profile_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 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>
</div>

View File

@@ -1,194 +0,0 @@
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);

View File

@@ -57,7 +57,7 @@ const DeleteAccount = ({ disabled = false }: { title?: string; disabled?: boolea
</Button>
</OGDialogTrigger>
</div>
<OGDialogContent className="w-11/12 max-w-md">
<OGDialogContent className="w-11/12 max-w-2xl">
<OGDialogHeader>
<OGDialogTitle className="text-lg font-medium leading-6">
{localize('com_nav_delete_account_confirm')}

View File

@@ -1,36 +0,0 @@
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>
);
};

View File

@@ -1,71 +0,0 @@
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>
</>
);
}

View File

@@ -1,298 +0,0 @@
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);

View File

@@ -1,60 +0,0 @@
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>
);
};

View File

@@ -1,88 +0,0 @@
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>
);
};

View File

@@ -1,66 +0,0 @@
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>
);
};

View File

@@ -1,42 +0,0 @@
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>
);
};

View File

@@ -1,58 +0,0 @@
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>
);
};

View File

@@ -1,5 +0,0 @@
export * from './BackupPhase';
export * from './QRPhase';
export * from './VerifyPhase';
export * from './SetupPhase';
export * from './DisablePhase';

View File

@@ -82,7 +82,7 @@ function ImportConversations() {
onClick={handleImportClick}
onKeyDown={handleKeyDown}
disabled={!allowImport}
aria-label={localize('com_ui_import')}
aria-label={localize('com_ui_import_conversation')}
className="btn btn-neutral relative"
>
{allowImport ? (
@@ -90,7 +90,7 @@ function ImportConversations() {
) : (
<Spinner className="mr-1 w-4" />
)}
<span>{localize('com_ui_import')}</span>
<span>{localize('com_ui_import_conversation')}</span>
</button>
<input
ref={fileInputRef}

View File

@@ -270,7 +270,9 @@ export default function SharedLinks() {
<OGDialog open={isOpen} onOpenChange={setIsOpen}>
<OGDialogTrigger asChild onClick={() => setIsOpen(true)}>
<Button variant="outline">{localize('com_ui_manage')}</Button>
<button className="btn btn-neutral relative">
{localize('com_nav_shared_links_manage')}
</button>
</OGDialogTrigger>
<OGDialogContent

View File

@@ -12,7 +12,7 @@ export default function ArchivedChats() {
<OGDialog>
<OGDialogTrigger asChild>
<Button variant="outline" aria-label="Archived chats">
{localize('com_ui_manage')}
{localize('com_nav_archived_chats_manage')}
</Button>
</OGDialogTrigger>
<OGDialogTemplate

View File

@@ -51,17 +51,15 @@ export const LangSelector = ({
const languageOptions = [
{ value: 'auto', label: localize('com_nav_lang_auto') },
{ value: 'en-US', label: localize('com_nav_lang_english') },
{ value: 'zh-Hans', label: localize('com_nav_lang_chinese') },
{ value: 'zh-CN', 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') },

View File

@@ -44,7 +44,7 @@ const Command = ({
}
return (
<div className="rounded-xl border border-border-light shadow-md">
<div className="rounded-xl border border-border-light">
<h3 className="flex h-10 items-center gap-1 pl-4 text-sm text-text-secondary">
<SquareSlash className="icon-sm" aria-hidden="true" />
<Input

View File

@@ -41,7 +41,7 @@ const Description = ({
}
return (
<div className="rounded-xl border border-border-light shadow-md">
<div className="rounded-xl border border-border-light">
<h3 className="flex h-10 items-center gap-1 pl-4 text-sm text-text-secondary">
<Info className="icon-sm" aria-hidden="true" />
<Input

View File

@@ -32,7 +32,7 @@ export default function List({
<div className="flex w-full justify-end">
<Button
variant="outline"
className={`w-full bg-transparent ${isChatRoute ? '' : 'mx-2'}`}
className="w-full bg-transparent px-3"
onClick={() => navigate('/d/prompts/new')}
>
<Plus className="size-4" aria-hidden />

View File

@@ -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 shadow-md transition-all duration-150 sm:p-4',
'w-full flex-1 overflow-auto rounded-b-xl border border-border-light p-2 transition-all duration-150 sm:p-4',
{
'cursor-pointer bg-surface-primary hover:bg-surface-secondary active:bg-surface-tertiary':
!isEditing,
@@ -105,7 +105,6 @@ 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}

View File

@@ -237,6 +237,7 @@ 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} />}
@@ -348,7 +349,7 @@ const PromptForm = () => {
{isLoadingPrompts ? (
<Skeleton className="h-96" aria-live="polite" />
) : (
<div className="mb-2 flex h-full flex-col gap-4">
<div className="flex h-full flex-col gap-4">
<PromptEditor name="prompt" isEditing={isEditing} setIsEditing={setIsEditing} />
<PromptVariables promptText={promptText} />
<Description

View File

@@ -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-sm font-medium text-text-primary">
<span className="text-text-text-primary text-sm font-medium">
{localize('com_ui_dropdown_variables')}
</span>
<span className="text-sm text-text-secondary">

Some files were not shown because too many files have changed in this diff Show More