diff --git a/.env.example b/.env.example index a5e1af8a40a..a42ed5d683e 100644 --- a/.env.example +++ b/.env.example @@ -2,11 +2,9 @@ # LibreChat Configuration # #=====================================================================# # Please refer to the reference documentation for assistance # -# with configuring your LibreChat environment. The guide is # -# available both online and within your local LibreChat # -# directory: # -# Online: https://docs.librechat.ai/install/configuration/dotenv.html # -# Locally: ./docs/install/configuration/dotenv.md # +# with configuring your LibreChat environment. # +# # +# https://www.librechat.ai/docs/configuration/dotenv # #=====================================================================# #==================================================# @@ -62,15 +60,17 @@ PROXY= #===================================# # Known Endpoints - librechat.yaml # #===================================# -# https://docs.librechat.ai/install/configuration/ai_endpoints.html +# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints -# GROQ_API_KEY= -# SHUTTLEAI_KEY= -# OPENROUTER_KEY= -# MISTRAL_API_KEY= # ANYSCALE_API_KEY= +# APIPIE_API_KEY= # FIREWORKS_API_KEY= +# GROQ_API_KEY= +# HUGGINGFACE_TOKEN= +# MISTRAL_API_KEY= +# OPENROUTER_KEY= # PERPLEXITY_API_KEY= +# SHUTTLEAI_API_KEY= # TOGETHERAI_API_KEY= #============# @@ -113,15 +113,34 @@ BINGAI_TOKEN=user_provided #============# GOOGLE_KEY=user_provided -# GOOGLE_MODELS=gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k # GOOGLE_REVERSE_PROXY= +# Gemini API +# GOOGLE_MODELS=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 + +# Vertex AI +# GOOGLE_MODELS=gemini-1.5-flash-preview-0514,gemini-1.5-pro-preview-0409,gemini-1.0-pro-vision-001,gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k + +# Google Gemini Safety Settings +# NOTE (Vertex AI): You do not have access to the BLOCK_NONE setting by default. +# To use this restricted HarmBlockThreshold setting, you will need to either: +# +# (a) Get access through an allowlist via your Google account team +# (b) Switch your account type to monthly invoiced billing following this instruction: +# https://cloud.google.com/billing/docs/how-to/invoiced-billing +# +# GOOGLE_SAFETY_SEXUALLY_EXPLICIT=BLOCK_ONLY_HIGH +# GOOGLE_SAFETY_HATE_SPEECH=BLOCK_ONLY_HIGH +# GOOGLE_SAFETY_HARASSMENT=BLOCK_ONLY_HIGH +# GOOGLE_SAFETY_DANGEROUS_CONTENT=BLOCK_ONLY_HIGH + + #============# # OpenAI # #============# OPENAI_API_KEY=user_provided -# OPENAI_MODELS=gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k +# OPENAI_MODELS=gpt-4o,gpt-3.5-turbo-0125,gpt-3.5-turbo-0301,gpt-3.5-turbo,gpt-4,gpt-4-0613,gpt-4-vision-preview,gpt-3.5-turbo-0613,gpt-3.5-turbo-16k-0613,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview,gpt-3.5-turbo-1106,gpt-3.5-turbo-instruct,gpt-3.5-turbo-instruct-0914,gpt-3.5-turbo-16k DEBUG_OPENAI=false @@ -143,19 +162,19 @@ DEBUG_OPENAI=false ASSISTANTS_API_KEY=user_provided # ASSISTANTS_BASE_URL= -# ASSISTANTS_MODELS=gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview +# ASSISTANTS_MODELS=gpt-4o,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview #============# # OpenRouter # #============# - +# !!!Warning: Use the variable above instead of this one. Using this one will override the OpenAI endpoint # OPENROUTER_API_KEY= #============# # Plugins # #============# -# PLUGIN_MODELS=gpt-4,gpt-4-turbo-preview,gpt-4-0125-preview,gpt-4-1106-preview,gpt-4-0613,gpt-3.5-turbo,gpt-3.5-turbo-0125,gpt-3.5-turbo-1106,gpt-3.5-turbo-0613 +# PLUGIN_MODELS=gpt-4o,gpt-4,gpt-4-turbo-preview,gpt-4-0125-preview,gpt-4-1106-preview,gpt-4-0613,gpt-3.5-turbo,gpt-3.5-turbo-0125,gpt-3.5-turbo-1106,gpt-3.5-turbo-0613 DEBUG_PLUGINS=true @@ -192,7 +211,7 @@ AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= # Google #----------------- -GOOGLE_API_KEY= +GOOGLE_SEARCH_API_KEY= GOOGLE_CSE_ID= # SerpAPI diff --git a/.eslintrc.js b/.eslintrc.js index e85e0d768ca..58ee6d20a23 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -132,6 +132,13 @@ module.exports = { }, ], }, + { + files: './config/translations/**/*.ts', + parser: '@typescript-eslint/parser', + parserOptions: { + project: './config/translations/tsconfig.json', + }, + }, { files: ['./packages/data-provider/specs/**/*.ts'], parserOptions: { diff --git a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml index 26155bdc685..d85957fd22e 100644 --- a/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml +++ b/.github/ISSUE_TEMPLATE/FEATURE-REQUEST.yml @@ -43,7 +43,7 @@ body: id: terms attributes: label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/CODE_OF_CONDUCT.md) + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct required: true diff --git a/.github/ISSUE_TEMPLATE/QUESTION.yml b/.github/ISSUE_TEMPLATE/QUESTION.yml index 8a0cbf5535b..0669fd67244 100644 --- a/.github/ISSUE_TEMPLATE/QUESTION.yml +++ b/.github/ISSUE_TEMPLATE/QUESTION.yml @@ -44,7 +44,7 @@ body: id: terms attributes: label: Code of Conduct - description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/CODE_OF_CONDUCT.md) + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index a1542cb76e4..cb637787f12 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,10 @@ # Pull Request Template +⚠️ Before Submitting a PR, Please Review: +- Please ensure that you have thoroughly read and understood the [Contributing Docs](https://github.com/danny-avila/LibreChat/blob/main/.github/CONTRIBUTING.md) before submitting your Pull Request. -### ⚠️ Before Submitting a PR, read the [Contributing Docs](https://github.com/danny-avila/LibreChat/blob/main/.github/CONTRIBUTING.md) in full! +⚠️ Documentation Updates Notice: +- Kindly note that documentation updates are managed in this repository: [librechat.ai](https://github.com/LibreChat-AI/librechat.ai) ## Summary @@ -16,8 +19,6 @@ Please delete any irrelevant options. - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update - [ ] Translation update -- [ ] Documentation update - ## Testing @@ -37,4 +38,4 @@ Please delete any irrelevant options. - [ ] I have written tests demonstrating that my changes are effective or that my feature works - [ ] Local unit tests pass with my changes - [ ] Any changes dependent on mine have been merged and published in downstream modules. -- [ ] New documents have been locally validated with mkdocs +- [ ] A pull request for updating the documentation has been submitted. diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 9f8bd7b5302..52560009a97 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -51,6 +51,9 @@ jobs: exit 1 fi + - name: Prepare .env.test file + run: cp api/test/.env.test.example api/test/.env.test + - name: Run unit tests run: cd api && npm run test:ci @@ -60,4 +63,4 @@ jobs: - name: Run linters uses: wearerequired/lint-action@v2 with: - eslint: true \ No newline at end of file + eslint: true diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index 9f479e1b7a0..c8ba609a72c 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -1,11 +1,6 @@ -#github action to run unit tests for frontend with jest name: Frontend Unit Tests + on: - # push: - # branches: - # - main - # - dev - # - release/* pull_request: branches: - main @@ -14,9 +9,10 @@ on: paths: - 'client/**' - 'packages/**' + jobs: - tests_frontend: - name: Run frontend unit tests + tests_frontend_ubuntu: + name: Run frontend unit tests on Ubuntu timeout-minutes: 60 runs-on: ubuntu-latest steps: @@ -35,4 +31,26 @@ jobs: - name: Run unit tests run: npm run test:ci --verbose - working-directory: client \ No newline at end of file + working-directory: client + + tests_frontend_windows: + name: Run frontend unit tests on Windows + timeout-minutes: 60 + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build Client + run: npm run frontend:ci + + - name: Run unit tests + run: npm run test:ci --verbose + working-directory: client diff --git a/.github/workflows/mkdocs.yaml b/.github/workflows/mkdocs.yaml deleted file mode 100644 index 3b2878fa2a7..00000000000 --- a/.github/workflows/mkdocs.yaml +++ /dev/null @@ -1,27 +0,0 @@ -name: mkdocs -on: - push: - branches: - - main -permissions: - contents: write -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 - with: - python-version: 3.x - - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV - - uses: actions/cache@v3 - with: - key: mkdocs-material-${{ env.cache_id }} - path: .cache - restore-keys: | - mkdocs-material- - - run: pip install mkdocs-material - - run: pip install mkdocs-nav-weight - - run: pip install mkdocs-publisher - - run: pip install mkdocs-exclude - - run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index c55115988b9..3a01fc80176 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ coverage # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt +# translation services +config/translations/stores/* +client/src/localization/languages/*_missing_keys.json + # Compiled Dirs (http://nodejs.org/api/addons.html) build/ dist/ @@ -69,6 +73,8 @@ src/style - official.css /playwright/.cache/ .DS_Store *.code-workspace +.idx +monospace.json .idea *.iml *.pem @@ -76,6 +82,7 @@ config.local.ts **/storageState.json junit.xml **/.venv/ +**/venv/ # docker override file docker-compose.override.yaml diff --git a/Dockerfile b/Dockerfile index fd087eae39d..6c70a6d7f24 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,8 @@ -# v0.7.0 +# v0.7.2 # Base node image -FROM node:18-alpine3.18 AS node +FROM node:20-alpine AS node -RUN apk add g++ make py3-pip -RUN npm install -g node-gyp RUN apk --no-cache add curl RUN mkdir -p /app && chown node:node /app @@ -14,20 +12,20 @@ USER node COPY --chown=node:node . . -# Allow mounting of these files, which have no default -# values. -RUN touch .env -RUN npm config set fetch-retry-maxtimeout 600000 -RUN npm config set fetch-retries 5 -RUN npm config set fetch-retry-mintimeout 15000 -RUN npm install --no-audit +RUN \ + # Allow mounting of these files, which have no default + touch .env ; \ + # Create directories for the volumes to inherit the correct permissions + mkdir -p /app/client/public/images /app/api/logs ; \ + npm config set fetch-retry-maxtimeout 600000 ; \ + npm config set fetch-retries 5 ; \ + npm config set fetch-retry-mintimeout 15000 ; \ + npm install --no-audit; \ + # React client build + NODE_OPTIONS="--max-old-space-size=2048" npm run frontend; \ + npm prune --production; \ + npm cache clean --force -# React client build -ENV NODE_OPTIONS="--max-old-space-size=2048" -RUN npm run frontend - -# Create directories for the volumes to inherit -# the correct permissions RUN mkdir -p /app/client/public/images /app/api/logs # Node API setup diff --git a/Dockerfile.multi b/Dockerfile.multi index 00ed37e3ef8..41e8825b810 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,4 +1,4 @@ -# v0.7.0 +# v0.7.2 # Build API, Client and Data Provider FROM node:20-alpine AS base @@ -7,32 +7,31 @@ FROM node:20-alpine AS base FROM base AS data-provider-build WORKDIR /app/packages/data-provider COPY ./packages/data-provider ./ -RUN npm install +RUN npm install; npm cache clean --force RUN npm run build +RUN npm prune --production # React client build -FROM data-provider-build AS client-build +FROM base AS client-build WORKDIR /app/client COPY ./client/package*.json ./ # Copy data-provider to client's node_modules -RUN mkdir -p /app/client/node_modules/librechat-data-provider/ -RUN cp -R /app/packages/data-provider/* /app/client/node_modules/librechat-data-provider/ -RUN npm install +COPY --from=data-provider-build /app/packages/data-provider/ /app/client/node_modules/librechat-data-provider/ +RUN npm install; npm cache clean --force COPY ./client/ ./ ENV NODE_OPTIONS="--max-old-space-size=2048" RUN npm run build # Node API setup -FROM data-provider-build AS api-build +FROM base AS api-build WORKDIR /app/api COPY api/package*.json ./ COPY api/ ./ # Copy helper scripts COPY config/ ./ # Copy data-provider to API's node_modules -RUN mkdir -p /app/api/node_modules/librechat-data-provider/ -RUN cp -R /app/packages/data-provider/* /app/api/node_modules/librechat-data-provider/ -RUN npm install +COPY --from=data-provider-build /app/packages/data-provider/ /app/api/node_modules/librechat-data-provider/ +RUN npm install --include prod; npm cache clean --force COPY --from=client-build /app/client/dist /app/client/dist EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/README.md b/README.md index 901ddbc7c14..2cb0b593fc8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- +

LibreChat @@ -27,7 +27,7 @@

- + Deploy on Railway @@ -41,8 +41,16 @@ # 📃 Features - 🖥️ UI matching ChatGPT, including Dark mode, Streaming, and latest updates +- 🤖 AI model selection: + - OpenAI, Azure OpenAI, BingAI, ChatGPT, Google Vertex AI, Anthropic (Claude), Plugins, Assistants API (including Azure Assistants) +- ✅ Compatible across both **[Remote & Local AI services](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):** + - groq, Ollama, Cohere, Mistral AI, Apple MLX, koboldcpp, OpenRouter, together.ai, Perplexity, ShuttleAI, and more +- 💾 Create, Save, & Share Custom Presets +- 🔀 Switch between AI Endpoints and Presets, mid-chat +- 🔄 Edit, Resubmit, and Continue Messages with Conversation branching +- 🌿 Fork Messages & Conversations for Advanced Context control - 💬 Multimodal Chat: - - Upload and analyze images with Claude 3, GPT-4, and Gemini Vision 📸 + - Upload and analyze images with Claude 3, GPT-4 (including `gpt-4o`), and Gemini Vision 📸 - Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, & Google. 🗃️ - Advanced Agents with Files, Code Interpreter, Tools, and API Actions 🔦 - Available through the [OpenAI Assistants API](https://platform.openai.com/docs/assistants/overview) 🌤️ @@ -50,18 +58,18 @@ - 🌎 Multilingual UI: - English, 中文, Deutsch, Español, Français, Italiano, Polski, Português Brasileiro, - Русский, 日本語, Svenska, 한국어, Tiếng Việt, 繁體中文, العربية, Türkçe, Nederlands, עברית -- 🤖 AI model selection: OpenAI, Azure OpenAI, BingAI, ChatGPT, Google Vertex AI, Anthropic (Claude), Plugins, Assistants API (including Azure Assistants) -- 💾 Create, Save, & Share Custom Presets -- 🔄 Edit, Resubmit, and Continue messages with conversation branching +- 🎨 Customizable Dropdown & Interface: Adapts to both power users and newcomers. +- 📥 Import Conversations from LibreChat, ChatGPT, Chatbot UI - 📤 Export conversations as screenshots, markdown, text, json. - 🔍 Search all messages/conversations - 🔌 Plugins, including web access, image generation with DALL-E-3 and more - 👥 Multi-User, Secure Authentication with Moderation and Token spend tools -- ⚙️ Configure Proxy, Reverse Proxy, Docker, & many Deployment options +- ⚙️ Configure Proxy, Reverse Proxy, Docker, & many Deployment options: + - Use completely local or deploy on the cloud - 📖 Completely Open-Source & Built in Public - 🧑‍🤝‍🧑 Community-driven development, support, and feedback -[For a thorough review of our features, see our docs here](https://docs.librechat.ai/features/plugins/introduction.html) 📚 +[For a thorough review of our features, see our docs here](https://docs.librechat.ai/) 📚 ## 🪶 All-In-One AI Conversations with LibreChat @@ -69,37 +77,49 @@ LibreChat brings together the future of assistant AIs with the revolutionary tec With LibreChat, you no longer need to opt for ChatGPT Plus and can instead use free or pay-per-call APIs. We welcome contributions, cloning, and forking to enhance the capabilities of this advanced chatbot platform. - - -[![Watch the video](https://img.youtube.com/vi/pNIOs1ovsXw/maxresdefault.jpg)](https://youtu.be/pNIOs1ovsXw) +[![Watch the video](https://img.youtube.com/vi/YLVUW5UP9N0/maxresdefault.jpg)](https://www.youtube.com/watch?v=YLVUW5UP9N0) Click on the thumbnail to open the video☝️ --- -## 📚 Documentation +## 🌐 Resources -For more information on how to use our advanced features, install and configure our software, and access our guidelines and tutorials, please check out our documentation at [docs.librechat.ai](https://docs.librechat.ai) +**GitHub Repo:** + - **RAG API:** [github.com/danny-avila/rag_api](https://github.com/danny-avila/rag_api) + - **Website:** [github.com/LibreChat-AI/librechat.ai](https://github.com/LibreChat-AI/librechat.ai) + +**Other:** + - **Website:** [librechat.ai](https://librechat.ai) + - **Documentation:** [docs.librechat.ai](https://docs.librechat.ai) + - **Blog:** [blog.librechat.ai](https://docs.librechat.ai) --- ## 📝 Changelog -Keep up with the latest updates by visiting the releases page - [Releases](https://github.com/danny-avila/LibreChat/releases) +Keep up with the latest updates by visiting the releases page and notes: +- [Releases](https://github.com/danny-avila/LibreChat/releases) +- [Changelog](https://www.librechat.ai/changelog) -**⚠️ [Breaking Changes](docs/general_info/breaking_changes.md)** -Please consult the breaking changes before updating. +**⚠️ Please consult the [changelog](https://www.librechat.ai/changelog) for breaking changes before updating.** --- ## ⭐ Star History

-danny-avila%2FLibreChat | Trendshift + + Star History Chart + +

+

+ + danny-avila%2FLibreChat | Trendshift + + + ROSS Index - Fastest Growing Open-Source Startups in Q1 2024 | Runa Capital +

- - - Star History Chart - --- diff --git a/api/app/bingai.js b/api/app/bingai.js index f7ecf4462d4..ecb7cf33667 100644 --- a/api/app/bingai.js +++ b/api/app/bingai.js @@ -1,5 +1,6 @@ require('dotenv').config(); const { KeyvFile } = require('keyv-file'); +const { EModelEndpoint } = require('librechat-data-provider'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const { logger } = require('~/config'); @@ -23,10 +24,7 @@ const askBing = async ({ let key = null; if (expiresAt && isUserProvided) { - checkUserKeyExpiry( - expiresAt, - 'Your BingAI Cookies have expired. Please provide your cookies again.', - ); + checkUserKeyExpiry(expiresAt, EModelEndpoint.bingAI); key = await getUserKey({ userId, name: 'bingAI' }); } diff --git a/api/app/chatgpt-browser.js b/api/app/chatgpt-browser.js index 818661555dc..f3444d0e781 100644 --- a/api/app/chatgpt-browser.js +++ b/api/app/chatgpt-browser.js @@ -1,6 +1,6 @@ require('dotenv').config(); const { KeyvFile } = require('keyv-file'); -const { Constants } = require('librechat-data-provider'); +const { Constants, EModelEndpoint } = require('librechat-data-provider'); const { getUserKey, checkUserKeyExpiry } = require('../server/services/UserService'); const browserClient = async ({ @@ -18,10 +18,7 @@ const browserClient = async ({ let key = null; if (expiresAt && isUserProvided) { - checkUserKeyExpiry( - expiresAt, - 'Your ChatGPT Access Token has expired. Please provide your token again.', - ); + checkUserKeyExpiry(expiresAt, EModelEndpoint.chatGPTBrowser); key = await getUserKey({ userId, name: 'chatGPTBrowser' }); } diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 6d478defab0..aa5f913367c 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -7,10 +7,10 @@ const { } = require('librechat-data-provider'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { - titleFunctionPrompt, - parseTitleFromPrompt, truncateText, formatMessage, + titleFunctionPrompt, + parseParamFromPrompt, createContextHandlers, } = require('./prompts'); const spendTokens = require('~/models/spendTokens'); @@ -75,7 +75,9 @@ class AnthropicClient extends BaseClient { this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments)); this.maxContextTokens = - getModelMaxTokens(this.modelOptions.model, EModelEndpoint.anthropic) ?? 100000; + this.options.maxContextTokens ?? + getModelMaxTokens(this.modelOptions.model, EModelEndpoint.anthropic) ?? + 100000; this.maxResponseTokens = this.modelOptions.maxOutputTokens || 1500; this.maxPromptTokens = this.options.maxPromptTokens || this.maxContextTokens - this.maxResponseTokens; @@ -652,9 +654,13 @@ class AnthropicClient extends BaseClient { getSaveOptions() { return { + maxContextTokens: this.options.maxContextTokens, promptPrefix: this.options.promptPrefix, modelLabel: this.options.modelLabel, resendFiles: this.options.resendFiles, + iconURL: this.options.iconURL, + greeting: this.options.greeting, + spec: this.options.spec, ...this.modelOptions, }; } @@ -742,7 +748,7 @@ class AnthropicClient extends BaseClient { context: 'title', }); const text = response.content[0].text; - title = parseTitleFromPrompt(text); + title = parseParamFromPrompt(text, 'title'); } catch (e) { logger.error('[AnthropicClient] There was an issue generating the title', e); } diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index f7ed3b9cf18..7a4d345d74d 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -456,6 +456,8 @@ class BaseClient { sender: this.sender, text: addSpaceIfNeeded(generation) + completion, promptTokens, + iconURL: this.options.iconURL, + endpoint: this.options.endpoint, ...(this.metadata ?? {}), }; @@ -525,8 +527,19 @@ class BaseClient { return _messages; } + /** + * Save a message to the database. + * @param {TMessage} message + * @param {Partial} endpointOptions + * @param {string | null} user + */ async saveMessageToDatabase(message, endpointOptions, user = null) { - await saveMessage({ ...message, endpoint: this.options.endpoint, user, unfinished: false }); + await saveMessage({ + ...message, + endpoint: this.options.endpoint, + unfinished: false, + user, + }); await saveConvo(user, { conversationId: message.conversationId, endpoint: this.options.endpoint, @@ -556,11 +569,11 @@ class BaseClient { * the message is considered a root message. * * @param {Object} options - The options for the function. - * @param {Array} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property. + * @param {TMessage[]} options.messages - An array of message objects. Each object should have either an 'id' or 'messageId' property, and may have a 'parentMessageId' property. * @param {string} options.parentMessageId - The ID of the parent message to start the traversal from. * @param {Function} [options.mapMethod] - An optional function to map over the ordered messages. If provided, it will be applied to each message in the resulting array. * @param {boolean} [options.summary=false] - If set to true, the traversal modifies messages with 'summary' and 'summaryTokenCount' properties and stops at the message with a 'summary' property. - * @returns {Array} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'. + * @returns {TMessage[]} An array containing the messages in the order they should be displayed, starting with the most recent message with a 'summary' property if the 'summary' option is true, and ending with the message identified by 'parentMessageId'. */ static getMessagesForConversation({ messages, diff --git a/api/app/clients/GoogleClient.js b/api/app/clients/GoogleClient.js index c5edcb275a8..6852410efb9 100644 --- a/api/app/clients/GoogleClient.js +++ b/api/app/clients/GoogleClient.js @@ -1,7 +1,9 @@ const { google } = require('googleapis'); const { Agent, ProxyAgent } = require('undici'); -const { GoogleVertexAI } = require('langchain/llms/googlevertexai'); +const { ChatVertexAI } = require('@langchain/google-vertexai'); const { ChatGoogleGenerativeAI } = require('@langchain/google-genai'); +const { GoogleGenerativeAI: GenAI } = require('@google/generative-ai'); +const { GoogleVertexAI } = require('@langchain/community/llms/googlevertexai'); const { ChatGoogleVertexAI } = require('langchain/chat_models/googlevertexai'); const { AIMessage, HumanMessage, SystemMessage } = require('langchain/schema'); const { encoding_for_model: encodingForModel, get_encoding: getEncoding } = require('tiktoken'); @@ -10,6 +12,7 @@ const { getResponseSender, endpointSettings, EModelEndpoint, + VisionModes, AuthKeys, } = require('librechat-data-provider'); const { encodeAndFormat } = require('~/server/services/Files/images'); @@ -126,7 +129,7 @@ class GoogleClient extends BaseClient { this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments)); - // TODO: as of 12/14/23, only gemini models are "Generative AI" models provided by Google + /** @type {boolean} Whether using a "GenerativeAI" Model */ this.isGenerativeModel = this.modelOptions.model.includes('gemini'); const { isGenerativeModel } = this; this.isChatModel = !isGenerativeModel && this.modelOptions.model.includes('chat'); @@ -135,7 +138,10 @@ class GoogleClient extends BaseClient { !isGenerativeModel && !isChatModel && /code|text/.test(this.modelOptions.model); const { isTextModel } = this; - this.maxContextTokens = getModelMaxTokens(this.modelOptions.model, EModelEndpoint.google); + this.maxContextTokens = + this.options.maxContextTokens ?? + getModelMaxTokens(this.modelOptions.model, EModelEndpoint.google); + // The max prompt tokens is determined by the max context tokens minus the max response tokens. // Earlier messages will be dropped until the prompt is within the limit. this.maxResponseTokens = this.modelOptions.maxOutputTokens || settings.maxOutputTokens.default; @@ -234,7 +240,7 @@ class GoogleClient extends BaseClient { this.isVisionModel = true; } - if (this.isVisionModel && !attachments) { + if (this.isVisionModel && !attachments && this.modelOptions.model.includes('gemini-pro')) { this.modelOptions.model = 'gemini-pro'; this.isVisionModel = false; } @@ -247,6 +253,40 @@ class GoogleClient extends BaseClient { })).bind(this); } + /** + * Formats messages for generative AI + * @param {TMessage[]} messages + * @returns + */ + async formatGenerativeMessages(messages) { + const formattedMessages = []; + const attachments = await this.options.attachments; + const latestMessage = { ...messages[messages.length - 1] }; + const files = await this.addImageURLs(latestMessage, attachments, VisionModes.generative); + this.options.attachments = files; + messages[messages.length - 1] = latestMessage; + + for (const _message of messages) { + const role = _message.isCreatedByUser ? this.userLabel : this.modelLabel; + const parts = []; + parts.push({ text: _message.text }); + if (!_message.image_urls?.length) { + formattedMessages.push({ role, parts }); + continue; + } + + for (const images of _message.image_urls) { + if (images.inlineData) { + parts.push({ inlineData: images.inlineData }); + } + } + + formattedMessages.push({ role, parts }); + } + + return formattedMessages; + } + /** * * Adds image URLs to the message object and returns the files @@ -255,17 +295,23 @@ class GoogleClient extends BaseClient { * @param {MongoFile[]} files * @returns {Promise} */ - async addImageURLs(message, attachments) { + async addImageURLs(message, attachments, mode = '') { const { files, image_urls } = await encodeAndFormat( this.options.req, attachments, EModelEndpoint.google, + mode, ); message.image_urls = image_urls.length ? image_urls : undefined; return files; } - async buildVisionMessages(messages = [], parentMessageId) { + /** + * Builds the augmented prompt for attachments + * TODO: Add File API Support + * @param {TMessage[]} messages + */ + async buildAugmentedPrompt(messages = []) { const attachments = await this.options.attachments; const latestMessage = { ...messages[messages.length - 1] }; this.contextHandlers = createContextHandlers(this.options.req, latestMessage.text); @@ -281,6 +327,12 @@ class GoogleClient extends BaseClient { this.augmentedPrompt = await this.contextHandlers.createContext(); this.options.promptPrefix = this.augmentedPrompt + this.options.promptPrefix; } + } + + async buildVisionMessages(messages = [], parentMessageId) { + const attachments = await this.options.attachments; + const latestMessage = { ...messages[messages.length - 1] }; + await this.buildAugmentedPrompt(messages); const { prompt } = await this.buildMessagesPrompt(messages, parentMessageId); @@ -301,15 +353,26 @@ class GoogleClient extends BaseClient { return { prompt: payload }; } + /** @param {TMessage[]} [messages=[]] */ + async buildGenerativeMessages(messages = []) { + this.userLabel = 'user'; + this.modelLabel = 'model'; + const promises = []; + promises.push(await this.formatGenerativeMessages(messages)); + promises.push(this.buildAugmentedPrompt(messages)); + const [formattedMessages] = await Promise.all(promises); + return { prompt: formattedMessages }; + } + async buildMessages(messages = [], parentMessageId) { if (!this.isGenerativeModel && !this.project_id) { throw new Error( '[GoogleClient] a Service Account JSON Key is required for PaLM 2 and Codey models (Vertex AI)', ); - } else if (this.isGenerativeModel && (!this.apiKey || this.apiKey === 'user_provided')) { - throw new Error( - '[GoogleClient] an API Key is required for Gemini models (Generative Language API)', - ); + } + + if (!this.project_id && this.modelOptions.model.includes('1.5')) { + return await this.buildGenerativeMessages(messages); } if (this.options.attachments && this.isGenerativeModel) { @@ -526,13 +589,24 @@ class GoogleClient extends BaseClient { } createLLM(clientOptions) { - if (this.isGenerativeModel) { - return new ChatGoogleGenerativeAI({ ...clientOptions, apiKey: this.apiKey }); + const model = clientOptions.modelName ?? clientOptions.model; + if (this.project_id && this.isTextModel) { + return new GoogleVertexAI(clientOptions); + } else if (this.project_id && this.isChatModel) { + return new ChatGoogleVertexAI(clientOptions); + } else if (this.project_id) { + return new ChatVertexAI(clientOptions); + } else if (model.includes('1.5')) { + return new GenAI(this.apiKey).getGenerativeModel( + { + ...clientOptions, + model, + }, + { apiVersion: 'v1beta' }, + ); } - return this.isTextModel - ? new GoogleVertexAI(clientOptions) - : new ChatGoogleVertexAI(clientOptions); + return new ChatGoogleGenerativeAI({ ...clientOptions, apiKey: this.apiKey }); } async getCompletion(_payload, options = {}) { @@ -544,7 +618,7 @@ class GoogleClient extends BaseClient { let clientOptions = { ...parameters, maxRetries: 2 }; - if (!this.isGenerativeModel) { + if (this.project_id) { clientOptions['authOptions'] = { credentials: { ...this.serviceKey, @@ -557,7 +631,7 @@ class GoogleClient extends BaseClient { clientOptions = { ...clientOptions, ...this.modelOptions }; } - if (this.isGenerativeModel) { + if (this.isGenerativeModel && !this.project_id) { clientOptions.modelName = clientOptions.model; delete clientOptions.model; } @@ -588,16 +662,56 @@ class GoogleClient extends BaseClient { messages.unshift(new SystemMessage(context)); } + const modelName = clientOptions.modelName ?? clientOptions.model ?? ''; + if (modelName?.includes('1.5') && !this.project_id) { + /** @type {GenerativeModel} */ + const client = model; + const requestOptions = { + contents: _payload, + }; + + if (this.options?.promptPrefix?.length) { + requestOptions.systemInstruction = { + parts: [ + { + text: this.options.promptPrefix, + }, + ], + }; + } + + const safetySettings = _payload.safetySettings; + requestOptions.safetySettings = safetySettings; + + const delay = modelName.includes('flash') ? 8 : 14; + const result = await client.generateContentStream(requestOptions); + for await (const chunk of result.stream) { + const chunkText = chunk.text(); + await this.generateTextStream(chunkText, onProgress, { + delay, + }); + reply += chunkText; + } + return reply; + } + + const safetySettings = _payload.safetySettings; const stream = await model.stream(messages, { signal: abortController.signal, timeout: 7000, + safetySettings: safetySettings, }); + let delay = this.isGenerativeModel ? 12 : 8; + if (modelName.includes('flash')) { + delay = 5; + } for await (const chunk of stream) { - await this.generateTextStream(chunk?.content ?? chunk, onProgress, { - delay: this.isGenerativeModel ? 12 : 8, + const chunkText = chunk?.content ?? chunk; + await this.generateTextStream(chunkText, onProgress, { + delay, }); - reply += chunk?.content ?? chunk; + reply += chunkText; } return reply; @@ -607,6 +721,9 @@ class GoogleClient extends BaseClient { return { promptPrefix: this.options.promptPrefix, modelLabel: this.options.modelLabel, + iconURL: this.options.iconURL, + greeting: this.options.greeting, + spec: this.options.spec, ...this.modelOptions, }; } @@ -616,6 +733,33 @@ class GoogleClient extends BaseClient { } async sendCompletion(payload, opts = {}) { + const modelName = payload.parameters?.model; + + if (modelName && modelName.toLowerCase().includes('gemini')) { + const safetySettings = [ + { + category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + threshold: + process.env.GOOGLE_SAFETY_SEXUALLY_EXPLICIT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED', + }, + { + category: 'HARM_CATEGORY_HATE_SPEECH', + threshold: process.env.GOOGLE_SAFETY_HATE_SPEECH || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED', + }, + { + category: 'HARM_CATEGORY_HARASSMENT', + threshold: process.env.GOOGLE_SAFETY_HARASSMENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED', + }, + { + category: 'HARM_CATEGORY_DANGEROUS_CONTENT', + threshold: + process.env.GOOGLE_SAFETY_DANGEROUS_CONTENT || 'HARM_BLOCK_THRESHOLD_UNSPECIFIED', + }, + ]; + + payload.safetySettings = safetySettings; + } + let reply = ''; reply = await this.getCompletion(payload, opts); return reply.trim(); diff --git a/api/app/clients/OllamaClient.js b/api/app/clients/OllamaClient.js new file mode 100644 index 00000000000..57bc8754fb9 --- /dev/null +++ b/api/app/clients/OllamaClient.js @@ -0,0 +1,154 @@ +const { z } = require('zod'); +const axios = require('axios'); +const { Ollama } = require('ollama'); +const { deriveBaseURL } = require('~/utils'); +const { logger } = require('~/config'); + +const ollamaPayloadSchema = z.object({ + mirostat: z.number().optional(), + mirostat_eta: z.number().optional(), + mirostat_tau: z.number().optional(), + num_ctx: z.number().optional(), + repeat_last_n: z.number().optional(), + repeat_penalty: z.number().optional(), + temperature: z.number().optional(), + seed: z.number().nullable().optional(), + stop: z.array(z.string()).optional(), + tfs_z: z.number().optional(), + num_predict: z.number().optional(), + top_k: z.number().optional(), + top_p: z.number().optional(), + stream: z.optional(z.boolean()), + model: z.string(), +}); + +/** + * @param {string} imageUrl + * @returns {string} + * @throws {Error} + */ +const getValidBase64 = (imageUrl) => { + const parts = imageUrl.split(';base64,'); + + if (parts.length === 2) { + return parts[1]; + } else { + logger.error('Invalid or no Base64 string found in URL.'); + } +}; + +class OllamaClient { + constructor(options = {}) { + const host = deriveBaseURL(options.baseURL ?? 'http://localhost:11434'); + /** @type {Ollama} */ + this.client = new Ollama({ host }); + } + + /** + * Fetches Ollama models from the specified base API path. + * @param {string} baseURL + * @returns {Promise} The Ollama models. + */ + static async fetchModels(baseURL) { + let models = []; + if (!baseURL) { + return models; + } + try { + const ollamaEndpoint = deriveBaseURL(baseURL); + /** @type {Promise>} */ + const response = await axios.get(`${ollamaEndpoint}/api/tags`); + models = response.data.models.map((tag) => tag.name); + return models; + } catch (error) { + const logMessage = + 'Failed to fetch models from Ollama API. If you are not using Ollama directly, and instead, through some aggregator or reverse proxy that handles fetching via OpenAI spec, ensure the name of the endpoint doesn\'t start with `ollama` (case-insensitive).'; + logger.error(logMessage, error); + return []; + } + } + + /** + * @param {ChatCompletionMessage[]} messages + * @returns {OllamaMessage[]} + */ + static formatOpenAIMessages(messages) { + const ollamaMessages = []; + + for (const message of messages) { + if (typeof message.content === 'string') { + ollamaMessages.push({ + role: message.role, + content: message.content, + }); + continue; + } + + let aggregatedText = ''; + let imageUrls = []; + + for (const content of message.content) { + if (content.type === 'text') { + aggregatedText += content.text + ' '; + } else if (content.type === 'image_url') { + imageUrls.push(getValidBase64(content.image_url.url)); + } + } + + const ollamaMessage = { + role: message.role, + content: aggregatedText.trim(), + }; + + if (imageUrls.length > 0) { + ollamaMessage.images = imageUrls; + } + + ollamaMessages.push(ollamaMessage); + } + + return ollamaMessages; + } + + /*** + * @param {Object} params + * @param {ChatCompletionPayload} params.payload + * @param {onTokenProgress} params.onProgress + * @param {AbortController} params.abortController + */ + async chatCompletion({ payload, onProgress, abortController = null }) { + let intermediateReply = ''; + + const parameters = ollamaPayloadSchema.parse(payload); + const messages = OllamaClient.formatOpenAIMessages(payload.messages); + + if (parameters.stream) { + const stream = await this.client.chat({ + messages, + ...parameters, + }); + + for await (const chunk of stream) { + const token = chunk.message.content; + intermediateReply += token; + onProgress(token); + if (abortController.signal.aborted) { + stream.controller.abort(); + break; + } + } + } + // TODO: regular completion + else { + // const generation = await this.client.generate(payload); + } + + return intermediateReply; + } + catch(err) { + logger.error('[OllamaClient.chatCompletion]', err); + throw err; + } +} + +module.exports = { OllamaClient, ollamaPayloadSchema }; diff --git a/api/app/clients/OpenAIClient.js b/api/app/clients/OpenAIClient.js index f66afda4abd..b4a50bc05c6 100644 --- a/api/app/clients/OpenAIClient.js +++ b/api/app/clients/OpenAIClient.js @@ -1,6 +1,8 @@ const OpenAI = require('openai'); +const { OllamaClient } = require('./OllamaClient'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { + Constants, ImageDetail, EModelEndpoint, resolveHeaders, @@ -20,16 +22,16 @@ const { const { truncateText, formatMessage, - createContextHandlers, CUT_OFF_PROMPT, titleInstruction, + createContextHandlers, } = require('./prompts'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); +const { isEnabled, sleep } = require('~/server/utils'); const { handleOpenAIErrors } = require('./tools/util'); const spendTokens = require('~/models/spendTokens'); const { createLLM, RunManager } = require('./llm'); const ChatGPTClient = require('./ChatGPTClient'); -const { isEnabled } = require('~/server/utils'); const { summaryBuffer } = require('./memory'); const { runTitleChain } = require('./chains'); const { tokenSplit } = require('./document'); @@ -127,6 +129,10 @@ class OpenAIClient extends BaseClient { this.useOpenRouter = true; } + if (this.options.endpoint?.toLowerCase() === 'ollama') { + this.isOllama = true; + } + this.FORCE_PROMPT = isEnabled(OPENAI_FORCE_PROMPT) || (reverseProxy && reverseProxy.includes('completions') && !reverseProxy.includes('chat')); @@ -159,11 +165,13 @@ class OpenAIClient extends BaseClient { model.startsWith('text-chat') || model.startsWith('text-davinci-002-render'); this.maxContextTokens = + this.options.maxContextTokens ?? getModelMaxTokens( model, this.options.endpointType ?? this.options.endpoint, this.options.endpointTokenConfig, - ) ?? 4095; // 1 less than maximum + ) ?? + 4095; // 1 less than maximum if (this.shouldSummarize) { this.maxContextTokens = Math.floor(this.maxContextTokens / 2); @@ -200,16 +208,6 @@ class OpenAIClient extends BaseClient { this.setupTokens(); - if (!this.modelOptions.stop && !this.isVisionModel) { - const stopTokens = [this.startToken]; - if (this.endToken && this.endToken !== this.startToken) { - stopTokens.push(this.endToken); - } - stopTokens.push(`\n${this.userLabel}:`); - stopTokens.push('<|diff_marker|>'); - this.modelOptions.stop = stopTokens; - } - if (reverseProxy) { this.completionsUrl = reverseProxy; this.langchainProxy = extractBaseURL(reverseProxy); @@ -243,23 +241,52 @@ class OpenAIClient extends BaseClient { * @param {MongoFile[]} attachments */ checkVisionRequest(attachments) { + if (!attachments) { + return; + } + const availableModels = this.options.modelsConfig?.[this.options.endpoint]; - this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels }); + if (!availableModels) { + return; + } - const visionModelAvailable = availableModels?.includes(this.defaultVisionModel); - if ( - attachments && - attachments.some((file) => file?.type && file?.type?.includes('image')) && - visionModelAvailable && - !this.isVisionModel - ) { - this.modelOptions.model = this.defaultVisionModel; - this.isVisionModel = true; + let visionRequestDetected = false; + for (const file of attachments) { + if (file?.type?.includes('image')) { + visionRequestDetected = true; + break; + } + } + 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; + } + this.modelOptions.model = model; + this.isVisionModel = true; + 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; } setupTokens() { @@ -281,7 +308,7 @@ class OpenAIClient extends BaseClient { let tokenizer; this.encoding = 'text-davinci-003'; if (this.isChatCompletion) { - this.encoding = 'cl100k_base'; + this.encoding = this.modelOptions.model.includes('gpt-4o') ? 'o200k_base' : 'cl100k_base'; tokenizer = this.constructor.getTokenizer(this.encoding); } else if (this.isUnofficialChatGptModel) { const extendSpecialTokens = { @@ -386,10 +413,14 @@ class OpenAIClient extends BaseClient { getSaveOptions() { return { + maxContextTokens: this.options.maxContextTokens, chatGptLabel: this.options.chatGptLabel, promptPrefix: this.options.promptPrefix, resendFiles: this.options.resendFiles, imageDetail: this.options.imageDetail, + iconURL: this.options.iconURL, + greeting: this.options.greeting, + spec: this.options.spec, ...this.modelOptions, }; } @@ -411,7 +442,11 @@ class OpenAIClient extends BaseClient { * @returns {Promise} */ async addImageURLs(message, attachments) { - const { files, image_urls } = await encodeAndFormat(this.options.req, attachments); + const { files, image_urls } = await encodeAndFormat( + this.options.req, + attachments, + this.options.endpoint, + ); message.image_urls = image_urls.length ? image_urls : undefined; return files; } @@ -721,6 +756,10 @@ class OpenAIClient extends BaseClient { * In case of failure, it will return the default title, "New Chat". */ async titleConvo({ text, conversationId, responseText = '' }) { + if (this.options.attachments) { + delete this.options.attachments; + } + let title = 'New Chat'; const convo = `||>User: "${truncateText(text)}" @@ -729,7 +768,10 @@ class OpenAIClient extends BaseClient { const { OPENAI_TITLE_MODEL } = process.env ?? {}; - const model = this.options.titleModel ?? OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo'; + let model = this.options.titleModel ?? OPENAI_TITLE_MODEL ?? 'gpt-3.5-turbo'; + if (model === Constants.CURRENT_MODEL) { + model = this.modelOptions.model; + } const modelOptions = { // TODO: remove the gpt fallback and make it specific to endpoint @@ -851,7 +893,11 @@ ${convo} // TODO: remove the gpt fallback and make it specific to endpoint const { OPENAI_SUMMARY_MODEL = 'gpt-3.5-turbo' } = process.env ?? {}; - const model = this.options.summaryModel ?? OPENAI_SUMMARY_MODEL; + let model = this.options.summaryModel ?? OPENAI_SUMMARY_MODEL; + if (model === Constants.CURRENT_MODEL) { + model = this.modelOptions.model; + } + const maxContextTokens = getModelMaxTokens( model, @@ -1078,11 +1124,8 @@ ${convo} ...opts, }); - /* hacky fixes for Mistral AI API: - - Re-orders system message to the top of the messages payload, as not allowed anywhere else - - If there is only one message and it's a system message, change the role to user - */ - if (opts.baseURL.includes('https://api.mistral.ai/v1') && modelOptions.messages) { + /* Re-orders system message to the top of the messages payload, as not allowed anywhere else */ + if (modelOptions.messages && (opts.baseURL.includes('api.mistral.ai') || this.isOllama)) { const { messages } = modelOptions; const systemMessageIndex = messages.findIndex((msg) => msg.role === 'system'); @@ -1093,10 +1136,16 @@ ${convo} } modelOptions.messages = messages; + } - if (messages.length === 1 && messages[0].role === 'system') { - modelOptions.messages[0].role = 'user'; - } + /* If there is only one message and it's a system message, change the role to user */ + if ( + (opts.baseURL.includes('api.mistral.ai') || opts.baseURL.includes('api.perplexity.ai')) && + modelOptions.messages && + modelOptions.messages.length === 1 && + modelOptions.messages[0]?.role === 'system' + ) { + modelOptions.messages[0].role = 'user'; } if (this.options.addParams && typeof this.options.addParams === 'object') { @@ -1120,6 +1169,15 @@ ${convo} }); } + if (this.message_file_map && this.isOllama) { + const ollamaClient = new OllamaClient({ baseURL }); + return await ollamaClient.chatCompletion({ + payload: modelOptions, + onProgress, + abortController, + }); + } + let UnexpectedRoleError = false; if (modelOptions.stream) { const stream = await openai.beta.chat.completions @@ -1150,6 +1208,7 @@ ${convo} } }); + const azureDelay = this.modelOptions.model?.includes('gpt-4') ? 30 : 17; for await (const chunk of stream) { const token = chunk.choices[0]?.delta?.content || ''; intermediateReply += token; @@ -1158,6 +1217,10 @@ ${convo} stream.controller.abort(); break; } + + if (this.azure) { + await sleep(azureDelay); + } } if (!UnexpectedRoleError) { diff --git a/api/app/clients/PluginsClient.js b/api/app/clients/PluginsClient.js index 033c122664f..9c57ee2e9cd 100644 --- a/api/app/clients/PluginsClient.js +++ b/api/app/clients/PluginsClient.js @@ -42,8 +42,12 @@ class PluginsClient extends OpenAIClient { return { chatGptLabel: this.options.chatGptLabel, promptPrefix: this.options.promptPrefix, + tools: this.options.tools, ...this.modelOptions, agentOptions: this.agentOptions, + iconURL: this.options.iconURL, + greeting: this.options.greeting, + spec: this.options.spec, }; } @@ -144,9 +148,11 @@ class PluginsClient extends OpenAIClient { signal, pastMessages, tools: this.tools, - currentDateString: this.currentDateString, verbose: this.options.debug, returnIntermediateSteps: true, + customName: this.options.chatGptLabel, + currentDateString: this.currentDateString, + customInstructions: this.options.promptPrefix, callbackManager: CallbackManager.fromHandlers({ async handleAgentAction(action, runId) { handleAction(action, runId, onAgentAction); @@ -244,7 +250,7 @@ class PluginsClient extends OpenAIClient { this.setOptions(opts); return super.sendMessage(message, opts); } - logger.debug('[PluginsClient] sendMessage', { message, opts }); + logger.debug('[PluginsClient] sendMessage', { userMessageText: message, opts }); const { user, isEdited, @@ -304,6 +310,8 @@ class PluginsClient extends OpenAIClient { } const responseMessage = { + endpoint: EModelEndpoint.gptPlugins, + iconURL: this.options.iconURL, messageId: responseMessageId, conversationId, parentMessageId: userMessage.messageId, diff --git a/api/app/clients/agents/CustomAgent/initializeCustomAgent.js b/api/app/clients/agents/CustomAgent/initializeCustomAgent.js index 2a7813eea60..3d45e5be834 100644 --- a/api/app/clients/agents/CustomAgent/initializeCustomAgent.js +++ b/api/app/clients/agents/CustomAgent/initializeCustomAgent.js @@ -13,10 +13,18 @@ const initializeCustomAgent = async ({ tools, model, pastMessages, + customName, + customInstructions, currentDateString, ...rest }) => { let prompt = CustomAgent.createPrompt(tools, { currentDateString, model: model.modelName }); + if (customName) { + prompt = `You are "${customName}".\n${prompt}`; + } + if (customInstructions) { + prompt = `${prompt}\n${customInstructions}`; + } const chatPrompt = ChatPromptTemplate.fromMessages([ new SystemMessagePromptTemplate(prompt), diff --git a/api/app/clients/agents/Functions/initializeFunctionsAgent.js b/api/app/clients/agents/Functions/initializeFunctionsAgent.js index 3d1a1704ea0..3e813bdbcca 100644 --- a/api/app/clients/agents/Functions/initializeFunctionsAgent.js +++ b/api/app/clients/agents/Functions/initializeFunctionsAgent.js @@ -10,6 +10,8 @@ const initializeFunctionsAgent = async ({ tools, model, pastMessages, + customName, + customInstructions, currentDateString, ...rest }) => { @@ -24,7 +26,13 @@ const initializeFunctionsAgent = async ({ returnMessages: true, }); - const prefix = addToolDescriptions(`Current Date: ${currentDateString}\n${PREFIX}`, tools); + let prefix = addToolDescriptions(`Current Date: ${currentDateString}\n${PREFIX}`, tools); + if (customName) { + prefix = `You are "${customName}".\n${prefix}`; + } + if (customInstructions) { + prefix = `${prefix}\n${customInstructions}`; + } return await initializeAgentExecutorWithOptions(tools, model, { agentType: 'openai-functions', diff --git a/api/app/clients/prompts/index.js b/api/app/clients/prompts/index.js index 36bb6f7e283..9477fb30ca6 100644 --- a/api/app/clients/prompts/index.js +++ b/api/app/clients/prompts/index.js @@ -13,7 +13,7 @@ module.exports = { ...handleInputs, ...instructions, ...titlePrompts, - truncateText, + ...truncateText, createVisionPrompt, createContextHandlers, }; diff --git a/api/app/clients/prompts/titlePrompts.js b/api/app/clients/prompts/titlePrompts.js index 83d8909f3a7..4b3ec354c6b 100644 --- a/api/app/clients/prompts/titlePrompts.js +++ b/api/app/clients/prompts/titlePrompts.js @@ -59,25 +59,57 @@ Submit a brief title in the conversation's language, following the parameter des `; -/** - * Parses titles from title functions based on the provided prompt. - * @param {string} prompt - The prompt containing the title function. - * @returns {string} The parsed title. "New Chat" if no title is found. - */ -function parseTitleFromPrompt(prompt) { - const titleRegex = /(.+?)<\/title>/; - const titleMatch = prompt.match(titleRegex); +const genTranslationPrompt = ( + translationPrompt, +) => `In this environment you have access to a set of tools you can use to translate text. + +You may call them like this: +<function_calls> +<invoke> +<tool_name>$TOOL_NAME</tool_name> +<parameters> +<$PARAMETER_NAME>$PARAMETER_VALUE</$PARAMETER_NAME> +... +</parameters> +</invoke> +</function_calls> - if (titleMatch && titleMatch[1]) { - const title = titleMatch[1].trim(); +Here are the tools available: +<tools> +<tool_description> +<tool_name>submit_translation</tool_name> +<description> +Submit a translation in the target language, following the parameter description and its language closely. +</description> +<parameters> +<parameter> +<name>translation</name> +<type>string</type> +<description>${translationPrompt} +ONLY include the generated translation without quotations, nor its related key</description> +</parameter> +</parameters> +</tool_description> +</tools>`; - // // Capitalize the first letter of each word; Note: unnecessary due to title case prompting - // const capitalizedTitle = title.replace(/\b\w/g, (char) => char.toUpperCase()); +/** + * Parses specified parameter from the provided prompt. + * @param {string} prompt - The prompt containing the desired parameter. + * @param {string} paramName - The name of the parameter to extract. + * @returns {string} The parsed parameter's value or a default value if not found. + */ +function parseParamFromPrompt(prompt, paramName) { + const paramRegex = new RegExp(`<${paramName}>([\\s\\S]+?)</${paramName}>`); + const paramMatch = prompt.match(paramRegex); - return title; + if (paramMatch && paramMatch[1]) { + return paramMatch[1].trim(); } - return 'New Chat'; + if (prompt && prompt.length) { + return `NO TOOL INVOCATION: ${prompt}`; + } + return `No ${paramName} provided`; } module.exports = { @@ -85,5 +117,6 @@ module.exports = { titleInstruction, createTitlePrompt, titleFunctionPrompt, - parseTitleFromPrompt, + parseParamFromPrompt, + genTranslationPrompt, }; diff --git a/api/app/clients/prompts/truncateText.js b/api/app/clients/prompts/truncateText.js index 003b1bc9af5..e744b40daad 100644 --- a/api/app/clients/prompts/truncateText.js +++ b/api/app/clients/prompts/truncateText.js @@ -1,10 +1,40 @@ const MAX_CHAR = 255; -function truncateText(text) { - if (text.length > MAX_CHAR) { - return `${text.slice(0, MAX_CHAR)}... [text truncated for brevity]`; +/** + * Truncates a given text to a specified maximum length, appending ellipsis and a notification + * if the original text exceeds the maximum length. + * + * @param {string} text - The text to be truncated. + * @param {number} [maxLength=MAX_CHAR] - The maximum length of the text after truncation. Defaults to MAX_CHAR. + * @returns {string} The truncated text if the original text length exceeds maxLength, otherwise returns the original text. + */ +function truncateText(text, maxLength = MAX_CHAR) { + if (text.length > maxLength) { + return `${text.slice(0, maxLength)}... [text truncated for brevity]`; } return text; } -module.exports = truncateText; +/** + * Truncates a given text to a specified maximum length by showing the first half and the last half of the text, + * separated by ellipsis. This method ensures the output does not exceed the maximum length, including the addition + * of ellipsis and notification if the original text exceeds the maximum length. + * + * @param {string} text - The text to be truncated. + * @param {number} [maxLength=MAX_CHAR] - The maximum length of the output text after truncation. Defaults to MAX_CHAR. + * @returns {string} The truncated text showing the first half and the last half, or the original text if it does not exceed maxLength. + */ +function smartTruncateText(text, maxLength = MAX_CHAR) { + const ellipsis = '...'; + const notification = ' [text truncated for brevity]'; + const halfMaxLength = Math.floor((maxLength - ellipsis.length - notification.length) / 2); + + if (text.length > maxLength) { + const startLastHalf = text.length - halfMaxLength; + return `${text.slice(0, halfMaxLength)}${ellipsis}${text.slice(startLastHalf)}${notification}`; + } + + return text; +} + +module.exports = { truncateText, smartTruncateText }; diff --git a/api/app/clients/specs/FakeClient.js b/api/app/clients/specs/FakeClient.js index a5915adcf24..7f4b75e1db9 100644 --- a/api/app/clients/specs/FakeClient.js +++ b/api/app/clients/specs/FakeClient.js @@ -40,7 +40,8 @@ class FakeClient extends BaseClient { }; } - this.maxContextTokens = getModelMaxTokens(this.modelOptions.model) ?? 4097; + this.maxContextTokens = + this.options.maxContextTokens ?? getModelMaxTokens(this.modelOptions.model) ?? 4097; } buildMessages() {} getTokenCount(str) { diff --git a/api/app/clients/specs/OpenAIClient.test.js b/api/app/clients/specs/OpenAIClient.test.js index 8c2226215c1..7ef4fdcae51 100644 --- a/api/app/clients/specs/OpenAIClient.test.js +++ b/api/app/clients/specs/OpenAIClient.test.js @@ -157,12 +157,19 @@ describe('OpenAIClient', () => { azureOpenAIApiVersion: '2020-07-01-preview', }; + let originalWarn; + beforeAll(() => { - jest.spyOn(console, 'warn').mockImplementation(() => {}); + originalWarn = console.warn; + console.warn = jest.fn(); }); afterAll(() => { - console.warn.mockRestore(); + console.warn = originalWarn; + }); + + beforeEach(() => { + console.warn.mockClear(); }); beforeEach(() => { @@ -662,4 +669,35 @@ describe('OpenAIClient', () => { expect(constructorArgs.baseURL).toBe(expectedURL); }); }); + + describe('checkVisionRequest functionality', () => { + let client; + const attachments = [{ type: 'image/png' }]; + + beforeEach(() => { + client = new OpenAIClient('test-api-key', { + endpoint: 'ollama', + modelOptions: { + model: 'initial-model', + }, + modelsConfig: { + ollama: ['initial-model', 'llava', 'other-model'], + }, + }); + + client.defaultVisionModel = 'non-valid-default-model'; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should set "llava" as the model if it is the first valid model when default validation fails', () => { + client.checkVisionRequest(attachments); + + expect(client.modelOptions.model).toBe('llava'); + expect(client.isVisionModel).toBeTruthy(); + expect(client.modelOptions.stop).toBeUndefined(); + }); + }); }); diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index 3daaf9dd3bc..c8beed976fe 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -24,7 +24,7 @@ "description": "This is your Google Custom Search Engine ID. For instructions on how to obtain this, see <a href='https://github.com/danny-avila/LibreChat/blob/main/docs/features/plugins/google_search.md'>Our Docs</a>." }, { - "authField": "GOOGLE_API_KEY", + "authField": "GOOGLE_SEARCH_API_KEY", "label": "Google API Key", "description": "This is your Google Custom Search API Key. For instructions on how to obtain this, see <a href='https://github.com/danny-avila/LibreChat/blob/main/docs/features/plugins/google_search.md'>Our Docs</a>." } @@ -60,7 +60,7 @@ "name": "CodeSherpa", "pluginKey": "codesherpa_tools", "description": "[Experimental] A REPL for your chat. Requires https://github.com/iamgreggarcia/codesherpa", - "icon": "https://github.com/iamgreggarcia/codesherpa/blob/main/localserver/_logo.png", + "icon": "https://raw.githubusercontent.com/iamgreggarcia/codesherpa/main/localserver/_logo.png", "authConfig": [ { "authField": "CODESHERPA_SERVER_URL", diff --git a/api/app/clients/tools/structured/GoogleSearch.js b/api/app/clients/tools/structured/GoogleSearch.js index 92d33272c83..bae1a458e0d 100644 --- a/api/app/clients/tools/structured/GoogleSearch.js +++ b/api/app/clients/tools/structured/GoogleSearch.js @@ -9,7 +9,7 @@ class GoogleSearchResults extends Tool { constructor(fields = {}) { super(fields); - this.envVarApiKey = 'GOOGLE_API_KEY'; + this.envVarApiKey = 'GOOGLE_SEARCH_API_KEY'; this.envVarSearchEngineId = 'GOOGLE_CSE_ID'; this.override = fields.override ?? false; this.apiKey = fields.apiKey ?? getEnvironmentVariable(this.envVarApiKey); diff --git a/api/cache/banViolation.js b/api/cache/banViolation.js index 3d67e578722..1d86007638b 100644 --- a/api/cache/banViolation.js +++ b/api/cache/banViolation.js @@ -1,6 +1,7 @@ -const Session = require('~/models/Session'); -const getLogStores = require('./getLogStores'); +const { ViolationTypes } = require('librechat-data-provider'); const { isEnabled, math, removePorts } = require('~/server/utils'); +const getLogStores = require('./getLogStores'); +const Session = require('~/models/Session'); const { logger } = require('~/config'); const { BAN_VIOLATIONS, BAN_INTERVAL } = process.env ?? {}; @@ -48,7 +49,7 @@ const banViolation = async (req, res, errorMessage) => { await Session.deleteAllUserSessions(user_id); res.clearCookie('refreshToken'); - const banLogs = getLogStores('ban'); + const banLogs = getLogStores(ViolationTypes.BAN); const duration = errorMessage.duration || banLogs.opts.ttl; if (duration <= 0) { diff --git a/api/cache/banViolation.spec.js b/api/cache/banViolation.spec.js index ba8e78a1edc..8fef16920f8 100644 --- a/api/cache/banViolation.spec.js +++ b/api/cache/banViolation.spec.js @@ -6,6 +6,7 @@ jest.mock('../models/Session'); jest.mock('./getLogStores', () => { return jest.fn().mockImplementation(() => { const EventEmitter = require('events'); + const { CacheKeys } = require('librechat-data-provider'); const math = require('../server/utils/math'); const mockGet = jest.fn(); const mockSet = jest.fn(); @@ -33,7 +34,7 @@ jest.mock('./getLogStores', () => { } return new KeyvMongo('', { - namespace: 'bans', + namespace: CacheKeys.BANS, ttl: math(process.env.BAN_DURATION, 7200000), }); }); diff --git a/api/cache/getLogStores.js b/api/cache/getLogStores.js index 786bb1f1f74..0d9b662e4e1 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -6,6 +6,7 @@ const keyvRedis = require('./keyvRedis'); const keyvMongo = require('./keyvMongo'); const { BAN_DURATION, USE_REDIS } = process.env ?? {}; +const THIRTY_MINUTES = 1800000; const duration = math(BAN_DURATION, 7200000); @@ -24,8 +25,8 @@ const config = isEnabled(USE_REDIS) : new Keyv({ namespace: CacheKeys.CONFIG_STORE }); const tokenConfig = isEnabled(USE_REDIS) // ttl: 30 minutes - ? new Keyv({ store: keyvRedis, ttl: 1800000 }) - : new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: 1800000 }); + ? new Keyv({ store: keyvRedis, ttl: THIRTY_MINUTES }) + : new Keyv({ namespace: CacheKeys.TOKEN_CONFIG, ttl: THIRTY_MINUTES }); const genTitle = isEnabled(USE_REDIS) // ttl: 2 minutes ? new Keyv({ store: keyvRedis, ttl: 120000 }) @@ -42,7 +43,12 @@ const abortKeys = isEnabled(USE_REDIS) const namespaces = { [CacheKeys.CONFIG_STORE]: config, pending_req, - ban: new Keyv({ store: keyvMongo, namespace: 'bans', ttl: duration }), + [ViolationTypes.BAN]: new Keyv({ store: keyvMongo, namespace: CacheKeys.BANS, ttl: duration }), + [CacheKeys.ENCODED_DOMAINS]: new Keyv({ + store: keyvMongo, + namespace: CacheKeys.ENCODED_DOMAINS, + ttl: 0, + }), general: new Keyv({ store: logFile, namespace: 'violations' }), concurrent: createViolationInstance('concurrent'), non_browser: createViolationInstance('non_browser'), diff --git a/api/lib/db/indexSync.js b/api/lib/db/indexSync.js index 53ac3d3a266..86c909419d6 100644 --- a/api/lib/db/indexSync.js +++ b/api/lib/db/indexSync.js @@ -1,11 +1,28 @@ const { MeiliSearch } = require('meilisearch'); -const Message = require('~/models/schema/messageSchema'); const Conversation = require('~/models/schema/convoSchema'); +const Message = require('~/models/schema/messageSchema'); const { logger } = require('~/config'); const searchEnabled = process.env?.SEARCH?.toLowerCase() === 'true'; let currentTimeout = null; +class MeiliSearchClient { + static instance = null; + + static getInstance() { + if (!MeiliSearchClient.instance) { + if (!process.env.MEILI_HOST || !process.env.MEILI_MASTER_KEY) { + throw new Error('Meilisearch configuration is missing.'); + } + MeiliSearchClient.instance = new MeiliSearch({ + host: process.env.MEILI_HOST, + apiKey: process.env.MEILI_MASTER_KEY, + }); + } + return MeiliSearchClient.instance; + } +} + // eslint-disable-next-line no-unused-vars async function indexSync(req, res, next) { if (!searchEnabled) { @@ -13,20 +30,10 @@ async function indexSync(req, res, next) { } try { - if (!process.env.MEILI_HOST || !process.env.MEILI_MASTER_KEY || !searchEnabled) { - throw new Error('Meilisearch not configured, search will be disabled.'); - } - - const client = new MeiliSearch({ - host: process.env.MEILI_HOST, - apiKey: process.env.MEILI_MASTER_KEY, - }); + const client = MeiliSearchClient.getInstance(); const { status } = await client.health(); - // logger.debug(`[indexSync] Meilisearch: ${status}`); - const result = status === 'available' && !!process.env.SEARCH; - - if (!result) { + if (status !== 'available' || !process.env.SEARCH) { throw new Error('Meilisearch not available'); } @@ -37,12 +44,8 @@ async function indexSync(req, res, next) { const messagesIndexed = messages.numberOfDocuments; const convosIndexed = convos.numberOfDocuments; - logger.debug( - `[indexSync] There are ${messageCount} messages in the database, ${messagesIndexed} indexed`, - ); - logger.debug( - `[indexSync] There are ${convoCount} convos in the database, ${convosIndexed} indexed`, - ); + logger.debug(`[indexSync] There are ${messageCount} messages and ${messagesIndexed} indexed`); + logger.debug(`[indexSync] There are ${convoCount} convos and ${convosIndexed} indexed`); if (messageCount !== messagesIndexed) { logger.debug('[indexSync] Messages out of sync, indexing'); @@ -54,7 +57,6 @@ async function indexSync(req, res, next) { Conversation.syncWithMeili(); } } catch (err) { - // logger.debug('[indexSync] in index sync'); if (err.message.includes('not found')) { logger.debug('[indexSync] Creating indices...'); currentTimeout = setTimeout(async () => { diff --git a/api/models/Action.js b/api/models/Action.js index 5141569c103..9acac078b9f 100644 --- a/api/models/Action.js +++ b/api/models/Action.js @@ -5,19 +5,18 @@ const Action = mongoose.model('action', actionSchema); /** * Update an action with new data without overwriting existing properties, - * or create a new action if it doesn't exist. + * or create a new action if it doesn't exist, within a transaction session if provided. * * @param {Object} searchParams - The search parameters to find the action to update. * @param {string} searchParams.action_id - The ID of the action to update. * @param {string} searchParams.user - The user ID of the action's author. * @param {Object} updateData - An object containing the properties to update. + * @param {mongoose.ClientSession} [session] - The transaction session to use. * @returns {Promise<Object>} The updated or newly created action document as a plain object. */ -const updateAction = async (searchParams, updateData) => { - return await Action.findOneAndUpdate(searchParams, updateData, { - new: true, - upsert: true, - }).lean(); +const updateAction = async (searchParams, updateData, session = null) => { + const options = { new: true, upsert: true, session }; + return await Action.findOneAndUpdate(searchParams, updateData, options).lean(); }; /** @@ -50,15 +49,17 @@ const getActions = async (searchParams, includeSensitive = false) => { }; /** - * Deletes an action by its ID. + * Deletes an action by params, within a transaction session if provided. * - * @param {Object} searchParams - The search parameters to find the action to update. - * @param {string} searchParams.action_id - The ID of the action to update. + * @param {Object} searchParams - The search parameters to find the action to delete. + * @param {string} searchParams.action_id - The ID of the action to delete. * @param {string} searchParams.user - The user ID of the action's author. + * @param {mongoose.ClientSession} [session] - The transaction session to use (optional). * @returns {Promise<Object>} A promise that resolves to the deleted action document as a plain object, or null if no document was found. */ -const deleteAction = async (searchParams) => { - return await Action.findOneAndDelete(searchParams).lean(); +const deleteAction = async (searchParams, session = null) => { + const options = session ? { session } : {}; + return await Action.findOneAndDelete(searchParams, options).lean(); }; module.exports = { diff --git a/api/models/Assistant.js b/api/models/Assistant.js index fa6192eee93..17e40772209 100644 --- a/api/models/Assistant.js +++ b/api/models/Assistant.js @@ -5,19 +5,18 @@ const Assistant = mongoose.model('assistant', assistantSchema); /** * Update an assistant with new data without overwriting existing properties, - * or create a new assistant if it doesn't exist. + * or create a new assistant if it doesn't exist, within a transaction session if provided. * * @param {Object} searchParams - The search parameters to find the assistant to update. * @param {string} searchParams.assistant_id - The ID of the assistant to update. * @param {string} searchParams.user - The user ID of the assistant's author. * @param {Object} updateData - An object containing the properties to update. + * @param {mongoose.ClientSession} [session] - The transaction session to use (optional). * @returns {Promise<Object>} The updated or newly created assistant document as a plain object. */ -const updateAssistant = async (searchParams, updateData) => { - return await Assistant.findOneAndUpdate(searchParams, updateData, { - new: true, - upsert: true, - }).lean(); +const updateAssistant = async (searchParams, updateData, session = null) => { + const options = { new: true, upsert: true, session }; + return await Assistant.findOneAndUpdate(searchParams, updateData, options).lean(); }; /** diff --git a/api/models/Conversation.js b/api/models/Conversation.js index 1ef47241cac..2dfe8d51cd0 100644 --- a/api/models/Conversation.js +++ b/api/models/Conversation.js @@ -2,6 +2,12 @@ const Conversation = require('./schema/convoSchema'); const { getMessages, deleteMessages } = require('./Message'); const logger = require('~/config/winston'); +/** + * Retrieves a single conversation for a given user and conversation ID. + * @param {string} user - The user's ID. + * @param {string} conversationId - The conversation's ID. + * @returns {Promise<TConversation>} The conversation object. + */ const getConvo = async (user, conversationId) => { try { return await Conversation.findOne({ user, conversationId }).lean(); @@ -30,11 +36,35 @@ module.exports = { return { message: 'Error saving conversation' }; } }, - getConvosByPage: async (user, pageNumber = 1, pageSize = 25) => { + bulkSaveConvos: async (conversations) => { try { - const totalConvos = (await Conversation.countDocuments({ user })) || 1; + const bulkOps = conversations.map((convo) => ({ + updateOne: { + filter: { conversationId: convo.conversationId, user: convo.user }, + update: convo, + upsert: true, + timestamps: false, + }, + })); + + const result = await Conversation.bulkWrite(bulkOps); + return result; + } catch (error) { + logger.error('[saveBulkConversations] Error saving conversations in bulk', error); + throw new Error('Failed to save conversations in bulk.'); + } + }, + getConvosByPage: async (user, pageNumber = 1, pageSize = 25, isArchived = false) => { + const query = { user }; + if (isArchived) { + query.isArchived = true; + } else { + query.$or = [{ isArchived: false }, { isArchived: { $exists: false } }]; + } + try { + const totalConvos = (await Conversation.countDocuments(query)) || 1; const totalPages = Math.ceil(totalConvos / pageSize); - const convos = await Conversation.find({ user }) + const convos = await Conversation.find(query) .sort({ updatedAt: -1 }) .skip((pageNumber - 1) * pageSize) .limit(pageSize) diff --git a/api/models/Message.js b/api/models/Message.js index a8e1acdf149..0df0a7c2da4 100644 --- a/api/models/Message.js +++ b/api/models/Message.js @@ -10,6 +10,7 @@ module.exports = { async saveMessage({ user, endpoint, + iconURL, messageId, newMessageId, conversationId, @@ -35,6 +36,7 @@ module.exports = { const update = { user, + iconURL, endpoint, messageId: newMessageId || messageId, conversationId, @@ -72,6 +74,25 @@ module.exports = { throw new Error('Failed to save message.'); } }, + + async bulkSaveMessages(messages) { + try { + const bulkOps = messages.map((message) => ({ + updateOne: { + filter: { messageId: message.messageId }, + update: message, + upsert: true, + }, + })); + + const result = await Message.bulkWrite(bulkOps); + return result; + } catch (err) { + logger.error('Error saving messages in bulk:', err); + throw new Error('Failed to save messages in bulk.'); + } + }, + /** * Records a message in the database. * diff --git a/api/models/Preset.js b/api/models/Preset.js index e9f0a1e77e8..c0134eca6a2 100644 --- a/api/models/Preset.js +++ b/api/models/Preset.js @@ -39,6 +39,12 @@ module.exports = { try { const setter = { $set: {} }; const update = { presetId, ...preset }; + if (preset.tools && Array.isArray(preset.tools)) { + update.tools = + preset.tools + .map((tool) => tool?.pluginKey ?? tool) + .filter((toolName) => typeof toolName === 'string') ?? []; + } if (newPresetId) { update.presetId = newPresetId; } diff --git a/api/models/plugins/mongoMeili.js b/api/models/plugins/mongoMeili.js index 79dd30b11cc..261b5c50c78 100644 --- a/api/models/plugins/mongoMeili.js +++ b/api/models/plugins/mongoMeili.js @@ -348,7 +348,7 @@ module.exports = function mongoMeili(schema, options) { try { meiliDoc = await client.index('convos').getDocument(doc.conversationId); } catch (error) { - logger.error( + logger.debug( '[MeiliMongooseModel.findOneAndUpdate] Convo not found in MeiliSearch and will index ' + doc.conversationId, error, diff --git a/api/models/schema/defaults.js b/api/models/schema/defaults.js index b2ea3a12c70..16ceb6aa206 100644 --- a/api/models/schema/defaults.js +++ b/api/models/schema/defaults.js @@ -88,6 +88,28 @@ const conversationPreset = { instructions: { type: String, }, + stop: { type: [{ type: String }], default: undefined }, + isArchived: { + type: Boolean, + default: false, + }, + /* UI Components */ + iconURL: { + type: String, + }, + greeting: { + type: String, + }, + spec: { + type: String, + }, + tools: { type: [{ type: String }], default: undefined }, + maxContextTokens: { + type: Number, + }, + max_tokens: { + type: Number, + }, }; const agentOptions = { diff --git a/api/models/schema/fileSchema.js b/api/models/schema/fileSchema.js index 93a8815e53b..2075538b1d2 100644 --- a/api/models/schema/fileSchema.js +++ b/api/models/schema/fileSchema.js @@ -99,4 +99,6 @@ const fileSchema = mongoose.Schema( }, ); +fileSchema.index({ createdAt: 1, updatedAt: 1 }); + module.exports = fileSchema; diff --git a/api/models/schema/messageSchema.js b/api/models/schema/messageSchema.js index fc745499fe5..d9a6dfb9397 100644 --- a/api/models/schema/messageSchema.js +++ b/api/models/schema/messageSchema.js @@ -110,6 +110,10 @@ const messageSchema = mongoose.Schema( thread_id: { type: String, }, + /* frontend components */ + iconURL: { + type: String, + }, }, { timestamps: true }, ); diff --git a/api/models/spendTokens.js b/api/models/spendTokens.js index e37aa41d0cc..830cda20758 100644 --- a/api/models/spendTokens.js +++ b/api/models/spendTokens.js @@ -54,7 +54,7 @@ const spendTokens = async (txData, tokenUsage) => { prompt && completion && logger.debug('[spendTokens] Transaction data record against balance:', { - user: prompt.user, + user: txData.user, prompt: prompt.prompt, promptRate: prompt.rate, completion: completion.completion, diff --git a/api/models/tx.js b/api/models/tx.js index bc993290f44..1b37ffc8659 100644 --- a/api/models/tx.js +++ b/api/models/tx.js @@ -12,6 +12,7 @@ const tokenValues = { '4k': { prompt: 1.5, completion: 2 }, '16k': { prompt: 3, completion: 4 }, 'gpt-3.5-turbo-1106': { prompt: 1, completion: 2 }, + 'gpt-4o': { prompt: 5, completion: 15 }, 'gpt-4-1106': { prompt: 10, completion: 30 }, 'gpt-3.5-turbo-0125': { prompt: 0.5, completion: 1.5 }, 'claude-3-opus': { prompt: 15, completion: 75 }, @@ -25,6 +26,10 @@ const tokenValues = { /* 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 }, + // 'gemini-1.5': { prompt: 7, completion: 21 }, // May 2nd, 2024 pricing + // 'gemini': { prompt: 0.5, completion: 1.5 }, // May 2nd, 2024 pricing + 'gemini-1.5': { prompt: 0, completion: 0 }, // currently free + gemini: { prompt: 0, completion: 0 }, // currently free }; /** @@ -48,6 +53,10 @@ const getValueKey = (model, endpoint) => { return 'gpt-3.5-turbo-1106'; } else if (modelName.includes('gpt-3.5')) { return '4k'; + } else if (modelName.includes('gpt-4o')) { + return 'gpt-4o'; + } else if (modelName.includes('gpt-4-vision')) { + return 'gpt-4-1106'; } else if (modelName.includes('gpt-4-1106')) { return 'gpt-4-1106'; } else if (modelName.includes('gpt-4-0125')) { diff --git a/api/models/tx.spec.js b/api/models/tx.spec.js index 36533a11dd4..ce7d227d518 100644 --- a/api/models/tx.spec.js +++ b/api/models/tx.spec.js @@ -34,6 +34,20 @@ describe('getValueKey', () => { expect(getValueKey('openai/gpt-4-1106')).toBe('gpt-4-1106'); expect(getValueKey('gpt-4-1106/openai/')).toBe('gpt-4-1106'); }); + + it('should return "gpt-4-1106" for model type of "gpt-4-1106"', () => { + expect(getValueKey('gpt-4-vision-preview')).toBe('gpt-4-1106'); + expect(getValueKey('openai/gpt-4-1106')).toBe('gpt-4-1106'); + expect(getValueKey('gpt-4-turbo')).toBe('gpt-4-1106'); + expect(getValueKey('gpt-4-0125')).toBe('gpt-4-1106'); + }); + + it('should return "gpt-4o" for model type of "gpt-4o"', () => { + expect(getValueKey('gpt-4o-2024-05-13')).toBe('gpt-4o'); + expect(getValueKey('openai/gpt-4o')).toBe('gpt-4o'); + expect(getValueKey('gpt-4o-turbo')).toBe('gpt-4o'); + expect(getValueKey('gpt-4o-0125')).toBe('gpt-4o'); + }); }); describe('getMultiplier', () => { @@ -77,6 +91,17 @@ describe('getMultiplier', () => { ); }); + it('should return the correct multiplier for gpt-4o', () => { + const valueKey = getValueKey('gpt-4o-2024-05-13'); + expect(getMultiplier({ valueKey, tokenType: 'prompt' })).toBe(tokenValues['gpt-4o'].prompt); + expect(getMultiplier({ valueKey, tokenType: 'completion' })).toBe( + tokenValues['gpt-4o'].completion, + ); + expect(getMultiplier({ valueKey, tokenType: 'completion' })).not.toBe( + tokenValues['gpt-4-1106'].completion, + ); + }); + it('should derive the valueKey from the model if not provided for new models', () => { expect( getMultiplier({ tokenType: 'prompt', model: 'gpt-3.5-turbo-1106-some-other-info' }), diff --git a/api/package.json b/api/package.json index 31df31f7c5f..d91b6031eff 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "0.7.0", + "version": "0.7.2", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -35,10 +35,13 @@ "dependencies": { "@anthropic-ai/sdk": "^0.16.1", "@azure/search-documents": "^12.0.0", + "@google/generative-ai": "^0.5.0", "@keyv/mongo": "^2.1.8", "@keyv/redis": "^2.8.1", - "@langchain/community": "^0.0.17", - "@langchain/google-genai": "^0.0.8", + "@langchain/community": "^0.0.46", + "@langchain/google-genai": "^0.0.11", + "@langchain/google-vertexai": "^0.0.5", + "agenda": "^5.0.0", "axios": "^1.3.4", "bcryptjs": "^2.4.3", "cheerio": "^1.0.0-rc.12", @@ -72,7 +75,8 @@ "multer": "^1.4.5-lts.1", "nodejs-gpt": "^1.37.4", "nodemailer": "^6.9.4", - "openai": "^4.29.0", + "ollama": "^0.5.0", + "openai": "4.36.0", "openai-chat-tokens": "^0.2.8", "openid-client": "^5.4.2", "passport": "^0.6.0", @@ -85,7 +89,7 @@ "passport-local": "^1.0.0", "pino": "^8.12.1", "sharp": "^0.32.6", - "tiktoken": "^1.0.10", + "tiktoken": "^1.0.15", "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", "winston": "^3.11.0", diff --git a/api/server/controllers/AskController.js b/api/server/controllers/AskController.js index 171e7aaae74..0925b2221fa 100644 --- a/api/server/controllers/AskController.js +++ b/api/server/controllers/AskController.js @@ -1,5 +1,5 @@ const throttle = require('lodash/throttle'); -const { getResponseSender, Constants } = require('librechat-data-provider'); +const { getResponseSender, Constants, EModelEndpoint } = require('librechat-data-provider'); const { createAbortController, handleAbortError } = require('~/server/middleware'); const { sendMessage, createOnProgress } = require('~/server/utils'); const { saveMessage, getConvo } = require('~/models'); @@ -48,7 +48,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { try { const { client } = await initializeClient({ req, res, endpointOption }); - + const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true; const { onProgress: progressCallback, getPartialText } = createOnProgress({ onProgress: throttle( ({ text: partialText }) => { @@ -59,7 +59,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => { parentMessageId: overrideParentMessageId ?? userMessageId, text: partialText, model: client.modelOptions.model, - unfinished: true, + unfinished, error: false, user, }); diff --git a/api/server/controllers/EditController.js b/api/server/controllers/EditController.js index 28a35185ffb..ba936ac867d 100644 --- a/api/server/controllers/EditController.js +++ b/api/server/controllers/EditController.js @@ -1,5 +1,5 @@ const throttle = require('lodash/throttle'); -const { getResponseSender } = require('librechat-data-provider'); +const { getResponseSender, EModelEndpoint } = require('librechat-data-provider'); const { createAbortController, handleAbortError } = require('~/server/middleware'); const { sendMessage, createOnProgress } = require('~/server/utils'); const { saveMessage, getConvo } = require('~/models'); @@ -48,6 +48,7 @@ const EditController = async (req, res, next, initializeClient) => { } }; + const unfinished = endpointOption.endpoint === EModelEndpoint.google ? false : true; const { onProgress: progressCallback, getPartialText } = createOnProgress({ generation, onProgress: throttle( @@ -59,7 +60,7 @@ const EditController = async (req, res, next, initializeClient) => { parentMessageId: overrideParentMessageId ?? userMessageId, text: partialText, model: endpointOption.modelOptions.model, - unfinished: true, + unfinished, isEdited: true, error: false, user, diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index 803d89923ba..5bb34671f8d 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -55,19 +55,27 @@ const getAvailablePluginsController = async (req, res) => { return; } + /** @type {{ filteredTools: string[], includedTools: string[] }} */ + const { filteredTools = [], includedTools = [] } = req.app.locals; const pluginManifest = await fs.readFile(req.app.locals.paths.pluginManifest, 'utf8'); - const jsonData = JSON.parse(pluginManifest); - /** @type {TPlugin[]} */ + const uniquePlugins = filterUniquePlugins(jsonData); - const authenticatedPlugins = uniquePlugins.map((plugin) => { - if (isPluginAuthenticated(plugin)) { - return { ...plugin, authenticated: true }; - } else { - return plugin; - } - }); - const plugins = await addOpenAPISpecs(authenticatedPlugins); + let authenticatedPlugins = []; + for (const plugin of uniquePlugins) { + authenticatedPlugins.push( + isPluginAuthenticated(plugin) ? { ...plugin, authenticated: true } : plugin, + ); + } + + let plugins = await addOpenAPISpecs(authenticatedPlugins); + + if (includedTools.length > 0) { + plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey)); + } else { + plugins = plugins.filter((plugin) => !filteredTools.includes(plugin.pluginKey)); + } + await cache.set(CacheKeys.PLUGINS, plugins); res.status(200).json(plugins); } catch (error) { diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index a2be50ee82d..a868b107b34 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -1,9 +1,9 @@ const { EModelEndpoint } = require('librechat-data-provider'); const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils'); +const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const { saveMessage, getConvo, getConvoTitle } = require('~/models'); const clearPendingReq = require('~/cache/clearPendingReq'); const abortControllers = require('./abortControllers'); -const { redactMessage } = require('~/config/parsers'); const spendTokens = require('~/models/spendTokens'); const { abortRun } = require('./abortRun'); const { logger } = require('~/config'); @@ -73,6 +73,8 @@ const createAbortController = (req, res, getAbortData) => { ...responseData, conversationId, finish_reason: 'incomplete', + endpoint: endpointOption.endpoint, + iconURL: endpointOption.iconURL, model: endpointOption.modelOptions.model, unfinished: false, error: false, @@ -100,7 +102,15 @@ const createAbortController = (req, res, getAbortData) => { }; const handleAbortError = async (res, req, error, data) => { - logger.error('[handleAbortError] AI response error; aborting request:', error); + if (error?.message?.includes('base64')) { + logger.error('[handleAbortError] Error in base64 encoding', { + ...error, + stack: smartTruncateText(error?.stack, 1000), + message: truncateText(error.message, 350), + }); + } else { + logger.error('[handleAbortError] AI response error; aborting request:', error); + } const { sender, conversationId, messageId, parentMessageId, partialText } = data; if (error.stack && error.stack.includes('google')) { @@ -109,13 +119,17 @@ const handleAbortError = async (res, req, error, data) => { ); } + const errorText = error?.message?.includes('"type"') + ? error.message + : 'An error occurred while processing your request. Please contact the Admin.'; + const respondWithError = async (partialText) => { let options = { sender, messageId, conversationId, parentMessageId, - text: redactMessage(error.message), + text: errorText, shouldSaveMessage: true, user: req.user.id, }; diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index e0ae6c8534d..3de13ed2e67 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -7,6 +7,8 @@ const anthropic = require('~/server/services/Endpoints/anthropic'); const openAI = require('~/server/services/Endpoints/openAI'); const custom = require('~/server/services/Endpoints/custom'); const google = require('~/server/services/Endpoints/google'); +const enforceModelSpec = require('./enforceModelSpec'); +const { handleError } = require('~/server/utils'); const buildFunction = { [EModelEndpoint.openAI]: openAI.buildOptions, @@ -21,6 +23,40 @@ const buildFunction = { async function buildEndpointOption(req, res, next) { const { endpoint, endpointType } = req.body; const parsedBody = parseConvo({ endpoint, endpointType, conversation: req.body }); + + if (req.app.locals.modelSpecs?.list && req.app.locals.modelSpecs?.enforce) { + /** @type {{ list: TModelSpec[] }}*/ + const { list } = req.app.locals.modelSpecs; + const { spec } = parsedBody; + + if (!spec) { + return handleError(res, { text: 'No model spec selected' }); + } + + const currentModelSpec = list.find((s) => s.name === spec); + if (!currentModelSpec) { + return handleError(res, { text: 'Invalid model spec' }); + } + + if (endpoint !== currentModelSpec.preset.endpoint) { + return handleError(res, { text: 'Model spec mismatch' }); + } + + if ( + currentModelSpec.preset.endpoint !== EModelEndpoint.gptPlugins && + currentModelSpec.preset.tools + ) { + return handleError(res, { + text: `Only the "${EModelEndpoint.gptPlugins}" endpoint can have tools defined in the preset`, + }); + } + + const isValidModelSpec = enforceModelSpec(currentModelSpec, parsedBody); + if (!isValidModelSpec) { + return handleError(res, { text: 'Model spec mismatch' }); + } + } + req.body.endpointOption = buildFunction[endpointType ?? endpoint]( endpoint, parsedBody, diff --git a/api/server/middleware/checkBan.js b/api/server/middleware/checkBan.js index a7eab87bdfc..aa322cd1c2e 100644 --- a/api/server/middleware/checkBan.js +++ b/api/server/middleware/checkBan.js @@ -1,14 +1,15 @@ const Keyv = require('keyv'); const uap = require('ua-parser-js'); -const denyRequest = require('./denyRequest'); -const { getLogStores } = require('../../cache'); +const { ViolationTypes } = require('librechat-data-provider'); const { isEnabled, removePorts } = require('../utils'); -const keyvRedis = require('../../cache/keyvRedis'); -const User = require('../../models/User'); +const keyvRedis = require('~/cache/keyvRedis'); +const denyRequest = require('./denyRequest'); +const { getLogStores } = require('~/cache'); +const User = require('~/models/User'); const banCache = isEnabled(process.env.USE_REDIS) ? new Keyv({ store: keyvRedis }) - : new Keyv({ namespace: 'bans', ttl: 0 }); + : new Keyv({ namespace: ViolationTypes.BAN, ttl: 0 }); const message = 'Your account has been temporarily banned due to violations of our service.'; /** @@ -28,7 +29,7 @@ const banResponse = async (req, res) => { if (!ua.browser.name) { return res.status(403).json({ message }); } else if (baseUrl === '/api/ask' || baseUrl === '/api/edit') { - return await denyRequest(req, res, { type: 'ban' }); + return await denyRequest(req, res, { type: ViolationTypes.BAN }); } return res.status(403).json({ message }); @@ -87,7 +88,7 @@ const checkBan = async (req, res, next = () => {}) => { return await banResponse(req, res); } - const banLogs = getLogStores('ban'); + const banLogs = getLogStores(ViolationTypes.BAN); const duration = banLogs.opts.ttl; if (duration <= 0) { diff --git a/api/server/middleware/checkDomainAllowed.js b/api/server/middleware/checkDomainAllowed.js new file mode 100644 index 00000000000..895ce99a567 --- /dev/null +++ b/api/server/middleware/checkDomainAllowed.js @@ -0,0 +1,25 @@ +const { isDomainAllowed } = require('~/server/services/AuthService'); +const { logger } = require('~/config'); + +/** + * Checks the domain's social login is allowed + * + * @async + * @function + * @param {Object} req - Express request object. + * @param {Object} res - Express response object. + * @param {Function} next - Next middleware function. + * + * @returns {Promise<function|Object>} - Returns a Promise which when resolved calls next middleware if the domain's email is allowed + */ +const checkDomainAllowed = async (req, res, next = () => {}) => { + const email = req?.user?.email; + if (email && !(await isDomainAllowed(email))) { + logger.error(`[Social Login] [Social Login not allowed] [Email: ${email}]`); + return res.redirect('/login'); + } else { + return next(); + } +}; + +module.exports = checkDomainAllowed; diff --git a/api/server/middleware/enforceModelSpec.js b/api/server/middleware/enforceModelSpec.js new file mode 100644 index 00000000000..17270a5cf8f --- /dev/null +++ b/api/server/middleware/enforceModelSpec.js @@ -0,0 +1,58 @@ +const interchangeableKeys = new Map([ + ['chatGptLabel', ['modelLabel']], + ['modelLabel', ['chatGptLabel']], +]); + +/** + * Middleware to enforce the model spec for a conversation + * @param {TModelSpec} modelSpec - The model spec to enforce + * @param {TConversation} parsedBody - The parsed body of the conversation + * @returns {boolean} - Whether the model spec is enforced + */ +const enforceModelSpec = (modelSpec, parsedBody) => { + for (const [key, value] of Object.entries(modelSpec.preset)) { + if (key === 'endpoint') { + continue; + } + + if (!checkMatch(key, value, parsedBody)) { + return false; + } + } + return true; +}; + +/** + * Checks if there is a match for the given key and value in the parsed body + * or any of its interchangeable keys, including deep comparison for objects and arrays. + * @param {string} key + * @param {any} value + * @param {object} parsedBody + * @returns {boolean} + */ +const checkMatch = (key, value, parsedBody) => { + const isEqual = (a, b) => { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((val, index) => isEqual(val, b[index])); + } else if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) { + const keysA = Object.keys(a); + const keysB = Object.keys(b); + return keysA.length === keysB.length && keysA.every((k) => isEqual(a[k], b[k])); + } + return a === b; + }; + + if (isEqual(parsedBody[key], value)) { + return true; + } + + if (interchangeableKeys.has(key)) { + return interchangeableKeys + .get(key) + .some((interchangeableKey) => isEqual(parsedBody[interchangeableKey], value)); + } + + return false; +}; + +module.exports = enforceModelSpec; diff --git a/api/server/middleware/enforceModelSpec.spec.js b/api/server/middleware/enforceModelSpec.spec.js new file mode 100644 index 00000000000..04a8e5b35fb --- /dev/null +++ b/api/server/middleware/enforceModelSpec.spec.js @@ -0,0 +1,47 @@ +// enforceModelSpec.test.js + +const enforceModelSpec = require('./enforceModelSpec'); + +describe('enforceModelSpec function', () => { + test('returns true when all model specs match parsed body directly', () => { + const modelSpec = { preset: { title: 'Dialog', status: 'Active' } }; + const parsedBody = { title: 'Dialog', status: 'Active' }; + expect(enforceModelSpec(modelSpec, parsedBody)).toBe(true); + }); + + test('returns true when model specs match via interchangeable keys', () => { + const modelSpec = { preset: { chatGptLabel: 'GPT-4' } }; + const parsedBody = { modelLabel: 'GPT-4' }; + expect(enforceModelSpec(modelSpec, parsedBody)).toBe(true); + }); + + test('returns false if any key value does not match', () => { + const modelSpec = { preset: { language: 'English', level: 'Advanced' } }; + const parsedBody = { language: 'Spanish', level: 'Advanced' }; + expect(enforceModelSpec(modelSpec, parsedBody)).toBe(false); + }); + + test('ignores the \'endpoint\' key in model spec', () => { + const modelSpec = { preset: { endpoint: 'ignored', feature: 'Special' } }; + const parsedBody = { feature: 'Special' }; + expect(enforceModelSpec(modelSpec, parsedBody)).toBe(true); + }); + + test('handles nested objects correctly', () => { + const modelSpec = { preset: { details: { time: 'noon', location: 'park' } } }; + const parsedBody = { details: { time: 'noon', location: 'park' } }; + expect(enforceModelSpec(modelSpec, parsedBody)).toBe(true); + }); + + test('handles arrays within objects', () => { + const modelSpec = { preset: { tags: ['urgent', 'important'] } }; + const parsedBody = { tags: ['urgent', 'important'] }; + expect(enforceModelSpec(modelSpec, parsedBody)).toBe(true); + }); + + test('fails when arrays in objects do not match', () => { + const modelSpec = { preset: { tags: ['urgent', 'important'] } }; + const parsedBody = { tags: ['important', 'urgent'] }; // Different order + expect(enforceModelSpec(modelSpec, parsedBody)).toBe(false); + }); +}); diff --git a/api/server/middleware/importLimiters.js b/api/server/middleware/importLimiters.js new file mode 100644 index 00000000000..a21fa6453e2 --- /dev/null +++ b/api/server/middleware/importLimiters.js @@ -0,0 +1,69 @@ +const rateLimit = require('express-rate-limit'); +const { ViolationTypes } = require('librechat-data-provider'); +const logViolation = require('~/cache/logViolation'); + +const getEnvironmentVariables = () => { + const IMPORT_IP_MAX = parseInt(process.env.IMPORT_IP_MAX) || 100; + const IMPORT_IP_WINDOW = parseInt(process.env.IMPORT_IP_WINDOW) || 15; + const IMPORT_USER_MAX = parseInt(process.env.IMPORT_USER_MAX) || 50; + const IMPORT_USER_WINDOW = parseInt(process.env.IMPORT_USER_WINDOW) || 15; + + const importIpWindowMs = IMPORT_IP_WINDOW * 60 * 1000; + const importIpMax = IMPORT_IP_MAX; + const importIpWindowInMinutes = importIpWindowMs / 60000; + + const importUserWindowMs = IMPORT_USER_WINDOW * 60 * 1000; + const importUserMax = IMPORT_USER_MAX; + const importUserWindowInMinutes = importUserWindowMs / 60000; + + return { + importIpWindowMs, + importIpMax, + importIpWindowInMinutes, + importUserWindowMs, + importUserMax, + importUserWindowInMinutes, + }; +}; + +const createImportHandler = (ip = true) => { + const { importIpMax, importIpWindowInMinutes, importUserMax, importUserWindowInMinutes } = + getEnvironmentVariables(); + + return async (req, res) => { + const type = ViolationTypes.FILE_UPLOAD_LIMIT; + const errorMessage = { + type, + max: ip ? importIpMax : importUserMax, + limiter: ip ? 'ip' : 'user', + windowInMinutes: ip ? importIpWindowInMinutes : importUserWindowInMinutes, + }; + + await logViolation(req, res, type, errorMessage); + res.status(429).json({ message: 'Too many conversation import requests. Try again later' }); + }; +}; + +const createImportLimiters = () => { + const { importIpWindowMs, importIpMax, importUserWindowMs, importUserMax } = + getEnvironmentVariables(); + + const importIpLimiter = rateLimit({ + windowMs: importIpWindowMs, + max: importIpMax, + handler: createImportHandler(), + }); + + const importUserLimiter = rateLimit({ + windowMs: importUserWindowMs, + max: importUserMax, + handler: createImportHandler(false), + keyGenerator: function (req) { + return req.user?.id; // Use the user ID or NULL if not available + }, + }); + + return { importIpLimiter, importUserLimiter }; +}; + +module.exports = { createImportLimiters }; diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index b9960a237af..77ca0165701 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -1,5 +1,6 @@ const abortMiddleware = require('./abortMiddleware'); const checkBan = require('./checkBan'); +const checkDomainAllowed = require('./checkDomainAllowed'); const uaParser = require('./uaParser'); const setHeaders = require('./setHeaders'); const loginLimiter = require('./loginLimiter'); @@ -17,6 +18,7 @@ const validateRegistration = require('./validateRegistration'); const validateImageRequest = require('./validateImageRequest'); const moderateText = require('./moderateText'); const noIndex = require('./noIndex'); +const importLimiters = require('./importLimiters'); module.exports = { ...uploadLimiters, @@ -38,4 +40,6 @@ module.exports = { validateModel, moderateText, noIndex, + ...importLimiters, + checkDomainAllowed, }; diff --git a/api/server/middleware/moderateText.js b/api/server/middleware/moderateText.js index 40bc5e9430b..18d370b560d 100644 --- a/api/server/middleware/moderateText.js +++ b/api/server/middleware/moderateText.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const { ErrorTypes } = require('librechat-data-provider'); const denyRequest = require('./denyRequest'); const { logger } = require('~/config'); @@ -24,7 +25,7 @@ async function moderateText(req, res, next) { const flagged = results.some((result) => result.flagged); if (flagged) { - const type = 'moderation'; + const type = ErrorTypes.MODERATION; const errorMessage = { type }; return await denyRequest(req, res, errorMessage); } diff --git a/api/server/middleware/validateImageRequest.js b/api/server/middleware/validateImageRequest.js index 03482b4b118..c0e8e5fe83f 100644 --- a/api/server/middleware/validateImageRequest.js +++ b/api/server/middleware/validateImageRequest.js @@ -3,9 +3,14 @@ const jwt = require('jsonwebtoken'); const { logger } = require('~/config'); /** - * Middleware to validate image request + * Middleware to validate image request. + * Must be set by `secureImageLinks` via custom config file. */ function validateImageRequest(req, res, next) { + if (!req.app.locals.secureImageLinks) { + return next(); + } + const refreshToken = req.headers.cookie ? cookies.parse(req.headers.cookie).refreshToken : null; if (!refreshToken) { logger.warn('[validateImageRequest] Refresh token not provided'); diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js index 33db6ce803a..9cf47c86995 100644 --- a/api/server/routes/assistants/actions.js +++ b/api/server/routes/assistants/actions.js @@ -1,8 +1,8 @@ const { v4 } = require('uuid'); const express = require('express'); -const { actionDelimiter } = require('librechat-data-provider'); -const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); +const { actionDelimiter, EModelEndpoint } = require('librechat-data-provider'); +const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { updateAction, getActions, deleteAction } = require('~/models/Action'); const { updateAssistant, getAssistant } = require('~/models/Assistant'); const { logger } = require('~/config'); @@ -46,7 +46,7 @@ router.post('/:assistant_id', async (req, res) => { let { domain } = metadata; /* Azure doesn't support periods in function names */ - domain = domainParser(req, domain, true); + domain = await domainParser(req, domain, true); if (!domain) { return res.status(400).json({ message: 'No domain provided' }); @@ -108,6 +108,7 @@ router.post('/:assistant_id', async (req, res) => { })), ); + let updatedAssistant = await openai.beta.assistants.update(assistant_id, { tools }); const promises = []; promises.push( updateAssistant( @@ -118,18 +119,26 @@ router.post('/:assistant_id', async (req, res) => { }, ), ); - promises.push(openai.beta.assistants.update(assistant_id, { tools })); promises.push(updateAction({ action_id }, { metadata, assistant_id, user: req.user.id })); - /** @type {[AssistantDocument, Assistant, Action]} */ - const resolved = await Promise.all(promises); + /** @type {[AssistantDocument, Action]} */ + let [assistantDocument, updatedAction] = await Promise.all(promises); const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; for (let field of sensitiveFields) { - if (resolved[2].metadata[field]) { - delete resolved[2].metadata[field]; + if (updatedAction.metadata[field]) { + delete updatedAction.metadata[field]; } } - res.json(resolved); + + /* Map Azure OpenAI model to the assistant as defined by config */ + if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) { + updatedAssistant = { + ...updatedAssistant, + model: req.body.model, + }; + } + + res.json([assistantDocument, updatedAssistant, updatedAction]); } catch (error) { const message = 'Trouble updating the Assistant Action'; logger.error(message, error); @@ -171,12 +180,14 @@ router.delete('/:assistant_id/:action_id/:model', async (req, res) => { return true; }); - domain = domainParser(req, domain, true); + domain = await domainParser(req, domain, true); const updatedTools = tools.filter( (tool) => !(tool.function && tool.function.name.includes(domain)), ); + await openai.beta.assistants.update(assistant_id, { tools: updatedTools }); + const promises = []; promises.push( updateAssistant( @@ -187,7 +198,6 @@ router.delete('/:assistant_id/:action_id/:model', async (req, res) => { }, ), ); - promises.push(openai.beta.assistants.update(assistant_id, { tools: updatedTools })); promises.push(deleteAction({ action_id })); await Promise.all(promises); diff --git a/api/server/routes/assistants/assistants.js b/api/server/routes/assistants/assistants.js index 70c685a97a0..67f200f6b4d 100644 --- a/api/server/routes/assistants/assistants.js +++ b/api/server/routes/assistants/assistants.js @@ -213,7 +213,13 @@ router.post('/avatar/:assistant_id', upload.single('file'), async (req, res) => /** @type {{ openai: OpenAI }} */ const { openai } = await initializeClient({ req, res }); - const image = await uploadImageBuffer({ req, context: FileContext.avatar }); + const image = await uploadImageBuffer({ + req, + context: FileContext.avatar, + metadata: { + buffer: req.file.buffer, + }, + }); try { _metadata = JSON.parse(_metadata); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a9f6772deea..32e03bc8e8a 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -14,6 +14,7 @@ router.get('/', async function (req, res) { }; try { + /** @type {TStartupConfig} */ const payload = { appTitle: process.env.APP_TITLE || 'LibreChat', socialLogins: req.app.locals.socialLogins ?? defaultSocialLogins, @@ -44,7 +45,8 @@ router.get('/', async function (req, res) { isEnabled(process.env.SHOW_BIRTHDAY_ICON) || process.env.SHOW_BIRTHDAY_ICON === '', helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', - interface: req.app.locals.interface, + interface: req.app.locals.interfaceConfig, + modelSpecs: req.app.locals.modelSpecs, }; if (typeof process.env.CUSTOM_FOOTER === 'string') { diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 0fa45223805..275f5a9755e 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -1,8 +1,14 @@ +const multer = require('multer'); const express = require('express'); const { CacheKeys } = require('librechat-data-provider'); const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { getConvosByPage, deleteConvos, getConvo, saveConvo } = require('~/models/Conversation'); +const { IMPORT_CONVERSATION_JOB_NAME } = require('~/server/utils/import/jobDefinition'); +const { storage, importFileFilter } = require('~/server/routes/files/multer'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const { forkConversation } = require('~/server/utils/import/fork'); +const { createImportLimiters } = require('~/server/middleware'); +const jobScheduler = require('~/server/utils/jobScheduler'); const getLogStores = require('~/cache/getLogStores'); const { sleep } = require('~/server/utils'); const { logger } = require('~/config'); @@ -18,7 +24,15 @@ router.get('/', async (req, res) => { return res.status(400).json({ error: 'Invalid page number' }); } - res.status(200).send(await getConvosByPage(req.user.id, pageNumber)); + let pageSize = req.query.pageSize || 25; + pageSize = parseInt(pageSize, 10); + + if (isNaN(pageSize) || pageSize < 1) { + return res.status(400).json({ error: 'Invalid page size' }); + } + const isArchived = req.query.isArchived === 'true'; + + res.status(200).send(await getConvosByPage(req.user.id, pageNumber, pageSize, isArchived)); }); router.get('/:conversationId', async (req, res) => { @@ -99,4 +113,80 @@ router.post('/update', async (req, res) => { } }); +const { importIpLimiter, importUserLimiter } = createImportLimiters(); +const upload = multer({ storage: storage, fileFilter: importFileFilter }); + +/** + * Imports a conversation from a JSON file and saves it to the database. + * @route POST /import + * @param {Express.Multer.File} req.file - The JSON file to import. + * @returns {object} 201 - success response - application/json + */ +router.post( + '/import', + importIpLimiter, + importUserLimiter, + upload.single('file'), + async (req, res) => { + try { + const filepath = req.file.path; + const job = await jobScheduler.now(IMPORT_CONVERSATION_JOB_NAME, filepath, req.user.id); + + res.status(201).json({ message: 'Import started', jobId: job.id }); + } catch (error) { + logger.error('Error processing file', error); + res.status(500).send('Error processing file'); + } + }, +); + +/** + * POST /fork + * This route handles forking a conversation based on the TForkConvoRequest and responds with TForkConvoResponse. + * @route POST /fork + * @param {express.Request<{}, TForkConvoResponse, TForkConvoRequest>} req - Express request object. + * @param {express.Response<TForkConvoResponse>} res - Express response object. + * @returns {Promise<void>} - The response after forking the conversation. + */ +router.post('/fork', async (req, res) => { + try { + /** @type {TForkConvoRequest} */ + const { conversationId, messageId, option, splitAtTarget, latestMessageId } = req.body; + const result = await forkConversation({ + requestUserId: req.user.id, + originalConvoId: conversationId, + targetMessageId: messageId, + latestMessageId, + records: true, + splitAtTarget, + option, + }); + + res.json(result); + } catch (error) { + logger.error('Error forking conversation', error); + res.status(500).send('Error forking conversation'); + } +}); + +// Get the status of an import job for polling +router.get('/import/jobs/:jobId', async (req, res) => { + try { + const { jobId } = req.params; + const { userId, ...jobStatus } = await jobScheduler.getJobStatus(jobId); + if (!jobStatus) { + return res.status(404).json({ message: 'Job not found.' }); + } + + if (userId !== req.user.id) { + return res.status(403).json({ message: 'Unauthorized' }); + } + + res.json(jobStatus); + } catch (error) { + logger.error('Error getting job details', error); + res.status(500).send('Error getting job details'); + } +}); + module.exports = router; diff --git a/api/server/routes/files/avatar.js b/api/server/routes/files/avatar.js index 71ade965cde..beb64d449a8 100644 --- a/api/server/routes/files/avatar.js +++ b/api/server/routes/files/avatar.js @@ -18,13 +18,15 @@ router.post('/', upload.single('input'), async (req, res) => { } const fileStrategy = req.app.locals.fileStrategy; - const webPBuffer = await resizeAvatar({ + const desiredFormat = req.app.locals.imageOutputType; + const resizedBuffer = await resizeAvatar({ userId, input, + desiredFormat, }); const { processAvatar } = getStrategyFunctions(fileStrategy); - const url = await processAvatar({ buffer: webPBuffer, userId, manual }); + const url = await processAvatar({ buffer: resizedBuffer, userId, manual }); res.json({ url }); } catch (error) { diff --git a/api/server/routes/files/index.js b/api/server/routes/files/index.js index c9f5ce1679e..d268e0a1ba6 100644 --- a/api/server/routes/files/index.js +++ b/api/server/routes/files/index.js @@ -1,6 +1,6 @@ const express = require('express'); -const createMulterInstance = require('./multer'); const { uaParser, checkBan, requireJwtAuth, createFileLimiters } = require('~/server/middleware'); +const { createMulterInstance } = require('./multer'); const files = require('./files'); const images = require('./images'); diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js index 2162a0d8075..76c4d50c3e8 100644 --- a/api/server/routes/files/multer.js +++ b/api/server/routes/files/multer.js @@ -20,6 +20,16 @@ const storage = multer.diskStorage({ }, }); +const importFileFilter = (req, file, cb) => { + if (file.mimetype === 'application/json') { + cb(null, true); + } else if (path.extname(file.originalname).toLowerCase() === '.json') { + cb(null, true); + } else { + cb(new Error('Only JSON files are allowed'), false); + } +}; + const fileFilter = (req, file, cb) => { if (!file) { return cb(new Error('No file provided'), false); @@ -42,4 +52,4 @@ const createMulterInstance = async () => { }); }; -module.exports = createMulterInstance; +module.exports = { createMulterInstance, storage, importFileFilter }; diff --git a/api/server/routes/oauth.js b/api/server/routes/oauth.js index e85d83d8883..07494368653 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -4,7 +4,7 @@ const passport = require('passport'); const express = require('express'); const router = express.Router(); const { setAuthTokens } = require('~/server/services/AuthService'); -const { loginLimiter, checkBan } = require('~/server/middleware'); +const { loginLimiter, checkBan, checkDomainAllowed } = require('~/server/middleware'); const { logger } = require('~/config'); const domains = { @@ -16,6 +16,7 @@ router.use(loginLimiter); const oauthHandler = async (req, res) => { try { + await checkDomainAllowed(req, res); await checkBan(req, res); if (req.banned) { return; diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index 22770f15500..344a6570bab 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -1,20 +1,27 @@ -const { AuthTypeEnum, EModelEndpoint, actionDomainSeparator } = require('librechat-data-provider'); +const { + AuthTypeEnum, + EModelEndpoint, + actionDomainSeparator, + CacheKeys, + Constants, +} = require('librechat-data-provider'); const { encryptV2, decryptV2 } = require('~/server/utils/crypto'); const { getActions } = require('~/models/Action'); +const { getLogStores } = require('~/cache'); const { logger } = require('~/config'); /** - * Parses the domain for an action. + * Encodes or decodes a domain name to/from base64, or replacing periods with a custom separator. * - * Azure OpenAI Assistants API doesn't support periods in function - * names due to `[a-zA-Z0-9_-]*` Regex Validation. + * Necessary because Azure OpenAI Assistants API doesn't support periods in function + * names due to `[a-zA-Z0-9_-]*` Regex Validation, limited to a 64-character maximum. * - * @param {Express.Request} req - Express Request object - * @param {string} domain - The domain for the actoin - * @param {boolean} inverse - If true, replaces periods with `actionDomainSeparator` - * @returns {string} The parsed domain + * @param {Express.Request} req - The Express Request object. + * @param {string} domain - The domain name to encode/decode. + * @param {boolean} inverse - False to decode from base64, true to encode to base64. + * @returns {Promise<string>} Encoded or decoded domain string. */ -function domainParser(req, domain, inverse = false) { +async function domainParser(req, domain, inverse = false) { if (!domain) { return; } @@ -23,11 +30,35 @@ function domainParser(req, domain, inverse = false) { return domain; } - if (inverse) { + const domainsCache = getLogStores(CacheKeys.ENCODED_DOMAINS); + const cachedDomain = await domainsCache.get(domain); + if (inverse && cachedDomain) { + return domain; + } + + if (inverse && domain.length <= Constants.ENCODED_DOMAIN_LENGTH) { return domain.replace(/\./g, actionDomainSeparator); } - return domain.replace(actionDomainSeparator, '.'); + if (inverse) { + const modifiedDomain = Buffer.from(domain).toString('base64'); + const key = modifiedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH); + await domainsCache.set(key, modifiedDomain); + return key; + } + + const replaceSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); + + if (!cachedDomain) { + return domain.replace(replaceSeparatorRegex, '.'); + } + + try { + return Buffer.from(cachedDomain, 'base64').toString('utf-8'); + } catch (error) { + logger.error(`Failed to parse domain (possibly not base64): ${domain}`, error); + return domain; + } } /** diff --git a/api/server/services/ActionService.spec.js b/api/server/services/ActionService.spec.js new file mode 100644 index 00000000000..57f99889613 --- /dev/null +++ b/api/server/services/ActionService.spec.js @@ -0,0 +1,196 @@ +const { Constants, EModelEndpoint, actionDomainSeparator } = require('librechat-data-provider'); +const { domainParser } = require('./ActionService'); + +jest.mock('keyv'); + +const globalCache = {}; +jest.mock('~/cache/getLogStores', () => { + return jest.fn().mockImplementation(() => { + const EventEmitter = require('events'); + const { CacheKeys } = require('librechat-data-provider'); + + class KeyvMongo extends EventEmitter { + constructor(url = 'mongodb://127.0.0.1:27017', options) { + super(); + this.ttlSupport = false; + url = url ?? {}; + if (typeof url === 'string') { + url = { url }; + } + if (url.uri) { + url = { url: url.uri, ...url }; + } + this.opts = { + url, + collection: 'keyv', + ...url, + ...options, + }; + } + + get = async (key) => { + return new Promise((resolve) => { + resolve(globalCache[key] || null); + }); + }; + + set = async (key, value) => { + return new Promise((resolve) => { + globalCache[key] = value; + resolve(true); + }); + }; + } + + return new KeyvMongo('', { + namespace: CacheKeys.ENCODED_DOMAINS, + ttl: 0, + }); + }); +}); + +describe('domainParser', () => { + const req = { + app: { + locals: { + [EModelEndpoint.azureOpenAI]: { + assistants: true, + }, + }, + }, + }; + + const reqNoAzure = { + app: { + locals: { + [EModelEndpoint.azureOpenAI]: { + assistants: false, + }, + }, + }, + }; + + const TLD = '.com'; + + // Non-azure request + it('returns domain as is if not azure', async () => { + const domain = `example.com${actionDomainSeparator}test${actionDomainSeparator}`; + const result1 = await domainParser(reqNoAzure, domain, false); + const result2 = await domainParser(reqNoAzure, domain, true); + expect(result1).toEqual(domain); + expect(result2).toEqual(domain); + }); + + // Test for Empty or Null Inputs + it('returns undefined for null domain input', async () => { + const result = await domainParser(req, null, true); + expect(result).toBeUndefined(); + }); + + it('returns undefined for empty domain input', async () => { + const result = await domainParser(req, '', true); + expect(result).toBeUndefined(); + }); + + // Verify Correct Caching Behavior + it('caches encoded domain correctly', async () => { + const domain = 'longdomainname.com'; + const encodedDomain = Buffer.from(domain) + .toString('base64') + .substring(0, Constants.ENCODED_DOMAIN_LENGTH); + + await domainParser(req, domain, true); + + const cachedValue = await globalCache[encodedDomain]; + expect(cachedValue).toEqual(Buffer.from(domain).toString('base64')); + }); + + // Test for Edge Cases Around Length Threshold + it('encodes domain exactly at threshold without modification', async () => { + const domain = 'a'.repeat(Constants.ENCODED_DOMAIN_LENGTH - TLD.length) + TLD; + const expected = domain.replace(/\./g, actionDomainSeparator); + const result = await domainParser(req, domain, true); + expect(result).toEqual(expected); + }); + + it('encodes domain just below threshold without modification', async () => { + const domain = 'a'.repeat(Constants.ENCODED_DOMAIN_LENGTH - 1 - TLD.length) + TLD; + const expected = domain.replace(/\./g, actionDomainSeparator); + const result = await domainParser(req, domain, true); + expect(result).toEqual(expected); + }); + + // Test for Unicode Domain Names + it('handles unicode characters in domain names correctly when encoding', async () => { + const unicodeDomain = 'täst.example.com'; + const encodedDomain = Buffer.from(unicodeDomain) + .toString('base64') + .substring(0, Constants.ENCODED_DOMAIN_LENGTH); + const result = await domainParser(req, unicodeDomain, true); + expect(result).toEqual(encodedDomain); + }); + + it('decodes unicode domain names correctly', async () => { + const unicodeDomain = 'täst.example.com'; + const encodedDomain = Buffer.from(unicodeDomain).toString('base64'); + globalCache[encodedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH)] = encodedDomain; // Simulate caching + + const result = await domainParser( + req, + encodedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH), + false, + ); + expect(result).toEqual(unicodeDomain); + }); + + // Core Functionality Tests + it('returns domain with replaced separators if no cached domain exists', async () => { + const domain = 'example.com'; + const withSeparator = domain.replace(/\./g, actionDomainSeparator); + const result = await domainParser(req, withSeparator, false); + expect(result).toEqual(domain); + }); + + it('returns domain with replaced separators when inverse is false and under encoding length', async () => { + const domain = 'examp.com'; + const withSeparator = domain.replace(/\./g, actionDomainSeparator); + const result = await domainParser(req, withSeparator, false); + expect(result).toEqual(domain); + }); + + it('replaces periods with actionDomainSeparator when inverse is true and under encoding length', async () => { + const domain = 'examp.com'; + const expected = domain.replace(/\./g, actionDomainSeparator); + const result = await domainParser(req, domain, true); + expect(result).toEqual(expected); + }); + + it('encodes domain when length is above threshold and inverse is true', async () => { + const domain = 'a'.repeat(Constants.ENCODED_DOMAIN_LENGTH + 1).concat('.com'); + const result = await domainParser(req, domain, true); + expect(result).not.toEqual(domain); + expect(result.length).toBeLessThanOrEqual(Constants.ENCODED_DOMAIN_LENGTH); + }); + + it('returns encoded value if no encoded value is cached, and inverse is false', async () => { + const originalDomain = 'example.com'; + const encodedDomain = Buffer.from( + originalDomain.replace(/\./g, actionDomainSeparator), + ).toString('base64'); + const result = await domainParser(req, encodedDomain, false); + expect(result).toEqual(encodedDomain); + }); + + it('decodes encoded value if cached and encoded value is provided, and inverse is false', async () => { + const originalDomain = 'example.com'; + const encodedDomain = await domainParser(req, originalDomain, true); + const result = await domainParser(req, encodedDomain, false); + expect(result).toEqual(originalDomain); + }); + + it('handles invalid base64 encoded values gracefully', async () => { + const invalidBase64Domain = 'not_base64_encoded'; + const result = await domainParser(req, invalidBase64Domain, false); + expect(result).toEqual(invalidBase64Domain); + }); +}); diff --git a/api/server/services/AppService.js b/api/server/services/AppService.js index e4cb416b416..4163a3df87b 100644 --- a/api/server/services/AppService.js +++ b/api/server/services/AppService.js @@ -1,28 +1,13 @@ -const { - Constants, - FileSources, - Capabilities, - EModelEndpoint, - defaultSocialLogins, - validateAzureGroups, - mapModelToAzureConfig, - assistantEndpointSchema, - deprecatedAzureVariables, - conflictingAzureVariables, -} = require('librechat-data-provider'); +const { FileSources, EModelEndpoint, getConfigDefaults } = require('librechat-data-provider'); +const { checkVariables, checkHealth, checkConfig, checkAzureVariables } = require('./start/checks'); +const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants'); const { initializeFirebase } = require('./Files/Firebase/initialize'); const loadCustomConfig = require('./Config/loadCustomConfig'); const handleRateLimits = require('./Config/handleRateLimits'); +const { loadDefaultInterface } = require('./start/interface'); +const { azureConfigSetup } = require('./start/azureOpenAI'); const { loadAndFormatTools } = require('./ToolService'); const paths = require('~/config/paths'); -const { logger } = require('~/config'); - -const secretDefaults = { - CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', - CREDS_IV: 'e2341419ec3dd3d19b13a1a87fafcbfb', - JWT_SECRET: '16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef', - JWT_REFRESH_SECRET: 'eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418', -}; /** * @@ -33,10 +18,18 @@ const secretDefaults = { const AppService = async (app) => { /** @type {TCustomConfig}*/ const config = (await loadCustomConfig()) ?? {}; + const configDefaults = getConfigDefaults(); + + const filteredTools = config.filteredTools; + const includedTools = config.includedTools; + const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy; + const imageOutputType = config?.imageOutputType ?? configDefaults.imageOutputType; - const fileStrategy = config.fileStrategy ?? FileSources.local; process.env.CDN_PROVIDER = fileStrategy; + checkVariables(); + await checkHealth(); + if (fileStrategy === FileSources.firebase) { initializeFirebase(); } @@ -44,165 +37,58 @@ const AppService = async (app) => { /** @type {Record<string, FunctionTool} */ const availableTools = loadAndFormatTools({ directory: paths.structuredTools, - filter: new Set([ - 'ChatTool.js', - 'CodeSherpa.js', - 'CodeSherpaTools.js', - 'E2BTools.js', - 'extractionChain.js', - ]), + adminFilter: filteredTools, + adminIncluded: includedTools, }); - const socialLogins = config?.registration?.socialLogins ?? defaultSocialLogins; + const socialLogins = + config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins; + const interfaceConfig = loadDefaultInterface(config, configDefaults); - if (!Object.keys(config).length) { - app.locals = { - availableTools, - fileStrategy, - socialLogins, - paths, - }; + const defaultLocals = { + paths, + fileStrategy, + socialLogins, + filteredTools, + includedTools, + availableTools, + imageOutputType, + interfaceConfig, + }; + if (!Object.keys(config).length) { + app.locals = defaultLocals; return; } - if (config.version !== Constants.CONFIG_VERSION) { - logger.info( - `\nOutdated Config version: ${config.version}. Current version: ${Constants.CONFIG_VERSION}\n\nCheck out the latest config file guide for new options and features.\nhttps://docs.librechat.ai/install/configuration/custom_config.html\n\n`, - ); - } - + checkConfig(config); handleRateLimits(config?.rateLimits); const endpointLocals = {}; if (config?.endpoints?.[EModelEndpoint.azureOpenAI]) { - const { groups, ...azureConfiguration } = config.endpoints[EModelEndpoint.azureOpenAI]; - const { isValid, modelNames, modelGroupMap, groupMap, errors } = validateAzureGroups(groups); - - if (!isValid) { - const errorString = errors.join('\n'); - const errorMessage = 'Invalid Azure OpenAI configuration:\n' + errorString; - logger.error(errorMessage); - throw new Error(errorMessage); - } - - const assistantModels = []; - const assistantGroups = new Set(); - for (const modelName of modelNames) { - mapModelToAzureConfig({ modelName, modelGroupMap, groupMap }); - const groupName = modelGroupMap?.[modelName]?.group; - const modelGroup = groupMap?.[groupName]; - let supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; - if (supportsAssistants) { - assistantModels.push(modelName); - !assistantGroups.has(groupName) && assistantGroups.add(groupName); - } - } - - if (azureConfiguration.assistants && assistantModels.length === 0) { - throw new Error( - 'No Azure models are configured to support assistants. Please remove the `assistants` field or configure at least one model to support assistants.', - ); - } - - endpointLocals[EModelEndpoint.azureOpenAI] = { - modelNames, - modelGroupMap, - groupMap, - assistantModels, - assistantGroups: Array.from(assistantGroups), - ...azureConfiguration, - }; - - deprecatedAzureVariables.forEach(({ key, description }) => { - if (process.env[key]) { - logger.warn( - `The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`, - ); - } - }); - - conflictingAzureVariables.forEach(({ key }) => { - if (process.env[key]) { - logger.warn( - `The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`, - ); - } - }); - - if (azureConfiguration.assistants) { - endpointLocals[EModelEndpoint.assistants] = { - // Note: may need to add retrieval models here in the future - capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter], - }; - } + endpointLocals[EModelEndpoint.azureOpenAI] = azureConfigSetup(config); + checkAzureVariables(); } - if (config?.endpoints?.[EModelEndpoint.assistants]) { - const assistantsConfig = config.endpoints[EModelEndpoint.assistants]; - const parsedConfig = assistantEndpointSchema.parse(assistantsConfig); - if (assistantsConfig.supportedIds?.length && assistantsConfig.excludedIds?.length) { - logger.warn( - `Both \`supportedIds\` and \`excludedIds\` are defined for the ${EModelEndpoint.assistants} endpoint; \`excludedIds\` field will be ignored.`, - ); - } - - const prevConfig = endpointLocals[EModelEndpoint.assistants] ?? {}; - - /** @type {Partial<TAssistantEndpoint>} */ - endpointLocals[EModelEndpoint.assistants] = { - ...prevConfig, - retrievalModels: parsedConfig.retrievalModels, - disableBuilder: parsedConfig.disableBuilder, - pollIntervalMs: parsedConfig.pollIntervalMs, - supportedIds: parsedConfig.supportedIds, - capabilities: parsedConfig.capabilities, - excludedIds: parsedConfig.excludedIds, - timeoutMs: parsedConfig.timeoutMs, - }; + if (config?.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) { + endpointLocals[EModelEndpoint.assistants] = azureAssistantsDefaults(); } - try { - const response = await fetch(`${process.env.RAG_API_URL}/health`); - if (response?.ok && response?.status === 200) { - logger.info(`RAG API is running and reachable at ${process.env.RAG_API_URL}.`); - } - } catch (error) { - logger.warn( - `RAG API is either not running or not reachable at ${process.env.RAG_API_URL}, you may experience errors with file uploads.`, + if (config?.endpoints?.[EModelEndpoint.assistants]) { + endpointLocals[EModelEndpoint.assistants] = assistantsConfigSetup( + config, + endpointLocals[EModelEndpoint.assistants], ); } app.locals = { - socialLogins, - availableTools, - fileStrategy, + ...defaultLocals, + modelSpecs: config.modelSpecs, fileConfig: config?.fileConfig, - interface: config?.interface, - paths, + secureImageLinks: config?.secureImageLinks, ...endpointLocals, }; - - let hasDefaultSecrets = false; - for (const [key, value] of Object.entries(secretDefaults)) { - if (process.env[key] === value) { - logger.warn(`Default value for ${key} is being used.`); - !hasDefaultSecrets && (hasDefaultSecrets = true); - } - } - - if (hasDefaultSecrets) { - logger.info( - `Please replace any default secret values. - - For your conveninence, fork & run this replit to generate your own secret values: - - https://replit.com/@daavila/crypto#index.js - - `, - ); - } }; module.exports = AppService; diff --git a/api/server/services/AppService.spec.js b/api/server/services/AppService.spec.js index 3a40a49b3e7..e55bff99469 100644 --- a/api/server/services/AppService.spec.js +++ b/api/server/services/AppService.spec.js @@ -1,6 +1,7 @@ const { FileSources, EModelEndpoint, + EImageOutputType, defaultSocialLogins, validateAzureGroups, deprecatedAzureVariables, @@ -92,6 +93,16 @@ describe('AppService', () => { expect(app.locals).toEqual({ socialLogins: ['testLogin'], fileStrategy: 'testStrategy', + interfaceConfig: expect.objectContaining({ + privacyPolicy: undefined, + termsOfService: undefined, + endpointsMenu: true, + modelSelect: true, + parameters: true, + sidePanel: true, + presets: true, + }), + modelSpecs: undefined, availableTools: { ExampleTool: { type: 'function', @@ -107,6 +118,9 @@ describe('AppService', () => { }, }, paths: expect.anything(), + imageOutputType: expect.any(String), + fileConfig: undefined, + secureImageLinks: undefined, }); }); @@ -125,6 +139,36 @@ describe('AppService', () => { expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Outdated Config version')); }); + it('should change the `imageOutputType` based on config value', async () => { + require('./Config/loadCustomConfig').mockImplementationOnce(() => + Promise.resolve({ + version: '0.10.0', + imageOutputType: EImageOutputType.WEBP, + }), + ); + + await AppService(app); + expect(app.locals.imageOutputType).toEqual(EImageOutputType.WEBP); + }); + + it('should default to `PNG` `imageOutputType` with no provided type', async () => { + require('./Config/loadCustomConfig').mockImplementationOnce(() => + Promise.resolve({ + version: '0.10.0', + }), + ); + + await AppService(app); + expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); + }); + + it('should default to `PNG` `imageOutputType` with no provided config', async () => { + require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve(undefined)); + + await AppService(app); + expect(app.locals.imageOutputType).toEqual(EImageOutputType.PNG); + }); + it('should initialize Firebase when fileStrategy is firebase', async () => { require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve({ @@ -146,7 +190,6 @@ describe('AppService', () => { expect(loadAndFormatTools).toHaveBeenCalledWith({ directory: expect.anything(), - filter: expect.anything(), }); expect(app.locals.availableTools.ExampleTool).toBeDefined(); @@ -193,6 +236,27 @@ describe('AppService', () => { ); }); + it('should correctly configure minimum Azure OpenAI Assistant values', async () => { + const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; + require('./Config/loadCustomConfig').mockImplementationOnce(() => + Promise.resolve({ + endpoints: { + [EModelEndpoint.azureOpenAI]: { + groups: assistantGroups, + assistants: true, + }, + }, + }), + ); + + process.env.WESTUS_API_KEY = 'westus-key'; + process.env.EASTUS_API_KEY = 'eastus-key'; + + await AppService(app); + expect(app.locals).toHaveProperty(EModelEndpoint.assistants); + expect(app.locals[EModelEndpoint.assistants].capabilities.length).toEqual(3); + }); + it('should correctly configure Azure OpenAI endpoint based on custom config', async () => { require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve({ @@ -283,6 +347,69 @@ describe('AppService', () => { expect(process.env.FILE_UPLOAD_USER_MAX).toEqual('initialUserMax'); expect(process.env.FILE_UPLOAD_USER_WINDOW).toEqual('initialUserWindow'); }); + + it('should not modify IMPORT environment variables without rate limits', async () => { + // Setup initial environment variables + process.env.IMPORT_IP_MAX = '10'; + process.env.IMPORT_IP_WINDOW = '15'; + process.env.IMPORT_USER_MAX = '5'; + process.env.IMPORT_USER_WINDOW = '20'; + + const initialEnv = { ...process.env }; + + await AppService(app); + + // Expect environment variables to remain unchanged + expect(process.env.IMPORT_IP_MAX).toEqual(initialEnv.IMPORT_IP_MAX); + expect(process.env.IMPORT_IP_WINDOW).toEqual(initialEnv.IMPORT_IP_WINDOW); + expect(process.env.IMPORT_USER_MAX).toEqual(initialEnv.IMPORT_USER_MAX); + expect(process.env.IMPORT_USER_WINDOW).toEqual(initialEnv.IMPORT_USER_WINDOW); + }); + + it('should correctly set IMPORT environment variables based on rate limits', async () => { + // Define and mock a custom configuration with rate limits + const importLimitsConfig = { + rateLimits: { + conversationsImport: { + ipMax: '150', + ipWindowInMinutes: '60', + userMax: '50', + userWindowInMinutes: '30', + }, + }, + }; + + require('./Config/loadCustomConfig').mockImplementationOnce(() => + Promise.resolve(importLimitsConfig), + ); + + await AppService(app); + + // Verify that process.env has been updated according to the rate limits config + expect(process.env.IMPORT_IP_MAX).toEqual('150'); + expect(process.env.IMPORT_IP_WINDOW).toEqual('60'); + expect(process.env.IMPORT_USER_MAX).toEqual('50'); + expect(process.env.IMPORT_USER_WINDOW).toEqual('30'); + }); + + it('should fallback to default IMPORT environment variables when rate limits are unspecified', async () => { + // Setup initial environment variables to non-default values + process.env.IMPORT_IP_MAX = 'initialMax'; + process.env.IMPORT_IP_WINDOW = 'initialWindow'; + process.env.IMPORT_USER_MAX = 'initialUserMax'; + process.env.IMPORT_USER_WINDOW = 'initialUserWindow'; + + // Mock a custom configuration without specific rate limits + require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve({})); + + await AppService(app); + + // Verify that process.env falls back to the initial values + expect(process.env.IMPORT_IP_MAX).toEqual('initialMax'); + expect(process.env.IMPORT_IP_WINDOW).toEqual('initialWindow'); + expect(process.env.IMPORT_USER_MAX).toEqual('initialUserMax'); + expect(process.env.IMPORT_USER_WINDOW).toEqual('initialUserWindow'); + }); }); describe('AppService updating app.locals and issuing warnings', () => { diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 098110df0d4..b4accee5ac8 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -2,7 +2,7 @@ const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const { errorsToString } = require('librechat-data-provider'); const { registerSchema } = require('~/strategies/validators'); -const getCustomConfig = require('~/server/services/Config/getCustomConfig'); +const isDomainAllowed = require('./isDomainAllowed'); const Token = require('~/models/schema/tokenSchema'); const { sendEmail } = require('~/server/utils'); const Session = require('~/models/Session'); @@ -14,27 +14,6 @@ const domains = { server: process.env.DOMAIN_SERVER, }; -async function isDomainAllowed(email) { - if (!email) { - return false; - } - - const domain = email.split('@')[1]; - - if (!domain) { - return false; - } - - const customConfig = await getCustomConfig(); - if (!customConfig) { - return true; - } else if (!customConfig?.registration?.allowedDomains) { - return true; - } - - return customConfig.registration.allowedDomains.includes(domain); -} - const isProduction = process.env.NODE_ENV === 'production'; /** diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js deleted file mode 100644 index fb5d8e2533c..00000000000 --- a/api/server/services/AuthService.spec.js +++ /dev/null @@ -1,39 +0,0 @@ -const getCustomConfig = require('~/server/services/Config/getCustomConfig'); -const { isDomainAllowed } = require('./AuthService'); - -jest.mock('~/server/services/Config/getCustomConfig', () => jest.fn()); - -describe('isDomainAllowed', () => { - it('should allow domain when customConfig is not available', async () => { - getCustomConfig.mockResolvedValue(null); - await expect(isDomainAllowed('test@domain1.com')).resolves.toBe(true); - }); - - it('should allow domain when allowedDomains is not defined in customConfig', async () => { - getCustomConfig.mockResolvedValue({}); - await expect(isDomainAllowed('test@domain1.com')).resolves.toBe(true); - }); - - it('should reject an email if it is falsy', async () => { - getCustomConfig.mockResolvedValue({}); - await expect(isDomainAllowed('')).resolves.toBe(false); - }); - - it('should allow a domain if it is included in the allowedDomains', async () => { - getCustomConfig.mockResolvedValue({ - registration: { - allowedDomains: ['domain1.com', 'domain2.com'], - }, - }); - await expect(isDomainAllowed('user@domain1.com')).resolves.toBe(true); - }); - - it('should reject a domain if it is not included in the allowedDomains', async () => { - getCustomConfig.mockResolvedValue({ - registration: { - allowedDomains: ['domain1.com', 'domain2.com'], - }, - }); - await expect(isDomainAllowed('user@domain3.com')).resolves.toBe(false); - }); -}); diff --git a/api/server/services/Config/handleRateLimits.js b/api/server/services/Config/handleRateLimits.js index d40ccfb4f33..19fb808ec21 100644 --- a/api/server/services/Config/handleRateLimits.js +++ b/api/server/services/Config/handleRateLimits.js @@ -6,17 +6,24 @@ const handleRateLimits = (rateLimits) => { if (!rateLimits) { return; } - const { fileUploads } = rateLimits; - if (!fileUploads) { - return; + const { fileUploads, conversationsImport } = rateLimits; + if (fileUploads) { + process.env.FILE_UPLOAD_IP_MAX = fileUploads.ipMax ?? process.env.FILE_UPLOAD_IP_MAX; + process.env.FILE_UPLOAD_IP_WINDOW = + fileUploads.ipWindowInMinutes ?? process.env.FILE_UPLOAD_IP_WINDOW; + process.env.FILE_UPLOAD_USER_MAX = fileUploads.userMax ?? process.env.FILE_UPLOAD_USER_MAX; + process.env.FILE_UPLOAD_USER_WINDOW = + fileUploads.userWindowInMinutes ?? process.env.FILE_UPLOAD_USER_WINDOW; } - process.env.FILE_UPLOAD_IP_MAX = fileUploads.ipMax ?? process.env.FILE_UPLOAD_IP_MAX; - process.env.FILE_UPLOAD_IP_WINDOW = - fileUploads.ipWindowInMinutes ?? process.env.FILE_UPLOAD_IP_WINDOW; - process.env.FILE_UPLOAD_USER_MAX = fileUploads.userMax ?? process.env.FILE_UPLOAD_USER_MAX; - process.env.FILE_UPLOAD_USER_WINDOW = - fileUploads.userWindowInMinutes ?? process.env.FILE_UPLOAD_USER_WINDOW; + if (conversationsImport) { + process.env.IMPORT_IP_MAX = conversationsImport.ipMax ?? process.env.IMPORT_IP_MAX; + process.env.IMPORT_IP_WINDOW = + conversationsImport.ipWindowInMinutes ?? process.env.IMPORT_IP_WINDOW; + process.env.IMPORT_USER_MAX = conversationsImport.userMax ?? process.env.IMPORT_USER_MAX; + process.env.IMPORT_USER_WINDOW = + conversationsImport.userWindowInMinutes ?? process.env.IMPORT_USER_WINDOW; + } }; module.exports = handleRateLimits; diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js index 1b7dec5fd71..828ecd881ed 100644 --- a/api/server/services/Config/loadConfigModels.spec.js +++ b/api/server/services/Config/loadConfigModels.spec.js @@ -46,6 +46,15 @@ const exampleConfig = { fetch: false, }, }, + { + name: 'MLX', + apiKey: 'user_provided', + baseURL: 'http://localhost:8080/v1/', + models: { + default: ['Meta-Llama-3-8B-Instruct-4bit'], + fetch: false, + }, + }, ], }, }; diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index 617cd7d9469..1b5b2870664 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -1,5 +1,5 @@ const path = require('path'); -const { CacheKeys, configSchema } = require('librechat-data-provider'); +const { CacheKeys, configSchema, EImageOutputType } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); const loadYaml = require('~/utils/loadYaml'); const { logger } = require('~/config'); @@ -37,11 +37,17 @@ async function loadCustomConfig() { if (!customConfig) { i === 0 && logger.info( - 'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://docs.librechat.ai/install/configuration/custom_config.html\n\n', + 'Custom config file missing or YAML format invalid.\n\nCheck out the latest config file guide for configurable options and features.\nhttps://www.librechat.ai/docs/configuration/librechat_yaml\n\n', ); i === 0 && i++; return null; } + + if (customConfig.reason || customConfig.stack) { + i === 0 && logger.error('Config file YAML format is invalid:', customConfig); + i === 0 && i++; + return null; + } } if (typeof customConfig === 'string') { @@ -55,6 +61,20 @@ async function loadCustomConfig() { } const result = configSchema.strict().safeParse(customConfig); + if (result?.error?.errors?.some((err) => err?.path && err.path?.includes('imageOutputType'))) { + throw new Error( + ` +Please specify a correct \`imageOutputType\` value (case-sensitive). + + The available options are: + - ${EImageOutputType.JPEG} + - ${EImageOutputType.PNG} + - ${EImageOutputType.WEBP} + + Refer to the latest config file guide for more information: + https://www.librechat.ai/docs/configuration/librechat_yaml`, + ); + } if (!result.success) { i === 0 && logger.error(`Invalid custom config file at ${configPath}`, result.error); i === 0 && i++; @@ -70,6 +90,10 @@ async function loadCustomConfig() { await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig); } + if (result.data.modelSpecs) { + customConfig.modelSpecs = result.data.modelSpecs; + } + return customConfig; } diff --git a/api/server/services/Endpoints/anthropic/buildOptions.js b/api/server/services/Endpoints/anthropic/buildOptions.js index 4cd9ba8b925..677eabc6ae7 100644 --- a/api/server/services/Endpoints/anthropic/buildOptions.js +++ b/api/server/services/Endpoints/anthropic/buildOptions.js @@ -1,10 +1,23 @@ const buildOptions = (endpoint, parsedBody) => { - const { modelLabel, promptPrefix, resendFiles, ...rest } = parsedBody; + const { + modelLabel, + promptPrefix, + maxContextTokens, + resendFiles, + iconURL, + greeting, + spec, + ...rest + } = parsedBody; const endpointOption = { endpoint, modelLabel, promptPrefix, resendFiles, + iconURL, + greeting, + spec, + maxContextTokens, modelOptions: { ...rest, }, diff --git a/api/server/services/Endpoints/anthropic/initializeClient.js b/api/server/services/Endpoints/anthropic/initializeClient.js index 575a216998e..c5d6696b3ec 100644 --- a/api/server/services/Endpoints/anthropic/initializeClient.js +++ b/api/server/services/Endpoints/anthropic/initializeClient.js @@ -1,5 +1,6 @@ -const { AnthropicClient } = require('~/app'); +const { EModelEndpoint } = require('librechat-data-provider'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); +const { AnthropicClient } = require('~/app'); const initializeClient = async ({ req, res, endpointOption }) => { const { ANTHROPIC_API_KEY, ANTHROPIC_REVERSE_PROXY, PROXY } = process.env; @@ -7,14 +8,15 @@ const initializeClient = async ({ req, res, endpointOption }) => { const isUserProvided = ANTHROPIC_API_KEY === 'user_provided'; const anthropicApiKey = isUserProvided - ? await getAnthropicUserKey(req.user.id) + ? await getUserKey({ userId: req.user.id, name: EModelEndpoint.anthropic }) : ANTHROPIC_API_KEY; + if (!anthropicApiKey) { + throw new Error('Anthropic API key not provided. Please provide it again.'); + } + if (expiresAt && isUserProvided) { - checkUserKeyExpiry( - expiresAt, - 'Your ANTHROPIC_API_KEY has expired. Please provide your API key again.', - ); + checkUserKeyExpiry(expiresAt, EModelEndpoint.anthropic); } const client = new AnthropicClient(anthropicApiKey, { @@ -31,8 +33,4 @@ const initializeClient = async ({ req, res, endpointOption }) => { }; }; -const getAnthropicUserKey = async (userId) => { - return await getUserKey({ userId, name: 'anthropic' }); -}; - module.exports = initializeClient; diff --git a/api/server/services/Endpoints/assistants/buildOptions.js b/api/server/services/Endpoints/assistants/buildOptions.js index c670953539d..047663c4e53 100644 --- a/api/server/services/Endpoints/assistants/buildOptions.js +++ b/api/server/services/Endpoints/assistants/buildOptions.js @@ -1,10 +1,13 @@ const buildOptions = (endpoint, parsedBody) => { // eslint-disable-next-line no-unused-vars - const { promptPrefix, assistant_id, ...rest } = parsedBody; + const { promptPrefix, assistant_id, iconURL, greeting, spec, ...rest } = parsedBody; const endpointOption = { endpoint, promptPrefix, assistant_id, + iconURL, + greeting, + spec, modelOptions: { ...rest, }, diff --git a/api/server/services/Endpoints/assistants/initializeClient.js b/api/server/services/Endpoints/assistants/initializeClient.js index 05a9232f9fe..c44bc66f310 100644 --- a/api/server/services/Endpoints/assistants/initializeClient.js +++ b/api/server/services/Endpoints/assistants/initializeClient.js @@ -1,12 +1,13 @@ const OpenAI = require('openai'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { + ErrorTypes, EModelEndpoint, resolveHeaders, mapModelToAzureConfig, } = require('librechat-data-provider'); const { - getUserKey, + getUserKeyValues, getUserKeyExpiry, checkUserKeyExpiry, } = require('~/server/services/UserService'); @@ -26,18 +27,8 @@ const initializeClient = async ({ req, res, endpointOption, initAppClient = fals userId: req.user.id, name: EModelEndpoint.assistants, }); - checkUserKeyExpiry( - expiresAt, - 'Your Assistants API key has expired. Please provide your API key again.', - ); - userValues = await getUserKey({ userId: req.user.id, name: EModelEndpoint.assistants }); - try { - userValues = JSON.parse(userValues); - } catch (e) { - throw new Error( - 'Invalid JSON provided for Assistants API user values. Please provide them again.', - ); - } + checkUserKeyExpiry(expiresAt, EModelEndpoint.assistants); + userValues = await getUserKeyValues({ userId: req.user.id, name: EModelEndpoint.assistants }); } let apiKey = userProvidesKey ? userValues.apiKey : ASSISTANTS_API_KEY; @@ -101,6 +92,14 @@ const initializeClient = async ({ req, res, endpointOption, initAppClient = fals } } + if (userProvidesKey & !apiKey) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.NO_USER_KEY, + }), + ); + } + if (!apiKey) { throw new Error('Assistants API key not provided. Please provide it again.'); } diff --git a/api/server/services/Endpoints/assistants/initializeClient.spec.js b/api/server/services/Endpoints/assistants/initializeClient.spec.js index 3a1e4692738..3879fc0ffce 100644 --- a/api/server/services/Endpoints/assistants/initializeClient.spec.js +++ b/api/server/services/Endpoints/assistants/initializeClient.spec.js @@ -1,12 +1,14 @@ // const OpenAI = require('openai'); const { HttpsProxyAgent } = require('https-proxy-agent'); -const { getUserKey, getUserKeyExpiry } = require('~/server/services/UserService'); +const { ErrorTypes } = require('librechat-data-provider'); +const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService'); const initializeClient = require('./initializeClient'); // const { OpenAIClient } = require('~/app'); jest.mock('~/server/services/UserService', () => ({ getUserKey: jest.fn(), getUserKeyExpiry: jest.fn(), + getUserKeyValues: jest.fn(), checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, })); @@ -52,9 +54,7 @@ describe('initializeClient', () => { process.env.ASSISTANTS_API_KEY = 'user_provided'; process.env.ASSISTANTS_BASE_URL = 'user_provided'; - getUserKey.mockResolvedValue( - JSON.stringify({ apiKey: 'user-api-key', baseURL: 'https://user.api.url' }), - ); + getUserKeyValues.mockResolvedValue({ apiKey: 'user-api-key', baseURL: 'https://user.api.url' }); getUserKeyExpiry.mockResolvedValue(isoString); const req = { user: { id: 'user123' }, app }; @@ -70,11 +70,24 @@ describe('initializeClient', () => { process.env.ASSISTANTS_API_KEY = 'user_provided'; getUserKey.mockResolvedValue('invalid-json'); getUserKeyExpiry.mockResolvedValue(isoString); + getUserKeyValues.mockImplementation(() => { + let userValues = getUserKey(); + try { + userValues = JSON.parse(userValues); + } catch (e) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.INVALID_USER_KEY, + }), + ); + } + return userValues; + }); const req = { user: { id: 'user123' } }; const res = {}; - await expect(initializeClient({ req, res })).rejects.toThrow(/Invalid JSON/); + await expect(initializeClient({ req, res })).rejects.toThrow(/invalid_user_key/); }); test('throws error if API key is not provided', async () => { diff --git a/api/server/services/Endpoints/custom/buildOptions.js b/api/server/services/Endpoints/custom/buildOptions.js index 3d937957323..83f8d387dd1 100644 --- a/api/server/services/Endpoints/custom/buildOptions.js +++ b/api/server/services/Endpoints/custom/buildOptions.js @@ -1,5 +1,15 @@ const buildOptions = (endpoint, parsedBody, endpointType) => { - const { chatGptLabel, promptPrefix, resendFiles, imageDetail, ...rest } = parsedBody; + const { + chatGptLabel, + promptPrefix, + maxContextTokens, + resendFiles, + imageDetail, + iconURL, + greeting, + spec, + ...rest + } = parsedBody; const endpointOption = { endpoint, endpointType, @@ -7,6 +17,10 @@ const buildOptions = (endpoint, parsedBody, endpointType) => { promptPrefix, resendFiles, imageDetail, + iconURL, + greeting, + spec, + maxContextTokens, modelOptions: { ...rest, }, diff --git a/api/server/services/Endpoints/custom/initializeClient.js b/api/server/services/Endpoints/custom/initializeClient.js index a80f5efaa7a..a5da7782822 100644 --- a/api/server/services/Endpoints/custom/initializeClient.js +++ b/api/server/services/Endpoints/custom/initializeClient.js @@ -1,11 +1,12 @@ const { CacheKeys, + ErrorTypes, envVarRegex, EModelEndpoint, FetchTokenConfig, extractEnvVariable, } = require('librechat-data-provider'); -const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); +const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); const getCustomConfig = require('~/server/services/Config/getCustomConfig'); const { fetchModels } = require('~/server/services/ModelService'); const getLogStores = require('~/cache/getLogStores'); @@ -48,21 +49,29 @@ const initializeClient = async ({ req, res, endpointOption }) => { let userValues = null; if (expiresAt && (userProvidesKey || userProvidesURL)) { - checkUserKeyExpiry( - expiresAt, - `Your API values for ${endpoint} have expired. Please configure them again.`, - ); - userValues = await getUserKey({ userId: req.user.id, name: endpoint }); - try { - userValues = JSON.parse(userValues); - } catch (e) { - throw new Error(`Invalid JSON provided for ${endpoint} user values.`); - } + checkUserKeyExpiry(expiresAt, endpoint); + userValues = await getUserKeyValues({ userId: req.user.id, name: endpoint }); } let apiKey = userProvidesKey ? userValues?.apiKey : CUSTOM_API_KEY; let baseURL = userProvidesURL ? userValues?.baseURL : CUSTOM_BASE_URL; + if (userProvidesKey & !apiKey) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.NO_USER_KEY, + }), + ); + } + + if (userProvidesURL && !baseURL) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.NO_BASE_URL, + }), + ); + } + if (!apiKey) { throw new Error(`${endpoint} API key not provided.`); } diff --git a/api/server/services/Endpoints/google/buildOptions.js b/api/server/services/Endpoints/google/buildOptions.js index 0f00bf82d07..0d26e23c33a 100644 --- a/api/server/services/Endpoints/google/buildOptions.js +++ b/api/server/services/Endpoints/google/buildOptions.js @@ -1,10 +1,13 @@ const buildOptions = (endpoint, parsedBody) => { - const { examples, modelLabel, promptPrefix, ...rest } = parsedBody; + const { examples, modelLabel, promptPrefix, iconURL, greeting, spec, ...rest } = parsedBody; const endpointOption = { examples, endpoint, modelLabel, promptPrefix, + iconURL, + greeting, + spec, modelOptions: { ...rest, }, diff --git a/api/server/services/Endpoints/google/initializeClient.js b/api/server/services/Endpoints/google/initializeClient.js index 4e97c82ab60..d2099edcf56 100644 --- a/api/server/services/Endpoints/google/initializeClient.js +++ b/api/server/services/Endpoints/google/initializeClient.js @@ -1,6 +1,6 @@ -const { GoogleClient } = require('~/app'); const { EModelEndpoint, AuthKeys } = require('librechat-data-provider'); const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); +const { GoogleClient } = require('~/app'); const initializeClient = async ({ req, res, endpointOption }) => { const { GOOGLE_KEY, GOOGLE_REVERSE_PROXY, PROXY } = process.env; @@ -9,10 +9,7 @@ const initializeClient = async ({ req, res, endpointOption }) => { let userKey = null; if (expiresAt && isUserProvided) { - checkUserKeyExpiry( - expiresAt, - 'Your Google Credentials have expired. Please provide your Service Account JSON Key or Generative Language API Key again.', - ); + checkUserKeyExpiry(expiresAt, EModelEndpoint.google); userKey = await getUserKey({ userId: req.user.id, name: EModelEndpoint.google }); } diff --git a/api/server/services/Endpoints/google/initializeClient.spec.js b/api/server/services/Endpoints/google/initializeClient.spec.js index e39e51b8571..b46a5356185 100644 --- a/api/server/services/Endpoints/google/initializeClient.spec.js +++ b/api/server/services/Endpoints/google/initializeClient.spec.js @@ -1,15 +1,10 @@ // file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets - +const { getUserKey } = require('~/server/services/UserService'); const initializeClient = require('./initializeClient'); const { GoogleClient } = require('~/app'); -const { checkUserKeyExpiry, getUserKey } = require('../../UserService'); -jest.mock('../../UserService', () => ({ - checkUserKeyExpiry: jest.fn().mockImplementation((expiresAt, errorMessage) => { - if (new Date(expiresAt) < new Date()) { - throw new Error(errorMessage); - } - }), +jest.mock('~/server/services/UserService', () => ({ + checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, getUserKey: jest.fn().mockImplementation(() => ({})), })); @@ -74,13 +69,8 @@ describe('google/initializeClient', () => { }; const res = {}; const endpointOption = { modelOptions: { model: 'default-model' } }; - - checkUserKeyExpiry.mockImplementation((expiresAt, errorMessage) => { - throw new Error(errorMessage); - }); - await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( - /Your Google Credentials have expired/, + /expired_user_key/, ); }); }); diff --git a/api/server/services/Endpoints/gptPlugins/buildOptions.js b/api/server/services/Endpoints/gptPlugins/buildOptions.js index ebf4116ec3a..ec098e9e3be 100644 --- a/api/server/services/Endpoints/gptPlugins/buildOptions.js +++ b/api/server/services/Endpoints/gptPlugins/buildOptions.js @@ -4,25 +4,26 @@ const buildOptions = (endpoint, parsedBody) => { promptPrefix, agentOptions, tools, - model, - temperature, - top_p, - presence_penalty, - frequency_penalty, + iconURL, + greeting, + spec, + maxContextTokens, + ...modelOptions } = parsedBody; const endpointOption = { endpoint, - tools: tools.map((tool) => tool.pluginKey) ?? [], + tools: + tools + .map((tool) => tool?.pluginKey ?? tool) + .filter((toolName) => typeof toolName === 'string') ?? [], chatGptLabel, promptPrefix, agentOptions, - modelOptions: { - model, - temperature, - top_p, - presence_penalty, - frequency_penalty, - }, + iconURL, + greeting, + spec, + maxContextTokens, + modelOptions, }; return endpointOption; diff --git a/api/server/services/Endpoints/gptPlugins/initializeClient.js b/api/server/services/Endpoints/gptPlugins/initializeClient.js index 2920a589176..312b23eb67a 100644 --- a/api/server/services/Endpoints/gptPlugins/initializeClient.js +++ b/api/server/services/Endpoints/gptPlugins/initializeClient.js @@ -3,7 +3,7 @@ const { mapModelToAzureConfig, resolveHeaders, } = require('librechat-data-provider'); -const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); +const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); const { isEnabled, isUserProvided } = require('~/server/utils'); const { getAzureCredentials } = require('~/utils'); const { PluginsClient } = require('~/app'); @@ -49,18 +49,8 @@ const initializeClient = async ({ req, res, endpointOption }) => { let userValues = null; if (expiresAt && (userProvidesKey || userProvidesURL)) { - checkUserKeyExpiry( - expiresAt, - 'Your OpenAI API values have expired. Please provide them again.', - ); - userValues = await getUserKey({ userId: req.user.id, name: endpoint }); - try { - userValues = JSON.parse(userValues); - } catch (e) { - throw new Error( - `Invalid JSON provided for ${endpoint} user values. Please provide them again.`, - ); - } + checkUserKeyExpiry(expiresAt, endpoint); + userValues = await getUserKeyValues({ userId: req.user.id, name: endpoint }); } let apiKey = userProvidesKey ? userValues?.apiKey : credentials[endpoint]; diff --git a/api/server/services/Endpoints/gptPlugins/initializeClient.spec.js b/api/server/services/Endpoints/gptPlugins/initializeClient.spec.js index 280acf5aadb..2dc5bc0653e 100644 --- a/api/server/services/Endpoints/gptPlugins/initializeClient.spec.js +++ b/api/server/services/Endpoints/gptPlugins/initializeClient.spec.js @@ -1,12 +1,13 @@ // gptPlugins/initializeClient.spec.js -const { EModelEndpoint, validateAzureGroups } = require('librechat-data-provider'); -const { getUserKey } = require('~/server/services/UserService'); +const { EModelEndpoint, ErrorTypes, validateAzureGroups } = require('librechat-data-provider'); +const { getUserKey, getUserKeyValues } = require('~/server/services/UserService'); const initializeClient = require('./initializeClient'); const { PluginsClient } = require('~/app'); // Mock getUserKey since it's the only function we want to mock jest.mock('~/server/services/UserService', () => ({ getUserKey: jest.fn(), + getUserKeyValues: jest.fn(), checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, })); @@ -205,7 +206,7 @@ describe('gptPlugins/initializeClient', () => { const res = {}; const endpointOption = { modelOptions: { model: 'default-model' } }; - getUserKey.mockResolvedValue(JSON.stringify({ apiKey: 'test-user-provided-openai-api-key' })); + getUserKeyValues.mockResolvedValue({ apiKey: 'test-user-provided-openai-api-key' }); const { openAIApiKey } = await initializeClient({ req, res, endpointOption }); @@ -225,14 +226,12 @@ describe('gptPlugins/initializeClient', () => { const res = {}; const endpointOption = { modelOptions: { model: 'test-model' } }; - getUserKey.mockResolvedValue( - JSON.stringify({ - apiKey: JSON.stringify({ - azureOpenAIApiKey: 'test-user-provided-azure-api-key', - azureOpenAIApiDeploymentName: 'test-deployment', - }), + getUserKeyValues.mockResolvedValue({ + apiKey: JSON.stringify({ + azureOpenAIApiKey: 'test-user-provided-azure-api-key', + azureOpenAIApiDeploymentName: 'test-deployment', }), - ); + }); const { azure } = await initializeClient({ req, res, endpointOption }); @@ -251,7 +250,9 @@ describe('gptPlugins/initializeClient', () => { const res = {}; const endpointOption = { modelOptions: { model: 'default-model' } }; - await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(/Your OpenAI API/); + await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( + /expired_user_key/, + ); }); test('should throw an error if the user-provided Azure key is invalid JSON', async () => { @@ -268,9 +269,22 @@ describe('gptPlugins/initializeClient', () => { // Simulate an invalid JSON string returned from getUserKey getUserKey.mockResolvedValue('invalid-json'); + getUserKeyValues.mockImplementation(() => { + let userValues = getUserKey(); + try { + userValues = JSON.parse(userValues); + } catch (e) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.INVALID_USER_KEY, + }), + ); + } + return userValues; + }); await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( - /Invalid JSON provided/, + /invalid_user_key/, ); }); @@ -305,9 +319,22 @@ describe('gptPlugins/initializeClient', () => { // Mock getUserKey to return a non-JSON string getUserKey.mockResolvedValue('not-a-json'); + getUserKeyValues.mockImplementation(() => { + let userValues = getUserKey(); + try { + userValues = JSON.parse(userValues); + } catch (e) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.INVALID_USER_KEY, + }), + ); + } + return userValues; + }); await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( - /Invalid JSON provided for openAI user values/, + /invalid_user_key/, ); }); @@ -369,9 +396,10 @@ describe('gptPlugins/initializeClient', () => { const res = {}; const endpointOption = {}; - getUserKey.mockResolvedValue( - JSON.stringify({ apiKey: 'test', baseURL: 'https://user-provided-url.com' }), - ); + getUserKeyValues.mockResolvedValue({ + apiKey: 'test', + baseURL: 'https://user-provided-url.com', + }); const result = await initializeClient({ req, res, endpointOption }); diff --git a/api/server/services/Endpoints/openAI/buildOptions.js b/api/server/services/Endpoints/openAI/buildOptions.js index 0b1fb3eabd8..1a6ebea4b69 100644 --- a/api/server/services/Endpoints/openAI/buildOptions.js +++ b/api/server/services/Endpoints/openAI/buildOptions.js @@ -1,11 +1,25 @@ const buildOptions = (endpoint, parsedBody) => { - const { chatGptLabel, promptPrefix, resendFiles, imageDetail, ...rest } = parsedBody; + const { + chatGptLabel, + promptPrefix, + maxContextTokens, + resendFiles, + imageDetail, + iconURL, + greeting, + spec, + ...rest + } = parsedBody; const endpointOption = { endpoint, chatGptLabel, promptPrefix, resendFiles, imageDetail, + iconURL, + greeting, + spec, + maxContextTokens, modelOptions: { ...rest, }, diff --git a/api/server/services/Endpoints/openAI/initializeClient.js b/api/server/services/Endpoints/openAI/initializeClient.js index 10a541526bd..9a3a5c41894 100644 --- a/api/server/services/Endpoints/openAI/initializeClient.js +++ b/api/server/services/Endpoints/openAI/initializeClient.js @@ -1,9 +1,10 @@ const { + ErrorTypes, EModelEndpoint, resolveHeaders, mapModelToAzureConfig, } = require('librechat-data-provider'); -const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); +const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); const { isEnabled, isUserProvided } = require('~/server/utils'); const { getAzureCredentials } = require('~/utils'); const { OpenAIClient } = require('~/app'); @@ -36,18 +37,8 @@ const initializeClient = async ({ req, res, endpointOption }) => { let userValues = null; if (expiresAt && (userProvidesKey || userProvidesURL)) { - checkUserKeyExpiry( - expiresAt, - 'Your OpenAI API values have expired. Please provide them again.', - ); - userValues = await getUserKey({ userId: req.user.id, name: endpoint }); - try { - userValues = JSON.parse(userValues); - } catch (e) { - throw new Error( - `Invalid JSON provided for ${endpoint} user values. Please provide them again.`, - ); - } + checkUserKeyExpiry(expiresAt, endpoint); + userValues = await getUserKeyValues({ userId: req.user.id, name: endpoint }); } let apiKey = userProvidesKey ? userValues?.apiKey : credentials[endpoint]; @@ -99,8 +90,16 @@ const initializeClient = async ({ req, res, endpointOption }) => { apiKey = clientOptions.azure.azureOpenAIApiKey; } + if (userProvidesKey & !apiKey) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.NO_USER_KEY, + }), + ); + } + if (!apiKey) { - throw new Error(`${endpoint} API key not provided. Please provide it again.`); + throw new Error(`${endpoint} API Key not provided.`); } const client = new OpenAIClient(apiKey, clientOptions); diff --git a/api/server/services/Endpoints/openAI/initializeClient.spec.js b/api/server/services/Endpoints/openAI/initializeClient.spec.js index 1a53f95b3de..0988a0fcb79 100644 --- a/api/server/services/Endpoints/openAI/initializeClient.spec.js +++ b/api/server/services/Endpoints/openAI/initializeClient.spec.js @@ -1,11 +1,12 @@ -const { EModelEndpoint, validateAzureGroups } = require('librechat-data-provider'); -const { getUserKey } = require('~/server/services/UserService'); +const { EModelEndpoint, ErrorTypes, validateAzureGroups } = require('librechat-data-provider'); +const { getUserKey, getUserKeyValues } = require('~/server/services/UserService'); const initializeClient = require('./initializeClient'); const { OpenAIClient } = require('~/app'); // Mock getUserKey since it's the only function we want to mock jest.mock('~/server/services/UserService', () => ({ getUserKey: jest.fn(), + getUserKeyValues: jest.fn(), checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry, })); @@ -200,7 +201,9 @@ describe('initializeClient', () => { const res = {}; const endpointOption = {}; - await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(/Your OpenAI API/); + await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( + /expired_user_key/, + ); }); test('should throw an error if no API keys are provided in the environment', async () => { @@ -217,7 +220,7 @@ describe('initializeClient', () => { const endpointOption = {}; await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( - `${EModelEndpoint.openAI} API key not provided.`, + `${EModelEndpoint.openAI} API Key not provided.`, ); }); @@ -241,7 +244,7 @@ describe('initializeClient', () => { process.env.OPENAI_API_KEY = 'user_provided'; // Mock getUserKey to return the expected key - getUserKey.mockResolvedValue(JSON.stringify({ apiKey: 'test-user-provided-openai-api-key' })); + getUserKeyValues.mockResolvedValue({ apiKey: 'test-user-provided-openai-api-key' }); // Call the initializeClient function const result = await initializeClient({ req, res, endpointOption }); @@ -266,7 +269,9 @@ describe('initializeClient', () => { // Mock getUserKey to return an invalid key getUserKey.mockResolvedValue(invalidKey); - await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow(/Your OpenAI API/); + await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( + /expired_user_key/, + ); }); test('should throw an error when user-provided values are not valid JSON', async () => { @@ -281,9 +286,22 @@ describe('initializeClient', () => { // Mock getUserKey to return a non-JSON string getUserKey.mockResolvedValue('not-a-json'); + getUserKeyValues.mockImplementation(() => { + let userValues = getUserKey(); + try { + userValues = JSON.parse(userValues); + } catch (e) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.INVALID_USER_KEY, + }), + ); + } + return userValues; + }); await expect(initializeClient({ req, res, endpointOption })).rejects.toThrow( - /Invalid JSON provided for openAI user values/, + /invalid_user_key/, ); }); @@ -347,9 +365,10 @@ describe('initializeClient', () => { const res = {}; const endpointOption = {}; - getUserKey.mockResolvedValue( - JSON.stringify({ apiKey: 'test', baseURL: 'https://user-provided-url.com' }), - ); + getUserKeyValues.mockResolvedValue({ + apiKey: 'test', + baseURL: 'https://user-provided-url.com', + }); const result = await initializeClient({ req, res, endpointOption }); diff --git a/api/server/services/Files/Firebase/images.js b/api/server/services/Files/Firebase/images.js index f06718063c9..7345f30df16 100644 --- a/api/server/services/Files/Firebase/images.js +++ b/api/server/services/Files/Firebase/images.js @@ -8,7 +8,7 @@ const { updateFile } = require('~/models/File'); const { logger } = require('~/config'); /** - * Converts an image file to the WebP format. The function first resizes the image based on the specified + * Converts an image file to the target format. The function first resizes the image based on the specified * resolution. * * @param {Object} params - The params object. @@ -21,7 +21,7 @@ const { logger } = require('~/config'); * * @returns {Promise<{ filepath: string, bytes: number, width: number, height: number}>} * A promise that resolves to an object containing: - * - filepath: The path where the converted WebP image is saved. + * - filepath: The path where the converted image is saved. * - bytes: The size of the converted image in bytes. * - width: The width of the converted image. * - height: The height of the converted image. @@ -39,15 +39,16 @@ async function uploadImageToFirebase({ req, file, file_id, endpoint, resolution let webPBuffer; let fileName = `${file_id}__${path.basename(inputFilePath)}`; - if (extension.toLowerCase() === '.webp') { + const targetExtension = `.${req.app.locals.imageOutputType}`; + if (extension.toLowerCase() === targetExtension) { webPBuffer = resizedBuffer; } else { - webPBuffer = await sharp(resizedBuffer).toFormat('webp').toBuffer(); + webPBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); // Replace or append the correct extension const extRegExp = new RegExp(path.extname(fileName) + '$'); - fileName = fileName.replace(extRegExp, '.webp'); + fileName = fileName.replace(extRegExp, targetExtension); if (!path.extname(fileName)) { - fileName += '.webp'; + fileName += targetExtension; } } @@ -79,7 +80,7 @@ async function prepareImageURL(req, file) { * If the 'manual' flag is set to 'true', it also updates the user's avatar URL in the database. * * @param {object} params - The parameters object. - * @param {Buffer} params.buffer - The Buffer containing the avatar image in WebP format. + * @param {Buffer} params.buffer - The Buffer containing the avatar image. * @param {string} params.userId - The user ID. * @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false'). * @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar. diff --git a/api/server/services/Files/Local/images.js b/api/server/services/Files/Local/images.js index 4d5b9565f1f..13055053811 100644 --- a/api/server/services/Files/Local/images.js +++ b/api/server/services/Files/Local/images.js @@ -6,11 +6,11 @@ const { updateUser } = require('~/models/userMethods'); const { updateFile } = require('~/models/File'); /** - * Converts an image file to the WebP format. The function first resizes the image based on the specified + * Converts an image file to the target format. The function first resizes the image based on the specified * resolution. * - * If the original image is already in WebP format, it writes the resized image back. Otherwise, - * it converts the image to WebP format before saving. + * If the original image is already in target format, it writes the resized image back. Otherwise, + * it converts the image to target format before saving. * * The original image is deleted after conversion. * @param {Object} params - The params object. @@ -24,7 +24,7 @@ const { updateFile } = require('~/models/File'); * * @returns {Promise<{ filepath: string, bytes: number, width: number, height: number}>} * A promise that resolves to an object containing: - * - filepath: The path where the converted WebP image is saved. + * - filepath: The path where the converted image is saved. * - bytes: The size of the converted image in bytes. * - width: The width of the converted image. * - height: The height of the converted image. @@ -48,16 +48,17 @@ async function uploadLocalImage({ req, file, file_id, endpoint, resolution = 'hi const fileName = `${file_id}__${path.basename(inputFilePath)}`; const newPath = path.join(userPath, fileName); + const targetExtension = `.${req.app.locals.imageOutputType}`; - if (extension.toLowerCase() === '.webp') { + if (extension.toLowerCase() === targetExtension) { const bytes = Buffer.byteLength(resizedBuffer); await fs.promises.writeFile(newPath, resizedBuffer); const filepath = path.posix.join('/', 'images', req.user.id, path.basename(newPath)); return { filepath, bytes, width, height }; } - const outputFilePath = newPath.replace(extension, '.webp'); - const data = await sharp(resizedBuffer).toFormat('webp').toBuffer(); + const outputFilePath = newPath.replace(extension, targetExtension); + const data = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); await fs.promises.writeFile(outputFilePath, data); const bytes = Buffer.byteLength(data); const filepath = path.posix.join('/', 'images', req.user.id, path.basename(outputFilePath)); @@ -109,7 +110,7 @@ async function prepareImagesLocal(req, file) { * If the 'manual' flag is set to 'true', it also updates the user's avatar URL in the database. * * @param {object} params - The parameters object. - * @param {Buffer} params.buffer - The Buffer containing the avatar image in WebP format. + * @param {Buffer} params.buffer - The Buffer containing the avatar image. * @param {string} params.userId - The user ID. * @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false'). * @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar. diff --git a/api/server/services/Files/images/avatar.js b/api/server/services/Files/images/avatar.js index 8f4f65b8e29..3c1068a453e 100644 --- a/api/server/services/Files/images/avatar.js +++ b/api/server/services/Files/images/avatar.js @@ -1,15 +1,17 @@ const sharp = require('sharp'); const fs = require('fs').promises; const fetch = require('node-fetch'); +const { EImageOutputType } = require('librechat-data-provider'); const { resizeAndConvert } = require('./resize'); const { logger } = require('~/config'); /** * Uploads an avatar image for a user. This function can handle various types of input (URL, Buffer, or File object), - * processes the image to a square format, converts it to WebP format, and returns the resized buffer. + * processes the image to a square format, converts it to target format, and returns the resized buffer. * * @param {Object} params - The parameters object. * @param {string} params.userId - The unique identifier of the user for whom the avatar is being uploaded. + * @param {string} options.desiredFormat - The desired output format of the image. * @param {(string|Buffer|File)} params.input - The input representing the avatar image. Can be a URL (string), * a Buffer, or a File object. * @@ -19,7 +21,7 @@ const { logger } = require('~/config'); * @throws {Error} Throws an error if the user ID is undefined, the input type is invalid, the image fetching fails, * or any other error occurs during the processing. */ -async function resizeAvatar({ userId, input }) { +async function resizeAvatar({ userId, input, desiredFormat = EImageOutputType.PNG }) { try { if (userId === undefined) { throw new Error('User ID is undefined'); @@ -53,7 +55,10 @@ async function resizeAvatar({ userId, input }) { }) .toBuffer(); - const { buffer } = await resizeAndConvert(squaredBuffer); + const { buffer } = await resizeAndConvert({ + inputBuffer: squaredBuffer, + desiredFormat, + }); return buffer; } catch (error) { logger.error('Error uploading the avatar:', error); diff --git a/api/server/services/Files/images/convert.js b/api/server/services/Files/images/convert.js index 744e591717f..4e7ab75d445 100644 --- a/api/server/services/Files/images/convert.js +++ b/api/server/services/Files/images/convert.js @@ -6,7 +6,7 @@ const { getStrategyFunctions } = require('../strategies'); const { logger } = require('~/config'); /** - * Converts an image file or buffer to WebP format with specified resolution. + * Converts an image file or buffer to target output type with specified resolution. * * @param {Express.Request} req - The request object, containing user and app configuration data. * @param {Buffer | Express.Multer.File} file - The file object, containing either a path or a buffer. @@ -15,7 +15,7 @@ const { logger } = require('~/config'); * @returns {Promise<{filepath: string, bytes: number, width: number, height: number}>} An object containing the path, size, and dimensions of the converted image. * @throws Throws an error if there is an issue during the conversion process. */ -async function convertToWebP(req, file, resolution = 'high', basename = '') { +async function convertImage(req, file, resolution = 'high', basename = '') { try { let inputBuffer; let outputBuffer; @@ -38,13 +38,13 @@ async function convertToWebP(req, file, resolution = 'high', basename = '') { height, } = await resizeImageBuffer(inputBuffer, resolution); - // Check if the file is already in WebP format - // If it isn't, convert it: - if (extension === '.webp') { + // Check if the file is already in target format; if it isn't, convert it: + const targetExtension = `.${req.app.locals.imageOutputType}`; + if (extension === targetExtension) { outputBuffer = resizedBuffer; } else { - outputBuffer = await sharp(resizedBuffer).toFormat('webp').toBuffer(); - extension = '.webp'; + outputBuffer = await sharp(resizedBuffer).toFormat(req.app.locals.imageOutputType).toBuffer(); + extension = targetExtension; } // Generate a new filename for the output file @@ -67,4 +67,4 @@ async function convertToWebP(req, file, resolution = 'high', basename = '') { } } -module.exports = { convertToWebP }; +module.exports = { convertImage }; diff --git a/api/server/services/Files/images/encode.js b/api/server/services/Files/images/encode.js index ade39ac2e72..4edb0bd56ce 100644 --- a/api/server/services/Files/images/encode.js +++ b/api/server/services/Files/images/encode.js @@ -1,5 +1,5 @@ const axios = require('axios'); -const { EModelEndpoint, FileSources } = require('librechat-data-provider'); +const { EModelEndpoint, FileSources, VisionModes } = require('librechat-data-provider'); const { getStrategyFunctions } = require('../strategies'); const { logger } = require('~/config'); @@ -23,18 +23,27 @@ async function fetchImageToBase64(url) { } } -const base64Only = new Set([EModelEndpoint.google, EModelEndpoint.anthropic]); +const base64Only = new Set([EModelEndpoint.google, EModelEndpoint.anthropic, 'Ollama', 'ollama']); /** * Encodes and formats the given files. * @param {Express.Request} req - The request object. * @param {Array<MongoFile>} files - The array of files to encode and format. * @param {EModelEndpoint} [endpoint] - Optional: The endpoint for the image. + * @param {string} [mode] - Optional: The endpoint mode for the image. * @returns {Promise<Object>} - A promise that resolves to the result object containing the encoded images and file details. */ -async function encodeAndFormat(req, files, endpoint) { +async function encodeAndFormat(req, files, endpoint, mode) { const promises = []; const encodingMethods = {}; + const result = { + files: [], + image_urls: [], + }; + + if (!files || !files.length) { + return result; + } for (let file of files) { const source = file.source ?? FileSources.local; @@ -69,11 +78,6 @@ async function encodeAndFormat(req, files, endpoint) { /** @type {Array<[MongoFile, string]>} */ const formattedImages = await Promise.all(promises); - const result = { - files: [], - image_urls: [], - }; - for (const [file, imageContent] of formattedImages) { const fileMetadata = { type: file.type, @@ -98,12 +102,18 @@ async function encodeAndFormat(req, files, endpoint) { image_url: { url: imageContent.startsWith('http') ? imageContent - : `data:image/webp;base64,${imageContent}`, + : `data:${file.type};base64,${imageContent}`, detail, }, }; - if (endpoint && endpoint === EModelEndpoint.google) { + if (endpoint && endpoint === EModelEndpoint.google && mode === VisionModes.generative) { + delete imagePart.image_url; + imagePart.inlineData = { + mimeType: file.type, + data: imageContent, + }; + } else if (endpoint && endpoint === EModelEndpoint.google) { imagePart.image_url = imagePart.image_url.url; } else if (endpoint && endpoint === EModelEndpoint.anthropic) { imagePart.type = 'image'; diff --git a/api/server/services/Files/images/resize.js b/api/server/services/Files/images/resize.js index ac05ba99463..531c9a2c635 100644 --- a/api/server/services/Files/images/resize.js +++ b/api/server/services/Files/images/resize.js @@ -62,14 +62,20 @@ async function resizeImageBuffer(inputBuffer, resolution, endpoint) { } /** - * Resizes an image buffer to webp format as well as reduces by specified or default 150 px width. + * Resizes an image buffer to a specified format and width. * - * @param {Buffer} inputBuffer - The buffer of the image to be resized. - * @returns {Promise<{ buffer: Buffer, width: number, height: number, bytes: number }>} An object containing the resized image buffer, its size and dimensions. - * @throws Will throw an error if the resolution parameter is invalid. + * @param {Object} options - The options for resizing and converting the image. + * @param {Buffer} options.inputBuffer - The buffer of the image to be resized. + * @param {string} options.desiredFormat - The desired output format of the image. + * @param {number} [options.width=150] - The desired width of the image. Defaults to 150 pixels. + * @returns {Promise<{ buffer: Buffer, width: number, height: number, bytes: number }>} An object containing the resized image buffer, its size, and dimensions. + * @throws Will throw an error if the resolution or format parameters are invalid. */ -async function resizeAndConvert(inputBuffer, width = 150) { - const resizedBuffer = await sharp(inputBuffer).resize({ width }).toFormat('webp').toBuffer(); +async function resizeAndConvert({ inputBuffer, desiredFormat, width = 150 }) { + const resizedBuffer = await sharp(inputBuffer) + .resize({ width }) + .toFormat(desiredFormat) + .toBuffer(); const resizedMetadata = await sharp(resizedBuffer).metadata(); return { buffer: resizedBuffer, diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index 66a5e454996..7f91d481ae4 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -12,7 +12,7 @@ const { hostImageIdSuffix, hostImageNamePrefix, } = require('librechat-data-provider'); -const { convertToWebP, resizeAndConvert } = require('~/server/services/Files/images'); +const { convertImage, resizeAndConvert } = require('~/server/services/Files/images'); const { initializeClient } = require('~/server/services/Endpoints/assistants'); const { createFile, updateFileUsage, deleteFiles } = require('~/models/File'); const { LB_QueueAsyncCall } = require('~/server/utils/queue'); @@ -207,7 +207,7 @@ const processImageFile = async ({ req, res, file, metadata }) => { filename: file.originalname, context: FileContext.message_attachment, source, - type: 'image/webp', + type: `image/${req.app.locals.imageOutputType}`, width, height, }, @@ -223,9 +223,9 @@ const processImageFile = async ({ req, res, file, metadata }) => { * @param {Object} params - The parameters object. * @param {Express.Request} params.req - The Express request object. * @param {FileContext} params.context - The context of the file (e.g., 'avatar', 'image_generation', etc.) - * @param {boolean} [params.resize=true] - Whether to resize and convert the image to WebP. Default is `true`. + * @param {boolean} [params.resize=true] - Whether to resize and convert the image to target format. Default is `true`. * @param {{ buffer: Buffer, width: number, height: number, bytes: number, filename: string, type: string, file_id: string }} [params.metadata] - Required metadata for the file if resize is false. - * @returns {Promise<{ filepath: string, filename: string, source: string, type: 'image/webp'}>} + * @returns {Promise<{ filepath: string, filename: string, source: string, type: string}>} */ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) => { const source = req.app.locals.fileStrategy; @@ -233,9 +233,14 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) let { buffer, width, height, bytes, filename, file_id, type } = metadata; if (resize) { file_id = v4(); - type = 'image/webp'; - ({ buffer, width, height, bytes } = await resizeAndConvert(req.file.buffer)); - filename = path.basename(req.file.originalname, path.extname(req.file.originalname)) + '.webp'; + type = `image/${req.app.locals.imageOutputType}`; + ({ buffer, width, height, bytes } = await resizeAndConvert({ + inputBuffer: buffer, + desiredFormat: req.app.locals.imageOutputType, + })); + filename = `${path.basename(req.file.originalname, path.extname(req.file.originalname))}.${ + req.app.locals.imageOutputType + }`; } const filepath = await saveBuffer({ userId: req.user.id, fileName: filename, buffer }); @@ -363,7 +368,7 @@ const processOpenAIFile = async ({ }; /** - * Process OpenAI image files, convert to webp, save and return file metadata. + * Process OpenAI image files, convert to target format, save and return file metadata. * @param {object} params - The params object. * @param {Express.Request} params.req - The Express request object. * @param {Buffer} params.buffer - The image buffer. @@ -375,12 +380,12 @@ const processOpenAIFile = async ({ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileExt }) => { const currentDate = new Date(); const formattedDate = currentDate.toISOString(); - const _file = await convertToWebP(req, buffer, 'high', `${file_id}${fileExt}`); + const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`); const file = { ..._file, usage: 1, user: req.user.id, - type: 'image/webp', + type: `image/${req.app.locals.imageOutputType}`, createdAt: formattedDate, updatedAt: formattedDate, source: req.app.locals.fileStrategy, @@ -509,6 +514,10 @@ function filterFile({ req, file, image }) { throw new Error('No file_id provided'); } + if (file.size === 0) { + throw new Error('Empty file uploaded'); + } + /* parse to validate api call, throws error on fail */ isUUID.parse(file_id); diff --git a/api/server/services/ModelService.js b/api/server/services/ModelService.js index 69c71629a4a..3c560b297b2 100644 --- a/api/server/services/ModelService.js +++ b/api/server/services/ModelService.js @@ -2,6 +2,7 @@ const axios = require('axios'); const { HttpsProxyAgent } = require('https-proxy-agent'); const { EModelEndpoint, defaultModels, CacheKeys } = require('librechat-data-provider'); const { extractBaseURL, inputSchema, processModelData, logAxiosError } = require('~/utils'); +const { OllamaClient } = require('~/app/clients/OllamaClient'); const getLogStores = require('~/cache/getLogStores'); const { openAIApiKey, userProvidedOpenAI } = require('./Config/EndpointService').config; @@ -41,6 +42,10 @@ const fetchModels = async ({ return models; } + if (name && name.toLowerCase().startsWith('ollama')) { + return await OllamaClient.fetchModels(baseURL); + } + try { const options = { headers: { @@ -87,6 +92,7 @@ const fetchModels = async ({ * @param {object} opts - The options for fetching the models. * @param {string} opts.user - The user ID to send to the API. * @param {boolean} [opts.azure=false] - Whether to fetch models from Azure. + * @param {boolean} [opts.assistants=false] - Whether to fetch models from Azure. * @param {boolean} [opts.plugins=false] - Whether to fetch models from the plugins. * @param {string[]} [_models=[]] - The models to use as a fallback. */ @@ -96,7 +102,10 @@ const fetchOpenAIModels = async (opts, _models = []) => { const openaiBaseURL = 'https://api.openai.com/v1'; let baseURL = openaiBaseURL; let reverseProxyUrl = process.env.OPENAI_REVERSE_PROXY; - if (opts.azure) { + + if (opts.assistants && process.env.ASSISTANTS_BASE_URL) { + reverseProxyUrl = process.env.ASSISTANTS_BASE_URL; + } else if (opts.azure) { return models; // const azure = getAzureCredentials(); // baseURL = (genAzureChatCompletion(azure)) @@ -191,10 +200,6 @@ const getOpenAIModels = async (opts) => { return models; } - if (opts.assistants) { - return models; - } - return await fetchOpenAIModels(opts, models); }; diff --git a/api/server/services/ModelService.spec.js b/api/server/services/ModelService.spec.js index 7c1d326fa1a..fc7c8b1079a 100644 --- a/api/server/services/ModelService.spec.js +++ b/api/server/services/ModelService.spec.js @@ -1,4 +1,5 @@ const axios = require('axios'); +const { logger } = require('~/config'); const { fetchModels, getOpenAIModels } = require('./ModelService'); jest.mock('~/utils', () => { @@ -256,3 +257,75 @@ describe('getOpenAIModels sorting behavior', () => { jest.clearAllMocks(); }); }); + +describe('fetchModels with Ollama specific logic', () => { + const mockOllamaData = { + data: { + models: [{ name: 'Ollama-Base' }, { name: 'Ollama-Advanced' }], + }, + }; + + beforeEach(() => { + axios.get.mockResolvedValue(mockOllamaData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch Ollama models when name starts with "ollama"', async () => { + const models = await fetchModels({ + user: 'user789', + apiKey: 'testApiKey', + baseURL: 'https://api.ollama.test.com', + name: 'OllamaAPI', + }); + + expect(models).toEqual(['Ollama-Base', 'Ollama-Advanced']); + expect(axios.get).toHaveBeenCalledWith('https://api.ollama.test.com/api/tags'); // Adjusted to expect only one argument if no options are passed + }); + + it('should handle errors gracefully when fetching Ollama models fails', async () => { + axios.get.mockRejectedValue(new Error('Network error')); + const models = await fetchModels({ + user: 'user789', + apiKey: 'testApiKey', + baseURL: 'https://api.ollama.test.com', + name: 'OllamaAPI', + }); + + expect(models).toEqual([]); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should return an empty array if no baseURL is provided', async () => { + const models = await fetchModels({ + user: 'user789', + apiKey: 'testApiKey', + name: 'OllamaAPI', + }); + expect(models).toEqual([]); + }); + + it('should not fetch Ollama models if the name does not start with "ollama"', async () => { + // Mock axios to return a different set of models for non-Ollama API calls + axios.get.mockResolvedValue({ + data: { + data: [{ id: 'model-1' }, { id: 'model-2' }], + }, + }); + + const models = await fetchModels({ + user: 'user789', + apiKey: 'testApiKey', + baseURL: 'https://api.test.com', + name: 'TestAPI', + }); + + expect(models).toEqual(['model-1', 'model-2']); + expect(axios.get).toHaveBeenCalledWith( + 'https://api.test.com/models', // Ensure the correct API endpoint is called + expect.any(Object), // Ensuring some object (headers, etc.) is passed + ); + }); +}); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 81c6ca42830..149afc5df05 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -20,6 +20,14 @@ const { redactMessage } = require('~/config/parsers'); const { sleep } = require('~/server/utils'); const { logger } = require('~/config'); +const filteredTools = new Set([ + 'ChatTool.js', + 'CodeSherpa.js', + 'CodeSherpaTools.js', + 'E2BTools.js', + 'extractionChain.js', +]); + /** * Loads and formats tools from the specified tool directory. * @@ -30,57 +38,66 @@ const { logger } = require('~/config'); * * @param {object} params - The parameters for the function. * @param {string} params.directory - The directory path where the tools are located. - * @param {Set<string>} [params.filter=new Set()] - A set of filenames to exclude from loading. + * @param {Array<string>} [params.adminFilter=[]] - Array of admin-defined tool keys to exclude from loading. + * @param {Array<string>} [params.adminIncluded=[]] - Array of admin-defined tool keys to include from loading. * @returns {Record<string, FunctionTool>} An object mapping each tool's plugin key to its instance. */ -function loadAndFormatTools({ directory, filter = new Set() }) { +function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] }) { + const filter = new Set([...adminFilter, ...filteredTools]); + const included = new Set(adminIncluded); const tools = []; /* Structured Tools Directory */ const files = fs.readdirSync(directory); + if (included.size > 0 && adminFilter.length > 0) { + logger.warn( + 'Both `includedTools` and `filteredTools` are defined; `filteredTools` will be ignored.', + ); + } + for (const file of files) { - if (file.endsWith('.js') && !filter.has(file)) { - const filePath = path.join(directory, file); - let ToolClass = null; - try { - ToolClass = require(filePath); - } catch (error) { - logger.error(`[loadAndFormatTools] Error loading tool from ${filePath}:`, error); - continue; - } + const filePath = path.join(directory, file); + if (!file.endsWith('.js') || (filter.has(file) && included.size === 0)) { + continue; + } - if (!ToolClass) { - continue; - } + let ToolClass = null; + try { + ToolClass = require(filePath); + } catch (error) { + logger.error(`[loadAndFormatTools] Error loading tool from ${filePath}:`, error); + continue; + } - if (ToolClass.prototype instanceof StructuredTool) { - /** @type {StructuredTool | null} */ - let toolInstance = null; - try { - toolInstance = new ToolClass({ override: true }); - } catch (error) { - logger.error( - `[loadAndFormatTools] Error initializing \`${file}\` tool; if it requires authentication, is the \`override\` field configured?`, - error, - ); - continue; - } + if (!ToolClass || !(ToolClass.prototype instanceof StructuredTool)) { + continue; + } - if (!toolInstance) { - continue; - } + if (included.size > 0 && !included.has(file)) { + continue; + } - const formattedTool = formatToOpenAIAssistantTool(toolInstance); - tools.push(formattedTool); - } + let toolInstance = null; + try { + toolInstance = new ToolClass({ override: true }); + } catch (error) { + logger.error( + `[loadAndFormatTools] Error initializing \`${file}\` tool; if it requires authentication, is the \`override\` field configured?`, + error, + ); + continue; + } + + if (!toolInstance) { + continue; } + + const formattedTool = formatToOpenAIAssistantTool(toolInstance); + tools.push(formattedTool); } - /** - * Basic Tools; schema: { input: string } - */ + /** Basic Tools; schema: { input: string } */ const basicToolInstances = [new Calculator()]; - for (const toolInstance of basicToolInstances) { const formattedTool = formatToOpenAIAssistantTool(toolInstance); tools.push(formattedTool); @@ -274,9 +291,16 @@ async function processRequiredActions(client, requiredActions) { })) ?? []; } - const actionSet = actionSets.find((action) => - currentAction.tool.includes(domainParser(client.req, action.metadata.domain, true)), - ); + let actionSet = null; + let currentDomain = ''; + for (let action of actionSets) { + const domain = await domainParser(client.req, action.metadata.domain, true); + if (currentAction.tool.includes(domain)) { + currentDomain = domain; + actionSet = action; + break; + } + } if (!actionSet) { // TODO: try `function` if no action set is found @@ -298,10 +322,8 @@ async function processRequiredActions(client, requiredActions) { builders = requestBuilders; } - const functionName = currentAction.tool.replace( - `${actionDelimiter}${domainParser(client.req, actionSet.metadata.domain, true)}`, - '', - ); + const functionName = currentAction.tool.replace(`${actionDelimiter}${currentDomain}`, ''); + const requestBuilder = builders[functionName]; if (!requestBuilder) { diff --git a/api/server/services/UserService.js b/api/server/services/UserService.js index 4a9d3abe7b3..1b5907b87ec 100644 --- a/api/server/services/UserService.js +++ b/api/server/services/UserService.js @@ -1,7 +1,19 @@ -const { User, Key } = require('~/models'); +const { ErrorTypes } = require('librechat-data-provider'); const { encrypt, decrypt } = require('~/server/utils'); +const { User, Key } = require('~/models'); const { logger } = require('~/config'); +/** + * Updates the plugins for a user based on the action specified (install/uninstall). + * @async + * @param {Object} user - The user whose plugins are to be updated. + * @param {string} pluginKey - The key of the plugin to install or uninstall. + * @param {'install' | 'uninstall'} action - The action to perform, 'install' or 'uninstall'. + * @returns {Promise<Object>} The result of the update operation. + * @throws Logs the error internally if the update operation fails. + * @description This function updates the plugin array of a user document based on the specified action. + * It adds a plugin key to the plugins array for an 'install' action, and removes it for an 'uninstall' action. + */ const updateUserPluginsService = async (user, pluginKey, action) => { try { if (action === 'install') { @@ -21,14 +33,64 @@ const updateUserPluginsService = async (user, pluginKey, action) => { } }; +/** + * Retrieves and decrypts the key value for a given user identified by userId and identifier name. + * @param {Object} params - The parameters object. + * @param {string} params.userId - The unique identifier for the user. + * @param {string} params.name - The name associated with the key. + * @returns {Promise<string>} The decrypted key value. + * @throws {Error} Throws an error if the key is not found or if there is a problem during key retrieval. + * @description This function searches for a user's key in the database using their userId and name. + * If found, it decrypts the value of the key and returns it. If no key is found, it throws + * an error indicating that there is no user key available. + */ const getUserKey = async ({ userId, name }) => { const keyValue = await Key.findOne({ userId, name }).lean(); if (!keyValue) { - throw new Error('User-provided key not found'); + throw new Error( + JSON.stringify({ + type: ErrorTypes.NO_USER_KEY, + }), + ); } return decrypt(keyValue.value); }; +/** + * Retrieves, decrypts, and parses the key values for a given user identified by userId and name. + * @param {Object} params - The parameters object. + * @param {string} params.userId - The unique identifier for the user. + * @param {string} params.name - The name associated with the key. + * @returns {Promise<Record<string,string>>} The decrypted and parsed key values. + * @throws {Error} Throws an error if the key is invalid or if there is a problem during key value parsing. + * @description This function retrieves a user's encrypted key using their userId and name, decrypts it, + * and then attempts to parse the decrypted string into a JSON object. If the parsing fails, + * it throws an error indicating that the user key is invalid. + */ +const getUserKeyValues = async ({ userId, name }) => { + let userValues = await getUserKey({ userId, name }); + try { + userValues = JSON.parse(userValues); + } catch (e) { + throw new Error( + JSON.stringify({ + type: ErrorTypes.INVALID_USER_KEY, + }), + ); + } + return userValues; +}; + +/** + * Retrieves the expiry information of a user's key identified by userId and name. + * @async + * @param {Object} params - The parameters object. + * @param {string} params.userId - The unique identifier for the user. + * @param {string} params.name - The name associated with the key. + * @returns {Promise<{expiresAt: Date | null}>} The expiry date of the key or null if the key doesn't exist. + * @description This function fetches a user's key from the database using their userId and name and + * returns its expiry date. If the key is not found, it returns null for the expiry date. + */ const getUserKeyExpiry = async ({ userId, name }) => { const keyValue = await Key.findOne({ userId, name }).lean(); if (!keyValue) { @@ -37,6 +99,18 @@ const getUserKeyExpiry = async ({ userId, name }) => { return { expiresAt: keyValue.expiresAt }; }; +/** + * Updates or inserts a new key for a given user identified by userId and name, with a specified value and expiry date. + * @async + * @param {Object} params - The parameters object. + * @param {string} params.userId - The unique identifier for the user. + * @param {string} params.name - The name associated with the key. + * @param {string} params.value - The value to be encrypted and stored as the key's value. + * @param {Date} params.expiresAt - The expiry date for the key. + * @returns {Promise<Object>} The updated or newly inserted key document. + * @description This function either updates an existing user key or inserts a new one into the database, + * after encrypting the provided value. It sets the provided expiry date for the key. + */ const updateUserKey = async ({ userId, name, value, expiresAt }) => { const encryptedValue = encrypt(value); return await Key.findOneAndUpdate( @@ -51,6 +125,18 @@ const updateUserKey = async ({ userId, name, value, expiresAt }) => { ).lean(); }; +/** + * Deletes a key or all keys for a given user identified by userId, optionally based on a specified name. + * @async + * @param {Object} params - The parameters object. + * @param {string} params.userId - The unique identifier for the user. + * @param {string} [params.name] - The name associated with the key to delete. If not provided and all is true, deletes all keys. + * @param {boolean} [params.all=false] - Whether to delete all keys for the user. + * @returns {Promise<Object>} The result of the deletion operation. + * @description This function deletes a specific key or all keys for a user from the database. + * If a name is provided and all is false, it deletes only the key with that name. + * If all is true, it ignores the name and deletes all keys for the user. + */ const deleteUserKey = async ({ userId, name, all = false }) => { if (all) { return await Key.deleteMany({ userId }); @@ -59,11 +145,23 @@ const deleteUserKey = async ({ userId, name, all = false }) => { await Key.findOneAndDelete({ userId, name }).lean(); }; -const checkUserKeyExpiry = (expiresAt, message) => { +/** + * Checks if a user key has expired based on the provided expiration date and endpoint. + * If the key has expired, it throws an Error with details including the type of error, the expiration date, and the endpoint. + * + * @param {string} expiresAt - The expiration date of the user key in a format that can be parsed by the Date constructor. + * @param {string} endpoint - The endpoint associated with the user key to be checked. + * @throws {Error} Throws an error if the user key has expired. The error message is a stringified JSON object + * containing the type of error (`ErrorTypes.EXPIRED_USER_KEY`), the expiration date in the local string format, and the endpoint. + */ +const checkUserKeyExpiry = (expiresAt, endpoint) => { const expiresAtDate = new Date(expiresAt); if (expiresAtDate < new Date()) { - const expiryStr = `User-provided key expired at ${expiresAtDate.toLocaleString()}`; - const errorMessage = message ? `${message}\n${expiryStr}` : expiryStr; + const errorMessage = JSON.stringify({ + type: ErrorTypes.EXPIRED_USER_KEY, + expiredAt: expiresAtDate.toLocaleString(), + endpoint, + }); throw new Error(errorMessage); } }; @@ -71,6 +169,7 @@ const checkUserKeyExpiry = (expiresAt, message) => { module.exports = { updateUserPluginsService, getUserKey, + getUserKeyValues, getUserKeyExpiry, updateUserKey, deleteUserKey, diff --git a/api/server/services/isDomainAllowed.js b/api/server/services/isDomainAllowed.js new file mode 100644 index 00000000000..48e0747511e --- /dev/null +++ b/api/server/services/isDomainAllowed.js @@ -0,0 +1,24 @@ +const getCustomConfig = require('~/server/services/Config/getCustomConfig'); + +async function isDomainAllowed(email) { + if (!email) { + return false; + } + + const domain = email.split('@')[1]; + + if (!domain) { + return false; + } + + const customConfig = await getCustomConfig(); + if (!customConfig) { + return true; + } else if (!customConfig?.registration?.allowedDomains) { + return true; + } + + return customConfig.registration.allowedDomains.includes(domain); +} + +module.exports = isDomainAllowed; diff --git a/api/server/services/isDomainAllowed.spec.js b/api/server/services/isDomainAllowed.spec.js new file mode 100644 index 00000000000..b1cf03a5672 --- /dev/null +++ b/api/server/services/isDomainAllowed.spec.js @@ -0,0 +1,58 @@ +const getCustomConfig = require('~/server/services/Config/getCustomConfig'); +const isDomainAllowed = require('./isDomainAllowed'); + +jest.mock('~/server/services/Config/getCustomConfig', () => jest.fn()); + +describe('isDomainAllowed', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return false if email is falsy', async () => { + const email = ''; + const result = await isDomainAllowed(email); + expect(result).toBe(false); + }); + + it('should return false if domain is not present in the email', async () => { + const email = 'test'; + const result = await isDomainAllowed(email); + expect(result).toBe(false); + }); + + it('should return true if customConfig is not available', async () => { + const email = 'test@domain1.com'; + getCustomConfig.mockResolvedValue(null); + const result = await isDomainAllowed(email); + expect(result).toBe(true); + }); + + it('should return true if allowedDomains is not defined in customConfig', async () => { + const email = 'test@domain1.com'; + getCustomConfig.mockResolvedValue({}); + const result = await isDomainAllowed(email); + expect(result).toBe(true); + }); + + it('should return true if domain is included in the allowedDomains', async () => { + const email = 'user@domain1.com'; + getCustomConfig.mockResolvedValue({ + registration: { + allowedDomains: ['domain1.com', 'domain2.com'], + }, + }); + const result = await isDomainAllowed(email); + expect(result).toBe(true); + }); + + it('should return false if domain is not included in the allowedDomains', async () => { + const email = 'user@domain3.com'; + getCustomConfig.mockResolvedValue({ + registration: { + allowedDomains: ['domain1.com', 'domain2.com'], + }, + }); + const result = await isDomainAllowed(email); + expect(result).toBe(false); + }); +}); diff --git a/api/server/services/start/assistants.js b/api/server/services/start/assistants.js new file mode 100644 index 00000000000..dfef99e5914 --- /dev/null +++ b/api/server/services/start/assistants.js @@ -0,0 +1,46 @@ +const { + Capabilities, + EModelEndpoint, + assistantEndpointSchema, +} = require('librechat-data-provider'); +const { logger } = require('~/config'); + +/** + * Sets up the minimum, default Assistants configuration if Azure OpenAI Assistants option is enabled. + * @returns {Partial<TAssistantEndpoint>} The Assistants endpoint configuration. + */ +function azureAssistantsDefaults() { + return { + capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter], + }; +} + +/** + * Sets up the Assistants configuration from the config (`librechat.yaml`) file. + * @param {TCustomConfig} config - The loaded custom configuration. + * @param {Partial<TAssistantEndpoint>} [prevConfig] + * - The previously loaded assistants configuration from Azure OpenAI Assistants option. + * @returns {Partial<TAssistantEndpoint>} The Assistants endpoint configuration. + */ +function assistantsConfigSetup(config, prevConfig = {}) { + const assistantsConfig = config.endpoints[EModelEndpoint.assistants]; + const parsedConfig = assistantEndpointSchema.parse(assistantsConfig); + if (assistantsConfig.supportedIds?.length && assistantsConfig.excludedIds?.length) { + logger.warn( + `Both \`supportedIds\` and \`excludedIds\` are defined for the ${EModelEndpoint.assistants} endpoint; \`excludedIds\` field will be ignored.`, + ); + } + + return { + ...prevConfig, + retrievalModels: parsedConfig.retrievalModels, + disableBuilder: parsedConfig.disableBuilder, + pollIntervalMs: parsedConfig.pollIntervalMs, + supportedIds: parsedConfig.supportedIds, + capabilities: parsedConfig.capabilities, + excludedIds: parsedConfig.excludedIds, + timeoutMs: parsedConfig.timeoutMs, + }; +} + +module.exports = { azureAssistantsDefaults, assistantsConfigSetup }; diff --git a/api/server/services/start/azureOpenAI.js b/api/server/services/start/azureOpenAI.js new file mode 100644 index 00000000000..3b5c446204f --- /dev/null +++ b/api/server/services/start/azureOpenAI.js @@ -0,0 +1,54 @@ +const { + EModelEndpoint, + validateAzureGroups, + mapModelToAzureConfig, +} = require('librechat-data-provider'); +const { logger } = require('~/config'); + +/** + * Sets up the Azure OpenAI configuration from the config (`librechat.yaml`) file. + * @param {TCustomConfig} config - The loaded custom configuration. + * @returns {TAzureConfig} The Azure OpenAI configuration. + */ +function azureConfigSetup(config) { + const { groups, ...azureConfiguration } = config.endpoints[EModelEndpoint.azureOpenAI]; + /** @type {TAzureConfigValidationResult} */ + const { isValid, modelNames, modelGroupMap, groupMap, errors } = validateAzureGroups(groups); + + if (!isValid) { + const errorString = errors.join('\n'); + const errorMessage = 'Invalid Azure OpenAI configuration:\n' + errorString; + logger.error(errorMessage); + throw new Error(errorMessage); + } + + const assistantModels = []; + const assistantGroups = new Set(); + for (const modelName of modelNames) { + mapModelToAzureConfig({ modelName, modelGroupMap, groupMap }); + const groupName = modelGroupMap?.[modelName]?.group; + const modelGroup = groupMap?.[groupName]; + let supportsAssistants = modelGroup?.assistants || modelGroup?.[modelName]?.assistants; + if (supportsAssistants) { + assistantModels.push(modelName); + !assistantGroups.has(groupName) && assistantGroups.add(groupName); + } + } + + if (azureConfiguration.assistants && assistantModels.length === 0) { + throw new Error( + 'No Azure models are configured to support assistants. Please remove the `assistants` field or configure at least one model to support assistants.', + ); + } + + return { + modelNames, + modelGroupMap, + groupMap, + assistantModels, + assistantGroups: Array.from(assistantGroups), + ...azureConfiguration, + }; +} + +module.exports = { azureConfigSetup }; diff --git a/api/server/services/start/checks.js b/api/server/services/start/checks.js new file mode 100644 index 00000000000..2e7ba0a3d0a --- /dev/null +++ b/api/server/services/start/checks.js @@ -0,0 +1,110 @@ +const { + Constants, + deprecatedAzureVariables, + conflictingAzureVariables, +} = require('librechat-data-provider'); +const { logger } = require('~/config'); + +const secretDefaults = { + CREDS_KEY: 'f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0', + CREDS_IV: 'e2341419ec3dd3d19b13a1a87fafcbfb', + JWT_SECRET: '16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef', + JWT_REFRESH_SECRET: 'eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418', +}; + +/** + * Checks environment variables for default secrets and deprecated variables. + * Logs warnings for any default secret values being used and for usage of deprecated `GOOGLE_API_KEY`. + * Advises on replacing default secrets and updating deprecated variables. + */ +function checkVariables() { + let hasDefaultSecrets = false; + for (const [key, value] of Object.entries(secretDefaults)) { + if (process.env[key] === value) { + logger.warn(`Default value for ${key} is being used.`); + !hasDefaultSecrets && (hasDefaultSecrets = true); + } + } + + if (hasDefaultSecrets) { + logger.info('Please replace any default secret values.'); + logger.info(`\u200B + + For your convenience, use this tool to generate your own secret values: + https://www.librechat.ai/toolkit/creds_generator + + \u200B`); + } + + if (process.env.GOOGLE_API_KEY) { + logger.warn( + 'The `GOOGLE_API_KEY` environment variable is deprecated.\nPlease use the `GOOGLE_SEARCH_API_KEY` environment variable instead.', + ); + } + + if (process.env.OPENROUTER_API_KEY) { + logger.warn( + `The \`OPENROUTER_API_KEY\` environment variable is deprecated and its functionality will be removed soon. + Use of this environment variable is highly discouraged as it can lead to unexpected errors when using custom endpoints. + Please use the config (\`librechat.yaml\`) file for setting up OpenRouter, and use \`OPENROUTER_KEY\` or another environment variable instead.`, + ); + } +} + +/** + * Checks the health of auxiliary API's by attempting a fetch request to their respective `/health` endpoints. + * Logs information or warning based on the API's availability and response. + */ +async function checkHealth() { + try { + const response = await fetch(`${process.env.RAG_API_URL}/health`); + if (response?.ok && response?.status === 200) { + logger.info(`RAG API is running and reachable at ${process.env.RAG_API_URL}.`); + } + } catch (error) { + logger.warn( + `RAG API is either not running or not reachable at ${process.env.RAG_API_URL}, you may experience errors with file uploads.`, + ); + } +} + +/** + * Checks for the usage of deprecated and conflicting Azure variables. + * Logs warnings for any deprecated or conflicting environment variables found, indicating potential issues with `azureOpenAI` endpoint configuration. + */ +function checkAzureVariables() { + deprecatedAzureVariables.forEach(({ key, description }) => { + if (process.env[key]) { + logger.warn( + `The \`${key}\` environment variable (related to ${description}) should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you will experience conflicts and errors.`, + ); + } + }); + + conflictingAzureVariables.forEach(({ key }) => { + if (process.env[key]) { + logger.warn( + `The \`${key}\` environment variable should not be used in combination with the \`azureOpenAI\` endpoint configuration, as you may experience with the defined placeholders for mapping to the current model grouping using the same name.`, + ); + } + }); +} + +/** + * Performs basic checks on the loaded config object. + * @param {TCustomConfig} config - The loaded custom configuration. + */ +function checkConfig(config) { + if (config.version !== Constants.CONFIG_VERSION) { + logger.info( + `\nOutdated Config version: ${config.version} +Latest version: ${Constants.CONFIG_VERSION} + + Check out the Config changelogs for the latest options and features added. + + https://www.librechat.ai/changelog\n\n`, + ); + } +} + +module.exports = { checkVariables, checkHealth, checkConfig, checkAzureVariables }; diff --git a/api/server/services/start/interface.js b/api/server/services/start/interface.js new file mode 100644 index 00000000000..7cda7061044 --- /dev/null +++ b/api/server/services/start/interface.js @@ -0,0 +1,74 @@ +const { logger } = require('~/config'); + +/** + * Loads the default interface object. + * @param {TCustomConfig | undefined} config - The loaded custom configuration. + * @param {TConfigDefaults} configDefaults - The custom configuration default values. + * @returns {TCustomConfig['interface']} The default interface object. + */ +function loadDefaultInterface(config, configDefaults) { + const { interface: interfaceConfig } = config ?? {}; + const { interface: defaults } = configDefaults; + const hasModelSpecs = config?.modelSpecs?.list?.length > 0; + + const loadedInterface = { + endpointsMenu: + interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu), + modelSelect: interfaceConfig?.modelSelect ?? (hasModelSpecs ? false : defaults.modelSelect), + parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters), + presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets), + sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel, + privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy, + termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService, + }; + + let i = 0; + const logSettings = () => { + // log interface object and model specs object (without list) for reference + logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`); + logger.warn( + `\`modelSpecs\` settings:\n${JSON.stringify( + { ...(config?.modelSpecs ?? {}), list: undefined }, + null, + 2, + )}`, + ); + }; + + // warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs. + if (config?.modelSpecs?.prioritize && loadedInterface.presets) { + logger.warn( + 'Note: Prioritizing model specs can conflict with default presets if a default preset is set. It\'s recommended to disable presets from the interface or disable use of a default preset.', + ); + i === 0 && i++; + } + + // warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options. + if ( + config?.modelSpecs?.enforce && + (loadedInterface.endpointsMenu || + loadedInterface.modelSelect || + loadedInterface.presets || + loadedInterface.parameters) + ) { + logger.warn( + 'Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It\'s recommended to disable these options from the interface or disable enforcing model specs.', + ); + i === 0 && i++; + } + // warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior. + if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) { + logger.warn( + 'Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It\'s recommended to enable prioritizing model specs if enforcing them.', + ); + i === 0 && i++; + } + + if (i > 0) { + logSettings(); + } + + return loadedInterface; +} + +module.exports = { loadDefaultInterface }; diff --git a/api/server/utils/import/__data__/chatbotui-export.json b/api/server/utils/import/__data__/chatbotui-export.json new file mode 100644 index 00000000000..b1b5e2a04ec --- /dev/null +++ b/api/server/utils/import/__data__/chatbotui-export.json @@ -0,0 +1,98 @@ +{ + "version": 4, + "history": [ + { + "id": "493cb218-9ebb-4d35-af8b-5aded3f351be", + "name": "Hello what are you able to do?", + "messages": [ + { + "role": "user", + "type": "text", + "content": "Hello what are you able to do?" + }, + { + "role": "assistant", + "type": "text", + "content": "Hello! As an AI developed by OpenAI, I'm designed to assist with a wide range of tasks, including but not limited to:\n\n1. **Answering Questions**: I can provide explanations on various topics from science to history, technology, and more.\n2. **Providing Information**: Whether you need the latest knowledge (up to my last training data), tips, statistics, or how-to guides, I can help.\n3. **Learning and Education**: I can assist with learning new skills, languages, or subjects.\n4. **Writing and Editing**: From composing texts to editing them, I can assist in creating content such as essays, emails, and creative writing.\n5. **Programming Help**: Guidance in understanding programming concepts, debugging code, or learning about algorithms.\n6. **Entertainment**: I can tell jokes, stories, or even generate ideas for games and activities.\n7. **Brainstorming**: Whether it's ideas for a birthday party or strategies for a marketing plan, I can help brainstorm.\n8. **Conversations**: I can have in-depth or casual discussions on a broad range of topics.\n9. **Mental Health Support**: While I'm not a substitute for professional help, I can lend an ear and offer some general advice and support.\n10. **Travel Information**: Advice on destinations, tips for travel, and information about different cultures.\n\nPlease note that my responses are based on patterns and knowledge from text up to my last update, so I might not be aware of the very latest data or events." + }, + { + "role": "user", + "type": "text", + "content": "I like Entertainment part. Go ahead. Entertain me." + }, + { + "role": "assistant", + "type": "text", + "content": "Certainly! Let's begin with a light-hearted joke:\n\nWhy don't skeletons fight each other?\n\nThey don't have the guts.\n\nIf you're in the mood for a story instead, let's start with a short tale:\n\n---\n\n**Title: The Lost Key**\n\nIn a quiet town nestled between rolling green hills and a silver-sheened lake, there lived a locksmith named Eli. Eli had a peculiar ability; he could craft keys that didn't just unlock doors but also opened pathways to hidden places and secret realms. However, Eli never ventured through these magical doors; he was content knowing that he had the power to unveil them.\n\nOne day, Eli crafted his masterpiece—a key forged from an unknown, shimmering metal that seemed to hum with an inner light. The locksmith felt a deep connection to this key, as if it were calling to him to seek the door it was destined to open. Despite his usual reservations, curiosity got the better of him, and he embarked on a journey to find the matching lock.\n\nHe traveled through the town, greeting familiar faces and searching every nook and cranny, but to no avail. Eli was about to give up when he noticed a peculiar cat with a coat as dark as the night sky, its eyes glinting with a hint of stars. The cat seemed to beckon him to follow.\n\nLed by the cosmic feline through alleys and gardens, Eli arrived at a forgotten part of town where an old, vine-covered wall stood. To his astonishment, in the wall was a door that rippled like the surface of the lake when touched by a soft breeze. It was a door he had never seen before. Trembling with excitement, Eli took out the key. It fit perfectly into the lock, which turned with a satisfying click.\n\nAs the door creaked open, a brilliant light spilled forth. Eli shielded his eyes, and when he finally could see, he found himself not in another realm, but in the heart of his own town. However, everything appeared different—colors were more vibrant, sounds were clearer, and the air carried the sweet scent of blooming flowers.\n\nThe town's people approached him, smiling. \"We've been waiting for you,\" they said. \"The key wasn't meant to lead you to another world, but to allow you to see your own world differently. To show you that magic exists here, with all of us.\"\n\nEli's life transformed that day. He realized that every smile, every act of kindness, and every moment of connection was a door to a world just as magical as those he could unlock with his keys.\n\nAnd as for the mysterious cosmic cat, it would appear now and then, always at the edge of his vision, reminding him that the real magic was here all along, hidden in plain sight.\n\n---\n\nI hope you enjoyed the joke and the short story. If you'd like to continue the entertainment, feel free to guide me on what you'd like to hear or discuss next!" + } + ], + "model": { + "id": "gpt-4-1106-preview", + "name": "GPT-4 Turbo Preview", + "maxLength": 384000, + "tokenLimit": 128000 + }, + "prompt": "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.", + "temperature": 1, + "folderId": null + }, + { + "id": "f883fb40-6e5c-45fd-92d4-883a5595560f", + "name": "Give me the code that inverts ...", + "messages": [ + { + "role": "user", + "type": "text", + "content": "Give me the code that inverts binary tree in COBOL" + }, + { + "role": "assistant", + "type": "text", + "content": "COBOL is not a language that is commonly used for tasks like inverting a binary tree. It is more suited for business applications and mainframe programming. However, if you are looking for a general idea of how you might approach this in COBOL, here is a simplified example:\n\n```cobol\nIDENTIFICATION DIVISION.\nPROGRAM-ID. InvertBinaryTree.\n\nDATA DIVISION.\nWORKING-STORAGE SECTION.\n01 Node.\n 05 Left-Child PIC 9.\n 05 Right-Child PIC 9.\n\nPROCEDURE DIVISION.\nInvert-Binary-Tree.\n MOVE 1 TO Left-Child\n MOVE 2 TO Right-Child\n\n DISPLAY \"Original Binary Tree:\"\n DISPLAY \"Left Child: \" Left-Child\n DISPLAY \"Right Child: \" Right-Child\n\n PERFORM Swap-Children\n\n DISPLAY \"Inverted Binary Tree:\"\n DISPLAY \"Left Child: \" Left-Child\n DISPLAY \"Right Child: \" Right-Child\n\n STOP RUN.\n\nSwap-Children.\n MOVE Left-Child TO Node\n MOVE Right-Child TO Left-Child\n MOVE Node TO Right-Child\n```\n\nThis is a very basic example and may not cover all the complexities of inverting a binary tree. You would need to adapt and expand this code significantly for a more complex binary tree structure." + } + ], + "model": { + "id": "gpt-3.5-turbo", + "name": "GPT-3.5" + }, + "prompt": "You are ChatGPT, a large language model trained by OpenAI. Follow the user's instructions carefully. Respond using markdown.", + "temperature": 0.5, + "folderId": null + } + ], + "folders": [ + { + "id": "cdc857de-e669-498d-8fac-edc4995c9d7a", + "name": "New folder", + "type": "prompt" + } + ], + "prompts": [ + { + "id": "a61573d8-6686-487c-9c5d-cd79c6d201ee", + "name": "Prompt 1", + "description": "", + "content": "", + "model": { + "id": "gpt-4", + "name": "GPT-4", + "maxLength": 24000, + "tokenLimit": 8000 + }, + "folderId": null + }, + { + "id": "9bf456e3-61fc-494d-b940-55ec934e7a04", + "name": "Prompt 2", + "description": "afgdfsg", + "content": "adfdsfsadf", + "model": { + "id": "gpt-4", + "name": "GPT-4", + "maxLength": 24000, + "tokenLimit": 8000 + }, + "folderId": null + } + ] +} diff --git a/api/server/utils/import/__data__/chatgpt-export.json b/api/server/utils/import/__data__/chatgpt-export.json new file mode 100644 index 00000000000..a8ee0f3a667 --- /dev/null +++ b/api/server/utils/import/__data__/chatgpt-export.json @@ -0,0 +1,1224 @@ +[ + { + "title": "Conversation 1. Web Search", + "create_time": 1704629915.775304, + "update_time": 1704717442.442031, + "mapping": { + "6d251922-28a1-48a5-af9f-687fab4184a8": { + "id": "6d251922-28a1-48a5-af9f-687fab4184a8", + "message": { + "id": "6d251922-28a1-48a5-af9f-687fab4184a8", + "author": { + "role": "system", + "name": null, + "metadata": {} + }, + "create_time": null, + "update_time": null, + "content": { + "content_type": "text", + "parts": [""] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 0.0, + "metadata": { + "is_visually_hidden_from_conversation": true + }, + "recipient": "all" + }, + "parent": "7a4306a5-8df1-4e69-9469-98e7619801db", + "children": ["bbb277e8-11d0-44f4-86c9-01dc3027228a"] + }, + "7a4306a5-8df1-4e69-9469-98e7619801db": { + "id": "7a4306a5-8df1-4e69-9469-98e7619801db", + "message": null, + "parent": null, + "children": ["6d251922-28a1-48a5-af9f-687fab4184a8"] + }, + "bbb277e8-11d0-44f4-86c9-01dc3027228a": { + "id": "bbb277e8-11d0-44f4-86c9-01dc3027228a", + "message": { + "id": "bbb277e8-11d0-44f4-86c9-01dc3027228a", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1704629915.776371, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["What is the fuel consumption of vw transporter with 8 people in l/km"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "unknown" + }, + "citations": [], + "voice_mode_message": false, + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "6d251922-28a1-48a5-af9f-687fab4184a8", + "children": ["412dd50f-40c9-4f21-9102-fe148eb41a0b"] + }, + "412dd50f-40c9-4f21-9102-fe148eb41a0b": { + "id": "412dd50f-40c9-4f21-9102-fe148eb41a0b", + "message": { + "id": "412dd50f-40c9-4f21-9102-fe148eb41a0b", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1704629939.839052, + "update_time": null, + "content": { + "content_type": "code", + "language": "unknown", + "text": "search(\"Volkswagen Transporter fuel consumption with 8 people l/km\")" + }, + "status": "finished_successfully", + "end_turn": false, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100265] + }, + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "bbb277e8-11d0-44f4-86c9-01dc3027228a", + "timestamp_": "absolute" + }, + "recipient": "browser" + }, + "parent": "bbb277e8-11d0-44f4-86c9-01dc3027228a", + "children": ["374bbcc8-2013-4387-8cd8-3e64abbd60ca"] + }, + "374bbcc8-2013-4387-8cd8-3e64abbd60ca": { + "id": "374bbcc8-2013-4387-8cd8-3e64abbd60ca", + "message": { + "id": "374bbcc8-2013-4387-8cd8-3e64abbd60ca", + "author": { + "role": "tool", + "name": "browser", + "metadata": {} + }, + "create_time": 1704629939.840484, + "update_time": null, + "content": { + "content_type": "tether_browsing_display", + "result": "# \u30100\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n528 Volkswagen Transporters have provided 7.3 million miles of real world fuel economy & MPG data. Click here to view all the Volkswagen Transporters currently participating in our fuel tracking program. 2020. 22.0 Avg MPG. 3 Vehicles.\n# \u30101\u20202020 Volkswagen Transporter MPG - Actual MPG from 3 2020 ... - Fuelly\u2020www.fuelly.com\u3011\n2020 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Dec 2020 \u2022 60 Fuel-ups. Property of oleg_r_vitvitskiy . 21.8 Avg MPG. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u30102\u20202022 Volkswagen Multivan (T7) specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nVolkswagen Multivan (T7) | Technical Specs, Fuel consumption, Space, Volume and weights, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size ... 9.4 sec, 0-60 mph: 8.9 sec Fuel consumption: 7.6-7.7 l/100 km | 31 - 31 US mpg | 37 - 37 UK mpg | 13 - 13 km/l: 2.0 TDI (150 Hp) DSG 2022 - Maximum ...\n# \u30103\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\n41 MPG 5.7 L/100 km 50 MPG UK: Fuel Consumption - Economy - City: 31 MPG 7.5 L/100 km 38 MPG UK: Range : 679 miles / 1093 km: Fuel Tank Capacity : ... The 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP top speed is 155 Km/h / 96 mph. Is 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP All Wheel Drive (AWD)? No, the 2021 Volkswagen ...\n# \u30104\u2020Gas Mileage of 2021 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nHighway MPG: 23. highway. 5.3 gals/ 100 miles. 2021 Volkswagen Atlas 4 cyl, 2.0 L, Automatic (S8) Regular Gasoline. Not Available.\n# \u30105\u20202019 Volkswagen Transporter MPG - Actual MPG from 6 2019 ... - Fuelly\u2020www.fuelly.com\u3011\n2019 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Oct 2020 \u2022 12 Fuel-ups. Property of Amarokian . 27.0 Avg MPG. Recent Activity. from other Volkswagen Transporter vehicles . ... Get an accurate view of your vehicles fuel economy;\n# \u30106\u2020Volkswagen Transporter van review (2023) - Parkers\u2020www.parkers.co.uk\u3011\nThis 2023 VW Transporter review covers the T6 and T6.1 versions of this popular medium van, originally launched in 2015 then updated with a major facelift (known as the Transporter 6.1) in 2019.. And while this sixth-generation model is closely related to the previous VW Transporter T5 under the skin, significant gains have been made in the areas of running costs, driver comfort and safety.\n# \u30107\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals ...\n# \u30108\u2020VW Van Fuel Consumption and Residual Values | VW Vans - Volkswagen Vans\u2020www.volkswagen-vans.co.uk\u3011\nResidual value after 3 year/ 60,000 miles \u20602. Volkswagen Crafter CR30 Startline MWB FWD 2.0 TDI 102 PS. \u00a37,975 / 31.44 % \u2060. 2. Mercedes-Benz Sprinter Light Commercial 316 L1 3.5t 2.1CDi 163. \u00a39,750 / 29.53% \u2060. 2. Ford Transit Light Commercial 290 L2 2.0EcoBlue 130 Trend Medium Roof. \u00a38,350 / 29.35% \u2060.\n# \u30109\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301010\u20202018 Volkswagen Transporter MPG - Actual MPG from 15 2018 ... - Fuelly\u2020www.fuelly.com\u3011\n2018 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Jul 2018 \u2022 101 Fuel-ups. Property of hwarang73 . 23.1 Avg MPG. Pinky. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u301011\u2020Gas Mileage of 2022 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nWe can help you calculate and track your fuel economy. MPG Estimates from Others; MPG estimates from drivers like you! Advanced Cars & Fuels. ... 2022 Volkswagen Taos 4 cyl, 1.5 L, Automatic 8-spd Regular Gasoline: View Estimates How can I share my MPG? Combined MPG: 31. combined. city/highway. MPG. City MPG: 28. city. Highway MPG: 36.\n# \u301012\u20202021 Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG\u2020www.auto-data.net\u3011\nWhat is the fuel economy, Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG? 1.5 l/100 km 156.81 US mpg 188.32 UK mpg 66.67 km/l: How ECO is the car, Volkswagen Multivan 1.4 eHybrid (218 Hp) DSG? 34 g/km CO 2 Euro 6d-ISC-FCM: What is the range of pure electric driving, 1.4 eHybrid (218 Hp) DSG?\n# \u301013\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\n35 MPG 6.7 L/100 km 42 MPG UK: Fuel Consumption - Economy - Open road: 40 MPG 5.9 L/100 km 48 MPG UK: Fuel Consumption - Economy - City: 30 ... The Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP top speed is 182 Km/h / 113 mph. Is Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP All Wheel Drive (AWD)? No, the Volkswagen Transporter T6.1 L2H1 2.0 ...\n# \u301014\u2020Volkswagen Transporter Review (2024) | Autocar\u2020www.autocar.co.uk\u3011\nStill, a van\u2019s a van and, ergonomically at least, this one doesn\u2019t do much differently from any other. Auxiliary heating system for the second-row seats is a \u00a3330 option, controlled from this ...\n# \u301015\u20202019 Volkswagen Multivan (T6) 2.0 TDI (150 Hp)\u2020www.auto-data.net\u3011\n14.49 - 13.89 km/l: How ECO is the car, Volkswagen Multivan 2.0 TDI (150 Hp)? 181-189 g/km CO 2 Euro 6d-Temp: How fast is the car, 2019 Multivan (T6) 2.0 TDI (150 Hp)? ... 16.39 - 15.87 km/l: Fuel consumption (economy) - combined: 6.9-7.2 l/100 km 34.09 - 32.67 US mpg 40.94 - 39.23 UK mpg 14.49 - 13.89 km/l: CO 2 emissions: 181-189 g/km : Fuel ...\n# \u301016\u2020Fuel Capacity & Consumption Of A Volkswagen (All Models) - Vehicle Help\u2020vehiclehelp.com\u3011\nThe average fuel capacity of all Volkswagen vehicles is 17.2 gallons, and the average consumption is 38 MPG. The most fuel-efficient model is the Volkswagen ID.4, and the Volkswagen Atlas has the worst fuel economy. Depending on your requirements, you can buy a smaller, lighter-on-fuel Volkswagen or a bigger, heavier-on-fuel model.\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u20202025 Volkswagen ID.Buzz: What We Know So Far - Car and Driver\u2020www.caranddriver.com\u3011\nPricing and Which One to Buy. The price of the 2025 Volkswagen ID.Buzz is expected to start around $40,000. ID.Buzz. 0 $10k $20k $30k $40k $50k $60k. VW hasn't said how much the ID.Buzz will cost ...\n# \u301019\u2020Boom and Crisis in the One-Product Business - Volkswagen Group\u2020www.volkswagen-group.com\u3011\nFitted out as a 1-tonne delivery van, an estate with a 5 cubic metre loading volume or with seating for up to 9 people, the Transporter is still a much in-demand master of versatility. Its rear-mounted 47 horsepower boxer engine achieves a top speed of 105 km/h, while fuel consumption of 10.4 litres per 100 kilometres keeps running costs down.\nVisible: 0% - 100%", + "summary": null, + "assets": [] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 0.0, + "metadata": { + "_cite_metadata": { + "citation_format": { + "name": "tether_og" + }, + "metadata_list": [ + { + "type": "webpage", + "title": "Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly", + "url": "https://www.fuelly.com/car/volkswagen/transporter", + "text": "\n528 Volkswagen Transporters have provided 7.3 million miles of real world fuel economy & MPG data. Click here to view all the Volkswagen Transporters currently participating in our fuel tracking program. 2020. 22.0 Avg MPG. 3 Vehicles.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2020 Volkswagen Transporter MPG - Actual MPG from 3 2020 ... - Fuelly", + "url": "https://www.fuelly.com/car/volkswagen/transporter/2020", + "text": "\n2020 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Dec 2020 \u2022 60 Fuel-ups. Property of oleg_r_vitvitskiy . 21.8 Avg MPG. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2022 Volkswagen Multivan (T7) specs, Fuel consumption, Dimensions", + "url": "https://www.auto-data.net/en/volkswagen-multivan-t7-generation-8578", + "text": "\nVolkswagen Multivan (T7) | Technical Specs, Fuel consumption, Space, Volume and weights, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size ... 9.4 sec, 0-60 mph: 8.9 sec Fuel consumption: 7.6-7.7 l/100 km | 31 - 31 US mpg | 37 - 37 UK mpg | 13 - 13 km/l: 2.0 TDI (150 Hp) DSG 2022 - Maximum ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs", + "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html", + "text": "\n41 MPG 5.7 L/100 km 50 MPG UK: Fuel Consumption - Economy - City: 31 MPG 7.5 L/100 km 38 MPG UK: Range : 679 miles / 1093 km: Fuel Tank Capacity : ... The 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP top speed is 155 Km/h / 96 mph. Is 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP All Wheel Drive (AWD)? No, the 2021 Volkswagen ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Gas Mileage of 2021 Vehicles by Volkswagen - FuelEconomy.gov", + "url": "https://www.fueleconomy.gov/feg/bymake/Volkswagen2021.shtml", + "text": "\nHighway MPG: 23. highway. 5.3 gals/ 100 miles. 2021 Volkswagen Atlas 4 cyl, 2.0 L, Automatic (S8) Regular Gasoline. Not Available.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2019 Volkswagen Transporter MPG - Actual MPG from 6 2019 ... - Fuelly", + "url": "https://www.fuelly.com/car/volkswagen/transporter/2019", + "text": "\n2019 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Oct 2020 \u2022 12 Fuel-ups. Property of Amarokian . 27.0 Avg MPG. Recent Activity. from other Volkswagen Transporter vehicles . ... Get an accurate view of your vehicles fuel economy;\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter van review (2023) - Parkers", + "url": "https://www.parkers.co.uk/vans-pickups/volkswagen/transporter/2015-review/", + "text": "\nThis 2023 VW Transporter review covers the T6 and T6.1 versions of this popular medium van, originally launched in 2015 then updated with a major facelift (known as the Transporter 6.1) in 2019.. And while this sixth-generation model is closely related to the previous VW Transporter T5 under the skin, significant gains have been made in the areas of running costs, driver comfort and safety.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter Review - Drive", + "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/", + "text": "\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "VW Van Fuel Consumption and Residual Values | VW Vans - Volkswagen Vans", + "url": "https://www.volkswagen-vans.co.uk/en/finance-offers-and-fleet/fleet/van-life-costs.html", + "text": "\nResidual value after 3 year/ 60,000 miles \u20602. Volkswagen Crafter CR30 Startline MWB FWD 2.0 TDI 102 PS. \u00a37,975 / 31.44 % \u2060. 2. Mercedes-Benz Sprinter Light Commercial 316 L1 3.5t 2.1CDi 163. \u00a39,750 / 29.53% \u2060. 2. Ford Transit Light Commercial 290 L2 2.0EcoBlue 130 Trend Medium Roof. \u00a38,350 / 29.35% \u2060.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen | Technical Specs, Fuel consumption, Dimensions", + "url": "https://www.auto-data.net/en/volkswagen-brand-80", + "text": "\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2018 Volkswagen Transporter MPG - Actual MPG from 15 2018 ... - Fuelly", + "url": "https://www.fuelly.com/car/volkswagen/transporter/2018", + "text": "\n2018 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Jul 2018 \u2022 101 Fuel-ups. Property of hwarang73 . 23.1 Avg MPG. Pinky. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Gas Mileage of 2022 Vehicles by Volkswagen - FuelEconomy.gov", + "url": "https://www.fueleconomy.gov/feg/bymake/Volkswagen2022.shtml", + "text": "\nWe can help you calculate and track your fuel economy. MPG Estimates from Others; MPG estimates from drivers like you! Advanced Cars & Fuels. ... 2022 Volkswagen Taos 4 cyl, 1.5 L, Automatic 8-spd Regular Gasoline: View Estimates How can I share my MPG? Combined MPG: 31. combined. city/highway. MPG. City MPG: 28. city. Highway MPG: 36.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2021 Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG", + "url": "https://www.auto-data.net/en/volkswagen-multivan-t7-1.4-ehybrid-218hp-dsg-45272", + "text": "\nWhat is the fuel economy, Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG? 1.5 l/100 km 156.81 US mpg 188.32 UK mpg 66.67 km/l: How ECO is the car, Volkswagen Multivan 1.4 eHybrid (218 Hp) DSG? 34 g/km CO 2 Euro 6d-ISC-FCM: What is the range of pure electric driving, 1.4 eHybrid (218 Hp) DSG?\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs", + "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118425/Volkswagen-Transporter-T61-L2H1-20-TDI-150HP.html", + "text": "\n35 MPG 6.7 L/100 km 42 MPG UK: Fuel Consumption - Economy - Open road: 40 MPG 5.9 L/100 km 48 MPG UK: Fuel Consumption - Economy - City: 30 ... The Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP top speed is 182 Km/h / 113 mph. Is Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP All Wheel Drive (AWD)? No, the Volkswagen Transporter T6.1 L2H1 2.0 ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter Review (2024) | Autocar", + "url": "https://www.autocar.co.uk/car-review/volkswagen/transporter", + "text": "\nStill, a van\u2019s a van and, ergonomically at least, this one doesn\u2019t do much differently from any other. Auxiliary heating system for the second-row seats is a \u00a3330 option, controlled from this ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2019 Volkswagen Multivan (T6) 2.0 TDI (150 Hp)", + "url": "https://www.auto-data.net/en/volkswagen-multivan-t6-2.0-tdi-150hp-36051", + "text": "\n14.49 - 13.89 km/l: How ECO is the car, Volkswagen Multivan 2.0 TDI (150 Hp)? 181-189 g/km CO 2 Euro 6d-Temp: How fast is the car, 2019 Multivan (T6) 2.0 TDI (150 Hp)? ... 16.39 - 15.87 km/l: Fuel consumption (economy) - combined: 6.9-7.2 l/100 km 34.09 - 32.67 US mpg 40.94 - 39.23 UK mpg 14.49 - 13.89 km/l: CO 2 emissions: 181-189 g/km : Fuel ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Fuel Capacity & Consumption Of A Volkswagen (All Models) - Vehicle Help", + "url": "https://vehiclehelp.com/fuel-capacity-consumption-of-a-volkswagen/", + "text": "\nThe average fuel capacity of all Volkswagen vehicles is 17.2 gallons, and the average consumption is 38 MPG. The most fuel-efficient model is the Volkswagen ID.4, and the Volkswagen Atlas has the worst fuel economy. Depending on your requirements, you can buy a smaller, lighter-on-fuel Volkswagen or a bigger, heavier-on-fuel model.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Type 2 - Wikipedia", + "url": "https://en.wikipedia.org/wiki/Volkswagen_Type_2", + "text": "\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2025 Volkswagen ID.Buzz: What We Know So Far - Car and Driver", + "url": "https://www.caranddriver.com/volkswagen/id-buzz-microbus", + "text": "\nPricing and Which One to Buy. The price of the 2025 Volkswagen ID.Buzz is expected to start around $40,000. ID.Buzz. 0 $10k $20k $30k $40k $50k $60k. VW hasn't said how much the ID.Buzz will cost ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Boom and Crisis in the One-Product Business - Volkswagen Group", + "url": "https://www.volkswagen-group.com/en/volkswagen-chronicle-17351/1961-to-1972-boom-and-crisis-in-the-one-product-business-17357", + "text": "\nFitted out as a 1-tonne delivery van, an estate with a 5 cubic metre loading volume or with seating for up to 9 people, the Transporter is still a much in-demand master of versatility. Its rear-mounted 47 horsepower boxer engine achieves a top speed of 105 km/h, while fuel consumption of 10.4 litres per 100 kilometres keeps running costs down.\nVisible: 0% - 100%", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Search results for query: 'Volkswagen Transporter fuel consumption with 8 people l/km'", + "url": "", + "text": "# \u30100\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n528 Volkswagen Transporters have provided 7.3 million miles of real world fuel economy & MPG data. Click here to view all the Volkswagen Transporters currently participating in our fuel tracking program. 2020. 22.0 Avg MPG. 3 Vehicles.\n# \u30101\u20202020 Volkswagen Transporter MPG - Actual MPG from 3 2020 ... - Fuelly\u2020www.fuelly.com\u3011\n2020 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Dec 2020 \u2022 60 Fuel-ups. Property of oleg_r_vitvitskiy . 21.8 Avg MPG. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u30102\u20202022 Volkswagen Multivan (T7) specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nVolkswagen Multivan (T7) | Technical Specs, Fuel consumption, Space, Volume and weights, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size ... 9.4 sec, 0-60 mph: 8.9 sec Fuel consumption: 7.6-7.7 l/100 km | 31 - 31 US mpg | 37 - 37 UK mpg | 13 - 13 km/l: 2.0 TDI (150 Hp) DSG 2022 - Maximum ...\n# \u30103\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\n41 MPG 5.7 L/100 km 50 MPG UK: Fuel Consumption - Economy - City: 31 MPG 7.5 L/100 km 38 MPG UK: Range : 679 miles / 1093 km: Fuel Tank Capacity : ... The 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP top speed is 155 Km/h / 96 mph. Is 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP All Wheel Drive (AWD)? No, the 2021 Volkswagen ...\n# \u30104\u2020Gas Mileage of 2021 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nHighway MPG: 23. highway. 5.3 gals/ 100 miles. 2021 Volkswagen Atlas 4 cyl, 2.0 L, Automatic (S8) Regular Gasoline. Not Available.\n# \u30105\u20202019 Volkswagen Transporter MPG - Actual MPG from 6 2019 ... - Fuelly\u2020www.fuelly.com\u3011\n2019 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Oct 2020 \u2022 12 Fuel-ups. Property of Amarokian . 27.0 Avg MPG. Recent Activity. from other Volkswagen Transporter vehicles . ... Get an accurate view of your vehicles fuel economy;\n# \u30106\u2020Volkswagen Transporter van review (2023) - Parkers\u2020www.parkers.co.uk\u3011\nThis 2023 VW Transporter review covers the T6 and T6.1 versions of this popular medium van, originally launched in 2015 then updated with a major facelift (known as the Transporter 6.1) in 2019.. And while this sixth-generation model is closely related to the previous VW Transporter T5 under the skin, significant gains have been made in the areas of running costs, driver comfort and safety.\n# \u30107\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals ...\n# \u30108\u2020VW Van Fuel Consumption and Residual Values | VW Vans - Volkswagen Vans\u2020www.volkswagen-vans.co.uk\u3011\nResidual value after 3 year/ 60,000 miles \u20602. Volkswagen Crafter CR30 Startline MWB FWD 2.0 TDI 102 PS. \u00a37,975 / 31.44 % \u2060. 2. Mercedes-Benz Sprinter Light Commercial 316 L1 3.5t 2.1CDi 163. \u00a39,750 / 29.53% \u2060. 2. Ford Transit Light Commercial 290 L2 2.0EcoBlue 130 Trend Medium Roof. \u00a38,350 / 29.35% \u2060.\n# \u30109\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301010\u20202018 Volkswagen Transporter MPG - Actual MPG from 15 2018 ... - Fuelly\u2020www.fuelly.com\u3011\n2018 Volkswagen Transporter 2,0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Jul 2018 \u2022 101 Fuel-ups. Property of hwarang73 . 23.1 Avg MPG. Pinky. ... A simple & effecive way to track fuel consumption Easy to understand the real cost of your vehicle. Benefits.\n# \u301011\u2020Gas Mileage of 2022 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nWe can help you calculate and track your fuel economy. MPG Estimates from Others; MPG estimates from drivers like you! Advanced Cars & Fuels. ... 2022 Volkswagen Taos 4 cyl, 1.5 L, Automatic 8-spd Regular Gasoline: View Estimates How can I share my MPG? Combined MPG: 31. combined. city/highway. MPG. City MPG: 28. city. Highway MPG: 36.\n# \u301012\u20202021 Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG\u2020www.auto-data.net\u3011\nWhat is the fuel economy, Volkswagen Multivan (T7) 1.4 eHybrid (218 Hp) DSG? 1.5 l/100 km 156.81 US mpg 188.32 UK mpg 66.67 km/l: How ECO is the car, Volkswagen Multivan 1.4 eHybrid (218 Hp) DSG? 34 g/km CO 2 Euro 6d-ISC-FCM: What is the range of pure electric driving, 1.4 eHybrid (218 Hp) DSG?\n# \u301013\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\n35 MPG 6.7 L/100 km 42 MPG UK: Fuel Consumption - Economy - Open road: 40 MPG 5.9 L/100 km 48 MPG UK: Fuel Consumption - Economy - City: 30 ... The Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP top speed is 182 Km/h / 113 mph. Is Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP All Wheel Drive (AWD)? No, the Volkswagen Transporter T6.1 L2H1 2.0 ...\n# \u301014\u2020Volkswagen Transporter Review (2024) | Autocar\u2020www.autocar.co.uk\u3011\nStill, a van\u2019s a van and, ergonomically at least, this one doesn\u2019t do much differently from any other. Auxiliary heating system for the second-row seats is a \u00a3330 option, controlled from this ...\n# \u301015\u20202019 Volkswagen Multivan (T6) 2.0 TDI (150 Hp)\u2020www.auto-data.net\u3011\n14.49 - 13.89 km/l: How ECO is the car, Volkswagen Multivan 2.0 TDI (150 Hp)? 181-189 g/km CO 2 Euro 6d-Temp: How fast is the car, 2019 Multivan (T6) 2.0 TDI (150 Hp)? ... 16.39 - 15.87 km/l: Fuel consumption (economy) - combined: 6.9-7.2 l/100 km 34.09 - 32.67 US mpg 40.94 - 39.23 UK mpg 14.49 - 13.89 km/l: CO 2 emissions: 181-189 g/km : Fuel ...\n# \u301016\u2020Fuel Capacity & Consumption Of A Volkswagen (All Models) - Vehicle Help\u2020vehiclehelp.com\u3011\nThe average fuel capacity of all Volkswagen vehicles is 17.2 gallons, and the average consumption is 38 MPG. The most fuel-efficient model is the Volkswagen ID.4, and the Volkswagen Atlas has the worst fuel economy. Depending on your requirements, you can buy a smaller, lighter-on-fuel Volkswagen or a bigger, heavier-on-fuel model.\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u20202025 Volkswagen ID.Buzz: What We Know So Far - Car and Driver\u2020www.caranddriver.com\u3011\nPricing and Which One to Buy. The price of the 2025 Volkswagen ID.Buzz is expected to start around $40,000. ID.Buzz. 0 $10k $20k $30k $40k $50k $60k. VW hasn't said how much the ID.Buzz will cost ...\n# \u301019\u2020Boom and Crisis in the One-Product Business - Volkswagen Group\u2020www.volkswagen-group.com\u3011\nFitted out as a 1-tonne delivery van, an estate with a 5 cubic metre loading volume or with seating for up to 9 people, the Transporter is still a much in-demand master of versatility. Its rear-mounted 47 horsepower boxer engine achieves a top speed of 105 km/h, while fuel consumption of 10.4 litres per 100 kilometres keeps running costs down.\nVisible: 0% - 100%", + "pub_date": null, + "extra": null + } + ], + "original_query": null + }, + "command": "search", + "args": ["Volkswagen Transporter fuel consumption with 8 people l/km"], + "status": "finished", + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "412dd50f-40c9-4f21-9102-fe148eb41a0b", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "412dd50f-40c9-4f21-9102-fe148eb41a0b", + "children": ["36b62905-c942-414a-8ad2-d5d26739efa5"] + }, + "36b62905-c942-414a-8ad2-d5d26739efa5": { + "id": "36b62905-c942-414a-8ad2-d5d26739efa5", + "message": { + "id": "36b62905-c942-414a-8ad2-d5d26739efa5", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1704629939.845544, + "update_time": null, + "content": { + "content_type": "code", + "language": "unknown", + "text": "mclick([0, 3, 7])" + }, + "status": "finished_successfully", + "end_turn": false, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100265] + }, + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "374bbcc8-2013-4387-8cd8-3e64abbd60ca", + "timestamp_": "absolute" + }, + "recipient": "browser" + }, + "parent": "374bbcc8-2013-4387-8cd8-3e64abbd60ca", + "children": ["b87c7f57-a6f4-4f4f-999f-38bd70981ae0"] + }, + "b87c7f57-a6f4-4f4f-999f-38bd70981ae0": { + "id": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0", + "message": { + "id": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0", + "author": { + "role": "tool", + "name": "browser", + "metadata": {} + }, + "create_time": 1704629939.849366, + "update_time": null, + "content": { + "content_type": "tether_quote", + "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html", + "domain": "www.ultimatespecs.com", + "text": "\nURL: https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html\n\n\n[Image 0: English] \n\n\u30100\u2020 English \u3011 \u30101\u2020 Fran\u00e7ais \u3011 \u30102\u2020 Deutsch \u3011 \u30103\u2020 Svensk \u3011 \u30104\u2020 Portugu\u00eas PT \u3011 \u30105\u2020 Espa\u00f1ol \u3011 \u30106\u2020 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u3011 \u30107\u2020 Italiano \u3011 \u30108\u2020 \u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 \u3011 \u30109\u2020 Nederlands \u3011 \u301010\u2020 Polski \u3011 \u301011\u2020 Portugu\u00eas BR \u3011 \u301012\u2020 T\u00fcrk\u00e7e \u3011 \n\n\u301013\u2020 \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301014\u2020 Motos \u3011 \n\n\u301015\u2020 Tractors \u3011 \n\n \u301016\u2020 \u2020www.facebook.com\u3011 \u301017\u2020 \u2020twitter.com\u3011 \n\n< Back \n\n * * \u301018\u2020 Cars \u3011 \n * \u301019\u2020 Electric & Hybrid Cars \u3011 \n * \u301020\u2020 Compare cars \u3011 \n * \u301021\u2020 Car Images \u3011 \n * \u301022\u2020 Advanced Search \u3011 \n\n[Image 1: menu] \n\nIt seems that you have reached a high volume of page views. \nPlease confirm that you are a human by clicking the box below. \n\nThank you! \n\n Send \n\n## Latest Car Specs\n\n\u301023\u20202023 Cupra Formentor VZ5 2.5 TSI 4Drive\u3011\u301024\u20202023 BMW G21 3 Series Touring LCI 318i Auto\u3011\u301025\u20202024 Lexus LBX 1.5 Hybrid E-Four e-CVT\u3011\u301026\u20202024 Lexus LBX 1.5 Hybrid e-CVT\u3011\n\n\u301027\u20201983 Buick Electra Coupe 1980 Limited 5.0L V8 4-speed Auto\u3011\u301028\u20201981 Buick Electra Coupe 1980 Limited 4.1 V6 4-speed Auto\u3011\u301029\u20201980 Buick Electra Coupe 1980 Limited 4.1 V6 Overdrive 4-speed Auto\u3011\u301030\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid DCT\u3011\n\n\u301031\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid Auto\u3011\u301032\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid DCT\u3011\u301033\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid Auto\u3011\u301034\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI 48V-Hybrid DCT\u3011\n\n\u301035\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI\u3011\u301036\u20202023 Hyundai i20 (BC3) 2023 1.2 MPI\u3011\u301037\u20202023 BYD Seal 83 kWh 530HP AWD\u3011\u301038\u20202023 BYD Seal 83 kWh 313HP\u3011\n\n\u301039\u20202023 BYD Dolphin 60 kWh 204HP\u3011\u301040\u20201992 Alfa Romeo 164 Super V6 Turbo\u3011\u301041\u20202023 BMW G20 3 Series Sedan LCI M340i Mild Hybrid xDrive Auto\u3011\u301042\u20202023 BMW G20 3 Series Sedan LCI 330i xDrive Auto\u3011\n\n\u301043\u20202023 BMW G20 3 Series Sedan LCI 330i Auto\u3011\u301044\u20202023 BMW G20 3 Series Sedan LCI 320i xDrive Auto\u3011\u301045\u20202023 BMW G20 3 Series Sedan LCI 320i Auto\u3011\u301046\u20202023 BMW G20 3 Series Sedan LCI 318i Auto\u3011\n\n\u301047\u20201976 Ford Pinto 2-Door Sedan 1977 2.8 V6 Cruise-O-Matic\u3011\u301048\u20201976 Ford Pinto 2-Door Sedan 1977 2.3 Cruise-O-Matic\u3011\u301049\u20201976 Ford Pinto 2-Door Sedan 1977 2.3\u3011\u301050\u2020View more\u2020ultimatespecs.com\u3011\n\n## Latest Models\n\n\u301051\u2020BMW G21 3 Series Touring LCI\u3011\u301052\u2020Lexus LBX\u3011\u301053\u2020Hyundai i20 (BC3) 2023\u3011\u301054\u2020Hyundai i20 (BC3)\u3011\n\n\u301055\u2020BYD Seal\u3011\u301056\u2020BYD Dolphin\u3011\u301057\u2020BMW G20 3 Series Sedan LCI\u3011\u301058\u2020Ford Pinto 2-Door Sedan 1977\u3011\n\n\u301059\u2020Buick Electra Coupe 1980\u3011\u301060\u2020Ford Pinto 2-Door Sedan 1976\u3011\u301061\u2020Citroen C3 Phase IV\u3011\u301062\u2020Citroen C5 X\u3011\n\n\u301063\u2020Ford Pinto 2-Door Sedan 1975\u3011\u301064\u2020Lancia Delta\u3011\u301065\u2020Ford Pinto 2-Door Sedan 1974\u3011\u301066\u2020Ford Pinto 2-Door Sedan 1973\u3011\n\n\u301067\u2020Peugeot 408\u3011\u301068\u2020Ford Pinto 2-Door Sedan 1972\u3011\u301069\u2020Renault Sc\u00e9nic 5\u3011\u301070\u2020Renault Espace 6\u3011\n\n\u301071\u2020Ford Pinto 2-Door Sedan\u3011\u301072\u2020Renault Austral\u3011\u301073\u2020Mazda RX-8 2008\u3011\u301074\u2020Bentley Bentayga EWB\u3011\n\n\u301075\u2020Bentley Bentayga 2020 Facelift\u3011\u301076\u2020Toyota GR86 2021\u3011\u301077\u2020Fiat Cinquecento\u3011\u301078\u2020Audi A8 L 2022 (D5)\u3011\n\n\u00a92024 Ultimate Specs - The Most Comprehensive Car Specifications Database. Over 46.000 technical specs!! - \u301079\u2020Change consent\u2020javascript:;\u3011 \n\n- Do not share my Personal Information.\n\n- \u301080\u2020 About \u3011- \u301081\u2020 Privacy Policy \u3011- \u301082\u2020 Contact US \u3011", + "title": "2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions" + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 0.0, + "metadata": { + "_cite_metadata": { + "citation_format": { + "name": "tether_og" + }, + "metadata_list": [ + { + "type": "webpage", + "title": "2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions", + "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html", + "text": "\nURL: https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html\n\n\n[Image 0: English] \n\n\u30100\u2020 English \u3011 \u30101\u2020 Fran\u00e7ais \u3011 \u30102\u2020 Deutsch \u3011 \u30103\u2020 Svensk \u3011 \u30104\u2020 Portugu\u00eas PT \u3011 \u30105\u2020 Espa\u00f1ol \u3011 \u30106\u2020 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u3011 \u30107\u2020 Italiano \u3011 \u30108\u2020 \u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 \u3011 \u30109\u2020 Nederlands \u3011 \u301010\u2020 Polski \u3011 \u301011\u2020 Portugu\u00eas BR \u3011 \u301012\u2020 T\u00fcrk\u00e7e \u3011 \n\n\u301013\u2020 \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301014\u2020 Motos \u3011 \n\n\u301015\u2020 Tractors \u3011 \n\n \u301016\u2020 \u2020www.facebook.com\u3011 \u301017\u2020 \u2020twitter.com\u3011 \n\n< Back \n\n * * \u301018\u2020 Cars \u3011 \n * \u301019\u2020 Electric & Hybrid Cars \u3011 \n * \u301020\u2020 Compare cars \u3011 \n * \u301021\u2020 Car Images \u3011 \n * \u301022\u2020 Advanced Search \u3011 \n\n[Image 1: menu] \n\nIt seems that you have reached a high volume of page views. \nPlease confirm that you are a human by clicking the box below. \n\nThank you! \n\n Send \n\n## Latest Car Specs\n\n\u301023\u20202023 Cupra Formentor VZ5 2.5 TSI 4Drive\u3011\u301024\u20202023 BMW G21 3 Series Touring LCI 318i Auto\u3011\u301025\u20202024 Lexus LBX 1.5 Hybrid E-Four e-CVT\u3011\u301026\u20202024 Lexus LBX 1.5 Hybrid e-CVT\u3011\n\n\u301027\u20201983 Buick Electra Coupe 1980 Limited 5.0L V8 4-speed Auto\u3011\u301028\u20201981 Buick Electra Coupe 1980 Limited 4.1 V6 4-speed Auto\u3011\u301029\u20201980 Buick Electra Coupe 1980 Limited 4.1 V6 Overdrive 4-speed Auto\u3011\u301030\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid DCT\u3011\n\n\u301031\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid Auto\u3011\u301032\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid DCT\u3011\u301033\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid Auto\u3011\u301034\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI 48V-Hybrid DCT\u3011\n\n\u301035\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI\u3011\u301036\u20202023 Hyundai i20 (BC3) 2023 1.2 MPI\u3011\u301037\u20202023 BYD Seal 83 kWh 530HP AWD\u3011\u301038\u20202023 BYD Seal 83 kWh 313HP\u3011\n\n\u301039\u20202023 BYD Dolphin 60 kWh 204HP\u3011\u301040\u20201992 Alfa Romeo 164 Super V6 Turbo\u3011\u301041\u20202023 BMW G20 3 Series Sedan LCI M340i Mild Hybrid xDrive Auto\u3011\u301042\u20202023 BMW G20 3 Series Sedan LCI 330i xDrive Auto\u3011\n\n\u301043\u20202023 BMW G20 3 Series Sedan LCI 330i Auto\u3011\u301044\u20202023 BMW G20 3 Series Sedan LCI 320i xDrive Auto\u3011\u301045\u20202023 BMW G20 3 Series Sedan LCI 320i Auto\u3011\u301046\u20202023 BMW G20 3 Series Sedan LCI 318i Auto\u3011\n\n\u301047\u20201976 Ford Pinto 2-Door Sedan 1977 2.8 V6 Cruise-O-Matic\u3011\u301048\u20201976 Ford Pinto 2-Door Sedan 1977 2.3 Cruise-O-Matic\u3011\u301049\u20201976 Ford Pinto 2-Door Sedan 1977 2.3\u3011\u301050\u2020View more\u2020ultimatespecs.com\u3011\n\n## Latest Models\n\n\u301051\u2020BMW G21 3 Series Touring LCI\u3011\u301052\u2020Lexus LBX\u3011\u301053\u2020Hyundai i20 (BC3) 2023\u3011\u301054\u2020Hyundai i20 (BC3)\u3011\n\n\u301055\u2020BYD Seal\u3011\u301056\u2020BYD Dolphin\u3011\u301057\u2020BMW G20 3 Series Sedan LCI\u3011\u301058\u2020Ford Pinto 2-Door Sedan 1977\u3011\n\n\u301059\u2020Buick Electra Coupe 1980\u3011\u301060\u2020Ford Pinto 2-Door Sedan 1976\u3011\u301061\u2020Citroen C3 Phase IV\u3011\u301062\u2020Citroen C5 X\u3011\n\n\u301063\u2020Ford Pinto 2-Door Sedan 1975\u3011\u301064\u2020Lancia Delta\u3011\u301065\u2020Ford Pinto 2-Door Sedan 1974\u3011\u301066\u2020Ford Pinto 2-Door Sedan 1973\u3011\n\n\u301067\u2020Peugeot 408\u3011\u301068\u2020Ford Pinto 2-Door Sedan 1972\u3011\u301069\u2020Renault Sc\u00e9nic 5\u3011\u301070\u2020Renault Espace 6\u3011\n\n\u301071\u2020Ford Pinto 2-Door Sedan\u3011\u301072\u2020Renault Austral\u3011\u301073\u2020Mazda RX-8 2008\u3011\u301074\u2020Bentley Bentayga EWB\u3011\n\n\u301075\u2020Bentley Bentayga 2020 Facelift\u3011\u301076\u2020Toyota GR86 2021\u3011\u301077\u2020Fiat Cinquecento\u3011\u301078\u2020Audi A8 L 2022 (D5)\u3011\n\n\u00a92024 Ultimate Specs - The Most Comprehensive Car Specifications Database. Over 46.000 technical specs!! - \u301079\u2020Change consent\u2020javascript:;\u3011 \n\n- Do not share my Personal Information.\n\n- \u301080\u2020 About \u3011- \u301081\u2020 Privacy Policy \u3011- \u301082\u2020 Contact US \u3011", + "pub_date": null, + "extra": null + } + ], + "original_query": null + }, + "command": "mclick", + "args": ["[0, 3, 7]"], + "status": "finished", + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "36b62905-c942-414a-8ad2-d5d26739efa5", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "36b62905-c942-414a-8ad2-d5d26739efa5", + "children": ["2d717cdc-b597-4850-878b-ac13e97a6696"] + }, + "2d717cdc-b597-4850-878b-ac13e97a6696": { + "id": "2d717cdc-b597-4850-878b-ac13e97a6696", + "message": { + "id": "2d717cdc-b597-4850-878b-ac13e97a6696", + "author": { + "role": "tool", + "name": "browser", + "metadata": {} + }, + "create_time": 1704629939.856997, + "update_time": null, + "content": { + "content_type": "tether_quote", + "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/", + "domain": "www.drive.com.au", + "text": "\n###### What we don't\n\n * Seven-speed dual-clutch DSG automatic can get grumbly in traffic \n * no Bluetooth connectivity \n * no cruise control \n * single sliding door only \n * expensive options and servicing\n\n[Image 41: Volkswagen Transporter 2014 30]View 31 images[Image 42: PhotoIcon]\n\nA staple of the German car maker's commercial line-up since 2003, the T5 Volkswagen Transporter remains one of the most popular options available in today's competitive Australian van segment.\n\nPriced from $36,490, the short-wheelbase \u3010178\u2020Volkswagen Transporter\u3011 comes in below its \u3010183\u2020Ford Transit Custom\u3011 ($37,490) and \u3010184\u2020Mercedes-Benz Vito\u3011 ($38,990) equivalents.\n\nThe third highest-selling van in its class, the Transporter is still north of the likes of the Chinese-built \u3010185\u2020LDV V80\u3011 ($30,800), \u3010186\u2020Hyundai iLoad\u3011 ($30,990), soon-to-be-replaced \u3010187\u2020Renault Traffic\u3011 ($32,990 driveaway) and reigning king of moving \u2018stuff\u2019 from one place to an other, the \u3010188\u2020Toyota HiAce\u3011 ($32,990) \u2013 the latter only available in long- and super-long-wheelbase configurations.\n\nPacked with a 2.0-litre four-cylinder turbo diesel and a seven-speed dual-clutch automatic transmission, our $40,990 front-wheel-drive TDI340 test car delivers 103kW at 3500rpm and 340Nm between 1750-2500rpm.\n\nPipping both the HiAce\u2019s 3.0-litre and Vito\u2019s 2.1-litre turbo diesels by 3kW and at least 30Nm, the \u301060\u2020Volkswagen\u3011 Transporter TDI340 also trumps the 85kW/290Nm 2.0-litre in the \u301051\u2020Renault\u3011 Trafic and the 100kW/330Nm 2.5-litre in the manual-only \u301033\u2020LDV\u3011 V80.\n\nAnd while the Volkswagen does fall 10Nm shy of the 92kW/350Nm 2.2-litre turbo diesel unit in the all-new sixth-generation \u301021\u2020Ford\u3011 Transit Custom, the Blue Oval\u2019s challenger is again exclusively offered with a six-speed manual transmission.\n\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals \u2013 bookended by the single-clutch-equipped Trafic and four-speed \u301059\u2020Toyota\u3011 HiAce respectively. As a result, only the Trafic (8.0L/100km) and the Transit Custom (7.1L/100km) claim sharper economy figures.\n\nComing standard with air conditioning, a two-speaker stereo, daytime running lights and 16-inch steel wheels, the entry-level automatic Transporter TDI340 misses out on the Bluetooth connectivity included on the Toyota, \u301026\u2020Hyundai\u3011, \u301044\u2020Mercedes-Benz\u3011, Ford and Renault offerings.\n\nObtaining cruise control \u2013 standard on Vito, Transit Custom, Trafic and V80 \u2013 also requires a further $490.\n\nOnce settled into the narrow and flat but still comfortable vinyl-winged cloth seat base and gripping the soft-rimmed button-free steering wheel, the Transporter isn\u2019t a bad place to be.\n\nA little utilitarian among a sea of hard-wearing dash and door trims, manual climate controls and a basic audio unit, the scratchy yet durable centre console, air vents, grab handles and plastic floor liner are all offset by one-touch driver and passenger power windows (up and down), damped indicator and wipers stalks, and Volkswagen\u2019s standard clear and simple instrument layout.\n\n### Get a great deal today \n\nInterested in this car? Provide your details and we'll connect you to a member of the Drive team.\n\nVolkswagen Transporter \n\nVolkswagen Transporter\n\nI'd like to hear about finance deals\n\nSubscribe to the newsletter \n\nBy clicking the Send Enquiry button you acknowledge that you have read and agree to the Drive \u3010189\u2020Terms and Conditions \u3011 and \u3010190\u2020Privacy Policy.\u3011\n\nSend Enquiry \n\nLimited to only Trip A and B kilometre readings \u2013 with no average fuel or average speed figures given \u2013 the gauges are joined in the cabin by a heavy-lidded but amply sized lockable glovebox and a netted storage pocket below it.\n\nAiding practicality are two flip-out cupholders, four in-dash cubby holes, a single overhead cut-out, a dash-top storage space for sunglasses and business cards and large split-level door pockets for both driver and passenger.\n\nLess easy to learn to live with are the super-low floor-mounted hard plastic handbrake lever and high NVH (noise, vibration, harshness) levels highlighted by plenty of road, tyre and engine noise.\n\nComfortable and compliant riding on tall 65-profile Continental tyres, the Volkswagen Transporter bobs along in a controlled fashion over undulations, with acceptable \u2013 and expected for a commercial van \u2013 amounts of body roll present through bends.\n\nMowing flatly through ruts, potholes and road joins with an audible thump, the 1752kg TDI340 stays on track with little fuss, and calmly hums over tramlines.\n\nConsistently light but responsive steering works together with an 11.9m turning circle and genuine handling agility to ensure punting through tight inner-city streets is a legitimately enjoyable experience.\n\nConsistent, too, are the brakes \u2013 despite being attached to a mildly slack-feeling brake pedal, heavily contrasted by a tightly sprung throttle pedal.\n\nThe engine is also a gem. Happy to complete most tasks asked of it below 2000rpm \u2013 including freeway stretches at 100km/h \u2013 the grunty turbo diesel delivers sound cruising pace from as low as 1600rpm until things noticeably drop off around 4400rpm. It\u2019ll even contently coast along at 60km/h doing 1400rpm in fifth gear.\n\nProne to some hesitation and jerkiness when responding to sporadic prods of the throttle in stop-start traffic situations, the DSG gearbox and its dash-mounted gear selector work well overall, delivering smooth ratio swaps once moving, with little to no interruption to drive.\n\nAnnoyingly, though, the transmission\u2019s own gear indicator \u2013 located on the left-hand side of the selector\u2019s base \u2013 is obstructed from the driver\u2019s view by the DSG-stamped gear lever itself. A slight ergonomic oversight, the issue can be easily circumvented by relying on the gear display in the instrument cluster, which sits next to the time and above outside temperature, trip and fuel information.\n\nShifting goods is what the Transporter\u2019s all about, though, and despite being shorter in length than the iLoad, Transit Custom and V80, the 4892mm-long Volkswagen\u2019s 5800L load volume is only bettered by the HiAce (6000L) and V80 (6400L).\n\nThe Volkswagen Transporter\u2019s 1268kg payload rating is also the pick of the bunch, while its 2000kg braked towing capacity can only be matched by the Vito and Trafic and topped by the Ford at 2500kg.\n\nSliding back the heavy passenger side door presents an area 2353mm long and 1692mm wide at its maximum. Slightly reduced due to our test van being fitted with a $690 mesh cargo barrier, the Transporter\u2019s rear end space still offers plenty of stacking room thanks to its 1410mm floor-to-roof height and 1244mm minimum width (between rear wheel guards).\n\nProviding excellent head clearance at 1305mm tall, the weighty 1486mm-wide tailgate creates a large aperture for loading items through, although some may find the six floor-mounted tie-down hooks more of a nuisance than convenient.\n\nOddly too, the Volkswagen Transporter is free of any side or roof strapping/tie-down points.\n\nAnd while the tailgate gives drivers an uninterrupted view out the back \u2013 rather than a thick join line common to barn door-style rear doors \u2013 lower rear vision is made much more difficult, particularly when reverse parking.\n\nBut where a rear-view camera is standard on the HiAce, Transporter buyers looking to match the Toyota need to fit not only a $2490 sat-nav unit but additionally a $1890 package that includes front and rear parking sensors ($890) and a rear-view camera, for a total of $4380.\n\nFortunately, the standard heated power side mirrors provide excellent assistance and rear parking sensors on their own will only set you back $490.\n\nStandard on the Mercedes-Benz Vito and Hyundai iLoad, a driver\u2019s side sliding door is also an option on the Volkswagen Transporter, priced at $1190.\n\nA driver and front passenger airbag, stability control and hill-start assist are all standard fair, and the German van is also covered under Volkswagen\u2019s three-year/unlimited kilometre warranty with three years 24hr roadside assist.\n\nWith capped-price services ranging from $435 to $508 over the first three 15,000km annual service intervals, the first three years of ownership in the Volkswagen Transporter TDI340 DSG will cost you $1577.\n\nBetween $530 and $377 dearer than servicing costs over the same time period for the iLoad, Trafic and Transit Custom \u2013 the latter including brake fluid replacement \u2013 only Volkswagen declares costs for replacing both the pollen filter and brake fluid (both required every two years).\n\nWith service intervals every 10,000km, however, the Toyota HiAce \u2013 with two services annually \u2013 still takes the crown, requiring $1020 over three years or 60,000km.\n\n", + "title": "Volkswagen Transporter Review - Drive" + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 0.0, + "metadata": { + "_cite_metadata": { + "citation_format": { + "name": "tether_og" + }, + "metadata_list": [ + { + "type": "webpage", + "title": "Volkswagen Transporter Review - Drive", + "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/", + "text": "\n###### What we don't\n\n * Seven-speed dual-clutch DSG automatic can get grumbly in traffic \n * no Bluetooth connectivity \n * no cruise control \n * single sliding door only \n * expensive options and servicing\n\n[Image 41: Volkswagen Transporter 2014 30]View 31 images[Image 42: PhotoIcon]\n\nA staple of the German car maker's commercial line-up since 2003, the T5 Volkswagen Transporter remains one of the most popular options available in today's competitive Australian van segment.\n\nPriced from $36,490, the short-wheelbase \u3010178\u2020Volkswagen Transporter\u3011 comes in below its \u3010183\u2020Ford Transit Custom\u3011 ($37,490) and \u3010184\u2020Mercedes-Benz Vito\u3011 ($38,990) equivalents.\n\nThe third highest-selling van in its class, the Transporter is still north of the likes of the Chinese-built \u3010185\u2020LDV V80\u3011 ($30,800), \u3010186\u2020Hyundai iLoad\u3011 ($30,990), soon-to-be-replaced \u3010187\u2020Renault Traffic\u3011 ($32,990 driveaway) and reigning king of moving \u2018stuff\u2019 from one place to an other, the \u3010188\u2020Toyota HiAce\u3011 ($32,990) \u2013 the latter only available in long- and super-long-wheelbase configurations.\n\nPacked with a 2.0-litre four-cylinder turbo diesel and a seven-speed dual-clutch automatic transmission, our $40,990 front-wheel-drive TDI340 test car delivers 103kW at 3500rpm and 340Nm between 1750-2500rpm.\n\nPipping both the HiAce\u2019s 3.0-litre and Vito\u2019s 2.1-litre turbo diesels by 3kW and at least 30Nm, the \u301060\u2020Volkswagen\u3011 Transporter TDI340 also trumps the 85kW/290Nm 2.0-litre in the \u301051\u2020Renault\u3011 Trafic and the 100kW/330Nm 2.5-litre in the manual-only \u301033\u2020LDV\u3011 V80.\n\nAnd while the Volkswagen does fall 10Nm shy of the 92kW/350Nm 2.2-litre turbo diesel unit in the all-new sixth-generation \u301021\u2020Ford\u3011 Transit Custom, the Blue Oval\u2019s challenger is again exclusively offered with a six-speed manual transmission.\n\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals \u2013 bookended by the single-clutch-equipped Trafic and four-speed \u301059\u2020Toyota\u3011 HiAce respectively. As a result, only the Trafic (8.0L/100km) and the Transit Custom (7.1L/100km) claim sharper economy figures.\n\nComing standard with air conditioning, a two-speaker stereo, daytime running lights and 16-inch steel wheels, the entry-level automatic Transporter TDI340 misses out on the Bluetooth connectivity included on the Toyota, \u301026\u2020Hyundai\u3011, \u301044\u2020Mercedes-Benz\u3011, Ford and Renault offerings.\n\nObtaining cruise control \u2013 standard on Vito, Transit Custom, Trafic and V80 \u2013 also requires a further $490.\n\nOnce settled into the narrow and flat but still comfortable vinyl-winged cloth seat base and gripping the soft-rimmed button-free steering wheel, the Transporter isn\u2019t a bad place to be.\n\nA little utilitarian among a sea of hard-wearing dash and door trims, manual climate controls and a basic audio unit, the scratchy yet durable centre console, air vents, grab handles and plastic floor liner are all offset by one-touch driver and passenger power windows (up and down), damped indicator and wipers stalks, and Volkswagen\u2019s standard clear and simple instrument layout.\n\n### Get a great deal today \n\nInterested in this car? Provide your details and we'll connect you to a member of the Drive team.\n\nVolkswagen Transporter \n\nVolkswagen Transporter\n\nI'd like to hear about finance deals\n\nSubscribe to the newsletter \n\nBy clicking the Send Enquiry button you acknowledge that you have read and agree to the Drive \u3010189\u2020Terms and Conditions \u3011 and \u3010190\u2020Privacy Policy.\u3011\n\nSend Enquiry \n\nLimited to only Trip A and B kilometre readings \u2013 with no average fuel or average speed figures given \u2013 the gauges are joined in the cabin by a heavy-lidded but amply sized lockable glovebox and a netted storage pocket below it.\n\nAiding practicality are two flip-out cupholders, four in-dash cubby holes, a single overhead cut-out, a dash-top storage space for sunglasses and business cards and large split-level door pockets for both driver and passenger.\n\nLess easy to learn to live with are the super-low floor-mounted hard plastic handbrake lever and high NVH (noise, vibration, harshness) levels highlighted by plenty of road, tyre and engine noise.\n\nComfortable and compliant riding on tall 65-profile Continental tyres, the Volkswagen Transporter bobs along in a controlled fashion over undulations, with acceptable \u2013 and expected for a commercial van \u2013 amounts of body roll present through bends.\n\nMowing flatly through ruts, potholes and road joins with an audible thump, the 1752kg TDI340 stays on track with little fuss, and calmly hums over tramlines.\n\nConsistently light but responsive steering works together with an 11.9m turning circle and genuine handling agility to ensure punting through tight inner-city streets is a legitimately enjoyable experience.\n\nConsistent, too, are the brakes \u2013 despite being attached to a mildly slack-feeling brake pedal, heavily contrasted by a tightly sprung throttle pedal.\n\nThe engine is also a gem. Happy to complete most tasks asked of it below 2000rpm \u2013 including freeway stretches at 100km/h \u2013 the grunty turbo diesel delivers sound cruising pace from as low as 1600rpm until things noticeably drop off around 4400rpm. It\u2019ll even contently coast along at 60km/h doing 1400rpm in fifth gear.\n\nProne to some hesitation and jerkiness when responding to sporadic prods of the throttle in stop-start traffic situations, the DSG gearbox and its dash-mounted gear selector work well overall, delivering smooth ratio swaps once moving, with little to no interruption to drive.\n\nAnnoyingly, though, the transmission\u2019s own gear indicator \u2013 located on the left-hand side of the selector\u2019s base \u2013 is obstructed from the driver\u2019s view by the DSG-stamped gear lever itself. A slight ergonomic oversight, the issue can be easily circumvented by relying on the gear display in the instrument cluster, which sits next to the time and above outside temperature, trip and fuel information.\n\nShifting goods is what the Transporter\u2019s all about, though, and despite being shorter in length than the iLoad, Transit Custom and V80, the 4892mm-long Volkswagen\u2019s 5800L load volume is only bettered by the HiAce (6000L) and V80 (6400L).\n\nThe Volkswagen Transporter\u2019s 1268kg payload rating is also the pick of the bunch, while its 2000kg braked towing capacity can only be matched by the Vito and Trafic and topped by the Ford at 2500kg.\n\nSliding back the heavy passenger side door presents an area 2353mm long and 1692mm wide at its maximum. Slightly reduced due to our test van being fitted with a $690 mesh cargo barrier, the Transporter\u2019s rear end space still offers plenty of stacking room thanks to its 1410mm floor-to-roof height and 1244mm minimum width (between rear wheel guards).\n\nProviding excellent head clearance at 1305mm tall, the weighty 1486mm-wide tailgate creates a large aperture for loading items through, although some may find the six floor-mounted tie-down hooks more of a nuisance than convenient.\n\nOddly too, the Volkswagen Transporter is free of any side or roof strapping/tie-down points.\n\nAnd while the tailgate gives drivers an uninterrupted view out the back \u2013 rather than a thick join line common to barn door-style rear doors \u2013 lower rear vision is made much more difficult, particularly when reverse parking.\n\nBut where a rear-view camera is standard on the HiAce, Transporter buyers looking to match the Toyota need to fit not only a $2490 sat-nav unit but additionally a $1890 package that includes front and rear parking sensors ($890) and a rear-view camera, for a total of $4380.\n\nFortunately, the standard heated power side mirrors provide excellent assistance and rear parking sensors on their own will only set you back $490.\n\nStandard on the Mercedes-Benz Vito and Hyundai iLoad, a driver\u2019s side sliding door is also an option on the Volkswagen Transporter, priced at $1190.\n\nA driver and front passenger airbag, stability control and hill-start assist are all standard fair, and the German van is also covered under Volkswagen\u2019s three-year/unlimited kilometre warranty with three years 24hr roadside assist.\n\nWith capped-price services ranging from $435 to $508 over the first three 15,000km annual service intervals, the first three years of ownership in the Volkswagen Transporter TDI340 DSG will cost you $1577.\n\nBetween $530 and $377 dearer than servicing costs over the same time period for the iLoad, Trafic and Transit Custom \u2013 the latter including brake fluid replacement \u2013 only Volkswagen declares costs for replacing both the pollen filter and brake fluid (both required every two years).\n\nWith service intervals every 10,000km, however, the Toyota HiAce \u2013 with two services annually \u2013 still takes the crown, requiring $1020 over three years or 60,000km.\n\n", + "pub_date": null, + "extra": null + } + ], + "original_query": null + }, + "command": "mclick", + "args": ["[0, 3, 7]"], + "status": "finished", + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "b87c7f57-a6f4-4f4f-999f-38bd70981ae0", + "children": ["5c57c3b5-35df-4b1c-ab2d-8ca76cc63629"] + }, + "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629": { + "id": "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629", + "message": { + "id": "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1704629939.865778, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "The fuel consumption of a Volkswagen Transporter can vary based on the specific model and driving conditions. For example, the 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP, which is one of the recent models, doesn't have its fuel consumption data explicitly mentioned in the sources I found. However, a review of a Volkswagen Transporter model, which is equipped with a 2.0-litre four-cylinder turbo diesel engine and a seven-speed dual-clutch automatic transmission, mentions a combined cycle fuel consumption figure of 8.2 litres per 100km. It's important to note that this figure might vary, especially when the vehicle is carrying eight people, as more weight can lead to increased fuel consumption.\n\nFor more detailed information, you might need to refer to the specific model's manual or contact a Volkswagen dealer. Also, keep in mind that real-world fuel consumption can be influenced by factors such as driving style, road conditions, and maintenance of the vehicle\u30106\u2020source\u3011\u30107\u2020source\u3011." + ] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [ + { + "start_ix": 981, + "end_ix": 991, + "citation_format_type": "tether_og", + "metadata": { + "type": "webpage", + "title": "2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions", + "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html", + "text": "\nURL: https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html\n\n\n[Image 0: English] \n\n\u30100\u2020 English \u3011 \u30101\u2020 Fran\u00e7ais \u3011 \u30102\u2020 Deutsch \u3011 \u30103\u2020 Svensk \u3011 \u30104\u2020 Portugu\u00eas PT \u3011 \u30105\u2020 Espa\u00f1ol \u3011 \u30106\u2020 \u0440\u0443\u0441\u0441\u043a\u0438\u0439 \u3011 \u30107\u2020 Italiano \u3011 \u30108\u2020 \u0431\u044a\u043b\u0433\u0430\u0440\u0441\u043a\u0438 \u3011 \u30109\u2020 Nederlands \u3011 \u301010\u2020 Polski \u3011 \u301011\u2020 Portugu\u00eas BR \u3011 \u301012\u2020 T\u00fcrk\u00e7e \u3011 \n\n\u301013\u2020 \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301013\u2020 Cars \u3011 \n\n\u301014\u2020 Motos \u3011 \n\n\u301015\u2020 Tractors \u3011 \n\n \u301016\u2020 \u2020www.facebook.com\u3011 \u301017\u2020 \u2020twitter.com\u3011 \n\n< Back \n\n * * \u301018\u2020 Cars \u3011 \n * \u301019\u2020 Electric & Hybrid Cars \u3011 \n * \u301020\u2020 Compare cars \u3011 \n * \u301021\u2020 Car Images \u3011 \n * \u301022\u2020 Advanced Search \u3011 \n\n[Image 1: menu] \n\nIt seems that you have reached a high volume of page views. \nPlease confirm that you are a human by clicking the box below. \n\nThank you! \n\n Send \n\n## Latest Car Specs\n\n\u301023\u20202023 Cupra Formentor VZ5 2.5 TSI 4Drive\u3011\u301024\u20202023 BMW G21 3 Series Touring LCI 318i Auto\u3011\u301025\u20202024 Lexus LBX 1.5 Hybrid E-Four e-CVT\u3011\u301026\u20202024 Lexus LBX 1.5 Hybrid e-CVT\u3011\n\n\u301027\u20201983 Buick Electra Coupe 1980 Limited 5.0L V8 4-speed Auto\u3011\u301028\u20201981 Buick Electra Coupe 1980 Limited 4.1 V6 4-speed Auto\u3011\u301029\u20201980 Buick Electra Coupe 1980 Limited 4.1 V6 Overdrive 4-speed Auto\u3011\u301030\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid DCT\u3011\n\n\u301031\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 100HP Hybrid Auto\u3011\u301032\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid DCT\u3011\u301033\u20202020 Hyundai i20 (BC3) 1.0 T-GDI 48V 120HP Hybrid Auto\u3011\u301034\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI 48V-Hybrid DCT\u3011\n\n\u301035\u20202023 Hyundai i20 (BC3) 2023 1.0 T-GDI\u3011\u301036\u20202023 Hyundai i20 (BC3) 2023 1.2 MPI\u3011\u301037\u20202023 BYD Seal 83 kWh 530HP AWD\u3011\u301038\u20202023 BYD Seal 83 kWh 313HP\u3011\n\n\u301039\u20202023 BYD Dolphin 60 kWh 204HP\u3011\u301040\u20201992 Alfa Romeo 164 Super V6 Turbo\u3011\u301041\u20202023 BMW G20 3 Series Sedan LCI M340i Mild Hybrid xDrive Auto\u3011\u301042\u20202023 BMW G20 3 Series Sedan LCI 330i xDrive Auto\u3011\n\n\u301043\u20202023 BMW G20 3 Series Sedan LCI 330i Auto\u3011\u301044\u20202023 BMW G20 3 Series Sedan LCI 320i xDrive Auto\u3011\u301045\u20202023 BMW G20 3 Series Sedan LCI 320i Auto\u3011\u301046\u20202023 BMW G20 3 Series Sedan LCI 318i Auto\u3011\n\n\u301047\u20201976 Ford Pinto 2-Door Sedan 1977 2.8 V6 Cruise-O-Matic\u3011\u301048\u20201976 Ford Pinto 2-Door Sedan 1977 2.3 Cruise-O-Matic\u3011\u301049\u20201976 Ford Pinto 2-Door Sedan 1977 2.3\u3011\u301050\u2020View more\u2020ultimatespecs.com\u3011\n\n## Latest Models\n\n\u301051\u2020BMW G21 3 Series Touring LCI\u3011\u301052\u2020Lexus LBX\u3011\u301053\u2020Hyundai i20 (BC3) 2023\u3011\u301054\u2020Hyundai i20 (BC3)\u3011\n\n\u301055\u2020BYD Seal\u3011\u301056\u2020BYD Dolphin\u3011\u301057\u2020BMW G20 3 Series Sedan LCI\u3011\u301058\u2020Ford Pinto 2-Door Sedan 1977\u3011\n\n\u301059\u2020Buick Electra Coupe 1980\u3011\u301060\u2020Ford Pinto 2-Door Sedan 1976\u3011\u301061\u2020Citroen C3 Phase IV\u3011\u301062\u2020Citroen C5 X\u3011\n\n\u301063\u2020Ford Pinto 2-Door Sedan 1975\u3011\u301064\u2020Lancia Delta\u3011\u301065\u2020Ford Pinto 2-Door Sedan 1974\u3011\u301066\u2020Ford Pinto 2-Door Sedan 1973\u3011\n\n\u301067\u2020Peugeot 408\u3011\u301068\u2020Ford Pinto 2-Door Sedan 1972\u3011\u301069\u2020Renault Sc\u00e9nic 5\u3011\u301070\u2020Renault Espace 6\u3011\n\n\u301071\u2020Ford Pinto 2-Door Sedan\u3011\u301072\u2020Renault Austral\u3011\u301073\u2020Mazda RX-8 2008\u3011\u301074\u2020Bentley Bentayga EWB\u3011\n\n\u301075\u2020Bentley Bentayga 2020 Facelift\u3011\u301076\u2020Toyota GR86 2021\u3011\u301077\u2020Fiat Cinquecento\u3011\u301078\u2020Audi A8 L 2022 (D5)\u3011\n\n\u00a92024 Ultimate Specs - The Most Comprehensive Car Specifications Database. Over 46.000 technical specs!! - \u301079\u2020Change consent\u2020javascript:;\u3011 \n\n- Do not share my Personal Information.\n\n- \u301080\u2020 About \u3011- \u301081\u2020 Privacy Policy \u3011- \u301082\u2020 Contact US \u3011", + "pub_date": null, + "extra": { + "cited_message_idx": 6, + "evidence_text": "source" + } + } + }, + { + "start_ix": 991, + "end_ix": 1001, + "citation_format_type": "tether_og", + "metadata": { + "type": "webpage", + "title": "Volkswagen Transporter Review - Drive", + "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/", + "text": "\n###### What we don't\n\n * Seven-speed dual-clutch DSG automatic can get grumbly in traffic \n * no Bluetooth connectivity \n * no cruise control \n * single sliding door only \n * expensive options and servicing\n\n[Image 41: Volkswagen Transporter 2014 30]View 31 images[Image 42: PhotoIcon]\n\nA staple of the German car maker's commercial line-up since 2003, the T5 Volkswagen Transporter remains one of the most popular options available in today's competitive Australian van segment.\n\nPriced from $36,490, the short-wheelbase \u3010178\u2020Volkswagen Transporter\u3011 comes in below its \u3010183\u2020Ford Transit Custom\u3011 ($37,490) and \u3010184\u2020Mercedes-Benz Vito\u3011 ($38,990) equivalents.\n\nThe third highest-selling van in its class, the Transporter is still north of the likes of the Chinese-built \u3010185\u2020LDV V80\u3011 ($30,800), \u3010186\u2020Hyundai iLoad\u3011 ($30,990), soon-to-be-replaced \u3010187\u2020Renault Traffic\u3011 ($32,990 driveaway) and reigning king of moving \u2018stuff\u2019 from one place to an other, the \u3010188\u2020Toyota HiAce\u3011 ($32,990) \u2013 the latter only available in long- and super-long-wheelbase configurations.\n\nPacked with a 2.0-litre four-cylinder turbo diesel and a seven-speed dual-clutch automatic transmission, our $40,990 front-wheel-drive TDI340 test car delivers 103kW at 3500rpm and 340Nm between 1750-2500rpm.\n\nPipping both the HiAce\u2019s 3.0-litre and Vito\u2019s 2.1-litre turbo diesels by 3kW and at least 30Nm, the \u301060\u2020Volkswagen\u3011 Transporter TDI340 also trumps the 85kW/290Nm 2.0-litre in the \u301051\u2020Renault\u3011 Trafic and the 100kW/330Nm 2.5-litre in the manual-only \u301033\u2020LDV\u3011 V80.\n\nAnd while the Volkswagen does fall 10Nm shy of the 92kW/350Nm 2.2-litre turbo diesel unit in the all-new sixth-generation \u301021\u2020Ford\u3011 Transit Custom, the Blue Oval\u2019s challenger is again exclusively offered with a six-speed manual transmission.\n\nHelping the Volkswagen Transporter claim a combined cycle fuel consumption figure of 8.2 litres per 100km, the seven-speed auto is also up by between one and three ratios on its main rivals \u2013 bookended by the single-clutch-equipped Trafic and four-speed \u301059\u2020Toyota\u3011 HiAce respectively. As a result, only the Trafic (8.0L/100km) and the Transit Custom (7.1L/100km) claim sharper economy figures.\n\nComing standard with air conditioning, a two-speaker stereo, daytime running lights and 16-inch steel wheels, the entry-level automatic Transporter TDI340 misses out on the Bluetooth connectivity included on the Toyota, \u301026\u2020Hyundai\u3011, \u301044\u2020Mercedes-Benz\u3011, Ford and Renault offerings.\n\nObtaining cruise control \u2013 standard on Vito, Transit Custom, Trafic and V80 \u2013 also requires a further $490.\n\nOnce settled into the narrow and flat but still comfortable vinyl-winged cloth seat base and gripping the soft-rimmed button-free steering wheel, the Transporter isn\u2019t a bad place to be.\n\nA little utilitarian among a sea of hard-wearing dash and door trims, manual climate controls and a basic audio unit, the scratchy yet durable centre console, air vents, grab handles and plastic floor liner are all offset by one-touch driver and passenger power windows (up and down), damped indicator and wipers stalks, and Volkswagen\u2019s standard clear and simple instrument layout.\n\n### Get a great deal today \n\nInterested in this car? Provide your details and we'll connect you to a member of the Drive team.\n\nVolkswagen Transporter \n\nVolkswagen Transporter\n\nI'd like to hear about finance deals\n\nSubscribe to the newsletter \n\nBy clicking the Send Enquiry button you acknowledge that you have read and agree to the Drive \u3010189\u2020Terms and Conditions \u3011 and \u3010190\u2020Privacy Policy.\u3011\n\nSend Enquiry \n\nLimited to only Trip A and B kilometre readings \u2013 with no average fuel or average speed figures given \u2013 the gauges are joined in the cabin by a heavy-lidded but amply sized lockable glovebox and a netted storage pocket below it.\n\nAiding practicality are two flip-out cupholders, four in-dash cubby holes, a single overhead cut-out, a dash-top storage space for sunglasses and business cards and large split-level door pockets for both driver and passenger.\n\nLess easy to learn to live with are the super-low floor-mounted hard plastic handbrake lever and high NVH (noise, vibration, harshness) levels highlighted by plenty of road, tyre and engine noise.\n\nComfortable and compliant riding on tall 65-profile Continental tyres, the Volkswagen Transporter bobs along in a controlled fashion over undulations, with acceptable \u2013 and expected for a commercial van \u2013 amounts of body roll present through bends.\n\nMowing flatly through ruts, potholes and road joins with an audible thump, the 1752kg TDI340 stays on track with little fuss, and calmly hums over tramlines.\n\nConsistently light but responsive steering works together with an 11.9m turning circle and genuine handling agility to ensure punting through tight inner-city streets is a legitimately enjoyable experience.\n\nConsistent, too, are the brakes \u2013 despite being attached to a mildly slack-feeling brake pedal, heavily contrasted by a tightly sprung throttle pedal.\n\nThe engine is also a gem. Happy to complete most tasks asked of it below 2000rpm \u2013 including freeway stretches at 100km/h \u2013 the grunty turbo diesel delivers sound cruising pace from as low as 1600rpm until things noticeably drop off around 4400rpm. It\u2019ll even contently coast along at 60km/h doing 1400rpm in fifth gear.\n\nProne to some hesitation and jerkiness when responding to sporadic prods of the throttle in stop-start traffic situations, the DSG gearbox and its dash-mounted gear selector work well overall, delivering smooth ratio swaps once moving, with little to no interruption to drive.\n\nAnnoyingly, though, the transmission\u2019s own gear indicator \u2013 located on the left-hand side of the selector\u2019s base \u2013 is obstructed from the driver\u2019s view by the DSG-stamped gear lever itself. A slight ergonomic oversight, the issue can be easily circumvented by relying on the gear display in the instrument cluster, which sits next to the time and above outside temperature, trip and fuel information.\n\nShifting goods is what the Transporter\u2019s all about, though, and despite being shorter in length than the iLoad, Transit Custom and V80, the 4892mm-long Volkswagen\u2019s 5800L load volume is only bettered by the HiAce (6000L) and V80 (6400L).\n\nThe Volkswagen Transporter\u2019s 1268kg payload rating is also the pick of the bunch, while its 2000kg braked towing capacity can only be matched by the Vito and Trafic and topped by the Ford at 2500kg.\n\nSliding back the heavy passenger side door presents an area 2353mm long and 1692mm wide at its maximum. Slightly reduced due to our test van being fitted with a $690 mesh cargo barrier, the Transporter\u2019s rear end space still offers plenty of stacking room thanks to its 1410mm floor-to-roof height and 1244mm minimum width (between rear wheel guards).\n\nProviding excellent head clearance at 1305mm tall, the weighty 1486mm-wide tailgate creates a large aperture for loading items through, although some may find the six floor-mounted tie-down hooks more of a nuisance than convenient.\n\nOddly too, the Volkswagen Transporter is free of any side or roof strapping/tie-down points.\n\nAnd while the tailgate gives drivers an uninterrupted view out the back \u2013 rather than a thick join line common to barn door-style rear doors \u2013 lower rear vision is made much more difficult, particularly when reverse parking.\n\nBut where a rear-view camera is standard on the HiAce, Transporter buyers looking to match the Toyota need to fit not only a $2490 sat-nav unit but additionally a $1890 package that includes front and rear parking sensors ($890) and a rear-view camera, for a total of $4380.\n\nFortunately, the standard heated power side mirrors provide excellent assistance and rear parking sensors on their own will only set you back $490.\n\nStandard on the Mercedes-Benz Vito and Hyundai iLoad, a driver\u2019s side sliding door is also an option on the Volkswagen Transporter, priced at $1190.\n\nA driver and front passenger airbag, stability control and hill-start assist are all standard fair, and the German van is also covered under Volkswagen\u2019s three-year/unlimited kilometre warranty with three years 24hr roadside assist.\n\nWith capped-price services ranging from $435 to $508 over the first three 15,000km annual service intervals, the first three years of ownership in the Volkswagen Transporter TDI340 DSG will cost you $1577.\n\nBetween $530 and $377 dearer than servicing costs over the same time period for the iLoad, Trafic and Transit Custom \u2013 the latter including brake fluid replacement \u2013 only Volkswagen declares costs for replacing both the pollen filter and brake fluid (both required every two years).\n\nWith service intervals every 10,000km, however, the Toyota HiAce \u2013 with two services annually \u2013 still takes the crown, requiring $1020 over three years or 60,000km.\n\n", + "pub_date": null, + "extra": { + "cited_message_idx": 7, + "evidence_text": "source" + } + } + } + ], + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "2d717cdc-b597-4850-878b-ac13e97a6696", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "2d717cdc-b597-4850-878b-ac13e97a6696", + "children": ["bbb2682f-b4f7-49f8-b842-5352f8b6e3c0"] + }, + "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0": { + "id": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0", + "message": { + "id": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1704629961.277709, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["What about 10 year old model"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "unknown" + }, + "citations": [], + "voice_mode_message": false, + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "5c57c3b5-35df-4b1c-ab2d-8ca76cc63629", + "children": ["bea0c246-0b82-4df0-88e3-e28c9c28b909"] + }, + "bea0c246-0b82-4df0-88e3-e28c9c28b909": { + "id": "bea0c246-0b82-4df0-88e3-e28c9c28b909", + "message": { + "id": "bea0c246-0b82-4df0-88e3-e28c9c28b909", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1704629977.563998, + "update_time": null, + "content": { + "content_type": "code", + "language": "unknown", + "text": "search(\"2014 Volkswagen Transporter fuel consumption\")" + }, + "status": "finished_successfully", + "end_turn": false, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100265] + }, + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0", + "timestamp_": "absolute" + }, + "recipient": "browser" + }, + "parent": "bbb2682f-b4f7-49f8-b842-5352f8b6e3c0", + "children": ["3db71263-feb6-445a-b60c-dddc8302fa52"] + }, + "3db71263-feb6-445a-b60c-dddc8302fa52": { + "id": "3db71263-feb6-445a-b60c-dddc8302fa52", + "message": { + "id": "3db71263-feb6-445a-b60c-dddc8302fa52", + "author": { + "role": "tool", + "name": "browser", + "metadata": {} + }, + "create_time": 1704629977.577253, + "update_time": null, + "content": { + "content_type": "tether_browsing_display", + "result": "# \u30100\u20202014 Volkswagen Transporter MPG - Actual MPG from 14 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nBased on data from 14 vehicles, 639 fuel-ups and 260,822 miles of driving, the 2014 Volkswagen Transporter gets a combined Avg MPG of 23.76 with a 0.37 MPG margin of error. Below you can see a distribution of the fuel-ups with 24 outliers (3.62%) removed. Following shows the average MPG of each of the 14 vehicles in the system. T5.1 Transporter\n# \u30101\u2020Gas Mileage of 2014 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nFuel Economy of 2014 Volkswagen Vehicles Search by Manufacturer. Search by make for fuel efficient new and used cars and trucks\n# \u30102\u20202014 Volkswagen Transporter MPG - Actual MPG from 18 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nAdded Feb 2015 \u2022 171 Fuel-ups. ... 2014 Volkswagen Transporter 2.0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Apr 2018 \u2022 134 Fuel-ups. Property of Elec61 . 25.4 Avg MPG. Transporter. 2014 Volkswagen Transporter 2.0L L4 DIESEL Manual 6 Speed Van Camper Added Jun 2021 \u2022 26 Fuel-ups. Property of damian1471 .\n# \u30103\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n2019 27.1 Avg MPG 6 Vehicles 416 Fuel-ups 178,106 Miles Tracked View All 2019 Volkswagen Transporters 2018 26.5 Avg MPG 15 Vehicles 622 Fuel-ups 235,245 Miles Tracked View All 2018 Volkswagen Transporters 2017 28.2 Avg MPG 13 Vehicles 1,287 Fuel-ups 554,573 Miles Tracked View All 2017 Volkswagen Transporters 2016 26.5 Avg MPG 22 Vehicles\n# \u30104\u2020Volkswagen Transporter 2014 | CarsGuide\u2020www.carsguide.com.au\u3011\nFind all of our 2014 Volkswagen Transporter Reviews, Videos, FAQs & News in one place. Learn how it drives and what features set the 2014 Volkswagen Transporter apart from its rivals. Our comprehensive reviews include detailed ratings on Price and Features, Design, Practicality, Engine, Fuel Consumption, Ownership, Driving & Safety.\n# \u30105\u20202014 Volkswagen Transporter Review, Price and Specification\u2020www.carexpert.com.au\u3011\nPrice $12,400 - $29,500 About the Transporter The 2014 Volkswagen Transporter was available in one hundred and twenty-four variants, is classed as a Van and was built in Germany. It uses Diesel fuel. The 2014 Volkswagen Transporter was sold with an engine size of 2.0L and with turbocharged four-cylinder. Official Links\n# \u30106\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Technical Specs 2019,2020,2021: 150 PS (148 hp), Diesel, Fuel consumption:6.7 l/100km (35 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u30107\u2020Volkswagen T5 Transporter (2013-2015) van review | Auto Express\u2020www.autoexpress.co.uk\u3011\nOptional BlueMotion Technology delivers lower emissions and improved fuel consumption on 83bhp and 112bhp versions, while there\u2019s also a 112bhp Transporter BlueMotion promising nearly 45mpg ...\n# \u30108\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nReview 4 doors, 2 seats 2.0DT, 4 cyl. 103kW, 340Nm Diesel 8.2L/100KM FWD Auto (DCT) 3 Yr, Unltd KMs NA See Pricing + Full Specs All Work Cars Best Vans All Volkswagen Volkswagen Transporter Review David Zalstein 12:44 12 May 2014 0 comments\n# \u30109\u2020Volkswagen Transporter van review (2010-2015) - Parkers\u2020www.parkers.co.uk\u3011\n4.5 out of 5 4.5 This Transporter was naturally powered by a new generation 2.0-litre turbodiesel engine, which is both smooth and refined. It was offered in four different power outputs. The 84 and 102hp versions come with a five-speed gearbox that is well-suited to the wide spread of power.\n# \u301010\u2020Fuel Economy Trip Calculator\u2020www.fueleconomy.gov\u3011\nYou will be able to modify the route by dragging the route line on the map. section above will be updated with the cost of fuel for the trip. This website is administered by Oak Ridge National Laboratory for the U.S. Department of Energy and the U.S. Environmental Protection Agency. My Trip Calculator. Plan your route, estimate fuel costs, and ...\n# \u301011\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301012\u2020Volkswagen Transporter T4 MPG - Car Emissions\u2020www.car-emissions.com\u3011\nFuel Economy, Road Tax & Emissions. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen Transporter T4 cars. average fuel consumption is 46.1 MPG or 6.6 litres/100km. and average CO2 output is 166.0 g/km. based on 6782 models.\n# \u301013\u20202014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 - Carsales\u2020www.carsales.com.au\u3011\nFuel Consumption Combined. 8.2 L/100km . Fuel Consumption Extra Urban. 6.9 L/100km . Fuel Consumption Urban. 10.2 L/100km ... 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY15 $18,600* Excl. Govt. Charges Volkswagen Transporter Car Reviews. View all. Review 21 ...\n# \u301014\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Technical Specs 2019,2020,2021: 110 PS (109 hp), Diesel, Fuel consumption:6.4 l/100km (37 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u301015\u2020Volkswagen T5 Transporter Combi 2.0 TDI (140 hp) 2009-2015 MPG, WLTP ...\u2020motoreu.com\u3011\nConsumption in city is 10.2 lt/100km, consumption in highway is 6.9 lt/100km and combined fuel consumption is 8.1 lt/100km according to official factory data. Estimated T5 Transporter Combi 2.0 TDI Short 4MOTION real World MPG/consumption is 12,4 lt/100km. It is expected to consume 48 percent more fuel than factory data in real conditions and ...\n# \u301016\u20202015 Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2\u2020www.auto-data.net\u3011\nWhat is the body type, Volkswagen Transporter (T6) Kombi? Van, 4-5 Doors, 4-9 Seats What is the fuel economy, Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2?\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u2020Volkswagen MPG (Fuel Consumption) - Car Emissions\u2020www.car-emissions.com\u3011\n166.0 g/km What is the average MPG (fuel economy) of a Volkswagen ? Volkswagen average fuel consumption is 46.1 MPG or 6.6 litres/100km and average CO2 output is 166.0 g/km based on 6782 models. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen cars. Data table - click on a title to order by column:\n# \u301019\u2020Rental Truck Fuel Calculator | U-Pack\u2020www.upack.com\u3011\nMiles \u00f7 MPG x Gas Price = Minimum Fuel Cost. ( Miles = total distance of the trip) ( MPG = Moving Truck average mile per gallon) ( Gas Price = average cost of gas per gallon) For example, if you\u2019re planning to drive 850 miles in a U-Haul \u00ae truck that gets 10 miles to the gallon, and gas averages $2.50, you should budget around $212.50 for ...\nVisible: 0% - 100%", + "summary": null, + "assets": [] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 0.0, + "metadata": { + "_cite_metadata": { + "citation_format": { + "name": "tether_og" + }, + "metadata_list": [ + { + "type": "webpage", + "title": "2014 Volkswagen Transporter MPG - Actual MPG from 14 2014 ... - Fuelly", + "url": "https://www.fuelly.com/car/volkswagen/transporter/2014", + "text": "\nBased on data from 14 vehicles, 639 fuel-ups and 260,822 miles of driving, the 2014 Volkswagen Transporter gets a combined Avg MPG of 23.76 with a 0.37 MPG margin of error. Below you can see a distribution of the fuel-ups with 24 outliers (3.62%) removed. Following shows the average MPG of each of the 14 vehicles in the system. T5.1 Transporter\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Gas Mileage of 2014 Vehicles by Volkswagen - FuelEconomy.gov", + "url": "https://www.fueleconomy.gov/feg/bymake/Volkswagen2014.shtml", + "text": "\nFuel Economy of 2014 Volkswagen Vehicles Search by Manufacturer. Search by make for fuel efficient new and used cars and trucks\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2014 Volkswagen Transporter MPG - Actual MPG from 18 2014 ... - Fuelly", + "url": "https://www.fuelly.com/car/Volkswagen/Transporter/2014/all", + "text": "\nAdded Feb 2015 \u2022 171 Fuel-ups. ... 2014 Volkswagen Transporter 2.0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Apr 2018 \u2022 134 Fuel-ups. Property of Elec61 . 25.4 Avg MPG. Transporter. 2014 Volkswagen Transporter 2.0L L4 DIESEL Manual 6 Speed Van Camper Added Jun 2021 \u2022 26 Fuel-ups. Property of damian1471 .\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly", + "url": "https://www.fuelly.com/car/volkswagen/transporter", + "text": "\n2019 27.1 Avg MPG 6 Vehicles 416 Fuel-ups 178,106 Miles Tracked View All 2019 Volkswagen Transporters 2018 26.5 Avg MPG 15 Vehicles 622 Fuel-ups 235,245 Miles Tracked View All 2018 Volkswagen Transporters 2017 28.2 Avg MPG 13 Vehicles 1,287 Fuel-ups 554,573 Miles Tracked View All 2017 Volkswagen Transporters 2016 26.5 Avg MPG 22 Vehicles\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter 2014 | CarsGuide", + "url": "https://www.carsguide.com.au/volkswagen/transporter/2014", + "text": "\nFind all of our 2014 Volkswagen Transporter Reviews, Videos, FAQs & News in one place. Learn how it drives and what features set the 2014 Volkswagen Transporter apart from its rivals. Our comprehensive reviews include detailed ratings on Price and Features, Design, Practicality, Engine, Fuel Consumption, Ownership, Driving & Safety.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2014 Volkswagen Transporter Review, Price and Specification", + "url": "https://www.carexpert.com.au/volkswagen/transporter/2014", + "text": "\nPrice $12,400 - $29,500 About the Transporter The 2014 Volkswagen Transporter was available in one hundred and twenty-four variants, is classed as a Van and was built in Germany. It uses Diesel fuel. The 2014 Volkswagen Transporter was sold with an engine size of 2.0L and with turbocharged four-cylinder. Official Links\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs", + "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118425/Volkswagen-Transporter-T61-L2H1-20-TDI-150HP.html", + "text": "\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Technical Specs 2019,2020,2021: 150 PS (148 hp), Diesel, Fuel consumption:6.7 l/100km (35 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen T5 Transporter (2013-2015) van review | Auto Express", + "url": "https://www.autoexpress.co.uk/volkswagen/transporter/64655/2013-2015-van", + "text": "\nOptional BlueMotion Technology delivers lower emissions and improved fuel consumption on 83bhp and 112bhp versions, while there\u2019s also a 112bhp Transporter BlueMotion promising nearly 45mpg ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter Review - Drive", + "url": "https://www.drive.com.au/reviews/volkswagen-transporter-review/", + "text": "\nReview 4 doors, 2 seats 2.0DT, 4 cyl. 103kW, 340Nm Diesel 8.2L/100KM FWD Auto (DCT) 3 Yr, Unltd KMs NA See Pricing + Full Specs All Work Cars Best Vans All Volkswagen Volkswagen Transporter Review David Zalstein 12:44 12 May 2014 0 comments\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter van review (2010-2015) - Parkers", + "url": "https://www.parkers.co.uk/vans-pickups/volkswagen/transporter/2010-review/", + "text": "\n4.5 out of 5 4.5 This Transporter was naturally powered by a new generation 2.0-litre turbodiesel engine, which is both smooth and refined. It was offered in four different power outputs. The 84 and 102hp versions come with a five-speed gearbox that is well-suited to the wide spread of power.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Fuel Economy Trip Calculator", + "url": "https://www.fueleconomy.gov/trip/", + "text": "\nYou will be able to modify the route by dragging the route line on the map. section above will be updated with the cost of fuel for the trip. This website is administered by Oak Ridge National Laboratory for the U.S. Department of Energy and the U.S. Environmental Protection Agency. My Trip Calculator. Plan your route, estimate fuel costs, and ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen | Technical Specs, Fuel consumption, Dimensions", + "url": "https://www.auto-data.net/en/volkswagen-brand-80", + "text": "\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter T4 MPG - Car Emissions", + "url": "https://www.car-emissions.com/cars/index/volkswagen+transporter+t4", + "text": "\nFuel Economy, Road Tax & Emissions. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen Transporter T4 cars. average fuel consumption is 46.1 MPG or 6.6 litres/100km. and average CO2 output is 166.0 g/km. based on 6782 models.\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 - Carsales", + "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/", + "text": "\nFuel Consumption Combined. 8.2 L/100km . Fuel Consumption Extra Urban. 6.9 L/100km . Fuel Consumption Urban. 10.2 L/100km ... 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY15 $18,600* Excl. Govt. Charges Volkswagen Transporter Car Reviews. View all. Review 21 ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs", + "url": "https://www.ultimatespecs.com/car-specs/Volkswagen/118424/Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html", + "text": "\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Technical Specs 2019,2020,2021: 110 PS (109 hp), Diesel, Fuel consumption:6.4 l/100km (37 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen T5 Transporter Combi 2.0 TDI (140 hp) 2009-2015 MPG, WLTP ...", + "url": "https://motoreu.com/volkswagen-t5-transporter-combi-2.0-tdi-mpg-fuel-consumption-technical-specifications-171706", + "text": "\nConsumption in city is 10.2 lt/100km, consumption in highway is 6.9 lt/100km and combined fuel consumption is 8.1 lt/100km according to official factory data. Estimated T5 Transporter Combi 2.0 TDI Short 4MOTION real World MPG/consumption is 12,4 lt/100km. It is expected to consume 48 percent more fuel than factory data in real conditions and ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "2015 Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2", + "url": "https://www.auto-data.net/en/volkswagen-transporter-t6-kombi-2.0-tsi-150hp-l1h2-49664", + "text": "\nWhat is the body type, Volkswagen Transporter (T6) Kombi? Van, 4-5 Doors, 4-9 Seats What is the fuel economy, Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2?\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen Type 2 - Wikipedia", + "url": "https://en.wikipedia.org/wiki/Volkswagen_Type_2", + "text": "\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Volkswagen MPG (Fuel Consumption) - Car Emissions", + "url": "https://www.car-emissions.com/cars/model/volkswagen/", + "text": "\n166.0 g/km What is the average MPG (fuel economy) of a Volkswagen ? Volkswagen average fuel consumption is 46.1 MPG or 6.6 litres/100km and average CO2 output is 166.0 g/km based on 6782 models. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen cars. Data table - click on a title to order by column:\n", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Rental Truck Fuel Calculator | U-Pack", + "url": "https://www.upack.com/moving-resources/documents-and-tools/fuel-calculator", + "text": "\nMiles \u00f7 MPG x Gas Price = Minimum Fuel Cost. ( Miles = total distance of the trip) ( MPG = Moving Truck average mile per gallon) ( Gas Price = average cost of gas per gallon) For example, if you\u2019re planning to drive 850 miles in a U-Haul \u00ae truck that gets 10 miles to the gallon, and gas averages $2.50, you should budget around $212.50 for ...\nVisible: 0% - 100%", + "pub_date": null, + "extra": null + }, + { + "type": "webpage", + "title": "Search results for query: '2014 Volkswagen Transporter fuel consumption'", + "url": "", + "text": "# \u30100\u20202014 Volkswagen Transporter MPG - Actual MPG from 14 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nBased on data from 14 vehicles, 639 fuel-ups and 260,822 miles of driving, the 2014 Volkswagen Transporter gets a combined Avg MPG of 23.76 with a 0.37 MPG margin of error. Below you can see a distribution of the fuel-ups with 24 outliers (3.62%) removed. Following shows the average MPG of each of the 14 vehicles in the system. T5.1 Transporter\n# \u30101\u2020Gas Mileage of 2014 Vehicles by Volkswagen - FuelEconomy.gov\u2020www.fueleconomy.gov\u3011\nFuel Economy of 2014 Volkswagen Vehicles Search by Manufacturer. Search by make for fuel efficient new and used cars and trucks\n# \u30102\u20202014 Volkswagen Transporter MPG - Actual MPG from 18 2014 ... - Fuelly\u2020www.fuelly.com\u3011\nAdded Feb 2015 \u2022 171 Fuel-ups. ... 2014 Volkswagen Transporter 2.0L L4 DIESEL Automatic Dual Clutch 7 Speed Standard Passenger Van Added Apr 2018 \u2022 134 Fuel-ups. Property of Elec61 . 25.4 Avg MPG. Transporter. 2014 Volkswagen Transporter 2.0L L4 DIESEL Manual 6 Speed Van Camper Added Jun 2021 \u2022 26 Fuel-ups. Property of damian1471 .\n# \u30103\u2020Volkswagen Transporter MPG - Actual MPG from 528 Volkswagen ... - Fuelly\u2020www.fuelly.com\u3011\n2019 27.1 Avg MPG 6 Vehicles 416 Fuel-ups 178,106 Miles Tracked View All 2019 Volkswagen Transporters 2018 26.5 Avg MPG 15 Vehicles 622 Fuel-ups 235,245 Miles Tracked View All 2018 Volkswagen Transporters 2017 28.2 Avg MPG 13 Vehicles 1,287 Fuel-ups 554,573 Miles Tracked View All 2017 Volkswagen Transporters 2016 26.5 Avg MPG 22 Vehicles\n# \u30104\u2020Volkswagen Transporter 2014 | CarsGuide\u2020www.carsguide.com.au\u3011\nFind all of our 2014 Volkswagen Transporter Reviews, Videos, FAQs & News in one place. Learn how it drives and what features set the 2014 Volkswagen Transporter apart from its rivals. Our comprehensive reviews include detailed ratings on Price and Features, Design, Practicality, Engine, Fuel Consumption, Ownership, Driving & Safety.\n# \u30105\u20202014 Volkswagen Transporter Review, Price and Specification\u2020www.carexpert.com.au\u3011\nPrice $12,400 - $29,500 About the Transporter The 2014 Volkswagen Transporter was available in one hundred and twenty-four variants, is classed as a Van and was built in Germany. It uses Diesel fuel. The 2014 Volkswagen Transporter was sold with an engine size of 2.0L and with turbocharged four-cylinder. Official Links\n# \u30106\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Technical Specs 2019,2020,2021: 150 PS (148 hp), Diesel, Fuel consumption:6.7 l/100km (35 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 150HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u30107\u2020Volkswagen T5 Transporter (2013-2015) van review | Auto Express\u2020www.autoexpress.co.uk\u3011\nOptional BlueMotion Technology delivers lower emissions and improved fuel consumption on 83bhp and 112bhp versions, while there\u2019s also a 112bhp Transporter BlueMotion promising nearly 45mpg ...\n# \u30108\u2020Volkswagen Transporter Review - Drive\u2020www.drive.com.au\u3011\nReview 4 doors, 2 seats 2.0DT, 4 cyl. 103kW, 340Nm Diesel 8.2L/100KM FWD Auto (DCT) 3 Yr, Unltd KMs NA See Pricing + Full Specs All Work Cars Best Vans All Volkswagen Volkswagen Transporter Review David Zalstein 12:44 12 May 2014 0 comments\n# \u30109\u2020Volkswagen Transporter van review (2010-2015) - Parkers\u2020www.parkers.co.uk\u3011\n4.5 out of 5 4.5 This Transporter was naturally powered by a new generation 2.0-litre turbodiesel engine, which is both smooth and refined. It was offered in four different power outputs. The 84 and 102hp versions come with a five-speed gearbox that is well-suited to the wide spread of power.\n# \u301010\u2020Fuel Economy Trip Calculator\u2020www.fueleconomy.gov\u3011\nYou will be able to modify the route by dragging the route line on the map. section above will be updated with the cost of fuel for the trip. This website is administered by Oak Ridge National Laboratory for the U.S. Department of Energy and the U.S. Environmental Protection Agency. My Trip Calculator. Plan your route, estimate fuel costs, and ...\n# \u301011\u2020Volkswagen | Technical Specs, Fuel consumption, Dimensions\u2020www.auto-data.net\u3011\nThe 1.8 T has 5 valves per cylinder - 3 intake and 2 exhaust. Dacia Ford GMC Haval Honda Hummer Jaguar Jeep Koenigsegg Lada Lamborghini Lancia Lexus Maserati. Volkswagen | Technical Specs, Fuel consumption, Dimensions, Power, Maximum speed, Torque, Acceleration 0 - 100 km/h, Engine displacement, Drive wheel, Tires size, Body type, Doors, Seats.\n# \u301012\u2020Volkswagen Transporter T4 MPG - Car Emissions\u2020www.car-emissions.com\u3011\nFuel Economy, Road Tax & Emissions. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen Transporter T4 cars. average fuel consumption is 46.1 MPG or 6.6 litres/100km. and average CO2 output is 166.0 g/km. based on 6782 models.\n# \u301013\u20202014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 - Carsales\u2020www.carsales.com.au\u3011\nFuel Consumption Combined. 8.2 L/100km . Fuel Consumption Extra Urban. 6.9 L/100km . Fuel Consumption Urban. 10.2 L/100km ... 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY15 $18,600* Excl. Govt. Charges Volkswagen Transporter Car Reviews. View all. Review 21 ...\n# \u301014\u2020Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Specs\u2020www.ultimatespecs.com\u3011\nVolkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Technical Specs 2019,2020,2021: 110 PS (109 hp), Diesel, Fuel consumption:6.4 l/100km (37 MPG), Dimensions: Length: 208.82 in (530.4 cm), Width: 74.96 in (190.4 cm), Height: 78.35 in (199.0 cm) ... Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP Fuel Consumption (Economy), Emissions and Range. Fuel ...\n# \u301015\u2020Volkswagen T5 Transporter Combi 2.0 TDI (140 hp) 2009-2015 MPG, WLTP ...\u2020motoreu.com\u3011\nConsumption in city is 10.2 lt/100km, consumption in highway is 6.9 lt/100km and combined fuel consumption is 8.1 lt/100km according to official factory data. Estimated T5 Transporter Combi 2.0 TDI Short 4MOTION real World MPG/consumption is 12,4 lt/100km. It is expected to consume 48 percent more fuel than factory data in real conditions and ...\n# \u301016\u20202015 Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2\u2020www.auto-data.net\u3011\nWhat is the body type, Volkswagen Transporter (T6) Kombi? Van, 4-5 Doors, 4-9 Seats What is the fuel economy, Volkswagen Transporter (T6) Kombi 2.0 TSI (150 Hp) L1H2?\n# \u301017\u2020Volkswagen Type 2 - Wikipedia\u2020en.wikipedia.org\u3011\nThe Volkswagen Type 2 is a forward control light commercial vehicle introduced in 1950 by the German automaker Volkswagen as its second car model.Known officially (depending on body type) as the Transporter, Kombi or Microbus, or, informally, as the Volkswagen Station Wagon (US), Bus (also US), Camper (UK) or Bulli (Germany), it was given the factory designation Type 2 as it followed \u2013 and ...\n# \u301018\u2020Volkswagen MPG (Fuel Consumption) - Car Emissions\u2020www.car-emissions.com\u3011\n166.0 g/km What is the average MPG (fuel economy) of a Volkswagen ? Volkswagen average fuel consumption is 46.1 MPG or 6.6 litres/100km and average CO2 output is 166.0 g/km based on 6782 models. CO2 (carbon dioxide), mileage, fuel economy and other emissions data for Volkswagen cars. Data table - click on a title to order by column:\n# \u301019\u2020Rental Truck Fuel Calculator | U-Pack\u2020www.upack.com\u3011\nMiles \u00f7 MPG x Gas Price = Minimum Fuel Cost. ( Miles = total distance of the trip) ( MPG = Moving Truck average mile per gallon) ( Gas Price = average cost of gas per gallon) For example, if you\u2019re planning to drive 850 miles in a U-Haul \u00ae truck that gets 10 miles to the gallon, and gas averages $2.50, you should budget around $212.50 for ...\nVisible: 0% - 100%", + "pub_date": null, + "extra": null + } + ], + "original_query": null + }, + "command": "search", + "args": ["2014 Volkswagen Transporter fuel consumption"], + "status": "finished", + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "bea0c246-0b82-4df0-88e3-e28c9c28b909", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "bea0c246-0b82-4df0-88e3-e28c9c28b909", + "children": ["bdad1960-d8ec-4bbb-b27f-437d54655069"] + }, + "bdad1960-d8ec-4bbb-b27f-437d54655069": { + "id": "bdad1960-d8ec-4bbb-b27f-437d54655069", + "message": { + "id": "bdad1960-d8ec-4bbb-b27f-437d54655069", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1704629977.590843, + "update_time": null, + "content": { + "content_type": "code", + "language": "unknown", + "text": "mclick([0, 2, 13])" + }, + "status": "finished_successfully", + "end_turn": false, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100265] + }, + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "3db71263-feb6-445a-b60c-dddc8302fa52", + "timestamp_": "absolute" + }, + "recipient": "browser" + }, + "parent": "3db71263-feb6-445a-b60c-dddc8302fa52", + "children": ["adf6394d-b5a9-422a-bc85-e388a20b219b"] + }, + "adf6394d-b5a9-422a-bc85-e388a20b219b": { + "id": "adf6394d-b5a9-422a-bc85-e388a20b219b", + "message": { + "id": "adf6394d-b5a9-422a-bc85-e388a20b219b", + "author": { + "role": "tool", + "name": "browser", + "metadata": {} + }, + "create_time": 1704629977.598691, + "update_time": null, + "content": { + "content_type": "tether_quote", + "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/", + "domain": "www.carsales.com.au", + "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ", + "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au" + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 0.0, + "metadata": { + "_cite_metadata": { + "citation_format": { + "name": "tether_og" + }, + "metadata_list": [ + { + "type": "webpage", + "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au", + "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/", + "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ", + "pub_date": null, + "extra": null + } + ], + "original_query": null + }, + "command": "mclick", + "args": ["[0, 2, 13]"], + "status": "finished", + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "bdad1960-d8ec-4bbb-b27f-437d54655069", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "bdad1960-d8ec-4bbb-b27f-437d54655069", + "children": ["9e0f92f9-9b23-464a-98ec-2b034164f2ec"] + }, + "9e0f92f9-9b23-464a-98ec-2b034164f2ec": { + "id": "9e0f92f9-9b23-464a-98ec-2b034164f2ec", + "message": { + "id": "9e0f92f9-9b23-464a-98ec-2b034164f2ec", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1704629977.607185, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "The fuel consumption of a 2014 Volkswagen Transporter, specifically the TDI340 model with a 2.0 L engine, is reported as follows:\n- Combined: 8.2 L/100km\n- Highway: 6.9 L/100km\n- City: 10.2 L/100km\n\nThese figures can vary based on driving conditions and the vehicle's load. The actual consumption might be higher when carrying eight people due to the additional weight\u301013\u2020source\u3011." + ] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [ + { + "start_ix": 368, + "end_ix": 379, + "citation_format_type": "tether_og", + "metadata": { + "type": "webpage", + "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au", + "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/", + "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ", + "pub_date": null, + "extra": { + "cited_message_idx": 13, + "evidence_text": "source" + } + } + } + ], + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "adf6394d-b5a9-422a-bc85-e388a20b219b", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "adf6394d-b5a9-422a-bc85-e388a20b219b", + "children": ["bbb26113-6bb3-40ef-91a2-02b92f6fd1d0"] + }, + "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0": { + "id": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0", + "message": { + "id": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1704630026.249975, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["Is it for fully loaded car?"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "unknown" + }, + "citations": [], + "voice_mode_message": false, + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "9e0f92f9-9b23-464a-98ec-2b034164f2ec", + "children": ["88a0cf9f-e860-4b34-8e7e-65f8346f4862"] + }, + "88a0cf9f-e860-4b34-8e7e-65f8346f4862": { + "id": "88a0cf9f-e860-4b34-8e7e-65f8346f4862", + "message": { + "id": "88a0cf9f-e860-4b34-8e7e-65f8346f4862", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1704630034.629284, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "The fuel consumption figures for the 2014 Volkswagen Transporter TDI340, such as the combined 8.2 L/100km, are typically based on standardized tests. These tests don't necessarily reflect a fully loaded vehicle. The actual fuel consumption can vary and is likely to be higher when the vehicle is fully loaded with passengers or cargo, due to the increased weight and potential changes in driving conditions such as more frequent acceleration and deceleration\u301013\u2020source\u3011." + ] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [ + { + "start_ix": 458, + "end_ix": 469, + "citation_format_type": "tether_og", + "metadata": { + "type": "webpage", + "title": "Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au", + "url": "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/", + "text": "\nNumber of Airbags\n\n2 \n\n### Engine \n\nEngine Type\n\nPiston \n\nEngine Location\n\nFront \n\nEngine Size (cc)\n\n1968 cc \n\nEngine Size (L)\n\n2.0 L \n\nInduction\n\nTurbo Intercooled \n\nEngine Configuration\n\nIn-line \n\nCylinders\n\n4 \n\nCamshaft\n\nDouble Overhead Cam \n\nValves/Ports per Cylinder\n\n4 \n\nCompression ratio\n\n16.5 \n\nEngine Code\n\nCAAC \n\nGeneric Engine Type\n\nPiston \n\nPower\n\n103.0kW @ 3500rpm \n\nTorque\n\n340Nm @ 1750-2500rpm \n\nPower to Weight Ratio\n\n52.9 kW/t \n\n### Transmission & drivetrain \n\nGears\n\n7 \n\nGear Type\n\nDirect-Shift Gearbox (Sports Automatic Dual Clutch) \n\nGeneric Gear Type\n\nAutomatic \n\nGear Location\n\nDash \n\nDrive\n\nFront Wheel Drive \n\n### Fuel \n\nFuel Type\n\nDiesel \n\nFuel Capacity\n\n80 L \n\nFuel Delivery\n\nCommon-rail Direct Injection \n\nMethod of Delivery\n\nElectronic Sequential \n\nFuel Consumption Combined\n\n8.2 L/100km \n\nFuel Consumption Highway\n\n6.9 L/100km \n\nFuel Consumption City\n\n10.2 L/100km \n\nFuel Average Distance\n\n976 km \n\nFuel Maximum Distance\n\n1159 km \n\nFuel Minimum Distance\n\n784 km \n\nCO2 Emission Combined\n\n216 g/km \n\nCO2 Extra Urban\n\n182 g/km \n\nCO2 Urban\n\n274 g/km \n\nGreenhouse Rating\n\n6 \n\nAir Pollution Rating\n\n5 \n\nGreen Star Rating\n\n3 \n\n### Steering \n\nSteering\n\nRack and Pinion \n\n### Wheels & tyres \n\nRim Material\n\nSteel \n\nFront Rim Description\n\n16x6.5 \n\nRear Rim Description\n\n16x6.5 \n\nFront Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\nRear Tyre Description\n\n\u301092\u2020205/65 R16 C\u2020www.tyresales.com.au\u3011 \n\n### Dimensions & weights \n\nLength\n\n5292 mm \n\nWidth\n\n1904 mm \n\nHeight\n\n1990 mm \n\nWheelbase\n\n3400 mm \n\nTrack Front\n\n1628 mm \n\nTrack Rear\n\n1628 mm \n\nKerb Weight\n\n1948 kg \n\nBoot / Load Space Max (L)\n\n6700 L \n\nGross Vehicle Mass\n\n3000 kg \n\nPayload\n\n1052 kg \n\nTowing Capacity (braked)\n\n2000 kg \n\nTowing Capacity (Unbraked)\n\n750 kg \n\nLoad Length\n\n2753 mm \n\nLoad Width\n\n1692 mm \n\nLoad Height\n\n1410 mm \n\nWidth Between Wheel Arches\n\n1244 mm \n\nRear Side Door Width\n\n1020 mm \n\nRear Side Door Height\n\n1284 mm \n\n### Warranty & service \n\nWarranty in Years from First Registration\n\n3 yr \n\nWarranty in Km\n\nUnlimited km \n\nWarranty Customer Assistance\n\n3Yrs Roadside \n\nWarranty Anti Corrosion in Years from First Registration\n\n12 yr \n\nRegular Service Interval in Km\n\n15000 km \n\nRegular Service Interval in Months\n\n12 mth \n\n### Other \n\nCountry of Origin\n\nGERMANY \n\nLaunch Year\n\n2013 \n\nLaunch Month\n\n9 \n\nGeneration Name\n\nT5 \n\nSeries\n\nT5 \n\nModel Year\n\nMY14 \n\nBadge\n\nTDI340 \n\nDoors\n\n4 \n\nSeat Capacity\n\n5 \n\nBody Style\n\nCrewvan (Van) \n\nOverview\n\nCovering all aspects utilitarian, from cab/chassis through the crew cab, the short and long wheel based front-wheel-drive vans, right up to the all-wheel-drive 4-Motion, the T5 Transporter remains VW's best selling commercial vehicle. That fact alone speaks volumes in this tough sector. Adopting car-like ambiance and feel, sporting a range of 2.0-litre turbodiesels and transmissions, you can then opt for any number of the bewildering array of options. Load volumes for the Crew vans runs from 5.8 m up to 7.8 m in the LWB medium-roof. Safety is very good for the class with four ANCAP stars. \n\n### P plate status \n\nNSW Probationary Status\n\nAllowed \n\n### Approximate Running Costs \n\nFuel cost per 1000km\n\n$159.00 \n\nFuel cost per fill\n\n$154.00 \n\n### Audio, visual & communication \n\nInputs\n\nMP3 decoder \n\nCD / DVD\n\nCD player \n\n### Safety & security \n\nAirbags\n\nDriver \n\nPassenger \n\nSeatbelts\n\nLap/sash for 2 seats \n\nPretensioners 1st row (front) \n\nAdjustable height 1st row \n\nEmergency\n\nBrake assist \n\nVehicle control\n\nABS (antilock brakes) \n\nTraction \n\nElectronic stability \n\nHill holder \n\nEBD (electronic brake force distribution) \n\nSecurity\n\nCentral locking - remote/keyless \n\nEngine immobiliser \n\n### Comfort & convenience \n\nAir conditioning\n\nAir conditioning \n\n### Lights & windows \n\nLights\n\nDaytime running lamps \n\nFog lamps - rear \n\nPower windows\n\nFront only \n\n### Interior \n\nCloth\n\nTrim \n\n### Instruments & controls \n\nDisplay\n\nClock - digital \n\nGauges\n\nTacho \n\n### Exterior \n\nMirrors\n\nElectric - heated \n\nMudflaps\n\nFront \n\nRear \n\n### Body \n\nDoors\n\nSide sliding lhs(passenger side) \n\n### Brakes \n\nFront\n\nVentilated \n\nRear\n\nSolid \n\n### Suspension \n\nType\n\nIndependent front suspension \n\n### Option pack \n\nOption pack\n\nComfort Pack \n\nAirbags - Front Side & Head \n\n- Airbags - Head for 1st Row Seats (Front)\n\n- Airbags - Side for 1st Row Occupants (Front)\n\nControl - Park Distance Front & Rear \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\nControl - Park Distance Front & Rear with Camera \n\n- Control - Park Distance Front\n\n- Control - Park Distance Rear\n\n- Parking Assist - Graphical Display\n\n- Camera - Rear Vision\n\nLight & Sight Pack \n\n- Headlamps - See me home\n\n- Rain Sensor (Auto wipers)\n\n### Audio, visual & communication \n\nInputs\n\nMedia Device Interface - Aux Ipod/USB Socket \n\nBluetooth\n\nBluetooth Phone Preparation \n\nRadio\n\nRCD310 Radio with Media-In Interface \n\n### Safety & security \n\nDriver assistance\n\nControl - Park Distance Rear \n\nSecurity\n\nAlarm \n\n### Comfort & convenience \n\nAir conditioning\n\nAir Conditioning - Rear \n\nDriving\n\nCruise Control \n\nArmrests\n\nArmrest - Drivers Seat \n\nArmrest - Front (Driver & Passenger) \n\nCargo space\n\nFixed Partition with Fixed Window \n\n### Lights & windows \n\nLights\n\nFog Lamps - Front with Fixed Corner Function \n\nWindows\n\nWindow - Side Slide Centre Left \n\nWindow - Side Slide Centre Right \n\n### Interior \n\nOther\n\nRubber - Cargo Floor Covering \n\nLining material\n\nCargo Area - Fully Trimmed Sides \n\nWooden Cargo Floor \n\n### Seating \n\nFront row seats\n\nSeat - Drivers Height Adjust (includes lumbar) \n\nSeat - Double Bench \n\nSeat - Height Adjust Driver/Passen (incl. lumbar) \n\n### Instruments & controls \n\nDisplay\n\nMulti-functionTrip Comp w/- open door display \n\nTrip Computer - Basic \n\nNavigation\n\nGPS (Satellite Navigation) RNS510 inc MFD/Aux In \n\n### Exterior \n\nBody coloured\n\nBody Colour - Bumpers \n\nMirrors\n\nPower Door Mirrors - Folding \n\nPaint\n\nPaint - Metallic \n\nPaint - Pearl \n\nSunroof\n\nSunroof - Sliding/Tilting in Cab \n\n### Body \n\nDoors\n\nDoor - side sliding RHS(drivers side) \n\nDoors - Rear Wing 270 degree opening \n\nDoors - Rear Wing w/- Heated Windows \n\nPower Sliding Side Doors \n\nRoof\n\nHigh Roof in Body Colour \n\nHigh Roof in White \n\nMid Roof in Body Colour \n\nC-Rail Roof Rack Prep \n\n### Electrical \n\nBattery\n\nBattery - Dual (2nd) \n\nBattery - Stronger \n\n### Steering \n\nOperation\n\nMulti-function Steering Wheel \n\n### Suspension \n\nType\n\nReinforced Standard Dampers & Springs \n\nSuspension - Upgraded Shocks & Springs \n\n### Wheels & tyres \n\nFront rim\n\n17\" Alloy Wheels - Thunder \n\nOther\n\nChild lock on side slilding door \n\n## Currently listed for sale\n\n\u301087\u2020View all\u3011 \n\n \u301093\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $23,000* Excl. Govt. Charges \u3011 \u301094\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Manual MY14 $29,888 Drive Away \u3011 \u301095\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY14 $20,000* Excl. Govt. Charges \u3011 \u301096\u2020 2014 Volkswagen Transporter TDI340 T5 LWB Auto MY15 $35,000* Excl. Govt. Charges \u3011 \u301097\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY14 $15,000* Excl. Govt. Charges \u3011 \u301098\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $32,990 Drive Away \u3011 \u301099\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Auto MY15 $19,500* Excl. Govt. Charges \u3011 \u3010100\u2020 2014 Volkswagen Transporter TDI340 T5 SWB Manual MY14 $16,900* Excl. Govt. Charges \u3011 \n\n## Volkswagen Transporter Car Reviews\n\n\u3010101\u2020View all\u3011 \n\n \u3010102\u2020 Review 21 Volkswagen Transporter 2016 Review April 2016 \u3011 \u3010103\u2020 Review 12 Volkswagen Transporter 2016 Review December 2015 \u3011 \u3010104\u2020 Review 16 Volkswagen Transporter and Multivan T6 2015 Review July 2015 \u3011 \n\n## Volkswagen Transporter Car News\n\n\u3010105\u2020View ", + "pub_date": null, + "extra": { + "cited_message_idx": 13, + "evidence_text": "source" + } + } + } + ], + "is_complete": true, + "message_type": null, + "model_slug": "gpt-4", + "parent_id": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "bbb26113-6bb3-40ef-91a2-02b92f6fd1d0", + "children": [] + } + }, + "moderation_results": [], + "current_node": "88a0cf9f-e860-4b34-8e7e-65f8346f4862", + "plugin_ids": null, + "conversation_id": "d6523d1e-7ec3-474f-a363-0e9dffdb3d93", + "conversation_template_id": null, + "gizmo_id": null, + "is_archived": false, + "safe_urls": [ + "https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/", + "https://www.drive.com.au/reviews/volkswagen-transporter-review/", + "https://www.ultimatespecs.com/car-specs/volkswagen/118424/2021-volkswagen-transporter-t61-l2h1-20-tdi-110hp.html" + ], + "default_model_slug": null, + "id": "d6523d1e-7ec3-474f-a363-0e9dffdb3d93" + }, + { + "title": "Conversation 2", + "create_time": 1697373097.899566, + "update_time": 1697373123.975989, + "mapping": { + "35f71f31-2118-461f-9032-e019a94eb20e": { + "id": "35f71f31-2118-461f-9032-e019a94eb20e", + "message": { + "id": "35f71f31-2118-461f-9032-e019a94eb20e", + "author": { + "role": "system", + "name": null, + "metadata": {} + }, + "create_time": null, + "update_time": null, + "content": { + "content_type": "text", + "parts": [""] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 0.0, + "metadata": { + "is_visually_hidden_from_conversation": true + }, + "recipient": "all" + }, + "parent": "aaa1f95f-dd59-4786-ac6e-37f463163896", + "children": ["aaa2e334-b386-4ed8-b33b-5d788c882f1d"] + }, + "aaa1f95f-dd59-4786-ac6e-37f463163896": { + "id": "aaa1f95f-dd59-4786-ac6e-37f463163896", + "message": null, + "parent": null, + "children": ["35f71f31-2118-461f-9032-e019a94eb20e"] + }, + "aaa2e334-b386-4ed8-b33b-5d788c882f1d": { + "id": "aaa2e334-b386-4ed8-b33b-5d788c882f1d", + "message": { + "id": "aaa2e334-b386-4ed8-b33b-5d788c882f1d", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1697373097.900124, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "In programming how to find if all intervals have the one point where they intersect. \nFor example in [2, 5] [4,5] [3,7] it will be 4." + ] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1.0, + "metadata": { + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "35f71f31-2118-461f-9032-e019a94eb20e", + "children": ["9f97ac83-7408-4872-9e3b-ee4272c94e10"] + }, + "9f97ac83-7408-4872-9e3b-ee4272c94e10": { + "id": "9f97ac83-7408-4872-9e3b-ee4272c94e10", + "message": { + "id": "9f97ac83-7408-4872-9e3b-ee4272c94e10", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1697373107.211353, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "To find the point where all intervals intersect, you need to determine the common overlap region among all the intervals. Here's how you can do it in Python:\n\n```python\ndef find_intersection(intervals):\n # Sort intervals based on the start point\n intervals.sort(key=lambda x: x[0])\n\n # Initialize the result with the first interval\n result_start, result_end = intervals[0]\n\n # Iterate through the sorted intervals and find the common overlap\n for interval_start, interval_end in intervals[1:]:\n # If the current interval is within the current result interval\n if interval_start <= result_end:\n result_start = max(result_start, interval_start)\n result_end = min(result_end, interval_end)\n else:\n # No overlap found, return None\n return None\n\n # Return the common overlap region\n return result_start, result_end\n\n# Example intervals\nintervals = [[2, 5], [4, 5], [3, 7]]\n\n# Find the intersection\nintersection = find_intersection(intervals)\n\nif intersection:\n print(f\"The intersection point is: {intersection[0]}\")\nelse:\n print(\"There is no common intersection point.\")\n```\n\nIn this code, the `find_intersection` function takes a list of intervals as input. It sorts the intervals based on their start points and then iterates through the sorted intervals to find the common overlap region. If there is a common overlap, it returns the start and end points of the overlap. If there is no common overlap, it returns `None`. In the given example, the output will be:\n\n```\nThe intersection point is: 4\n```" + ] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "is_complete": true, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "parent_id": "aaa2e334-b386-4ed8-b33b-5d788c882f1d", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa2e334-b386-4ed8-b33b-5d788c882f1d", + "children": ["aaa21d18-8c68-43b2-8939-86fadac2b861"] + }, + "aaa21d18-8c68-43b2-8939-86fadac2b861": { + "id": "aaa21d18-8c68-43b2-8939-86fadac2b861", + "message": { + "id": "aaa21d18-8c68-43b2-8939-86fadac2b861", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1697373115.892485, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["give me code in C#"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1.0, + "metadata": { + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "9f97ac83-7408-4872-9e3b-ee4272c94e10", + "children": ["73a2fe12-36bd-4cc2-8460-8108d16cc42d"] + }, + "73a2fe12-36bd-4cc2-8460-8108d16cc42d": { + "id": "73a2fe12-36bd-4cc2-8460-8108d16cc42d", + "message": { + "id": "73a2fe12-36bd-4cc2-8460-8108d16cc42d", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1697373123.970743, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "Certainly! Here's the same logic written in the C# programming language:\n\n```csharp\nusing System;\nusing System.Collections.Generic;\n\nclass Program\n{\n static void Main()\n {\n List<int[]> intervals = new List<int[]>\n {\n new int[] {2, 5},\n new int[] {4, 5},\n new int[] {3, 7}\n };\n\n var intersection = FindIntersection(intervals);\n\n if (intersection != null)\n {\n Console.WriteLine($\"The intersection point is: {intersection[0]}\");\n }\n else\n {\n Console.WriteLine(\"There is no common intersection point.\");\n }\n }\n\n static int[] FindIntersection(List<int[]> intervals)\n {\n intervals.Sort((x, y) => x[0].CompareTo(y[0]));\n\n int[] result = intervals[0];\n \n foreach (var interval in intervals)\n {\n if (interval[0] <= result[1])\n {\n result[0] = Math.Max(result[0], interval[0]);\n result[1] = Math.Min(result[1], interval[1]);\n }\n else\n {\n return null;\n }\n }\n\n return result;\n }\n}\n```\n\nThis C# code performs the same task as the Python code provided earlier. It finds the common intersection point among a list of intervals and outputs the result." + ] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1.0, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "is_complete": true, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "parent_id": "aaa21d18-8c68-43b2-8939-86fadac2b861", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa21d18-8c68-43b2-8939-86fadac2b861", + "children": [] + } + }, + "moderation_results": [], + "current_node": "73a2fe12-36bd-4cc2-8460-8108d16cc42d", + "plugin_ids": null, + "conversation_id": "7c5ab593-dbab-43bd-862d-2c3c1eeebf6a", + "conversation_template_id": null, + "gizmo_id": null, + "is_archived": false, + "safe_urls": [], + "default_model_slug": null, + "id": "7c5ab593-dbab-43bd-862d-2c3c1eeebf6a" + } +] diff --git a/api/server/utils/import/__data__/chatgpt-tree.json b/api/server/utils/import/__data__/chatgpt-tree.json new file mode 100644 index 00000000000..7f01417b1eb --- /dev/null +++ b/api/server/utils/import/__data__/chatgpt-tree.json @@ -0,0 +1,429 @@ +[ + { + "title": "Assist user with summary", + "create_time": 1714585031.148505, + "update_time": 1714585060.879308, + "mapping": { + "d38605d2-7b2c-43de-b044-22ce472c749b": { + "id": "d38605d2-7b2c-43de-b044-22ce472c749b", + "message": { + "id": "d38605d2-7b2c-43de-b044-22ce472c749b", + "author": { + "role": "system", + "name": null, + "metadata": {} + }, + "create_time": null, + "update_time": null, + "content": { + "content_type": "text", + "parts": [""] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 0, + "metadata": { + "is_visually_hidden_from_conversation": true + }, + "recipient": "all" + }, + "parent": "aaa1f70c-100e-46f0-999e-10c8565f047f", + "children": ["aaa297ba-e2da-440e-84f4-e62e7be8b003"] + }, + "aaa1f70c-100e-46f0-999e-10c8565f047f": { + "id": "aaa1f70c-100e-46f0-999e-10c8565f047f", + "message": null, + "parent": null, + "children": ["d38605d2-7b2c-43de-b044-22ce472c749b"] + }, + "aaa297ba-e2da-440e-84f4-e62e7be8b003": { + "id": "aaa297ba-e2da-440e-84f4-e62e7be8b003", + "message": { + "id": "aaa297ba-e2da-440e-84f4-e62e7be8b003", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1714585031.150442, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["hi there"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1, + "metadata": { + "request_id": "87d189bb49d412c5-IAD", + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "d38605d2-7b2c-43de-b044-22ce472c749b", + "children": ["bda8a275-886d-4f59-b38c-d7037144f0d5"] + }, + "bda8a275-886d-4f59-b38c-d7037144f0d5": { + "id": "bda8a275-886d-4f59-b38c-d7037144f0d5", + "message": { + "id": "bda8a275-886d-4f59-b38c-d7037144f0d5", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1714585031.757056, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["Hello! How can I assist you today?"] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [], + "gizmo_id": null, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "default_model_slug": "text-davinci-002-render-sha", + "pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA", + "parent_id": "aaa297ba-e2da-440e-84f4-e62e7be8b003", + "is_complete": true, + "request_id": "87d189bb49d412c5-IAD", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa297ba-e2da-440e-84f4-e62e7be8b003", + "children": ["aaa24023-b02f-4d49-b568-5856b41750c0", "aaa236a3-cdfc-4eb1-b5c5-790c6641f880"] + }, + "aaa24023-b02f-4d49-b568-5856b41750c0": { + "id": "aaa24023-b02f-4d49-b568-5856b41750c0", + "message": { + "id": "aaa24023-b02f-4d49-b568-5856b41750c0", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1714585034.306995, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["so cool bro"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1, + "metadata": { + "request_id": "87d189cf3df512c5-IAD", + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "bda8a275-886d-4f59-b38c-d7037144f0d5", + "children": ["23afbea9-ca08-49f2-b417-e7ae58a1c97d"] + }, + "23afbea9-ca08-49f2-b417-e7ae58a1c97d": { + "id": "23afbea9-ca08-49f2-b417-e7ae58a1c97d", + "message": { + "id": "23afbea9-ca08-49f2-b417-e7ae58a1c97d", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1714585034.755907, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["Thanks! What brings you here today?"] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [], + "gizmo_id": null, + "is_complete": true, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "default_model_slug": "text-davinci-002-render-sha", + "pad": "AAAAAAAAAAAAAAAAAAAAAAAAA", + "parent_id": "aaa24023-b02f-4d49-b568-5856b41750c0", + "request_id": "87d189cf3df512c5-IAD", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa24023-b02f-4d49-b568-5856b41750c0", + "children": ["aaa292cc-1842-4dbf-bd79-13cf7150366a"] + }, + "aaa292cc-1842-4dbf-bd79-13cf7150366a": { + "id": "aaa292cc-1842-4dbf-bd79-13cf7150366a", + "message": { + "id": "aaa292cc-1842-4dbf-bd79-13cf7150366a", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1714585037.56986, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["tell me a story"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1, + "metadata": { + "request_id": "87d189e3dac712c5-IAD", + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "23afbea9-ca08-49f2-b417-e7ae58a1c97d", + "children": ["ada93f81-f59e-4b31-933d-1357efd68bfc"] + }, + "ada93f81-f59e-4b31-933d-1357efd68bfc": { + "id": "ada93f81-f59e-4b31-933d-1357efd68bfc", + "message": { + "id": "ada93f81-f59e-4b31-933d-1357efd68bfc", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1714585045.606752, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "Sure! Here's a short story for you:\n\n---\n\nOnce upon a time, in a small village nestled between rolling" + ] + }, + "status": "in_progress", + "end_turn": null, + "weight": 1, + "metadata": { + "citations": [], + "gizmo_id": null, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "default_model_slug": "text-davinci-002-render-sha", + "pad": "AAAAAAAAAAAAAAAAAA", + "parent_id": "aaa292cc-1842-4dbf-bd79-13cf7150366a", + "finish_details": { + "type": "interrupted" + }, + "request_id": "87d189e3dac712c5-IAD", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa292cc-1842-4dbf-bd79-13cf7150366a", + "children": [] + }, + "aaa236a3-cdfc-4eb1-b5c5-790c6641f880": { + "id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880", + "message": { + "id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1714585050.906034, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["hi again"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1, + "metadata": { + "request_id": "87d18a36cf9312c5-IAD", + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "bda8a275-886d-4f59-b38c-d7037144f0d5", + "children": ["db88eddf-3622-4246-8527-b6eaf0e9e8cd"] + }, + "db88eddf-3622-4246-8527-b6eaf0e9e8cd": { + "id": "db88eddf-3622-4246-8527-b6eaf0e9e8cd", + "message": { + "id": "db88eddf-3622-4246-8527-b6eaf0e9e8cd", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1714585051.690729, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["Hey! Welcome back. What's on your mind?"] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [], + "gizmo_id": null, + "is_complete": true, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "default_model_slug": "text-davinci-002-render-sha", + "pad": "AAAAAAAAAAAAAAAAAAAAA", + "parent_id": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880", + "request_id": "87d18a36cf9312c5-IAD", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa236a3-cdfc-4eb1-b5c5-790c6641f880", + "children": ["aaa20127-b9e3-44f6-afbe-a2475838625a"] + }, + "aaa20127-b9e3-44f6-afbe-a2475838625a": { + "id": "aaa20127-b9e3-44f6-afbe-a2475838625a", + "message": { + "id": "aaa20127-b9e3-44f6-afbe-a2475838625a", + "author": { + "role": "user", + "name": null, + "metadata": {} + }, + "create_time": 1714585055.908847, + "update_time": null, + "content": { + "content_type": "text", + "parts": ["tell me a joke"] + }, + "status": "finished_successfully", + "end_turn": null, + "weight": 1, + "metadata": { + "request_id": "87d18a6e39a312c5-IAD", + "timestamp_": "absolute", + "message_type": null + }, + "recipient": "all" + }, + "parent": "db88eddf-3622-4246-8527-b6eaf0e9e8cd", + "children": ["d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae", "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8"] + }, + "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae": { + "id": "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae", + "message": { + "id": "d0d2a7df-d2fc-4df9-bf0a-1c5121e227ae", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1714585056.580956, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "Sure, here's one for you:\n\nWhy don't scientists trust atoms?\n\nBecause they make up everything!" + ] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [], + "gizmo_id": null, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "default_model_slug": "text-davinci-002-render-sha", + "pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA", + "parent_id": "aaa20127-b9e3-44f6-afbe-a2475838625a", + "is_complete": true, + "request_id": "87d18a55ca6212c5-IAD", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa20127-b9e3-44f6-afbe-a2475838625a", + "children": [] + }, + "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8": { + "id": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8", + "message": { + "id": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8", + "author": { + "role": "assistant", + "name": null, + "metadata": {} + }, + "create_time": 1714585060.598792, + "update_time": null, + "content": { + "content_type": "text", + "parts": [ + "Sure, here's one for you:\n\nWhy don't scientists trust atoms?\n\nBecause they make up everything!" + ] + }, + "status": "finished_successfully", + "end_turn": true, + "weight": 1, + "metadata": { + "finish_details": { + "type": "stop", + "stop_tokens": [100260] + }, + "citations": [], + "gizmo_id": null, + "is_complete": true, + "message_type": null, + "model_slug": "text-davinci-002-render-sha", + "default_model_slug": "text-davinci-002-render-sha", + "pad": "AAAAAAAAAAAAAAAAAAAAAAAAAA", + "parent_id": "aaa20127-b9e3-44f6-afbe-a2475838625a", + "request_id": "87d18a6e39a312c5-IAD", + "timestamp_": "absolute" + }, + "recipient": "all" + }, + "parent": "aaa20127-b9e3-44f6-afbe-a2475838625a", + "children": [] + } + }, + "moderation_results": [], + "current_node": "f63b8e17-aa5c-4ca6-a1bf-d4d285e269b8", + "plugin_ids": null, + "conversation_id": "d5dc5307-6807-41a0-8b04-4acee626eeb7", + "conversation_template_id": null, + "gizmo_id": null, + "is_archived": false, + "safe_urls": [], + "default_model_slug": "text-davinci-002-render-sha", + "id": "d5dc5307-6807-41a0-8b04-4acee626eeb7" + } +] diff --git a/api/server/utils/import/__data__/librechat-export.json b/api/server/utils/import/__data__/librechat-export.json new file mode 100644 index 00000000000..6e28098d4f5 --- /dev/null +++ b/api/server/utils/import/__data__/librechat-export.json @@ -0,0 +1,143 @@ +{ + "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733", + "endpoint": "openAI", + "title": "Conversation 1. Web Search", + "exportAt": "16:33:32 GMT+0200 (Central European Summer Time)", + "branches": true, + "recursive": true, + "options": { + "presetId": null, + "model": "gpt-3.5-turbo", + "chatGptLabel": null, + "promptPrefix": null, + "temperature": 1, + "top_p": 1, + "presence_penalty": 0, + "frequency_penalty": 0, + "resendFiles": true, + "imageDetail": "auto", + "endpoint": "openAI", + "title": "VW Transporter 2014 Fuel Consumption. Web Search" + }, + "messagesTree": [ + { + "_id": "6615516574dc2ddcdebe40b6", + "messageId": "b123942f-ca1a-4b16-9e1f-ea4af5171168", + "__v": 0, + "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733", + "createdAt": "2024-04-09T14:32:05.230Z", + "endpoint": "openAI", + "error": false, + "isCreatedByUser": true, + "isEdited": false, + "model": null, + "parentMessageId": "00000000-0000-0000-0000-000000000000", + "sender": "user", + "text": "What is the fuel consumption of vw transporter with 8 people in l/km", + "unfinished": false, + "updatedAt": "2024-04-09T14:32:05.230Z", + "user": "65f1ad8c90523874d2d409f8", + "children": [ + { + "_id": "6615516574dc2ddcdebe40b8", + "messageId": "549a4f45-cf93-4e3b-ae62-1abf02afbfc8", + "__v": 0, + "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733", + "createdAt": "2024-04-09T14:32:05.242Z", + "endpoint": "openAI", + "error": false, + "isCreatedByUser": false, + "isEdited": false, + "model": null, + "parentMessageId": "b123942f-ca1a-4b16-9e1f-ea4af5171168", + "sender": "GPT-3.5", + "text": "The fuel consumption of a Volkswagen Transporter can vary based on the specific model and driving conditions. For example, the 2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP, which is one of the recent models, doesn't have its fuel consumption data explicitly mentioned in the sources I found. However, a review of a Volkswagen Transporter model, which is equipped with a 2.0-litre four-cylinder turbo diesel engine and a seven-speed dual-clutch automatic transmission, mentions a combined cycle fuel consumption figure of 8.2 litres per 100km. It's important to note that this figure might vary, especially when the vehicle is carrying eight people, as more weight can lead to increased fuel consumption.\n\nFor more detailed information, you might need to refer to the specific model's manual or contact a Volkswagen dealer. Also, keep in mind that real-world fuel consumption can be influenced by factors such as driving style, road conditions, and maintenance of the vehicle ([2021 Volkswagen Transporter T6.1 L2H1 2.0 TDI 110HP specs, dimensions](https://www.ultimatespecs.com/car-specs/Volkswagen/118424/2021-Volkswagen-Transporter-T61-L2H1-20-TDI-110HP.html)) ([Volkswagen Transporter Review - Drive](https://www.drive.com.au/reviews/volkswagen-transporter-review/)).", + "unfinished": false, + "updatedAt": "2024-04-09T14:32:05.242Z", + "user": "65f1ad8c90523874d2d409f8", + "children": [ + { + "_id": "6615516574dc2ddcdebe40ba", + "messageId": "880e5357-3e0c-4218-b351-fd3fc184adef", + "__v": 0, + "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733", + "createdAt": "2024-04-09T14:32:05.247Z", + "endpoint": "openAI", + "error": false, + "isCreatedByUser": true, + "isEdited": false, + "model": null, + "parentMessageId": "549a4f45-cf93-4e3b-ae62-1abf02afbfc8", + "sender": "user", + "text": "What about 10 year old model", + "unfinished": false, + "updatedAt": "2024-04-09T14:32:05.247Z", + "user": "65f1ad8c90523874d2d409f8", + "children": [ + { + "_id": "6615516574dc2ddcdebe40bc", + "messageId": "e9796d11-3bdf-4e25-9f0e-4802bbbb8c6d", + "__v": 0, + "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733", + "createdAt": "2024-04-09T14:32:05.256Z", + "endpoint": "openAI", + "error": false, + "isCreatedByUser": false, + "isEdited": false, + "model": null, + "parentMessageId": "880e5357-3e0c-4218-b351-fd3fc184adef", + "sender": "GPT-3.5", + "text": "The fuel consumption of a 2014 Volkswagen Transporter, specifically the TDI340 model with a 2.0 L engine, is reported as follows:\n- Combined: 8.2 L/100km\n- Highway: 6.9 L/100km\n- City: 10.2 L/100km\n\nThese figures can vary based on driving conditions and the vehicle's load. The actual consumption might be higher when carrying eight people due to the additional weight ([Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au](https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/)).", + "unfinished": false, + "updatedAt": "2024-04-09T14:32:05.256Z", + "user": "65f1ad8c90523874d2d409f8", + "children": [ + { + "_id": "6615516574dc2ddcdebe40be", + "messageId": "04408c06-62dc-4961-8ef5-4336b68e7a0a", + "__v": 0, + "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733", + "createdAt": "2024-04-09T14:32:05.261Z", + "endpoint": "openAI", + "error": false, + "isCreatedByUser": true, + "isEdited": false, + "model": null, + "parentMessageId": "e9796d11-3bdf-4e25-9f0e-4802bbbb8c6d", + "sender": "user", + "text": "Is it for fully loaded car?", + "unfinished": false, + "updatedAt": "2024-04-09T14:32:05.261Z", + "user": "65f1ad8c90523874d2d409f8", + "children": [ + { + "_id": "6615516574dc2ddcdebe40c0", + "messageId": "645b925b-ef14-451f-9be3-814de55a5ae4", + "__v": 0, + "conversationId": "af1ea676-f525-444f-a9ed-7c8dbf062733", + "createdAt": "2024-04-09T14:32:05.266Z", + "endpoint": "openAI", + "error": false, + "isCreatedByUser": false, + "isEdited": false, + "model": null, + "parentMessageId": "04408c06-62dc-4961-8ef5-4336b68e7a0a", + "sender": "GPT-3.5", + "text": "The fuel consumption figures for the 2014 Volkswagen Transporter TDI340, such as the combined 8.2 L/100km, are typically based on standardized tests. These tests don't necessarily reflect a fully loaded vehicle. The actual fuel consumption can vary and is likely to be higher when the vehicle is fully loaded with passengers or cargo, due to the increased weight and potential changes in driving conditions such as more frequent acceleration and deceleration ([Volkswagen Transporter TDI340 2014 - Pricing & Specifications | carsales.com.au](https://www.carsales.com.au/volkswagen/transporter/price/2014/tdi340-381631/)).", + "unfinished": false, + "updatedAt": "2024-04-09T14:32:05.266Z", + "user": "65f1ad8c90523874d2d409f8", + "children": [] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] +} diff --git a/api/server/utils/import/__data__/librechat-tree.json b/api/server/utils/import/__data__/librechat-tree.json new file mode 100644 index 00000000000..20eefcdf9bd --- /dev/null +++ b/api/server/utils/import/__data__/librechat-tree.json @@ -0,0 +1,153 @@ +{ + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "endpoint": "openAI", + "title": "Troubleshooting Python Virtual Environment Activation Issue", + "exportAt": "13:18:04 GMT-0400 (Eastern Daylight Time)", + "branches": true, + "recursive": true, + "options": { + "presetId": null, + "model": "gpt-4-turbo", + "chatGptLabel": "Software Engineer", + "promptPrefix": "You are an expert software engineer.", + "temperature": 0.8, + "top_p": 1, + "presence_penalty": 0, + "frequency_penalty": 0, + "resendFiles": true, + "imageDetail": "auto", + "endpoint": "openAI", + "title": "Troubleshooting Python Virtual Environment Activation Issue" + }, + "messagesTree": [ + { + "_id": "66326f3f04bed94b7f5be68d", + "messageId": "9501f99d-9bbb-40cb-bbb2-16d79aeceb72", + "__v": 0, + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "createdAt": "2024-05-01T16:35:11.530Z", + "endpoint": "openAI", + "error": false, + "isCreatedByUser": true, + "isEdited": false, + "model": null, + "parentMessageId": "00000000-0000-0000-0000-000000000000", + "sender": "User", + "text": "How do I use source venv/bin/activate?\nbash: venv/bin/activate: No such file or directory", + "tokenCount": 59, + "unfinished": false, + "updatedAt": "2024-05-01T16:35:39.114Z", + "user": "64d4f005d73ef522e1f48818", + "children": [ + { + "_id": "66326f4004bed94b7f5be699", + "messageId": "4c395510-fdfd-4556-b034-f9440f025e91", + "__v": 0, + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "createdAt": "2024-05-01T16:35:12.604Z", + "error": false, + "isCreatedByUser": false, + "isEdited": false, + "model": "gpt-4-turbo", + "parentMessageId": "9501f99d-9bbb-40cb-bbb2-16d79aeceb72", + "sender": "Software Engineer", + "text": "It looks like you're trying to activate a Python virtual environment but encountering an issue because the system cannot find the `activate` script in the `venv/bin/` directory. This typically happens if the virtual environment has not been created yet or if the path specified is incorrect. Here are a few steps to troubleshoot and resolve this issue:\n\n1. **Check if the Virtual Environment Exists**:\n Ensure that the virtual environment directory (`venv`) exists in your current directory (`~/denis-libre`). You can check this by listing the directories:\n\n ```bash\n ls -l\n ```\n\n Look for `venv` in the output. If it's not there, you'll need to create it.\n\n2. **Create the Virtual Environment**:\n If the virtual environment does not exist, you can create one by using the following command:\n\n ```bash\n python3 -m venv venv\n ```\n\n This command creates a new virtual environment named `venv` in your current directory.\n\n3. **Activate the Virtual Environment**:\n Once you have confirmed that the `venv` directory exists, try activating the virtual environment again:\n\n ```bash\n source venv/bin/activate\n ```\n\n If you still receive an error, double-check the path. If you are on a Windows system using Git Bash or similar, the path might be `venv\\Scripts\\activate` instead.\n\n4. **Check for Environment Specific Issues**:\n - If you are using a different shell (like `csh` or `fish`), the activation script could be different (e.g., `activate.csh` or `activate.fish`).\n - Ensure there’s no typo in your command or path misconfiguration.\n\n5. **Permissions Issue**:\n Make sure you have the necessary permissions to access the `venv` directory and its contents. If not, you might need to modify the permissions:\n\n ```bash\n chmod -R 755 venv\n ```\n\n6. **Using the Correct Python Version**:\n Confirm that you are using the correct version of Python for which the virtual environment is supposed to be set up. Sometimes, using a different version might cause issues.\n\nBy following these steps, you should be able to resolve the issue with activating your Python virtual environment. If the problem persists, there might be a more specific issue with your Python installation or system configuration.", + "unfinished": false, + "updatedAt": "2024-05-01T16:35:39.104Z", + "user": "64d4f005d73ef522e1f48818", + "endpoint": "openAI", + "finish_reason": "stop", + "tokenCount": 504, + "children": [ + { + "messageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270", + "parentMessageId": "4c395510-fdfd-4556-b034-f9440f025e91", + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "sender": "User", + "text": "thanks", + "isCreatedByUser": true, + "tokenCount": 5, + "children": [ + { + "messageId": "5da2cdce-87f7-46b4-87c5-e8224e5d5125", + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "parentMessageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270", + "isCreatedByUser": false, + "model": "gpt-4-turbo", + "sender": "Software Engineer", + "text": "You're welcome! If you have any more questions or need further assistance, feel free to ask. Good luck with your project!", + "promptTokens": 586, + "endpoint": "openAI", + "finish_reason": "stop", + "children": [] + }, + { + "messageId": "64ed7568-bdcf-42a9-b34a-73386d4d99dc", + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "parentMessageId": "6e4c95ca-fcb1-4869-adc3-b4bcec83a270", + "isCreatedByUser": false, + "model": "gpt-4-turbo", + "sender": "Software Engineer", + "text": "You're welcome! If you have any more questions or need further assistance, feel free to ask. Happy coding!", + "promptTokens": 586, + "endpoint": "openAI", + "finish_reason": "stop", + "children": [ + { + "messageId": "f82cca6f-7d2b-4fc1-9d5a-636acc137368", + "parentMessageId": "64ed7568-bdcf-42a9-b34a-73386d4d99dc", + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "sender": "User", + "text": "does coding make you happy", + "isCreatedByUser": true, + "tokenCount": 9, + "children": [ + { + "messageId": "ad7b6ec6-aaa4-434c-9188-def6aadbb2bf", + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "parentMessageId": "f82cca6f-7d2b-4fc1-9d5a-636acc137368", + "isCreatedByUser": false, + "model": "gpt-4-turbo", + "sender": "Software Engineer", + "text": "As a software engineer, coding can indeed be a source of happiness and satisfaction for many professionals in the field. Here’s why coding might make someone happy:\n\n1. **Problem Solving**: Coding offers a constant stream of challenges that require problem-solving skills. Solving these problems can be immensely gratifying and can provide a sense of accomplishment.\n\n2. **Creativity**: Writing software is often a creative process, similar to composing music or painting. Developers can express themselves through their code, which can be a fulfilling experience.\n\n3. **Continuous Learning**: The tech field is always evolving, offering endless opportunities to learn new languages, tools, and techniques. For many, this continuous learning keeps the job exciting and engaging.\n\n4. **Impact**: Software engineers often work on applications that impact the lives of millions of people. Knowing that your work is helping others can be a powerful source of happiness.\n\n5. **Community**: The coding community is known for its collaboration and sharing of knowledge through platforms like GitHub, Stack Overflow, and various forums. Being a part of this community can provide a sense of belonging and support.\n\n6. **Career Opportunities**: Coding skills are in high demand, which can lead to job security, good salaries, and the opportunity to work in a variety of industries. This can contribute to overall life satisfaction and stability.\n\nHowever, like any job, coding can also be stressful at times. Deadlines, bugs, difficult problems, and other challenges can cause frustration. The key is to find a balance, continue learning, and choose projects that align with your interests and values. If these elements are in place, coding can indeed be a very happy career choice.", + "promptTokens": 622, + "endpoint": "openAI", + "finish_reason": "stop", + "children": [] + } + ] + } + ] + } + ] + }, + { + "messageId": "d3e12088-962e-42ce-900d-edd6816acae5", + "parentMessageId": "4c395510-fdfd-4556-b034-f9440f025e91", + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "sender": "User", + "text": "lol", + "isCreatedByUser": true, + "tokenCount": 5, + "children": [ + { + "messageId": "1f062c99-ff0a-4cf4-a1cf-7150261a24e2", + "conversationId": "4a86c40e-e627-4454-b158-889680e23ad3", + "parentMessageId": "d3e12088-962e-42ce-900d-edd6816acae5", + "isCreatedByUser": false, + "model": "gpt-4-turbo", + "sender": "Software Engineer", + "text": "It looks like you might have been amused or found something funny about the situation! If you have any specific questions or need further help with your virtual environment setup or anything else related to software engineering, feel free to ask!", + "promptTokens": 586, + "endpoint": "openAI", + "finish_reason": "stop", + "children": [] + } + ] + } + ] + } + ] + } + ] +} diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js new file mode 100644 index 00000000000..cb75d7863bb --- /dev/null +++ b/api/server/utils/import/fork.js @@ -0,0 +1,314 @@ +const { v4: uuidv4 } = require('uuid'); +const { EModelEndpoint, Constants, ForkOptions } = require('librechat-data-provider'); +const { createImportBatchBuilder } = require('./importBatchBuilder'); +const BaseClient = require('~/app/clients/BaseClient'); +const { getConvo } = require('~/models/Conversation'); +const { getMessages } = require('~/models/Message'); +const logger = require('~/config/winston'); + +/** + * + * @param {object} params - The parameters for the importer. + * @param {string} params.originalConvoId - The ID of the conversation to fork. + * @param {string} params.targetMessageId - The ID of the message to fork from. + * @param {string} params.requestUserId - The ID of the user making the request. + * @param {string} [params.newTitle] - Optional new title for the forked conversation uses old title if not provided + * @param {string} [params.option=''] - Optional flag for fork option + * @param {boolean} [params.records=false] - Optional flag for returning actual database records or resulting conversation and messages. + * @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level. + * @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true. + * @param {(userId: string) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. + * @returns {Promise<TForkConvoResponse>} The response after forking the conversation. + */ +async function forkConversation({ + originalConvoId, + targetMessageId: targetId, + requestUserId, + newTitle, + option = ForkOptions.TARGET_LEVEL, + records = false, + splitAtTarget = false, + latestMessageId, + builderFactory = createImportBatchBuilder, +}) { + try { + const originalConvo = await getConvo(requestUserId, originalConvoId); + let originalMessages = await getMessages({ + user: requestUserId, + conversationId: originalConvoId, + }); + + let targetMessageId = targetId; + if (splitAtTarget && !latestMessageId) { + throw new Error('Latest `messageId` is required for forking from target message.'); + } else if (splitAtTarget) { + originalMessages = splitAtTargetLevel(originalMessages, targetId); + targetMessageId = latestMessageId; + } + + const importBatchBuilder = builderFactory(requestUserId); + importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); + + let messagesToClone = []; + + if (option === ForkOptions.DIRECT_PATH) { + // Direct path only + messagesToClone = BaseClient.getMessagesForConversation({ + messages: originalMessages, + parentMessageId: targetMessageId, + }); + } else if (option === ForkOptions.INCLUDE_BRANCHES) { + // Direct path and siblings + messagesToClone = getAllMessagesUpToParent(originalMessages, targetMessageId); + } else if (option === ForkOptions.TARGET_LEVEL || !option) { + // Direct path, siblings, and all descendants + messagesToClone = getMessagesUpToTargetLevel(originalMessages, targetMessageId); + } + + const idMapping = new Map(); + + for (const message of messagesToClone) { + const newMessageId = uuidv4(); + idMapping.set(message.messageId, newMessageId); + + const clonedMessage = { + ...message, + messageId: newMessageId, + parentMessageId: + message.parentMessageId && message.parentMessageId !== Constants.NO_PARENT + ? idMapping.get(message.parentMessageId) + : Constants.NO_PARENT, + }; + + importBatchBuilder.saveMessage(clonedMessage); + } + + const result = importBatchBuilder.finishConversation( + newTitle || originalConvo.title, + new Date(), + originalConvo, + ); + await importBatchBuilder.saveBatch(); + logger.debug( + `user: ${requestUserId} | New conversation "${ + newTitle || originalConvo.title + }" forked from conversation ID ${originalConvoId}`, + ); + + if (!records) { + return result; + } + + const conversation = await getConvo(requestUserId, result.conversation.conversationId); + const messages = await getMessages({ + user: requestUserId, + conversationId: conversation.conversationId, + }); + + return { + conversation, + messages, + }; + } catch (error) { + logger.error( + `user: ${requestUserId} | Error forking conversation from original ID ${originalConvoId}`, + error, + ); + throw error; + } +} + +/** + * Retrieves all messages up to the root from the target message. + * @param {TMessage[]} messages - The list of messages to search. + * @param {string} targetMessageId - The ID of the target message. + * @returns {TMessage[]} The list of messages up to the root from the target message. + */ +function getAllMessagesUpToParent(messages, targetMessageId) { + const targetMessage = messages.find((msg) => msg.messageId === targetMessageId); + if (!targetMessage) { + return []; + } + + const pathToRoot = new Set(); + const visited = new Set(); + let current = targetMessage; + + while (current) { + if (visited.has(current.messageId)) { + break; + } + + visited.add(current.messageId); + pathToRoot.add(current.messageId); + + const currentParentId = current.parentMessageId ?? Constants.NO_PARENT; + if (currentParentId === Constants.NO_PARENT) { + break; + } + + current = messages.find((msg) => msg.messageId === currentParentId); + } + + // Include all messages that are in the path or whose parent is in the path + // Exclude children of the target message + return messages.filter( + (msg) => + (pathToRoot.has(msg.messageId) && msg.messageId !== targetMessageId) || + (pathToRoot.has(msg.parentMessageId) && msg.parentMessageId !== targetMessageId) || + msg.messageId === targetMessageId, + ); +} + +/** + * Retrieves all messages up to the root from the target message and its neighbors. + * @param {TMessage[]} messages - The list of messages to search. + * @param {string} targetMessageId - The ID of the target message. + * @returns {TMessage[]} The list of inclusive messages up to the root from the target message. + */ +function getMessagesUpToTargetLevel(messages, targetMessageId) { + if (messages.length === 1 && messages[0] && messages[0].messageId === targetMessageId) { + return messages; + } + + // Create a map of parentMessageId to children messages + const parentToChildrenMap = new Map(); + for (const message of messages) { + if (!parentToChildrenMap.has(message.parentMessageId)) { + parentToChildrenMap.set(message.parentMessageId, []); + } + parentToChildrenMap.get(message.parentMessageId).push(message); + } + + // Retrieve the target message + const targetMessage = messages.find((msg) => msg.messageId === targetMessageId); + if (!targetMessage) { + logger.error('Target message not found.'); + return []; + } + + const visited = new Set(); + + const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || []; + let currentLevel = rootMessages.length > 0 ? [...rootMessages] : [targetMessage]; + const results = new Set(currentLevel); + + // Check if the target message is at the root level + if ( + currentLevel.some((msg) => msg.messageId === targetMessageId) && + targetMessage.parentMessageId === Constants.NO_PARENT + ) { + return Array.from(results); + } + + // Iterate level by level until the target is found + let targetFound = false; + while (!targetFound && currentLevel.length > 0) { + const nextLevel = []; + for (const node of currentLevel) { + if (visited.has(node.messageId)) { + logger.warn('Cycle detected in message tree'); + continue; + } + visited.add(node.messageId); + const children = parentToChildrenMap.get(node.messageId) || []; + for (const child of children) { + if (visited.has(child.messageId)) { + logger.warn('Cycle detected in message tree'); + continue; + } + nextLevel.push(child); + results.add(child); + if (child.messageId === targetMessageId) { + targetFound = true; + } + } + } + currentLevel = nextLevel; + } + + return Array.from(results); +} + +/** + * Splits the conversation at the targeted message level, including the target, its siblings, and all descendant messages. + * All target level messages have their parentMessageId set to the root. + * @param {TMessage[]} messages - The list of messages to analyze. + * @param {string} targetMessageId - The ID of the message to start the split from. + * @returns {TMessage[]} The list of messages at and below the target level. + */ +function splitAtTargetLevel(messages, targetMessageId) { + // Create a map of parentMessageId to children messages + const parentToChildrenMap = new Map(); + for (const message of messages) { + if (!parentToChildrenMap.has(message.parentMessageId)) { + parentToChildrenMap.set(message.parentMessageId, []); + } + parentToChildrenMap.get(message.parentMessageId).push(message); + } + + // Retrieve the target message + const targetMessage = messages.find((msg) => msg.messageId === targetMessageId); + if (!targetMessage) { + logger.error('Target message not found.'); + return []; + } + + // Initialize the search with root messages + const rootMessages = parentToChildrenMap.get(Constants.NO_PARENT) || []; + let currentLevel = [...rootMessages]; + let currentLevelIndex = 0; + const levelMap = {}; + + // Map messages to their levels + rootMessages.forEach((msg) => { + levelMap[msg.messageId] = 0; + }); + + // Search for the target level + while (currentLevel.length > 0) { + const nextLevel = []; + for (const node of currentLevel) { + const children = parentToChildrenMap.get(node.messageId) || []; + for (const child of children) { + nextLevel.push(child); + levelMap[child.messageId] = currentLevelIndex + 1; + } + } + currentLevel = nextLevel; + currentLevelIndex++; + } + + // Determine the target level + const targetLevel = levelMap[targetMessageId]; + if (targetLevel === undefined) { + logger.error('Target level not found.'); + return []; + } + + // Filter messages at or below the target level + const filteredMessages = messages + .map((msg) => { + const messageLevel = levelMap[msg.messageId]; + if (messageLevel < targetLevel) { + return null; + } else if (messageLevel === targetLevel) { + return { + ...msg, + parentMessageId: Constants.NO_PARENT, + }; + } + + return msg; + }) + .filter((msg) => msg !== null); + + return filteredMessages; +} + +module.exports = { + forkConversation, + splitAtTargetLevel, + getAllMessagesUpToParent, + getMessagesUpToTargetLevel, +}; diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js new file mode 100644 index 00000000000..f4f4a2b81ee --- /dev/null +++ b/api/server/utils/import/fork.spec.js @@ -0,0 +1,574 @@ +const { Constants, ForkOptions } = require('librechat-data-provider'); + +jest.mock('~/models/Conversation', () => ({ + getConvo: jest.fn(), + bulkSaveConvos: jest.fn(), +})); + +jest.mock('~/models/Message', () => ({ + getMessages: jest.fn(), + bulkSaveMessages: jest.fn(), +})); + +let mockIdCounter = 0; +jest.mock('uuid', () => { + return { + v4: jest.fn(() => { + mockIdCounter++; + return mockIdCounter.toString(); + }), + }; +}); + +const { + forkConversation, + splitAtTargetLevel, + getAllMessagesUpToParent, + getMessagesUpToTargetLevel, +} = require('./fork'); +const { getConvo, bulkSaveConvos } = require('~/models/Conversation'); +const { getMessages, bulkSaveMessages } = require('~/models/Message'); +const BaseClient = require('~/app/clients/BaseClient'); + +/** + * + * @param {TMessage[]} messages - The list of messages to visualize. + * @param {string | null} parentId - The parent message ID. + * @param {string} prefix - The prefix to use for each line. + * @returns + */ +function printMessageTree(messages, parentId = Constants.NO_PARENT, prefix = '') { + let treeVisual = ''; + + const childMessages = messages.filter((msg) => msg.parentMessageId === parentId); + for (let index = 0; index < childMessages.length; index++) { + const msg = childMessages[index]; + const isLast = index === childMessages.length - 1; + const connector = isLast ? '└── ' : '├── '; + + treeVisual += `${prefix}${connector}[${msg.messageId}]: ${ + msg.parentMessageId !== Constants.NO_PARENT ? `Child of ${msg.parentMessageId}` : 'Root' + }\n`; + treeVisual += printMessageTree(messages, msg.messageId, prefix + (isLast ? ' ' : '| ')); + } + + return treeVisual; +} + +const mockMessages = [ + { + messageId: '0', + parentMessageId: Constants.NO_PARENT, + text: 'Root message 1', + createdAt: '2021-01-01', + }, + { + messageId: '1', + parentMessageId: Constants.NO_PARENT, + text: 'Root message 2', + createdAt: '2021-01-01', + }, + { messageId: '2', parentMessageId: '1', text: 'Child of 1', createdAt: '2021-01-02' }, + { messageId: '3', parentMessageId: '1', text: 'Child of 1', createdAt: '2021-01-03' }, + { messageId: '4', parentMessageId: '2', text: 'Child of 2', createdAt: '2021-01-04' }, + { messageId: '5', parentMessageId: '2', text: 'Child of 2', createdAt: '2021-01-05' }, + { messageId: '6', parentMessageId: '3', text: 'Child of 3', createdAt: '2021-01-06' }, + { messageId: '7', parentMessageId: '3', text: 'Child of 3', createdAt: '2021-01-07' }, + { messageId: '8', parentMessageId: '7', text: 'Child of 7', createdAt: '2021-01-07' }, +]; + +const mockConversation = { convoId: 'abc123', title: 'Original Title' }; + +describe('forkConversation', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIdCounter = 0; + getConvo.mockResolvedValue(mockConversation); + getMessages.mockResolvedValue(mockMessages); + bulkSaveConvos.mockResolvedValue(null); + bulkSaveMessages.mockResolvedValue(null); + }); + + test('should fork conversation without branches', async () => { + const result = await forkConversation({ + originalConvoId: 'abc123', + targetMessageId: '3', + requestUserId: 'user1', + option: ForkOptions.DIRECT_PATH, + }); + console.debug('forkConversation: direct path\n', printMessageTree(result.messages)); + + // Reversed order due to setup in function + const expectedMessagesTexts = ['Child of 1', 'Root message 2']; + expect(getMessages).toHaveBeenCalled(); + expect(bulkSaveMessages).toHaveBeenCalledWith( + expect.arrayContaining( + expectedMessagesTexts.map((text) => expect.objectContaining({ text })), + ), + ); + }); + + test('should fork conversation without branches (deeper)', async () => { + const result = await forkConversation({ + originalConvoId: 'abc123', + targetMessageId: '8', + requestUserId: 'user1', + option: ForkOptions.DIRECT_PATH, + }); + console.debug('forkConversation: direct path (deeper)\n', printMessageTree(result.messages)); + + const expectedMessagesTexts = ['Child of 7', 'Child of 3', 'Child of 1', 'Root message 2']; + expect(getMessages).toHaveBeenCalled(); + expect(bulkSaveMessages).toHaveBeenCalledWith( + expect.arrayContaining( + expectedMessagesTexts.map((text) => expect.objectContaining({ text })), + ), + ); + }); + + test('should fork conversation with branches', async () => { + const result = await forkConversation({ + originalConvoId: 'abc123', + targetMessageId: '3', + requestUserId: 'user1', + option: ForkOptions.INCLUDE_BRANCHES, + }); + + console.debug('forkConversation: include branches\n', printMessageTree(result.messages)); + + const expectedMessagesTexts = ['Root message 2', 'Child of 1', 'Child of 1']; + expect(getMessages).toHaveBeenCalled(); + expect(bulkSaveMessages).toHaveBeenCalledWith( + expect.arrayContaining( + expectedMessagesTexts.map((text) => expect.objectContaining({ text })), + ), + ); + }); + + test('should fork conversation up to target level', async () => { + const result = await forkConversation({ + originalConvoId: 'abc123', + targetMessageId: '3', + requestUserId: 'user1', + option: ForkOptions.TARGET_LEVEL, + }); + + console.debug('forkConversation: target level\n', printMessageTree(result.messages)); + + const expectedMessagesTexts = ['Root message 1', 'Root message 2', 'Child of 1', 'Child of 1']; + expect(getMessages).toHaveBeenCalled(); + expect(bulkSaveMessages).toHaveBeenCalledWith( + expect.arrayContaining( + expectedMessagesTexts.map((text) => expect.objectContaining({ text })), + ), + ); + }); + + test('should handle errors during message fetching', async () => { + getMessages.mockRejectedValue(new Error('Failed to fetch messages')); + + await expect( + forkConversation({ + originalConvoId: 'abc123', + targetMessageId: '3', + requestUserId: 'user1', + }), + ).rejects.toThrow('Failed to fetch messages'); + }); +}); + +const mockMessagesComplex = [ + { messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' }, + { messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' }, + { messageId: '5', parentMessageId: '7', text: 'Message 5' }, + { messageId: '6', parentMessageId: '7', text: 'Message 6' }, + { messageId: '9', parentMessageId: '8', text: 'Message 9' }, + { messageId: '2', parentMessageId: '5', text: 'Message 2' }, + { messageId: '3', parentMessageId: '5', text: 'Message 3' }, + { messageId: '1', parentMessageId: '6', text: 'Message 1' }, + { messageId: '4', parentMessageId: '6', text: 'Message 4' }, + { messageId: '10', parentMessageId: '3', text: 'Message 10' }, +]; + +describe('getMessagesUpToTargetLevel', () => { + test('should get all messages up to target level', async () => { + const result = getMessagesUpToTargetLevel(mockMessagesComplex, '5'); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesUpToTargetLevel] should get all messages up to target level\n', + mappedResult, + ); + console.debug('mockMessages\n', printMessageTree(mockMessagesComplex)); + console.debug('result\n', printMessageTree(result)); + expect(mappedResult).toEqual(['7', '8', '5', '6', '9']); + }); + + test('should get all messages if target is deepest level', async () => { + const result = getMessagesUpToTargetLevel(mockMessagesComplex, '10'); + expect(result.length).toEqual(mockMessagesComplex.length); + }); + + test('should return target if only message', async () => { + const result = getMessagesUpToTargetLevel( + [mockMessagesComplex[mockMessagesComplex.length - 1]], + '10', + ); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesUpToTargetLevel] should return target if only message\n', + mappedResult, + ); + console.debug('mockMessages\n', printMessageTree(mockMessages)); + console.debug('result\n', printMessageTree(result)); + expect(mappedResult).toEqual(['10']); + }); + + test('should return empty array if target message ID does not exist', async () => { + const result = getMessagesUpToTargetLevel(mockMessagesComplex, '123'); + expect(result).toEqual([]); + }); + + test('should return correct messages when target is a root message', async () => { + const result = getMessagesUpToTargetLevel(mockMessagesComplex, '7'); + const mappedResult = result.map((msg) => msg.messageId); + expect(mappedResult).toEqual(['7', '8']); + }); + + test('should correctly handle single message with non-matching ID', async () => { + const singleMessage = [ + { messageId: '30', parentMessageId: Constants.NO_PARENT, text: 'Message 30' }, + ]; + const result = getMessagesUpToTargetLevel(singleMessage, '31'); + expect(result).toEqual([]); + }); + + test('should correctly handle case with circular dependencies', async () => { + const circularMessages = [ + { messageId: '40', parentMessageId: '42', text: 'Message 40' }, + { messageId: '41', parentMessageId: '40', text: 'Message 41' }, + { messageId: '42', parentMessageId: '41', text: 'Message 42' }, + ]; + const result = getMessagesUpToTargetLevel(circularMessages, '40'); + const mappedResult = result.map((msg) => msg.messageId); + expect(new Set(mappedResult)).toEqual(new Set(['40', '41', '42'])); + }); + + test('should return all messages when all are interconnected and target is deep in hierarchy', async () => { + const interconnectedMessages = [ + { messageId: '50', parentMessageId: Constants.NO_PARENT, text: 'Root Message' }, + { messageId: '51', parentMessageId: '50', text: 'Child Level 1' }, + { messageId: '52', parentMessageId: '51', text: 'Child Level 2' }, + { messageId: '53', parentMessageId: '52', text: 'Child Level 3' }, + ]; + const result = getMessagesUpToTargetLevel(interconnectedMessages, '53'); + const mappedResult = result.map((msg) => msg.messageId); + expect(mappedResult).toEqual(['50', '51', '52', '53']); + }); +}); + +describe('getAllMessagesUpToParent', () => { + const mockMessages = [ + { messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' }, + { messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' }, + { messageId: '13', parentMessageId: '11', text: 'Message 13' }, + { messageId: '14', parentMessageId: '12', text: 'Message 14' }, + { messageId: '15', parentMessageId: '13', text: 'Message 15' }, + { messageId: '16', parentMessageId: '13', text: 'Message 16' }, + { messageId: '21', parentMessageId: '13', text: 'Message 21' }, + { messageId: '17', parentMessageId: '14', text: 'Message 17' }, + { messageId: '18', parentMessageId: '16', text: 'Message 18' }, + { messageId: '19', parentMessageId: '18', text: 'Message 19' }, + { messageId: '20', parentMessageId: '19', text: 'Message 20' }, + ]; + + test('should handle empty message list', async () => { + const result = getAllMessagesUpToParent([], '10'); + expect(result).toEqual([]); + }); + + test('should handle target message not found', async () => { + const result = getAllMessagesUpToParent(mockMessages, 'invalid-id'); + expect(result).toEqual([]); + }); + + test('should handle single level tree (no parents)', async () => { + const result = getAllMessagesUpToParent( + [ + { messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' }, + { messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' }, + ], + '11', + ); + const mappedResult = result.map((msg) => msg.messageId); + expect(mappedResult).toEqual(['11']); + }); + + test('should correctly retrieve messages in a deeply nested structure', async () => { + const result = getAllMessagesUpToParent(mockMessages, '20'); + const mappedResult = result.map((msg) => msg.messageId); + expect(mappedResult).toContain('11'); + expect(mappedResult).toContain('13'); + expect(mappedResult).toContain('16'); + expect(mappedResult).toContain('18'); + expect(mappedResult).toContain('19'); + expect(mappedResult).toContain('20'); + }); + + test('should return only the target message if it has no parent', async () => { + const result = getAllMessagesUpToParent(mockMessages, '11'); + const mappedResult = result.map((msg) => msg.messageId); + expect(mappedResult).toEqual(['11']); + }); + + test('should handle messages without a parent ID defined', async () => { + const additionalMessages = [ + ...mockMessages, + { messageId: '22', text: 'Message 22' }, // No parentMessageId field + ]; + const result = getAllMessagesUpToParent(additionalMessages, '22'); + const mappedResult = result.map((msg) => msg.messageId); + expect(mappedResult).toEqual(['22']); + }); + + test('should retrieve all messages from the target to the root (including indirect ancestors)', async () => { + const result = getAllMessagesUpToParent(mockMessages, '18'); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getAllMessagesUpToParent] should retrieve all messages from the target to the root\n', + mappedResult, + ); + console.debug('mockMessages\n', printMessageTree(mockMessages)); + console.debug('result\n', printMessageTree(result)); + expect(mappedResult).toEqual(['11', '13', '15', '16', '21', '18']); + }); + + test('should handle circular dependencies gracefully', () => { + const mockMessages = [ + { messageId: '1', parentMessageId: '2' }, + { messageId: '2', parentMessageId: '3' }, + { messageId: '3', parentMessageId: '1' }, + ]; + + const targetMessageId = '1'; + const result = getAllMessagesUpToParent(mockMessages, targetMessageId); + + const uniqueIds = new Set(result.map((msg) => msg.messageId)); + expect(uniqueIds.size).toBe(result.length); + expect(result.map((msg) => msg.messageId).sort()).toEqual(['1', '2', '3'].sort()); + }); + + test('should return target if only message', async () => { + const result = getAllMessagesUpToParent([mockMessages[mockMessages.length - 1]], '20'); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getAllMessagesUpToParent] should return target if only message\n', + mappedResult, + ); + console.debug('mockMessages\n', printMessageTree(mockMessages)); + console.debug('result\n', printMessageTree(result)); + expect(mappedResult).toEqual(['20']); + }); +}); + +describe('getMessagesForConversation', () => { + const mockMessages = [ + { messageId: '11', parentMessageId: Constants.NO_PARENT, text: 'Message 11' }, + { messageId: '12', parentMessageId: Constants.NO_PARENT, text: 'Message 12' }, + { messageId: '13', parentMessageId: '11', text: 'Message 13' }, + { messageId: '14', parentMessageId: '12', text: 'Message 14' }, + { messageId: '15', parentMessageId: '13', text: 'Message 15' }, + { messageId: '16', parentMessageId: '13', text: 'Message 16' }, + { messageId: '21', parentMessageId: '13', text: 'Message 21' }, + { messageId: '17', parentMessageId: '14', text: 'Message 17' }, + { messageId: '18', parentMessageId: '16', text: 'Message 18' }, + { messageId: '19', parentMessageId: '18', text: 'Message 19' }, + { messageId: '20', parentMessageId: '19', text: 'Message 20' }, + ]; + + test('should provide the direct path to the target without branches', async () => { + const result = BaseClient.getMessagesForConversation({ + messages: mockMessages, + parentMessageId: '18', + }); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesForConversation] should provide the direct path to the target without branches\n', + mappedResult, + ); + console.debug('mockMessages\n', printMessageTree(mockMessages)); + console.debug('result\n', printMessageTree(result)); + expect(new Set(mappedResult)).toEqual(new Set(['11', '13', '16', '18'])); + }); + + test('should return target if only message', async () => { + const result = BaseClient.getMessagesForConversation({ + messages: [mockMessages[mockMessages.length - 1]], + parentMessageId: '20', + }); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesForConversation] should return target if only message\n', + mappedResult, + ); + console.debug('mockMessages\n', printMessageTree(mockMessages)); + console.debug('result\n', printMessageTree(result)); + expect(new Set(mappedResult)).toEqual(new Set(['20'])); + }); + + test('should break on detecting a circular dependency', async () => { + const mockMessagesWithCycle = [ + ...mockMessagesComplex, + { messageId: '100', parentMessageId: '101', text: 'Message 100' }, + { messageId: '101', parentMessageId: '100', text: 'Message 101' }, // introduces circular dependency + ]; + + const result = BaseClient.getMessagesForConversation({ + messages: mockMessagesWithCycle, + parentMessageId: '100', + }); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesForConversation] should break on detecting a circular dependency\n', + mappedResult, + ); + expect(mappedResult).toEqual(['101', '100']); + }); + + // Testing with mockMessagesComplex + test('should correctly find the conversation path including root messages', async () => { + const result = BaseClient.getMessagesForConversation({ + messages: mockMessagesComplex, + parentMessageId: '2', + }); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesForConversation] should correctly find the conversation path including root messages\n', + mappedResult, + ); + expect(new Set(mappedResult)).toEqual(new Set(['7', '5', '2'])); + }); + + // Testing summary feature + test('should stop at summary if option is enabled', async () => { + const messagesWithSummary = [ + ...mockMessagesComplex, + { messageId: '11', parentMessageId: '7', text: 'Message 11', summary: 'Summary for 11' }, + ]; + + const result = BaseClient.getMessagesForConversation({ + messages: messagesWithSummary, + parentMessageId: '11', + summary: true, + }); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesForConversation] should stop at summary if option is enabled\n', + mappedResult, + ); + expect(mappedResult).toEqual(['11']); // Should include only the summarizing message + }); + + // Testing no parent condition + test('should return only the root message if no parent exists', async () => { + const result = BaseClient.getMessagesForConversation({ + messages: mockMessagesComplex, + parentMessageId: '8', + }); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + '[getMessagesForConversation] should return only the root message if no parent exists\n', + mappedResult, + ); + expect(mappedResult).toEqual(['8']); // The message with no parent in the thread + }); +}); + +describe('splitAtTargetLevel', () => { + /* const mockMessagesComplex = [ + { messageId: '7', parentMessageId: Constants.NO_PARENT, text: 'Message 7' }, + { messageId: '8', parentMessageId: Constants.NO_PARENT, text: 'Message 8' }, + { messageId: '5', parentMessageId: '7', text: 'Message 5' }, + { messageId: '6', parentMessageId: '7', text: 'Message 6' }, + { messageId: '9', parentMessageId: '8', text: 'Message 9' }, + { messageId: '2', parentMessageId: '5', text: 'Message 2' }, + { messageId: '3', parentMessageId: '5', text: 'Message 3' }, + { messageId: '1', parentMessageId: '6', text: 'Message 1' }, + { messageId: '4', parentMessageId: '6', text: 'Message 4' }, + { messageId: '10', parentMessageId: '3', text: 'Message 10' }, + ]; + + mockMessages + ├── [7]: Root + | ├── [5]: Child of 7 + | | ├── [2]: Child of 5 + | | └── [3]: Child of 5 + | | └── [10]: Child of 3 + | └── [6]: Child of 7 + | ├── [1]: Child of 6 + | └── [4]: Child of 6 + └── [8]: Root + └── [9]: Child of 8 + */ + test('should include target message level and all descendants (1/2)', () => { + console.debug('splitAtTargetLevel: mockMessages\n', printMessageTree(mockMessagesComplex)); + const result = splitAtTargetLevel(mockMessagesComplex, '2'); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + 'splitAtTargetLevel: include target message level and all descendants (1/2)\n', + printMessageTree(result), + ); + expect(mappedResult).toEqual(['2', '3', '1', '4', '10']); + }); + + test('should include target message level and all descendants (2/2)', () => { + console.debug('splitAtTargetLevel: mockMessages\n', printMessageTree(mockMessagesComplex)); + const result = splitAtTargetLevel(mockMessagesComplex, '5'); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + 'splitAtTargetLevel: include target message level and all descendants (2/2)\n', + printMessageTree(result), + ); + expect(mappedResult).toEqual(['5', '6', '9', '2', '3', '1', '4', '10']); + }); + + test('should handle when target message is root', () => { + const result = splitAtTargetLevel(mockMessagesComplex, '7'); + console.debug('splitAtTargetLevel: target level is root message\n', printMessageTree(result)); + expect(result.length).toBe(mockMessagesComplex.length); + }); + + test('should handle when target message is deepest, lonely child', () => { + const result = splitAtTargetLevel(mockMessagesComplex, '10'); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + 'splitAtTargetLevel: target message is deepest, lonely child\n', + printMessageTree(result), + ); + expect(mappedResult).toEqual(['10']); + }); + + test('should handle when target level is last with many neighbors', () => { + const mockMessages = [ + ...mockMessagesComplex, + { messageId: '11', parentMessageId: '10', text: 'Message 11' }, + { messageId: '12', parentMessageId: '10', text: 'Message 12' }, + { messageId: '13', parentMessageId: '10', text: 'Message 13' }, + { messageId: '14', parentMessageId: '10', text: 'Message 14' }, + { messageId: '15', parentMessageId: '4', text: 'Message 15' }, + { messageId: '16', parentMessageId: '15', text: 'Message 15' }, + ]; + const result = splitAtTargetLevel(mockMessages, '11'); + const mappedResult = result.map((msg) => msg.messageId); + console.debug( + 'splitAtTargetLevel: should handle when target level is last with many neighbors\n', + printMessageTree(result), + ); + expect(mappedResult).toEqual(['11', '12', '13', '14', '16']); + }); + + test('should handle non-existent target message', () => { + // Non-existent message ID + const result = splitAtTargetLevel(mockMessagesComplex, '99'); + expect(result.length).toBe(0); + }); +}); diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js new file mode 100644 index 00000000000..16b4f0ffdaf --- /dev/null +++ b/api/server/utils/import/importBatchBuilder.js @@ -0,0 +1,158 @@ +const { v4: uuidv4 } = require('uuid'); +const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); +const { bulkSaveConvos } = require('~/models/Conversation'); +const { bulkSaveMessages } = require('~/models/Message'); +const { logger } = require('~/config'); + +/** + * Factory function for creating an instance of ImportBatchBuilder. + * @param {string} requestUserId - The ID of the user making the request. + * @returns {ImportBatchBuilder} - The newly created ImportBatchBuilder instance. + */ +function createImportBatchBuilder(requestUserId) { + return new ImportBatchBuilder(requestUserId); +} + +/** + * Class for building a batch of conversations and messages and pushing them to DB for Conversation Import functionality + */ +class ImportBatchBuilder { + /** + * Creates an instance of ImportBatchBuilder. + * @param {string} requestUserId - The ID of the user making the import request. + */ + constructor(requestUserId) { + this.requestUserId = requestUserId; + this.conversations = []; + this.messages = []; + } + + /** + * Starts a new conversation in the batch. + * @param {string} [endpoint=EModelEndpoint.openAI] - The endpoint for the conversation. Defaults to EModelEndpoint.openAI. + * @returns {void} + */ + startConversation(endpoint) { + // we are simplifying by using a single model for the entire conversation + this.endpoint = endpoint || EModelEndpoint.openAI; + this.conversationId = uuidv4(); + this.lastMessageId = Constants.NO_PARENT; + } + + /** + * Adds a user message to the current conversation. + * @param {string} text - The text of the user message. + * @returns {object} The saved message object. + */ + addUserMessage(text) { + const message = this.saveMessage({ text, sender: 'user', isCreatedByUser: true }); + return message; + } + + /** + * Adds a GPT message to the current conversation. + * @param {string} text - The text of the GPT message. + * @param {string} [model='defaultModel'] - The model used for generating the GPT message. Defaults to 'defaultModel'. + * @param {string} [sender='GPT-3.5'] - The sender of the GPT message. Defaults to 'GPT-3.5'. + * @returns {object} The saved message object. + */ + addGptMessage(text, model, sender = 'GPT-3.5') { + const message = this.saveMessage({ + text, + sender, + isCreatedByUser: false, + model: model || openAISettings.model.default, + }); + return message; + } + + /** + * Finishes the current conversation and adds it to the batch. + * @param {string} [title='Imported Chat'] - The title of the conversation. Defaults to 'Imported Chat'. + * @param {Date} [createdAt] - The creation date of the conversation. + * @param {TConversation} [originalConvo] - The original conversation. + * @returns {{ conversation: TConversation, messages: TMessage[] }} The resulting conversation and messages. + */ + finishConversation(title, createdAt, originalConvo = {}) { + const convo = { + ...originalConvo, + user: this.requestUserId, + conversationId: this.conversationId, + title: title || 'Imported Chat', + createdAt: createdAt, + updatedAt: createdAt, + overrideTimestamp: true, + endpoint: this.endpoint, + model: originalConvo.model ?? openAISettings.model.default, + }; + convo._id && delete convo._id; + this.conversations.push(convo); + + return { conversation: convo, messages: this.messages }; + } + + /** + * Saves the batch of conversations and messages to the DB. + * @returns {Promise<void>} A promise that resolves when the batch is saved. + * @throws {Error} If there is an error saving the batch. + */ + async saveBatch() { + try { + await bulkSaveConvos(this.conversations); + await bulkSaveMessages(this.messages); + logger.debug( + `user: ${this.requestUserId} | Added ${this.conversations.length} conversations and ${this.messages.length} messages to the DB.`, + ); + } catch (error) { + logger.error('Error saving batch', error); + throw error; + } + } + + /** + * Saves a message to the current conversation. + * @param {object} messageDetails - The details of the message. + * @param {string} messageDetails.text - The text of the message. + * @param {string} messageDetails.sender - The sender of the message. + * @param {string} [messageDetails.messageId] - The ID of the current message. + * @param {boolean} messageDetails.isCreatedByUser - Indicates whether the message is created by the user. + * @param {string} [messageDetails.model] - The model used for generating the message. + * @param {string} [messageDetails.endpoint] - The endpoint used for generating the message. + * @param {string} [messageDetails.parentMessageId=this.lastMessageId] - The ID of the parent message. + * @param {Partial<TMessage>} messageDetails.rest - Additional properties that may be included in the message. + * @returns {object} The saved message object. + */ + saveMessage({ + text, + sender, + isCreatedByUser, + model, + messageId, + parentMessageId = this.lastMessageId, + endpoint, + ...rest + }) { + const newMessageId = messageId ?? uuidv4(); + const message = { + ...rest, + parentMessageId, + messageId: newMessageId, + conversationId: this.conversationId, + isCreatedByUser: isCreatedByUser, + model: model || this.model, + user: this.requestUserId, + endpoint: endpoint ?? this.endpoint, + unfinished: false, + isEdited: false, + error: false, + sender, + text, + }; + message._id && delete message._id; + this.lastMessageId = newMessageId; + this.messages.push(message); + return message; + } +} + +module.exports = { ImportBatchBuilder, createImportBatchBuilder }; diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js new file mode 100644 index 00000000000..6e151ead277 --- /dev/null +++ b/api/server/utils/import/importers.js @@ -0,0 +1,295 @@ +const { v4: uuidv4 } = require('uuid'); +const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); +const { createImportBatchBuilder } = require('./importBatchBuilder'); +const logger = require('~/config/winston'); + +/** + * Returns the appropriate importer function based on the provided JSON data. + * + * @param {Object} jsonData - The JSON data to import. + * @returns {Function} - The importer function. + * @throws {Error} - If the import type is not supported. + */ +function getImporter(jsonData) { + // For ChatGPT + if (Array.isArray(jsonData)) { + logger.info('Importing ChatGPT conversation'); + return importChatGptConvo; + } + + // For ChatbotUI + if (jsonData.version && Array.isArray(jsonData.history)) { + logger.info('Importing ChatbotUI conversation'); + return importChatBotUiConvo; + } + + // For LibreChat + if (jsonData.conversationId && jsonData.messagesTree) { + logger.info('Importing LibreChat conversation'); + return importLibreChatConvo; + } + + throw new Error('Unsupported import type'); +} + +/** + * Imports a chatbot-ui V1 conversation from a JSON file and saves it to the database. + * + * @param {Object} jsonData - The JSON data containing the chatbot conversation. + * @param {string} requestUserId - The ID of the user making the import request. + * @param {Function} [builderFactory=createImportBatchBuilder] - The factory function to create an import batch builder. + * @returns {Promise<void>} - A promise that resolves when the import is complete. + * @throws {Error} - If there is an error creating the conversation from the JSON file. + */ +async function importChatBotUiConvo( + jsonData, + requestUserId, + builderFactory = createImportBatchBuilder, +) { + // this have been tested with chatbot-ui V1 export https://github.com/mckaywrigley/chatbot-ui/tree/b865b0555f53957e96727bc0bbb369c9eaecd83b#legacy-code + try { + /** @type {ImportBatchBuilder} */ + const importBatchBuilder = builderFactory(requestUserId); + + for (const historyItem of jsonData.history) { + importBatchBuilder.startConversation(EModelEndpoint.openAI); + for (const message of historyItem.messages) { + if (message.role === 'assistant') { + importBatchBuilder.addGptMessage(message.content, historyItem.model.id); + } else if (message.role === 'user') { + importBatchBuilder.addUserMessage(message.content); + } + } + importBatchBuilder.finishConversation(historyItem.name, new Date()); + } + await importBatchBuilder.saveBatch(); + logger.info(`user: ${requestUserId} | ChatbotUI conversation imported`); + } catch (error) { + logger.error(`user: ${requestUserId} | Error creating conversation from ChatbotUI file`, error); + } +} + +/** + * Imports a LibreChat conversation from JSON. + * + * @param {Object} jsonData - The JSON data representing the conversation. + * @param {string} requestUserId - The ID of the user making the import request. + * @param {Function} [builderFactory=createImportBatchBuilder] - The factory function to create an import batch builder. + * @returns {Promise<void>} - A promise that resolves when the import is complete. + */ +async function importLibreChatConvo( + jsonData, + requestUserId, + builderFactory = createImportBatchBuilder, +) { + try { + /** @type {ImportBatchBuilder} */ + const importBatchBuilder = builderFactory(requestUserId); + importBatchBuilder.startConversation(EModelEndpoint.openAI); + + let firstMessageDate = null; + + const traverseMessages = (messages, parentMessageId = null) => { + for (const message of messages) { + if (!message.text) { + continue; + } + + let savedMessage; + if (message.sender?.toLowerCase() === 'user') { + savedMessage = importBatchBuilder.saveMessage({ + text: message.text, + sender: 'user', + isCreatedByUser: true, + parentMessageId: parentMessageId, + }); + } else { + savedMessage = importBatchBuilder.saveMessage({ + text: message.text, + sender: message.sender, + isCreatedByUser: false, + model: jsonData.options.model, + parentMessageId: parentMessageId, + }); + } + + if (!firstMessageDate) { + firstMessageDate = new Date(message.createdAt); + } + + if (message.children) { + traverseMessages(message.children, savedMessage.messageId); + } + } + }; + + traverseMessages(jsonData.messagesTree); + + importBatchBuilder.finishConversation(jsonData.title, firstMessageDate); + await importBatchBuilder.saveBatch(); + logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`); + } catch (error) { + logger.error(`user: ${requestUserId} | Error creating conversation from LibreChat file`, error); + } +} + +/** + * Imports ChatGPT conversations from provided JSON data. + * Initializes the import process by creating a batch builder and processing each conversation in the data. + * + * @param {ChatGPTConvo[]} jsonData - Array of conversation objects to be imported. + * @param {string} requestUserId - The ID of the user who initiated the import process. + * @param {Function} builderFactory - Factory function to create a new import batch builder instance, defaults to createImportBatchBuilder. + * @returns {Promise<void>} Promise that resolves when all conversations have been imported. + */ +async function importChatGptConvo( + jsonData, + requestUserId, + builderFactory = createImportBatchBuilder, +) { + try { + const importBatchBuilder = builderFactory(requestUserId); + for (const conv of jsonData) { + processConversation(conv, importBatchBuilder, requestUserId); + } + await importBatchBuilder.saveBatch(); + } catch (error) { + logger.error(`user: ${requestUserId} | Error creating conversation from imported file`, error); + } +} + +/** + * Processes a single conversation, adding messages to the batch builder based on author roles and handling text content. + * It directly manages the addition of messages for different roles and handles citations for assistant messages. + * + * @param {ChatGPTConvo} conv - A single conversation object that contains multiple messages and other details. + * @param {ImportBatchBuilder} importBatchBuilder - The batch builder instance used to manage and batch conversation data. + * @param {string} requestUserId - The ID of the user who initiated the import process. + * @returns {void} + */ +function processConversation(conv, importBatchBuilder, requestUserId) { + importBatchBuilder.startConversation(EModelEndpoint.openAI); + + // Map all message IDs to new UUIDs + const messageMap = new Map(); + for (const [id, mapping] of Object.entries(conv.mapping)) { + if (mapping.message && mapping.message.content.content_type) { + const newMessageId = uuidv4(); + messageMap.set(id, newMessageId); + } + } + + // Create and save messages using the mapped IDs + const messages = []; + for (const [id, mapping] of Object.entries(conv.mapping)) { + const role = mapping.message?.author?.role; + if (!mapping.message) { + messageMap.delete(id); + continue; + } else if (role === 'system') { + messageMap.delete(id); + continue; + } + + const newMessageId = messageMap.get(id); + const parentMessageId = + mapping.parent && messageMap.has(mapping.parent) + ? messageMap.get(mapping.parent) + : Constants.NO_PARENT; + + const messageText = formatMessageText(mapping.message); + + const isCreatedByUser = role === 'user'; + let sender = isCreatedByUser ? 'user' : 'GPT-3.5'; + const model = mapping.message.metadata.model_slug || openAISettings.model.default; + if (model.includes('gpt-4')) { + sender = 'GPT-4'; + } + + messages.push({ + messageId: newMessageId, + parentMessageId, + text: messageText, + sender, + isCreatedByUser, + model, + user: requestUserId, + endpoint: EModelEndpoint.openAI, + }); + } + + for (const message of messages) { + importBatchBuilder.saveMessage(message); + } + + importBatchBuilder.finishConversation(conv.title, new Date(conv.create_time * 1000)); +} + +/** + * Processes text content of messages authored by an assistant, inserting citation links as required. + * Applies citation metadata to construct regex patterns and replacements for inserting links into the text. + * + * @param {ChatGPTMessage} messageData - The message data containing metadata about citations. + * @param {string} messageText - The original text of the message which may be altered by inserting citation links. + * @returns {string} - The updated message text after processing for citations. + */ +function processAssistantMessage(messageData, messageText) { + const citations = messageData.metadata.citations ?? []; + + for (const citation of citations) { + if ( + !citation.metadata || + !citation.metadata.extra || + !citation.metadata.extra.cited_message_idx || + (citation.metadata.type && citation.metadata.type !== 'webpage') + ) { + continue; + } + + const pattern = new RegExp( + `\\u3010${citation.metadata.extra.cited_message_idx}\\u2020.+?\\u3011`, + 'g', + ); + const replacement = ` ([${citation.metadata.title}](${citation.metadata.url}))`; + messageText = messageText.replace(pattern, replacement); + } + + return messageText; +} + +/** + * Formats the text content of a message based on its content type and author role. + * @param {ChatGPTMessage} messageData - The message data. + * @returns {string} - The updated message text after processing. + */ +function formatMessageText(messageData) { + const isText = messageData.content.content_type === 'text'; + let messageText = ''; + + if (isText && messageData.content.parts) { + messageText = messageData.content.parts.join(' '); + } else if (messageData.content.content_type === 'code') { + messageText = `\`\`\`${messageData.content.language}\n${messageData.content.text}\n\`\`\``; + } else if (messageData.content.content_type === 'execution_output') { + messageText = `Execution Output:\n> ${messageData.content.text}`; + } else if (messageData.content.parts) { + for (const part of messageData.content.parts) { + if (typeof part === 'string') { + messageText += part + ' '; + } else if (typeof part === 'object') { + messageText = `\`\`\`json\n${JSON.stringify(part, null, 2)}\n\`\`\`\n`; + } + } + messageText = messageText.trim(); + } else { + messageText = `\`\`\`json\n${JSON.stringify(messageData.content, null, 2)}\n\`\`\``; + } + + if (isText && messageData.author.role !== 'user') { + messageText = processAssistantMessage(messageData, messageText); + } + + return messageText; +} + +module.exports = { getImporter }; diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js new file mode 100644 index 00000000000..38036fe3eb3 --- /dev/null +++ b/api/server/utils/import/importers.spec.js @@ -0,0 +1,246 @@ +const fs = require('fs'); +const path = require('path'); +const { EModelEndpoint, Constants } = require('librechat-data-provider'); +const { ImportBatchBuilder } = require('./importBatchBuilder'); +const { getImporter } = require('./importers'); + +// Mocking the ImportBatchBuilder class and its methods +jest.mock('./importBatchBuilder', () => { + return { + ImportBatchBuilder: jest.fn().mockImplementation(() => { + return { + startConversation: jest.fn().mockResolvedValue(undefined), + addUserMessage: jest.fn().mockResolvedValue(undefined), + addGptMessage: jest.fn().mockResolvedValue(undefined), + saveMessage: jest.fn().mockResolvedValue(undefined), + finishConversation: jest.fn().mockResolvedValue(undefined), + saveBatch: jest.fn().mockResolvedValue(undefined), + }; + }), + }; +}); + +describe('importChatGptConvo', () => { + it('should import conversation correctly', async () => { + const expectedNumberOfMessages = 19; + const expectedNumberOfConversations = 2; + // Given + const jsonData = JSON.parse( + fs.readFileSync(path.join(__dirname, '__data__', 'chatgpt-export.json'), 'utf8'), + ); + const requestUserId = 'user-123'; + const mockedBuilderFactory = jest.fn().mockReturnValue(new ImportBatchBuilder(requestUserId)); + + // When + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, mockedBuilderFactory); + + // Then + expect(mockedBuilderFactory).toHaveBeenCalledWith(requestUserId); + const mockImportBatchBuilder = mockedBuilderFactory.mock.results[0].value; + + expect(mockImportBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.openAI); + expect(mockImportBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedNumberOfMessages); // Adjust expected number + expect(mockImportBatchBuilder.finishConversation).toHaveBeenCalledTimes( + expectedNumberOfConversations, + ); // Adjust expected number + expect(mockImportBatchBuilder.saveBatch).toHaveBeenCalled(); + }); + it('should maintain correct message hierarchy (tree parent/children relationship)', async () => { + // Prepare test data with known hierarchy + const jsonData = JSON.parse( + fs.readFileSync(path.join(__dirname, '__data__', 'chatgpt-tree.json'), 'utf8'), + ); + + const requestUserId = 'user-123'; + const mockedBuilderFactory = jest.fn().mockReturnValue(new ImportBatchBuilder(requestUserId)); + + // When + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, mockedBuilderFactory); + + // Then + expect(mockedBuilderFactory).toHaveBeenCalledWith(requestUserId); + const mockImportBatchBuilder = mockedBuilderFactory.mock.results[0].value; + + const entries = Object.keys(jsonData[0].mapping); + // Filter entries that should be processed (not system and have content) + const messageEntries = entries.filter( + (id) => + jsonData[0].mapping[id].message && + jsonData[0].mapping[id].message.author.role !== 'system' && + jsonData[0].mapping[id].message.content, + ); + + // Expect the saveMessage to be called for each valid entry + expect(mockImportBatchBuilder.saveMessage).toHaveBeenCalledTimes(messageEntries.length); + + const idToUUIDMap = new Map(); + // Map original IDs to dynamically generated UUIDs + mockImportBatchBuilder.saveMessage.mock.calls.forEach((call, index) => { + const originalId = messageEntries[index]; + idToUUIDMap.set(originalId, call[0].messageId); + }); + + // Validate the UUID map contains all expected entries + expect(idToUUIDMap.size).toBe(messageEntries.length); + + // Validate correct parent-child relationships + messageEntries.forEach((id) => { + const { parent } = jsonData[0].mapping[id]; + + const expectedParentId = parent + ? idToUUIDMap.get(parent) ?? Constants.NO_PARENT + : Constants.NO_PARENT; + + const actualParentId = idToUUIDMap.get(id) + ? mockImportBatchBuilder.saveMessage.mock.calls.find( + (call) => call[0].messageId === idToUUIDMap.get(id), + )[0].parentMessageId + : Constants.NO_PARENT; + + expect(actualParentId).toBe(expectedParentId); + }); + + expect(mockImportBatchBuilder.saveBatch).toHaveBeenCalled(); + }); +}); + +describe('importLibreChatConvo', () => { + it('should import conversation correctly', async () => { + const expectedNumberOfMessages = 6; + const expectedNumberOfConversations = 1; + + // Given + const jsonData = JSON.parse( + fs.readFileSync(path.join(__dirname, '__data__', 'librechat-export.json'), 'utf8'), + ); + const requestUserId = 'user-123'; + const mockedBuilderFactory = jest.fn().mockReturnValue(new ImportBatchBuilder(requestUserId)); + + // When + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, mockedBuilderFactory); + + // Then + const mockImportBatchBuilder = mockedBuilderFactory.mock.results[0].value; + expect(mockImportBatchBuilder.startConversation).toHaveBeenCalledWith(EModelEndpoint.openAI); + expect(mockImportBatchBuilder.saveMessage).toHaveBeenCalledTimes(expectedNumberOfMessages); // Adjust expected number + expect(mockImportBatchBuilder.finishConversation).toHaveBeenCalledTimes( + expectedNumberOfConversations, + ); // Adjust expected number + expect(mockImportBatchBuilder.saveBatch).toHaveBeenCalled(); + }); + it('should maintain correct message hierarchy (tree parent/children relationship)', async () => { + // Load test data + const jsonData = JSON.parse( + fs.readFileSync(path.join(__dirname, '__data__', 'librechat-tree.json'), 'utf8'), + ); + const requestUserId = 'user-123'; + const mockedBuilderFactory = jest.fn().mockReturnValue(new ImportBatchBuilder(requestUserId)); + + // When + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, mockedBuilderFactory); + + // Then + const mockImportBatchBuilder = mockedBuilderFactory.mock.results[0].value; + + // Create a map to track original message IDs to new UUIDs + const idToUUIDMap = new Map(); + mockImportBatchBuilder.saveMessage.mock.calls.forEach((call) => { + const message = call[0]; + idToUUIDMap.set(message.originalMessageId, message.messageId); + }); + + // Function to recursively check children + const checkChildren = (children, parentId) => { + children.forEach((child) => { + const childUUID = idToUUIDMap.get(child.messageId); + const expectedParentId = idToUUIDMap.get(parentId) ?? null; + const messageCall = mockImportBatchBuilder.saveMessage.mock.calls.find( + (call) => call[0].messageId === childUUID, + ); + + const actualParentId = messageCall[0].parentMessageId; + expect(actualParentId).toBe(expectedParentId); + + if (child.children && child.children.length > 0) { + checkChildren(child.children, child.messageId); + } + }); + }; + + // Start hierarchy validation from root messages + checkChildren(jsonData.messagesTree, null); // Assuming root messages have no parent + + expect(mockImportBatchBuilder.saveBatch).toHaveBeenCalled(); + }); +}); + +describe('importChatBotUiConvo', () => { + it('should import custom conversation correctly', async () => { + // Given + const jsonData = JSON.parse( + fs.readFileSync(path.join(__dirname, '__data__', 'chatbotui-export.json'), 'utf8'), + ); + const requestUserId = 'custom-user-456'; + const mockedBuilderFactory = jest.fn().mockReturnValue(new ImportBatchBuilder(requestUserId)); + + // When + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, mockedBuilderFactory); + + // Then + const mockImportBatchBuilder = mockedBuilderFactory.mock.results[0].value; + expect(mockImportBatchBuilder.startConversation).toHaveBeenCalledWith('openAI'); + + // User messages + expect(mockImportBatchBuilder.addUserMessage).toHaveBeenCalledTimes(3); + expect(mockImportBatchBuilder.addUserMessage).toHaveBeenNthCalledWith( + 1, + 'Hello what are you able to do?', + ); + expect(mockImportBatchBuilder.addUserMessage).toHaveBeenNthCalledWith( + 3, + 'Give me the code that inverts binary tree in COBOL', + ); + + // GPT messages + expect(mockImportBatchBuilder.addGptMessage).toHaveBeenCalledTimes(3); + expect(mockImportBatchBuilder.addGptMessage).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/^Hello! As an AI developed by OpenAI/), + 'gpt-4-1106-preview', + ); + expect(mockImportBatchBuilder.addGptMessage).toHaveBeenNthCalledWith( + 3, + expect.stringContaining('```cobol'), + 'gpt-3.5-turbo', + ); + + expect(mockImportBatchBuilder.finishConversation).toHaveBeenCalledTimes(2); + expect(mockImportBatchBuilder.finishConversation).toHaveBeenNthCalledWith( + 1, + 'Hello what are you able to do?', + expect.any(Date), + ); + expect(mockImportBatchBuilder.finishConversation).toHaveBeenNthCalledWith( + 2, + 'Give me the code that inverts ...', + expect.any(Date), + ); + + expect(mockImportBatchBuilder.saveBatch).toHaveBeenCalled(); + }); +}); + +describe('getImporter', () => { + it('should throw an error if the import type is not supported', () => { + // Given + const jsonData = { unsupported: 'data' }; + + // When + expect(() => getImporter(jsonData)).toThrow('Unsupported import type'); + }); +}); diff --git a/api/server/utils/import/index.js b/api/server/utils/import/index.js new file mode 100644 index 00000000000..0afa7bfa800 --- /dev/null +++ b/api/server/utils/import/index.js @@ -0,0 +1,5 @@ +const importers = require('./importers'); + +module.exports = { + ...importers, +}; diff --git a/api/server/utils/import/jobDefinition.js b/api/server/utils/import/jobDefinition.js new file mode 100644 index 00000000000..7b5d217229f --- /dev/null +++ b/api/server/utils/import/jobDefinition.js @@ -0,0 +1,41 @@ +const fs = require('fs').promises; +const jobScheduler = require('~/server/utils/jobScheduler'); +const { getImporter } = require('./importers'); +const { indexSync } = require('~/lib/db'); +const { logger } = require('~/config'); + +const IMPORT_CONVERSATION_JOB_NAME = 'import conversation'; + +/** + * Job definition for importing a conversation. + * @param {import('agenda').Job} job - The job object. + * @param {Function} done - The done function. + */ +const importConversationJob = async (job, done) => { + const { filepath, requestUserId } = job.attrs.data; + try { + logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`); + const fileData = await fs.readFile(filepath, 'utf8'); + const jsonData = JSON.parse(fileData); + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId); + // Sync Meilisearch index + await indexSync(); + logger.debug(`user: ${requestUserId} | Finished importing conversations`); + done(); + } catch (error) { + logger.error(`user: ${requestUserId} | Failed to import conversation: `, error); + done(error); + } finally { + try { + await fs.unlink(filepath); + } catch (error) { + logger.error(`user: ${requestUserId} | Failed to delete file: ${filepath}`, error); + } + } +}; + +// Call the jobScheduler.define function at startup +jobScheduler.define(IMPORT_CONVERSATION_JOB_NAME, importConversationJob); + +module.exports = { IMPORT_CONVERSATION_JOB_NAME }; diff --git a/api/server/utils/jobScheduler.js b/api/server/utils/jobScheduler.js new file mode 100644 index 00000000000..d297b3bbd87 --- /dev/null +++ b/api/server/utils/jobScheduler.js @@ -0,0 +1,99 @@ +const Agenda = require('agenda'); +const { logger } = require('~/config'); +const mongodb = require('mongodb'); + +/** + * Class for scheduling and running jobs. + * The workflow is as follows: start the job scheduler, define a job, and then schedule the job using defined job name. + */ +class JobScheduler { + constructor() { + this.agenda = new Agenda({ db: { address: process.env.MONGO_URI } }); + } + + /** + * Starts the job scheduler. + */ + async start() { + try { + logger.info('Starting Agenda...'); + await this.agenda.start(); + logger.info('Agenda successfully started and connected to MongoDB.'); + } catch (error) { + logger.error('Failed to start Agenda:', error); + } + } + + /** + * Schedules a job to start immediately. + * @param {string} jobName - The name of the job to schedule. + * @param {string} filepath - The filepath to pass to the job. + * @param {string} userId - The ID of the user requesting the job. + * @returns {Promise<{ id: string }>} - A promise that resolves with the ID of the scheduled job. + * @throws {Error} - If the job fails to schedule. + */ + async now(jobName, filepath, userId) { + try { + const job = await this.agenda.now(jobName, { filepath, requestUserId: userId }); + logger.debug(`Job '${job.attrs.name}' scheduled successfully.`); + return { id: job.attrs._id.toString() }; + } catch (error) { + throw new Error(`Failed to schedule job '${jobName}': ${error}`); + } + } + + /** + * Gets the status of a job. + * @param {string} jobId - The ID of the job to get the status of. + * @returns {Promise<{ id: string, userId: string, name: string, failReason: string, status: string } | null>} - A promise that resolves with the job status or null if the job is not found. + * @throws {Error} - If multiple jobs are found. + */ + async getJobStatus(jobId) { + const job = await this.agenda.jobs({ _id: new mongodb.ObjectId(jobId) }); + if (!job || job.length === 0) { + return null; + } + + if (job.length > 1) { + // This should never happen + throw new Error('Multiple jobs found.'); + } + + const jobDetails = { + id: job[0]._id, + userId: job[0].attrs.data.requestUserId, + name: job[0].attrs.name, + failReason: job[0].attrs.failReason, + status: !job[0].attrs.lastRunAt + ? 'scheduled' + : job[0].attrs.failedAt + ? 'failed' + : job[0].attrs.lastFinishedAt + ? 'completed' + : 'running', + }; + + return jobDetails; + } + + /** + * Defines a new job. + * @param {string} name - The name of the job. + * @param {Function} jobFunction - The function to run when the job is executed. + */ + define(name, jobFunction) { + this.agenda.define(name, async (job, done) => { + try { + await jobFunction(job, done); + } catch (error) { + logger.error(`Failed to run job '${name}': ${error}`); + done(error); + } + }); + } +} + +const jobScheduler = new JobScheduler(); +jobScheduler.start(); + +module.exports = jobScheduler; diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index cb56d42db02..f60e894f623 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -1,9 +1,7 @@ -const fs = require('fs'); -const path = require('path'); -const axios = require('axios'); const passport = require('passport'); const jwtDecode = require('jsonwebtoken/decode'); const { Issuer, Strategy: OpenIDStrategy } = require('openid-client'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { logger } = require('~/config'); const User = require('~/models/User'); @@ -13,22 +11,31 @@ try { } catch (err) { logger.error('[openidStrategy] crypto support is disabled!', err); } +/** + * Downloads an image from a URL using an access token. + * @param {string} url + * @param {string} accessToken + * @returns {Promise<Buffer>} + */ +const downloadImage = async (url, accessToken) => { + if (!url) { + return ''; + } -const downloadImage = async (url, imagePath, accessToken) => { try { - const response = await axios.get(url, { + const response = await fetch(url, { + method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, }, - responseType: 'arraybuffer', }); - fs.mkdirSync(path.dirname(imagePath), { recursive: true }); - fs.writeFileSync(imagePath, response.data); - - const fileName = path.basename(imagePath); - - return `/images/openid/${fileName}`; + if (response.ok) { + const buffer = await response.buffer(); + return buffer; + } else { + throw new Error(`${response.statusText} (HTTP ${response.status})`); + } } catch (error) { logger.error( `[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, @@ -124,7 +131,9 @@ async function setupOpenId() { } } - const username = convertToUsername(userinfo.username || userinfo.given_name || userinfo.email); + const username = convertToUsername( + userinfo.username || userinfo.given_name || userinfo.email, + ); if (!user) { user = new User({ @@ -143,6 +152,7 @@ async function setupOpenId() { } if (userinfo.picture) { + /** @type {string | undefined} */ const imageUrl = userinfo.picture; let fileName; @@ -154,24 +164,18 @@ async function setupOpenId() { fileName = userinfo.sub + '.png'; } - const imagePath = path.join( - __dirname, - '..', - '..', - 'client', - 'public', - 'images', - 'openid', - fileName, - ); - - const imagePathOrEmpty = await downloadImage( - imageUrl, - imagePath, - tokenset.access_token, - ); - - user.avatar = imagePathOrEmpty; + const imageBuffer = await downloadImage(imageUrl, tokenset.access_token); + const { saveBuffer } = getStrategyFunctions(process.env.CDN_PROVIDER); + if (imageBuffer) { + const imagePath = await saveBuffer({ + fileName, + userId: user._id.toString(), + buffer: imageBuffer, + }); + user.avatar = imagePath ?? ''; + } else { + user.avatar = ''; + } } else { user.avatar = ''; } diff --git a/api/strategies/process.js b/api/strategies/process.js index 9b791023195..76d2d6ae472 100644 --- a/api/strategies/process.js +++ b/api/strategies/process.js @@ -25,12 +25,12 @@ const handleExistingUser = async (oldUser, avatarUrl) => { await oldUser.save(); } else if (!isLocal && (oldUser.avatar === null || !oldUser.avatar.includes('?manual=true'))) { const userId = oldUser._id; - const webPBuffer = await resizeAvatar({ + const resizedBuffer = await resizeAvatar({ userId, input: avatarUrl, }); const { processAvatar } = getStrategyFunctions(fileStrategy); - oldUser.avatar = await processAvatar({ buffer: webPBuffer, userId }); + oldUser.avatar = await processAvatar({ buffer: resizedBuffer, userId }); await oldUser.save(); } }; @@ -83,12 +83,12 @@ const createNewUser = async ({ if (!isLocal) { const userId = newUser._id; - const webPBuffer = await resizeAvatar({ + const resizedBuffer = await resizeAvatar({ userId, input: avatarUrl, }); const { processAvatar } = getStrategyFunctions(fileStrategy); - newUser.avatar = await processAvatar({ buffer: webPBuffer, userId }); + newUser.avatar = await processAvatar({ buffer: resizedBuffer, userId }); await newUser.save(); } diff --git a/api/test/.env.test.example b/api/test/.env.test.example index 16730f672db..9b7a75a9963 100644 --- a/api/test/.env.test.example +++ b/api/test/.env.test.example @@ -1,4 +1,4 @@ -# Test database. You can use your actual MONGO_URI if you don't mind it potentially including test data. +# Test DB URI. You can use your actual MONGO_URI if you don't mind it potentially including test data. MONGO_URI=mongodb://127.0.0.1:27017/chatgpt-jest # Credential encryption/decryption for testing diff --git a/api/test/jestSetup.js b/api/test/jestSetup.js index 456832e0713..cc6f61177ce 100644 --- a/api/test/jestSetup.js +++ b/api/test/jestSetup.js @@ -1,6 +1,7 @@ // See .env.test.example for an example of the '.env.test' file. require('dotenv').config({ path: './test/.env.test' }); +process.env.MONGO_URI = 'mongodb://127.0.0.1:27017/dummy-uri'; process.env.BAN_VIOLATIONS = 'true'; process.env.BAN_DURATION = '7200000'; process.env.BAN_INTERVAL = '20'; diff --git a/api/typedefs.js b/api/typedefs.js index e844e1eb909..f7970be4f3b 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -8,12 +8,30 @@ * @memberof typedefs */ +/** + * @exports Ollama + * @typedef {import('ollama').Ollama} Ollama + * @memberof typedefs + */ + +/** + * @exports AxiosResponse + * @typedef {import('axios').AxiosResponse} AxiosResponse + * @memberof typedefs + */ + /** * @exports Anthropic * @typedef {import('@anthropic-ai/sdk').default} Anthropic * @memberof typedefs */ +/** + * @exports GenerativeModel + * @typedef {import('@google/generative-ai').GenerativeModel} GenerativeModel + * @memberof typedefs + */ + /** * @exports AssistantStreamEvent * @typedef {import('openai').default.Beta.AssistantStreamEvent} AssistantStreamEvent @@ -51,8 +69,14 @@ */ /** - * @exports ChatCompletionMessages - * @typedef {import('openai').OpenAI.ChatCompletionMessageParam} ChatCompletionMessages + * @exports OllamaMessage + * @typedef {import('ollama').Message} OllamaMessage + * @memberof typedefs + */ + +/** + * @exports ChatCompletionMessage + * @typedef {import('openai').OpenAI.ChatCompletionMessageParam} ChatCompletionMessage * @memberof typedefs */ @@ -289,12 +313,36 @@ * @memberof typedefs */ +/** + * @exports TStartupConfig + * @typedef {import('librechat-data-provider').TStartupConfig} TStartupConfig + * @memberof typedefs + */ + +/** + * @exports TConfigDefaults + * @typedef {import('librechat-data-provider').TConfigDefaults} TConfigDefaults + * @memberof typedefs + */ + /** * @exports TPlugin * @typedef {import('librechat-data-provider').TPlugin} TPlugin * @memberof typedefs */ +/** + * @exports TAzureConfigValidationResult + * @typedef {import('librechat-data-provider').TAzureConfigValidationResult} TAzureConfigValidationResult + * @memberof typedefs + */ + +/** + * @exports EImageOutputType + * @typedef {import('librechat-data-provider').EImageOutputType} EImageOutputType + * @memberof typedefs + */ + /** * @exports TCustomConfig * @typedef {import('librechat-data-provider').TCustomConfig} TCustomConfig @@ -319,6 +367,18 @@ * @memberof typedefs */ +/** + * @exports TConversation + * @typedef {import('librechat-data-provider').TConversation} TConversation + * @memberof typedefs + */ + +/** + * @exports TModelSpec + * @typedef {import('librechat-data-provider').TModelSpec} TModelSpec + * @memberof typedefs + */ + /** * @exports TPlugin * @typedef {import('librechat-data-provider').TPlugin} TPlugin @@ -597,6 +657,12 @@ * @memberof typedefs */ +/** + * @exports ImportBatchBuilder + * @typedef {import('./server/utils/import/importBatchBuilder.js').ImportBatchBuilder} ImportBatchBuilder + * @memberof typedefs + */ + /** * @exports Thread * @typedef {Object} Thread @@ -1100,7 +1166,7 @@ /** * Main entrypoint for API completion calls * @callback sendCompletion - * @param {Array<ChatCompletionMessages> | string} payload - The messages or prompt to send to the model + * @param {Array<ChatCompletionMessage> | string} payload - The messages or prompt to send to the model * @param {object} opts - Options for the completion * @param {onTokenProgress} opts.onProgress - Callback function to handle token progress * @param {AbortController} opts.abortController - AbortController instance @@ -1111,7 +1177,7 @@ /** * Legacy completion handler for OpenAI API. * @callback getCompletion - * @param {Array<ChatCompletionMessages> | string} input - Array of messages or a single prompt string + * @param {Array<ChatCompletionMessage> | string} input - Array of messages or a single prompt string * @param {(event: object | string) => Promise<void>} onProgress - SSE progress handler * @param {onTokenProgress} onTokenProgress - Token progress handler * @param {AbortController} [abortController] - AbortController instance @@ -1127,3 +1193,100 @@ * @param {onTokenProgress} params.onTokenProgress * @memberof typedefs */ + +/** + * @typedef {Object} OllamaModelDetails + * @property {string} parent_model - The identifier for the parent model, if any. + * @property {string} format - The format of the model. + * @property {string} family - The primary family to which the model belongs. + * @property {string[]} families - An array of families that include the model. + * @property {string} parameter_size - The size of the parameters of the model. + * @property {string} quantization_level - The level of quantization of the model. + * @memberof typedefs + */ + +/** + * @typedef {Object} OllamaModel + * @property {string} name - The name of the model, including version tag. + * @property {string} model - A redundant copy of the name, including version tag. + * @property {string} modified_at - The ISO string representing the last modification date. + * @property {number} size - The size of the model in bytes. + * @property {string} digest - The digest hash of the model. + * @property {OllamaModelDetails} details - Detailed information about the model. + * @memberof typedefs + */ + +/** + * @typedef {Object} OllamaListResponse + * @property {OllamaModel[]} models - the list of models available. + * @memberof typedefs + */ + +/** + * @typedef {Object} ChatGPTAuthor + * @property {string} role - The role of the author (e.g., 'assistant', 'system', 'user'). + * @property {?string} name - The name of the author, if available. + * @property {Object} metadata - Additional metadata related to the author. + * @memberof typedefs + */ + +/** + * @typedef {Object} ChatGPTContentPart + * @property {string} content_type - The type of content (e.g., 'text'). + * @property {string[]} parts - The textual parts of the message. + * @memberof typedefs + */ + +/** + * @typedef {Object} ChatGPTMetadata + * @property {boolean} is_visually_hidden_from_conversation - Indicates if the message should be hidden. + * @property {?Array<Object>} citations - Potential citations included in the message. + * @memberof typedefs + */ + +/** + * @typedef {Object} ChatGPTMessage + * @property {string} id - Unique identifier for the message. + * @property {?ChatGPTAuthor} author - The author of the message. + * @property {?number} create_time - Creation time as a Unix timestamp. + * @property {?number} update_time - Last update time as a Unix timestamp. + * @property {ChatGPTContentPart} content - Content of the message. + * @property {string} status - Status of the message (e.g., 'finished_successfully'). + * @property {boolean} end_turn - Indicates if it's the end of a conversation turn. + * @property {number} weight - A numerical value representing the weight/importance of the message. + * @property {ChatGPTMetadata} metadata - Metadata associated with the message. + * @property {string} recipient - Intended recipient of the message. + * @memberof typedefs + */ + +/** + * @typedef {Object} ChatGPTMapping + * @property {ChatGPTMessage} message - Details of the message. + * @property {string} id - Identifier of the message. + * @property {?string} parent - Parent message ID. + * @property {string[]} children - Child message IDs. + * @memberof typedefs + */ + +/** + * @typedef {Object} ChatGPTConvo + * @property {string} title - Title of the conversation. + * @property {number} create_time - Creation time of the conversation as a Unix timestamp. + * @property {number} update_time - Last update time of the conversation as a Unix timestamp. + * @property {Object.<string, ChatGPTMapping>} mapping - Mapping of message nodes within the conversation. + * @memberof typedefs + */ + +/** Mutations */ + +/** + * @exports TForkConvoResponse + * @typedef {import('librechat-data-provider').TForkConvoResponse} TForkConvoResponse + * @memberof typedefs + */ + +/** + * @exports TForkConvoRequest + * @typedef {import('librechat-data-provider').TForkConvoRequest} TForkConvoRequest + * @memberof typedefs + */ diff --git a/api/utils/deriveBaseURL.js b/api/utils/deriveBaseURL.js new file mode 100644 index 00000000000..c377ddf874d --- /dev/null +++ b/api/utils/deriveBaseURL.js @@ -0,0 +1,28 @@ +const { logger } = require('~/config'); + +/** + * Extracts the base URL from the provided URL. + * @param {string} fullURL - The full URL. + * @returns {string} The base URL. + */ +function deriveBaseURL(fullURL) { + try { + const parsedUrl = new URL(fullURL); + const protocol = parsedUrl.protocol; + const hostname = parsedUrl.hostname; + const port = parsedUrl.port; + + // Check if the parsed URL components are meaningful + if (!protocol || !hostname) { + return fullURL; + } + + // Reconstruct the base URL + return `${protocol}//${hostname}${port ? `:${port}` : ''}`; + } catch (error) { + logger.error('Failed to derive base URL', error); + return fullURL; // Return the original URL in case of any exception + } +} + +module.exports = deriveBaseURL; diff --git a/api/utils/deriveBaseURL.spec.js b/api/utils/deriveBaseURL.spec.js new file mode 100644 index 00000000000..6df0bc65cd7 --- /dev/null +++ b/api/utils/deriveBaseURL.spec.js @@ -0,0 +1,74 @@ +const axios = require('axios'); +const deriveBaseURL = require('./deriveBaseURL'); +jest.mock('~/utils', () => { + const originalUtils = jest.requireActual('~/utils'); + return { + ...originalUtils, + processModelData: jest.fn((...args) => { + return originalUtils.processModelData(...args); + }), + }; +}); + +jest.mock('axios'); +jest.mock('~/cache/getLogStores', () => + jest.fn().mockImplementation(() => ({ + get: jest.fn().mockResolvedValue(undefined), + set: jest.fn().mockResolvedValue(true), + })), +); +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + }, +})); + +axios.get.mockResolvedValue({ + data: { + data: [{ id: 'model-1' }, { id: 'model-2' }], + }, +}); + +describe('deriveBaseURL', () => { + it('should extract the base URL correctly from a full URL with a port', () => { + const fullURL = 'https://example.com:8080/path?query=123'; + const baseURL = deriveBaseURL(fullURL); + expect(baseURL).toEqual('https://example.com:8080'); + }); + + it('should extract the base URL correctly from a full URL without a port', () => { + const fullURL = 'https://example.com/path?query=123'; + const baseURL = deriveBaseURL(fullURL); + expect(baseURL).toEqual('https://example.com'); + }); + + it('should handle URLs using the HTTP protocol', () => { + const fullURL = 'http://example.com:3000/path?query=123'; + const baseURL = deriveBaseURL(fullURL); + expect(baseURL).toEqual('http://example.com:3000'); + }); + + it('should return only the protocol and hostname if no port is specified', () => { + const fullURL = 'http://example.com/path?query=123'; + const baseURL = deriveBaseURL(fullURL); + expect(baseURL).toEqual('http://example.com'); + }); + + it('should handle URLs with uncommon protocols', () => { + const fullURL = 'ftp://example.com:2121/path?query=123'; + const baseURL = deriveBaseURL(fullURL); + expect(baseURL).toEqual('ftp://example.com:2121'); + }); + + it('should handle edge case where URL ends with a slash', () => { + const fullURL = 'https://example.com/'; + const baseURL = deriveBaseURL(fullURL); + expect(baseURL).toEqual('https://example.com'); + }); + + it('should return the original URL if the URL is invalid', () => { + const invalidURL = 'htp:/example.com:8080'; + const result = deriveBaseURL(invalidURL); + expect(result).toBe(invalidURL); + }); +}); diff --git a/api/utils/index.js b/api/utils/index.js index 7b539cbb141..29357f7adb8 100644 --- a/api/utils/index.js +++ b/api/utils/index.js @@ -1,6 +1,7 @@ const loadYaml = require('./loadYaml'); const tokenHelpers = require('./tokens'); const azureUtils = require('./azureUtils'); +const deriveBaseURL = require('./deriveBaseURL'); const logAxiosError = require('./logAxiosError'); const extractBaseURL = require('./extractBaseURL'); const findMessageContent = require('./findMessageContent'); @@ -9,6 +10,7 @@ module.exports = { loadYaml, ...tokenHelpers, ...azureUtils, + deriveBaseURL, logAxiosError, extractBaseURL, findMessageContent, diff --git a/api/utils/loadYaml.js b/api/utils/loadYaml.js index b7068e209f0..50e5d23ec39 100644 --- a/api/utils/loadYaml.js +++ b/api/utils/loadYaml.js @@ -6,7 +6,7 @@ function loadYaml(filepath) { let fileContents = fs.readFileSync(filepath, 'utf8'); return yaml.load(fileContents); } catch (e) { - // console.error(e); + return e; } } diff --git a/api/utils/tokens.js b/api/utils/tokens.js index bc1a5a10f3a..b0d90988219 100644 --- a/api/utils/tokens.js +++ b/api/utils/tokens.js @@ -48,7 +48,9 @@ const openAIModels = { 'gpt-4-32k-0613': 32758, // -10 from max 'gpt-4-1106': 127990, // -10 from max 'gpt-4-0125': 127990, // -10 from max + 'gpt-4o': 127990, // -10 from max 'gpt-4-turbo': 127990, // -10 from max + 'gpt-4-vision': 127990, // -10 from max 'gpt-3.5-turbo': 16375, // -10 from max 'gpt-3.5-turbo-0613': 4092, // -5 from max 'gpt-3.5-turbo-0301': 4092, // -5 from max @@ -65,12 +67,14 @@ const cohereModels = { command: 4086, // -10 from max 'command-nightly': 8182, // -10 from max 'command-r': 127500, // -500 from max - 'command-r-plus:': 127500, // -500 from max + 'command-r-plus': 127500, // -500 from max }; const googleModels = { /* Max I/O is combined so we subtract the amount from max response tokens for actual total */ - gemini: 32750, // -10 from max + gemini: 30720, // -2048 from max + 'gemini-pro-vision': 12288, // -4096 from max + 'gemini-1.5': 1048576, // -8192 from max 'text-bison-32k': 32758, // -10 from max 'chat-bison-32k': 32758, // -10 from max 'code-bison-32k': 32758, // -10 from max diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 641b300458a..c51610c917f 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -59,6 +59,12 @@ describe('getModelMaxTokens', () => { expect(getModelMaxTokens('gpt-4-1106')).toBe(maxTokensMap[EModelEndpoint.openAI]['gpt-4-1106']); }); + test('should return correct tokens for gpt-4-vision exact match', () => { + expect(getModelMaxTokens('gpt-4-vision')).toBe( + maxTokensMap[EModelEndpoint.openAI]['gpt-4-vision'], + ); + }); + test('should return correct tokens for gpt-3.5-turbo-1106 partial match', () => { expect(getModelMaxTokens('something-/gpt-3.5-turbo-1106')).toBe( maxTokensMap[EModelEndpoint.openAI]['gpt-3.5-turbo-1106'], @@ -131,6 +137,18 @@ describe('getModelMaxTokens', () => { }); test('should return correct tokens for partial match - Google models', () => { + expect(getModelMaxTokens('gemini-1.5-pro-latest', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-1.5'], + ); + expect(getModelMaxTokens('gemini-1.5-pro-preview-0409', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-1.5'], + ); + expect(getModelMaxTokens('gemini-pro-vision', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-pro-vision'], + ); + expect(getModelMaxTokens('gemini-1.0', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini'], + ); expect(getModelMaxTokens('gemini-pro', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini'], ); @@ -142,6 +160,15 @@ describe('getModelMaxTokens', () => { ); }); + test('should return correct tokens for partial match - Cohere models', () => { + expect(getModelMaxTokens('command', EModelEndpoint.custom)).toBe( + maxTokensMap[EModelEndpoint.custom]['command'], + ); + expect(getModelMaxTokens('command-r-plus', EModelEndpoint.custom)).toBe( + maxTokensMap[EModelEndpoint.custom]['command-r-plus'], + ); + }); + test('should return correct tokens when using a custom endpointTokenConfig', () => { const customTokenConfig = { 'custom-model': 12345, diff --git a/bun.lockb b/bun.lockb index b85c088716c..3f37e7a782e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/client/check_updates.sh b/client/check_updates.sh new file mode 100755 index 00000000000..8ee7c109de5 --- /dev/null +++ b/client/check_updates.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# Set the directory containing the package.json file +dir=${1:-.} + +# Today's date and the date 3 days ago in seconds since the Unix epoch +today=$(date +%s) +three_days_ago=$(date -d "3 days ago" +%s) + +# Read dependencies and devDependencies from package.json +dependencies=$(jq -r '.dependencies,.devDependencies|keys[]' "$dir/package.json") +packages=($dependencies) # Convert JSON array to bash array + +# Array to hold update messages +declare -a updates + +# Loop over each package +for pkg in "${packages[@]}" +do + echo "Checking $pkg..." + # Retrieve the version time information as JSON + times=$(npm view "$pkg" time --json) + + # Loop through dates from the JSON object and check if any are within the last 3 days + echo $times | jq -r '. | to_entries[] | select(.key as $k | $k|test("^[0-9]")) | [.key, .value] | @csv' | while IFS="," read -r version date + do + # Format the date to remove quotes and trim it + date=$(echo $date | tr -d '"' | xargs) + # Convert date to seconds since the Unix epoch + version_date=$(date -d "$date" +%s) + + # Check if this date is within the last three days + if (( version_date > three_days_ago && version_date <= today )) + then + # Convert UTC to Eastern Time (ET), ensuring compatibility + et_date=$(date -u -d "$date" +"%Y-%m-%d %H:%M:%S UTC") + et_date=$(date -d "$et_date -4 hours" +"%Y-%m-%d %H:%M:%S ET") + update_message="Version $version of $pkg was released on $et_date" + echo "$update_message" + updates+=("$update_message") + fi + done +done + +# Display all collected updates +if [ ${#updates[@]} -eq 0 ]; then + echo "No recent updates found within the last three days." +else + echo "Recent updates within the last three days:" + printf "%s\n" "${updates[@]}" +fi diff --git a/client/index.html b/client/index.html index bad6b4194c8..100b208a0c8 100644 --- a/client/index.html +++ b/client/index.html @@ -5,6 +5,7 @@ <meta name="theme-color" content="#171717"> <meta name="mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes"> + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <title>LibreChat vite-output.log 2>&1", "build": "cross-env NODE_ENV=production vite build", "build:ci": "cross-env NODE_ENV=development vite build --mode ci", "dev": "cross-env NODE_ENV=development vite", @@ -127,7 +128,7 @@ "ts-jest": "^29.1.0", "typescript": "^5.0.4", "vite": "^5.1.1", - "vite-plugin-html": "^3.2.0", - "vite-plugin-node-polyfills": "^0.17.0" + "vite-plugin-node-polyfills": "^0.17.0", + "vite-plugin-pwa": "^0.19.8" } } diff --git a/client/public/assets/ShuttleAI_Fibonacci.png b/client/public/assets/ShuttleAI_Fibonacci.png deleted file mode 100644 index eddeb4e362d..00000000000 Binary files a/client/public/assets/ShuttleAI_Fibonacci.png and /dev/null differ diff --git a/client/public/assets/apipie.png b/client/public/assets/apipie.png new file mode 100644 index 00000000000..f133c466106 Binary files /dev/null and b/client/public/assets/apipie.png differ diff --git a/client/public/assets/huggingface.svg b/client/public/assets/huggingface.svg new file mode 100644 index 00000000000..ab959d165fa --- /dev/null +++ b/client/public/assets/huggingface.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/docs/assets/LibreChat.svg b/client/public/assets/logo.svg similarity index 100% rename from docs/assets/LibreChat.svg rename to client/public/assets/logo.svg diff --git a/client/public/assets/maskable-icon.png b/client/public/assets/maskable-icon.png new file mode 100644 index 00000000000..18305a24460 Binary files /dev/null and b/client/public/assets/maskable-icon.png differ diff --git a/client/public/assets/mlx.png b/client/public/assets/mlx.png new file mode 100644 index 00000000000..06a77c9b6c1 Binary files /dev/null and b/client/public/assets/mlx.png differ diff --git a/client/src/Providers/SearchContext.tsx b/client/src/Providers/SearchContext.tsx new file mode 100644 index 00000000000..678818aa186 --- /dev/null +++ b/client/src/Providers/SearchContext.tsx @@ -0,0 +1,6 @@ +import { createContext, useContext } from 'react'; +import useSearch from '~/hooks/Conversations/useSearch'; +type SearchContextType = ReturnType; + +export const SearchContext = createContext({} as SearchContextType); +export const useSearchContext = () => useContext(SearchContext); diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 32e5c25dc49..debfdeac046 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -2,6 +2,7 @@ export { default as ToastProvider } from './ToastContext'; export { default as AssistantsProvider } from './AssistantsContext'; export * from './ChatContext'; export * from './ToastContext'; +export * from './SearchContext'; export * from './FileMapContext'; export * from './AssistantsContext'; export * from './AssistantsMapContext'; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index a5a8a01c11b..e574e90d89f 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -1,26 +1,32 @@ import { FileSources } from 'librechat-data-provider'; +import type * as InputNumberPrimitive from 'rc-input-number'; import type { ColumnDef } from '@tanstack/react-table'; import type { SetterOrUpdater } from 'recoil'; import type { - TSetOption as SetOption, - TConversation, - TMessage, - TPreset, - TLoginUser, TUser, - EModelEndpoint, Action, + TPreset, + TPlugin, + TMessage, + TLoginUser, AuthTypeEnum, + TConversation, + EModelEndpoint, AuthorizationTypeEnum, + TSetOption as SetOption, TokenExchangeMethodEnum, } from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; import type { LucideIcon } from 'lucide-react'; +export type TPluginMap = Record; + export type GenericSetter = (value: T | ((currentValue: T) => T)) => void; export type LastSelectedModels = Record; +export type LocalizeFunction = (phraseKey: string, ...values: string[]) => string; + export const mainTextareaId = 'prompt-textarea'; export enum IconContext { @@ -30,6 +36,16 @@ export enum IconContext { message = 'message', } +export type IconMapProps = { + className?: string; + iconURL?: string; + context?: 'landing' | 'menu-item' | 'nav' | 'message'; + endpoint?: string | null; + assistantName?: string; + avatar?: string; + size?: number; +}; + export type NavLink = { title: string; label?: string; @@ -100,6 +116,8 @@ export type TSetExample = ( newValue: number | string | boolean | null, ) => void; +export type OnInputNumberChange = InputNumberPrimitive.InputNumberProps['onChange']; + export const defaultDebouncedDelay = 450; export enum ESide { @@ -309,6 +327,7 @@ export type Option = Record & { }; export type OptionWithIcon = Option & { icon?: React.ReactNode }; +export type MentionOption = OptionWithIcon & { type: string; value: string; description?: string }; export type TOptionSettings = { showExamples?: boolean; diff --git a/client/src/components/Auth/Login.tsx b/client/src/components/Auth/Login.tsx index c811ac0a146..8bff10c2588 100644 --- a/client/src/components/Auth/Login.tsx +++ b/client/src/components/Auth/Login.tsx @@ -96,7 +96,7 @@ function Login() { const privacyPolicyRender = privacyPolicy?.externalUrl && ( -
+
+
+ Logo +
+
-
-

- {localize('com_auth_welcome_back')} -

- {error && ( -
+
+

- {localize(getLoginError(error))} -

- )} - {startupConfig.emailLoginEnabled && } - {startupConfig.registrationEnabled && ( -

- {' '} - {localize('com_auth_no_account')}{' '} - - {localize('com_auth_sign_up')} - -

- )} - {startupConfig.socialLoginEnabled && ( - <> - {startupConfig.emailLoginEnabled && ( - <> -
-
- Or -
-
-
- - )} -
- {socialLogins.map((provider) => providerComponents[provider] || null)} + {localize('com_auth_welcome_back')} +

+ {error && ( +
+ {localize(getLoginError(error))}
- - )} + )} + {startupConfig.emailLoginEnabled && } + {startupConfig.registrationEnabled && ( +

+ {' '} + {localize('com_auth_no_account')}{' '} + + {localize('com_auth_sign_up')} + +

+ )} + {startupConfig.socialLoginEnabled && ( + <> + {startupConfig.emailLoginEnabled && ( + <> +
+
+ Or +
+
+
+ + )} +
+ {socialLogins.map((provider) => providerComponents[provider] || null)} +
+ + )} +
-
+
{privacyPolicyRender} {privacyPolicyRender && termsOfServiceRender && ( -
+
)} {termsOfServiceRender}
diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index 102c4826576..c9ad52c673a 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -18,7 +18,7 @@ const LoginForm: React.FC = ({ onSubmit }) => { const renderError = (fieldName: string) => { const errorMessage = errors[fieldName]?.message; return errorMessage ? ( - + {String(errorMessage)} ) : null; @@ -44,12 +44,12 @@ const LoginForm: React.FC = ({ onSubmit }) => { pattern: { value: /\S+@\S+\.\S+/, message: localize('com_auth_email_pattern') }, })} aria-invalid={!!errors.email} - className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-black/10 bg-white px-2.5 pb-2.5 pt-5 text-sm text-gray-800 focus:border-green-500 focus:outline-none dark:border-white/20 dark:bg-gray-900 dark:text-white dark:focus:border-green-500" + className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" placeholder=" " /> @@ -69,19 +69,19 @@ const LoginForm: React.FC = ({ onSubmit }) => { maxLength: { value: 128, message: localize('com_auth_password_max_length') }, })} aria-invalid={!!errors.password} - className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-black/10 bg-white px-2.5 pb-2.5 pt-5 text-sm text-gray-800 focus:border-green-500 focus:outline-none dark:border-white/20 dark:bg-gray-900 dark:text-white dark:focus:border-green-500" + className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" placeholder=" " />
{renderError('password')}
- + {localize('com_auth_password_forgot')}
diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index 02e462a0f24..6bdd55d9720 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -64,19 +64,19 @@ const Registration: React.FC = () => { validation, )} aria-invalid={!!errors[id]} - className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-black/10 bg-white px-2.5 pb-2.5 pt-5 text-sm text-gray-800 focus:border-green-500 focus:outline-none dark:border-white/20 dark:bg-gray-900 dark:text-white dark:focus:border-green-500" + className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" placeholder=" " data-testid={id} >
{errors[id] && ( - + {String(errors[id]?.message) ?? ''} )} @@ -147,117 +147,154 @@ const Registration: React.FC = () => { ), }; + const privacyPolicy = startupConfig.interface?.privacyPolicy; + const termsOfService = startupConfig.interface?.termsOfService; + + const privacyPolicyRender = privacyPolicy?.externalUrl && ( + + {localize('com_ui_privacy_policy')} + + ); + + const termsOfServiceRender = termsOfService?.externalUrl && ( + + {localize('com_ui_terms_of_service')} + + ); + return ( -
-
+
+
+ Logo +
+
-
-

- {localize('com_auth_create_account')} -

- {error && ( -
+
+

- {localize('com_auth_error_create')} {errorMessage} -

- )} -
- {renderInput('name', 'com_auth_full_name', 'text', { - required: localize('com_auth_name_required'), - minLength: { - value: 3, - message: localize('com_auth_name_min_length'), - }, - maxLength: { - value: 80, - message: localize('com_auth_name_max_length'), - }, - })} - {renderInput('username', 'com_auth_username', 'text', { - minLength: { - value: 2, - message: localize('com_auth_username_min_length'), - }, - maxLength: { - value: 80, - message: localize('com_auth_username_max_length'), - }, - })} - {renderInput('email', 'com_auth_email', 'email', { - required: localize('com_auth_email_required'), - minLength: { - value: 1, - message: localize('com_auth_email_min_length'), - }, - maxLength: { - value: 120, - message: localize('com_auth_email_max_length'), - }, - pattern: { - value: /\S+@\S+\.\S+/, - message: localize('com_auth_email_pattern'), - }, - })} - {renderInput('password', 'com_auth_password', 'password', { - required: localize('com_auth_password_required'), - minLength: { - value: 8, - message: localize('com_auth_password_min_length'), - }, - maxLength: { - value: 128, - message: localize('com_auth_password_max_length'), - }, - })} - {renderInput('confirm_password', 'com_auth_password_confirm', 'password', { - validate: (value) => value === password || localize('com_auth_password_not_match'), - })} -
- -
-
-

- {localize('com_auth_already_have_account')}{' '} - - {localize('com_auth_login')} - -

- {startupConfig.socialLoginEnabled && ( - <> - {startupConfig.emailLoginEnabled && ( - <> -
-
- Or -
-
-
- - )} -
- {socialLogins.map((provider) => providerComponents[provider] || null)} + {localize('com_auth_error_create')} {errorMessage} +
+ )} +
+ {renderInput('name', 'com_auth_full_name', 'text', { + required: localize('com_auth_name_required'), + minLength: { + value: 3, + message: localize('com_auth_name_min_length'), + }, + maxLength: { + value: 80, + message: localize('com_auth_name_max_length'), + }, + })} + {renderInput('username', 'com_auth_username', 'text', { + minLength: { + value: 2, + message: localize('com_auth_username_min_length'), + }, + maxLength: { + value: 80, + message: localize('com_auth_username_max_length'), + }, + })} + {renderInput('email', 'com_auth_email', 'email', { + required: localize('com_auth_email_required'), + minLength: { + value: 1, + message: localize('com_auth_email_min_length'), + }, + maxLength: { + value: 120, + message: localize('com_auth_email_max_length'), + }, + pattern: { + value: /\S+@\S+\.\S+/, + message: localize('com_auth_email_pattern'), + }, + })} + {renderInput('password', 'com_auth_password', 'password', { + required: localize('com_auth_password_required'), + minLength: { + value: 8, + message: localize('com_auth_password_min_length'), + }, + maxLength: { + value: 128, + message: localize('com_auth_password_max_length'), + }, + })} + {renderInput('confirm_password', 'com_auth_password_confirm', 'password', { + validate: (value) => value === password || localize('com_auth_password_not_match'), + })} +
+
- +
+

+ {localize('com_auth_already_have_account')}{' '} + + {localize('com_auth_login')} + +

+ {startupConfig.socialLoginEnabled && ( + <> + {startupConfig.emailLoginEnabled && ( + <> +
+
+ Or +
+
+
+ + )} +
+ {socialLogins.map((provider) => providerComponents[provider] || null)} +
+ + )} +
+
+
+ {privacyPolicyRender} + {privacyPolicyRender && termsOfServiceRender && ( +
)} + {termsOfServiceRender}
); diff --git a/client/src/components/Auth/RequestPasswordReset.tsx b/client/src/components/Auth/RequestPasswordReset.tsx index ded90c7db65..671ca481333 100644 --- a/client/src/components/Auth/RequestPasswordReset.tsx +++ b/client/src/components/Auth/RequestPasswordReset.tsx @@ -49,7 +49,7 @@ function RequestPasswordReset() { setBodyText( {localize('com_auth_click')}{' '} - + {localize('com_auth_here')} {' '} {localize('com_auth_to_reset_your_password')} @@ -66,7 +66,7 @@ function RequestPasswordReset() { if (bodyText) { return (
{bodyText} @@ -103,18 +103,18 @@ function RequestPasswordReset() { }, })} aria-invalid={!!errors.email} - className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-black/10 bg-gray-50 px-2.5 pb-2.5 pt-5 text-sm text-gray-800 focus:border-green-500 focus:outline-none dark:border-white/20 dark:bg-gray-900 dark:text-white dark:focus:border-green-500" + className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" placeholder=" " >
{errors.email && ( - + {/* @ts-ignore not sure why */} {errors.email.message} @@ -129,7 +129,7 @@ function RequestPasswordReset() { {localize('com_auth_continue')} @@ -139,24 +139,61 @@ function RequestPasswordReset() { } }; + const privacyPolicy = config.data?.interface?.privacyPolicy; + const termsOfService = config.data?.interface?.termsOfService; + + const privacyPolicyRender = privacyPolicy?.externalUrl && ( + + {localize('com_ui_privacy_policy')} + + ); + + const termsOfServiceRender = termsOfService?.externalUrl && ( + + {localize('com_ui_terms_of_service')} + + ); + return ( -
-
+
+
+ Logo +
+
-
-

- {headerText} -

- {requestError && ( -
- {localize('com_auth_error_reset_password')} -
+
+
+

+ {headerText} +

+ {requestError && ( +
+ {localize('com_auth_error_reset_password')} +
+ )} + {renderFormContent()} +
+
+
+ {privacyPolicyRender} + {privacyPolicyRender && termsOfServiceRender && ( +
)} - {renderFormContent()} + {termsOfServiceRender}
); diff --git a/client/src/components/Auth/ResetPassword.tsx b/client/src/components/Auth/ResetPassword.tsx index bf6aa7944a3..bb5f3f3a777 100644 --- a/client/src/components/Auth/ResetPassword.tsx +++ b/client/src/components/Auth/ResetPassword.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useResetPasswordMutation } from 'librechat-data-provider/react-query'; +import { useGetStartupConfig, useResetPasswordMutation } from 'librechat-data-provider/react-query'; import type { TResetPassword } from 'librechat-data-provider'; import { ThemeSelector } from '~/components/ui'; import { useLocalize } from '~/hooks'; @@ -15,6 +15,7 @@ function ResetPassword() { formState: { errors }, } = useForm(); const resetPassword = useResetPasswordMutation(); + const config = useGetStartupConfig(); const [resetError, setResetError] = useState(false); const [params] = useSearchParams(); const navigate = useNavigate(); @@ -28,6 +29,31 @@ function ResetPassword() { }); }; + const privacyPolicy = config.data?.interface?.privacyPolicy; + const termsOfService = config.data?.interface?.termsOfService; + + const privacyPolicyRender = privacyPolicy?.externalUrl && ( + + {localize('com_ui_privacy_policy')} + + ); + + const termsOfServiceRender = termsOfService?.externalUrl && ( + + {localize('com_ui_terms_of_service')} + + ); + if (resetPassword.isSuccess) { return (
@@ -56,139 +82,146 @@ function ResetPassword() { ); } else { return ( -
-
+
+
+ Logo +
+
-
-

- {localize('com_auth_reset_password')} -

- {resetError && ( -
- {localize('com_auth_error_invalid_reset_token')}{' '} - - {localize('com_auth_click_here')} - {' '} - {localize('com_auth_to_try_again')} -
- )} -
-
-
- - - - +
+
+

+ {localize('com_auth_reset_password')} +

+ {resetError && ( +
+ {localize('com_auth_error_invalid_reset_token')}{' '} + + {localize('com_auth_click_here')} + {' '} + {localize('com_auth_to_try_again')}
+ )} + +
+
+ + + + +
- {errors.password && ( - - {/* @ts-ignore not sure why */} - {errors.password.message} - - )} -
-
-
- { - e.preventDefault(); - return false; - }} - {...register('confirm_password', { - validate: (value) => - value === password || localize('com_auth_password_not_match'), - })} - aria-invalid={!!errors.confirm_password} - className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-black/10 bg-white px-2.5 pb-2.5 pt-5 text-sm text-gray-800 focus:border-green-500 focus:outline-none dark:border-white/20 dark:bg-gray-900 dark:text-white dark:focus:border-green-500" - placeholder=" " - > -
+
+
+ + value === password || localize('com_auth_password_not_match'), + })} + aria-invalid={!!errors.confirm_password} + className="webkit-dark-styles peer block w-full appearance-none rounded-md border border-gray-300 bg-transparent px-3.5 pb-3.5 pt-4 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-0 dark:border-gray-600 dark:text-white dark:focus:border-green-500" + placeholder=" " + > + +
+ {errors.confirm_password && ( + + {/* @ts-ignore not sure why */} + {errors.confirm_password.message} + + )} + {errors.token && ( + + {/* @ts-ignore not sure why */} + {errors.token.message} + + )} + {errors.userId && ( + + {/* @ts-ignore not sure why */} + {errors.userId.message} + + )} +
+
+
- {errors.confirm_password && ( - - {/* @ts-ignore not sure why */} - {errors.confirm_password.message} - - )} - {errors.token && ( - - {/* @ts-ignore not sure why */} - {errors.token.message} - - )} - {errors.userId && ( - - {/* @ts-ignore not sure why */} - {errors.userId.message} - - )} -
-
- -
- + +
+
+
+ {privacyPolicyRender} + {privacyPolicyRender && termsOfServiceRender && ( +
+ )} + {termsOfServiceRender}
); diff --git a/client/src/components/Auth/SocialButton.tsx b/client/src/components/Auth/SocialButton.tsx index 7e76c6f7638..8b8318ac3a4 100644 --- a/client/src/components/Auth/SocialButton.tsx +++ b/client/src/components/Auth/SocialButton.tsx @@ -15,6 +15,7 @@ const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) => const handleMouseLeave = () => { setIsHovered(false); + if (isPressed) {setIsPressed(false);} }; const handleMouseDown = () => { @@ -28,7 +29,7 @@ const SocialButton = ({ id, enabled, serverDomain, oauthPath, Icon, label }) => const getButtonStyles = () => { // Define Tailwind CSS classes based on state - const baseStyles = 'border border-solid border-gray-300 dark:border-gray-800 transition-colors'; + const baseStyles = 'border border-solid border-gray-300 dark:border-gray-600 transition-colors'; const pressedStyles = 'bg-blue-200 border-blue-200 dark:bg-blue-900 dark:border-blue-600'; const hoverStyles = 'bg-gray-100 dark:bg-gray-700'; diff --git a/client/src/components/Chat/ExportButton.tsx b/client/src/components/Chat/ExportButton.tsx new file mode 100644 index 00000000000..48acd20ec2f --- /dev/null +++ b/client/src/components/Chat/ExportButton.tsx @@ -0,0 +1,71 @@ +import React from 'react'; + +import { useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import type { TConversation } from 'librechat-data-provider'; +import { Download } from 'lucide-react'; +import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui'; +import { useLocalize } from '~/hooks'; +import { ExportModal } from '../Nav'; +import { useRecoilValue } from 'recoil'; +import store from '~/store'; + +function ExportButton() { + const localize = useLocalize(); + const location = useLocation(); + + const [showExports, setShowExports] = useState(false); + + const activeConvo = useRecoilValue(store.conversationByIndex(0)); + const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation); + + let conversation: TConversation | null | undefined; + if (location.state?.from?.pathname.includes('/chat')) { + conversation = globalConvo; + } else { + conversation = activeConvo; + } + + const clickHandler = () => { + if (exportable) { + setShowExports(true); + } + }; + + const exportable = + conversation && + conversation.conversationId && + conversation.conversationId !== 'new' && + conversation.conversationId !== 'search'; + + return ( + <> + {exportable && ( +
+ + + + + + + {localize('com_nav_export_conversation')} + + + +
+ )} + {showExports && ( + + )} + + ); +} + +export default ExportButton; diff --git a/client/src/components/Chat/Header.tsx b/client/src/components/Chat/Header.tsx index 868f9292adf..ab40ace0b04 100644 --- a/client/src/components/Chat/Header.tsx +++ b/client/src/components/Chat/Header.tsx @@ -1,17 +1,34 @@ +import { useMemo } from 'react'; import { useOutletContext } from 'react-router-dom'; +import { getConfigDefaults } from 'librechat-data-provider'; +import { useGetStartupConfig } from 'librechat-data-provider/react-query'; import type { ContextType } from '~/common'; -import { EndpointsMenu, PresetsMenu, HeaderNewChat } from './Menus'; +import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus'; import HeaderOptions from './Input/HeaderOptions'; +import ExportButton from './ExportButton'; + +const defaultInterface = getConfigDefaults().interface; export default function Header() { + const { data: startupConfig } = useGetStartupConfig(); const { navVisible } = useOutletContext(); + const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]); + const interfaceConfig = useMemo( + () => startupConfig?.interface ?? defaultInterface, + [startupConfig], + ); + return (
-
- {!navVisible && } - - - +
+
+ {!navVisible && } + {interfaceConfig.endpointsMenu && } + {modelSpecs?.length > 0 && } + {} + {interfaceConfig.presets && } +
+
{/* Empty div for spacing */}
diff --git a/client/src/components/Chat/Input/ActiveSetting.tsx b/client/src/components/Chat/Input/ActiveSetting.tsx new file mode 100644 index 00000000000..24f8791ffaf --- /dev/null +++ b/client/src/components/Chat/Input/ActiveSetting.tsx @@ -0,0 +1,8 @@ +export default function ActiveSetting() { + return ( +
+ Talking to{' '} + [latest] Tailwind CSS GPT +
+ ); +} diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 51e9cb5c280..f05fd72793e 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -17,25 +17,27 @@ import { mainTextareaId } from '~/common'; import StopButton from './StopButton'; import SendButton from './SendButton'; import FileRow from './Files/FileRow'; +import Mention from './Mention'; import store from '~/store'; const ChatForm = ({ index = 0 }) => { const submitButtonRef = useRef(null); const textAreaRef = useRef(null); const [showStopButton, setShowStopButton] = useRecoilState(store.showStopButtonByIndex(index)); + const [showMentionPopover, setShowMentionPopover] = useRecoilState( + store.showMentionPopoverFamily(index), + ); const { requiresKey } = useRequiresKey(); const methods = useForm<{ text: string }>({ defaultValues: { text: '' }, }); - const { handlePaste, handleKeyUp, handleKeyDown, handleCompositionStart, handleCompositionEnd } = + const { handlePaste, handleKeyDown, handleKeyUp, handleCompositionStart, handleCompositionEnd } = useTextarea({ textAreaRef, submitButtonRef, disabled: !!requiresKey, - setValue: methods.setValue, - getValues: methods.getValues, }); const { @@ -58,9 +60,6 @@ const ChatForm = ({ index = 0 }) => { } ask({ text: data.text }); methods.reset(); - if (textAreaRef.current) { - textAreaRef.current.value = ''; - } }, [ask, methods], ); @@ -84,6 +83,13 @@ const ChatForm = ({ index = 0 }) => { [requiresKey, invalidAssistant], ); + const { ref, ...registerProps } = methods.register('text', { + required: true, + onChange: (e) => { + methods.setValue('text', e.target.value); + }, + }); + return (
submitMessage(data))} @@ -91,7 +97,10 @@ const ChatForm = ({ index = 0 }) => { >
-
+ {showMentionPopover && ( + + )} +
{ /> {endpoint && ( { - methods.setValue('text', e.target.value); - }, - })} + {...registerProps} autoFocus ref={(e) => { + ref(e); textAreaRef.current = e; }} disabled={disableInputs} onPaste={handlePaste} - onKeyUp={handleKeyUp} onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} onCompositionStart={handleCompositionStart} onCompositionEnd={handleCompositionEnd} id={mainTextareaId} diff --git a/client/src/components/Chat/Input/Files/FilePreview.tsx b/client/src/components/Chat/Input/Files/FilePreview.tsx index fcf4d01f641..55c66b9a826 100644 --- a/client/src/components/Chat/Input/Files/FilePreview.tsx +++ b/client/src/components/Chat/Input/Files/FilePreview.tsx @@ -20,12 +20,13 @@ const FilePreview = ({ }) => { const radius = 55; // Radius of the SVG circle const circumference = 2 * Math.PI * radius; - const progress = useProgress(file?.['progress'] ?? 1, 0.001); + const progress = useProgress(file?.['progress'] ?? 1, 0.001, file?.size ?? 1); + console.log(progress); // Calculate the offset based on the loading progress const offset = circumference - progress * circumference; const circleCSSProperties = { - transition: 'stroke-dashoffset 0.3s linear', + transition: 'stroke-dashoffset 0.5s linear', }; return ( diff --git a/client/src/components/Chat/Input/Files/FilesView.tsx b/client/src/components/Chat/Input/Files/FilesView.tsx index 32acf6aa2f1..efd9ec2a824 100644 --- a/client/src/components/Chat/Input/Files/FilesView.tsx +++ b/client/src/components/Chat/Input/Files/FilesView.tsx @@ -27,7 +27,9 @@ export default function Files({ open, onOpenChange }) { return ( - + {localize('com_nav_my_files')} diff --git a/client/src/components/Chat/Input/Files/Table/DataTable.tsx b/client/src/components/Chat/Input/Files/Table/DataTable.tsx index 23e7b8ea702..75bbb3823a6 100644 --- a/client/src/components/Chat/Input/Files/Table/DataTable.tsx +++ b/client/src/components/Chat/Input/Files/Table/DataTable.tsx @@ -220,7 +220,7 @@ export default function DataTable({ columns, data }: DataTablePro )}
- setShowPopover(false)} - PopoverButtons={} - > -
- - -
-
- - + {interfaceConfig?.parameters && ( + } + closePopover={() => setShowPopover(false)} + > +
+ + +
+
+ )} + {interfaceConfig?.presets && ( + + )} + {interfaceConfig?.parameters && ( + + )}
diff --git a/client/src/components/Chat/Input/Mention.tsx b/client/src/components/Chat/Input/Mention.tsx new file mode 100644 index 00000000000..229dd5a5451 --- /dev/null +++ b/client/src/components/Chat/Input/Mention.tsx @@ -0,0 +1,148 @@ +import { useState, useRef, useEffect } from 'react'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { SetterOrUpdater } from 'recoil'; +import type { MentionOption } from '~/common'; +import { useAssistantsMapContext } from '~/Providers'; +import useMentions from '~/hooks/Input/useMentions'; +import { useLocalize, useCombobox } from '~/hooks'; +import { removeAtSymbolIfLast } from '~/utils'; +import MentionItem from './MentionItem'; + +export default function Mention({ + setShowMentionPopover, + textAreaRef, +}: { + setShowMentionPopover: SetterOrUpdater; + textAreaRef: React.MutableRefObject; +}) { + const localize = useLocalize(); + const assistantMap = useAssistantsMapContext(); + const { options, modelsConfig, assistants, onSelectMention } = useMentions({ assistantMap }); + + const [activeIndex, setActiveIndex] = useState(0); + const timeoutRef = useRef(null); + const inputRef = useRef(null); + const [inputOptions, setInputOptions] = useState(options); + + const { open, setOpen, searchValue, setSearchValue, matches } = useCombobox({ + value: '', + options: inputOptions, + }); + + const handleSelect = (mention?: MentionOption) => { + if (!mention) { + return; + } + + const defaultSelect = () => { + setSearchValue(''); + setOpen(false); + setShowMentionPopover(false); + onSelectMention(mention); + + if (textAreaRef.current) { + removeAtSymbolIfLast(textAreaRef.current); + } + }; + + if (mention.type === 'endpoint' && mention.value === EModelEndpoint.assistants) { + setSearchValue(''); + setInputOptions(assistants); + setActiveIndex(0); + inputRef.current?.focus(); + } else if (mention.type === 'endpoint') { + const models = (modelsConfig?.[mention.value ?? ''] ?? []).map((model) => ({ + value: mention.value, + label: model, + type: 'model', + })); + + setActiveIndex(0); + setSearchValue(''); + setInputOptions(models); + inputRef.current?.focus(); + } else { + defaultSelect(); + } + }; + + useEffect(() => { + if (!open) { + setInputOptions(options); + setActiveIndex(0); + } + }, [open, options]); + + useEffect(() => { + const currentActiveItem = document.getElementById(`mention-item-${activeIndex}`); + currentActiveItem?.scrollIntoView({ behavior: 'instant', block: 'nearest' }); + }, [activeIndex]); + + return ( +
+
+ { + if (e.key === 'Escape') { + setOpen(false); + setShowMentionPopover(false); + textAreaRef.current?.focus(); + } + if (e.key === 'ArrowDown') { + setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length); + } else if (e.key === 'ArrowUp') { + setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length); + } else if (e.key === 'Enter' || e.key === 'Tab') { + const mentionOption = matches[activeIndex] as MentionOption | undefined; + if (mentionOption?.type === 'endpoint') { + e.preventDefault(); + } else if (e.key === 'Enter') { + e.preventDefault(); + } + handleSelect(matches[activeIndex] as MentionOption); + } else if (e.key === 'Backspace' && searchValue === '') { + setOpen(false); + setShowMentionPopover(false); + textAreaRef.current?.focus(); + } + }} + onChange={(e) => setSearchValue(e.target.value)} + onFocus={() => setOpen(true)} + onBlur={() => { + timeoutRef.current = setTimeout(() => { + setOpen(false); + setShowMentionPopover(false); + }, 150); + }} + /> + {open && ( +
+ {(matches as MentionOption[]).map((mention, index) => ( + { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = null; + handleSelect(mention); + }} + name={mention.label ?? ''} + icon={mention.icon} + description={mention.description} + isActive={index === activeIndex} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/components/Chat/Input/MentionItem.tsx b/client/src/components/Chat/Input/MentionItem.tsx new file mode 100644 index 00000000000..3cc4f191652 --- /dev/null +++ b/client/src/components/Chat/Input/MentionItem.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Clock4 } from 'lucide-react'; +import { cn } from '~/utils'; + +export default function MentionItem({ + name, + onClick, + index, + icon, + isActive, + description, +}: { + name: string; + onClick: () => void; + index: number; + icon?: React.ReactNode; + isActive?: boolean; + description?: string; +}) { + return ( +
+
+ {icon ? icon : null} +
+
+ {name} + {description ? ( + + {description} + + ) : null} +
+ + + +
+
+
+ ); +} diff --git a/client/src/components/Chat/Input/OptionsPopover.tsx b/client/src/components/Chat/Input/OptionsPopover.tsx index f59d94d26dc..8d0a193c412 100644 --- a/client/src/components/Chat/Input/OptionsPopover.tsx +++ b/client/src/components/Chat/Input/OptionsPopover.tsx @@ -13,6 +13,7 @@ type TOptionsPopoverProps = { saveAsPreset: () => void; closePopover: () => void; PopoverButtons: ReactNode; + presetsDisabled: boolean; }; export default function OptionsPopover({ @@ -22,6 +23,7 @@ export default function OptionsPopover({ saveAsPreset, closePopover, PopoverButtons, + presetsDisabled, }: TOptionsPopoverProps) { const popoverRef = useRef(null); useOnClickOutside( @@ -43,7 +45,7 @@ export default function OptionsPopover({ const localize = useLocalize(); const cardStyle = - 'shadow-xl rounded-md min-w-[75px] font-normal bg-white border-black/10 border dark:bg-gray-700 text-black dark:text-white '; + 'shadow-xl rounded-md min-w-[75px] font-normal bg-white border-black/10 border dark:bg-gray-700 text-black dark:text-white'; if (!visible) { return null; @@ -61,14 +63,16 @@ export default function OptionsPopover({ )} >
- + {presetsDisabled ? null : ( + + )} {PopoverButtons} +
+ ) : null} + {selected && ( + + + + )} +
+
+
+ {userProvidesKey && ( + + )} + + ); +}; + +export default MenuItem; diff --git a/client/src/components/Chat/Menus/Models/ModelSpecs.tsx b/client/src/components/Chat/Menus/Models/ModelSpecs.tsx new file mode 100644 index 00000000000..6d3a767462c --- /dev/null +++ b/client/src/components/Chat/Menus/Models/ModelSpecs.tsx @@ -0,0 +1,44 @@ +import type { FC } from 'react'; +import { Close } from '@radix-ui/react-popover'; +import { AuthType } from 'librechat-data-provider'; +import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; +import MenuSeparator from '~/components/Chat/Menus/UI/MenuSeparator'; +import ModelSpec from './ModelSpec'; + +const ModelSpecs: FC<{ + specs?: TModelSpec[]; + selected?: TModelSpec; + setSelected?: (spec: TModelSpec) => void; + endpointsConfig: TEndpointsConfig; +}> = ({ specs = [], selected, setSelected = () => ({}), endpointsConfig }) => { + return ( + <> + {specs && + specs.map((spec, i) => { + if (!spec) { + return null; + } + return ( + +
+ setSelected(spec)} + data-testid={`spec-item-${spec.name}`} + selected={selected?.name === spec.name} + userProvidesKey={spec.authType === AuthType.USER_PROVIDED} + endpointsConfig={endpointsConfig} + /> + {i !== specs.length - 1 && } +
+
+ ); + })} + + ); +}; + +export default ModelSpecs; diff --git a/client/src/components/Chat/Menus/Models/ModelSpecsMenu.tsx b/client/src/components/Chat/Menus/Models/ModelSpecsMenu.tsx new file mode 100644 index 00000000000..cc1fb2d1d99 --- /dev/null +++ b/client/src/components/Chat/Menus/Models/ModelSpecsMenu.tsx @@ -0,0 +1,108 @@ +import { useMemo } from 'react'; +import { useRecoilValue } from 'recoil'; +import { EModelEndpoint } from 'librechat-data-provider'; +import { Content, Portal, Root } from '@radix-ui/react-popover'; +import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; +import type { TModelSpec, TConversation, TEndpointsConfig } from 'librechat-data-provider'; +import { getConvoSwitchLogic, getModelSpecIconURL } from '~/utils'; +import { useDefaultConvo, useNewConvo } from '~/hooks'; +import { useChatContext } from '~/Providers'; +import MenuButton from './MenuButton'; +import ModelSpecs from './ModelSpecs'; +import store from '~/store'; + +export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[] }) { + const { conversation } = useChatContext(); + const { newConversation } = useNewConvo(); + + const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery(); + const modularChat = useRecoilValue(store.modularChat); + const getDefaultConversation = useDefaultConvo(); + + const onSelectSpec = (spec: TModelSpec) => { + const { preset } = spec; + preset.iconURL = getModelSpecIconURL(spec); + preset.spec = spec.name; + const { endpoint: newEndpoint } = preset; + if (!newEndpoint) { + return; + } + + const { + shouldSwitch, + isNewModular, + isCurrentModular, + isExistingConversation, + newEndpointType, + template, + } = getConvoSwitchLogic({ + newEndpoint, + modularChat, + conversation, + endpointsConfig, + }); + + if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) { + template.endpointType = newEndpointType as EModelEndpoint | undefined; + + const currentConvo = getDefaultConversation({ + /* target endpointType is necessary to avoid endpoint mixing */ + conversation: { ...(conversation ?? {}), endpointType: template.endpointType }, + preset: template, + }); + + /* We don't reset the latest message, only when changing settings mid-converstion */ + newConversation({ template: currentConvo, preset, keepLatestMessage: true }); + return; + } + + newConversation({ template: { ...(template as Partial) }, preset }); + }; + + const selected = useMemo(() => { + const spec = modelSpecs?.find((spec) => spec.name === conversation?.spec); + if (!spec) { + return undefined; + } + return spec; + }, [modelSpecs, conversation?.spec]); + + return ( + + + + {modelSpecs && modelSpecs?.length && ( +
+ + + +
+ )} +
+
+ ); +} diff --git a/client/src/components/Chat/Menus/Models/SpecIcon.tsx b/client/src/components/Chat/Menus/Models/SpecIcon.tsx new file mode 100644 index 00000000000..5235dd4860d --- /dev/null +++ b/client/src/components/Chat/Menus/Models/SpecIcon.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; +import type { IconMapProps } from '~/common'; +import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils'; +import { icons } from '~/components/Chat/Menus/Endpoints/Icons'; + +interface SpecIconProps { + currentSpec: TModelSpec; + endpointsConfig: TEndpointsConfig; +} + +const SpecIcon: React.FC = ({ currentSpec, endpointsConfig }) => { + const iconURL = getModelSpecIconURL(currentSpec); + const { endpoint } = currentSpec.preset; + const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); + let Icon: (props: IconMapProps) => React.JSX.Element; + + if (!iconURL?.includes('http')) { + Icon = icons[iconKey] ?? icons.unknown; + } else { + Icon = iconURL + ? () => ( +
+ {currentSpec.name} +
+ ) + : icons[endpoint ?? ''] ?? icons.unknown; + } + + return ( + + ); +}; + +export default SpecIcon; diff --git a/client/src/components/Chat/Menus/Models/fakeData.ts b/client/src/components/Chat/Menus/Models/fakeData.ts new file mode 100644 index 00000000000..7c994adb7bc --- /dev/null +++ b/client/src/components/Chat/Menus/Models/fakeData.ts @@ -0,0 +1,46 @@ +import { EModelEndpoint, AuthType } from 'librechat-data-provider'; +import type { TModelSpec } from 'librechat-data-provider'; + +export const data: TModelSpec[] = [ + { + name: 'commander_01', + label: 'Commander in Chief', + description: + 'Salute your president, soldier! Salute your president, soldier! Salute your president, soldier!', + iconURL: 'https://i.kym-cdn.com/entries/icons/facebook/000/017/252/2f0.jpg', + // iconURL: EModelEndpoint.openAI, + preset: { + endpoint: 'Ollama', + greeting: 'My fellow Americans,', + // 'endpointType': EModelEndpoint.custom, + frequency_penalty: 0, + // 'imageDetail': 'auto', + model: 'command-r', + presence_penalty: 0, + promptPrefix: null, + resendFiles: false, + temperature: 0.8, + top_p: 0.5, + }, + authType: AuthType.SYSTEM_DEFINED, + }, + { + name: 'vision_pro', + label: 'Vision Pro', + description: + 'Salute your president, soldier! Salute your president, soldier! Salute your president, soldier!', + // iconURL: 'https://i.ytimg.com/vi/SaneSRqePVY/maxresdefault.jpg', + iconURL: EModelEndpoint.openAI, // Allow using project-included icons + preset: { + chatGptLabel: 'Vision Helper', + greeting: 'What\'s up!!', + endpoint: EModelEndpoint.openAI, + model: 'gpt-4-turbo', + promptPrefix: + 'Examine images closely to understand its style, colors, composition, and other elements. Then, craft a detailed prompt to that closely resemble the original. Your focus is on accuracy in replicating the style, colors, techniques, and details of the original image in written form. Your prompt must be excruciatingly detailed as it will be given to an image generating AI for image generation. \n', + temperature: 0.8, + top_p: 1, + }, + authType: AuthType.SYSTEM_DEFINED, + }, +]; diff --git a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx index da9bd8c113d..6fd0e0a3109 100644 --- a/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx +++ b/client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx @@ -49,9 +49,9 @@ const EditPresetDialog = ({ title={`${localize('com_ui_edit') + ' ' + localize('com_endpoint_preset')} - ${ preset?.title }`} - className="h-full max-w-full overflow-y-auto pb-4 sm:w-[680px] sm:pb-0 md:h-[720px] md:w-[750px] md:overflow-y-hidden lg:w-[950px] xl:h-[720px]" + className="h-full max-w-full overflow-y-auto pb-4 sm:w-[680px] sm:pb-0 md:h-[720px] md:w-[750px] md:overflow-y-hidden md:overflow-y-hidden lg:w-[950px] xl:h-[720px]" main={ -
+
@@ -105,7 +105,6 @@ const EditPresetDialog = ({ conversation={preset} setOption={setOption} isPreset={true} - isMultiChat={true} className="h-full md:mb-4 md:h-[440px]" />
@@ -127,6 +126,7 @@ const EditPresetDialog = ({
} + footerClassName="bg-white dark:bg-gray-700" /> ); diff --git a/client/src/components/Chat/Menus/Presets/PresetItems.tsx b/client/src/components/Chat/Menus/Presets/PresetItems.tsx index df4742aeab6..77d60d33fcc 100644 --- a/client/src/components/Chat/Menus/Presets/PresetItems.tsx +++ b/client/src/components/Chat/Menus/Presets/PresetItems.tsx @@ -4,11 +4,11 @@ import { Flipper, Flipped } from 'react-flip-toolkit'; import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import type { FC } from 'react'; import type { TPreset } from 'librechat-data-provider'; +import { getPresetTitle, getEndpointField, getIconKey } from '~/utils'; import FileUpload from '~/components/Chat/Input/Files/FileUpload'; import { PinIcon, EditIcon, TrashIcon } from '~/components/svg'; +import { Dialog, DialogTrigger, Label } from '~/components/ui'; import DialogTemplate from '~/components/ui/DialogTemplate'; -import { getPresetTitle, getEndpointField } from '~/utils'; -import { Dialog, DialogTrigger, Label } from '~/components/ui/'; import { MenuSeparator, MenuItem } from '../UI'; import { icons } from '../Endpoints/Icons'; import { useLocalize } from '~/hooks'; @@ -115,9 +115,7 @@ const PresetItems: FC<{ return null; } - const iconKey = getEndpointField(endpointsConfig, preset.endpoint, 'type') - ? 'unknown' - : preset.endpointType ?? preset.endpoint ?? 'unknown'; + const iconKey = getIconKey({ endpoint: preset.endpoint, endpointsConfig }); const Icon = icons[iconKey]; return ( diff --git a/client/src/components/Chat/Menus/index.ts b/client/src/components/Chat/Menus/index.ts index d505f765101..f77208fe550 100644 --- a/client/src/components/Chat/Menus/index.ts +++ b/client/src/components/Chat/Menus/index.ts @@ -1,3 +1,4 @@ -export { default as EndpointsMenu } from './EndpointsMenu'; export { default as PresetsMenu } from './PresetsMenu'; +export { default as EndpointsMenu } from './EndpointsMenu'; export { default as HeaderNewChat } from './HeaderNewChat'; +export { default as ModelSpecsMenu } from './Models/ModelSpecsMenu'; diff --git a/client/src/components/Chat/Messages/Content/ImageGen.tsx b/client/src/components/Chat/Messages/Content/ImageGen.tsx index 21beda4c89a..080a153fb58 100644 --- a/client/src/components/Chat/Messages/Content/ImageGen.tsx +++ b/client/src/components/Chat/Messages/Content/ImageGen.tsx @@ -46,82 +46,25 @@ export default function ImageGen({ style={{ width: '100%', height: '100%', transform: 'translate3d(0px, 0px, 0px)' }} preserveAspectRatio="xMidYMid meet" > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* Moon SVG */} - - - - + + + + + + diff --git a/client/src/components/Chat/Messages/Content/SearchContent.tsx b/client/src/components/Chat/Messages/Content/SearchContent.tsx new file mode 100644 index 00000000000..109bbb1ebf1 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/SearchContent.tsx @@ -0,0 +1,53 @@ +import { Suspense } from 'react'; +import type { TMessage, TMessageContentParts } from 'librechat-data-provider'; +import { UnfinishedMessage } from './MessageContent'; +import { DelayedRender } from '~/components/ui'; +import MarkdownLite from './MarkdownLite'; +import { cn } from '~/utils'; +import Part from './Part'; + +const SearchContent = ({ message }: { message: TMessage }) => { + const { messageId } = message; + if (Array.isArray(message.content) && message.content.length > 0) { + return ( + <> + {message.content + .filter((part: TMessageContentParts | undefined) => part) + .map((part: TMessageContentParts | undefined, idx: number) => { + if (!part) { + return null; + } + return ( + + ); + })} + {message.unfinished && ( + + + + + + )} + + ); + } + + return ( +
+ +
+ ); +}; + +export default SearchContent; diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 29e0984ca86..fc1da37fbef 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -1,5 +1,5 @@ // import { useState, useEffect } from 'react'; -import { actionDelimiter, actionDomainSeparator } from 'librechat-data-provider'; +import { actionDelimiter, actionDomainSeparator, Constants } from 'librechat-data-provider'; import * as Popover from '@radix-ui/react-popover'; import useLocalize from '~/hooks/useLocalize'; import ProgressCircle from './ProgressCircle'; @@ -63,7 +63,7 @@ export default function ToolCall({ onClick={() => ({})} inProgressText={localize('com_assistants_running_action')} finishedText={ - domain + domain && domain.length !== Constants.ENCODED_DOMAIN_LENGTH ? localize('com_assistants_completed_action', domain) : localize('com_assistants_completed_function', function_name) } diff --git a/client/src/components/Chat/Messages/Content/WrenchIcon.tsx b/client/src/components/Chat/Messages/Content/WrenchIcon.tsx index 8233f5a7db3..9343f04b404 100644 --- a/client/src/components/Chat/Messages/Content/WrenchIcon.tsx +++ b/client/src/components/Chat/Messages/Content/WrenchIcon.tsx @@ -13,7 +13,6 @@ export default function WrenchIcon() { return ( - - - - - - - - - - - + - - - - - diff --git a/client/src/components/Chat/Messages/HoverButtons.tsx b/client/src/components/Chat/Messages/HoverButtons.tsx index 7f6b288cbbe..18ec845e843 100644 --- a/client/src/components/Chat/Messages/HoverButtons.tsx +++ b/client/src/components/Chat/Messages/HoverButtons.tsx @@ -3,6 +3,7 @@ import { EModelEndpoint } from 'librechat-data-provider'; import type { TConversation, TMessage } from 'librechat-data-provider'; import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg'; import { useGenerationsByLatest, useLocalize } from '~/hooks'; +import { Fork } from '~/components/Conversations'; import { cn } from '~/utils'; type THoverButtons = { @@ -34,13 +35,14 @@ export default function HoverButtons({ const { endpoint: _endpoint, endpointType } = conversation ?? {}; const endpoint = endpointType ?? _endpoint; const [isCopied, setIsCopied] = useState(false); - const { hideEditButton, regenerateEnabled, continueSupported } = useGenerationsByLatest({ - isEditing, - isSubmitting, - message, - endpoint: endpoint ?? '', - latestMessage, - }); + const { hideEditButton, regenerateEnabled, continueSupported, forkingSupported } = + useGenerationsByLatest({ + isEditing, + isSubmitting, + message, + endpoint: endpoint ?? '', + latestMessage, + }); if (!conversation) { return null; } @@ -100,6 +102,13 @@ export default function HoverButtons({ ) : null} + {continueSupported ? (
- {typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? ( - {icon} - ) : ( - icon - )} +
diff --git a/client/src/components/Chat/Messages/MessageIcon.tsx b/client/src/components/Chat/Messages/MessageIcon.tsx new file mode 100644 index 00000000000..6910feeb2f8 --- /dev/null +++ b/client/src/components/Chat/Messages/MessageIcon.tsx @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; +import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; +import type { TMessage, TPreset, Assistant } from 'librechat-data-provider'; +import type { TMessageProps } from '~/common'; +import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; +import { getEndpointField, getIconEndpoint } from '~/utils'; +import Icon from '~/components/Endpoints/Icon'; + +export default function MessageIcon( + props: Pick & { + assistant?: false | Assistant; + }, +) { + const { data: endpointsConfig } = useGetEndpointsQuery(); + const { message, conversation, assistant } = props; + + const assistantName = assistant ? (assistant.name as string | undefined) : ''; + const assistantAvatar = assistant ? (assistant.metadata?.avatar as string | undefined) : ''; + + const messageSettings = useMemo( + () => ({ + ...(conversation ?? {}), + ...({ + ...message, + iconURL: message?.iconURL ?? '', + } as TMessage), + }), + [conversation, message], + ); + + const iconURL = messageSettings?.iconURL; + let endpoint = messageSettings?.endpoint; + endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint }); + const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + + if (!message?.isCreatedByUser && iconURL && iconURL.includes('http')) { + return ( + + ); + } + + return ( + + ); +} diff --git a/client/src/components/Chat/Messages/MessageParts.tsx b/client/src/components/Chat/Messages/MessageParts.tsx index 8d0f2f88f98..a055ef83ea4 100644 --- a/client/src/components/Chat/Messages/MessageParts.tsx +++ b/client/src/components/Chat/Messages/MessageParts.tsx @@ -1,5 +1,6 @@ -import ContentParts from './Content/ContentParts'; import type { TMessageProps } from '~/common'; +import Icon from '~/components/Chat/Messages/MessageIcon'; +import ContentParts from './Content/ContentParts'; import SiblingSwitch from './SiblingSwitch'; import { useMessageHelpers } from '~/hooks'; // eslint-disable-next-line import/no-cycle @@ -14,7 +15,6 @@ export default function Message(props: TMessageProps) { const { ask, - icon, edit, isLast, enterEdit, @@ -47,11 +47,7 @@ export default function Message(props: TMessageProps) {
- {typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? ( - {icon} - ) : ( - icon - )} +
diff --git a/client/src/components/Chat/Messages/MinimalMessages.tsx b/client/src/components/Chat/Messages/MinimalMessages.tsx new file mode 100644 index 00000000000..be4d0cad2d7 --- /dev/null +++ b/client/src/components/Chat/Messages/MinimalMessages.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { cn } from '~/utils'; + +const MinimalMessages = React.forwardRef( + ( + props: { children: React.ReactNode; className?: string }, + ref: React.ForwardedRef, + ) => { + return ( +
+
+
+
+
+
+
+ {props.children} +
+
+
+
+
+
+
+
+ ); + }, +); + +export default MinimalMessages; diff --git a/client/src/components/Chat/Messages/SearchButtons.tsx b/client/src/components/Chat/Messages/SearchButtons.tsx new file mode 100644 index 00000000000..eba93e1b491 --- /dev/null +++ b/client/src/components/Chat/Messages/SearchButtons.tsx @@ -0,0 +1,40 @@ +import { Link } from 'lucide-react'; +import type { TMessage } from 'librechat-data-provider'; +import { useLocalize, useNavigateToConvo } from '~/hooks'; +import { useSearchContext } from '~/Providers'; +import { getConversationById } from '~/utils'; + +export default function SearchButtons({ message }: { message: TMessage }) { + const localize = useLocalize(); + const { searchQueryRes } = useSearchContext(); + const { navigateWithLastTools } = useNavigateToConvo(); + + if (!message.conversationId) { + return null; + } + + const clickHandler = (event: React.MouseEvent) => { + event.preventDefault(); + + const conversation = getConversationById(searchQueryRes?.data, message.conversationId); + if (!conversation) { + return; + } + + document.title = message.title ?? ''; + navigateWithLastTools(conversation); + }; + + return ( + + ); +} diff --git a/client/src/components/Chat/Messages/SearchMessage.tsx b/client/src/components/Chat/Messages/SearchMessage.tsx new file mode 100644 index 00000000000..46829dad97a --- /dev/null +++ b/client/src/components/Chat/Messages/SearchMessage.tsx @@ -0,0 +1,61 @@ +import { useRecoilValue } from 'recoil'; +import { useAuthContext, useLocalize } from '~/hooks'; +import type { TMessageProps } from '~/common'; +import Icon from '~/components/Chat/Messages/MessageIcon'; +import SearchContent from './Content/SearchContent'; +import SearchButtons from './SearchButtons'; +import SubRow from './SubRow'; +import { cn } from '~/utils'; +import store from '~/store'; + +export default function Message({ message }: Pick) { + const UsernameDisplay = useRecoilValue(store.UsernameDisplay); + const { user } = useAuthContext(); + const localize = useLocalize(); + + if (!message) { + return null; + } + + const { isCreatedByUser } = message ?? {}; + + let messageLabel = ''; + if (isCreatedByUser) { + messageLabel = UsernameDisplay ? user?.name || user?.username : localize('com_user_message'); + } else { + messageLabel = message.sender; + } + + return ( + <> +
+
+
+
+
+
+
+ +
+
+
+
+
+
{messageLabel}
+
+
+ +
+
+ + + +
+
+
+
+ + ); +} diff --git a/client/src/components/Chat/Presentation.tsx b/client/src/components/Chat/Presentation.tsx index eadd2413595..b3333f3067f 100644 --- a/client/src/components/Chat/Presentation.tsx +++ b/client/src/components/Chat/Presentation.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; import { useRecoilValue } from 'recoil'; -import { FileSources } from 'librechat-data-provider'; +import { useEffect, useMemo } from 'react'; +import { useGetStartupConfig } from 'librechat-data-provider/react-query'; +import { FileSources, LocalStorageKeys, getConfigDefaults } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; import { useDragHelpers, useSetFilesToDelete } from '~/hooks'; import DragDropOverlay from './Input/Files/DragDropOverlay'; @@ -8,6 +9,8 @@ import { useDeleteFilesMutation } from '~/data-provider'; import { SidePanel } from '~/components/SidePanel'; import store from '~/store'; +const defaultInterface = getConfigDefaults().interface; + export default function Presentation({ children, useSidePanel = false, @@ -17,9 +20,16 @@ export default function Presentation({ panel?: React.ReactNode; useSidePanel?: boolean; }) { + const { data: startupConfig } = useGetStartupConfig(); const hideSidePanel = useRecoilValue(store.hideSidePanel); - const { isOver, canDrop, drop } = useDragHelpers(); + const interfaceConfig = useMemo( + () => startupConfig?.interface ?? defaultInterface, + [startupConfig], + ); + const setFilesToDelete = useSetFilesToDelete(); + const { isOver, canDrop, drop } = useDragHelpers(); + const { mutateAsync } = useDeleteFilesMutation({ onSuccess: () => { console.log('Temporary Files deleted'); @@ -31,7 +41,7 @@ export default function Presentation({ }); useEffect(() => { - const filesToDelete = localStorage.getItem('filesToDelete'); + const filesToDelete = localStorage.getItem(LocalStorageKeys.FILES_TO_DELETE); const map = JSON.parse(filesToDelete ?? '{}') as Record; const files = Object.values(map) .filter((file) => file.filepath && file.source && !file.embedded && file.temp_file_id) @@ -49,12 +59,16 @@ export default function Presentation({ }, [mutateAsync]); const isActive = canDrop && isOver; - const resizableLayout = localStorage.getItem('react-resizable-panels:layout'); - const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed'); - const defaultLayout = resizableLayout ? JSON.parse(resizableLayout) : undefined; - const defaultCollapsed = collapsedPanels ? JSON.parse(collapsedPanels) : undefined; - const fullCollapse = localStorage.getItem('fullPanelCollapse') === 'true'; + const defaultLayout = useMemo(() => { + const resizableLayout = localStorage.getItem('react-resizable-panels:layout'); + return resizableLayout ? JSON.parse(resizableLayout) : undefined; + }, []); + const defaultCollapsed = useMemo(() => { + const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed'); + return collapsedPanels ? JSON.parse(collapsedPanels) : undefined; + }, []); + const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []); const layout = () => (
@@ -65,7 +79,7 @@ export default function Presentation({
); - if (useSidePanel && !hideSidePanel) { + if (useSidePanel && !hideSidePanel && interfaceConfig.sidePanel) { return (
-
-
- } /> -
-
-
- ); -} - -export default memo(SearchView); diff --git a/client/src/components/Conversations/ArchiveButton.tsx b/client/src/components/Conversations/ArchiveButton.tsx new file mode 100644 index 00000000000..d35e61c9147 --- /dev/null +++ b/client/src/components/Conversations/ArchiveButton.tsx @@ -0,0 +1,76 @@ +import { useParams, useNavigate } from 'react-router-dom'; +import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react'; +import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui'; +import { useConversations, useLocalize, useNewConvo } from '~/hooks'; +import { useArchiveConversationMutation } from '~/data-provider'; +import { NotificationSeverity } from '~/common'; +import { useToastContext } from '~/Providers'; + +type ArchiveButtonProps = { + conversationId: string; + retainView: () => void; + shouldArchive: boolean; + icon: React.ReactNode; + className?: string; +}; +export default function ArchiveButton({ + conversationId, + retainView, + shouldArchive, + icon, + className = '', +}: ArchiveButtonProps) { + const localize = useLocalize(); + const navigate = useNavigate(); + const { showToast } = useToastContext(); + const { newConversation } = useNewConvo(); + const { refreshConversations } = useConversations(); + const { conversationId: currentConvoId } = useParams(); + + const archiveConvoMutation = useArchiveConversationMutation(conversationId); + + const label = shouldArchive ? 'archive' : 'unarchive'; + const archiveHandler = ( + e: + | MouseEvent + | FocusEvent + | KeyboardEvent, + ) => { + e.preventDefault(); + archiveConvoMutation.mutate( + { conversationId, isArchived: shouldArchive }, + { + onSuccess: () => { + if (currentConvoId === conversationId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + refreshConversations(); + retainView(); + }, + onError: () => { + showToast({ + message: localize(`com_ui_${label}_error`), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }, + ); + }; + + return ( + + ); +} diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index f46e457c4a1..636c11763b0 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -2,6 +2,7 @@ import { useMemo, memo } from 'react'; import { parseISO, isToday } from 'date-fns'; import { TConversation } from 'librechat-data-provider'; import { groupConversationsByDate } from '~/utils'; +import { useLocalize } from '~/hooks'; import Convo from './Convo'; const Conversations = ({ @@ -13,12 +14,14 @@ const Conversations = ({ moveToTop: () => void; toggleNav: () => void; }) => { + const localize = useLocalize(); const groupedConversations = useMemo( () => groupConversationsByDate(conversations), [conversations], ); const firstTodayConvoId = useMemo( - () => conversations.find((convo) => isToday(parseISO(convo.updatedAt)))?.conversationId, + () => + conversations.find((convo) => convo && isToday(parseISO(convo.updatedAt)))?.conversationId, [conversations], ); @@ -37,7 +40,7 @@ const Conversations = ({ paddingLeft: '10px', }} > - {groupName} + {localize(groupName) || groupName}
{convos.map((convo, i) => ( ; @@ -22,14 +25,15 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const activeConvos = useRecoilValue(store.allConversationsSelector); const { data: endpointsConfig } = useGetEndpointsQuery(); + const { navigateWithLastTools } = useNavigateToConvo(); const { refreshConversations } = useConversations(); - const { navigateToConvo } = useNavigateToConvo(); const { showToast } = useToastContext(); const { conversationId, title } = conversation; const inputRef = useRef(null); const [titleInput, setTitleInput] = useState(title); const [renaming, setRenaming] = useState(false); + const [isPopoverActive, setIsPopoverActive] = useState(false); const clickHandler = async (event: React.MouseEvent) => { if (event.button === 0 && event.ctrlKey) { @@ -46,19 +50,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa // set document title document.title = title; - - // set conversation to the new conversation - if (conversation?.endpoint === EModelEndpoint.gptPlugins) { - let lastSelectedTools = []; - try { - lastSelectedTools = JSON.parse(localStorage.getItem('lastSelectedTools') ?? '') ?? []; - } catch (e) { - // console.error(e); - } - navigateToConvo({ ...conversation, tools: lastSelectedTools }); - } else { - navigateToConvo(conversation); - } + navigateWithLastTools(conversation); }; const renameHandler = (e: MouseEvent) => { @@ -95,86 +87,101 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa ); }; - const icon = MinimalIcon({ - size: 20, - iconURL: getEndpointField(endpointsConfig, conversation.endpoint, 'iconURL'), - endpoint: conversation.endpoint, - endpointType: conversation.endpointType, - model: conversation.model, - error: false, - className: 'mr-0', - isCreatedByUser: false, - chatGptLabel: undefined, - modelLabel: undefined, - jailbreak: undefined, - }); - const handleKeyDown = (e: KeyEvent) => { - if (e.key === 'Enter') { + if (e.key === 'Escape') { + setTitleInput(title); + setRenaming(false); + } else if (e.key === 'Enter') { onRename(e); } }; - const activeConvo = + const isActiveConvo = currentConvoId === conversationId || (isLatestConvo && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new'); - const aProps = { - className: - 'group relative rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-2 break-all rounded-lg bg-gray-200 dark:bg-gray-700 py-2 px-2', - }; - - if (!activeConvo) { - aProps.className = - 'group relative grow overflow-hidden whitespace-nowrap rounded-lg active:opacity-50 flex cursor-pointer items-center mt-2 gap-2 break-all rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 py-2 px-2'; - } - return ( - - {icon} -
- {renaming === true ? ( + {renaming ? ( +
setTitleInput(e.target.value)} onBlur={onRename} onKeyDown={handleKeyDown} /> - ) : ( - title - )} -
- {activeConvo ? ( -
+
) : ( -
- )} - {activeConvo ? ( -
- - + + + + + } /> -
- ) : ( -
+ )} - + + + {!renaming && ( +
{title}
+ )} + {isActiveConvo ? ( +
+ ) : ( + ); } diff --git a/client/src/components/Conversations/DeleteButton.tsx b/client/src/components/Conversations/DeleteButton.tsx index 6ebe078c792..904846ab705 100644 --- a/client/src/components/Conversations/DeleteButton.tsx +++ b/client/src/components/Conversations/DeleteButton.tsx @@ -1,46 +1,80 @@ -import { useParams } from 'react-router-dom'; +import { useCallback } from 'react'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; +import { useParams, useNavigate } from 'react-router-dom'; import type { TMessage } from 'librechat-data-provider'; -import { useLocalize, useConversations, useConversation } from '~/hooks'; import { useDeleteConversationMutation } from '~/data-provider'; -import { Dialog, DialogTrigger, Label } from '~/components/ui'; +import { + Dialog, + DialogTrigger, + Label, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '~/components/ui'; import DialogTemplate from '~/components/ui/DialogTemplate'; import { TrashIcon, CrossIcon } from '~/components/svg'; +import { useLocalize, useNewConvo } from '~/hooks'; -export default function DeleteButton({ conversationId, renaming, retainView, title }) { +export default function DeleteButton({ + conversationId, + renaming, + retainView, + title, + appendLabel = false, + className = '', +}) { const localize = useLocalize(); + const navigate = useNavigate(); const queryClient = useQueryClient(); - const { newConversation } = useConversation(); - const { refreshConversations } = useConversations(); + const { newConversation } = useNewConvo(); const { conversationId: currentConvoId } = useParams(); - const deleteConvoMutation = useDeleteConversationMutation(); + const deleteConvoMutation = useDeleteConversationMutation({ + onSuccess: () => { + if (currentConvoId === conversationId || currentConvoId === 'new') { + newConversation(); + navigate('/c/new', { replace: true }); + } + retainView(); + }, + }); - const confirmDelete = () => { + const confirmDelete = useCallback(() => { const messages = queryClient.getQueryData([QueryKeys.messages, conversationId]); const thread_id = messages?.[messages?.length - 1]?.thread_id; - deleteConvoMutation.mutate( - { conversationId, thread_id, source: 'button' }, - { - onSuccess: () => { - if (currentConvoId === conversationId) { - newConversation(); - } + deleteConvoMutation.mutate({ conversationId, thread_id, source: 'button' }); + }, [conversationId, deleteConvoMutation, queryClient]); - refreshConversations(); - retainView(); - }, - }, + const renderDeleteButton = () => { + if (appendLabel) { + return ( + <> + {localize('com_ui_delete')} + + ); + } + return ( + + + + + + + + + {localize('com_ui_delete')} + + + ); }; return ( - + = ({ children }: EditMenuButtonProps) => { + const localize = useLocalize(); + const { setPopoverActive } = useToggle(); + + return ( + setPopoverActive(open)}> + +
+ + + + + + + {localize('com_ui_more_options')} + + + +
+
+ + + {children} + + +
+ ); +}; + +export default EditMenuButton; diff --git a/client/src/components/Conversations/Fork.tsx b/client/src/components/Conversations/Fork.tsx new file mode 100644 index 00000000000..cb0a62d1a7f --- /dev/null +++ b/client/src/components/Conversations/Fork.tsx @@ -0,0 +1,331 @@ +import { useState, useRef } from 'react'; +import { useRecoilState } from 'recoil'; +import { GitFork, InfoIcon } from 'lucide-react'; +import * as Popover from '@radix-ui/react-popover'; +import { ForkOptions, TMessage } from 'librechat-data-provider'; +import { GitCommit, GitBranchPlus, ListTree } from 'lucide-react'; +import { + Checkbox, + HoverCard, + HoverCardTrigger, + HoverCardPortal, + HoverCardContent, +} from '~/components/ui'; +import OptionHover from '~/components/SidePanel/Parameters/OptionHover'; +import { useToastContext, useChatContext } from '~/Providers'; +import { useLocalize, useNavigateToConvo } from '~/hooks'; +import { useForkConvoMutation } from '~/data-provider'; +import { ESide } from '~/common'; +import { cn } from '~/utils'; +import store from '~/store'; + +interface PopoverButtonProps { + children: React.ReactNode; + setting: string; + onClick: (setting: string) => void; + setActiveSetting: React.Dispatch>; + sideOffset?: number; + timeoutRef: React.MutableRefObject; + hoverInfo?: React.ReactNode; + hoverTitle?: React.ReactNode; + hoverDescription?: React.ReactNode; +} + +const optionLabels = { + [ForkOptions.DIRECT_PATH]: 'com_ui_fork_visible', + [ForkOptions.INCLUDE_BRANCHES]: 'com_ui_fork_branches', + [ForkOptions.TARGET_LEVEL]: 'com_ui_fork_all_target', + default: 'com_ui_fork_from_message', +}; + +const PopoverButton: React.FC = ({ + children, + setting, + onClick, + setActiveSetting, + sideOffset = 30, + timeoutRef, + hoverInfo, + hoverTitle, + hoverDescription, +}) => { + return ( + + onClick(setting)} + onMouseEnter={() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + setActiveSetting(optionLabels[setting]); + }} + onMouseLeave={() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + setActiveSetting(optionLabels.default); + }, 175); + }} + className="mx-1 max-w-14 flex-1 rounded-lg border-2 bg-white text-gray-700 transition duration-300 ease-in-out hover:bg-gray-200 hover:text-gray-900 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600 dark:hover:text-gray-100 " + type="button" + > + {children} + + {(hoverInfo || hoverTitle || hoverDescription) && ( + + +
+

+ {hoverInfo && hoverInfo} + {hoverTitle && {hoverTitle}} + {hoverDescription && hoverDescription} +

+
+
+
+ )} +
+ ); +}; + +export default function Fork({ + isLast, + messageId, + conversationId, + forkingSupported, + latestMessage, +}: { + isLast?: boolean; + messageId: string; + conversationId: string | null; + forkingSupported?: boolean; + latestMessage: TMessage | null; +}) { + const localize = useLocalize(); + const { index } = useChatContext(); + const { showToast } = useToastContext(); + const [remember, setRemember] = useState(false); + const { navigateToConvo } = useNavigateToConvo(index); + const timeoutRef = useRef(null); + const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting); + const [activeSetting, setActiveSetting] = useState(optionLabels.default); + const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget); + const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberForkOption); + const forkConvo = useForkConvoMutation({ + onSuccess: (data) => { + if (data) { + navigateToConvo(data.conversation); + showToast({ + message: localize('com_ui_fork_success'), + status: 'success', + }); + } + }, + onMutate: () => { + showToast({ + message: localize('com_ui_fork_processing'), + status: 'info', + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_fork_error'), + status: 'error', + }); + }, + }); + + if (!forkingSupported || !conversationId || !messageId) { + return null; + } + + const onClick = (option: string) => { + if (remember) { + setRememberGlobal(true); + setForkSetting(option); + } + + forkConvo.mutate({ + messageId, + conversationId, + option, + splitAtTarget, + latestMessageId: latestMessage?.messageId, + }); + }; + + return ( + + + + + +
+ +
+ {localize(activeSetting)} + + + + + + +
+ {localize('com_ui_fork_info_1')} + {localize('com_ui_fork_info_2')} + + {localize('com_ui_fork_info_3', localize('com_ui_fork_split_target'))} + +
+
+
+
+
+
+ + + {localize(optionLabels[ForkOptions.DIRECT_PATH])} + + } + hoverDescription={localize('com_ui_fork_info_visible')} + > + + + + + + + {localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])} + + } + hoverDescription={localize('com_ui_fork_info_branches')} + > + + + + + + + {`${localize(optionLabels[ForkOptions.TARGET_LEVEL])} (${localize( + 'com_endpoint_default', + )})`} + + } + hoverDescription={localize('com_ui_fork_info_target')} + > + + + + +
+ + +
+ setSplitAtTarget(checked)} + className="m-2 transition duration-300 ease-in-out" + /> + {localize('com_ui_fork_split_target')} +
+
+ +
+ + +
+ { + if (checked) { + showToast({ + message: localize('com_ui_fork_remember_checked'), + status: 'info', + }); + } + setRemember(checked); + }} + className="m-2 transition duration-300 ease-in-out" + /> + {localize('com_ui_fork_remember')} +
+
+ +
+
+
+
+
+ ); +} diff --git a/client/src/components/Conversations/HoverToggle.tsx b/client/src/components/Conversations/HoverToggle.tsx new file mode 100644 index 00000000000..fab8bb2a262 --- /dev/null +++ b/client/src/components/Conversations/HoverToggle.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react'; +import { ToggleContext } from './ToggleContext'; +import { cn } from '~/utils'; + +const HoverToggle = ({ + children, + isActiveConvo, + isPopoverActive, + setIsPopoverActive, +}: { + children: React.ReactNode; + isActiveConvo: boolean; + isPopoverActive: boolean; + setIsPopoverActive: (isActive: boolean) => void; +}) => { + const setPopoverActive = (value: boolean) => setIsPopoverActive(value); + return ( + +
+ {children} +
+
+ ); +}; + +export default HoverToggle; diff --git a/client/src/components/Conversations/RenameButton.tsx b/client/src/components/Conversations/RenameButton.tsx index b18d2199fe8..cd00273b1ec 100644 --- a/client/src/components/Conversations/RenameButton.tsx +++ b/client/src/components/Conversations/RenameButton.tsx @@ -1,29 +1,43 @@ import type { MouseEvent, ReactElement } from 'react'; import { EditIcon, CheckMark } from '~/components/svg'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; interface RenameButtonProps { renaming: boolean; renameHandler: (e: MouseEvent) => void; onRename: (e: MouseEvent) => void; - twcss?: string; + appendLabel?: boolean; + className?: string; } export default function RenameButton({ renaming, renameHandler, onRename, - twcss, + appendLabel = false, + className = '', }: RenameButtonProps): ReactElement { + const localize = useLocalize(); const handler = renaming ? onRename : renameHandler; - const classProp: { className?: string } = { - className: 'p-1 hover:text-black dark:hover:text-white', - }; - if (twcss) { - classProp.className = twcss; - } + return ( - ); } diff --git a/client/src/components/Conversations/ToggleContext.ts b/client/src/components/Conversations/ToggleContext.ts new file mode 100644 index 00000000000..75276dfabdd --- /dev/null +++ b/client/src/components/Conversations/ToggleContext.ts @@ -0,0 +1,8 @@ +import { createContext, useContext } from 'react'; + +const defaultFunction: (value: boolean) => void = () => ({}); +export const ToggleContext = createContext({ + setPopoverActive: defaultFunction, +}); + +export const useToggle = () => useContext(ToggleContext); diff --git a/client/src/components/Conversations/index.ts b/client/src/components/Conversations/index.ts index 17acb3e438c..72e8babd449 100644 --- a/client/src/components/Conversations/index.ts +++ b/client/src/components/Conversations/index.ts @@ -1,3 +1,4 @@ +export { default as Fork } from './Fork'; export { default as Pages } from './Pages'; export { default as Conversation } from './Conversation'; export { default as RenameButton } from './RenameButton'; diff --git a/client/src/components/Endpoints/AlternativeSettings.tsx b/client/src/components/Endpoints/AlternativeSettings.tsx index 6420e369b75..2ee51da492f 100644 --- a/client/src/components/Endpoints/AlternativeSettings.tsx +++ b/client/src/components/Endpoints/AlternativeSettings.tsx @@ -10,7 +10,7 @@ export default function AlternativeSettings({ setOption, isPreset = false, className = '', -}: TSettingsProps & { isMultiChat?: boolean }) { +}: TSettingsProps) { const currentSettingsView = useRecoilValue(store.currentSettingsView); if (!conversation?.endpoint || currentSettingsView === SettingsViews.default) { return null; diff --git a/client/src/components/Endpoints/ConvoIcon.tsx b/client/src/components/Endpoints/ConvoIcon.tsx new file mode 100644 index 00000000000..cdbb487ac8d --- /dev/null +++ b/client/src/components/Endpoints/ConvoIcon.tsx @@ -0,0 +1,62 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { Assistant, TConversation, TEndpointsConfig, TPreset } from 'librechat-data-provider'; +import { icons } from '~/components/Chat/Menus/Endpoints/Icons'; +import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; +import { getEndpointField, getIconKey, getIconEndpoint } from '~/utils'; + +export default function ConvoIcon({ + conversation, + endpointsConfig, + assistantMap, + className = '', + containerClassName = '', + context, + size, +}: { + conversation: TConversation | TPreset | null; + endpointsConfig: TEndpointsConfig; + assistantMap: Record; + containerClassName?: string; + context?: 'message' | 'nav' | 'landing' | 'menu-item'; + className?: string; + size?: number; +}) { + const iconURL = conversation?.iconURL; + let endpoint = conversation?.endpoint; + endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint }); + const assistant = + endpoint === EModelEndpoint.assistants && assistantMap?.[conversation?.assistant_id ?? '']; + const assistantName = (assistant && assistant?.name) || ''; + + const avatar = (assistant && (assistant?.metadata?.avatar as string)) || ''; + const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); + const Icon = icons[iconKey]; + return ( + <> + {iconURL && iconURL.includes('http') ? ( + + ) : ( +
+ {endpoint && + Icon && + Icon({ + size, + context, + className, + iconURL: endpointIconURL, + assistantName, + endpoint, + avatar, + })} +
+ )} + + ); +} diff --git a/client/src/components/Endpoints/ConvoIconURL.tsx b/client/src/components/Endpoints/ConvoIconURL.tsx new file mode 100644 index 00000000000..8501ed02612 --- /dev/null +++ b/client/src/components/Endpoints/ConvoIconURL.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import type { TPreset } from 'librechat-data-provider'; +import type { IconMapProps } from '~/common'; +import { icons } from '~/components/Chat/Menus/Endpoints/Icons'; + +interface ConvoIconURLProps { + preset: TPreset | null; + endpointIconURL?: string; + assistantName?: string; + context?: 'landing' | 'menu-item' | 'nav' | 'message'; + assistantAvatar?: string; +} + +const classMap = { + 'menu-item': 'relative flex h-full items-center justify-center overflow-hidden rounded-full', + message: 'icon-md', + default: 'icon-xl relative flex h-full overflow-hidden rounded-full', +}; + +const styleMap = { + 'menu-item': { width: '20px', height: '20px' }, + default: { width: '100%', height: '100%' }, +}; + +const styleImageMap = { + default: { width: '100%', height: '100%' }, +}; + +const ConvoIconURL: React.FC = ({ + preset, + endpointIconURL, + assistantAvatar, + assistantName, + context, +}) => { + const { iconURL = '' } = preset ?? {}; + let Icon: ( + props: IconMapProps & { + context?: string; + iconURL?: string; + }, + ) => React.JSX.Element; + + const isURL = iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/')); + + if (!isURL) { + Icon = icons[iconURL] ?? icons.unknown; + } else { + Icon = () => ( +
+ {preset?.chatGptLabel +
+ ); + + return ; + } + + return ( +
+ +
+ ); +}; + +export default ConvoIconURL; diff --git a/client/src/components/Endpoints/EndpointIcon.tsx b/client/src/components/Endpoints/EndpointIcon.tsx new file mode 100644 index 00000000000..68e017afc85 --- /dev/null +++ b/client/src/components/Endpoints/EndpointIcon.tsx @@ -0,0 +1,65 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { Assistant, TConversation, TEndpointsConfig, TPreset } from 'librechat-data-provider'; +import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; +import MinimalIcon from '~/components/Endpoints/MinimalIcon'; +import { getEndpointField, getIconEndpoint } from '~/utils'; + +export default function EndpointIcon({ + conversation, + endpointsConfig, + className = 'mr-0', + assistantMap, + context, +}: { + conversation: TConversation | TPreset | null; + endpointsConfig: TEndpointsConfig; + containerClassName?: string; + context?: 'message' | 'nav' | 'landing' | 'menu-item'; + assistantMap?: Record; + className?: string; + size?: number; +}) { + const convoIconURL = conversation?.iconURL ?? ''; + let endpoint = conversation?.endpoint; + endpoint = getIconEndpoint({ endpointsConfig, iconURL: convoIconURL, endpoint }); + + const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); + const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + + const assistant = + endpoint === EModelEndpoint.assistants && assistantMap?.[conversation?.assistant_id ?? '']; + const assistantAvatar = (assistant && (assistant?.metadata?.avatar as string)) || ''; + const assistantName = (assistant && assistant?.name) || ''; + + const iconURL = assistantAvatar || convoIconURL; + + let icon: React.ReactNode | null = null; + if (iconURL && (iconURL.includes('http') || iconURL.startsWith('/images/'))) { + icon = ConvoIconURL({ + preset: { + ...(conversation as TPreset), + iconURL, + }, + context, + endpointIconURL, + assistantAvatar, + assistantName, + }); + } else { + icon = MinimalIcon({ + size: 20, + iconURL: endpointIconURL, + endpoint, + endpointType, + model: conversation?.model, + error: false, + className, + isCreatedByUser: false, + chatGptLabel: undefined, + modelLabel: undefined, + jailbreak: undefined, + }); + } + + return icon; +} diff --git a/client/src/components/Endpoints/EndpointOptionsPopover.tsx b/client/src/components/Endpoints/EndpointOptionsPopover.tsx deleted file mode 100644 index 24f9dbb507f..00000000000 --- a/client/src/components/Endpoints/EndpointOptionsPopover.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Save } from 'lucide-react'; -import type { ReactNode } from 'react'; -// import { EModelEndpoint } from 'librechat-data-provider'; -import { cn, removeFocusOutlines } from '~/utils'; -// import PopoverButtons from './PopoverButtons'; -import { CrossIcon } from '~/components/svg'; -import { Button } from '~/components/ui'; -import { useLocalize } from '~/hooks'; - -type TEndpointOptionsPopoverProps = { - children: ReactNode; - visible: boolean; - // endpoint: EModelEndpoint; - saveAsPreset: () => void; - closePopover: () => void; - PopoverButtons: ReactNode; -}; - -export default function EndpointOptionsPopover({ - children, - // endpoint, - visible, - saveAsPreset, - closePopover, - PopoverButtons, -}: TEndpointOptionsPopoverProps) { - const localize = useLocalize(); - const cardStyle = - 'shadow-xl rounded-md min-w-[75px] font-normal bg-white border-black/10 border dark:bg-gray-700 text-black dark:text-white'; - - return ( - <> -
-
-
- - {PopoverButtons} - -
-
{children}
-
-
- - ); -} diff --git a/client/src/components/Endpoints/EndpointSettings.tsx b/client/src/components/Endpoints/EndpointSettings.tsx index 20ee9cffe71..49f5c749288 100644 --- a/client/src/components/Endpoints/EndpointSettings.tsx +++ b/client/src/components/Endpoints/EndpointSettings.tsx @@ -11,15 +11,14 @@ export default function Settings({ setOption, isPreset = false, className = '', - isMultiChat = false, -}: TSettingsProps & { isMultiChat?: boolean }) { +}: TSettingsProps) { const modelsQuery = useGetModelsQuery(); const currentSettingsView = useRecoilValue(store.currentSettingsView); if (!conversation?.endpoint || currentSettingsView !== SettingsViews.default) { return null; } - const { settings, multiViewSettings } = getSettings(isMultiChat); + const { settings, multiViewSettings } = getSettings(); const { endpoint: _endpoint, endpointType } = conversation; const models = modelsQuery?.data?.[_endpoint] ?? []; const endpoint = endpointType ?? _endpoint; @@ -27,9 +26,7 @@ export default function Settings({ if (OptionComponent) { return ( -
+
= (props) => { const { user } = useAuthContext(); const { - size = 30, - isCreatedByUser, + error, button, - model = '', + iconURL, endpoint, - error, jailbreak, + size = 30, + model = '', assistantName, + isCreatedByUser, } = props; const avatarSrc = useAvatar(user); + const localize = useLocalize(); if (isCreatedByUser) { - const username = user?.name || 'User'; + const username = user?.name || user?.username || localize('com_nav_user'); return (
= (props) => { }, }; - const { icon, bg, name } = + let { icon, bg, name } = endpoint && endpointIcons[endpoint] ? endpointIcons[endpoint] : endpointIcons.default; + if (iconURL && endpointIcons[iconURL]) { + ({ icon, bg, name } = endpointIcons[iconURL]); + } + if (endpoint === EModelEndpoint.assistants) { return icon; } diff --git a/client/src/components/Endpoints/MinimalIcon.tsx b/client/src/components/Endpoints/MinimalIcon.tsx index 4e5c505d25b..2082ad8ea94 100644 --- a/client/src/components/Endpoints/MinimalIcon.tsx +++ b/client/src/components/Endpoints/MinimalIcon.tsx @@ -55,7 +55,10 @@ const MinimalIcon: React.FC = (props) => { }, }; - const { icon, name } = endpointIcons[endpoint] ?? endpointIcons.default; + let { icon, name } = endpointIcons[endpoint] ?? endpointIcons.default; + if (props.iconURL && endpointIcons[props.iconURL]) { + ({ icon, name } = endpointIcons[props.iconURL]); + } return (
{localize('com_endpoint_preset_name')} setTitle(e.target.value || '')} placeholder="Set a custom name for this preset" className={cn( - defaultTextPropsLabel, + defaultTextProps, 'flex h-10 max-h-10 w-full resize-none border-gray-100 px-3 py-2 dark:border-gray-600', removeFocusOutlines, )} diff --git a/client/src/components/Endpoints/Settings/Anthropic.tsx b/client/src/components/Endpoints/Settings/Anthropic.tsx index 55ef42a23f5..5f7a8ef3ef5 100644 --- a/client/src/components/Endpoints/Settings/Anthropic.tsx +++ b/client/src/components/Endpoints/Settings/Anthropic.tsx @@ -1,7 +1,6 @@ import React from 'react'; import TextareaAutosize from 'react-textarea-autosize'; -import type { TModelSelectProps } from '~/common'; -import { ESide } from '~/common'; +import type { TModelSelectProps, OnInputNumberChange } from '~/common'; import { Input, Label, @@ -12,17 +11,35 @@ import { SelectDropDown, HoverCardTrigger, } from '~/components/ui'; +import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils'; +import OptionHoverAlt from '~/components/SidePanel/Parameters/OptionHover'; +import { useLocalize, useDebouncedInput } from '~/hooks'; import OptionHover from './OptionHover'; -import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/'; -import { useLocalize } from '~/hooks'; +import { ESide } from '~/common'; export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) { const localize = useLocalize(); + const { + model, + modelLabel, + promptPrefix, + temperature, + topP, + topK, + maxOutputTokens, + maxContextTokens, + resendFiles, + } = conversation ?? {}; + const [setMaxContextTokens, maxContextTokensValue] = useDebouncedInput( + { + setOption, + optionKey: 'maxContextTokens', + initialValue: maxContextTokens, + }, + ); if (!conversation) { return null; } - const { model, modelLabel, promptPrefix, temperature, topP, topK, maxOutputTokens, resendFiles } = - conversation; const setModel = setOption('model'); const setModelLabel = setOption('modelLabel'); @@ -83,6 +100,40 @@ export default function Settings({ conversation, setOption, models, readonly }:
+ + +
+ + +
+
+ +
diff --git a/client/src/components/Endpoints/Settings/Google.tsx b/client/src/components/Endpoints/Settings/Google.tsx index abcf5144117..f16fb567dd4 100644 --- a/client/src/components/Endpoints/Settings/Google.tsx +++ b/client/src/components/Endpoints/Settings/Google.tsx @@ -1,34 +1,43 @@ import { useEffect } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; import { EModelEndpoint, endpointSettings } from 'librechat-data-provider'; -import type { TModelSelectProps } from '~/common'; -import { ESide } from '~/common'; +import type { TModelSelectProps, OnInputNumberChange } from '~/common'; import { - SelectDropDown, Input, Label, Slider, - InputNumber, HoverCard, + InputNumber, + SelectDropDown, HoverCardTrigger, } from '~/components/ui'; +import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils'; +import OptionHoverAlt from '~/components/SidePanel/Parameters/OptionHover'; +import { useLocalize, useDebouncedInput } from '~/hooks'; import OptionHover from './OptionHover'; -import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/'; -import { useLocalize } from '~/hooks'; +import { ESide } from '~/common'; export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) { const localize = useLocalize(); const google = endpointSettings[EModelEndpoint.google]; - const { model, modelLabel, promptPrefix, temperature, topP, topK, maxOutputTokens } = - conversation ?? {}; + const { + model, + modelLabel, + promptPrefix, + temperature, + topP, + topK, + maxContextTokens, + maxOutputTokens, + } = conversation ?? {}; - const isGeminiPro = model?.toLowerCase()?.includes('gemini-pro'); + const isGemini = model?.toLowerCase()?.includes('gemini'); - const maxOutputTokensMax = isGeminiPro - ? google.maxOutputTokens.maxGeminiPro + const maxOutputTokensMax = isGemini + ? google.maxOutputTokens.maxGemini : google.maxOutputTokens.max; - const maxOutputTokensDefault = isGeminiPro - ? google.maxOutputTokens.defaultGeminiPro + const maxOutputTokensDefault = isGemini + ? google.maxOutputTokens.defaultGemini : google.maxOutputTokens.default; useEffect( @@ -41,6 +50,14 @@ export default function Settings({ conversation, setOption, models, readonly }: [model], ); + const [setMaxContextTokens, maxContextTokensValue] = useDebouncedInput( + { + setOption, + optionKey: 'maxContextTokens', + initialValue: maxContextTokens, + }, + ); + if (!conversation) { return null; } @@ -53,10 +70,6 @@ export default function Settings({ conversation, setOption, models, readonly }: const setTopK = setOption('topK'); const setMaxOutputTokens = setOption('maxOutputTokens'); - const isGenerativeModel = model?.toLowerCase()?.includes('gemini'); - const isChatModel = !isGenerativeModel && model?.toLowerCase()?.includes('chat'); - const isTextModel = !isGenerativeModel && !isChatModel && /code|text/.test(model ?? ''); - return (
@@ -107,6 +120,40 @@ export default function Settings({ conversation, setOption, models, readonly }:
+ + +
+ + +
+
+ +
@@ -147,91 +194,87 @@ export default function Settings({ conversation, setOption, models, readonly }: - {!isTextModel && ( - <> - - -
- - setTopP(value ?? google.topP.default)} - max={google.topP.max} - min={google.topP.min} - step={google.topP.step} - controls={false} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200', - ), - )} - /> -
- setTopP(value[0])} - doubleClickHandler={() => setTopP(google.topP.default)} - max={google.topP.max} - min={google.topP.min} - step={google.topP.step} - className="flex h-4 w-full" - /> -
- -
+ + +
+ + setTopP(value ?? google.topP.default)} + max={google.topP.max} + min={google.topP.min} + step={google.topP.step} + controls={false} + className={cn( + defaultTextProps, + cn( + optionText, + 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200', + ), + )} + /> +
+ setTopP(value[0])} + doubleClickHandler={() => setTopP(google.topP.default)} + max={google.topP.max} + min={google.topP.min} + step={google.topP.step} + className="flex h-4 w-full" + /> +
+ +
- - -
- - setTopK(value ?? google.topK.default)} - max={google.topK.max} - min={google.topK.min} - step={google.topK.step} - controls={false} - className={cn( - defaultTextProps, - cn( - optionText, - 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200', - ), - )} - /> -
- setTopK(value[0])} - doubleClickHandler={() => setTopK(google.topK.default)} - max={google.topK.max} - min={google.topK.min} - step={google.topK.step} - className="flex h-4 w-full" - /> -
- -
- - )} + + +
+ + setTopK(value ?? google.topK.default)} + max={google.topK.max} + min={google.topK.min} + step={google.topK.step} + controls={false} + className={cn( + defaultTextProps, + cn( + optionText, + 'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200', + ), + )} + /> +
+ setTopK(value[0])} + doubleClickHandler={() => setTopK(google.topK.default)} + max={google.topK.max} + min={google.topK.min} + step={google.topK.step} + className="flex h-4 w-full" + /> +
+ +
diff --git a/client/src/components/Endpoints/Settings/MultiView/Google.tsx b/client/src/components/Endpoints/Settings/MultiView/Google.tsx deleted file mode 100644 index 9cdb5cd2140..00000000000 --- a/client/src/components/Endpoints/Settings/MultiView/Google.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Settings from '../Google'; -import Examples from '../Examples'; -import { useSetOptions } from '~/hooks'; -import { useRecoilValue } from 'recoil'; -import store from '~/store'; - -export default function GoogleView({ conversation, models, isPreset = false }) { - const optionSettings = useRecoilValue(store.optionSettings); - const { setOption, setExample, addExample, removeExample } = useSetOptions( - isPreset ? conversation : null, - ); - if (!conversation) { - return null; - } - - const { examples } = conversation; - const { showExamples, isCodeChat } = optionSettings; - return showExamples && !isCodeChat ? ( - - ) : ( - - ); -} diff --git a/client/src/components/Endpoints/Settings/MultiView/PluginSettings.tsx b/client/src/components/Endpoints/Settings/MultiView/PluginSettings.tsx index 90b808bf1ad..24f1ade4405 100644 --- a/client/src/components/Endpoints/Settings/MultiView/PluginSettings.tsx +++ b/client/src/components/Endpoints/Settings/MultiView/PluginSettings.tsx @@ -5,7 +5,9 @@ import { useChatContext } from '~/Providers'; export default function PluginsView({ conversation, models, isPreset = false }) { const { showAgentSettings } = useChatContext(); - const { setOption, setAgentOption } = useSetIndexOptions(isPreset ? conversation : null); + const { setOption, setTools, setAgentOption, checkPluginSelection } = useSetIndexOptions( + isPreset ? conversation : null, + ); if (!conversation) { return null; } @@ -13,6 +15,12 @@ export default function PluginsView({ conversation, models, isPreset = false }) return showAgentSettings ? ( ) : ( - + ); } diff --git a/client/src/components/Endpoints/Settings/MultiView/Plugins.tsx b/client/src/components/Endpoints/Settings/MultiView/Plugins.tsx deleted file mode 100644 index 7d88ce7e570..00000000000 --- a/client/src/components/Endpoints/Settings/MultiView/Plugins.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import Settings from '../Plugins'; -import AgentSettings from '../AgentSettings'; -import { useSetOptions } from '~/hooks'; -import { useRecoilValue } from 'recoil'; -import store from '~/store'; - -export default function PluginsView({ conversation, models, isPreset = false }) { - const showAgentSettings = useRecoilValue(store.showAgentSettings); - const { setOption, setAgentOption } = useSetOptions(isPreset ? conversation : null); - if (!conversation) { - return null; - } - - return showAgentSettings ? ( - - ) : ( - - ); -} diff --git a/client/src/components/Endpoints/Settings/MultiView/index.ts b/client/src/components/Endpoints/Settings/MultiView/index.ts index 3de12f02f31..f144aacc283 100644 --- a/client/src/components/Endpoints/Settings/MultiView/index.ts +++ b/client/src/components/Endpoints/Settings/MultiView/index.ts @@ -1,4 +1,2 @@ -export { default as Google } from './Google'; -export { default as Plugins } from './Plugins'; export { default as GoogleSettings } from './GoogleSettings'; export { default as PluginSettings } from './PluginSettings'; diff --git a/client/src/components/Endpoints/Settings/OpenAI.tsx b/client/src/components/Endpoints/Settings/OpenAI.tsx index 2f7889a19c5..3ca19fc11ff 100644 --- a/client/src/components/Endpoints/Settings/OpenAI.tsx +++ b/client/src/components/Endpoints/Settings/OpenAI.tsx @@ -1,5 +1,12 @@ +import { useMemo } from 'react'; import TextareaAutosize from 'react-textarea-autosize'; -import { ImageDetail, imageDetailNumeric, imageDetailValue } from 'librechat-data-provider'; +import { + EModelEndpoint, + ImageDetail, + imageDetailNumeric, + imageDetailValue, +} from 'librechat-data-provider'; +import type { TModelSelectProps, OnInputNumberChange } from '~/common'; import { Input, Label, @@ -10,9 +17,10 @@ import { SelectDropDown, HoverCardTrigger, } from '~/components/ui'; -import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/'; +import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils'; +import OptionHoverAlt from '~/components/SidePanel/Parameters/OptionHover'; +import { DynamicTags } from '~/components/SidePanel/Parameters'; import { useLocalize, useDebouncedInput } from '~/hooks'; -import type { TModelSelectProps } from '~/common'; import OptionHover from './OptionHover'; import { ESide } from '~/common'; @@ -22,6 +30,7 @@ export default function Settings({ conversation, setOption, models, readonly }: endpoint, endpointType, model, + modelLabel, chatGptLabel, promptPrefix, temperature, @@ -30,37 +39,58 @@ export default function Settings({ conversation, setOption, models, readonly }: presence_penalty: presP, resendFiles, imageDetail, + maxContextTokens, + max_tokens, } = conversation ?? {}; - const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput({ + + const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput({ setOption, optionKey: 'chatGptLabel', - initialValue: chatGptLabel, + initialValue: modelLabel ?? chatGptLabel, }); - const [setPromptPrefix, promptPrefixValue] = useDebouncedInput({ + const [setPromptPrefix, promptPrefixValue] = useDebouncedInput({ setOption, optionKey: 'promptPrefix', initialValue: promptPrefix, }); - const [setTemperature, temperatureValue] = useDebouncedInput({ + const [setTemperature, temperatureValue] = useDebouncedInput({ setOption, optionKey: 'temperature', initialValue: temperature, }); - const [setTopP, topPValue] = useDebouncedInput({ + const [setTopP, topPValue] = useDebouncedInput({ setOption, optionKey: 'top_p', initialValue: topP, }); - const [setFreqP, freqPValue] = useDebouncedInput({ + const [setFreqP, freqPValue] = useDebouncedInput({ setOption, optionKey: 'frequency_penalty', initialValue: freqP, }); - const [setPresP, presPValue] = useDebouncedInput({ + const [setPresP, presPValue] = useDebouncedInput({ setOption, optionKey: 'presence_penalty', initialValue: presP, }); + const [setMaxContextTokens, maxContextTokensValue] = useDebouncedInput( + { + setOption, + optionKey: 'maxContextTokens', + initialValue: maxContextTokens, + }, + ); + const [setMaxOutputTokens, maxOutputTokensValue] = useDebouncedInput({ + setOption, + optionKey: 'max_tokens', + initialValue: max_tokens, + }); + + const optionEndpoint = useMemo(() => endpointType ?? endpoint, [endpoint, endpointType]); + const isOpenAI = useMemo( + () => optionEndpoint === EModelEndpoint.openAI || optionEndpoint === EModelEndpoint.azureOpenAI, + [optionEndpoint], + ); if (!conversation) { return null; @@ -70,8 +100,6 @@ export default function Settings({ conversation, setOption, models, readonly }: const setResendFiles = setOption('resendFiles'); const setImageDetail = setOption('imageDetail'); - const optionEndpoint = endpointType ?? endpoint; - return (
@@ -120,8 +148,92 @@ export default function Settings({ conversation, setOption, models, readonly }: )} />
+
+ +
+ + +
+ + +
+
+ +
+ + +
+ + +
+
+ +
@@ -133,9 +245,10 @@ export default function Settings({ conversation, setOption, models, readonly }: void; + checkPluginSelection: (value: string) => boolean; +}) { const localize = useLocalize(); - if (!conversation) { - return null; - } + const availableTools = useRecoilValue(store.availableTools); + const { data: allPlugins } = useAvailablePluginsQuery({ + select: selectPlugins, + }); + + const conversationTools: TPlugin[] = useMemo(() => { + if (!conversation?.tools) { + return []; + } + return processPlugins(conversation.tools, allPlugins?.map); + }, [conversation, allPlugins]); + + const availablePlugins = useMemo(() => { + if (!availableTools) { + return []; + } + + return Object.values(availableTools); + }, [availableTools]); + const { model, + modelLabel, chatGptLabel, promptPrefix, temperature, top_p: topP, frequency_penalty: freqP, presence_penalty: presP, - tools, - } = conversation; + maxContextTokens, + } = conversation ?? {}; + + const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput({ + setOption, + optionKey: 'chatGptLabel', + initialValue: modelLabel ?? chatGptLabel, + }); + const [setPromptPrefix, promptPrefixValue] = useDebouncedInput({ + setOption, + optionKey: 'promptPrefix', + initialValue: promptPrefix, + }); + const [setTemperature, temperatureValue] = useDebouncedInput({ + setOption, + optionKey: 'temperature', + initialValue: temperature, + }); + const [setTopP, topPValue] = useDebouncedInput({ + setOption, + optionKey: 'top_p', + initialValue: topP, + }); + const [setFreqP, freqPValue] = useDebouncedInput({ + setOption, + optionKey: 'frequency_penalty', + initialValue: freqP, + }); + const [setPresP, presPValue] = useDebouncedInput({ + setOption, + optionKey: 'presence_penalty', + initialValue: presP, + }); + const [setMaxContextTokens, maxContextTokensValue] = useDebouncedInput( + { + setOption, + optionKey: 'maxContextTokens', + initialValue: maxContextTokens, + }, + ); const setModel = setOption('model'); - const setChatGptLabel = setOption('chatGptLabel'); - const setPromptPrefix = setOption('promptPrefix'); - const setTemperature = setOption('temperature'); - const setTopP = setOption('top_p'); - const setFreqP = setOption('frequency_penalty'); - const setPresP = setOption('presence_penalty'); - const toolsSelected = tools && tools.length > 0; + if (!conversation) { + return null; + } return (
@@ -58,21 +134,14 @@ export default function Settings({ conversation, setOption, models, readonly }:
setChatGptLabel(e.target.value ?? null)} - placeholder={ - toolsSelected - ? localize('com_endpoint_disabled_with_tools_placeholder') - : localize('com_endpoint_openai_custom_name_placeholder') - } + placeholder={localize('com_endpoint_openai_custom_name_placeholder')} className={cn( defaultTextProps, 'flex h-10 max-h-10 w-full resize-none px-3 py-2', @@ -83,21 +152,16 @@ export default function Settings({ conversation, setOption, models, readonly }:
setPromptPrefix(e.target.value ?? null)} - placeholder={ - toolsSelected - ? localize('com_endpoint_disabled_with_tools_placeholder') - : localize('com_endpoint_plug_set_custom_instructions_for_gpt_placeholder') - } + placeholder={localize( + 'com_endpoint_plug_set_custom_instructions_for_gpt_placeholder', + )} className={cn( defaultTextProps, 'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ', @@ -107,6 +171,54 @@ export default function Settings({ conversation, setOption, models, readonly }:
+ + + +
+ + +
+
+ +
@@ -119,7 +231,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setTemperature(Number(value))} max={2} min={0} @@ -136,7 +248,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
setTemperature(value[0])} doubleClickHandler={() => setTemperature(0.8)} max={2} @@ -159,7 +271,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setTopP(Number(value))} max={1} min={0} @@ -176,7 +288,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
setTopP(value[0])} doubleClickHandler={() => setTopP(1)} max={1} @@ -200,7 +312,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setFreqP(Number(value))} max={2} min={-2} @@ -217,7 +329,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
setFreqP(value[0])} doubleClickHandler={() => setFreqP(0)} max={2} @@ -241,7 +353,7 @@ export default function Settings({ conversation, setOption, models, readonly }: setPresP(Number(value))} max={2} min={-2} @@ -258,7 +370,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
setPresP(value[0])} doubleClickHandler={() => setPresP(0)} max={2} diff --git a/client/src/components/Endpoints/Settings/settings.ts b/client/src/components/Endpoints/Settings/settings.ts index 45389730436..1a6cf43fbcb 100644 --- a/client/src/components/Endpoints/Settings/settings.ts +++ b/client/src/components/Endpoints/Settings/settings.ts @@ -1,7 +1,7 @@ import { EModelEndpoint } from 'librechat-data-provider'; import type { FC } from 'react'; -import type { TModelSelectProps, TBaseSettingsProps, TModels } from '~/common'; -import { Google, Plugins, GoogleSettings, PluginSettings } from './MultiView'; +import type { TModelSelectProps } from '~/common'; +import { GoogleSettings, PluginSettings } from './MultiView'; import AssistantsSettings from './Assistants'; import AnthropicSettings from './Anthropic'; import BingAISettings from './BingAI'; @@ -16,19 +16,7 @@ const settings: { [key: string]: FC } = { [EModelEndpoint.anthropic]: AnthropicSettings, }; -const multiViewSettings: { [key: string]: FC } = { - [EModelEndpoint.google]: Google, - [EModelEndpoint.gptPlugins]: Plugins, -}; - -export const getSettings = (isMultiChat = false) => { - if (!isMultiChat) { - return { - settings, - multiViewSettings, - }; - } - +export const getSettings = () => { return { settings, multiViewSettings: { diff --git a/client/src/components/Endpoints/index.ts b/client/src/components/Endpoints/index.ts index 02f21d167b6..f171b4074ad 100644 --- a/client/src/components/Endpoints/index.ts +++ b/client/src/components/Endpoints/index.ts @@ -1,6 +1,8 @@ export { default as Icon } from './Icon'; export { default as MinimalIcon } from './MinimalIcon'; +export { default as ConvoIcon } from './ConvoIcon'; +export { default as EndpointIcon } from './EndpointIcon'; +export { default as ConvoIconURL } from './ConvoIconURL'; export { default as EndpointSettings } from './EndpointSettings'; export { default as SaveAsPresetDialog } from './SaveAsPresetDialog'; export { default as AlternativeSettings } from './AlternativeSettings'; -export { default as EndpointOptionsPopover } from './EndpointOptionsPopover'; diff --git a/client/src/components/Input/GenerationButtons.tsx b/client/src/components/Input/GenerationButtons.tsx deleted file mode 100644 index 71479febe37..00000000000 --- a/client/src/components/Input/GenerationButtons.tsx +++ /dev/null @@ -1,48 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { cn, removeFocusOutlines } from '~/utils/'; - -type GenerationButtonsProps = { - showPopover: boolean; - opacityClass: string; -}; - -export default function GenerationButtons({ showPopover, opacityClass }: GenerationButtonsProps) { - return ( -
-
-
-
- {/* */} -
-
-
- ); -} diff --git a/client/src/components/Input/Generations/GenerationButtons.tsx b/client/src/components/Input/Generations/GenerationButtons.tsx deleted file mode 100644 index ad9ef15c4e1..00000000000 --- a/client/src/components/Input/Generations/GenerationButtons.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useEffect, useState } from 'react'; -import type { TMessage } from 'librechat-data-provider'; -import { useMessageHandler, useMediaQuery, useGenerations } from '~/hooks'; -import { cn } from '~/utils'; -import Regenerate from './Regenerate'; -import Continue from './Continue'; -import Stop from './Stop'; - -type GenerationButtonsProps = { - endpoint: string; - showPopover: boolean; - opacityClass: string; -}; - -export default function GenerationButtons({ - endpoint, - showPopover, - opacityClass, -}: GenerationButtonsProps) { - const { - messages, - isSubmitting, - latestMessage, - handleContinue, - handleRegenerate, - handleStopGenerating, - } = useMessageHandler(); - const isSmallScreen = useMediaQuery('(max-width: 768px)'); - const { continueSupported, regenerateEnabled } = useGenerations({ - endpoint, - message: latestMessage as TMessage, - isSubmitting, - }); - - const [userStopped, setUserStopped] = useState(false); - - const handleStop = (e: React.MouseEvent) => { - setUserStopped(true); - handleStopGenerating(e); - }; - - useEffect(() => { - let timer: NodeJS.Timeout; - - if (userStopped) { - timer = setTimeout(() => { - setUserStopped(false); - }, 200); - } - - return () => { - clearTimeout(timer); - }; - }, [userStopped]); - - if (isSmallScreen) { - return null; - } - - let button: React.ReactNode = null; - - if (isSubmitting) { - button = ; - } else if (userStopped || continueSupported) { - button = ; - } else if (messages && messages.length > 0 && regenerateEnabled) { - button = ; - } - - return ( -
-
-
-
- {button} -
-
-
- ); -} diff --git a/client/src/components/Input/Generations/index.ts b/client/src/components/Input/Generations/index.ts deleted file mode 100644 index bbf5aeb41b0..00000000000 --- a/client/src/components/Input/Generations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as GenerationButtons } from './GenerationButtons'; diff --git a/client/src/components/Input/ModelSelect/ModelSelect.tsx b/client/src/components/Input/ModelSelect/ModelSelect.tsx index 32397ab3bf3..c4081db6ac9 100644 --- a/client/src/components/Input/ModelSelect/ModelSelect.tsx +++ b/client/src/components/Input/ModelSelect/ModelSelect.tsx @@ -1,7 +1,7 @@ +import { useGetModelsQuery } from 'librechat-data-provider/react-query'; import type { TConversation } from 'librechat-data-provider'; import type { TSetOption } from '~/common'; -import { options, multiChatOptions } from './options'; -import { useGetModelsQuery } from 'librechat-data-provider/react-query'; +import { multiChatOptions } from './options'; type TGoogleProps = { showExamples: boolean; @@ -12,14 +12,14 @@ type TSelectProps = { conversation: TConversation | null; setOption: TSetOption; extraProps?: TGoogleProps; - isMultiChat?: boolean; showAbove?: boolean; + popover?: boolean; }; export default function ModelSelect({ conversation, setOption, - isMultiChat = false, + popover = false, showAbove = true, }: TSelectProps) { const modelsQuery = useGetModelsQuery(); @@ -32,7 +32,7 @@ export default function ModelSelect({ const models = modelsQuery?.data?.[_endpoint] ?? []; const endpoint = endpointType ?? _endpoint; - const OptionComponent = isMultiChat ? multiChatOptions[endpoint] : options[endpoint]; + const OptionComponent = multiChatOptions[endpoint]; if (!OptionComponent) { return null; @@ -44,7 +44,7 @@ export default function ModelSelect({ setOption={setOption} models={models} showAbove={showAbove} - popover={isMultiChat} + popover={popover} /> ); } diff --git a/client/src/components/Input/ModelSelect/Plugins.tsx b/client/src/components/Input/ModelSelect/Plugins.tsx deleted file mode 100644 index d352da8424d..00000000000 --- a/client/src/components/Input/ModelSelect/Plugins.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { useRecoilState } from 'recoil'; -import { useState, useEffect } from 'react'; -import { ChevronDownIcon } from 'lucide-react'; -import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query'; -import type { TPlugin } from 'librechat-data-provider'; -import type { TModelSelectProps } from '~/common'; -import { SelectDropDown, MultiSelectDropDown, SelectDropDownPop, Button } from '~/components/ui'; -import { useSetOptions, useAuthContext, useMediaQuery, useLocalize } from '~/hooks'; -import { cn, cardStyle } from '~/utils/'; -import store from '~/store'; - -const pluginStore: TPlugin = { - name: 'Plugin store', - pluginKey: 'pluginStore', - isButton: true, - description: '', - icon: '', - authConfig: [], - authenticated: false, -}; - -export default function Plugins({ - conversation, - setOption, - models, - showAbove, - popover = false, -}: TModelSelectProps) { - const localize = useLocalize(); - const { data: allPlugins } = useAvailablePluginsQuery(); - const [visible, setVisibility] = useState(true); - const [availableTools, setAvailableTools] = useRecoilState(store.availableTools); - const { checkPluginSelection, setTools } = useSetOptions(); - const { user } = useAuthContext(); - const isSmallScreen = useMediaQuery('(max-width: 640px)'); - const Menu = popover ? SelectDropDownPop : SelectDropDown; - - useEffect(() => { - if (isSmallScreen) { - setVisibility(false); - } - }, [isSmallScreen]); - - useEffect(() => { - if (!user) { - return; - } - - if (!allPlugins) { - return; - } - - if (!user.plugins || user.plugins.length === 0) { - setAvailableTools([pluginStore]); - return; - } - - const tools = [...user.plugins] - .map((el) => allPlugins.find((plugin: TPlugin) => plugin.pluginKey === el)) - .filter((el): el is TPlugin => el !== undefined); - - /* Filter Last Selected Tools */ - const localStorageItem = localStorage.getItem('lastSelectedTools'); - if (!localStorageItem) { - return setAvailableTools([...tools, pluginStore]); - } - const lastSelectedTools = JSON.parse(localStorageItem); - const filteredTools = lastSelectedTools.filter((tool: TPlugin) => - tools.some((existingTool) => existingTool.pluginKey === tool.pluginKey), - ); - localStorage.setItem('lastSelectedTools', JSON.stringify(filteredTools)); - - setAvailableTools([...tools, pluginStore]); - // setAvailableTools is a recoil state setter, so it's safe to use it in useEffect - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allPlugins, user]); - - if (!conversation) { - return null; - } - - return ( - <> - - - - - ); -} diff --git a/client/src/components/Input/ModelSelect/PluginsByIndex.tsx b/client/src/components/Input/ModelSelect/PluginsByIndex.tsx index fe888473458..8546d824383 100644 --- a/client/src/components/Input/ModelSelect/PluginsByIndex.tsx +++ b/client/src/components/Input/ModelSelect/PluginsByIndex.tsx @@ -1,30 +1,20 @@ -import { useRecoilState } from 'recoil'; -import { useState, useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; import { ChevronDownIcon } from 'lucide-react'; +import { useState, useEffect, useMemo } from 'react'; import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query'; import type { TPlugin } from 'librechat-data-provider'; import type { TModelSelectProps } from '~/common'; import { + Button, + MultiSelectPop, SelectDropDown, SelectDropDownPop, MultiSelectDropDown, - MultiSelectPop, - Button, } from '~/components/ui'; import { useSetIndexOptions, useAuthContext, useMediaQuery, useLocalize } from '~/hooks'; -import { cn, cardStyle } from '~/utils/'; +import { cn, cardStyle, selectPlugins, processPlugins } from '~/utils'; import store from '~/store'; -const pluginStore: TPlugin = { - name: 'Plugin store', - pluginKey: 'pluginStore', - isButton: true, - description: '', - icon: '', - authConfig: [], - authenticated: false, -}; - export default function PluginsByIndex({ conversation, setOption, @@ -33,12 +23,16 @@ export default function PluginsByIndex({ popover = false, }: TModelSelectProps) { const localize = useLocalize(); - const { data: allPlugins } = useAvailablePluginsQuery(); - const [visible, setVisibility] = useState(true); - const [availableTools, setAvailableTools] = useRecoilState(store.availableTools); - const { checkPluginSelection, setTools } = useSetIndexOptions(); const { user } = useAuthContext(); + const [visible, setVisibility] = useState(true); const isSmallScreen = useMediaQuery('(max-width: 640px)'); + const availableTools = useRecoilValue(store.availableTools); + const { checkPluginSelection, setTools } = useSetIndexOptions(); + + const { data: allPlugins } = useAvailablePluginsQuery({ + enabled: !!user?.plugins, + select: selectPlugins, + }); useEffect(() => { if (isSmallScreen) { @@ -46,39 +40,20 @@ export default function PluginsByIndex({ } }, [isSmallScreen]); - useEffect(() => { - if (!user) { - return; - } - - if (!allPlugins) { - return; - } - - if (!user.plugins || user.plugins.length === 0) { - setAvailableTools([pluginStore]); - return; + const conversationTools: TPlugin[] = useMemo(() => { + if (!conversation?.tools) { + return []; } + return processPlugins(conversation.tools, allPlugins?.map); + }, [conversation, allPlugins]); - const tools = [...user.plugins] - .map((el) => allPlugins.find((plugin: TPlugin) => plugin.pluginKey === el)) - .filter((el): el is TPlugin => el !== undefined); - - /* Filter Last Selected Tools */ - const localStorageItem = localStorage.getItem('lastSelectedTools'); - if (!localStorageItem) { - return setAvailableTools([...tools, pluginStore]); + const availablePlugins = useMemo(() => { + if (!availableTools) { + return []; } - const lastSelectedTools = JSON.parse(localStorageItem); - const filteredTools = lastSelectedTools.filter((tool: TPlugin) => - tools.some((existingTool) => existingTool.pluginKey === tool.pluginKey), - ); - localStorage.setItem('lastSelectedTools', JSON.stringify(filteredTools)); - setAvailableTools([...tools, pluginStore]); - // setAvailableTools is a recoil state setter, so it's safe to use it in useEffect - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allPlugins, user]); + return Object.values(availableTools); + }, [availableTools]); if (!conversation) { return null; @@ -112,15 +87,19 @@ export default function PluginsByIndex({ availableValues={models} showAbove={showAbove} showLabel={false} + className={cn( + cardStyle, + 'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 hover:cursor-pointer', + )} /> diff --git a/client/src/components/Input/ModelSelect/options.ts b/client/src/components/Input/ModelSelect/options.ts index ce231e86371..0159f0782f7 100644 --- a/client/src/components/Input/ModelSelect/options.ts +++ b/client/src/components/Input/ModelSelect/options.ts @@ -5,7 +5,6 @@ import type { FC } from 'react'; import OpenAI from './OpenAI'; import BingAI from './BingAI'; import Google from './Google'; -import Plugins from './Plugins'; import ChatGPT from './ChatGPT'; import Anthropic from './Anthropic'; import PluginsByIndex from './PluginsByIndex'; @@ -16,7 +15,6 @@ export const options: { [key: string]: FC } = { [EModelEndpoint.azureOpenAI]: OpenAI, [EModelEndpoint.bingAI]: BingAI, [EModelEndpoint.google]: Google, - [EModelEndpoint.gptPlugins]: Plugins, [EModelEndpoint.anthropic]: Anthropic, [EModelEndpoint.chatGPTBrowser]: ChatGPT, }; diff --git a/client/src/components/Messages/Content/Error.tsx b/client/src/components/Messages/Content/Error.tsx index 3d7f4541f19..966528f1249 100644 --- a/client/src/components/Messages/Content/Error.tsx +++ b/client/src/components/Messages/Content/Error.tsx @@ -1,9 +1,13 @@ // file deepcode ignore HardcodedNonCryptoSecret: No hardcoded secrets -import { ViolationTypes } from 'librechat-data-provider'; +import { ViolationTypes, ErrorTypes } from 'librechat-data-provider'; import type { TOpenAIMessage } from 'librechat-data-provider'; +import type { LocalizeFunction } from '~/common'; import { formatJSON, extractJson, isJson } from '~/utils/json'; +import useLocalize from '~/hooks/useLocalize'; import CodeBlock from './CodeBlock'; +const localizedErrorPrefix = 'com_error'; + type TConcurrent = { limit: number; }; @@ -14,7 +18,7 @@ type TMessageLimit = { }; type TTokenBalance = { - type: ViolationTypes; + type: ViolationTypes | ErrorTypes; balance: number; tokenCost: number; promptTokens: number; @@ -24,14 +28,26 @@ type TTokenBalance = { generations?: TOpenAIMessage[]; }; +type TExpiredKey = { + expiredAt: string; + endpoint: string; +}; + const errorMessages = { - ban: 'Your account has been temporarily banned due to violations of our service.', + [ErrorTypes.MODERATION]: 'com_error_moderation', + [ErrorTypes.NO_USER_KEY]: 'com_error_no_user_key', + [ErrorTypes.INVALID_USER_KEY]: 'com_error_invalid_user_key', + [ErrorTypes.NO_BASE_URL]: 'com_error_no_base_url', + [ErrorTypes.EXPIRED_USER_KEY]: (json: TExpiredKey, localize: LocalizeFunction) => { + const { expiredAt, endpoint } = json; + return localize('com_error_expired_user_key', endpoint, expiredAt); + }, + [ViolationTypes.BAN]: + 'Your account has been temporarily banned due to violations of our service.', invalid_api_key: 'Invalid API key. Please check your API key and try again. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.', insufficient_quota: 'We apologize for any inconvenience caused. The default API key has reached its limit. To continue using this service, please set up your own API key. You can do this by clicking on the model logo in the left corner of the textbox and selecting "Set Token" for the current selected endpoint. Thank you for your understanding.', - moderation: - 'It appears that the content submitted has been flagged by our moderation system for not aligning with our community guidelines. We\'re unable to proceed with this specific topic. If you have any other questions or topics you\'d like to explore, please edit your message, or create a new conversation.', concurrent: (json: TConcurrent) => { const { limit } = json; const plural = limit > 1 ? 's' : ''; @@ -69,6 +85,7 @@ const errorMessages = { }; const Error = ({ text }: { text: string }) => { + const localize = useLocalize(); const jsonString = extractJson(text); const errorMessage = text.length > 512 && !jsonString ? text.slice(0, 512) + '...' : text; const defaultResponse = `Something went wrong. Here's the specific error message we encountered: ${errorMessage}`; @@ -82,7 +99,9 @@ const Error = ({ text }: { text: string }) => { const keyExists = errorKey && errorMessages[errorKey]; if (keyExists && typeof errorMessages[errorKey] === 'function') { - return errorMessages[errorKey](json); + return errorMessages[errorKey](json, localize); + } else if (keyExists && keyExists.startsWith(localizedErrorPrefix)) { + return localize(errorMessages[errorKey]); } else if (keyExists) { return errorMessages[errorKey]; } else { diff --git a/client/src/components/Messages/Content/MessageContent.tsx b/client/src/components/Messages/Content/MessageContent.tsx index 07c92f009b3..479ecac358a 100644 --- a/client/src/components/Messages/Content/MessageContent.tsx +++ b/client/src/components/Messages/Content/MessageContent.tsx @@ -1,4 +1,5 @@ import { Fragment } from 'react'; +import { ViolationTypes } from 'librechat-data-provider'; import type { TResPlugin } from 'librechat-data-provider'; import type { TMessageContentProps, TText, TDisplayProps } from '~/common'; import { useAuthContext } from '~/hooks'; @@ -12,7 +13,7 @@ import Error from './Error'; const ErrorMessage = ({ text }: TText) => { const { logout } = useAuthContext(); - if (text.includes('ban')) { + if (text.includes(ViolationTypes.BAN)) { logout(); return null; } diff --git a/client/src/components/Messages/HoverButtons.tsx b/client/src/components/Messages/HoverButtons.tsx deleted file mode 100644 index 6841b471a79..00000000000 --- a/client/src/components/Messages/HoverButtons.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { useState } from 'react'; -import type { TConversation, TMessage } from 'librechat-data-provider'; -import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg'; -import { useGenerations, useLocalize } from '~/hooks'; -import { cn } from '~/utils'; - -type THoverButtons = { - isEditing: boolean; - enterEdit: (cancel?: boolean) => void; - copyToClipboard: (setIsCopied: React.Dispatch>) => void; - conversation: TConversation | null; - isSubmitting: boolean; - message: TMessage; - regenerate: () => void; - handleContinue: (e: React.MouseEvent) => void; -}; - -export default function HoverButtons({ - isEditing, - enterEdit, - copyToClipboard, - conversation, - isSubmitting, - message, - regenerate, - handleContinue, -}: THoverButtons) { - const localize = useLocalize(); - const { endpoint } = conversation ?? {}; - const [isCopied, setIsCopied] = useState(false); - const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({ - isEditing, - isSubmitting, - message, - endpoint: endpoint ?? '', - }); - if (!conversation) { - return null; - } - - const { isCreatedByUser } = message; - - const onEdit = () => { - if (isEditing) { - return enterEdit(true); - } - enterEdit(); - }; - - return ( -
- - - {regenerateEnabled ? ( - - ) : null} - {continueSupported ? ( - - ) : null} -
- ); -} diff --git a/client/src/components/Messages/Message.tsx b/client/src/components/Messages/Message.tsx deleted file mode 100644 index 04bd84826b1..00000000000 --- a/client/src/components/Messages/Message.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/* eslint-disable react-hooks/exhaustive-deps */ -import { useGetConversationByIdQuery } from 'librechat-data-provider/react-query'; -import { useEffect } from 'react'; -import { useSetRecoilState, useRecoilState, useRecoilValue } from 'recoil'; -import copy from 'copy-to-clipboard'; -import { SubRow, Plugin, MessageContent } from './Content'; -// eslint-disable-next-line import/no-cycle -import MultiMessage from './MultiMessage'; -import HoverButtons from './HoverButtons'; -import SiblingSwitch from './SiblingSwitch'; -import { Icon } from '~/components/Endpoints'; -import { useMessageHandler, useConversation } from '~/hooks'; -import type { TMessageProps } from '~/common'; -import { cn } from '~/utils'; -import store from '~/store'; -import { useParams } from 'react-router-dom'; - -export default function Message(props: TMessageProps) { - const { - conversation, - message, - scrollToBottom, - currentEditId, - setCurrentEditId, - siblingIdx, - siblingCount, - setSiblingIdx, - } = props; - - const setLatestMessage = useSetRecoilState(store.latestMessage); - const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll); - const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler(); - const { switchToConversation } = useConversation(); - const { conversationId } = useParams(); - const isSearching = useRecoilValue(store.isSearching); - - const { - text, - children, - messageId = null, - searchResult, - isCreatedByUser, - error, - unfinished, - } = message ?? {}; - - const isLast = !children?.length; - const edit = messageId === currentEditId; - const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', { - enabled: false, - }); - - const autoScroll = useRecoilValue(store.autoScroll); - - useEffect(() => { - if (isSubmitting && scrollToBottom && !abortScroll) { - scrollToBottom(); - } - }, [isSubmitting, text, scrollToBottom, abortScroll]); - - useEffect(() => { - if (scrollToBottom && autoScroll && !isSearching && conversationId !== 'new') { - scrollToBottom(); - } - }, [autoScroll, conversationId, scrollToBottom, isSearching]); - - useEffect(() => { - if (!message) { - return; - } else if (isLast) { - setLatestMessage({ ...message }); - } - }, [isLast, message, setLatestMessage]); - - if (!message) { - return null; - } - - const enterEdit = (cancel?: boolean) => - setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId); - - const handleScroll = () => { - if (isSubmitting) { - setAbortScroll(true); - } else { - setAbortScroll(false); - } - }; - - const commonClasses = - 'w-full border-b text-gray-800 group border-black/10 dark:border-gray-800/50 dark:text-gray-200'; - const uniqueClasses = isCreatedByUser - ? 'bg-white dark:bg-gray-800 dark:text-gray-20' - : 'bg-gray-50 dark:bg-gray-700 dark:text-gray-100'; - - const messageProps = { - className: cn(commonClasses, uniqueClasses), - titleclass: '', - }; - - const icon = Icon({ - ...conversation, - ...message, - model: message?.model ?? conversation?.model, - size: 36, - }); - - if (message?.bg && searchResult) { - messageProps.className = message?.bg?.split('hover')[0]; - messageProps.titleclass = message?.bg?.split(messageProps.className)[1] + ' cursor-pointer'; - } - - const regenerateMessage = () => { - if (!isSubmitting && !isCreatedByUser) { - regenerate(message); - } - }; - - const copyToClipboard = (setIsCopied: React.Dispatch>) => { - setIsCopied(true); - copy(text ?? ''); - - setTimeout(() => { - setIsCopied(false); - }, 3000); - }; - - const clickSearchResult = async () => { - if (!searchResult) { - return; - } - if (!message) { - return; - } - const response = await getConversationQuery.refetch({ - queryKey: [message?.conversationId], - }); - - console.log('getConversationQuery response.data:', response.data); - - if (response.data) { - switchToConversation(response.data); - } - }; - - return ( - <> -
-
-
- {typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? ( - {icon} - ) : ( - icon - )} -
- -
-
-
- {searchResult && ( - - {`${message?.title} | ${message?.sender}`} - - )} -
- {/* Legacy Plugins */} - {message?.plugin && } - { - return; - }) - } - /> -
- regenerateMessage()} - handleContinue={handleContinue} - copyToClipboard={copyToClipboard} - /> - - - -
-
-
- - - ); -} diff --git a/client/src/components/Messages/MultiMessage.tsx b/client/src/components/Messages/MultiMessage.tsx deleted file mode 100644 index 08a17c33fdb..00000000000 --- a/client/src/components/Messages/MultiMessage.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { useEffect } from 'react'; -import { useRecoilState } from 'recoil'; -import type { TMessageProps } from '~/common'; -// eslint-disable-next-line import/no-cycle -import Message from './Message'; -import store from '~/store'; - -export default function MultiMessage({ - messageId, - conversation, - messagesTree, - scrollToBottom, - currentEditId, - setCurrentEditId, - isSearchView, -}: TMessageProps) { - const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId)); - - const setSiblingIdxRev = (value: number) => { - setSiblingIdx((messagesTree?.length ?? 0) - value - 1); - }; - - useEffect(() => { - // reset siblingIdx when the tree changes, mostly when a new message is submitting. - setSiblingIdx(0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [messagesTree?.length]); - - // if (!messageList?.length) return null; - if (!(messagesTree && messagesTree?.length)) { - return null; - } - - if (siblingIdx >= messagesTree?.length) { - setSiblingIdx(0); - return null; - } - - const message = messagesTree[messagesTree.length - siblingIdx - 1]; - if (isSearchView) { - return ( - <> - {messagesTree - ? messagesTree.map((message) => ( - - )) - : null} - - ); - } - return ( - - ); -} diff --git a/client/src/components/Messages/SiblingSwitch.tsx b/client/src/components/Messages/SiblingSwitch.tsx deleted file mode 100644 index 72c154662ac..00000000000 --- a/client/src/components/Messages/SiblingSwitch.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import type { TMessageProps } from '~/common'; - -type TSiblingSwitchProps = Pick; - -export default function SiblingSwitch({ - siblingIdx, - siblingCount, - setSiblingIdx, -}: TSiblingSwitchProps) { - if (siblingIdx === undefined) { - return null; - } else if (siblingCount === undefined) { - return null; - } - - const previous = () => { - setSiblingIdx && setSiblingIdx(siblingIdx - 1); - }; - - const next = () => { - setSiblingIdx && setSiblingIdx(siblingIdx + 1); - }; - - return siblingCount > 1 ? ( - <> - - - {siblingIdx + 1} / {siblingCount} - - - - ) : null; -} diff --git a/client/src/components/Nav/Logout.tsx b/client/src/components/Nav/Logout.tsx index f107e7c457c..7145dbbc3e4 100644 --- a/client/src/components/Nav/Logout.tsx +++ b/client/src/components/Nav/Logout.tsx @@ -9,7 +9,7 @@ const Logout = forwardRef(() => { return ( -

+

{title || localize('com_ui_new_chat')}

- {user?.name || localize('com_nav_user')} + {user?.name || user?.username || localize('com_nav_user')}
@@ -109,24 +88,23 @@ function NavLinks() { leaveFrom="translate-y-0 opacity-100" leaveTo="translate-y-2 opacity-0" > - - - } - text={localize('com_nav_export_conversation')} - clickHandler={clickHandler} - /> - -
+ +
+ {user?.email || localize('com_nav_user')} +
+
+ {startupConfig?.checkBalance && + balanceQuery.data && + !isNaN(parseFloat(balanceQuery.data)) && ( + <> +
+ {`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`} +
+
+ + )} } text={localize('com_nav_my_files')} clickHandler={() => setShowFiles(true)} @@ -135,7 +113,6 @@ function NavLinks() { {startupConfig?.helpAndFaqURL !== '/' && ( } text={localize('com_nav_help_faq')} clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')} @@ -144,13 +121,12 @@ function NavLinks() { )} } text={localize('com_nav_settings')} clickHandler={() => setShowSettings(true)} /> -
+
@@ -159,9 +135,6 @@ function NavLinks() { )} - {showExports && ( - - )} {showFiles && } {showSettings && } diff --git a/client/src/components/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index bdb16a4a994..19e99a66209 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -1,30 +1,74 @@ +import { Search } from 'lucide-react'; +import { useRecoilValue } from 'recoil'; import { useNavigate } from 'react-router-dom'; -import { EModelEndpoint } from 'librechat-data-provider'; import { useGetEndpointsQuery } from 'librechat-data-provider/react-query'; import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui'; -import { useLocalize, useNewConvo, useLocalStorage } from '~/hooks'; +import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils'; import { icons } from '~/components/Chat/Menus/Endpoints/Icons'; +import ConvoIconURL from '~/components/Endpoints/ConvoIconURL'; +import { useLocalize, useNewConvo } from '~/hooks'; import { NewChatIcon } from '~/components/svg'; -import { getEndpointField } from '~/utils'; +import store from '~/store'; +import type { TConversation } from 'librechat-data-provider'; + +const NewChatButtonIcon = ({ conversation }: { conversation: TConversation | null }) => { + const searchQuery = useRecoilValue(store.searchQuery); + const { data: endpointsConfig } = useGetEndpointsQuery(); + + if (searchQuery) { + return ( +
+ +
+ ); + } + + let { endpoint = '' } = conversation ?? {}; + const iconURL = conversation?.iconURL ?? ''; + endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint }); + + const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); + const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); + const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL }); + const Icon = icons[iconKey]; + + return ( +
+ {iconURL && iconURL.includes('http') ? ( + + ) : ( +
+ {endpoint && + Icon && + Icon({ + size: 41, + context: 'nav', + className: 'h-2/3 w-2/3', + endpoint, + endpointType, + iconURL: endpointIconURL, + })} +
+ )} +
+ ); +}; export default function NewChat({ + index = 0, toggleNav, subHeaders, }: { + index?: number; toggleNav: () => void; subHeaders?: React.ReactNode; }) { - const { newConversation: newConvo } = useNewConvo(); + /** Note: this component needs an explicit index passed if using more than one */ + const { newConversation: newConvo } = useNewConvo(index); const navigate = useNavigate(); const localize = useLocalize(); - const { data: endpointsConfig } = useGetEndpointsQuery(); - const [convo] = useLocalStorage('lastConversationSetup', { endpoint: EModelEndpoint.openAI }); - const { endpoint } = convo; - const endpointType = getEndpointField(endpointsConfig, endpoint, 'type'); - const iconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); - const iconKey = endpointType ? 'unknown' : endpoint ?? 'unknown'; - const Icon = icons[iconKey]; + const { conversation } = store.useCreateConversationAtom(index); const clickHandler = (event: React.MouseEvent) => { if (event.button === 0 && !event.ctrlKey) { @@ -46,19 +90,7 @@ export default function NewChat({ onClick={clickHandler} className="group flex h-10 items-center gap-2 rounded-lg px-2 font-medium hover:bg-gray-200 dark:hover:bg-gray-700" > -
-
- {endpoint && - Icon && - Icon({ - size: 41, - context: 'nav', - className: 'h-2/3 w-2/3', - endpoint: endpoint, - iconURL: iconURL, - })} -
-
+
{localize('com_ui_new_chat')}
diff --git a/client/src/components/Nav/SearchBar.tsx b/client/src/components/Nav/SearchBar.tsx index abb20f53ac4..5518fb2865c 100644 --- a/client/src/components/Nav/SearchBar.tsx +++ b/client/src/components/Nav/SearchBar.tsx @@ -1,7 +1,9 @@ -import { forwardRef, useState, useCallback, useMemo, Ref } from 'react'; +import debounce from 'lodash/debounce'; import { Search, X } from 'lucide-react'; import { useSetRecoilState } from 'recoil'; -import debounce from 'lodash/debounce'; +import { QueryKeys } from 'librechat-data-provider'; +import { useQueryClient } from '@tanstack/react-query'; +import { forwardRef, useState, useCallback, useMemo, Ref } from 'react'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -12,6 +14,8 @@ type SearchBarProps = { const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) => { const { clearSearch } = props; + const queryClient = useQueryClient(); + const clearConvoState = store.useClearConvoState(); const setSearchQuery = useSetRecoilState(store.searchQuery); const [showClearIcon, setShowClearIcon] = useState(false); const [text, setText] = useState(''); @@ -31,7 +35,17 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: Ref) = } }; - const sendRequest = useCallback((value: string) => setSearchQuery(value), [setSearchQuery]); + const sendRequest = useCallback( + (value: string) => { + setSearchQuery(value); + if (!value) { + return; + } + queryClient.invalidateQueries([QueryKeys.messages]); + clearConvoState(); + }, + [queryClient, clearConvoState, setSearchQuery], + ); const debouncedSendRequest = useMemo(() => debounce(sendRequest, 350), [sendRequest]); const onChange = (e: React.FormEvent) => { diff --git a/client/src/components/Nav/Settings.tsx b/client/src/components/Nav/Settings.tsx index 83ec09fb224..45d4e0e2b57 100644 --- a/client/src/components/Nav/Settings.tsx +++ b/client/src/components/Nav/Settings.tsx @@ -1,9 +1,10 @@ import * as Tabs from '@radix-ui/react-tabs'; +import { MessageSquare } from 'lucide-react'; import { SettingsTabValues } from 'librechat-data-provider'; import type { TDialogProps } from '~/common'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '~/components/ui'; import { GearIcon, DataIcon, UserIcon, ExperimentIcon } from '~/components/svg'; -import { General, Beta, Data, Account } from './SettingsTabs'; +import { General, Messages, Beta, Data, Account } from './SettingsTabs'; import { useMediaQuery, useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -15,8 +16,8 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { @@ -24,19 +25,19 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { {localize('com_nav_settings')} -
+
@@ -54,6 +55,20 @@ export default function Settings({ open, onOpenChange }: TDialogProps) { {localize('com_nav_setting_general')} + + + {localize('com_endpoint_messages')} + - - - - +
+ + + + + +
diff --git a/client/src/components/Nav/SettingsTabs/Account/Account.tsx b/client/src/components/Nav/SettingsTabs/Account/Account.tsx index c4d7c86fa46..73bf10f7f33 100644 --- a/client/src/components/Nav/SettingsTabs/Account/Account.tsx +++ b/client/src/components/Nav/SettingsTabs/Account/Account.tsx @@ -22,10 +22,10 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo
-
+
@@ -39,7 +39,7 @@ function Account({ onCheckedChange }: { onCheckedChange?: (value: boolean) => vo />
-
+
); } diff --git a/client/src/components/Nav/SettingsTabs/Beta/Beta.tsx b/client/src/components/Nav/SettingsTabs/Beta/Beta.tsx index 699def55745..869e8dcec5a 100644 --- a/client/src/components/Nav/SettingsTabs/Beta/Beta.tsx +++ b/client/src/components/Nav/SettingsTabs/Beta/Beta.tsx @@ -9,13 +9,13 @@ function Beta() {
-
+
-
+
diff --git a/client/src/components/Nav/SettingsTabs/DangerButton.tsx b/client/src/components/Nav/SettingsTabs/DangerButton.tsx index dcec128c95b..d43fd9a5862 100644 --- a/client/src/components/Nav/SettingsTabs/DangerButton.tsx +++ b/client/src/components/Nav/SettingsTabs/DangerButton.tsx @@ -40,7 +40,7 @@ const DangerButton = (props: TDangerButtonProps, ref: ForwardedRef diff --git a/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx new file mode 100644 index 00000000000..4fc2dadd8e0 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Data/ClearChats.tsx @@ -0,0 +1,29 @@ +import type { TDangerButtonProps } from '~/common'; +import DangerButton from '../DangerButton'; + +export const ClearChatsButton = ({ + confirmClear, + className = '', + showText = true, + mutation, + onClick, +}: Pick< + TDangerButtonProps, + 'confirmClear' | 'mutation' | 'className' | 'showText' | 'onClick' +>) => { + return ( + + ); +}; diff --git a/client/src/components/Nav/SettingsTabs/General/ClearChatsButton.spec.tsx b/client/src/components/Nav/SettingsTabs/Data/ClearChatsButton.spec.tsx similarity index 96% rename from client/src/components/Nav/SettingsTabs/General/ClearChatsButton.spec.tsx rename to client/src/components/Nav/SettingsTabs/Data/ClearChatsButton.spec.tsx index 14a2edfd918..fbf82fd9020 100644 --- a/client/src/components/Nav/SettingsTabs/General/ClearChatsButton.spec.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/ClearChatsButton.spec.tsx @@ -2,7 +2,7 @@ import 'test/matchMedia.mock'; import React from 'react'; import { render, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; -import { ClearChatsButton } from './General'; +import { ClearChatsButton } from './ClearChats'; import { RecoilRoot } from 'recoil'; describe('ClearChatsButton', () => { diff --git a/client/src/components/Nav/SettingsTabs/Data/Data.tsx b/client/src/components/Nav/SettingsTabs/Data/Data.tsx index 923843c93c7..65704d8c126 100644 --- a/client/src/components/Nav/SettingsTabs/Data/Data.tsx +++ b/client/src/components/Nav/SettingsTabs/Data/Data.tsx @@ -1,11 +1,14 @@ import * as Tabs from '@radix-ui/react-tabs'; import { - useRevokeAllUserKeysMutation, useRevokeUserKeyMutation, + useRevokeAllUserKeysMutation, + useClearConversationsMutation, } from 'librechat-data-provider/react-query'; import { SettingsTabValues } from 'librechat-data-provider'; import React, { useState, useCallback, useRef } from 'react'; -import { useOnClickOutside } from '~/hooks'; +import { useConversation, useConversations, useOnClickOutside } from '~/hooks'; +import ImportConversations from './ImportConversations'; +import { ClearChatsButton } from './ClearChats'; import DangerButton from '../DangerButton'; export const RevokeKeysButton = ({ @@ -19,42 +22,43 @@ export const RevokeKeysButton = ({ all?: boolean; disabled?: boolean; }) => { - const [confirmClear, setConfirmClear] = useState(false); - const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); + const [confirmRevoke, setConfirmRevoke] = useState(false); + const revokeKeysMutation = useRevokeAllUserKeysMutation(); + const revokeKeyMutation = useRevokeUserKeyMutation(endpoint); - const contentRef = useRef(null); - useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []); + const revokeContentRef = useRef(null); + useOnClickOutside(revokeContentRef, () => confirmRevoke && setConfirmRevoke(false), []); const revokeAllUserKeys = useCallback(() => { - if (confirmClear) { + if (confirmRevoke) { revokeKeysMutation.mutate({}); - setConfirmClear(false); + setConfirmRevoke(false); } else { - setConfirmClear(true); + setConfirmRevoke(true); } - }, [confirmClear, revokeKeysMutation]); + }, [confirmRevoke, revokeKeysMutation]); const revokeUserKey = useCallback(() => { if (!endpoint) { return; - } else if (confirmClear) { + } else if (confirmRevoke) { revokeKeyMutation.mutate({}); - setConfirmClear(false); + setConfirmRevoke(false); } else { - setConfirmClear(true); + setConfirmRevoke(true); } - }, [confirmClear, revokeKeyMutation, endpoint]); + }, [confirmRevoke, revokeKeyMutation, endpoint]); const onClick = all ? revokeAllUserKeys : revokeUserKey; return ( confirmClearConvos && setConfirmClearConvos(false), []); + + const { newConversation } = useConversation(); + const { refreshConversations } = useConversations(); + const clearConvosMutation = useClearConversationsMutation(); + + const clearConvos = () => { + if (confirmClearConvos) { + console.log('Clearing conversations...'); + setConfirmClearConvos(false); + clearConvosMutation.mutate( + {}, + { + onSuccess: () => { + newConversation(); + refreshConversations(); + }, + }, + ); + } else { + setConfirmClearConvos(true); + } + }; + return (
-
+
+ +
+
+ +
+ +
); diff --git a/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx new file mode 100644 index 00000000000..ae781dfd810 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Data/ImportConversations.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { Import } from 'lucide-react'; +import { useUploadConversationsMutation } from '~/data-provider'; +import { useLocalize, useConversations } from '~/hooks'; +import { useToastContext } from '~/Providers'; +import { Spinner } from '~/components/svg'; +import { cn } from '~/utils'; + +function ImportConversations() { + const localize = useLocalize(); + + const { showToast } = useToastContext(); + const [, setErrors] = useState([]); + const [allowImport, setAllowImport] = useState(true); + const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]); + const { refreshConversations } = useConversations(); + + const uploadFile = useUploadConversationsMutation({ + onSuccess: () => { + refreshConversations(); + showToast({ message: localize('com_ui_import_conversation_success') }); + setAllowImport(true); + }, + onError: (error) => { + console.error('Error: ', error); + setAllowImport(true); + setError( + (error as { response: { data: { message?: string } } })?.response?.data?.message ?? + 'An error occurred while uploading the file.', + ); + if (error?.toString().includes('Unsupported import type')) { + showToast({ + message: localize('com_ui_import_conversation_file_type_error'), + status: 'error', + }); + } else { + showToast({ message: localize('com_ui_import_conversation_error'), status: 'error' }); + } + }, + onMutate: () => { + setAllowImport(false); + }, + }); + + const startUpload = async (file: File) => { + const formData = new FormData(); + formData.append('file', file, encodeURIComponent(file?.name || 'File')); + + uploadFile.mutate(formData); + }; + + const handleFiles = async (_file: File) => { + /* Process files */ + try { + await startUpload(_file); + } catch (error) { + console.log('file handling error', error); + setError('An error occurred while processing the file.'); + } + }; + + const handleFileChange = (event) => { + const file = event.target.files[0]; + if (file) { + handleFiles(file); + } + }; + + return ( +
+ {localize('com_ui_import_conversation_info')} + +
+ ); +} + +export default ImportConversations; diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx new file mode 100644 index 00000000000..d033e0ddb57 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChats.tsx @@ -0,0 +1,29 @@ +import { useLocalize } from '~/hooks'; +import { Dialog, DialogTrigger } from '~/components/ui'; +import DialogTemplate from '~/components/ui/DialogTemplate'; + +import ArchivedChatsTable from './ArchivedChatsTable'; + +export default function ArchivedChats() { + const localize = useLocalize(); + + return ( +
+
{localize('com_nav_archived_chats')}
+ + + + + + } + /> + +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx new file mode 100644 index 00000000000..cff93b92eb2 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/General/ArchivedChatsTable.tsx @@ -0,0 +1,112 @@ +import { useMemo, useState } from 'react'; +import { MessageCircle, ArchiveRestore } from 'lucide-react'; +import { useConversationsInfiniteQuery } from '~/data-provider'; +import { useAuthContext, useLocalize, useNavScrolling } from '~/hooks'; +import ArchiveButton from '~/components/Conversations/ArchiveButton'; +import DeleteButton from '~/components/Conversations/DeleteButton'; +import { Spinner } from '~/components/svg'; +import { cn } from '~/utils'; + +export default function ArchivedChatsTable({ className }: { className?: string }) { + const localize = useLocalize(); + const { isAuthenticated } = useAuthContext(); + const [showLoading, setShowLoading] = useState(false); + + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useConversationsInfiniteQuery( + { pageNumber: '1', isArchived: true }, + { enabled: isAuthenticated }, + ); + + const { containerRef, moveToTop } = useNavScrolling({ + setShowLoading, + hasNextPage: hasNextPage, + fetchNextPage: fetchNextPage, + isFetchingNextPage: isFetchingNextPage, + }); + + const conversations = useMemo( + () => data?.pages.flatMap((page) => page.conversations) || [], + [data], + ); + + const classProp: { className?: string } = { + className: 'p-1 hover:text-black dark:hover:text-white', + }; + if (className) { + classProp.className = className; + } + + if (!conversations || conversations.length === 0) { + return
{localize('com_nav_archived_chats_empty')}
; + } + + return ( +
+ + + + + + + + + {conversations.map((conversation) => ( + + + + + ))} + +
{localize('com_nav_archive_name')}{localize('com_nav_archive_created_at')}
+ + {conversation.title} + +
+
+ {new Date(conversation.createdAt).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + })} +
+
+ {conversation.conversationId && ( + <> + } + /> +
+ +
+ + )} +
+
+
+ {(isFetchingNextPage || showLoading) && ( + + )} +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/General/General.tsx b/client/src/components/Nav/SettingsTabs/General/General.tsx index 6cadf89e424..62fe0a94b5f 100644 --- a/client/src/components/Nav/SettingsTabs/General/General.tsx +++ b/client/src/components/Nav/SettingsTabs/General/General.tsx @@ -1,23 +1,15 @@ import { useRecoilState } from 'recoil'; import * as Tabs from '@radix-ui/react-tabs'; import { SettingsTabValues } from 'librechat-data-provider'; -import React, { useState, useContext, useCallback, useRef } from 'react'; -import { useClearConversationsMutation } from 'librechat-data-provider/react-query'; -import { - ThemeContext, - useLocalize, - useOnClickOutside, - useConversation, - useConversations, - useLocalStorage, -} from '~/hooks'; +import React, { useContext, useCallback, useRef } from 'react'; import type { TDangerButtonProps } from '~/common'; +import { ThemeContext, useLocalize, useLocalStorage } from '~/hooks'; import HideSidePanelSwitch from './HideSidePanelSwitch'; import AutoScrollSwitch from './AutoScrollSwitch'; -import ShowCodeSwitch from './ShowCodeSwitch'; import { Dropdown } from '~/components/ui'; import DangerButton from '../DangerButton'; import store from '~/store'; +import ArchivedChats from './ArchivedChats'; export const ThemeSelector = ({ theme, @@ -41,7 +33,9 @@ export const ThemeSelector = ({ value={theme} onChange={onChange} options={themeOptions} - width={150} + width={220} + position={'left'} + maxHeight="200px" testId="theme-selector" />
@@ -111,40 +105,24 @@ export const LangSelector = ({ return (
{localize('com_nav_language')}
- +
); }; function General() { const { theme, setTheme } = useContext(ThemeContext); - const clearConvosMutation = useClearConversationsMutation(); - const [confirmClear, setConfirmClear] = useState(false); + const [langcode, setLangcode] = useRecoilState(store.lang); const [selectedLang, setSelectedLang] = useLocalStorage('selectedLang', langcode); - const { newConversation } = useConversation(); - const { refreshConversations } = useConversations(); const contentRef = useRef(null); - useOnClickOutside(contentRef, () => confirmClear && setConfirmClear(false), []); - - const clearConvos = () => { - if (confirmClear) { - console.log('Clearing conversations...'); - setConfirmClear(false); - clearConvosMutation.mutate( - {}, - { - onSuccess: () => { - newConversation(); - refreshConversations(); - }, - }, - ); - } else { - setConfirmClear(true); - } - }; const changeTheme = useCallback( (value: string) => { @@ -172,35 +150,27 @@ function General() {
-
+
-
+
- -
+
-
- -
-
+
- {/* Clear Chats should be last */} -
- +
+
+ {/*
+
*/}
); diff --git a/client/src/components/Nav/SettingsTabs/Messages/EnterToSend.tsx b/client/src/components/Nav/SettingsTabs/Messages/EnterToSend.tsx new file mode 100644 index 00000000000..1ff0caaee21 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Messages/EnterToSend.tsx @@ -0,0 +1,33 @@ +import { useRecoilState } from 'recoil'; +import { Switch } from '~/components/ui/Switch'; +import useLocalize from '~/hooks/useLocalize'; +import store from '~/store'; + +export default function SendMessageKeyEnter({ + onCheckedChange, +}: { + onCheckedChange?: (value: boolean) => void; +}) { + const [enterToSend, setEnterToSend] = useRecoilState(store.enterToSend); + const localize = useLocalize(); + + const handleCheckedChange = (value: boolean) => { + setEnterToSend(value); + if (onCheckedChange) { + onCheckedChange(value); + } + }; + + return ( +
+
{localize('com_nav_enter_to_send')}
+ +
+ ); +} diff --git a/client/src/components/Nav/SettingsTabs/Messages/ForkSettings.tsx b/client/src/components/Nav/SettingsTabs/Messages/ForkSettings.tsx new file mode 100644 index 00000000000..eb19532e428 --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Messages/ForkSettings.tsx @@ -0,0 +1,61 @@ +import { useRecoilState } from 'recoil'; +import { ForkOptions } from 'librechat-data-provider'; +import { Dropdown, Switch } from '~/components/ui'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +export const ForkSettings = () => { + const localize = useLocalize(); + const [forkSetting, setForkSetting] = useRecoilState(store.forkSetting); + const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget); + const [remember, setRemember] = useRecoilState(store.rememberForkOption); + + const forkOptions = [ + { value: ForkOptions.DIRECT_PATH, display: localize('com_ui_fork_visible') }, + { value: ForkOptions.INCLUDE_BRANCHES, display: localize('com_ui_fork_branches') }, + { value: ForkOptions.TARGET_LEVEL, display: localize('com_ui_fork_all_target') }, + ]; + + return ( + <> +
+
+
{localize('com_ui_fork_change_default')}
+ +
+
+
+
+
{localize('com_ui_fork_default')}
+ +
+
+
+
+
{localize('com_ui_fork_split_target_setting')}
+ +
+
+ + ); +}; diff --git a/client/src/components/Nav/SettingsTabs/Messages/Messages.tsx b/client/src/components/Nav/SettingsTabs/Messages/Messages.tsx new file mode 100644 index 00000000000..85ec7b09f1e --- /dev/null +++ b/client/src/components/Nav/SettingsTabs/Messages/Messages.tsx @@ -0,0 +1,24 @@ +import { memo } from 'react'; +import * as Tabs from '@radix-ui/react-tabs'; +import { SettingsTabValues } from 'librechat-data-provider'; +import SendMessageKeyEnter from './EnterToSend'; +import ShowCodeSwitch from './ShowCodeSwitch'; +import { ForkSettings } from './ForkSettings'; + +function Messages() { + return ( + +
+
+ +
+
+ +
+ +
+
+ ); +} + +export default memo(Messages); diff --git a/client/src/components/Nav/SettingsTabs/General/ShowCodeSwitch.tsx b/client/src/components/Nav/SettingsTabs/Messages/ShowCodeSwitch.tsx similarity index 100% rename from client/src/components/Nav/SettingsTabs/General/ShowCodeSwitch.tsx rename to client/src/components/Nav/SettingsTabs/Messages/ShowCodeSwitch.tsx diff --git a/client/src/components/Nav/SettingsTabs/index.ts b/client/src/components/Nav/SettingsTabs/index.ts index 71e8c36c198..df008bc8cf6 100644 --- a/client/src/components/Nav/SettingsTabs/index.ts +++ b/client/src/components/Nav/SettingsTabs/index.ts @@ -1,4 +1,5 @@ export { default as General } from './General/General'; +export { default as Messages } from './Messages/Messages'; export { ClearChatsButton } from './General/General'; export { default as Data } from './Data/Data'; export { default as Beta } from './Beta/Beta'; diff --git a/client/src/components/Plugins/Store/PluginStoreDialog.tsx b/client/src/components/Plugins/Store/PluginStoreDialog.tsx index aa61a4822dc..3bc48521635 100644 --- a/client/src/components/Plugins/Store/PluginStoreDialog.tsx +++ b/client/src/components/Plugins/Store/PluginStoreDialog.tsx @@ -1,13 +1,16 @@ import { Search, X } from 'lucide-react'; import { Dialog } from '@headlessui/react'; -import { useState, useEffect } from 'react'; -import { - useAvailablePluginsQuery, - useUpdateUserPluginsMutation, -} from 'librechat-data-provider/react-query'; -import type { TError, TPluginAction } from 'librechat-data-provider'; +import { useState, useEffect, useCallback } from 'react'; +import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query'; +import type { TError, TPlugin, TPluginAction } from 'librechat-data-provider'; import type { TPluginStoreDialogProps } from '~/common/types'; -import { useLocalize, usePluginDialogHelpers, useSetIndexOptions, useAuthContext } from '~/hooks'; +import { + usePluginDialogHelpers, + useSetIndexOptions, + usePluginInstall, + useAuthContext, + useLocalize, +} from '~/hooks'; import PluginPagination from './PluginPagination'; import PluginStoreItem from './PluginStoreItem'; import PluginAuthForm from './PluginAuthForm'; @@ -16,7 +19,6 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) { const localize = useLocalize(); const { user } = useAuthContext(); const { data: availablePlugins } = useAvailablePluginsQuery(); - const updateUserPlugins = useUpdateUserPluginsMutation(); const { setTools } = useSetIndexOptions(); const [userPlugins, setUserPlugins] = useState([]); @@ -44,50 +46,49 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) { setSelectedPlugin, } = usePluginDialogHelpers(); - const handleInstallError = (error: TError) => { - setError(true); - if (error.response?.data?.message) { - setErrorMessage(error.response?.data?.message); - } - setTimeout(() => { - setError(false); - setErrorMessage(''); - }, 5000); - }; + const handleInstallError = useCallback( + (error: TError) => { + setError(true); + if (error.response?.data?.message) { + setErrorMessage(error.response?.data?.message); + } + setTimeout(() => { + setError(false); + setErrorMessage(''); + }, 5000); + }, + [setError, setErrorMessage], + ); - const handleInstall = (pluginAction: TPluginAction) => { - updateUserPlugins.mutate(pluginAction, { - onError: (error: unknown) => { - handleInstallError(error as TError); - }, - }); - setShowPluginAuthForm(false); - }; + const { installPlugin, uninstallPlugin } = usePluginInstall({ + onInstallError: handleInstallError, + onUninstallError: handleInstallError, + onUninstallSuccess: (_data, variables) => { + setTools(variables.pluginKey, true); + }, + }); - const onPluginUninstall = (plugin: string) => { - updateUserPlugins.mutate( - { pluginKey: plugin, action: 'uninstall', auth: null }, - { - onError: (error: unknown) => { - handleInstallError(error as TError); - }, - onSuccess: () => { - setTools(plugin, true); - }, - }, - ); + const handleInstall = (pluginAction: TPluginAction, plugin?: TPlugin) => { + if (!plugin) { + return; + } + installPlugin(pluginAction, plugin); + setShowPluginAuthForm(false); }; const onPluginInstall = (pluginKey: string) => { - const getAvailablePluginFromKey = availablePlugins?.find((p) => p.pluginKey === pluginKey); - setSelectedPlugin(getAvailablePluginFromKey); + const plugin = availablePlugins?.find((p) => p.pluginKey === pluginKey); + if (!plugin) { + return; + } + setSelectedPlugin(plugin); - const { authConfig, authenticated } = getAvailablePluginFromKey ?? {}; + const { authConfig, authenticated } = plugin ?? {}; if (authConfig && authConfig.length > 0 && !authenticated) { setShowPluginAuthForm(true); } else { - handleInstall({ pluginKey, action: 'install', auth: null }); + handleInstall({ pluginKey, action: 'install', auth: null }, plugin); } }; @@ -107,10 +108,17 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) { setSearchChanged(false); } } - - // Disabled due to state setters erroneously being flagged as dependencies - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [availablePlugins, itemsPerPage, user, searchValue, filteredPlugins, searchChanged]); + }, [ + availablePlugins, + itemsPerPage, + user, + searchValue, + filteredPlugins, + searchChanged, + setMaxPage, + setCurrentPage, + setSearchChanged, + ]); return ( handleInstall(installActionData)} + onSubmit={(action: TPluginAction) => handleInstall(action, selectedPlugin)} />
)} @@ -197,7 +205,7 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) { plugin={plugin} isInstalled={userPlugins.includes(plugin.pluginKey)} onInstall={() => onPluginInstall(plugin.pluginKey)} - onUninstall={() => onPluginUninstall(plugin.pluginKey)} + onUninstall={() => uninstallPlugin(plugin.pluginKey)} /> ))}
diff --git a/client/src/components/SidePanel/AssistantSwitcher.tsx b/client/src/components/SidePanel/AssistantSwitcher.tsx index f36d14a2e2c..8b9f5fc776f 100644 --- a/client/src/components/SidePanel/AssistantSwitcher.tsx +++ b/client/src/components/SidePanel/AssistantSwitcher.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo } from 'react'; import { Combobox } from '~/components/ui'; -import { EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider'; +import { EModelEndpoint, defaultOrderQuery, LocalStorageKeys } from 'librechat-data-provider'; import type { SwitcherProps } from '~/common'; import { useSetIndexOptions, useSelectAssistant, useLocalize } from '~/hooks'; import { useChatContext, useAssistantsMapContext } from '~/Providers'; @@ -25,8 +25,11 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) { useEffect(() => { if (!selectedAssistant && assistants && assistants.length && assistantMap) { const assistant_id = - localStorage.getItem(`assistant_id__${index}`) ?? assistants[0]?.id ?? ''; + localStorage.getItem(`${LocalStorageKeys.ASST_ID_PREFIX}${index}`) ?? + assistants[0]?.id ?? + ''; const assistant = assistantMap?.[assistant_id]; + if (!assistant) { return; } @@ -34,6 +37,7 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) { if (endpoint !== EModelEndpoint.assistants) { return; } + setOption('model')(assistant.model); setOption('assistant_id')(assistant_id); } diff --git a/client/src/components/SidePanel/Builder/AssistantAction.tsx b/client/src/components/SidePanel/Builder/AssistantAction.tsx index acf5232b21a..114dfdc21b3 100644 --- a/client/src/components/SidePanel/Builder/AssistantAction.tsx +++ b/client/src/components/SidePanel/Builder/AssistantAction.tsx @@ -15,7 +15,7 @@ export default function AssistantAction({ className="border-token-border-medium flex w-full rounded-lg border text-sm hover:cursor-pointer" >
{action.metadata.domain} diff --git a/client/src/components/SidePanel/Files/PanelFileCell.tsx b/client/src/components/SidePanel/Files/PanelFileCell.tsx index 7753452b0b6..4d5d02979c9 100644 --- a/client/src/components/SidePanel/Files/PanelFileCell.tsx +++ b/client/src/components/SidePanel/Files/PanelFileCell.tsx @@ -92,7 +92,7 @@ export default function PanelFileCell({ row }: { row: Row }) { return (
{fileType && } {file.filename} diff --git a/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx b/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx index 4b460703054..5722a9e5ec6 100644 --- a/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicCheckbox.tsx @@ -20,9 +20,10 @@ function DynamicCheckbox({ showDefault = true, labelCode, descriptionCode, + conversation, }: DynamicSettingProps) { const localize = useLocalize(); - const { conversation = { conversationId: null }, preset } = useChatContext(); + const { preset } = useChatContext(); const [inputValue, setInputValue] = useState(!!(defaultValue as boolean | undefined)); const selectedValue = useMemo(() => { diff --git a/client/src/components/SidePanel/Parameters/DynamicDropdown.tsx b/client/src/components/SidePanel/Parameters/DynamicDropdown.tsx index 97fb9577352..4e298fb872e 100644 --- a/client/src/components/SidePanel/Parameters/DynamicDropdown.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicDropdown.tsx @@ -22,9 +22,10 @@ function DynamicDropdown({ showDefault = true, labelCode, descriptionCode, + conversation, }: DynamicSettingProps) { const localize = useLocalize(); - const { conversation = { conversationId: null }, preset } = useChatContext(); + const { preset } = useChatContext(); const [inputValue, setInputValue] = useState(null); const selectedValue = useMemo(() => { diff --git a/client/src/components/SidePanel/Parameters/DynamicInput.tsx b/client/src/components/SidePanel/Parameters/DynamicInput.tsx index 5de5df53041..feccbae784e 100644 --- a/client/src/components/SidePanel/Parameters/DynamicInput.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicInput.tsx @@ -22,9 +22,10 @@ function DynamicInput({ labelCode, descriptionCode, placeholderCode, + conversation, }: DynamicSettingProps) { const localize = useLocalize(); - const { conversation = { conversationId: null }, preset } = useChatContext(); + const { preset } = useChatContext(); const [setInputValue, inputValue] = useDebouncedInput({ optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, diff --git a/client/src/components/SidePanel/Parameters/DynamicInputNumber.tsx b/client/src/components/SidePanel/Parameters/DynamicInputNumber.tsx new file mode 100644 index 00000000000..3807f2ed6fb --- /dev/null +++ b/client/src/components/SidePanel/Parameters/DynamicInputNumber.tsx @@ -0,0 +1,108 @@ +import { OptionTypes } from 'librechat-data-provider'; +import type { DynamicSettingProps } from 'librechat-data-provider'; +import type { ValueType } from '@rc-component/mini-decimal'; +import { Label, HoverCard, InputNumber, HoverCardTrigger } from '~/components/ui'; +import { useLocalize, useDebouncedInput, useParameterEffects } from '~/hooks'; +import { cn, defaultTextProps, optionText } from '~/utils'; +import { ESide } from '~/common'; +import { useChatContext } from '~/Providers'; +import OptionHover from './OptionHover'; + +function DynamicInputNumber({ + label, + settingKey, + defaultValue, + description, + columnSpan, + setOption, + optionType, + readonly = false, + showDefault = true, + labelCode, + descriptionCode, + placeholderCode, + placeholder, + conversation, + range, + className = '', + inputClassName = '', +}: DynamicSettingProps) { + const localize = useLocalize(); + const { preset } = useChatContext(); + + const [setInputValue, inputValue] = useDebouncedInput({ + optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, + initialValue: + optionType !== OptionTypes.Custom + ? (conversation?.[settingKey] as number) + : (defaultValue as number), + setter: () => ({}), + setOption, + }); + + useParameterEffects({ + preset, + settingKey, + defaultValue: typeof defaultValue === 'undefined' ? '' : defaultValue, + conversation, + inputValue, + setInputValue, + }); + + return ( +
+ + +
+ + +
+
+ {description && ( + + )} +
+
+ ); +} + +export default DynamicInputNumber; diff --git a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx index 275aaeffe54..365f07177a2 100644 --- a/client/src/components/SidePanel/Parameters/DynamicSlider.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicSlider.tsx @@ -23,9 +23,10 @@ function DynamicSlider({ includeInput = true, labelCode, descriptionCode, + conversation, }: DynamicSettingProps) { const localize = useLocalize(); - const { conversation = { conversationId: null }, preset } = useChatContext(); + const { preset } = useChatContext(); const isEnum = useMemo(() => !range && options && options.length > 0, [options, range]); const [setInputValue, inputValue] = useDebouncedInput({ diff --git a/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx b/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx index 08fb126be62..f069e899d2c 100644 --- a/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicSwitch.tsx @@ -19,9 +19,10 @@ function DynamicSwitch({ showDefault = true, labelCode, descriptionCode, + conversation, }: DynamicSettingProps) { const localize = useLocalize(); - const { conversation = { conversationId: null }, preset } = useChatContext(); + const { preset } = useChatContext(); const [inputValue, setInputValue] = useState(!!(defaultValue as boolean | undefined)); useParameterEffects({ preset, diff --git a/client/src/components/SidePanel/Parameters/DynamicTags.tsx b/client/src/components/SidePanel/Parameters/DynamicTags.tsx new file mode 100644 index 00000000000..a028a8aa56c --- /dev/null +++ b/client/src/components/SidePanel/Parameters/DynamicTags.tsx @@ -0,0 +1,193 @@ +// client/src/components/SidePanel/Parameters/DynamicTags.tsx +import { useState, useMemo, useCallback, useRef } from 'react'; +import { OptionTypes } from 'librechat-data-provider'; +import type { DynamicSettingProps } from 'librechat-data-provider'; +import { Label, Input, HoverCard, HoverCardTrigger, Tag } from '~/components/ui'; +import { useChatContext, useToastContext } from '~/Providers'; +import { useLocalize, useParameterEffects } from '~/hooks'; +import { cn, defaultTextProps } from '~/utils'; +import OptionHover from './OptionHover'; +import { ESide } from '~/common'; + +function DynamicTags({ + label, + settingKey, + defaultValue = [], + description, + columnSpan, + setOption, + optionType, + placeholder, + readonly = false, + showDefault = true, + labelCode, + descriptionCode, + placeholderCode, + descriptionSide = ESide.Left, + conversation, + minTags, + maxTags, +}: DynamicSettingProps) { + const localize = useLocalize(); + const { preset } = useChatContext(); + const { showToast } = useToastContext(); + const inputRef = useRef(null); + const [tagText, setTagText] = useState(''); + const [tags, setTags] = useState( + (defaultValue as string[] | undefined) ?? [], + ); + + const updateState = useCallback( + (update: string[]) => { + if (optionType === OptionTypes.Custom) { + // TODO: custom logic, add to payload but not to conversation + setTags(update); + return; + } + setOption(settingKey)(update); + }, + [optionType, setOption, settingKey], + ); + + const onTagClick = useCallback(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, [inputRef]); + + const currentTags: string[] | undefined = useMemo(() => { + if (optionType === OptionTypes.Custom) { + // TODO: custom logic, add to payload but not to conversation + return tags; + } + + if (!conversation?.[settingKey]) { + return defaultValue ?? []; + } + + return conversation?.[settingKey]; + }, [conversation, defaultValue, optionType, settingKey, tags]); + + const onTagRemove = useCallback( + (indexToRemove: number) => { + if (!currentTags) { + return; + } + + if (minTags && currentTags.length <= minTags) { + showToast({ + message: localize('com_ui_min_tags', minTags + ''), + status: 'warning', + }); + return; + } + const update = currentTags.filter((_, index) => index !== indexToRemove); + updateState(update); + }, + [localize, minTags, currentTags, showToast, updateState], + ); + + const onTagAdd = useCallback(() => { + if (!tagText) { + return; + } + + let update = [...(currentTags ?? []), tagText]; + if (maxTags && update.length > maxTags) { + showToast({ + message: localize('com_ui_max_tags', maxTags + ''), + status: 'warning', + }); + update = update.slice(-maxTags); + } + updateState(update); + setTagText(''); + }, [tagText, currentTags, updateState, maxTags, showToast, localize]); + + useParameterEffects({ + preset, + settingKey, + defaultValue: typeof defaultValue === 'undefined' ? [] : defaultValue, + inputValue: tags, + setInputValue: setTags, + preventDelayedUpdate: true, + conversation, + }); + + return ( +
+ + +
+ +
+
+
+ {currentTags?.map((tag: string, index: number) => ( + { + onTagRemove(index); + if (inputRef.current) { + inputRef.current.focus(); + } + }} + /> + ))} + { + if (!currentTags) { + return; + } + if (e.key === 'Backspace' && !tagText) { + onTagRemove(currentTags.length - 1); + } + if (e.key === 'Enter') { + onTagAdd(); + } + }} + onChange={(e) => setTagText(e.target.value)} + placeholder={ + placeholderCode ? localize(placeholder ?? '') || placeholder : placeholder + } + className={cn(defaultTextProps, 'flex h-10 max-h-10 px-3 py-2')} + /> +
+
+
+ {description && ( + + )} +
+
+ ); +} + +export default DynamicTags; diff --git a/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx b/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx index f2e1b70b803..f6411b155b2 100644 --- a/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx +++ b/client/src/components/SidePanel/Parameters/DynamicTextarea.tsx @@ -22,9 +22,10 @@ function DynamicTextarea({ labelCode, descriptionCode, placeholderCode, + conversation, }: DynamicSettingProps) { const localize = useLocalize(); - const { conversation = { conversationId: null }, preset } = useChatContext(); + const { preset } = useChatContext(); const [setInputValue, inputValue] = useDebouncedInput({ optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined, diff --git a/client/src/components/SidePanel/Parameters/OptionHover.tsx b/client/src/components/SidePanel/Parameters/OptionHover.tsx index a53c68844c0..1a3714a0dc4 100644 --- a/client/src/components/SidePanel/Parameters/OptionHover.tsx +++ b/client/src/components/SidePanel/Parameters/OptionHover.tsx @@ -6,15 +6,20 @@ import { ESide } from '~/common'; type TOptionHoverProps = { description: string; langCode?: boolean; + sideOffset?: number; side: ESide; }; -function OptionHover({ side, description, langCode }: TOptionHoverProps) { +function OptionHover({ side, description, langCode, sideOffset = 30 }: TOptionHoverProps) { const localize = useLocalize(); const text = langCode ? localize(description) : description; return ( - +

{text}

diff --git a/client/src/components/SidePanel/Parameters/Panel.tsx b/client/src/components/SidePanel/Parameters/Panel.tsx index bb22f1fea3b..f277da16926 100644 --- a/client/src/components/SidePanel/Parameters/Panel.tsx +++ b/client/src/components/SidePanel/Parameters/Panel.tsx @@ -5,12 +5,16 @@ import type { SettingsConfiguration, } from 'librechat-data-provider'; import { useSetIndexOptions } from '~/hooks'; -import DynamicDropdown from './DynamicDropdown'; -import DynamicCheckbox from './DynamicCheckbox'; -import DynamicTextarea from './DynamicTextarea'; -import DynamicSlider from './DynamicSlider'; -import DynamicSwitch from './DynamicSwitch'; -import DynamicInput from './DynamicInput'; +import { useChatContext } from '~/Providers'; +import { + DynamicDropdown, + DynamicCheckbox, + DynamicTextarea, + DynamicSlider, + DynamicSwitch, + DynamicInput, + DynamicTags, +} from './'; const settingsConfiguration: SettingsConfiguration = [ { @@ -129,6 +133,22 @@ const settingsConfiguration: SettingsConfiguration = [ showDefault: false, columnSpan: 2, }, + { + key: 'stop', + label: 'com_endpoint_stop', + labelCode: true, + description: 'com_endpoint_openai_stop', + descriptionCode: true, + placeholder: 'com_endpoint_stop_placeholder', + placeholderCode: true, + type: 'array', + default: [], + component: 'tags', + optionType: 'conversation', + columnSpan: 4, + minTags: 1, + maxTags: 4, + }, ]; const componentMapping: Record> = { @@ -138,9 +158,11 @@ const componentMapping: Record setting.key === 'stop') as SettingDefinition; + const Tags = componentMapping[stop.component]; + const { key: stopKey, default: stopDefault, ...stopSettings } = stop; + return (
@@ -184,30 +210,42 @@ export default function Parameters() { defaultValue={inputDefault} {...inputSettings} setOption={setOption} + conversation={conversation} />