Compare commits
68 Commits
v0.7.8-rc1
...
feat/Multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edaa357e9d | ||
|
|
bba4184b2c | ||
|
|
b64265e5bf | ||
|
|
28b76ce339 | ||
|
|
eb1668ff22 | ||
|
|
e86842fd19 | ||
|
|
af96666ff4 | ||
|
|
59109cd2dd | ||
|
|
c8f5f5131e | ||
|
|
8c0be0e2f0 | ||
|
|
f8cb0cdcda | ||
|
|
55d52d07f2 | ||
|
|
7ce782fec6 | ||
|
|
c79ee32006 | ||
|
|
739b0d3012 | ||
|
|
9c9fe4e03a | ||
|
|
844bbbb162 | ||
|
|
26780bddf0 | ||
|
|
353adceb0c | ||
|
|
a92ac23c44 | ||
|
|
2a3bf259aa | ||
|
|
3152a1e536 | ||
|
|
2f4a03b581 | ||
|
|
7a91f6ca62 | ||
|
|
fe311df969 | ||
|
|
535e7798b3 | ||
|
|
621fa6e1aa | ||
|
|
f6cc394eab | ||
|
|
a85e853e12 | ||
|
|
5b402a755e | ||
|
|
b0405be9ea | ||
|
|
3f4dd08589 | ||
|
|
d5b399550e | ||
|
|
a5ff8253a4 | ||
|
|
0b44142383 | ||
|
|
502617db24 | ||
|
|
f2f285ca1e | ||
|
|
6dd1b39886 | ||
|
|
5a43f87584 | ||
|
|
4af72aac9b | ||
|
|
f7777a2723 | ||
|
|
e5b234bc72 | ||
|
|
4f2ed46450 | ||
|
|
66093b1eb3 | ||
|
|
d7390d24ec | ||
|
|
71105cd49c | ||
|
|
3606349a0f | ||
|
|
e3e796293c | ||
|
|
7c4c3a8796 | ||
|
|
20c9f1a783 | ||
|
|
8e1012c5aa | ||
|
|
7c92cef2b7 | ||
|
|
4fbb81c774 | ||
|
|
fc6e14efe2 | ||
|
|
6e663b2480 | ||
|
|
ddb2141eac | ||
|
|
37b50736bc | ||
|
|
5d6d13efe8 | ||
|
|
5efad8f646 | ||
|
|
9a7f763714 | ||
|
|
e6e7935fd8 | ||
|
|
da68cac76a | ||
|
|
069781b3a2 | ||
|
|
6333c45271 | ||
|
|
c14751cef5 | ||
|
|
b9b0c03a63 | ||
|
|
42f30a21f7 | ||
|
|
6577144554 |
20
.env.example
20
.env.example
@@ -142,12 +142,12 @@ GOOGLE_KEY=user_provided
|
||||
# GOOGLE_AUTH_HEADER=true
|
||||
|
||||
# Gemini API (AI Studio)
|
||||
# GOOGLE_MODELS=gemini-2.5-pro-exp-03-25,gemini-2.0-flash-exp,gemini-2.0-flash-thinking-exp-1219,gemini-exp-1121,gemini-exp-1114,gemini-1.5-flash-latest,gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
|
||||
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
|
||||
|
||||
# Vertex AI
|
||||
# GOOGLE_MODELS=gemini-1.5-flash-preview-0514,gemini-1.5-pro-preview-0514,gemini-1.0-pro-vision-001,gemini-1.0-pro-002,gemini-1.0-pro-001,gemini-pro-vision,gemini-1.0-pro
|
||||
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
|
||||
|
||||
# GOOGLE_TITLE_MODEL=gemini-pro
|
||||
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
|
||||
|
||||
# GOOGLE_LOC=us-central1
|
||||
|
||||
@@ -424,9 +424,11 @@ APPLE_PRIVATE_KEY_PATH=
|
||||
APPLE_CALLBACK_URL=/oauth/apple/callback
|
||||
|
||||
# OpenID
|
||||
OPENID_CLIENT_ID=
|
||||
OPENID_CLIENT_SECRET=
|
||||
OPENID_ISSUER=
|
||||
OPENID_ENABLED=
|
||||
OPENID_MULTI_TENANT=
|
||||
#OPENID_CLIENT_ID=
|
||||
#OPENID_CLIENT_SECRET=
|
||||
#OPENID_ISSUER=
|
||||
OPENID_SESSION_SECRET=
|
||||
OPENID_SCOPE="openid profile email"
|
||||
OPENID_CALLBACK_URL=/oauth/openid/callback
|
||||
@@ -563,9 +565,9 @@ HELP_AND_FAQ_URL=https://librechat.ai
|
||||
# users always get the latest version. Customize #
|
||||
# only if you understand caching implications. #
|
||||
|
||||
# INDEX_HTML_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
||||
# INDEX_HTML_PRAGMA=no-cache
|
||||
# INDEX_HTML_EXPIRES=0
|
||||
# INDEX_CACHE_CONTROL=no-cache, no-store, must-revalidate
|
||||
# INDEX_PRAGMA=no-cache
|
||||
# INDEX_EXPIRES=0
|
||||
|
||||
# no-cache: Forces validation with server before using cached version
|
||||
# no-store: Prevents storing the response entirely
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
generate-release-changelog-pr:
|
||||
@@ -88,7 +89,7 @@ jobs:
|
||||
base: main
|
||||
branch: "changelog/${{ github.ref_name }}"
|
||||
reviewers: danny-avila
|
||||
title: "chore: update CHANGELOG for release ${{ github.ref_name }}"
|
||||
title: "📜 docs: Changelog for release ${{ github.ref_name }}"
|
||||
body: |
|
||||
**Description**:
|
||||
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.
|
||||
- This PR updates the CHANGELOG.md by removing the "Unreleased" section and adding new release notes for release ${{ github.ref_name }} above previous releases.
|
||||
|
||||
@@ -99,9 +99,9 @@ jobs:
|
||||
branch: "changelog/unreleased-update"
|
||||
sign-commits: true
|
||||
commit-message: "action: update Unreleased changelog"
|
||||
title: "action: update Unreleased changelog"
|
||||
title: "📜 docs: 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.
|
||||
regenerates the Unreleased changelog, removes any old Unreleased block, and inserts the new content.
|
||||
|
||||
3
.github/workflows/helmcharts.yml
vendored
3
.github/workflows/helmcharts.yml
vendored
@@ -29,5 +29,8 @@ jobs:
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@v1.6.0
|
||||
with:
|
||||
charts_dir: helm
|
||||
skip_existing: true
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
2
.github/workflows/i18n-unused-keys.yml
vendored
2
.github/workflows/i18n-unused-keys.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|
||||
# Define paths
|
||||
I18N_FILE="client/src/locales/en/translation.json"
|
||||
SOURCE_DIRS=("client/src" "api")
|
||||
SOURCE_DIRS=("client/src" "api" "packages/data-provider/src")
|
||||
|
||||
# Check if translation file exists
|
||||
if [[ ! -f "$I18N_FILE" ]]; then
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -113,4 +113,11 @@ uploads/
|
||||
|
||||
# owner
|
||||
release/
|
||||
|
||||
# Helm
|
||||
helm/librechat/Chart.lock
|
||||
helm/**/charts/
|
||||
helm/**/.values.yaml
|
||||
|
||||
!/client/src/@types/i18next.d.ts
|
||||
|
||||
|
||||
85
CHANGELOG.md
85
CHANGELOG.md
@@ -3,10 +3,90 @@
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- ✨ feat: implement search parameter updates by **@mawburn** in [#7151](https://github.com/danny-avila/LibreChat/pull/7151)
|
||||
- 🎏 feat: Add MCP support for Streamable HTTP Transport by **@benverhees** in [#7353](https://github.com/danny-avila/LibreChat/pull/7353)
|
||||
- 🔒 feat: Add Content Security Policy using Helmet middleware by **@rubentalstra** in [#7377](https://github.com/danny-avila/LibreChat/pull/7377)
|
||||
- ✨ feat: Add Normalization for MCP Server Names by **@danny-avila** in [#7421](https://github.com/danny-avila/LibreChat/pull/7421)
|
||||
- 📊 feat: Improve Helm Chart by **@hofq** in [#3638](https://github.com/danny-avila/LibreChat/pull/3638)
|
||||
|
||||
### 🌍 Internationalization
|
||||
|
||||
- 🌍 i18n: Add `Danish` and `Czech` and `Catalan` localization support by **@rubentalstra** in [#7373](https://github.com/danny-avila/LibreChat/pull/7373)
|
||||
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7375](https://github.com/danny-avila/LibreChat/pull/7375)
|
||||
|
||||
### 🔧 Fixes
|
||||
|
||||
- 💬 fix: update aria-label for accessibility in ConvoLink component by **@berry-13** in [#7320](https://github.com/danny-avila/LibreChat/pull/7320)
|
||||
- 🔑 fix: use `apiKey` instead of `openAIApiKey` in OpenAI-like Config by **@danny-avila** in [#7337](https://github.com/danny-avila/LibreChat/pull/7337)
|
||||
- 🔄 fix: update navigation logic in `useFocusChatEffect` to ensure correct search parameters are used by **@mawburn** in [#7340](https://github.com/danny-avila/LibreChat/pull/7340)
|
||||
- 🔄 fix: Improve MCP Connection Cleanup by **@danny-avila** in [#7400](https://github.com/danny-avila/LibreChat/pull/7400)
|
||||
- 🛡️ fix: Preset and Validation Logic for URL Query Params by **@danny-avila** in [#7407](https://github.com/danny-avila/LibreChat/pull/7407)
|
||||
- 🌘 fix: artifact of preview text is illegible in dark mode by **@nhtruong** in [#7405](https://github.com/danny-avila/LibreChat/pull/7405)
|
||||
- 🛡️ fix: Temporarily Remove CSP until Configurable by **@danny-avila** in [#7419](https://github.com/danny-avila/LibreChat/pull/7419)
|
||||
- 💽 fix: Exclude index page `/` from static cache settings by **@sbruel** in [#7382](https://github.com/danny-avila/LibreChat/pull/7382)
|
||||
|
||||
### ⚙️ Other Changes
|
||||
|
||||
- 📜 docs: CHANGELOG for release v0.7.8 by **@github-actions[bot]** in [#7290](https://github.com/danny-avila/LibreChat/pull/7290)
|
||||
- 📦 chore: Update API Package Dependencies by **@danny-avila** in [#7359](https://github.com/danny-avila/LibreChat/pull/7359)
|
||||
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7321](https://github.com/danny-avila/LibreChat/pull/7321)
|
||||
|
||||
|
||||
|
||||
---
|
||||
## [v0.7.8] -
|
||||
|
||||
Changes from v0.7.8-rc1 to v0.7.8.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- ✨ feat: Enhance form submission for touch screens by **@berry-13** in [#7198](https://github.com/danny-avila/LibreChat/pull/7198)
|
||||
- 🔍 feat: Additional Tavily API Tool Parameters by **@glowforge-opensource** in [#7232](https://github.com/danny-avila/LibreChat/pull/7232)
|
||||
- 🐋 feat: Add python to Dockerfile for increased MCP compatibility by **@technicalpickles** in [#7270](https://github.com/danny-avila/LibreChat/pull/7270)
|
||||
|
||||
### 🔧 Fixes
|
||||
|
||||
- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
|
||||
- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
|
||||
- 🔄 fix: o-Series Model Regex for System Messages by **@danny-avila** in [#7245](https://github.com/danny-avila/LibreChat/pull/7245)
|
||||
- 🔖 fix: Custom Headers for Initial MCP SSE Connection by **@danny-avila** in [#7246](https://github.com/danny-avila/LibreChat/pull/7246)
|
||||
- 🛡️ fix: Deep Clone `MCPOptions` for User MCP Connections by **@danny-avila** in [#7247](https://github.com/danny-avila/LibreChat/pull/7247)
|
||||
- 🔄 fix: URL Param Race Condition and File Draft Persistence by **@danny-avila** in [#7257](https://github.com/danny-avila/LibreChat/pull/7257)
|
||||
- 🔄 fix: Assistants Endpoint & Minor Issues by **@danny-avila** in [#7274](https://github.com/danny-avila/LibreChat/pull/7274)
|
||||
- 🔄 fix: Ollama Think Tag Edge Case with Tools by **@danny-avila** in [#7275](https://github.com/danny-avila/LibreChat/pull/7275)
|
||||
|
||||
### ⚙️ Other Changes
|
||||
|
||||
- 📜 docs: CHANGELOG for release v0.7.8-rc1 by **@github-actions[bot]** in [#7153](https://github.com/danny-avila/LibreChat/pull/7153)
|
||||
- 🔄 refactor: Artifact Visibility Management by **@danny-avila** in [#7181](https://github.com/danny-avila/LibreChat/pull/7181)
|
||||
- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
|
||||
- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
|
||||
- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
|
||||
- 🎨 style: Improve KaTeX Rendering for LaTeX Equations by **@andresgit** in [#7223](https://github.com/danny-avila/LibreChat/pull/7223)
|
||||
- 📝 docs: Update `.env.example` Google models by **@marlonka** in [#7254](https://github.com/danny-avila/LibreChat/pull/7254)
|
||||
- 💬 refactor: MCP Chat Visibility Option, Google Rates, Remove OpenAPI Plugins by **@danny-avila** in [#7286](https://github.com/danny-avila/LibreChat/pull/7286)
|
||||
- 📜 docs: Unreleased Changelog by **@github-actions[bot]** in [#7214](https://github.com/danny-avila/LibreChat/pull/7214)
|
||||
|
||||
|
||||
|
||||
[See full release details][release-v0.7.8]
|
||||
|
||||
[release-v0.7.8]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8
|
||||
|
||||
---
|
||||
## [v0.7.8-rc1] -
|
||||
|
||||
Changes from v0.7.7 to v0.7.8-rc1.
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- 🔍 feat: Mistral OCR API / Upload Files as Text by **@danny-avila** in [#6274](https://github.com/danny-avila/LibreChat/pull/6274)
|
||||
- 🤖 feat: Support OpenAI Web Search models by **@danny-avila** in [#6313](https://github.com/danny-avila/LibreChat/pull/6313)
|
||||
- 🔗 feat: Agent Chain (Mixture-of-Agents) by **@danny-avila** in [#6374](https://github.com/danny-avila/LibreChat/pull/6374)
|
||||
@@ -136,7 +216,12 @@ All notable changes to this project will be documented in this file.
|
||||
- 🧭 refactor: Modernize Nav/Header by **@danny-avila** in [#7094](https://github.com/danny-avila/LibreChat/pull/7094)
|
||||
- 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations by **@danny-avila** in [#7100](https://github.com/danny-avila/LibreChat/pull/7100)
|
||||
- 🔃 refactor: Streamline Navigation, Message Loading UX by **@danny-avila** in [#7118](https://github.com/danny-avila/LibreChat/pull/7118)
|
||||
- 📜 docs: Unreleased changelog by **@github-actions[bot]** in [#6265](https://github.com/danny-avila/LibreChat/pull/6265)
|
||||
|
||||
|
||||
|
||||
[See full release details][release-v0.7.8-rc1]
|
||||
|
||||
[release-v0.7.8-rc1]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8-rc1
|
||||
|
||||
---
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# v0.7.8-rc1
|
||||
# v0.7.8
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
# Install jemalloc
|
||||
RUN apk add --no-cache jemalloc
|
||||
RUN apk add --no-cache python3 py3-pip uv
|
||||
|
||||
# Set environment variable to use jemalloc
|
||||
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.7.8-rc1
|
||||
# v0.7.8
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -396,13 +396,13 @@ class AnthropicClient extends BaseClient {
|
||||
const formattedMessages = orderedMessages.map((message, i) => {
|
||||
const formattedMessage = this.useMessages
|
||||
? formatMessage({
|
||||
message,
|
||||
endpoint: EModelEndpoint.anthropic,
|
||||
})
|
||||
message,
|
||||
endpoint: EModelEndpoint.anthropic,
|
||||
})
|
||||
: {
|
||||
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
|
||||
content: message?.content ?? message.text,
|
||||
};
|
||||
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
|
||||
content: message?.content ?? message.text,
|
||||
};
|
||||
|
||||
const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount;
|
||||
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
|
||||
@@ -680,7 +680,7 @@ class AnthropicClient extends BaseClient {
|
||||
}
|
||||
|
||||
getCompletion() {
|
||||
logger.debug('AnthropicClient doesn\'t use getCompletion (all handled in sendCompletion)');
|
||||
logger.debug("AnthropicClient doesn't use getCompletion (all handled in sendCompletion)");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -888,7 +888,7 @@ class AnthropicClient extends BaseClient {
|
||||
}
|
||||
|
||||
getBuildMessagesOptions() {
|
||||
logger.debug('AnthropicClient doesn\'t use getBuildMessagesOptions');
|
||||
logger.debug("AnthropicClient doesn't use getBuildMessagesOptions");
|
||||
}
|
||||
|
||||
getEncoding() {
|
||||
|
||||
@@ -63,15 +63,15 @@ class BaseClient {
|
||||
}
|
||||
|
||||
setOptions() {
|
||||
throw new Error('Method \'setOptions\' must be implemented.');
|
||||
throw new Error("Method 'setOptions' must be implemented.");
|
||||
}
|
||||
|
||||
async getCompletion() {
|
||||
throw new Error('Method \'getCompletion\' must be implemented.');
|
||||
throw new Error("Method 'getCompletion' must be implemented.");
|
||||
}
|
||||
|
||||
async sendCompletion() {
|
||||
throw new Error('Method \'sendCompletion\' must be implemented.');
|
||||
throw new Error("Method 'sendCompletion' must be implemented.");
|
||||
}
|
||||
|
||||
getSaveOptions() {
|
||||
@@ -237,11 +237,11 @@ class BaseClient {
|
||||
const userMessage = opts.isEdited
|
||||
? this.currentMessages[this.currentMessages.length - 2]
|
||||
: this.createUserMessage({
|
||||
messageId: userMessageId,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
text: message,
|
||||
});
|
||||
messageId: userMessageId,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
text: message,
|
||||
});
|
||||
|
||||
if (typeof opts?.getReqData === 'function') {
|
||||
opts.getReqData({
|
||||
|
||||
@@ -140,8 +140,7 @@ class GoogleClient extends BaseClient {
|
||||
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
|
||||
|
||||
/** @type {boolean} Whether using a "GenerativeAI" Model */
|
||||
this.isGenerativeModel =
|
||||
this.modelOptions.model.includes('gemini') || this.modelOptions.model.includes('learnlm');
|
||||
this.isGenerativeModel = /gemini|learnlm|gemma/.test(this.modelOptions.model);
|
||||
|
||||
this.maxContextTokens =
|
||||
this.options.maxContextTokens ??
|
||||
|
||||
@@ -475,7 +475,9 @@ class OpenAIClient extends BaseClient {
|
||||
promptPrefix = this.augmentedPrompt + promptPrefix;
|
||||
}
|
||||
|
||||
if (promptPrefix && this.isOmni !== true) {
|
||||
const noSystemModelRegex = /\b(o1-preview|o1-mini)\b/i.test(this.modelOptions.model);
|
||||
|
||||
if (promptPrefix && !noSystemModelRegex) {
|
||||
promptPrefix = `Instructions:\n${promptPrefix.trim()}`;
|
||||
instructions = {
|
||||
role: 'system',
|
||||
@@ -503,7 +505,7 @@ class OpenAIClient extends BaseClient {
|
||||
};
|
||||
|
||||
/** EXPERIMENTAL */
|
||||
if (promptPrefix && this.isOmni === true) {
|
||||
if (promptPrefix && noSystemModelRegex) {
|
||||
const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user');
|
||||
if (lastUserMessageIndex !== -1) {
|
||||
if (Array.isArray(payload[lastUserMessageIndex].content)) {
|
||||
@@ -1227,9 +1229,9 @@ ${convo}
|
||||
|
||||
opts.baseURL = this.langchainProxy
|
||||
? constructAzureURL({
|
||||
baseURL: this.langchainProxy,
|
||||
azureOptions: this.azure,
|
||||
})
|
||||
baseURL: this.langchainProxy,
|
||||
azureOptions: this.azure,
|
||||
})
|
||||
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
|
||||
|
||||
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
|
||||
@@ -1283,6 +1285,14 @@ ${convo}
|
||||
modelOptions.messages[0].role = 'user';
|
||||
}
|
||||
|
||||
if (
|
||||
(this.options.endpoint === EModelEndpoint.openAI ||
|
||||
this.options.endpoint === EModelEndpoint.azureOpenAI) &&
|
||||
modelOptions.stream === true
|
||||
) {
|
||||
modelOptions.stream_options = { include_usage: true };
|
||||
}
|
||||
|
||||
if (this.options.addParams && typeof this.options.addParams === 'object') {
|
||||
const addParams = { ...this.options.addParams };
|
||||
modelOptions = {
|
||||
@@ -1385,12 +1395,6 @@ ${convo}
|
||||
...modelOptions,
|
||||
stream: true,
|
||||
};
|
||||
if (
|
||||
this.options.endpoint === EModelEndpoint.openAI ||
|
||||
this.options.endpoint === EModelEndpoint.azureOpenAI
|
||||
) {
|
||||
params.stream_options = { include_usage: true };
|
||||
}
|
||||
const stream = await openai.beta.chat.completions
|
||||
.stream(params)
|
||||
.on('abort', () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ const DEFAULT_IMAGE_EDIT_DESCRIPTION =
|
||||
|
||||
When to use \`image_edit_oai\`:
|
||||
- The user wants to modify, extend, or remix one **or more** uploaded images, either:
|
||||
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
|
||||
- Previously generated, or in the current request (both to be included in the \`image_ids\` array).
|
||||
- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements.
|
||||
- Any current or existing images are to be used as visual guides.
|
||||
- If there are any files in the current request, they are more likely than not expected as references for image edit requests.
|
||||
|
||||
@@ -43,9 +43,39 @@ class TavilySearchResults extends Tool {
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether to include answers in the search results. Default is False.'),
|
||||
// include_raw_content: z.boolean().optional().describe('Whether to include raw content in the search results. Default is False.'),
|
||||
// include_domains: z.array(z.string()).optional().describe('A list of domains to specifically include in the search results.'),
|
||||
// exclude_domains: z.array(z.string()).optional().describe('A list of domains to specifically exclude from the search results.'),
|
||||
include_raw_content: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether to include raw content in the search results. Default is False.'),
|
||||
include_domains: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('A list of domains to specifically include in the search results.'),
|
||||
exclude_domains: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('A list of domains to specifically exclude from the search results.'),
|
||||
topic: z
|
||||
.enum(['general', 'news', 'finance'])
|
||||
.optional()
|
||||
.describe(
|
||||
'The category of the search. Use news ONLY if query SPECIFCALLY mentions the word "news".',
|
||||
),
|
||||
time_range: z
|
||||
.enum(['day', 'week', 'month', 'year', 'd', 'w', 'm', 'y'])
|
||||
.optional()
|
||||
.describe('The time range back from the current date to filter results.'),
|
||||
days: z
|
||||
.number()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Number of days back from the current date to include. Only if topic is news.'),
|
||||
include_image_descriptions: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'When include_images is true, also add a descriptive text for each image. Default is false.',
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
const { loadSpecs } = require('./loadSpecs');
|
||||
|
||||
function transformSpec(input) {
|
||||
return {
|
||||
name: input.name_for_human,
|
||||
pluginKey: input.name_for_model,
|
||||
description: input.description_for_human,
|
||||
icon: input?.logo_url ?? 'https://placehold.co/70x70.png',
|
||||
// TODO: add support for authentication
|
||||
isAuthRequired: 'false',
|
||||
authConfig: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function addOpenAPISpecs(availableTools) {
|
||||
try {
|
||||
const specs = (await loadSpecs({})).map(transformSpec);
|
||||
if (specs.length > 0) {
|
||||
return [...specs, ...availableTools];
|
||||
}
|
||||
return availableTools;
|
||||
} catch (error) {
|
||||
return availableTools;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
transformSpec,
|
||||
addOpenAPISpecs,
|
||||
};
|
||||
@@ -1,76 +0,0 @@
|
||||
const { addOpenAPISpecs, transformSpec } = require('./addOpenAPISpecs');
|
||||
const { loadSpecs } = require('./loadSpecs');
|
||||
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
|
||||
|
||||
jest.mock('./loadSpecs');
|
||||
jest.mock('../dynamic/OpenAPIPlugin');
|
||||
|
||||
describe('transformSpec', () => {
|
||||
it('should transform input spec to a desired format', () => {
|
||||
const input = {
|
||||
name_for_human: 'Human Name',
|
||||
name_for_model: 'Model Name',
|
||||
description_for_human: 'Human Description',
|
||||
logo_url: 'https://example.com/logo.png',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
name: 'Human Name',
|
||||
pluginKey: 'Model Name',
|
||||
description: 'Human Description',
|
||||
icon: 'https://example.com/logo.png',
|
||||
isAuthRequired: 'false',
|
||||
authConfig: [],
|
||||
};
|
||||
|
||||
expect(transformSpec(input)).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('should use default icon if logo_url is not provided', () => {
|
||||
const input = {
|
||||
name_for_human: 'Human Name',
|
||||
name_for_model: 'Model Name',
|
||||
description_for_human: 'Human Description',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
name: 'Human Name',
|
||||
pluginKey: 'Model Name',
|
||||
description: 'Human Description',
|
||||
icon: 'https://placehold.co/70x70.png',
|
||||
isAuthRequired: 'false',
|
||||
authConfig: [],
|
||||
};
|
||||
|
||||
expect(transformSpec(input)).toEqual(expectedOutput);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addOpenAPISpecs', () => {
|
||||
it('should add specs to available tools', async () => {
|
||||
const availableTools = ['Tool1', 'Tool2'];
|
||||
const specs = [
|
||||
{
|
||||
name_for_human: 'Human Name',
|
||||
name_for_model: 'Model Name',
|
||||
description_for_human: 'Human Description',
|
||||
logo_url: 'https://example.com/logo.png',
|
||||
},
|
||||
];
|
||||
|
||||
loadSpecs.mockResolvedValue(specs);
|
||||
createOpenAPIPlugin.mockReturnValue('Plugin');
|
||||
|
||||
const result = await addOpenAPISpecs(availableTools);
|
||||
expect(result).toEqual([...specs.map(transformSpec), ...availableTools]);
|
||||
});
|
||||
|
||||
it('should return available tools if specs loading fails', async () => {
|
||||
const availableTools = ['Tool1', 'Tool2'];
|
||||
|
||||
loadSpecs.mockRejectedValue(new Error('Failed to load specs'));
|
||||
|
||||
const result = await addOpenAPISpecs(availableTools);
|
||||
expect(result).toEqual(availableTools);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,6 @@ const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/pro
|
||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { createMCPTool } = require('~/server/services/MCP');
|
||||
const { loadSpecs } = require('./loadSpecs');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
|
||||
@@ -232,7 +231,6 @@ const loadTools = async ({
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const toolContextMap = {};
|
||||
const remainingTools = [];
|
||||
const appTools = options.req?.app?.locals?.availableTools ?? {};
|
||||
|
||||
for (const tool of tools) {
|
||||
@@ -292,30 +290,6 @@ const loadTools = async ({
|
||||
requestedTools[tool] = toolInstance;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (functions === true) {
|
||||
remainingTools.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
let specs = null;
|
||||
if (useSpecs === true && functions === true && remainingTools.length > 0) {
|
||||
specs = await loadSpecs({
|
||||
llm: model,
|
||||
user,
|
||||
message: options.message,
|
||||
memory: options.memory,
|
||||
signal: options.signal,
|
||||
tools: remainingTools,
|
||||
map: true,
|
||||
verbose: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const tool of remainingTools) {
|
||||
if (specs && specs[tool]) {
|
||||
requestedTools[tool] = specs[tool];
|
||||
}
|
||||
}
|
||||
|
||||
if (returnMap) {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { z } = require('zod');
|
||||
const { logger } = require('~/config');
|
||||
const { createOpenAPIPlugin } = require('~/app/clients/tools/dynamic/OpenAPIPlugin');
|
||||
|
||||
// The minimum Manifest definition
|
||||
const ManifestDefinition = z.object({
|
||||
schema_version: z.string().optional(),
|
||||
name_for_human: z.string(),
|
||||
name_for_model: z.string(),
|
||||
description_for_human: z.string(),
|
||||
description_for_model: z.string(),
|
||||
auth: z.object({}).optional(),
|
||||
api: z.object({
|
||||
// Spec URL or can be the filename of the OpenAPI spec yaml file,
|
||||
// located in api\app\clients\tools\.well-known\openapi
|
||||
url: z.string(),
|
||||
type: z.string().optional(),
|
||||
is_user_authenticated: z.boolean().nullable().optional(),
|
||||
has_user_authentication: z.boolean().nullable().optional(),
|
||||
}),
|
||||
// use to override any params that the LLM will consistently get wrong
|
||||
params: z.object({}).optional(),
|
||||
logo_url: z.string().optional(),
|
||||
contact_email: z.string().optional(),
|
||||
legal_info_url: z.string().optional(),
|
||||
});
|
||||
|
||||
function validateJson(json) {
|
||||
try {
|
||||
return ManifestDefinition.parse(json);
|
||||
} catch (error) {
|
||||
logger.debug('[validateJson] manifest parsing error', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// omit the LLM to return the well known jsons as objects
|
||||
async function loadSpecs({ llm, user, message, tools = [], map = false, memory, signal }) {
|
||||
const directoryPath = path.join(__dirname, '..', '.well-known');
|
||||
let files = [];
|
||||
|
||||
for (let i = 0; i < tools.length; i++) {
|
||||
const filePath = path.join(directoryPath, tools[i] + '.json');
|
||||
|
||||
try {
|
||||
// If the access Promise is resolved, it means that the file exists
|
||||
// Then we can add it to the files array
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
files.push(tools[i] + '.json');
|
||||
} catch (err) {
|
||||
logger.error(`[loadSpecs] File ${tools[i] + '.json'} does not exist`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
files = (await fs.promises.readdir(directoryPath)).filter(
|
||||
(file) => path.extname(file) === '.json',
|
||||
);
|
||||
}
|
||||
|
||||
const validJsons = [];
|
||||
const constructorMap = {};
|
||||
|
||||
logger.debug('[validateJson] files', files);
|
||||
|
||||
for (const file of files) {
|
||||
if (path.extname(file) === '.json') {
|
||||
const filePath = path.join(directoryPath, file);
|
||||
const fileContent = await fs.promises.readFile(filePath, 'utf8');
|
||||
const json = JSON.parse(fileContent);
|
||||
|
||||
if (!validateJson(json)) {
|
||||
logger.debug('[validateJson] Invalid json', json);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (llm && map) {
|
||||
constructorMap[json.name_for_model] = async () =>
|
||||
await createOpenAPIPlugin({
|
||||
data: json,
|
||||
llm,
|
||||
message,
|
||||
memory,
|
||||
signal,
|
||||
user,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (llm) {
|
||||
validJsons.push(createOpenAPIPlugin({ data: json, llm }));
|
||||
continue;
|
||||
}
|
||||
|
||||
validJsons.push(json);
|
||||
}
|
||||
}
|
||||
|
||||
if (map) {
|
||||
return constructorMap;
|
||||
}
|
||||
|
||||
const plugins = (await Promise.all(validJsons)).filter((plugin) => plugin);
|
||||
|
||||
// logger.debug('[validateJson] plugins', plugins);
|
||||
// logger.debug(plugins[0].name);
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadSpecs,
|
||||
validateJson,
|
||||
ManifestDefinition,
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const { validateJson, loadSpecs, ManifestDefinition } = require('./loadSpecs');
|
||||
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
|
||||
|
||||
jest.mock('../dynamic/OpenAPIPlugin');
|
||||
|
||||
describe('ManifestDefinition', () => {
|
||||
it('should validate correct json', () => {
|
||||
const json = {
|
||||
name_for_human: 'Test',
|
||||
name_for_model: 'Test',
|
||||
description_for_human: 'Test',
|
||||
description_for_model: 'Test',
|
||||
api: {
|
||||
url: 'http://test.com',
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => ManifestDefinition.parse(json)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not validate incorrect json', () => {
|
||||
const json = {
|
||||
name_for_human: 'Test',
|
||||
name_for_model: 'Test',
|
||||
description_for_human: 'Test',
|
||||
description_for_model: 'Test',
|
||||
api: {
|
||||
url: 123, // incorrect type
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => ManifestDefinition.parse(json)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateJson', () => {
|
||||
it('should return parsed json if valid', () => {
|
||||
const json = {
|
||||
name_for_human: 'Test',
|
||||
name_for_model: 'Test',
|
||||
description_for_human: 'Test',
|
||||
description_for_model: 'Test',
|
||||
api: {
|
||||
url: 'http://test.com',
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateJson(json)).toEqual(json);
|
||||
});
|
||||
|
||||
it('should return false if json is not valid', () => {
|
||||
const json = {
|
||||
name_for_human: 'Test',
|
||||
name_for_model: 'Test',
|
||||
description_for_human: 'Test',
|
||||
description_for_model: 'Test',
|
||||
api: {
|
||||
url: 123, // incorrect type
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateJson(json)).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSpecs', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['test.json']);
|
||||
jest.spyOn(fs.promises, 'readFile').mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name_for_human: 'Test',
|
||||
name_for_model: 'Test',
|
||||
description_for_human: 'Test',
|
||||
description_for_model: 'Test',
|
||||
api: {
|
||||
url: 'http://test.com',
|
||||
},
|
||||
}),
|
||||
);
|
||||
createOpenAPIPlugin.mockResolvedValue({});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return plugins', async () => {
|
||||
const plugins = await loadSpecs({ llm: true, verbose: false });
|
||||
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(createOpenAPIPlugin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return constructorMap if map is true', async () => {
|
||||
const plugins = await loadSpecs({ llm: {}, map: true, verbose: false });
|
||||
|
||||
expect(plugins).toHaveProperty('Test');
|
||||
expect(createOpenAPIPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,19 @@ const Agent = mongoose.model('agent', agentSchema);
|
||||
* @throws {Error} If the agent creation fails.
|
||||
*/
|
||||
const createAgent = async (agentData) => {
|
||||
return (await Agent.create(agentData)).toObject();
|
||||
const { versions, ...versionData } = agentData;
|
||||
const timestamp = new Date();
|
||||
const initialAgentData = {
|
||||
...agentData,
|
||||
versions: [
|
||||
{
|
||||
...versionData,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
],
|
||||
};
|
||||
return (await Agent.create(initialAgentData)).toObject();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -103,6 +115,8 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
agent.version = agent.versions ? agent.versions.length : 0;
|
||||
|
||||
if (agent.author.toString() === req.user.id) {
|
||||
return agent;
|
||||
}
|
||||
@@ -127,18 +141,146 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a version already exists in the versions array, excluding timestamp and author fields
|
||||
* @param {Object} updateData - The update data to compare
|
||||
* @param {Array} versions - The existing versions array
|
||||
* @returns {Object|null} - The matching version if found, null otherwise
|
||||
*/
|
||||
const isDuplicateVersion = (updateData, currentData, versions) => {
|
||||
if (!versions || versions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const excludeFields = [
|
||||
'_id',
|
||||
'id',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'author',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'__v',
|
||||
'agent_ids',
|
||||
'versions',
|
||||
];
|
||||
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
|
||||
if (Object.keys(directUpdates).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wouldBeVersion = { ...currentData, ...directUpdates };
|
||||
const lastVersion = versions[versions.length - 1];
|
||||
|
||||
const allFields = new Set([...Object.keys(wouldBeVersion), ...Object.keys(lastVersion)]);
|
||||
|
||||
const importantFields = Array.from(allFields).filter((field) => !excludeFields.includes(field));
|
||||
|
||||
let isMatch = true;
|
||||
for (const field of importantFields) {
|
||||
if (!wouldBeVersion[field] && !lastVersion[field]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
|
||||
if (wouldBeVersion[field].length !== lastVersion[field].length) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Special handling for projectIds (MongoDB ObjectIds)
|
||||
if (field === 'projectIds') {
|
||||
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
|
||||
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
|
||||
|
||||
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Handle arrays of objects like tool_kwargs
|
||||
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
|
||||
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
|
||||
|
||||
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const sortedWouldBe = [...wouldBeVersion[field]].sort();
|
||||
const sortedVersion = [...lastVersion[field]].sort();
|
||||
|
||||
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (field === 'model_parameters') {
|
||||
const wouldBeParams = wouldBeVersion[field] || {};
|
||||
const lastVersionParams = lastVersion[field] || {};
|
||||
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
} else if (wouldBeVersion[field] !== lastVersion[field]) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return isMatch ? lastVersion : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an agent with new data without overwriting existing
|
||||
* properties, or create a new agent if it doesn't exist.
|
||||
* When an agent is updated, a copy of the current state will be saved to the versions array.
|
||||
*
|
||||
* @param {Object} searchParameter - The search parameters to find the agent to update.
|
||||
* @param {string} searchParameter.id - The ID of the agent to update.
|
||||
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||
* @param {Object} updateData - An object containing the properties to update.
|
||||
* @returns {Promise<Agent>} The updated or newly created agent document as a plain object.
|
||||
* @throws {Error} If the update would create a duplicate version
|
||||
*/
|
||||
const updateAgent = async (searchParameter, updateData) => {
|
||||
const options = { new: true, upsert: false };
|
||||
|
||||
const currentAgent = await Agent.findOne(searchParameter);
|
||||
if (currentAgent) {
|
||||
const { __v, _id, id, versions, ...versionData } = currentAgent.toObject();
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
|
||||
if (Object.keys(directUpdates).length > 0 && versions && versions.length > 0) {
|
||||
const duplicateVersion = isDuplicateVersion(updateData, versionData, versions);
|
||||
if (duplicateVersion) {
|
||||
const error = new Error(
|
||||
'Duplicate version: This would create a version identical to an existing one',
|
||||
);
|
||||
error.statusCode = 409;
|
||||
error.details = {
|
||||
duplicateVersion,
|
||||
versionIndex: versions.findIndex(
|
||||
(v) => JSON.stringify(duplicateVersion) === JSON.stringify(v),
|
||||
),
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
updateData.$push = {
|
||||
...($push || {}),
|
||||
versions: {
|
||||
...versionData,
|
||||
...directUpdates,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return Agent.findOneAndUpdate(searchParameter, updateData, options).lean();
|
||||
};
|
||||
|
||||
@@ -358,6 +500,38 @@ const updateAgentProjects = async ({ user, agentId, projectIds, removeProjectIds
|
||||
return await getAgent({ id: agentId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts an agent to a specific version in its version history.
|
||||
* @param {Object} searchParameter - The search parameters to find the agent to revert.
|
||||
* @param {string} searchParameter.id - The ID of the agent to revert.
|
||||
* @param {string} [searchParameter.author] - The user ID of the agent's author.
|
||||
* @param {number} versionIndex - The index of the version to revert to in the versions array.
|
||||
* @returns {Promise<MongoAgent>} The updated agent document after reverting.
|
||||
* @throws {Error} If the agent is not found or the specified version does not exist.
|
||||
*/
|
||||
const revertAgentVersion = async (searchParameter, versionIndex) => {
|
||||
const agent = await Agent.findOne(searchParameter);
|
||||
if (!agent) {
|
||||
throw new Error('Agent not found');
|
||||
}
|
||||
|
||||
if (!agent.versions || !agent.versions[versionIndex]) {
|
||||
throw new Error(`Version ${versionIndex} not found`);
|
||||
}
|
||||
|
||||
const revertToVersion = agent.versions[versionIndex];
|
||||
|
||||
const updateData = {
|
||||
...revertToVersion,
|
||||
};
|
||||
|
||||
delete updateData._id;
|
||||
delete updateData.id;
|
||||
delete updateData.versions;
|
||||
|
||||
return Agent.findOneAndUpdate(searchParameter, updateData, { new: true }).lean();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
Agent,
|
||||
getAgent,
|
||||
@@ -369,4 +543,5 @@ module.exports = {
|
||||
updateAgentProjects,
|
||||
addAgentResourceFile,
|
||||
removeAgentResourceFiles,
|
||||
revertAgentVersion,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
const originalEnv = {
|
||||
CREDS_KEY: process.env.CREDS_KEY,
|
||||
CREDS_IV: process.env.CREDS_IV,
|
||||
};
|
||||
|
||||
process.env.CREDS_KEY = '0123456789abcdef0123456789abcdef';
|
||||
process.env.CREDS_IV = '0123456789abcdef';
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { Agent, addAgentResourceFile, removeAgentResourceFiles } = require('./Agent');
|
||||
const {
|
||||
Agent,
|
||||
addAgentResourceFile,
|
||||
removeAgentResourceFiles,
|
||||
createAgent,
|
||||
updateAgent,
|
||||
getAgent,
|
||||
deleteAgent,
|
||||
getListAgents,
|
||||
updateAgentProjects,
|
||||
} = require('./Agent');
|
||||
|
||||
describe('Agent Resource File Operations', () => {
|
||||
let mongoServer;
|
||||
@@ -15,6 +33,8 @@ describe('Agent Resource File Operations', () => {
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
process.env.CREDS_KEY = originalEnv.CREDS_KEY;
|
||||
process.env.CREDS_IV = originalEnv.CREDS_IV;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -332,3 +352,537 @@ describe('Agent Resource File Operations', () => {
|
||||
expect(finalFileIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent CRUD 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({});
|
||||
});
|
||||
|
||||
test('should create and get an agent', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
const newAgent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
description: 'Test description',
|
||||
});
|
||||
|
||||
expect(newAgent).toBeDefined();
|
||||
expect(newAgent.id).toBe(agentId);
|
||||
expect(newAgent.name).toBe('Test Agent');
|
||||
|
||||
const retrievedAgent = await getAgent({ id: agentId });
|
||||
expect(retrievedAgent).toBeDefined();
|
||||
expect(retrievedAgent.id).toBe(agentId);
|
||||
expect(retrievedAgent.name).toBe('Test Agent');
|
||||
expect(retrievedAgent.description).toBe('Test description');
|
||||
});
|
||||
|
||||
test('should delete an agent', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Agent To Delete',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
});
|
||||
|
||||
const agentBeforeDelete = await getAgent({ id: agentId });
|
||||
expect(agentBeforeDelete).toBeDefined();
|
||||
|
||||
await deleteAgent({ id: agentId });
|
||||
|
||||
const agentAfterDelete = await getAgent({ id: agentId });
|
||||
expect(agentAfterDelete).toBeNull();
|
||||
});
|
||||
|
||||
test('should list agents by author', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||
|
||||
const agentIds = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const id = `agent_${uuidv4()}`;
|
||||
agentIds.push(id);
|
||||
await createAgent({
|
||||
id,
|
||||
name: `Agent ${i}`,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
});
|
||||
}
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await createAgent({
|
||||
id: `other_agent_${uuidv4()}`,
|
||||
name: `Other Agent ${i}`,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: otherAuthorId,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await getListAgents({ author: authorId.toString() });
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.data).toHaveLength(5);
|
||||
expect(result.has_more).toBe(true);
|
||||
|
||||
for (const agent of result.data) {
|
||||
expect(agent.author).toBe(authorId.toString());
|
||||
}
|
||||
});
|
||||
|
||||
test('should update agent projects', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const projectId1 = new mongoose.Types.ObjectId();
|
||||
const projectId2 = new mongoose.Types.ObjectId();
|
||||
const projectId3 = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Project Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
projectIds: [projectId1],
|
||||
});
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{ $addToSet: { projectIds: { $each: [projectId2, projectId3] } } },
|
||||
);
|
||||
|
||||
await updateAgent({ id: agentId }, { $pull: { projectIds: projectId1 } });
|
||||
|
||||
await updateAgent({ id: agentId }, { projectIds: [projectId2, projectId3] });
|
||||
|
||||
const updatedAgent = await getAgent({ id: agentId });
|
||||
expect(updatedAgent.projectIds).toHaveLength(2);
|
||||
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId2.toString());
|
||||
expect(updatedAgent.projectIds.map((id) => id.toString())).toContain(projectId3.toString());
|
||||
expect(updatedAgent.projectIds.map((id) => id.toString())).not.toContain(projectId1.toString());
|
||||
|
||||
await updateAgent({ id: agentId }, { projectIds: [] });
|
||||
|
||||
const emptyProjectsAgent = await getAgent({ id: agentId });
|
||||
expect(emptyProjectsAgent.projectIds).toHaveLength(0);
|
||||
|
||||
const nonExistentId = `agent_${uuidv4()}`;
|
||||
await expect(
|
||||
updateAgentProjects({
|
||||
id: nonExistentId,
|
||||
projectIds: [projectId1],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
test('should handle ephemeral agent loading', async () => {
|
||||
const agentId = 'ephemeral_test';
|
||||
const endpoint = 'openai';
|
||||
|
||||
const originalModule = jest.requireActual('librechat-data-provider');
|
||||
|
||||
const mockDataProvider = {
|
||||
...originalModule,
|
||||
Constants: {
|
||||
...originalModule.Constants,
|
||||
EPHEMERAL_AGENT_ID: 'ephemeral_test',
|
||||
},
|
||||
};
|
||||
|
||||
jest.doMock('librechat-data-provider', () => mockDataProvider);
|
||||
|
||||
const mockReq = {
|
||||
user: { id: 'user123' },
|
||||
body: {
|
||||
promptPrefix: 'This is a test instruction',
|
||||
ephemeralAgent: {
|
||||
execute_code: true,
|
||||
mcp: ['server1', 'server2'],
|
||||
},
|
||||
},
|
||||
app: {
|
||||
locals: {
|
||||
availableTools: {
|
||||
tool__server1: {},
|
||||
tool__server2: {},
|
||||
another_tool: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const params = {
|
||||
req: mockReq,
|
||||
agent_id: agentId,
|
||||
endpoint,
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7,
|
||||
},
|
||||
};
|
||||
|
||||
expect(agentId).toBeDefined();
|
||||
expect(endpoint).toBeDefined();
|
||||
|
||||
jest.dontMock('librechat-data-provider');
|
||||
});
|
||||
|
||||
test('should handle loadAgent functionality and errors', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Load Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
tools: ['tool1', 'tool2'],
|
||||
});
|
||||
|
||||
const agent = await getAgent({ id: agentId });
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent.id).toBe(agentId);
|
||||
expect(agent.name).toBe('Test Load Agent');
|
||||
expect(agent.tools).toEqual(expect.arrayContaining(['tool1', 'tool2']));
|
||||
|
||||
const mockLoadAgent = jest.fn().mockResolvedValue(agent);
|
||||
const loadedAgent = await mockLoadAgent();
|
||||
expect(loadedAgent).toBeDefined();
|
||||
expect(loadedAgent.id).toBe(agentId);
|
||||
|
||||
const nonExistentId = `agent_${uuidv4()}`;
|
||||
const nonExistentAgent = await getAgent({ id: nonExistentId });
|
||||
expect(nonExistentAgent).toBeNull();
|
||||
|
||||
const mockLoadAgentError = jest.fn().mockRejectedValue(new Error('No agent found with ID'));
|
||||
await expect(mockLoadAgentError()).rejects.toThrow('No agent found with ID');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Version History', () => {
|
||||
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({});
|
||||
});
|
||||
|
||||
test('should create an agent with a single entry in versions array', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const agent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
|
||||
expect(agent.versions).toBeDefined();
|
||||
expect(Array.isArray(agent.versions)).toBe(true);
|
||||
expect(agent.versions).toHaveLength(1);
|
||||
expect(agent.versions[0].name).toBe('Test Agent');
|
||||
expect(agent.versions[0].provider).toBe('test');
|
||||
expect(agent.versions[0].model).toBe('test-model');
|
||||
});
|
||||
|
||||
test('should accumulate version history across multiple updates', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const author = new mongoose.Types.ObjectId();
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'First Name',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author,
|
||||
description: 'First description',
|
||||
});
|
||||
|
||||
await updateAgent({ id: agentId }, { name: 'Second Name', description: 'Second description' });
|
||||
await updateAgent({ id: agentId }, { name: 'Third Name', model: 'new-model' });
|
||||
const finalAgent = await updateAgent({ id: agentId }, { description: 'Final description' });
|
||||
|
||||
expect(finalAgent.versions).toBeDefined();
|
||||
expect(Array.isArray(finalAgent.versions)).toBe(true);
|
||||
expect(finalAgent.versions).toHaveLength(4);
|
||||
|
||||
expect(finalAgent.versions[0].name).toBe('First Name');
|
||||
expect(finalAgent.versions[0].description).toBe('First description');
|
||||
expect(finalAgent.versions[0].model).toBe('test-model');
|
||||
|
||||
expect(finalAgent.versions[1].name).toBe('Second Name');
|
||||
expect(finalAgent.versions[1].description).toBe('Second description');
|
||||
expect(finalAgent.versions[1].model).toBe('test-model');
|
||||
|
||||
expect(finalAgent.versions[2].name).toBe('Third Name');
|
||||
expect(finalAgent.versions[2].description).toBe('Second description');
|
||||
expect(finalAgent.versions[2].model).toBe('new-model');
|
||||
|
||||
expect(finalAgent.versions[3].name).toBe('Third Name');
|
||||
expect(finalAgent.versions[3].description).toBe('Final description');
|
||||
expect(finalAgent.versions[3].model).toBe('new-model');
|
||||
|
||||
expect(finalAgent.name).toBe('Third Name');
|
||||
expect(finalAgent.description).toBe('Final description');
|
||||
expect(finalAgent.model).toBe('new-model');
|
||||
});
|
||||
|
||||
test('should not include metadata fields in version history', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
|
||||
const updatedAgent = await updateAgent({ id: agentId }, { description: 'New description' });
|
||||
|
||||
expect(updatedAgent.versions).toHaveLength(2);
|
||||
expect(updatedAgent.versions[0]._id).toBeUndefined();
|
||||
expect(updatedAgent.versions[0].__v).toBeUndefined();
|
||||
expect(updatedAgent.versions[0].name).toBe('Test Agent');
|
||||
expect(updatedAgent.versions[0].author).toBeDefined();
|
||||
|
||||
expect(updatedAgent.versions[1]._id).toBeUndefined();
|
||||
expect(updatedAgent.versions[1].__v).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should not recursively include previous versions', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
|
||||
await updateAgent({ id: agentId }, { name: 'Updated Name 1' });
|
||||
await updateAgent({ id: agentId }, { name: 'Updated Name 2' });
|
||||
const finalAgent = await updateAgent({ id: agentId }, { name: 'Updated Name 3' });
|
||||
|
||||
expect(finalAgent.versions).toHaveLength(4);
|
||||
|
||||
finalAgent.versions.forEach((version) => {
|
||||
expect(version.versions).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle MongoDB operators and field updates correctly', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const projectId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'MongoDB Operator Test',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
tools: ['tool1'],
|
||||
});
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
description: 'Updated description',
|
||||
$push: { tools: 'tool2' },
|
||||
$addToSet: { projectIds: projectId },
|
||||
},
|
||||
);
|
||||
|
||||
const firstUpdate = await getAgent({ id: agentId });
|
||||
expect(firstUpdate.description).toBe('Updated description');
|
||||
expect(firstUpdate.tools).toContain('tool1');
|
||||
expect(firstUpdate.tools).toContain('tool2');
|
||||
expect(firstUpdate.projectIds.map((id) => id.toString())).toContain(projectId.toString());
|
||||
expect(firstUpdate.versions).toHaveLength(2);
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
tools: ['tool2', 'tool3'],
|
||||
},
|
||||
);
|
||||
|
||||
const secondUpdate = await getAgent({ id: agentId });
|
||||
expect(secondUpdate.tools).toHaveLength(2);
|
||||
expect(secondUpdate.tools).toContain('tool2');
|
||||
expect(secondUpdate.tools).toContain('tool3');
|
||||
expect(secondUpdate.tools).not.toContain('tool1');
|
||||
expect(secondUpdate.versions).toHaveLength(3);
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
$push: { tools: 'tool3' },
|
||||
},
|
||||
);
|
||||
|
||||
const thirdUpdate = await getAgent({ id: agentId });
|
||||
const toolCount = thirdUpdate.tools.filter((t) => t === 'tool3').length;
|
||||
expect(toolCount).toBe(2);
|
||||
expect(thirdUpdate.versions).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle parameter objects correctly', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Parameters Test',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
model_parameters: { temperature: 0.7 },
|
||||
});
|
||||
|
||||
const updatedAgent = await updateAgent(
|
||||
{ id: agentId },
|
||||
{ model_parameters: { temperature: 0.8 } },
|
||||
);
|
||||
|
||||
expect(updatedAgent.versions).toHaveLength(2);
|
||||
expect(updatedAgent.model_parameters.temperature).toBe(0.8);
|
||||
|
||||
await updateAgent(
|
||||
{ id: agentId },
|
||||
{
|
||||
model_parameters: {
|
||||
temperature: 0.8,
|
||||
max_tokens: 1000,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const complexAgent = await getAgent({ id: agentId });
|
||||
expect(complexAgent.versions).toHaveLength(3);
|
||||
expect(complexAgent.model_parameters.temperature).toBe(0.8);
|
||||
expect(complexAgent.model_parameters.max_tokens).toBe(1000);
|
||||
|
||||
await updateAgent({ id: agentId }, { model_parameters: {} });
|
||||
|
||||
const emptyParamsAgent = await getAgent({ id: agentId });
|
||||
expect(emptyParamsAgent.versions).toHaveLength(4);
|
||||
expect(emptyParamsAgent.model_parameters).toEqual({});
|
||||
});
|
||||
|
||||
test('should detect duplicate versions and reject updates', async () => {
|
||||
const originalConsoleError = console.error;
|
||||
console.error = jest.fn();
|
||||
|
||||
try {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const projectId1 = new mongoose.Types.ObjectId();
|
||||
const projectId2 = new mongoose.Types.ObjectId();
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
name: 'simple field update',
|
||||
initial: {
|
||||
name: 'Test Agent',
|
||||
description: 'Initial description',
|
||||
},
|
||||
update: { name: 'Updated Name' },
|
||||
duplicate: { name: 'Updated Name' },
|
||||
},
|
||||
{
|
||||
name: 'object field update',
|
||||
initial: {
|
||||
model_parameters: { temperature: 0.7 },
|
||||
},
|
||||
update: { model_parameters: { temperature: 0.8 } },
|
||||
duplicate: { model_parameters: { temperature: 0.8 } },
|
||||
},
|
||||
{
|
||||
name: 'array field update',
|
||||
initial: {
|
||||
tools: ['tool1', 'tool2'],
|
||||
},
|
||||
update: { tools: ['tool2', 'tool3'] },
|
||||
duplicate: { tools: ['tool2', 'tool3'] },
|
||||
},
|
||||
{
|
||||
name: 'projectIds update',
|
||||
initial: {
|
||||
projectIds: [projectId1],
|
||||
},
|
||||
update: { projectIds: [projectId1, projectId2] },
|
||||
duplicate: { projectIds: [projectId2, projectId1] },
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const testAgentId = `agent_${uuidv4()}`;
|
||||
|
||||
await createAgent({
|
||||
id: testAgentId,
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
...testCase.initial,
|
||||
});
|
||||
|
||||
await updateAgent({ id: testAgentId }, testCase.update);
|
||||
|
||||
let error;
|
||||
try {
|
||||
await updateAgent({ id: testAgentId }, testCase.duplicate);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
expect(error).toBeDefined();
|
||||
expect(error.message).toContain('Duplicate version');
|
||||
expect(error.statusCode).toBe(409);
|
||||
expect(error.details).toBeDefined();
|
||||
expect(error.details.duplicateVersion).toBeDefined();
|
||||
|
||||
const agent = await getAgent({ id: testAgentId });
|
||||
expect(agent.versions).toHaveLength(2);
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -111,10 +111,15 @@ const tokenValues = Object.assign(
|
||||
/* cohere doesn't have rates for the older command models,
|
||||
so this was from https://artificialanalysis.ai/models/command-light/providers */
|
||||
command: { prompt: 0.38, completion: 0.38 },
|
||||
gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
|
||||
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
|
||||
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
|
||||
'gemini-2.5-pro-preview-03-25': { prompt: 1.25, completion: 10 },
|
||||
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
|
||||
'gemini-2.5-flash': { prompt: 0.15, completion: 3.5 },
|
||||
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
|
||||
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
|
||||
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },
|
||||
|
||||
@@ -488,6 +488,9 @@ describe('getCacheMultiplier', () => {
|
||||
|
||||
describe('Google Model Tests', () => {
|
||||
const googleModels = [
|
||||
'gemini-2.5-pro-preview-05-06',
|
||||
'gemini-2.5-flash-preview-04-17',
|
||||
'gemini-2.5-exp',
|
||||
'gemini-2.0-flash-lite-preview-02-05',
|
||||
'gemini-2.0-flash-001',
|
||||
'gemini-2.0-flash-exp',
|
||||
@@ -525,6 +528,9 @@ describe('Google Model Tests', () => {
|
||||
|
||||
it('should map to the correct model keys', () => {
|
||||
const expected = {
|
||||
'gemini-2.5-pro-preview-05-06': 'gemini-2.5-pro',
|
||||
'gemini-2.5-flash-preview-04-17': 'gemini-2.5-flash',
|
||||
'gemini-2.5-exp': 'gemini-2.5',
|
||||
'gemini-2.0-flash-lite-preview-02-05': 'gemini-2.0-flash-lite',
|
||||
'gemini-2.0-flash-001': 'gemini-2.0-flash',
|
||||
'gemini-2.0-flash-exp': 'gemini-2.0-flash',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.7.8-rc1",
|
||||
"version": "v0.7.8",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -43,12 +43,12 @@
|
||||
"@google/generative-ai": "^0.23.0",
|
||||
"@googleapis/youtube": "^20.0.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@langchain/community": "^0.3.39",
|
||||
"@langchain/core": "^0.3.43",
|
||||
"@langchain/google-genai": "^0.2.2",
|
||||
"@langchain/google-vertexai": "^0.2.3",
|
||||
"@langchain/community": "^0.3.42",
|
||||
"@langchain/core": "^0.3.55",
|
||||
"@langchain/google-genai": "^0.2.8",
|
||||
"@langchain/google-vertexai": "^0.2.8",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.22",
|
||||
"@librechat/agents": "^2.4.317",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.8.2",
|
||||
@@ -86,11 +86,11 @@
|
||||
"mime": "^3.0.0",
|
||||
"module-alias": "^2.2.3",
|
||||
"mongoose": "^8.12.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.0.0",
|
||||
"nanoid": "^3.3.7",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ollama": "^0.5.0",
|
||||
"openai": "^4.47.1",
|
||||
"openai": "^4.96.2",
|
||||
"openai-chat-tokens": "^0.2.8",
|
||||
"openid-client": "^5.4.2",
|
||||
"passport": "^0.6.0",
|
||||
@@ -116,6 +116,6 @@
|
||||
"jest": "^29.7.0",
|
||||
"mongodb-memory-server": "^10.1.3",
|
||||
"nodemon": "^3.0.3",
|
||||
"supertest": "^7.0.0"
|
||||
"supertest": "^7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
|
||||
|
||||
if (!client?.skipSaveUserMessage && latestUserMessage) {
|
||||
await saveMessage(req, latestUserMessage, {
|
||||
context: 'api/server/controllers/AskController.js - don\'t skip saving user message',
|
||||
context: "api/server/controllers/AskController.js - don't skip saving user message",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const { CacheKeys, AuthType } = require('librechat-data-provider');
|
||||
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
|
||||
const { getToolkitKey } = require('~/server/services/ToolService');
|
||||
const { getCustomConfig } = require('~/server/services/Config');
|
||||
const { availableTools } = require('~/app/clients/tools');
|
||||
@@ -70,7 +69,7 @@ const getAvailablePluginsController = async (req, res) => {
|
||||
);
|
||||
}
|
||||
|
||||
let plugins = await addOpenAPISpecs(authenticatedPlugins);
|
||||
let plugins = authenticatedPlugins;
|
||||
|
||||
if (includedTools.length > 0) {
|
||||
plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey));
|
||||
@@ -106,11 +105,11 @@ const getAvailableTools = async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginManifest = availableTools;
|
||||
let pluginManifest = availableTools;
|
||||
const customConfig = await getCustomConfig();
|
||||
if (customConfig?.mcpServers != null) {
|
||||
const mcpManager = getMCPManager();
|
||||
await mcpManager.loadManifestTools(pluginManifest);
|
||||
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
|
||||
}
|
||||
|
||||
/** @type {TPlugin[]} */
|
||||
|
||||
@@ -14,15 +14,6 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { saveBase64Image } = require('~/server/services/Files/process');
|
||||
const { logger, sendEvent } = require('~/config');
|
||||
|
||||
/** @typedef {import('@librechat/agents').Graph} Graph */
|
||||
/** @typedef {import('@librechat/agents').EventHandler} EventHandler */
|
||||
/** @typedef {import('@librechat/agents').ModelEndData} ModelEndData */
|
||||
/** @typedef {import('@librechat/agents').ToolEndData} ToolEndData */
|
||||
/** @typedef {import('@librechat/agents').ToolEndCallback} ToolEndCallback */
|
||||
/** @typedef {import('@librechat/agents').ChatModelStreamHandler} ChatModelStreamHandler */
|
||||
/** @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator */
|
||||
/** @typedef {import('@librechat/agents').GraphEvents} GraphEvents */
|
||||
|
||||
class ModelEndHandler {
|
||||
/**
|
||||
* @param {Array<UsageMetadata>} collectedUsage
|
||||
@@ -38,7 +29,7 @@ class ModelEndHandler {
|
||||
* @param {string} event
|
||||
* @param {ModelEndData | undefined} data
|
||||
* @param {Record<string, unknown> | undefined} metadata
|
||||
* @param {Graph} graph
|
||||
* @param {StandardGraph} graph
|
||||
* @returns
|
||||
*/
|
||||
handle(event, data, metadata, graph) {
|
||||
@@ -61,7 +52,10 @@ class ModelEndHandler {
|
||||
}
|
||||
|
||||
this.collectedUsage.push(usage);
|
||||
if (!graph.clientOptions?.disableStreaming) {
|
||||
const streamingDisabled = !!(
|
||||
graph.clientOptions?.disableStreaming || graph?.boundModel?.disableStreaming
|
||||
);
|
||||
if (!streamingDisabled) {
|
||||
return;
|
||||
}
|
||||
if (!data.output.content) {
|
||||
|
||||
@@ -58,7 +58,7 @@ const payloadParser = ({ req, agent, endpoint }) => {
|
||||
|
||||
const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]);
|
||||
|
||||
const noSystemModelRegex = [/\b(o\d)\b/gi];
|
||||
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
|
||||
|
||||
// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory');
|
||||
// const { getFormattedMemories } = require('~/models/Memory');
|
||||
@@ -148,19 +148,13 @@ class AgentClient extends BaseClient {
|
||||
* @param {MongoFile[]} attachments
|
||||
*/
|
||||
checkVisionRequest(attachments) {
|
||||
logger.info(
|
||||
'[api/server/controllers/agents/client.js #checkVisionRequest] not implemented',
|
||||
attachments,
|
||||
);
|
||||
// if (!attachments) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// const availableModels = this.options.modelsConfig?.[this.options.endpoint];
|
||||
// if (!availableModels) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// let visionRequestDetected = false;
|
||||
// for (const file of attachments) {
|
||||
// if (file?.type?.includes('image')) {
|
||||
@@ -171,13 +165,11 @@ class AgentClient extends BaseClient {
|
||||
// if (!visionRequestDetected) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
|
||||
// if (this.isVisionModel) {
|
||||
// delete this.modelOptions.stop;
|
||||
// return;
|
||||
// }
|
||||
|
||||
// for (const model of availableModels) {
|
||||
// if (!validateVisionModel({ model, availableModels })) {
|
||||
// continue;
|
||||
@@ -187,14 +179,12 @@ class AgentClient extends BaseClient {
|
||||
// delete this.modelOptions.stop;
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (!availableModels.includes(this.defaultVisionModel)) {
|
||||
// return;
|
||||
// }
|
||||
// if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// this.modelOptions.model = this.defaultVisionModel;
|
||||
// this.isVisionModel = true;
|
||||
// delete this.modelOptions.stop;
|
||||
@@ -728,12 +718,14 @@ class AgentClient extends BaseClient {
|
||||
}
|
||||
|
||||
if (noSystemMessages === true && systemContent?.length) {
|
||||
let latestMessage = _messages.pop().content;
|
||||
const latestMessageContent = _messages.pop().content;
|
||||
if (typeof latestMessage !== 'string') {
|
||||
latestMessage = latestMessage[0].text;
|
||||
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
|
||||
_messages.push(new HumanMessage({ content: latestMessageContent }));
|
||||
} else {
|
||||
const text = [systemContent, latestMessageContent].join('\n');
|
||||
_messages.push(new HumanMessage(text));
|
||||
}
|
||||
latestMessage = [systemContent, latestMessage].join('\n');
|
||||
_messages.push(new HumanMessage(latestMessage));
|
||||
}
|
||||
|
||||
let messages = _messages;
|
||||
|
||||
@@ -23,6 +23,7 @@ const { updateAction, getActions } = require('~/models/Action');
|
||||
const { updateAgentProjects } = require('~/models/Agent');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { deleteFileByFilter } = require('~/models/File');
|
||||
const { revertAgentVersion } = require('~/models/Agent');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
const systemTools = {
|
||||
@@ -104,6 +105,8 @@ const getAgentHandler = async (req, res) => {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
agent.version = agent.versions ? agent.versions.length : 0;
|
||||
|
||||
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
|
||||
const originalUrl = agent.avatar.filepath;
|
||||
agent.avatar.filepath = await refreshS3Url(agent.avatar);
|
||||
@@ -127,6 +130,7 @@ const getAgentHandler = async (req, res) => {
|
||||
author: agent.author,
|
||||
projectIds: agent.projectIds,
|
||||
isCollaborative: agent.isCollaborative,
|
||||
version: agent.version,
|
||||
});
|
||||
}
|
||||
return res.status(200).json(agent);
|
||||
@@ -187,6 +191,14 @@ const updateAgentHandler = async (req, res) => {
|
||||
return res.json(updatedAgent);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/:id] Error updating Agent', error);
|
||||
|
||||
if (error.statusCode === 409) {
|
||||
return res.status(409).json({
|
||||
error: error.message,
|
||||
details: error.details,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
@@ -411,6 +423,66 @@ const uploadAgentAvatarHandler = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reverts an agent to a previous version from its version history.
|
||||
* @route PATCH /agents/:id/revert
|
||||
* @param {object} req - Express Request object
|
||||
* @param {object} req.params - Request parameters
|
||||
* @param {string} req.params.id - The ID of the agent to revert
|
||||
* @param {object} req.body - Request body
|
||||
* @param {number} req.body.version_index - The index of the version to revert to
|
||||
* @param {object} req.user - Authenticated user information
|
||||
* @param {string} req.user.id - User ID
|
||||
* @param {string} req.user.role - User role
|
||||
* @param {ServerResponse} res - Express Response object
|
||||
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
|
||||
* @throws {Error} 400 - If version_index is missing
|
||||
* @throws {Error} 403 - If user doesn't have permission to modify the agent
|
||||
* @throws {Error} 404 - If agent not found
|
||||
* @throws {Error} 500 - If there's an internal server error during the reversion process
|
||||
*/
|
||||
const revertAgentVersionHandler = async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { version_index } = req.body;
|
||||
|
||||
if (version_index === undefined) {
|
||||
return res.status(400).json({ error: 'version_index is required' });
|
||||
}
|
||||
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const existingAgent = await getAgent({ id });
|
||||
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||
|
||||
if (!hasEditPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'You do not have permission to modify this non-collaborative agent',
|
||||
});
|
||||
}
|
||||
|
||||
const updatedAgent = await revertAgentVersion({ id }, version_index);
|
||||
|
||||
if (updatedAgent.author) {
|
||||
updatedAgent.author = updatedAgent.author.toString();
|
||||
}
|
||||
|
||||
if (updatedAgent.author !== req.user.id) {
|
||||
delete updatedAgent.author;
|
||||
}
|
||||
|
||||
return res.json(updatedAgent);
|
||||
} catch (error) {
|
||||
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createAgent: createAgentHandler,
|
||||
getAgent: getAgentHandler,
|
||||
@@ -419,4 +491,5 @@ module.exports = {
|
||||
deleteAgent: deleteAgentHandler,
|
||||
getListAgents: getListAgentsHandler,
|
||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||
revertAgentVersion: revertAgentVersionHandler,
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ const chatV1 = async (req, res) => {
|
||||
} else if (/Files.*are invalid/.test(error.message)) {
|
||||
const errorMessage = `Files are invalid, or may not have uploaded yet.${
|
||||
endpoint === EModelEndpoint.azureAssistants
|
||||
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
|
||||
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
|
||||
: ''
|
||||
}`;
|
||||
return sendResponse(req, res, messageData, errorMessage);
|
||||
@@ -326,8 +326,15 @@ const chatV1 = async (req, res) => {
|
||||
|
||||
file_ids = files.map(({ file_id }) => file_id);
|
||||
if (file_ids.length || thread_file_ids.length) {
|
||||
userMessage.file_ids = file_ids;
|
||||
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
|
||||
if (endpoint === EModelEndpoint.azureAssistants) {
|
||||
userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({
|
||||
file_id,
|
||||
tools: [{ type: 'file_search' }],
|
||||
}));
|
||||
} else {
|
||||
userMessage.file_ids = Array.from(attachedFileIds);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -379,8 +386,8 @@ const chatV1 = async (req, res) => {
|
||||
body.additional_instructions ? `${body.additional_instructions}\n` : ''
|
||||
}The user has uploaded ${imageCount} image${pluralized}.
|
||||
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
|
||||
plural ? '' : 'a '
|
||||
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
|
||||
plural ? '' : 'a '
|
||||
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
|
||||
|
||||
return files;
|
||||
};
|
||||
@@ -576,6 +583,8 @@ const chatV1 = async (req, res) => {
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
endpoint,
|
||||
spec: endpointOption.spec,
|
||||
iconURL: endpointOption.iconURL,
|
||||
};
|
||||
|
||||
sendMessage(res, {
|
||||
|
||||
@@ -428,6 +428,8 @@ const chatV2 = async (req, res) => {
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
endpoint,
|
||||
spec: endpointOption.spec,
|
||||
iconURL: endpointOption.iconURL,
|
||||
};
|
||||
|
||||
sendMessage(res, {
|
||||
|
||||
@@ -24,10 +24,13 @@ const routes = require('./routes');
|
||||
|
||||
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
||||
|
||||
const port = Number(PORT) || 3080;
|
||||
// Allow PORT=0 to be used for automatic free port assignment
|
||||
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
|
||||
const host = HOST || 'localhost';
|
||||
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
||||
|
||||
const app = express();
|
||||
|
||||
const startServer = async () => {
|
||||
if (typeof Bun !== 'undefined') {
|
||||
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
||||
@@ -36,8 +39,9 @@ const startServer = async () => {
|
||||
logger.info('Connected to MongoDB');
|
||||
await indexSync();
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', trusted_proxy);
|
||||
|
||||
await AppService(app);
|
||||
|
||||
const indexPath = path.join(app.locals.paths.dist, 'index.html');
|
||||
@@ -49,23 +53,24 @@ const startServer = async () => {
|
||||
app.use(noIndex);
|
||||
app.use(errorController);
|
||||
app.use(express.json({ limit: '3mb' }));
|
||||
app.use(mongoSanitize());
|
||||
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
||||
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.use(mongoSanitize());
|
||||
app.use(cors());
|
||||
app.use(cookieParser());
|
||||
|
||||
if (!isEnabled(DISABLE_COMPRESSION)) {
|
||||
app.use(compression());
|
||||
} else {
|
||||
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
|
||||
}
|
||||
|
||||
// Serve static assets with aggressive caching
|
||||
app.use(staticCache(app.locals.paths.dist));
|
||||
app.use(staticCache(app.locals.paths.fonts));
|
||||
app.use(staticCache(app.locals.paths.assets));
|
||||
|
||||
if (!ALLOW_SOCIAL_LOGIN) {
|
||||
console.warn(
|
||||
'Social logins are disabled. Set Environment Variable "ALLOW_SOCIAL_LOGIN" to true to enable them.',
|
||||
);
|
||||
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
|
||||
}
|
||||
|
||||
/* OAUTH */
|
||||
@@ -128,7 +133,7 @@ const startServer = async () => {
|
||||
});
|
||||
|
||||
app.listen(port, host, () => {
|
||||
if (host == '0.0.0.0') {
|
||||
if (host === '0.0.0.0') {
|
||||
logger.info(
|
||||
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
||||
);
|
||||
@@ -176,3 +181,6 @@ process.on('uncaughtException', (err) => {
|
||||
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// export app for easier testing purposes
|
||||
module.exports = app;
|
||||
|
||||
78
api/server/index.spec.js
Normal file
78
api/server/index.spec.js
Normal file
@@ -0,0 +1,78 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const request = require('supertest');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
describe('Server Configuration', () => {
|
||||
// Increase the default timeout to allow for Mongo cleanup
|
||||
jest.setTimeout(30_000);
|
||||
|
||||
let mongoServer;
|
||||
let app;
|
||||
|
||||
/** Mocked fs.readFileSync for index.html */
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
beforeAll(() => {
|
||||
fs.readFileSync = function (filepath, options) {
|
||||
if (filepath.includes('index.html')) {
|
||||
return '<!DOCTYPE html><html><head><title>LibreChat</title></head><body><div id="root"></div></body></html>';
|
||||
}
|
||||
return originalReadFileSync(filepath, options);
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore original fs.readFileSync
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
process.env.MONGO_URI = mongoServer.getUri();
|
||||
process.env.PORT = '0'; // Use a random available port
|
||||
app = require('~/server');
|
||||
|
||||
// Wait for the app to be healthy
|
||||
await healthCheckPoll(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoServer.stop();
|
||||
await mongoose.disconnect();
|
||||
});
|
||||
|
||||
it('should return OK for /health', async () => {
|
||||
const response = await request(app).get('/health');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.text).toBe('OK');
|
||||
});
|
||||
|
||||
it('should not cache index page', async () => {
|
||||
const response = await request(app).get('/');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('no-cache, no-store, must-revalidate');
|
||||
expect(response.headers['pragma']).toBe('no-cache');
|
||||
expect(response.headers['expires']).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
|
||||
async function healthCheckPoll(app, retries = 0) {
|
||||
const maxRetries = Math.floor(10000 / 30); // 10 seconds / 30ms
|
||||
try {
|
||||
const response = await request(app).get('/health');
|
||||
if (response.status === 200) {
|
||||
return; // App is healthy
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore connection errors during polling
|
||||
}
|
||||
|
||||
if (retries < maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await healthCheckPoll(app, retries + 1);
|
||||
} else {
|
||||
throw new Error('App did not become healthy within 10 seconds.');
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
||||
*/
|
||||
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
||||
|
||||
/**
|
||||
* Reverts an agent to a previous version.
|
||||
* @route POST /agents/:id/revert
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @param {number} req.body.version_index - Index of the version to revert to.
|
||||
* @returns {Agent} 200 - success response - application/json
|
||||
*/
|
||||
router.post('/:id/revert', checkGlobalAgentShare, v1.revertAgentVersion);
|
||||
|
||||
/**
|
||||
* Returns a list of agents.
|
||||
* @route GET /agents
|
||||
|
||||
@@ -52,10 +52,9 @@ router.get('/', async function (req, res) {
|
||||
!!process.env.APPLE_KEY_ID &&
|
||||
!!process.env.APPLE_PRIVATE_KEY_PATH,
|
||||
openidLoginEnabled:
|
||||
!!process.env.OPENID_CLIENT_ID &&
|
||||
!!process.env.OPENID_CLIENT_SECRET &&
|
||||
!!process.env.OPENID_ISSUER &&
|
||||
!!process.env.OPENID_ENABLED &&
|
||||
!!process.env.OPENID_SESSION_SECRET,
|
||||
openidMultiTenantEnabled: !!process.env.OPENID_MULTI_TENANT,
|
||||
openidLabel: process.env.OPENID_BUTTON_LABEL || 'Continue with OpenID',
|
||||
openidImageUrl: process.env.OPENID_IMAGE_URL,
|
||||
openidAutoRedirect: isEnabled(process.env.OPENID_AUTO_REDIRECT),
|
||||
@@ -75,6 +74,7 @@ router.get('/', async function (req, res) {
|
||||
process.env.SHOW_BIRTHDAY_ICON === '',
|
||||
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
|
||||
interface: req.app.locals.interfaceConfig,
|
||||
turnstile: req.app.locals.turnstileConfig,
|
||||
modelSpecs: req.app.locals.modelSpecs,
|
||||
balance: req.app.locals.balance,
|
||||
sharedLinksEnabled,
|
||||
|
||||
@@ -21,6 +21,7 @@ const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
|
||||
const { getFiles, batchUpdateFiles } = require('~/models/File');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const { logger } = require('~/config');
|
||||
@@ -94,7 +95,7 @@ router.delete('/', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
/* Handle entity unlinking even if no valid files to delete */
|
||||
/* Handle agent unlinking even if no valid files to delete */
|
||||
if (req.body.agent_id && req.body.tool_resource && dbFiles.length === 0) {
|
||||
const agent = await getAgent({
|
||||
id: req.body.agent_id,
|
||||
@@ -104,10 +105,32 @@ router.delete('/', async (req, res) => {
|
||||
const agentFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
|
||||
|
||||
await processDeleteRequest({ req, files: agentFiles });
|
||||
res.status(200).json({ message: 'File associations removed successfully' });
|
||||
res.status(200).json({ message: 'File associations removed successfully from agent' });
|
||||
return;
|
||||
}
|
||||
|
||||
/* Handle assistant unlinking even if no valid files to delete */
|
||||
if (req.body.assistant_id && req.body.tool_resource && dbFiles.length === 0) {
|
||||
const assistant = await getAssistant({
|
||||
id: req.body.assistant_id,
|
||||
});
|
||||
|
||||
const toolResourceFiles = assistant.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
|
||||
const assistantFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
|
||||
|
||||
await processDeleteRequest({ req, files: assistantFiles });
|
||||
res.status(200).json({ message: 'File associations removed successfully from assistant' });
|
||||
return;
|
||||
} else if (
|
||||
req.body.assistant_id &&
|
||||
req.body.files?.[0]?.filepath === EModelEndpoint.azureAssistants
|
||||
) {
|
||||
await processDeleteRequest({ req, files: req.body.files });
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
||||
}
|
||||
|
||||
await processDeleteRequest({ req, files: dbFiles });
|
||||
|
||||
logger.debug(
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
} = require('~/server/middleware');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { logger } = require('~/config');
|
||||
const { chooseOpenIdStrategy } = require('~/server/utils/openidHelper');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -94,20 +95,32 @@ router.get(
|
||||
/**
|
||||
* OpenID Routes
|
||||
*/
|
||||
router.get(
|
||||
'/openid',
|
||||
passport.authenticate('openid', {
|
||||
session: false,
|
||||
}),
|
||||
);
|
||||
router.get('/openid', async (req, res, next) => {
|
||||
try {
|
||||
const strategy = await chooseOpenIdStrategy(req);
|
||||
console.log('OpenID login using strategy:', strategy);
|
||||
passport.authenticate(strategy, {
|
||||
session: false,
|
||||
})(req, res, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/openid/callback',
|
||||
passport.authenticate('openid', {
|
||||
failureRedirect: `${domains.client}/oauth/error`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
}),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const strategy = await chooseOpenIdStrategy(req);
|
||||
passport.authenticate(strategy, {
|
||||
failureRedirect: `${domains.client}/oauth/error`,
|
||||
failureMessage: true,
|
||||
session: false,
|
||||
})(req, res, next);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
},
|
||||
setBalanceConfig,
|
||||
oauthHandler,
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
const handleRateLimits = require('./Config/handleRateLimits');
|
||||
const { loadDefaultInterface } = require('./start/interface');
|
||||
const { loadTurnstileConfig } = require('./start/turnstile');
|
||||
const { azureConfigSetup } = require('./start/azureOpenAI');
|
||||
const { processModelSpecs } = require('./start/modelSpecs');
|
||||
const { initializeS3 } = require('./Files/S3/initialize');
|
||||
@@ -23,7 +24,6 @@ const { getMCPManager } = require('~/config');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
/**
|
||||
*
|
||||
* Loads custom config and initializes app-wide variables.
|
||||
* @function AppService
|
||||
* @param {Express.Application} app - The Express application object.
|
||||
@@ -74,6 +74,7 @@ const AppService = async (app) => {
|
||||
const socialLogins =
|
||||
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
|
||||
const interfaceConfig = await loadDefaultInterface(config, configDefaults);
|
||||
const turnstileConfig = loadTurnstileConfig(config, configDefaults);
|
||||
|
||||
const defaultLocals = {
|
||||
ocr,
|
||||
@@ -85,6 +86,7 @@ const AppService = async (app) => {
|
||||
availableTools,
|
||||
imageOutputType,
|
||||
interfaceConfig,
|
||||
turnstileConfig,
|
||||
balance,
|
||||
};
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@ jest.mock('./ToolService', () => ({
|
||||
},
|
||||
}),
|
||||
}));
|
||||
jest.mock('./start/turnstile', () => ({
|
||||
loadTurnstileConfig: jest.fn(() => ({
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
})),
|
||||
}));
|
||||
|
||||
const azureGroups = [
|
||||
{
|
||||
@@ -86,6 +92,10 @@ const azureGroups = [
|
||||
|
||||
describe('AppService', () => {
|
||||
let app;
|
||||
const mockedTurnstileConfig = {
|
||||
siteKey: 'default-site-key',
|
||||
options: {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
app = { locals: {} };
|
||||
@@ -107,6 +117,7 @@ describe('AppService', () => {
|
||||
sidePanel: true,
|
||||
presets: true,
|
||||
}),
|
||||
turnstileConfig: mockedTurnstileConfig,
|
||||
modelSpecs: undefined,
|
||||
availableTools: {
|
||||
ExampleTool: {
|
||||
|
||||
@@ -56,7 +56,7 @@ const logoutUser = async (req, refreshToken) => {
|
||||
try {
|
||||
req.session.destroy();
|
||||
} catch (destroyErr) {
|
||||
logger.error('[logoutUser] Failed to destroy session.', destroyErr);
|
||||
logger.debug('[logoutUser] Failed to destroy session.', destroyErr);
|
||||
}
|
||||
|
||||
return { status: 200, message: 'Logout successful' };
|
||||
|
||||
@@ -10,17 +10,7 @@ const getLogStores = require('~/cache/getLogStores');
|
||||
* */
|
||||
async function getCustomConfig() {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
let customConfig = await cache.get(CacheKeys.CUSTOM_CONFIG);
|
||||
|
||||
if (!customConfig) {
|
||||
customConfig = await loadCustomConfig();
|
||||
}
|
||||
|
||||
if (!customConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return customConfig;
|
||||
return (await cache.get(CacheKeys.CUSTOM_CONFIG)) || (await loadCustomConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,14 @@ async function loadConfigEndpoints(req) {
|
||||
|
||||
for (let i = 0; i < customEndpoints.length; i++) {
|
||||
const endpoint = customEndpoints[i];
|
||||
const { baseURL, apiKey, name: configName, iconURL, modelDisplayLabel } = endpoint;
|
||||
const {
|
||||
baseURL,
|
||||
apiKey,
|
||||
name: configName,
|
||||
iconURL,
|
||||
modelDisplayLabel,
|
||||
customParams,
|
||||
} = endpoint;
|
||||
const name = normalizeEndpointName(configName);
|
||||
|
||||
const resolvedApiKey = extractEnvVariable(apiKey);
|
||||
@@ -41,6 +48,7 @@ async function loadConfigEndpoints(req) {
|
||||
userProvideURL: isUserProvided(resolvedBaseURL),
|
||||
modelDisplayLabel,
|
||||
iconURL,
|
||||
customParams,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
const path = require('path');
|
||||
const { CacheKeys, configSchema, EImageOutputType } = require('librechat-data-provider');
|
||||
const {
|
||||
CacheKeys,
|
||||
configSchema,
|
||||
EImageOutputType,
|
||||
validateSettingDefinitions,
|
||||
agentParamSettings,
|
||||
paramSettings,
|
||||
} = require('librechat-data-provider');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const loadYaml = require('~/utils/loadYaml');
|
||||
const { logger } = require('~/config');
|
||||
const axios = require('axios');
|
||||
const yaml = require('js-yaml');
|
||||
const keyBy = require('lodash/keyBy');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
|
||||
const defaultConfigPath = path.resolve(projectRoot, 'librechat.yaml');
|
||||
@@ -105,6 +113,10 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
|
||||
logger.debug('Custom config:', customConfig);
|
||||
}
|
||||
|
||||
(customConfig.endpoints?.custom ?? [])
|
||||
.filter((endpoint) => endpoint.customParams)
|
||||
.forEach((endpoint) => parseCustomParams(endpoint.name, endpoint.customParams));
|
||||
|
||||
if (customConfig.cache) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
|
||||
@@ -117,4 +129,52 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
|
||||
return customConfig;
|
||||
}
|
||||
|
||||
// Validate and fill out missing values for custom parameters
|
||||
function parseCustomParams(endpointName, customParams) {
|
||||
const paramEndpoint = customParams.defaultParamsEndpoint;
|
||||
customParams.paramDefinitions = customParams.paramDefinitions || [];
|
||||
|
||||
// Checks if `defaultParamsEndpoint` is a key in `paramSettings`.
|
||||
const validEndpoints = new Set([
|
||||
...Object.keys(paramSettings),
|
||||
...Object.keys(agentParamSettings),
|
||||
]);
|
||||
if (!validEndpoints.has(paramEndpoint)) {
|
||||
throw new Error(
|
||||
`defaultParamsEndpoint of "${endpointName}" endpoint is invalid. ` +
|
||||
`Valid options are ${Array.from(validEndpoints).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// creates default param maps
|
||||
const regularParams = paramSettings[paramEndpoint] ?? [];
|
||||
const agentParams = agentParamSettings[paramEndpoint] ?? [];
|
||||
const defaultParams = regularParams.concat(agentParams);
|
||||
const defaultParamsMap = keyBy(defaultParams, 'key');
|
||||
|
||||
// TODO: Remove this check once we support new parameters not part of default parameters.
|
||||
// Checks if every key in `paramDefinitions` is valid.
|
||||
const validKeys = new Set(Object.keys(defaultParamsMap));
|
||||
const paramKeys = customParams.paramDefinitions.map((param) => param.key);
|
||||
if (paramKeys.some((key) => !validKeys.has(key))) {
|
||||
throw new Error(
|
||||
`paramDefinitions of "${endpointName}" endpoint contains invalid key(s). ` +
|
||||
`Valid parameter keys are ${Array.from(validKeys).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Fill out missing values for custom param definitions
|
||||
customParams.paramDefinitions = customParams.paramDefinitions.map((param) => {
|
||||
return { ...defaultParamsMap[param.key], ...param, optionType: 'custom' };
|
||||
});
|
||||
|
||||
try {
|
||||
validateSettingDefinitions(customParams.paramDefinitions);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Custom parameter definitions for "${endpointName}" endpoint is malformed: ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = loadCustomConfig;
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
jest.mock('axios');
|
||||
jest.mock('~/cache/getLogStores');
|
||||
jest.mock('~/utils/loadYaml');
|
||||
jest.mock('librechat-data-provider', () => {
|
||||
const actual = jest.requireActual('librechat-data-provider');
|
||||
return {
|
||||
...actual,
|
||||
paramSettings: { foo: {}, bar: {}, custom: {} },
|
||||
agentParamSettings: {
|
||||
custom: [],
|
||||
google: [
|
||||
{
|
||||
key: 'pressure',
|
||||
type: 'string',
|
||||
component: 'input',
|
||||
},
|
||||
{
|
||||
key: 'temperature',
|
||||
type: 'number',
|
||||
component: 'slider',
|
||||
default: 0.5,
|
||||
range: {
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.01,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const axios = require('axios');
|
||||
const loadCustomConfig = require('./loadCustomConfig');
|
||||
@@ -150,4 +178,126 @@ describe('loadCustomConfig', () => {
|
||||
expect(logger.info).toHaveBeenCalledWith(JSON.stringify(mockConfig, null, 2));
|
||||
expect(logger.debug).toHaveBeenCalledWith('Custom config:', mockConfig);
|
||||
});
|
||||
|
||||
describe('parseCustomParams', () => {
|
||||
const mockConfig = {
|
||||
version: '1.0',
|
||||
cache: false,
|
||||
endpoints: {
|
||||
custom: [
|
||||
{
|
||||
name: 'Google',
|
||||
apiKey: 'user_provided',
|
||||
customParams: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
async function loadCustomParams(customParams) {
|
||||
mockConfig.endpoints.custom[0].customParams = customParams;
|
||||
loadYaml.mockReturnValue(mockConfig);
|
||||
return await loadCustomConfig();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
process.env.CONFIG_PATH = 'validConfig.yaml';
|
||||
});
|
||||
|
||||
it('returns no error when customParams is undefined', async () => {
|
||||
const result = await loadCustomParams(undefined);
|
||||
expect(result).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
it('returns no error when customParams is valid', async () => {
|
||||
const result = await loadCustomParams({
|
||||
defaultParamsEndpoint: 'google',
|
||||
paramDefinitions: [
|
||||
{
|
||||
key: 'temperature',
|
||||
default: 0.5,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
it('throws an error when paramDefinitions contain unsupported keys', async () => {
|
||||
const malformedCustomParams = {
|
||||
defaultParamsEndpoint: 'google',
|
||||
paramDefinitions: [
|
||||
{ key: 'temperature', default: 0.5 },
|
||||
{ key: 'unsupportedKey', range: 0.5 },
|
||||
],
|
||||
};
|
||||
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
|
||||
'paramDefinitions of "Google" endpoint contains invalid key(s). Valid parameter keys are pressure, temperature',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when paramDefinitions is malformed', async () => {
|
||||
const malformedCustomParams = {
|
||||
defaultParamsEndpoint: 'google',
|
||||
paramDefinitions: [
|
||||
{
|
||||
key: 'temperature',
|
||||
type: 'noomba',
|
||||
component: 'inpoot',
|
||||
optionType: 'custom',
|
||||
},
|
||||
],
|
||||
};
|
||||
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
|
||||
/Custom parameter definitions for "Google" endpoint is malformed:/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws an error when defaultParamsEndpoint is not provided', async () => {
|
||||
const malformedCustomParams = { defaultParamsEndpoint: undefined };
|
||||
await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow(
|
||||
'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, google',
|
||||
);
|
||||
});
|
||||
|
||||
it('fills the paramDefinitions with missing values', async () => {
|
||||
const customParams = {
|
||||
defaultParamsEndpoint: 'google',
|
||||
paramDefinitions: [
|
||||
{ key: 'temperature', default: 0.7, range: { min: 0.1, max: 0.9, step: 0.1 } },
|
||||
{ key: 'pressure', component: 'textarea' },
|
||||
],
|
||||
};
|
||||
|
||||
const parsedConfig = await loadCustomParams(customParams);
|
||||
const paramDefinitions = parsedConfig.endpoints.custom[0].customParams.paramDefinitions;
|
||||
expect(paramDefinitions).toEqual([
|
||||
{
|
||||
columnSpan: 1,
|
||||
component: 'slider',
|
||||
default: 0.7, // overridden
|
||||
includeInput: true,
|
||||
key: 'temperature',
|
||||
label: 'temperature',
|
||||
optionType: 'custom',
|
||||
range: {
|
||||
// overridden
|
||||
max: 0.9,
|
||||
min: 0.1,
|
||||
step: 0.1,
|
||||
},
|
||||
type: 'number',
|
||||
},
|
||||
{
|
||||
columnSpan: 1,
|
||||
component: 'textarea', // overridden
|
||||
key: 'pressure',
|
||||
label: 'pressure',
|
||||
optionType: 'custom',
|
||||
placeholder: '',
|
||||
type: 'string',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,6 +233,13 @@ const initializeAgentOptions = async ({
|
||||
endpointOption: _endpointOption,
|
||||
});
|
||||
|
||||
if (
|
||||
agent.endpoint === EModelEndpoint.azureOpenAI &&
|
||||
options.llmConfig?.azureOpenAIApiInstanceName == null
|
||||
) {
|
||||
agent.provider = Providers.OPENAI;
|
||||
}
|
||||
|
||||
if (options.provider != null) {
|
||||
agent.provider = options.provider;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
|
||||
const buildOptions = async (endpoint, parsedBody) => {
|
||||
|
||||
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
|
||||
parsedBody;
|
||||
const endpointOption = removeNullishValues({
|
||||
|
||||
@@ -105,6 +105,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
headers: resolvedHeaders,
|
||||
addParams: endpointConfig.addParams,
|
||||
dropParams: endpointConfig.dropParams,
|
||||
customParams: endpointConfig.customParams,
|
||||
titleConvo: endpointConfig.titleConvo,
|
||||
titleModel: endpointConfig.titleModel,
|
||||
forcePrompt: endpointConfig.forcePrompt,
|
||||
|
||||
@@ -136,7 +136,7 @@ function getLLMConfig(apiKey, options = {}, endpoint = null) {
|
||||
Object.assign(llmConfig, azure);
|
||||
llmConfig.model = llmConfig.azureOpenAIApiDeploymentName;
|
||||
} else {
|
||||
llmConfig.openAIApiKey = apiKey;
|
||||
llmConfig.apiKey = apiKey;
|
||||
// Object.assign(llmConfig, {
|
||||
// configuration: { apiKey },
|
||||
// });
|
||||
|
||||
@@ -54,7 +54,7 @@ async function deleteOpenAIFile(req, file, openai) {
|
||||
throw new Error('OpenAI returned `false` for deleted status');
|
||||
}
|
||||
logger.debug(
|
||||
`[deleteOpenAIFile] User ${req.user.id} successfully deleted ${file.file_id} from OpenAI`,
|
||||
`[deleteOpenAIFile] User ${req.user.id} successfully deleted file "${file.file_id}" from OpenAI`,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[deleteOpenAIFile] Error deleting file from OpenAI: ' + error.message);
|
||||
|
||||
@@ -5,9 +5,10 @@ const { EModelEndpoint } = require('librechat-data-provider');
|
||||
* Resizes an image from a given buffer based on the specified resolution.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - The buffer of the image to be resized.
|
||||
* @param {'low' | 'high'} resolution - The resolution to resize the image to.
|
||||
* @param {'low' | 'high' | {percentage?: number, px?: number}} resolution - The resolution to resize the image to.
|
||||
* 'low' for a maximum of 512x512 resolution,
|
||||
* 'high' for a maximum of 768x2000 resolution.
|
||||
* 'high' for a maximum of 768x2000 resolution,
|
||||
* or a custom object with percentage or px values.
|
||||
* @param {EModelEndpoint} endpoint - Identifier for specific endpoint handling
|
||||
* @returns {Promise<{buffer: Buffer, width: number, height: number}>} An object containing the resized image buffer and its dimensions.
|
||||
* @throws Will throw an error if the resolution parameter is invalid.
|
||||
@@ -17,10 +18,32 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) {
|
||||
const maxShortSideHighRes = 768;
|
||||
const maxLongSideHighRes = endpoint === EModelEndpoint.anthropic ? 1568 : 2000;
|
||||
|
||||
let customPercent, customPx;
|
||||
if (resolution && typeof resolution === 'object') {
|
||||
if (typeof resolution.percentage === 'number') {
|
||||
customPercent = resolution.percentage;
|
||||
} else if (typeof resolution.px === 'number') {
|
||||
customPx = resolution.px;
|
||||
}
|
||||
}
|
||||
|
||||
let newWidth, newHeight;
|
||||
let resizeOptions = { fit: 'inside', withoutEnlargement: true };
|
||||
|
||||
if (resolution === 'low') {
|
||||
if (customPercent != null || customPx != null) {
|
||||
// percentage-based resize
|
||||
const metadata = await sharp(inputBuffer).metadata();
|
||||
if (customPercent != null) {
|
||||
newWidth = Math.round(metadata.width * (customPercent / 100));
|
||||
newHeight = Math.round(metadata.height * (customPercent / 100));
|
||||
} else {
|
||||
// pixel max on both sides
|
||||
newWidth = Math.min(metadata.width, customPx);
|
||||
newHeight = Math.min(metadata.height, customPx);
|
||||
}
|
||||
resizeOptions.width = newWidth;
|
||||
resizeOptions.height = newHeight;
|
||||
} else if (resolution === 'low') {
|
||||
resizeOptions.width = maxLowRes;
|
||||
resizeOptions.height = maxLowRes;
|
||||
} else if (resolution === 'high') {
|
||||
|
||||
@@ -137,11 +137,13 @@ const processDeleteRequest = async ({ req, files }) => {
|
||||
/** @type {Record<string, OpenAI | undefined>} */
|
||||
const client = { [FileSources.openai]: undefined, [FileSources.azure]: undefined };
|
||||
const initializeClients = async () => {
|
||||
const openAIClient = await getOpenAIClient({
|
||||
req,
|
||||
overrideEndpoint: EModelEndpoint.assistants,
|
||||
});
|
||||
client[FileSources.openai] = openAIClient.openai;
|
||||
if (req.app.locals[EModelEndpoint.assistants]) {
|
||||
const openAIClient = await getOpenAIClient({
|
||||
req,
|
||||
overrideEndpoint: EModelEndpoint.assistants,
|
||||
});
|
||||
client[FileSources.openai] = openAIClient.openai;
|
||||
}
|
||||
|
||||
if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
|
||||
return;
|
||||
@@ -693,7 +695,7 @@ const processOpenAIFile = async ({
|
||||
const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileExt }) => {
|
||||
const currentDate = new Date();
|
||||
const formattedDate = currentDate.toISOString();
|
||||
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
|
||||
const _file = await convertImage(req, buffer, undefined, `${file_id}${fileExt}`);
|
||||
const file = {
|
||||
..._file,
|
||||
usage: 1,
|
||||
@@ -838,8 +840,9 @@ function base64ToBuffer(base64String) {
|
||||
|
||||
async function saveBase64Image(
|
||||
url,
|
||||
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution = 'high' },
|
||||
{ req, file_id: _file_id, filename: _filename, endpoint, context, resolution },
|
||||
) {
|
||||
const effectiveResolution = resolution ?? req.app.locals.fileConfig?.imageGeneration ?? 'high';
|
||||
const file_id = _file_id ?? v4();
|
||||
let filename = `${file_id}-${_filename}`;
|
||||
const { buffer: inputBuffer, type } = base64ToBuffer(url);
|
||||
@@ -852,7 +855,7 @@ async function saveBase64Image(
|
||||
}
|
||||
}
|
||||
|
||||
const image = await resizeImageBuffer(inputBuffer, resolution, endpoint);
|
||||
const image = await resizeImageBuffer(inputBuffer, effectiveResolution, endpoint);
|
||||
const source = req.app.locals.fileStrategy;
|
||||
const { saveBuffer } = getStrategyFunctions(source);
|
||||
const filepath = await saveBuffer({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { normalizeServerName } = require('librechat-mcp');
|
||||
const { Constants: AgentConstants, Providers } = require('@librechat/agents');
|
||||
const {
|
||||
Constants,
|
||||
@@ -38,6 +39,7 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
|
||||
}
|
||||
|
||||
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
|
||||
const normalizedToolKey = `${toolName}${Constants.mcp_delimiter}${normalizeServerName(serverName)}`;
|
||||
|
||||
if (!req.user?.id) {
|
||||
logger.error(
|
||||
@@ -83,7 +85,7 @@ async function createMCPTool({ req, toolKey, provider: _provider }) {
|
||||
|
||||
const toolInstance = tool(_call, {
|
||||
schema,
|
||||
name: toolKey,
|
||||
name: normalizedToolKey,
|
||||
description: description || '',
|
||||
responseFormat: AgentConstants.CONTENT_AND_ARTIFACT,
|
||||
});
|
||||
|
||||
@@ -132,6 +132,8 @@ async function saveUserMessage(req, params) {
|
||||
* @param {string} params.endpoint - The conversation endpoint
|
||||
* @param {string} params.parentMessageId - The latest user message that triggered this response.
|
||||
* @param {string} [params.instructions] - Optional: from preset for `instructions` field.
|
||||
* @param {string} [params.spec] - Optional: Model spec identifier.
|
||||
* @param {string} [params.iconURL]
|
||||
* Overrides the instructions of the assistant.
|
||||
* @param {string} [params.promptPrefix] - Optional: from preset for `additional_instructions` field.
|
||||
* @return {Promise<Run>} A promise that resolves to the created run object.
|
||||
@@ -154,6 +156,8 @@ async function saveAssistantMessage(req, params) {
|
||||
text: params.text,
|
||||
unfinished: false,
|
||||
// tokenCount,
|
||||
iconURL: params.iconURL,
|
||||
spec: params.spec,
|
||||
});
|
||||
|
||||
await saveConvo(
|
||||
@@ -165,6 +169,8 @@ async function saveAssistantMessage(req, params) {
|
||||
instructions: params.instructions,
|
||||
assistant_id: params.assistant_id,
|
||||
model: params.model,
|
||||
iconURL: params.iconURL,
|
||||
spec: params.spec,
|
||||
},
|
||||
{ context: 'api/server/services/Threads/manage.js #saveAssistantMessage' },
|
||||
);
|
||||
|
||||
44
api/server/services/start/turnstile.js
Normal file
44
api/server/services/start/turnstile.js
Normal file
@@ -0,0 +1,44 @@
|
||||
const { removeNullishValues } = require('librechat-data-provider');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Loads and maps the Cloudflare Turnstile configuration.
|
||||
*
|
||||
* Expected config structure:
|
||||
*
|
||||
* turnstile:
|
||||
* siteKey: "your-site-key-here"
|
||||
* options:
|
||||
* language: "auto" // "auto" or an ISO 639-1 language code (e.g. en)
|
||||
* size: "normal" // Options: "normal", "compact", "flexible", or "invisible"
|
||||
*
|
||||
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
|
||||
* @param {TConfigDefaults} configDefaults - The custom configuration default values.
|
||||
* @returns {TCustomConfig['turnstile']} The mapped Turnstile configuration.
|
||||
*/
|
||||
function loadTurnstileConfig(config, configDefaults) {
|
||||
const { turnstile: customTurnstile = {} } = config ?? {};
|
||||
const { turnstile: defaults = {} } = configDefaults;
|
||||
|
||||
/** @type {TCustomConfig['turnstile']} */
|
||||
const loadedTurnstile = removeNullishValues({
|
||||
siteKey: customTurnstile.siteKey ?? defaults.siteKey,
|
||||
options: customTurnstile.options ?? defaults.options,
|
||||
});
|
||||
|
||||
const enabled = Boolean(loadedTurnstile.siteKey);
|
||||
|
||||
if (enabled) {
|
||||
logger.info(
|
||||
'Turnstile is ENABLED with configuration:\n' + JSON.stringify(loadedTurnstile, null, 2),
|
||||
);
|
||||
} else {
|
||||
logger.info('Turnstile is DISABLED (no siteKey provided).');
|
||||
}
|
||||
|
||||
return loadedTurnstile;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadTurnstileConfig,
|
||||
};
|
||||
@@ -16,7 +16,6 @@ const keyvRedis = require('~/cache/keyvRedis');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Express.Application} app
|
||||
*/
|
||||
const configureSocialLogins = (app) => {
|
||||
@@ -38,10 +37,7 @@ const configureSocialLogins = (app) => {
|
||||
passport.use(appleLogin());
|
||||
}
|
||||
if (
|
||||
process.env.OPENID_CLIENT_ID &&
|
||||
process.env.OPENID_CLIENT_SECRET &&
|
||||
process.env.OPENID_ISSUER &&
|
||||
process.env.OPENID_SCOPE &&
|
||||
process.env.OPENID_ENABLED &&
|
||||
process.env.OPENID_SESSION_SECRET
|
||||
) {
|
||||
logger.info('Configuring OpenID Connect...');
|
||||
|
||||
52
api/server/utils/openidHelper.js
Normal file
52
api/server/utils/openidHelper.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const { logger } = require('~/config');
|
||||
const { getCustomConfig } = require('~/server/services/Config');
|
||||
|
||||
/**
|
||||
* Loads the tenant configurations from the custom configuration.
|
||||
* @returns {Promise<Array>} Array of tenant configurations.
|
||||
*/
|
||||
async function getOpenIdTenants() {
|
||||
try {
|
||||
const customConfig = await getCustomConfig();
|
||||
if (customConfig?.openid?.tenants) {
|
||||
return customConfig.openid.tenants;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to load custom configuration for OpenID tenants:', err);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses the OpenID strategy name based on the email domain.
|
||||
* It consults the global tenant mapping (built in setupOpenId).
|
||||
* @param {import('express').Request} req - The Express request object.
|
||||
* @returns {Promise<string>} - The chosen strategy name.
|
||||
*/
|
||||
async function chooseOpenIdStrategy(req) {
|
||||
if (req.query.email) {
|
||||
const email = req.query.email;
|
||||
const domain = email.split('@')[1].toLowerCase();
|
||||
const tenants = await getOpenIdTenants();
|
||||
|
||||
// Iterate over the tenants and return the strategy name of the first matching tenant
|
||||
for (const tenant of tenants) {
|
||||
if (tenant.domains) {
|
||||
const tenantDomains = tenant.domains.split(',').map(s => s.trim().toLowerCase());
|
||||
if (tenantDomains.includes(domain)) {
|
||||
// Look up the registered strategy via the global mapping.
|
||||
if (tenant.name && tenant.name.trim() && global.__openidTenantMapping) {
|
||||
const mapped = global.__openidTenantMapping.get(tenant.name.trim().toLowerCase());
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
}
|
||||
return 'openid'; // Fallback if no mapping exists.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'openid';
|
||||
}
|
||||
|
||||
module.exports = { getOpenIdTenants, chooseOpenIdStrategy };
|
||||
@@ -14,6 +14,7 @@ const staticCache = (staticPath) =>
|
||||
res.setHeader('Cache-Control', `public, max-age=${maxAge}, s-maxage=${sMaxAge}`);
|
||||
}
|
||||
},
|
||||
index: false,
|
||||
});
|
||||
|
||||
module.exports = staticCache;
|
||||
|
||||
@@ -23,7 +23,7 @@ const {
|
||||
|
||||
// Check required environment variables
|
||||
if (!LDAP_URL || !LDAP_USER_SEARCH_BASE) {
|
||||
return null;
|
||||
module.exports = null;
|
||||
}
|
||||
|
||||
const searchAttributes = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const fetch = require('node-fetch');
|
||||
const passport = require('passport');
|
||||
const jwtDecode = require('jsonwebtoken/decode');
|
||||
const { decode: jwtDecode } = require('jsonwebtoken');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { Issuer, Strategy: OpenIDStrategy, custom } = require('openid-client');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
@@ -8,6 +8,7 @@ const { findUser, createUser, updateUser } = require('~/models/userMethods');
|
||||
const { hashToken } = require('~/server/utils/crypto');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
const { getOpenIdTenants } = require('~/server/utils/openidHelper');
|
||||
|
||||
let crypto;
|
||||
try {
|
||||
@@ -105,16 +106,18 @@ function convertToUsername(input, defaultValue = '') {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
async function setupOpenId() {
|
||||
/**
|
||||
* Sets up a single OpenID strategy for the given tenant configuration.
|
||||
* @param {Object} tenant - The tenant’s OpenID config (issuer, clientId, etc.).
|
||||
* @param {string} tenant.issuer
|
||||
* @param {string} tenant.clientId
|
||||
* @param {string} tenant.clientSecret
|
||||
* @param {string} strategyName - Unique name for the strategy.
|
||||
*/
|
||||
async function setupSingleStrategy(tenant, strategyName) {
|
||||
try {
|
||||
if (process.env.PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
custom.setHttpOptionsDefaults({
|
||||
agent: proxyAgent,
|
||||
});
|
||||
logger.info(`[openidStrategy] proxy agent added: ${process.env.PROXY}`);
|
||||
}
|
||||
const issuer = await Issuer.discover(process.env.OPENID_ISSUER);
|
||||
// Discover the issuer (this performs the .well-known lookup).
|
||||
const issuer = await Issuer.discover(tenant.issuer);
|
||||
/* Supported Algorithms, openid-client v5 doesn't set it automatically as discovered from server.
|
||||
- id_token_signed_response_alg // defaults to 'RS256'
|
||||
- request_object_signing_alg // defaults to 'RS256'
|
||||
@@ -124,8 +127,8 @@ async function setupOpenId() {
|
||||
*/
|
||||
/** @type {import('openid-client').ClientMetadata} */
|
||||
const clientMetadata = {
|
||||
client_id: process.env.OPENID_CLIENT_ID,
|
||||
client_secret: process.env.OPENID_CLIENT_SECRET,
|
||||
client_id: tenant.clientId,
|
||||
client_secret: tenant.clientSecret,
|
||||
redirect_uris: [process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL],
|
||||
};
|
||||
if (isEnabled(process.env.OPENID_SET_FIRST_SUPPORTED_ALGORITHM)) {
|
||||
@@ -146,7 +149,7 @@ async function setupOpenId() {
|
||||
async (tokenset, userinfo, done) => {
|
||||
try {
|
||||
logger.info(`[openidStrategy] verify login openidId: ${userinfo.sub}`);
|
||||
logger.debug('[openidStrategy] very login tokenset and userinfo', { tokenset, userinfo });
|
||||
logger.debug('[openidStrategy] verify login tokenset and userinfo', { tokenset, userinfo });
|
||||
|
||||
let user = await findUser({ openidId: userinfo.sub });
|
||||
logger.info(
|
||||
@@ -265,7 +268,65 @@ async function setupOpenId() {
|
||||
},
|
||||
);
|
||||
|
||||
passport.use('openid', openidLogin);
|
||||
passport.use(strategyName, openidLogin);
|
||||
logger.info(`Configured OpenID strategy [${strategyName}] for issuer: ${tenant.issuer}`);
|
||||
} catch (err) {
|
||||
logger.error(`[openidStrategy] Error configuring strategy "${strategyName}":`, err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the YAML configuration and registers strategies for multi-tenant OpenID Connect.
|
||||
*/
|
||||
async function setupOpenId() {
|
||||
try {
|
||||
// If a proxy is configured, set it for openid-client.
|
||||
|
||||
// Set global HTTP options for openid-client
|
||||
if (process.env.PROXY) {
|
||||
const proxyAgent = new HttpsProxyAgent(process.env.PROXY);
|
||||
custom.setHttpOptionsDefaults({
|
||||
agent: proxyAgent,
|
||||
timeout: 10000, // 10,000ms = 10 seconds
|
||||
});
|
||||
logger.info(`[openidStrategy] Proxy agent added: ${process.env.PROXY} with timeout 10000ms`);
|
||||
} else {
|
||||
custom.setHttpOptionsDefaults({
|
||||
timeout: 10000, // Increase the default timeout
|
||||
});
|
||||
logger.info('[openidStrategy] Set default timeout to 10000ms');
|
||||
}
|
||||
|
||||
const tenants = await getOpenIdTenants();
|
||||
|
||||
// Global mapping: tenant name (lowercase) -> strategy name.
|
||||
const tenantMapping = new Map();
|
||||
|
||||
// If there is one tenant with no domains specified, register it as the default "openid" strategy.
|
||||
if (tenants.length === 1 && (!tenants[0].domains || tenants[0].domains.trim() === '')) {
|
||||
await setupSingleStrategy(tenants[0].openid, 'openid');
|
||||
tenantMapping.set(tenants[0].name?.trim().toLowerCase() || 'openid', 'openid');
|
||||
logger.info('Configured single-tenant OpenID strategy as "openid"');
|
||||
} else {
|
||||
// Otherwise, iterate over each tenant.
|
||||
for (const tenantCfg of tenants) {
|
||||
const openidCfg = tenantCfg.openid;
|
||||
let strategyName = 'openid';
|
||||
if (tenantCfg.name && tenantCfg.name.trim()) {
|
||||
strategyName = `openid_${tenantCfg.name.trim()}`;
|
||||
}else {
|
||||
logger.warn(
|
||||
`[openidStrategy] Tenant with issuer ${openidCfg.issuer} has no domains specified; defaulting strategy name to "openid".`,
|
||||
);
|
||||
}
|
||||
await setupSingleStrategy(openidCfg, strategyName);
|
||||
if (tenantCfg.name && tenantCfg.name.trim()) {
|
||||
tenantMapping.set(tenantCfg.name.trim().toLowerCase(), strategyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Store the tenant mapping globally so that the helper can choose the correct strategy.
|
||||
global.__openidTenantMapping = tenantMapping;
|
||||
} catch (err) {
|
||||
logger.error('[openidStrategy]', err);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,15 @@ jest.mock('~/config', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// IMPORTANT: Mock the openid helper to return our desired tenant configuration.
|
||||
jest.mock('~/server/utils/openidHelper', () => ({
|
||||
getOpenIdTenants: jest.fn(),
|
||||
chooseOpenIdStrategy: jest.fn(), // Not used in these tests.
|
||||
}));
|
||||
|
||||
// Import our mocked helper so we can set its return value.
|
||||
const { getOpenIdTenants } = require('~/server/utils/openidHelper');
|
||||
|
||||
// Mock Issuer.discover so that setupOpenId gets a fake issuer and client
|
||||
Issuer.discover = jest.fn().mockResolvedValue({
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
@@ -97,6 +106,21 @@ describe('setupOpenId', () => {
|
||||
delete process.env.OPENID_NAME_CLAIM;
|
||||
delete process.env.PROXY;
|
||||
|
||||
// Set up our mocked tenant configuration.
|
||||
// Here we simulate a single tenant with an empty domains field.
|
||||
// (Our updated multi-tenant code uses the tenant name to build the strategy.)
|
||||
getOpenIdTenants.mockResolvedValue([
|
||||
{
|
||||
name: 'tenant1',
|
||||
domains: '', // Using an empty string so the single-tenant branch is taken.
|
||||
openid: {
|
||||
issuer: process.env.OPENID_ISSUER,
|
||||
clientId: process.env.OPENID_CLIENT_ID,
|
||||
clientSecret: process.env.OPENID_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Default jwtDecode mock returns a token that includes the required role.
|
||||
jwtDecode.mockReturnValue({
|
||||
roles: ['requiredRole'],
|
||||
|
||||
@@ -8,6 +8,7 @@ jest.mock('winston', () => {
|
||||
mockFormatFunction.printf = jest.fn();
|
||||
mockFormatFunction.errors = jest.fn();
|
||||
mockFormatFunction.splat = jest.fn();
|
||||
mockFormatFunction.json = jest.fn();
|
||||
return {
|
||||
format: mockFormatFunction,
|
||||
createLogger: jest.fn().mockReturnValue({
|
||||
@@ -19,6 +20,7 @@ jest.mock('winston', () => {
|
||||
transports: {
|
||||
Console: jest.fn(),
|
||||
DailyRotateFile: jest.fn(),
|
||||
File: jest.fn(),
|
||||
},
|
||||
addColors: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -6,3 +6,7 @@ process.env.BAN_VIOLATIONS = 'true';
|
||||
process.env.BAN_DURATION = '7200000';
|
||||
process.env.BAN_INTERVAL = '20';
|
||||
process.env.CI = 'true';
|
||||
process.env.JWT_SECRET = 'test';
|
||||
process.env.JWT_REFRESH_SECRET = 'test';
|
||||
process.env.CREDS_KEY = 'test';
|
||||
process.env.CREDS_IV = 'test';
|
||||
|
||||
@@ -43,6 +43,60 @@
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports Graph
|
||||
* @typedef {import('@librechat/agents').Graph} Graph
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports StandardGraph
|
||||
* @typedef {import('@librechat/agents').StandardGraph} StandardGraph
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports EventHandler
|
||||
* @typedef {import('@librechat/agents').EventHandler} EventHandler
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ModelEndData
|
||||
* @typedef {import('@librechat/agents').ModelEndData} ModelEndData
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ToolEndData
|
||||
* @typedef {import('@librechat/agents').ToolEndData} ToolEndData
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ToolEndCallback
|
||||
* @typedef {import('@librechat/agents').ToolEndCallback} ToolEndCallback
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ChatModelStreamHandler
|
||||
* @typedef {import('@librechat/agents').ChatModelStreamHandler} ChatModelStreamHandler
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ContentAggregator
|
||||
* @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports GraphEvents
|
||||
* @typedef {import('@librechat/agents').GraphEvents} GraphEvents
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports AgentRun
|
||||
* @typedef {import('@librechat/agents').Run} AgentRun
|
||||
@@ -97,12 +151,6 @@
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports ToolEndData
|
||||
* @typedef {import('@librechat/agents').ToolEndData} ToolEndData
|
||||
* @memberof typedefs
|
||||
*/
|
||||
|
||||
/**
|
||||
* @exports BaseMessage
|
||||
* @typedef {import('@langchain/core/messages').BaseMessage} BaseMessage
|
||||
|
||||
@@ -60,10 +60,16 @@ const cohereModels = {
|
||||
|
||||
const googleModels = {
|
||||
/* Max I/O is combined so we subtract the amount from max response tokens for actual total */
|
||||
gemma: 8196,
|
||||
'gemma-2': 32768,
|
||||
'gemma-3': 32768,
|
||||
'gemma-3-27b': 131072,
|
||||
gemini: 30720, // -2048 from max
|
||||
'gemini-pro-vision': 12288,
|
||||
'gemini-exp': 2000000,
|
||||
'gemini-2.5': 1000000, // 1M input tokens, 64k output tokens
|
||||
'gemini-2.5-pro': 1000000,
|
||||
'gemini-2.5-flash': 1000000,
|
||||
'gemini-2.0': 2000000,
|
||||
'gemini-2.0-flash': 1000000,
|
||||
'gemini-2.0-flash-lite': 1000000,
|
||||
@@ -235,12 +241,15 @@ const modelMaxOutputs = {
|
||||
system_default: 1024,
|
||||
};
|
||||
|
||||
/** Outputs from https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-names */
|
||||
const anthropicMaxOutputs = {
|
||||
'claude-3-haiku': 4096,
|
||||
'claude-3-sonnet': 4096,
|
||||
'claude-3-opus': 4096,
|
||||
'claude-3.5-sonnet': 8192,
|
||||
'claude-3-5-sonnet': 8192,
|
||||
'claude-3.7-sonnet': 128000,
|
||||
'claude-3-7-sonnet': 128000,
|
||||
};
|
||||
|
||||
const maxOutputTokensMap = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.7.8-rc1",
|
||||
"version": "v0.7.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -34,6 +34,7 @@
|
||||
"@dicebear/collection": "^9.2.2",
|
||||
"@dicebear/core": "^9.2.2",
|
||||
"@headlessui/react": "^2.1.2",
|
||||
"@marsidev/react-turnstile": "^1.1.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.3",
|
||||
@@ -141,7 +142,7 @@
|
||||
"tailwindcss": "^3.4.1",
|
||||
"ts-jest": "^29.2.5",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6.2.5",
|
||||
"vite": "^6.3.4",
|
||||
"vite-plugin-compression2": "^1.3.3",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-pwa": "^0.21.2"
|
||||
|
||||
@@ -142,6 +142,7 @@ export enum Panel {
|
||||
builder = 'builder',
|
||||
actions = 'actions',
|
||||
model = 'model',
|
||||
version = 'version',
|
||||
}
|
||||
|
||||
export type FileSetter =
|
||||
@@ -535,6 +536,7 @@ export type NewConversationParams = {
|
||||
buildDefault?: boolean;
|
||||
keepLatestMessage?: boolean;
|
||||
keepAddedConvos?: boolean;
|
||||
disableParams?: boolean;
|
||||
};
|
||||
|
||||
export type ConvoGenerator = (params: NewConversationParams) => void | t.TConversation;
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import { visit } from 'unist-util-visit';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import type { Pluggable } from 'unified';
|
||||
import type { Artifact } from '~/common';
|
||||
import { useMessageContext, useArtifactContext } from '~/Providers';
|
||||
@@ -45,6 +46,7 @@ export function Artifact({
|
||||
children: React.ReactNode | { props: { children: React.ReactNode } };
|
||||
node: unknown;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const { messageId } = useMessageContext();
|
||||
const { getNextIndex, resetCounter } = useArtifactContext();
|
||||
const artifactIndex = useRef(getNextIndex(false)).current;
|
||||
@@ -86,6 +88,10 @@ export function Artifact({
|
||||
lastUpdateTime: now,
|
||||
};
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return setArtifact(currentArtifact);
|
||||
}
|
||||
|
||||
setArtifacts((prevArtifacts) => {
|
||||
if (
|
||||
prevArtifacts?.[artifactKey] != null &&
|
||||
@@ -110,6 +116,7 @@ export function Artifact({
|
||||
props.identifier,
|
||||
messageId,
|
||||
artifactIndex,
|
||||
location.pathname,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,15 +1,52 @@
|
||||
import { useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
|
||||
import type { Artifact } from '~/common';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { getFileType, logger } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { getFileType } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
const localize = useLocalize();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisible);
|
||||
const location = useLocation();
|
||||
const setVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
|
||||
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
|
||||
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
|
||||
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
|
||||
|
||||
const debouncedSetVisibleRef = useRef(
|
||||
debounce((artifactToSet: Artifact) => {
|
||||
logger.log(
|
||||
'artifacts_visibility',
|
||||
'Setting artifact to visible state from Artifact button',
|
||||
artifactToSet,
|
||||
);
|
||||
setVisibleArtifacts((prev) => ({
|
||||
...prev,
|
||||
[artifactToSet.id]: artifactToSet,
|
||||
}));
|
||||
}, 750),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact == null || artifact?.id == null || artifact.id === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const debouncedSetVisible = debouncedSetVisibleRef.current;
|
||||
debouncedSetVisible(artifact);
|
||||
return () => {
|
||||
debouncedSetVisible.cancel();
|
||||
};
|
||||
}, [artifact, location.pathname]);
|
||||
|
||||
if (artifact === null || artifact === undefined) {
|
||||
return null;
|
||||
}
|
||||
@@ -20,8 +57,14 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!location.pathname.includes('/c/')) {
|
||||
return;
|
||||
}
|
||||
resetCurrentArtifactId();
|
||||
setVisible(true);
|
||||
if (artifacts?.[artifact.id] == null) {
|
||||
setArtifacts(visibleArtifacts);
|
||||
}
|
||||
setTimeout(() => {
|
||||
setCurrentArtifactId(artifact.id);
|
||||
}, 15);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||
import DownloadArtifact from './DownloadArtifact';
|
||||
@@ -18,7 +18,7 @@ export default function Artifacts() {
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
|
||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
@@ -48,37 +48,26 @@ export default function Artifacts() {
|
||||
setTimeout(() => setIsRefreshing(false), 750);
|
||||
};
|
||||
|
||||
const closeArtifacts = () => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
{/* Main Parent */}
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* Main Container */}
|
||||
<div
|
||||
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
|
||||
isVisible
|
||||
? 'translate-x-0 scale-100 opacity-100'
|
||||
: 'translate-x-full scale-95 opacity-0'
|
||||
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out ${
|
||||
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="mr-2 text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
|
||||
</svg>
|
||||
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
|
||||
</div>
|
||||
@@ -118,22 +107,8 @@ export default function Artifacts() {
|
||||
{localize('com_ui_code')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<button
|
||||
className="text-text-secondary"
|
||||
onClick={() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => setArtifactsVisible(false), 300);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
|
||||
</svg>
|
||||
<button className="text-text-secondary" onClick={closeArtifacts}>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -149,29 +124,13 @@ export default function Artifacts() {
|
||||
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
|
||||
<div className="flex items-center">
|
||||
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z" />
|
||||
</svg>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="text-xs">{`${currentIndex + 1} / ${
|
||||
orderedArtifactIds.length
|
||||
}`}</span>
|
||||
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z" />
|
||||
</svg>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -35,7 +35,7 @@ export const CodeMarkdown = memo(
|
||||
const [userScrolled, setUserScrolled] = useState(false);
|
||||
const currentContent = content;
|
||||
const rehypePlugins = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[rehypeKatex],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useContext } from 'react';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import type { TLoginUser, TStartupConfig } from 'librechat-data-provider';
|
||||
import type { TAuthContext } from '~/common';
|
||||
import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { ThemeContext, useLocalize } from '~/hooks';
|
||||
|
||||
type TLoginFormProps = {
|
||||
onSubmit: (data: TLoginUser) => void;
|
||||
@@ -14,6 +15,8 @@ type TLoginFormProps = {
|
||||
|
||||
const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error, setError }) => {
|
||||
const localize = useLocalize();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
|
||||
const {
|
||||
register,
|
||||
getValues,
|
||||
@@ -21,9 +24,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
formState: { errors },
|
||||
} = useForm<TLoginUser>();
|
||||
const [showResendLink, setShowResendLink] = useState<boolean>(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
|
||||
const { data: config } = useGetStartupConfig();
|
||||
const useUsernameLogin = config?.ldap?.username;
|
||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && error.includes('422') && !showResendLink) {
|
||||
@@ -96,20 +102,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
},
|
||||
})}
|
||||
aria-invalid={!!errors.email}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
className="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="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
|
||||
"
|
||||
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"
|
||||
>
|
||||
{useUsernameLogin
|
||||
? localize('com_auth_username').replace(/ \(.*$/, '')
|
||||
@@ -131,20 +129,12 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
maxLength: { value: 128, message: localize('com_auth_password_max_length') },
|
||||
})}
|
||||
aria-invalid={!!errors.password}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
className="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="password"
|
||||
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
|
||||
"
|
||||
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_password')}
|
||||
</label>
|
||||
@@ -159,15 +149,29 @@ const LoginForm: React.FC<TLoginFormProps> = ({ onSubmit, startupConfig, error,
|
||||
{localize('com_auth_password_forgot')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{requireCaptcha && (
|
||||
<div className="my-4 flex justify-center">
|
||||
<Turnstile
|
||||
siteKey={startupConfig.turnstile!.siteKey}
|
||||
options={{
|
||||
...startupConfig.turnstile!.options,
|
||||
theme: validTheme,
|
||||
}}
|
||||
onSuccess={setTurnstileToken}
|
||||
onError={() => setTurnstileToken(null)}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
aria-label={localize('com_auth_continue')}
|
||||
data-testid="login-button"
|
||||
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
|
||||
"
|
||||
disabled={requireCaptcha && !turnstileToken}
|
||||
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-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{localize('com_auth_continue')}
|
||||
</button>
|
||||
|
||||
104
client/src/components/Auth/MultiTenantOpenID.tsx
Normal file
104
client/src/components/Auth/MultiTenantOpenID.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { OpenIDIcon } from '~/components';
|
||||
|
||||
interface MultiTenantOpenIDProps {
|
||||
serverDomain: string;
|
||||
openidLabel: string;
|
||||
openidImageUrl: string;
|
||||
localize: (key: string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* When multi‑tenant mode is enabled (startupConfig.emailLoginEnabled === true),
|
||||
* we render a form for the user to enter their email. When submitted, we perform a GET
|
||||
* request (via redirect) to /oauth/openid with the email as a query parameter.
|
||||
* If, for some reason, no email is provided, we simply redirect to /oauth/openid.
|
||||
*/
|
||||
function MultiTenantOpenID({
|
||||
serverDomain,
|
||||
openidLabel,
|
||||
openidImageUrl,
|
||||
localize,
|
||||
}: MultiTenantOpenIDProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<{ email: string }>();
|
||||
|
||||
const onSubmit = (data: { email: string }) => {
|
||||
// If an email is provided, include it as a query parameter.
|
||||
// Otherwise, simply redirect without an email.
|
||||
const emailQuery =
|
||||
data.email && data.email.trim() !== ''
|
||||
? `?email=${encodeURIComponent(data.email)}`
|
||||
: '';
|
||||
window.location.href = `${serverDomain}/oauth/openid${emailQuery}`;
|
||||
};
|
||||
|
||||
const renderError = (fieldName: string) => {
|
||||
const errorMessage = errors[fieldName]?.message;
|
||||
return errorMessage ? (
|
||||
<span role="alert" className="mt-1 text-sm text-red-500 dark:text-red-900">
|
||||
{String(errorMessage)}
|
||||
</span>
|
||||
) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="mt-2">
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
autoComplete="email"
|
||||
aria-label={localize('com_auth_email')}
|
||||
{...register('email', {
|
||||
required: localize('com_auth_email_required'),
|
||||
maxLength: { value: 120, message: localize('com_auth_email_max_length') },
|
||||
pattern: {
|
||||
value: /\S+@\S+\.\S+/,
|
||||
message: localize('com_auth_email_pattern'),
|
||||
},
|
||||
})}
|
||||
aria-invalid={!!errors.email}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
placeholder=" "
|
||||
/>
|
||||
<label
|
||||
htmlFor="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>
|
||||
{renderError('email')}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
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="openid"
|
||||
>
|
||||
{openidImageUrl ? (
|
||||
<img src={openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
|
||||
) : (
|
||||
<OpenIDIcon />
|
||||
)}
|
||||
<p>{openidLabel}</p>
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default MultiTenantOpenID;
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { useRegisterUserMutation } from 'librechat-data-provider/react-query';
|
||||
import type { TRegisterUser, TError } from 'librechat-data-provider';
|
||||
import type { TLoginLayoutContext } from '~/common';
|
||||
import { ErrorMessage } from './ErrorMessage';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { useLocalize, TranslationKeys, ThemeContext } from '~/hooks';
|
||||
|
||||
const Registration: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const { startupConfig, startupConfigError, isFetching } = useOutletContext<TLoginLayoutContext>();
|
||||
|
||||
const {
|
||||
@@ -24,10 +26,15 @@ const Registration: React.FC = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [countdown, setCountdown] = useState<number>(3);
|
||||
const [turnstileToken, setTurnstileToken] = useState<string | null>(null);
|
||||
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const token = queryParams.get('token');
|
||||
const validTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
|
||||
// only require captcha if we have a siteKey
|
||||
const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey);
|
||||
|
||||
const registerUser = useRegisterUserMutation({
|
||||
onMutate: () => {
|
||||
@@ -69,21 +76,13 @@ const Registration: React.FC = () => {
|
||||
validation,
|
||||
)}
|
||||
aria-invalid={!!errors[id]}
|
||||
className="
|
||||
webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light
|
||||
bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none
|
||||
"
|
||||
className="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=" "
|
||||
data-testid={id}
|
||||
/>
|
||||
<label
|
||||
htmlFor={id}
|
||||
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-500
|
||||
rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4
|
||||
"
|
||||
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-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4"
|
||||
>
|
||||
{localize(label)}
|
||||
</label>
|
||||
@@ -178,17 +177,32 @@ const Registration: React.FC = () => {
|
||||
validate: (value: string) =>
|
||||
value === password || localize('com_auth_password_not_match'),
|
||||
})}
|
||||
|
||||
{startupConfig?.turnstile?.siteKey && (
|
||||
<div className="my-4 flex justify-center">
|
||||
<Turnstile
|
||||
siteKey={startupConfig.turnstile.siteKey}
|
||||
options={{
|
||||
...startupConfig.turnstile.options,
|
||||
theme: validTheme,
|
||||
}}
|
||||
onSuccess={(token) => setTurnstileToken(token)}
|
||||
onError={() => setTurnstileToken(null)}
|
||||
onExpire={() => setTurnstileToken(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
disabled={Object.keys(errors).length > 0}
|
||||
disabled={
|
||||
Object.keys(errors).length > 0 ||
|
||||
isSubmitting ||
|
||||
(requireCaptcha && !turnstileToken)
|
||||
}
|
||||
type="submit"
|
||||
aria-label="Submit registration"
|
||||
className="
|
||||
w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white
|
||||
transition-colors hover:bg-green-700 focus:outline-none focus:ring-2
|
||||
focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50
|
||||
disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700
|
||||
"
|
||||
className="w-full rounded-2xl bg-green-600 px-4 py-3 text-sm font-medium text-white transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:opacity-50 disabled:hover:bg-green-600 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
{isSubmitting ? <Spinner /> : localize('com_auth_continue')}
|
||||
</button>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { GoogleIcon, FacebookIcon, OpenIDIcon, GithubIcon, DiscordIcon, AppleIcon } from '~/components';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
GoogleIcon,
|
||||
FacebookIcon,
|
||||
OpenIDIcon,
|
||||
GithubIcon,
|
||||
DiscordIcon,
|
||||
AppleIcon,
|
||||
} from '~/components';
|
||||
import SocialButton from './SocialButton';
|
||||
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
import { TStartupConfig } from 'librechat-data-provider';
|
||||
import MultiTenantOpenID from './MultiTenantOpenID';
|
||||
|
||||
function SocialLoginRender({
|
||||
startupConfig,
|
||||
@@ -73,23 +79,37 @@ function SocialLoginRender({
|
||||
id="apple"
|
||||
/>
|
||||
),
|
||||
openid: startupConfig.openidLoginEnabled && (
|
||||
<SocialButton
|
||||
key="openid"
|
||||
enabled={startupConfig.openidLoginEnabled}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
oauthPath="openid"
|
||||
Icon={() =>
|
||||
startupConfig.openidImageUrl ? (
|
||||
<img src={startupConfig.openidImageUrl} alt="OpenID Logo" className="h-5 w-5" />
|
||||
) : (
|
||||
<OpenIDIcon />
|
||||
)
|
||||
}
|
||||
label={startupConfig.openidLabel}
|
||||
id="openid"
|
||||
/>
|
||||
),
|
||||
openid:
|
||||
startupConfig.openidLoginEnabled &&
|
||||
(startupConfig.openidMultiTenantEnabled ? (
|
||||
<MultiTenantOpenID
|
||||
key="openid"
|
||||
openidImageUrl={startupConfig.openidImageUrl}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
openidLabel={startupConfig.openidLabel}
|
||||
localize={localize}
|
||||
/>
|
||||
) : (
|
||||
<SocialButton
|
||||
key="openid"
|
||||
enabled={startupConfig.openidLoginEnabled}
|
||||
serverDomain={startupConfig.serverDomain}
|
||||
oauthPath="openid"
|
||||
Icon={() =>
|
||||
startupConfig.openidImageUrl ? (
|
||||
<img
|
||||
src={startupConfig.openidImageUrl}
|
||||
alt="OpenID Logo"
|
||||
className="h-5 w-5"
|
||||
/>
|
||||
) : (
|
||||
<OpenIDIcon />
|
||||
)
|
||||
}
|
||||
label={startupConfig.openidLabel}
|
||||
id="openid"
|
||||
/>
|
||||
)),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -44,15 +44,6 @@ export default function ExportAndShareMenu({
|
||||
};
|
||||
|
||||
const dropdownItems: t.MenuItemProps[] = [
|
||||
{
|
||||
label: localize('com_endpoint_export'),
|
||||
onClick: exportHandler,
|
||||
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: exportButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
{
|
||||
label: localize('com_ui_share'),
|
||||
onClick: shareHandler,
|
||||
@@ -63,6 +54,15 @@ export default function ExportAndShareMenu({
|
||||
ref: shareButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
{
|
||||
label: localize('com_endpoint_export'),
|
||||
onClick: exportHandler,
|
||||
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
|
||||
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
|
||||
hideOnClick: false,
|
||||
ref: exportButtonRef,
|
||||
render: (props) => <button {...props} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -70,6 +70,7 @@ export default function ExportAndShareMenu({
|
||||
<DropdownPopup
|
||||
menuId={menuId}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
trigger={
|
||||
@@ -81,7 +82,7 @@ export default function ExportAndShareMenu({
|
||||
aria-label="Export options"
|
||||
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Upload
|
||||
<Share2
|
||||
className="icon-md text-text-secondary"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
|
||||
@@ -108,6 +108,10 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
);
|
||||
|
||||
const handleContainerClick = useCallback(() => {
|
||||
/** Check if the device is a touchscreen */
|
||||
if (window.matchMedia?.('(pointer: coarse)').matches) {
|
||||
return;
|
||||
}
|
||||
textAreaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
@@ -126,6 +130,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
});
|
||||
|
||||
const { submitMessage, submitPrompt } = useSubmitMessage();
|
||||
|
||||
const handleKeyUp = useHandleKeyUp({
|
||||
index,
|
||||
textAreaRef,
|
||||
@@ -201,8 +206,8 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
|
||||
<form
|
||||
onSubmit={methods.handleSubmit(submitMessage)}
|
||||
className={cn(
|
||||
'mx-auto flex flex-row gap-3 sm:px-2',
|
||||
maximizeChatSpace ? 'w-full max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
'mx-auto flex w-full flex-row gap-3 transition-[max-width] duration-300 sm:px-2',
|
||||
maximizeChatSpace ? 'max-w-full' : 'md:max-w-3xl xl:max-w-4xl',
|
||||
centerFormOnLanding &&
|
||||
(conversationId == null || conversationId === Constants.NEW_CONVO) &&
|
||||
!isSubmitting &&
|
||||
|
||||
@@ -41,9 +41,9 @@ const CollapseChat = ({
|
||||
)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronDown className="h-full w-full" />
|
||||
) : (
|
||||
<ChevronUp className="h-full w-full" />
|
||||
) : (
|
||||
<ChevronDown className="h-full w-full" />
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -119,6 +119,7 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
|
||||
isOpen={isPopoverActive}
|
||||
setIsOpen={setIsPopoverActive}
|
||||
modal={true}
|
||||
unmountOnHide={true}
|
||||
trigger={menuTrigger}
|
||||
items={dropdownItems}
|
||||
iconClassName="mr-0"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { TFile } from 'librechat-data-provider';
|
||||
import type { ExtendedFile } from '~/common';
|
||||
import FileIcon from '~/components/svg/Files/FileIcon';
|
||||
import ProgressCircle from './ProgressCircle';
|
||||
import { Spinner } from '~/components';
|
||||
import SourceIcon from './SourceIcon';
|
||||
import { useProgress } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const FilePreview = ({
|
||||
@@ -19,28 +18,15 @@ const FilePreview = ({
|
||||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
const radius = 55;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const progress = useProgress(
|
||||
file?.['progress'] ?? 1,
|
||||
0.001,
|
||||
(file as ExtendedFile | undefined)?.size ?? 1,
|
||||
);
|
||||
|
||||
const offset = circumference - progress * circumference;
|
||||
const circleCSSProperties = {
|
||||
transition: 'stroke-dashoffset 0.5s linear',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('relative size-10 shrink-0 overflow-hidden rounded-xl', className)}>
|
||||
<FileIcon file={file} fileType={fileType} />
|
||||
<SourceIcon source={file?.source} isCodeFile={!!file?.['metadata']?.fileIdentifier} />
|
||||
{progress < 1 && (
|
||||
<ProgressCircle
|
||||
circumference={circumference}
|
||||
offset={offset}
|
||||
circleCSSProperties={circleCSSProperties}
|
||||
{typeof file?.['progress'] === 'number' && file?.['progress'] < 1 && (
|
||||
<Spinner
|
||||
bgOpacity={0.2}
|
||||
color="white"
|
||||
className="absolute inset-0 m-2.5 flex items-center justify-center"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -75,20 +75,20 @@ export default function FileRow({
|
||||
const renderFiles = () => {
|
||||
const rowStyle = isRTL
|
||||
? {
|
||||
display: 'flex',
|
||||
flexDirection: 'row-reverse',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
}
|
||||
display: 'flex',
|
||||
flexDirection: 'row-reverse',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
}
|
||||
: {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
};
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={rowStyle as React.CSSProperties}>
|
||||
|
||||
@@ -161,7 +161,7 @@ const ImagePreview = ({
|
||||
<OGDialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className={cn('w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto')}
|
||||
className="w-11/12 overflow-x-auto bg-transparent p-0 sm:w-auto"
|
||||
disableScroll={false}
|
||||
>
|
||||
<img
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { ListFilter } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
@@ -36,6 +37,7 @@ import { TrashIcon, Spinner } from '~/components/svg';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useMediaQuery } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
@@ -60,12 +62,14 @@ type Style = {
|
||||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const setFiles = useSetRecoilState(store.filesByIndex(0));
|
||||
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
|
||||
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const { deleteFiles } = useDeleteFilesFromTable(() => setIsDeleting(false));
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
@@ -96,7 +100,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
const filesToDelete = table
|
||||
.getFilteredSelectedRowModel()
|
||||
.rows.map((row) => row.original);
|
||||
deleteFiles({ files: filesToDelete as TFile[] });
|
||||
deleteFiles({ files: filesToDelete as TFile[], setFiles });
|
||||
setRowSelection({});
|
||||
}}
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length || isDeleting}
|
||||
@@ -218,13 +222,10 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||
<div className="flex items-center justify-end gap-2 py-4">
|
||||
<div className="ml-2 flex-1 truncate text-xs text-muted-foreground sm:ml-4 sm:text-sm">
|
||||
<span className="hidden sm:inline">
|
||||
{localize(
|
||||
'com_files_number_selected',
|
||||
{
|
||||
0: `${table.getFilteredSelectedRowModel().rows.length}`,
|
||||
1: `${table.getFilteredRowModel().rows.length}`,
|
||||
},
|
||||
)}
|
||||
{localize('com_files_number_selected', {
|
||||
0: `${table.getFilteredSelectedRowModel().rows.length}`,
|
||||
1: `${table.getFilteredRowModel().rows.length}`,
|
||||
})}
|
||||
</span>
|
||||
<span className="sm:hidden">
|
||||
{`${table.getFilteredSelectedRowModel().rows.length}/${
|
||||
|
||||
@@ -79,19 +79,19 @@ export default function HeaderOptions({
|
||||
{!noSettings[endpoint] &&
|
||||
interfaceConfig?.parameters === true &&
|
||||
paramEndpoint === false && (
|
||||
<TooltipAnchor
|
||||
id="parameters-button"
|
||||
aria-label={localize('com_ui_model_parameters')}
|
||||
description={localize('com_ui_model_parameters')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={triggerAdvancedMode}
|
||||
data-testid="parameters-button"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
<TooltipAnchor
|
||||
id="parameters-button"
|
||||
aria-label={localize('com_ui_model_parameters')}
|
||||
description={localize('com_ui_model_parameters')}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={triggerAdvancedMode}
|
||||
data-testid="parameters-button"
|
||||
className="inline-flex size-10 items-center justify-center rounded-lg border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
|
||||
>
|
||||
<Settings2 size={16} aria-label="Settings/Parameters Icon" />
|
||||
</TooltipAnchor>
|
||||
)}
|
||||
</div>
|
||||
{interfaceConfig?.parameters === true && paramEndpoint === false && (
|
||||
<OptionsPopover
|
||||
|
||||
@@ -31,7 +31,8 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||
select: (data) => {
|
||||
const serverNames = new Set<string>();
|
||||
data.forEach((tool) => {
|
||||
if (tool.pluginKey.includes(Constants.mcp_delimiter)) {
|
||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||
if (isMCP && tool.chatMenu !== false) {
|
||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
||||
serverNames.add(parts[parts.length - 1]);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function PopoverButtons({
|
||||
const endpoint = overrideEndpoint ?? endpointType ?? _endpoint ?? '';
|
||||
const model = overrideModel ?? _model;
|
||||
|
||||
const isGenerativeModel = model?.toLowerCase().includes('gemini') ?? false;
|
||||
const isGenerativeModel = /gemini|learnlm|gemma/.test(model ?? '') ?? false;
|
||||
const isChatModel = (!isGenerativeModel && model?.toLowerCase().includes('chat')) ?? false;
|
||||
const isTextModel = !isGenerativeModel && !isChatModel && /code|text/.test(model ?? '');
|
||||
|
||||
@@ -133,7 +133,6 @@ export default function PopoverButtons({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
|
||||
{disabled ? null : (
|
||||
<div className="flex w-[150px] items-center justify-end">
|
||||
{additionalButtons[settingsView].map((button, index) => (
|
||||
|
||||
@@ -160,6 +160,7 @@ const BookmarkMenu: FC = () => {
|
||||
focusLoop={true}
|
||||
menuId={menuId}
|
||||
isOpen={isMenuOpen}
|
||||
unmountOnHide={true}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
keyPrefix={`${conversationId}-bookmark-`}
|
||||
trigger={
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function CancelledIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-gray-300 text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.32256 1.48447C7.59011 1.16827 7.55068 0.695034 7.23447 0.427476C6.91827 0.159918 6.44503 0.199354 6.17748 0.515559L4.00002 3.08892L1.82256 0.515559C1.555 0.199354 1.08176 0.159918 0.765559 0.427476C0.449355 0.695034 0.409918 1.16827 0.677476 1.48447L3.01755 4.25002L0.677476 7.01556C0.409918 7.33176 0.449354 7.805 0.765559 8.07256C1.08176 8.34011 1.555 8.30068 1.82256 7.98447L4.00002 5.41111L6.17748 7.98447C6.44503 8.30068 6.91827 8.34011 7.23447 8.07256C7.55068 7.805 7.59011 7.33176 7.32256 7.01556L4.98248 4.25002L7.32256 1.48447Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex h-full w-full items-center justify-center rounded-full bg-transparent text-text-secondary">
|
||||
<X className="size-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { CodeInProgress } from './Parts/CodeProgress';
|
||||
import { useProgress, useLocalize } from '~/hooks';
|
||||
import ProgressText from './ProgressText';
|
||||
import FinishedIcon from './FinishedIcon';
|
||||
import MarkdownLite from './MarkdownLite';
|
||||
import store from '~/store';
|
||||
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
export default function CodeAnalyze({
|
||||
initialProgress = 0.1,
|
||||
code,
|
||||
outputs = [],
|
||||
isSubmitting,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
code: string;
|
||||
outputs: Record<string, unknown>[];
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const progress = useProgress(initialProgress);
|
||||
const showAnalysisCode = useRecoilValue(store.showCode);
|
||||
const [showCode, setShowCode] = useState(showAnalysisCode);
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
const logs = outputs.reduce((acc, output) => {
|
||||
if (output['logs']) {
|
||||
@@ -37,19 +29,6 @@ export default function CodeAnalyze({
|
||||
return (
|
||||
<>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<CodeInProgress
|
||||
offset={offset}
|
||||
radius={radius}
|
||||
progress={progress}
|
||||
isSubmitting={isSubmitting}
|
||||
circumference={circumference}
|
||||
/>
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useRecoilValue, useRecoilState } from 'recoil';
|
||||
import { ContentTypes } from 'librechat-data-provider';
|
||||
import type { TMessageContentParts, TAttachment, Agents } from 'librechat-data-provider';
|
||||
import { ThinkingButton } from '~/components/Artifacts/Thinking';
|
||||
import EditTextPart from './Parts/EditTextPart';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { mapAttachments } from '~/utils/map';
|
||||
import { MessageContext } from '~/Providers';
|
||||
import { EditTextPart } from './Parts';
|
||||
import store from '~/store';
|
||||
import Part from './Part';
|
||||
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { X, ArrowDownToLine } from 'lucide-react';
|
||||
import { Button, OGDialog, OGDialogContent } from '~/components';
|
||||
|
||||
export default function DialogImage({ src = '', width = 1920, height = 1080 }) {
|
||||
export default function DialogImage({ isOpen, onOpenChange, src = '', downloadImage }) {
|
||||
return (
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay
|
||||
className="radix-state-open:animate-show fixed inset-0 z-[100] flex items-center justify-center overflow-hidden bg-black/90 dark:bg-black/80"
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="h-full w-full rounded-none bg-transparent"
|
||||
disableScroll={false}
|
||||
overlayClassName="bg-surface-primary opacity-95 z-50"
|
||||
>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
className="absolute right-4 top-4 text-gray-50 transition hover:text-gray-200"
|
||||
type="button"
|
||||
<div className="absolute left-0 right-0 top-0 flex items-center justify-between p-4">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 p-0 hover:bg-surface-hover"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-5 w-5"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<Dialog.Content
|
||||
className="radix-state-open:animate-contentShow relative max-h-[85vh] max-w-[90vw] shadow-xl focus:outline-none"
|
||||
tabIndex={-1}
|
||||
style={{ pointerEvents: 'auto', aspectRatio: height > width ? 1 / 1.75 : 1.75 / 1 }}
|
||||
>
|
||||
<img src={src} alt="Uploaded image" className="h-full w-full object-contain" />
|
||||
</Dialog.Content>
|
||||
</Dialog.Overlay>
|
||||
</Dialog.Portal>
|
||||
<X className="size-6" />
|
||||
</Button>
|
||||
<Button onClick={() => downloadImage()} variant="ghost" className="h-10 w-10 p-0">
|
||||
<ArrowDownToLine className="size-6" />
|
||||
</Button>
|
||||
</div>
|
||||
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<OGDialogContent
|
||||
showCloseButton={false}
|
||||
className="w-11/12 overflow-x-auto rounded-none bg-transparent p-4 shadow-none sm:w-auto"
|
||||
disableScroll={false}
|
||||
overlayClassName="bg-transparent"
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt="Uploaded image"
|
||||
className="max-w-screen h-full max-h-screen w-full object-contain"
|
||||
/>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,9 +113,9 @@ const EditMessage = ({
|
||||
messages.map((msg) =>
|
||||
msg.messageId === messageId
|
||||
? {
|
||||
...msg,
|
||||
text: data.text,
|
||||
}
|
||||
...msg,
|
||||
text: data.text,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
export default function FinishedIcon() {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
className="flex size-4 items-center justify-center rounded-full bg-brand-purple text-white"
|
||||
data-projection-id="162"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 9" fill="none" width="8" height="9">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" fill="none" width="8" height="8">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import React, { useState, useRef, useMemo } from 'react';
|
||||
import { LazyLoadImage } from 'react-lazy-load-image-component';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { cn, scaleImage } from '~/utils';
|
||||
import DialogImage from './DialogImage';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const scaleImage = ({
|
||||
originalWidth,
|
||||
originalHeight,
|
||||
containerRef,
|
||||
}: {
|
||||
originalWidth?: number;
|
||||
originalHeight?: number;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
}) => {
|
||||
const containerWidth = containerRef.current?.offsetWidth ?? 0;
|
||||
if (containerWidth === 0 || originalWidth == null || originalHeight == null) {
|
||||
return { width: 'auto', height: 'auto' };
|
||||
}
|
||||
const aspectRatio = originalWidth / originalHeight;
|
||||
const scaledWidth = Math.min(containerWidth, originalWidth);
|
||||
const scaledHeight = scaledWidth / aspectRatio;
|
||||
return { width: `${scaledWidth}px`, height: `${scaledHeight}px` };
|
||||
};
|
||||
import { Skeleton } from '~/components';
|
||||
|
||||
const Image = ({
|
||||
imagePath,
|
||||
@@ -41,6 +22,7 @@ const Image = ({
|
||||
};
|
||||
className?: string;
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -56,39 +38,63 @@ const Image = ({
|
||||
[placeholderDimensions, height, width],
|
||||
);
|
||||
|
||||
const downloadImage = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = imagePath;
|
||||
link.download = altText;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root>
|
||||
<div ref={containerRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden bg-surface-active-alt text-text-secondary-alt',
|
||||
className,
|
||||
)}
|
||||
<div ref={containerRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative mt-1 flex h-auto w-full max-w-lg items-center justify-center overflow-hidden rounded-lg border border-border-light text-text-secondary-alt shadow-md',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`View ${altText} in dialog`}
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<LazyLoadImage
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'opacity-100 transition-opacity duration-100',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
style={{
|
||||
width: scaledWidth,
|
||||
height: 'auto',
|
||||
color: 'transparent',
|
||||
}}
|
||||
placeholder={<div style={{ width: scaledWidth, height: scaledHeight }} />}
|
||||
<LazyLoadImage
|
||||
alt={altText}
|
||||
onLoad={handleImageLoad}
|
||||
visibleByDefault={true}
|
||||
className={cn(
|
||||
'opacity-100 transition-opacity duration-100',
|
||||
isLoaded ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
src={imagePath}
|
||||
style={{
|
||||
width: `${scaledWidth}`,
|
||||
height: 'auto',
|
||||
color: 'transparent',
|
||||
display: 'block',
|
||||
}}
|
||||
placeholder={
|
||||
<Skeleton
|
||||
className={cn('h-auto w-full', `h-[${scaledHeight}] w-[${scaledWidth}]`)}
|
||||
aria-label="Loading image"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</button>
|
||||
</Dialog.Trigger>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
{isLoaded && (
|
||||
<DialogImage
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
src={imagePath}
|
||||
downloadImage={downloadImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isLoaded && <DialogImage src={imagePath} height={height} width={width} />}
|
||||
</Dialog.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
|
||||
|
||||
const rehypePlugins = useMemo(
|
||||
() => [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[rehypeKatex],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
|
||||
@@ -13,7 +13,7 @@ import { langSubset } from '~/utils';
|
||||
const MarkdownLite = memo(
|
||||
({ content = '', codeExecution = true }: { content?: string; codeExecution?: boolean }) => {
|
||||
const rehypePlugins: PluggableList = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[rehypeKatex],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
|
||||
@@ -7,17 +7,13 @@ import {
|
||||
} from 'librechat-data-provider';
|
||||
import { memo } from 'react';
|
||||
import type { TMessageContentParts, TAttachment } from 'librechat-data-provider';
|
||||
import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts';
|
||||
import { ErrorMessage } from './MessageContent';
|
||||
import AgentUpdate from './Parts/AgentUpdate';
|
||||
import ExecuteCode from './Parts/ExecuteCode';
|
||||
import RetrievalCall from './RetrievalCall';
|
||||
import Reasoning from './Parts/Reasoning';
|
||||
import EmptyText from './Parts/EmptyText';
|
||||
import CodeAnalyze from './CodeAnalyze';
|
||||
import Container from './Container';
|
||||
import ToolCall from './ToolCall';
|
||||
import ImageGen from './ImageGen';
|
||||
import Text from './Parts/Text';
|
||||
import Image from './Image';
|
||||
|
||||
type PartProps = {
|
||||
@@ -93,8 +89,21 @@ const Part = memo(
|
||||
<ExecuteCode
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
attachments={attachments}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
isToolCall &&
|
||||
(toolCall.name === 'image_gen_oai' || toolCall.name === 'image_edit_oai')
|
||||
) {
|
||||
return (
|
||||
<OpenAIImageGen
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
isSubmitting={isSubmitting}
|
||||
toolName={toolCall.name}
|
||||
args={typeof toolCall.args === 'string' ? toolCall.args : ''}
|
||||
output={toolCall.output ?? ''}
|
||||
attachments={attachments}
|
||||
/>
|
||||
);
|
||||
@@ -118,7 +127,6 @@ const Part = memo(
|
||||
initialProgress={toolCall.progress ?? 0.1}
|
||||
code={code_interpreter.input}
|
||||
outputs={code_interpreter.outputs ?? []}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
|
||||
@@ -1,25 +1,82 @@
|
||||
import { memo } from 'react';
|
||||
import { memo, useState, useEffect } from 'react';
|
||||
import { imageExtRegex } from 'librechat-data-provider';
|
||||
import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider';
|
||||
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||
import Image from '~/components/Chat/Messages/Content/Image';
|
||||
import { useAttachmentLink } from './LogLink';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const FileAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const FileAttachment = memo(({ attachment }: { attachment: Partial<TAttachment> }) => {
|
||||
const { handleDownload } = useAttachmentLink({
|
||||
href: attachment.filepath,
|
||||
filename: attachment.filename,
|
||||
href: attachment.filepath ?? '',
|
||||
filename: attachment.filename ?? '',
|
||||
});
|
||||
const extension = attachment.filename.split('.').pop();
|
||||
const extension = attachment.filename?.split('.').pop();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
if (!attachment.filepath) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'file-attachment-container',
|
||||
'transition-all duration-300 ease-out',
|
||||
isVisible ? 'translate-y-0 opacity-100' : 'translate-y-2 opacity-0',
|
||||
)}
|
||||
style={{
|
||||
transformOrigin: 'center top',
|
||||
willChange: 'opacity, transform',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<FileContainer
|
||||
file={attachment}
|
||||
onClick={handleDownload}
|
||||
overrideType={extension}
|
||||
containerClassName="max-w-fit"
|
||||
buttonClassName="bg-surface-secondary hover:cursor-pointer hover:bg-surface-hover active:bg-surface-secondary focus:bg-surface-hover hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoaded(false);
|
||||
const timer = setTimeout(() => setIsLoaded(true), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [attachment]);
|
||||
|
||||
return (
|
||||
<FileContainer
|
||||
file={attachment}
|
||||
onClick={handleDownload}
|
||||
overrideType={extension}
|
||||
containerClassName="max-w-fit"
|
||||
buttonClassName="hover:cursor-pointer hover:bg-surface-secondary active:bg-surface-secondary focus:bg-surface-secondary hover:border-border-heavy active:border-border-heavy"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'image-attachment-container',
|
||||
'transition-all duration-500 ease-out',
|
||||
isLoaded ? 'scale-100 opacity-100' : 'scale-[0.98] opacity-0',
|
||||
)}
|
||||
style={{
|
||||
transformOrigin: 'center top',
|
||||
willChange: 'opacity, transform',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath ?? ''}
|
||||
height={height ?? 0}
|
||||
width={width ?? 0}
|
||||
className="mb-4"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -27,20 +84,60 @@ export default function Attachment({ attachment }: { attachment?: TAttachment })
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage =
|
||||
imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null;
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<Image
|
||||
altText={attachment.filename}
|
||||
imagePath={filepath}
|
||||
height={height}
|
||||
width={width}
|
||||
className="mb-4"
|
||||
/>
|
||||
);
|
||||
return <ImageAttachment attachment={attachment} />;
|
||||
} else if (!attachment.filepath) {
|
||||
return null;
|
||||
}
|
||||
return <FileAttachment attachment={attachment} />;
|
||||
}
|
||||
|
||||
export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] }) {
|
||||
if (!attachments || attachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileAttachments: TAttachment[] = [];
|
||||
const imageAttachments: TAttachment[] = [];
|
||||
|
||||
attachments.forEach((attachment) => {
|
||||
const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata;
|
||||
const isImage =
|
||||
imageExtRegex.test(attachment.filename) &&
|
||||
width != null &&
|
||||
height != null &&
|
||||
filepath != null;
|
||||
|
||||
if (isImage) {
|
||||
imageAttachments.push(attachment);
|
||||
} else {
|
||||
fileAttachments.push(attachment);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="my-2 flex flex-wrap items-center gap-2.5">
|
||||
{fileAttachments.map((attachment, index) =>
|
||||
attachment.filepath ? (
|
||||
<FileAttachment attachment={attachment} key={`file-${index}`} />
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap items-center">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
<ImageAttachment attachment={attachment} key={`image-${index}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import ProgressCircle from '~/components/Chat/Messages/Content/ProgressCircle';
|
||||
import CancelledIcon from '~/components/Chat/Messages/Content/CancelledIcon';
|
||||
|
||||
export const CodeInProgress = ({
|
||||
offset,
|
||||
circumference,
|
||||
radius,
|
||||
isSubmitting,
|
||||
progress,
|
||||
}: {
|
||||
progress: number;
|
||||
offset: number;
|
||||
circumference: number;
|
||||
radius: number;
|
||||
isSubmitting: boolean;
|
||||
}) => {
|
||||
if (progress < 1 && !isSubmitting) {
|
||||
return <CancelledIcon />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
|
||||
style={{ opacity: 1, transform: 'none' }}
|
||||
data-projection-id="77"
|
||||
>
|
||||
<div className="absolute bottom-[1.5px] right-[1.5px]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 20 20"
|
||||
width="20"
|
||||
height="20"
|
||||
style={{ transform: 'translate3d(0px, 0px, 0px)' }}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<clipPath id="__lottie_element_11">
|
||||
<rect width="20" height="20" x="0" y="0" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#__lottie_element_11)">
|
||||
<g
|
||||
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
|
||||
className="slide-from-left"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,7.026679992675781,8.834091186523438)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.201031"
|
||||
d=" M1.2870399951934814,0.2207774966955185 C0.992609977722168,-0.07359249889850616 0.5152599811553955,-0.07359249889850616 0.22082999348640442,0.2207774966955185 C-0.07361000031232834,0.5151575207710266 -0.07361000031232834,0.992437481880188 0.22082999348640442,1.2868175506591797 C0.8473266959190369,1.9131841659545898 1.4738233089447021,2.53955078125 2.1003201007843018,3.16591739654541 C1.4738233089447021,3.7922842502593994 0.8473266959190369,4.4186506271362305 0.22082999348640442,5.045017719268799 C-0.07361000031232834,5.339417457580566 -0.07361000031232834,5.816617488861084 0.22082999348640442,6.11101770401001 C0.5152599811553955,6.405417442321777 0.992609977722168,6.405417442321777 1.2870399951934814,6.11101770401001 C2.091266632080078,5.306983947753906 2.895493268966675,4.502950668334961 3.6997199058532715,3.6989173889160156 C3.994119882583618,3.404517412185669 3.994119882583618,2.927217483520508 3.6997199058532715,2.6329174041748047 C2.895493268966675,1.8288708925247192 2.091266632080078,1.0248241424560547 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 C1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185 1.2870399951934814,0.2207774966955185"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g
|
||||
style={{ display: 'block', transform: 'matrix(1,0,0,1,-2,-2)', opacity: 1 }}
|
||||
className="slide-to-down"
|
||||
>
|
||||
<g opacity="1" transform="matrix(1,0,0,1,11.79640007019043,13.512199401855469)">
|
||||
<path
|
||||
fill="rgb(177,98,253)"
|
||||
fillOpacity="1"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fillOpacity="0"
|
||||
stroke="rgb(177,98,253)"
|
||||
strokeOpacity="1"
|
||||
strokeWidth="0.100515"
|
||||
d=" M4.3225998878479,0 C3.1498000621795654,0 1.9769999980926514,0 0.8041999936103821,0 C0.36010000109672546,0 0,0.36000001430511475 0,0.804099977016449 C0,1.2482000589370728 0.36010000109672546,1.6081000566482544 0.8041999936103821,1.6081000566482544 C1.9769999980926514,1.6081000566482544 3.1498000621795654,1.6081000566482544 4.3225998878479,1.6081000566482544 C4.7667999267578125,1.6081000566482544 5.126800060272217,1.2482000589370728 5.126800060272217,0.804099977016449 C5.126800060272217,0.36000001430511475 4.7667999267578125,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0 C4.3225998878479,0 4.3225998878479,0 4.3225998878479,0"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<ProgressCircle radius={radius} circumference={circumference} offset={offset} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo, useState, useRef, useEffect } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TAttachment } from 'librechat-data-provider';
|
||||
import ProgressText from '~/components/Chat/Messages/Content/ProgressText';
|
||||
import FinishedIcon from '~/components/Chat/Messages/Content/FinishedIcon';
|
||||
import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite';
|
||||
import { useProgress, useLocalize } from '~/hooks';
|
||||
import { CodeInProgress } from './CodeProgress';
|
||||
import Attachment from './Attachment';
|
||||
import { AttachmentGroup } from './Attachment';
|
||||
import Stdout from './Stdout';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface ParsedArgs {
|
||||
@@ -45,46 +44,101 @@ export function useParseArgs(args: string): ParsedArgs {
|
||||
}, [args]);
|
||||
}
|
||||
|
||||
const radius = 56.08695652173913;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
export default function ExecuteCode({
|
||||
initialProgress = 0.1,
|
||||
args,
|
||||
output = '',
|
||||
isSubmitting,
|
||||
attachments,
|
||||
}: {
|
||||
initialProgress: number;
|
||||
args: string;
|
||||
output?: string;
|
||||
isSubmitting: boolean;
|
||||
attachments?: TAttachment[];
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const showAnalysisCode = useRecoilValue(store.showCode);
|
||||
const [showCode, setShowCode] = useState(showAnalysisCode);
|
||||
const codeContentRef = useRef<HTMLDivElement>(null);
|
||||
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const hasOutput = output.length > 0;
|
||||
const outputRef = useRef<string>(output);
|
||||
const prevShowCodeRef = useRef<boolean>(showCode);
|
||||
|
||||
const { lang, code } = useParseArgs(args);
|
||||
const progress = useProgress(initialProgress);
|
||||
const offset = circumference - progress * circumference;
|
||||
|
||||
useEffect(() => {
|
||||
if (output !== outputRef.current) {
|
||||
outputRef.current = output;
|
||||
|
||||
if (showCode && codeContentRef.current) {
|
||||
setTimeout(() => {
|
||||
if (codeContentRef.current) {
|
||||
const newHeight = codeContentRef.current.scrollHeight;
|
||||
setContentHeight(newHeight);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
}, [output, showCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showCode !== prevShowCodeRef.current) {
|
||||
prevShowCodeRef.current = showCode;
|
||||
|
||||
if (showCode && codeContentRef.current) {
|
||||
setIsAnimating(true);
|
||||
requestAnimationFrame(() => {
|
||||
if (codeContentRef.current) {
|
||||
const height = codeContentRef.current.scrollHeight;
|
||||
setContentHeight(height);
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
} else if (!showCode) {
|
||||
setIsAnimating(true);
|
||||
setContentHeight(0);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setIsAnimating(false);
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [showCode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!codeContentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
if (showCode && !isAnimating) {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === codeContentRef.current) {
|
||||
setContentHeight(entry.contentRect.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserver.observe(codeContentRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [showCode, isAnimating]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="my-2.5 flex items-center gap-2.5">
|
||||
<div className="relative h-5 w-5 shrink-0">
|
||||
{progress < 1 ? (
|
||||
<CodeInProgress
|
||||
offset={offset}
|
||||
radius={radius}
|
||||
progress={progress}
|
||||
isSubmitting={isSubmitting}
|
||||
circumference={circumference}
|
||||
/>
|
||||
) : (
|
||||
<FinishedIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="relative my-2.5 flex size-5 shrink-0 items-center gap-2.5">
|
||||
<ProgressText
|
||||
progress={progress}
|
||||
onClick={() => setShowCode((prev) => !prev)}
|
||||
@@ -94,31 +148,71 @@ export default function ExecuteCode({
|
||||
isExpanded={showCode}
|
||||
/>
|
||||
</div>
|
||||
{showCode && (
|
||||
<div className="code-analyze-block mb-3 mt-0.5 overflow-hidden rounded-xl bg-black">
|
||||
<MarkdownLite
|
||||
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
|
||||
codeExecution={false}
|
||||
/>
|
||||
{output.length > 0 && (
|
||||
<div className="bg-gray-700 p-4 text-xs">
|
||||
<div
|
||||
className="prose flex flex-col-reverse text-white"
|
||||
style={{
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative mb-2"
|
||||
style={{
|
||||
height: showCode ? contentHeight : 0,
|
||||
overflow: 'hidden',
|
||||
transition:
|
||||
'height 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transformOrigin: 'top',
|
||||
willChange: 'height, opacity',
|
||||
perspective: '1000px',
|
||||
backfaceVisibility: 'hidden',
|
||||
WebkitFontSmoothing: 'subpixel-antialiased',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'code-analyze-block mt-0.5 overflow-hidden rounded-xl bg-surface-primary',
|
||||
showCode && 'shadow-lg',
|
||||
)}
|
||||
ref={codeContentRef}
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0) scale(1)' : 'translateY(-8px) scale(0.98)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.4s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{showCode && (
|
||||
<div
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0)' : 'translateY(-4px)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.35s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
<MarkdownLite
|
||||
content={code ? `\`\`\`${lang}\n${code}\n\`\`\`` : ''}
|
||||
codeExecution={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{hasOutput && (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-tertiary p-4 text-xs',
|
||||
showCode ? 'border-t border-surface-primary-contrast' : '',
|
||||
)}
|
||||
style={{
|
||||
transform: showCode ? 'translateY(0)' : 'translateY(-6px)',
|
||||
opacity: showCode ? 1 : 0,
|
||||
transition:
|
||||
'transform 0.45s cubic-bezier(0.16, 1, 0.3, 1) 0.05s, opacity 0.45s cubic-bezier(0.19, 1, 0.22, 1) 0.05s',
|
||||
boxShadow: showCode ? '0 -1px 0 rgba(0,0,0,0.05)' : 'none',
|
||||
}}
|
||||
>
|
||||
<div className="prose flex flex-col-reverse">
|
||||
<Stdout output={output} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2.5">
|
||||
{attachments?.map((attachment, index) => (
|
||||
<Attachment attachment={attachment} key={index} />
|
||||
))}
|
||||
</div>
|
||||
{attachments && attachments.length > 0 && <AttachmentGroup attachments={attachments} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user