Compare commits

..

1 Commits

Author SHA1 Message Date
Danny Avila
4ed22aaa59 feat: implement search parameter updates 2025-04-27 14:08:59 -04:00
175 changed files with 2739 additions and 2946 deletions

View File

@@ -142,12 +142,12 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# GOOGLE_MODELS=gemini-2.5-pro-exp-03-25,gemini-2.0-flash-exp,gemini-2.0-flash-thinking-exp-1219,gemini-exp-1121,gemini-exp-1114,gemini-1.5-flash-latest,gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
# Vertex AI
# GOOGLE_MODELS=gemini-2.5-pro-preview-05-06,gemini-2.5-flash-preview-04-17,gemini-2.0-flash-001,gemini-2.0-flash-exp,gemini-2.0-flash-lite-001,gemini-1.5-pro-002,gemini-1.5-flash-002
# GOOGLE_MODELS=gemini-1.5-flash-preview-0514,gemini-1.5-pro-preview-0514,gemini-1.0-pro-vision-001,gemini-1.0-pro-002,gemini-1.0-pro-001,gemini-pro-vision,gemini-1.0-pro
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-pro
# GOOGLE_LOC=us-central1

View File

@@ -3,7 +3,6 @@ name: Generate Unreleased Changelog PR
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at 00:00 UTC
workflow_dispatch:
jobs:
generate-unreleased-changelog-pr:

View File

@@ -39,35 +39,12 @@ jobs:
# Check if each key is used in the source code
for KEY in $KEYS; do
FOUND=false
# Special case for dynamically constructed special variable keys
if [[ "$KEY" == com_ui_special_var_* ]]; then
# Check if TSpecialVarLabel is used in the codebase
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then
FOUND=true
break
fi
done
# Also check if the key is directly used somewhere
if [[ "$FOUND" == false ]]; then
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
fi
done
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
fi
else
# Regular check for other keys
for DIR in "${SOURCE_DIRS[@]}"; do
if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
FOUND=true
break
fi
done
fi
done
if [[ "$FOUND" == false ]]; then
UNUSED_KEYS+=("$KEY")
@@ -113,4 +90,4 @@ jobs:
- name: Fail workflow if unused keys found
if: env.unused_keys != '[]'
run: exit 1
run: exit 1

View File

@@ -2,167 +2,15 @@
All notable changes to this project will be documented in this file.
## [Unreleased]
### 🔧 Fixes
- 🔧 fix: Google Gemma Support & OpenAI Reasoning Instructions by **@danny-avila** in [#7196](https://github.com/danny-avila/LibreChat/pull/7196)
- 🛠️ fix: Conversation Navigation State by **@danny-avila** in [#7210](https://github.com/danny-avila/LibreChat/pull/7210)
### ⚙️ Other Changes
- 📜 docs: CHANGELOG for release v0.7.8-rc1 by **@github-actions[bot]** in [#7153](https://github.com/danny-avila/LibreChat/pull/7153)
- 🔄 refactor: Artifact Visibility Management by **@danny-avila** in [#7181](https://github.com/danny-avila/LibreChat/pull/7181)
- 📦 chore: Bump Package Security by **@danny-avila** in [#7183](https://github.com/danny-avila/LibreChat/pull/7183)
- 🌿 refactor: Unmount Fork Popover on Hide for Better Performance by **@danny-avila** in [#7189](https://github.com/danny-avila/LibreChat/pull/7189)
- 🧰 chore: ESLint configuration to enforce Prettier formatting rules by **@mawburn** in [#7186](https://github.com/danny-avila/LibreChat/pull/7186)
---
## [v0.7.8-rc1] -
Changes from v0.7.7 to v0.7.8-rc1.
### ✨ New Features
- 🔍 feat: Mistral OCR API / Upload Files as Text by **@danny-avila** in [#6274](https://github.com/danny-avila/LibreChat/pull/6274)
- 🤖 feat: Support OpenAI Web Search models by **@danny-avila** in [#6313](https://github.com/danny-avila/LibreChat/pull/6313)
- 🔗 feat: Agent Chain (Mixture-of-Agents) by **@danny-avila** in [#6374](https://github.com/danny-avila/LibreChat/pull/6374)
- ⌛ feat: `initTimeout` for Slow Starting MCP Servers by **@perweij** in [#6383](https://github.com/danny-avila/LibreChat/pull/6383)
- 🚀 feat: `S3` Integration for File handling and Image uploads by **@rubentalstra** in [#6142](https://github.com/danny-avila/LibreChat/pull/6142)
- 🔒feat: Enable OpenID Auto-Redirect by **@leondape** in [#6066](https://github.com/danny-avila/LibreChat/pull/6066)
- 🚀 feat: Integrate `Azure Blob Storage` for file handling and image uploads by **@rubentalstra** in [#6153](https://github.com/danny-avila/LibreChat/pull/6153)
- 🚀 feat: Add support for custom `AWS` endpoint in `S3` by **@rubentalstra** in [#6431](https://github.com/danny-avila/LibreChat/pull/6431)
- 🚀 feat: Add support for LDAP STARTTLS in LDAP authentication by **@rubentalstra** in [#6438](https://github.com/danny-avila/LibreChat/pull/6438)
- 🚀 feat: Refactor schema exports and update package version to 0.0.4 by **@rubentalstra** in [#6455](https://github.com/danny-avila/LibreChat/pull/6455)
- 🔼 feat: Add Auto Submit For URL Query Params by **@mjaverto** in [#6440](https://github.com/danny-avila/LibreChat/pull/6440)
- 🛠 feat: Enhance Redis Integration, Rate Limiters & Log Headers by **@danny-avila** in [#6462](https://github.com/danny-avila/LibreChat/pull/6462)
- 💵 feat: Add Automatic Balance Refill by **@rubentalstra** in [#6452](https://github.com/danny-avila/LibreChat/pull/6452)
- 🗣️ feat: add support for gpt-4o-transcribe models by **@berry-13** in [#6483](https://github.com/danny-avila/LibreChat/pull/6483)
- 🎨 feat: UI Refresh for Enhanced UX by **@berry-13** in [#6346](https://github.com/danny-avila/LibreChat/pull/6346)
- 🌍 feat: Add support for Hungarian language localization by **@rubentalstra** in [#6508](https://github.com/danny-avila/LibreChat/pull/6508)
- 🚀 feat: Add Gemini 2.5 Token/Context Values, Increase Max Possible Output to 64k by **@danny-avila** in [#6563](https://github.com/danny-avila/LibreChat/pull/6563)
- 🚀 feat: Enhance MCP Connections For Multi-User Support by **@danny-avila** in [#6610](https://github.com/danny-avila/LibreChat/pull/6610)
- 🚀 feat: Enhance S3 URL Expiry with Refresh; fix: S3 File Deletion by **@danny-avila** in [#6647](https://github.com/danny-avila/LibreChat/pull/6647)
- 🚀 feat: enhance UI components and refactor settings by **@berry-13** in [#6625](https://github.com/danny-avila/LibreChat/pull/6625)
- 💬 feat: move TemporaryChat to the Header by **@berry-13** in [#6646](https://github.com/danny-avila/LibreChat/pull/6646)
- 🚀 feat: Use Model Specs + Specific Endpoints, Limit Providers for Agents by **@danny-avila** in [#6650](https://github.com/danny-avila/LibreChat/pull/6650)
- 🪙 feat: Sync Balance Config on Login by **@danny-avila** in [#6671](https://github.com/danny-avila/LibreChat/pull/6671)
- 🔦 feat: MCP Support for Non-Agent Endpoints by **@danny-avila** in [#6775](https://github.com/danny-avila/LibreChat/pull/6775)
- 🗃️ feat: Code Interpreter File Persistence between Sessions by **@danny-avila** in [#6790](https://github.com/danny-avila/LibreChat/pull/6790)
- 🖥️ feat: Code Interpreter API for Non-Agent Endpoints by **@danny-avila** in [#6803](https://github.com/danny-avila/LibreChat/pull/6803)
- ⚡ feat: Self-hosted Artifacts Static Bundler URL by **@danny-avila** in [#6827](https://github.com/danny-avila/LibreChat/pull/6827)
- 🐳 feat: Add Jemalloc and UV to Docker Builds by **@danny-avila** in [#6836](https://github.com/danny-avila/LibreChat/pull/6836)
- 🤖 feat: GPT-4.1 by **@danny-avila** in [#6880](https://github.com/danny-avila/LibreChat/pull/6880)
- 👋 feat: remove Edge TTS by **@berry-13** in [#6885](https://github.com/danny-avila/LibreChat/pull/6885)
- feat: nav optimization by **@berry-13** in [#5785](https://github.com/danny-avila/LibreChat/pull/5785)
- 🗺️ feat: Add Parameter Location Mapping for OpenAPI actions by **@peeeteeer** in [#6858](https://github.com/danny-avila/LibreChat/pull/6858)
- 🤖 feat: Support `o4-mini` and `o3` Models by **@danny-avila** in [#6928](https://github.com/danny-avila/LibreChat/pull/6928)
- 🎨 feat: OpenAI Image Tools (GPT-Image-1) by **@danny-avila** in [#7079](https://github.com/danny-avila/LibreChat/pull/7079)
- 🗓️ feat: Add Special Variables for Prompts & Agents, Prompt UI Improvements by **@danny-avila** in [#7123](https://github.com/danny-avila/LibreChat/pull/7123)
### 🌍 Internationalization
- 🌍 i18n: Add Thai Language Support and Update Translations by **@rubentalstra** in [#6219](https://github.com/danny-avila/LibreChat/pull/6219)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6220](https://github.com/danny-avila/LibreChat/pull/6220)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6240](https://github.com/danny-avila/LibreChat/pull/6240)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6241](https://github.com/danny-avila/LibreChat/pull/6241)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6277](https://github.com/danny-avila/LibreChat/pull/6277)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6414](https://github.com/danny-avila/LibreChat/pull/6414)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6505](https://github.com/danny-avila/LibreChat/pull/6505)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6530](https://github.com/danny-avila/LibreChat/pull/6530)
- 🌍 i18n: Add Persian Localization Support by **@rubentalstra** in [#6669](https://github.com/danny-avila/LibreChat/pull/6669)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#6667](https://github.com/danny-avila/LibreChat/pull/6667)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7126](https://github.com/danny-avila/LibreChat/pull/7126)
- 🌍 i18n: Update translation.json with latest translations by **@github-actions[bot]** in [#7148](https://github.com/danny-avila/LibreChat/pull/7148)
### 👐 Accessibility
- 🎨 a11y: Update Model Spec Description Text by **@berry-13** in [#6294](https://github.com/danny-avila/LibreChat/pull/6294)
- 🗑️ a11y: Add Accessible Name to Button for File Attachment Removal by **@kangabell** in [#6709](https://github.com/danny-avila/LibreChat/pull/6709)
- ⌨️ a11y: enhance accessibility & visual consistency by **@berry-13** in [#6866](https://github.com/danny-avila/LibreChat/pull/6866)
- 🙌 a11y: Searchbar/Conversations List Focus by **@danny-avila** in [#7096](https://github.com/danny-avila/LibreChat/pull/7096)
- 👐 a11y: Improve Fork and SplitText Accessibility by **@danny-avila** in [#7147](https://github.com/danny-avila/LibreChat/pull/7147)
### 🔧 Fixes
- 🐛 fix: Avatar Type Definitions in Agent/Assistant Schemas by **@danny-avila** in [#6235](https://github.com/danny-avila/LibreChat/pull/6235)
- 🔧 fix: MeiliSearch Field Error and Patch Incorrect Import by #6210 by **@rubentalstra** in [#6245](https://github.com/danny-avila/LibreChat/pull/6245)
- 🔏 fix: Enhance Two-Factor Authentication by **@rubentalstra** in [#6247](https://github.com/danny-avila/LibreChat/pull/6247)
- 🐛 fix: Await saveMessage in abortMiddleware to ensure proper execution by **@sh4shii** in [#6248](https://github.com/danny-avila/LibreChat/pull/6248)
- 🔧 fix: Axios Proxy Usage And Bump `mongoose` by **@danny-avila** in [#6298](https://github.com/danny-avila/LibreChat/pull/6298)
- 🔧 fix: comment out MCP servers to resolve service run issues by **@KunalScriptz** in [#6316](https://github.com/danny-avila/LibreChat/pull/6316)
- 🔧 fix: Update Token Calculations and Mapping, MCP `env` Initialization by **@danny-avila** in [#6406](https://github.com/danny-avila/LibreChat/pull/6406)
- 🐞 fix: Agent "Resend" Message Attachments + Source Icon Styling by **@danny-avila** in [#6408](https://github.com/danny-avila/LibreChat/pull/6408)
- 🐛 fix: Prevent Crash on Duplicate Message ID by **@Odrec** in [#6392](https://github.com/danny-avila/LibreChat/pull/6392)
- 🔐 fix: Invalid Key Length in 2FA Encryption by **@rubentalstra** in [#6432](https://github.com/danny-avila/LibreChat/pull/6432)
- 🏗️ fix: Fix Agents Token Spend Race Conditions, Expand Test Coverage by **@danny-avila** in [#6480](https://github.com/danny-avila/LibreChat/pull/6480)
- 🔃 fix: Draft Clearing, Claude Titles, Remove Default Vision Max Tokens by **@danny-avila** in [#6501](https://github.com/danny-avila/LibreChat/pull/6501)
- 🔧 fix: Update username reference to use user.name in greeting display by **@rubentalstra** in [#6534](https://github.com/danny-avila/LibreChat/pull/6534)
- 🔧 fix: S3 Download Stream with Key Extraction and Blob Storage Encoding for Vision by **@danny-avila** in [#6557](https://github.com/danny-avila/LibreChat/pull/6557)
- 🔧 fix: Mistral type strictness for `usage` & update token values/windows by **@danny-avila** in [#6562](https://github.com/danny-avila/LibreChat/pull/6562)
- 🔧 fix: Consolidate Text Parsing and TTS Edge Initialization by **@danny-avila** in [#6582](https://github.com/danny-avila/LibreChat/pull/6582)
- 🔧 fix: Ensure continuation in image processing on base64 encoding from Blob Storage by **@danny-avila** in [#6619](https://github.com/danny-avila/LibreChat/pull/6619)
- ✉️ fix: Fallback For User Name In Email Templates by **@danny-avila** in [#6620](https://github.com/danny-avila/LibreChat/pull/6620)
- 🔧 fix: Azure Blob Integration and File Source References by **@rubentalstra** in [#6575](https://github.com/danny-avila/LibreChat/pull/6575)
- 🐛 fix: Safeguard against undefined addedEndpoints by **@wipash** in [#6654](https://github.com/danny-avila/LibreChat/pull/6654)
- 🤖 fix: Gemini 2.5 Vision Support by **@danny-avila** in [#6663](https://github.com/danny-avila/LibreChat/pull/6663)
- 🔄 fix: Avatar & Error Handling Enhancements by **@danny-avila** in [#6687](https://github.com/danny-avila/LibreChat/pull/6687)
- 🔧 fix: Chat Middleware, Zod Conversion, Auto-Save and S3 URL Refresh by **@danny-avila** in [#6720](https://github.com/danny-avila/LibreChat/pull/6720)
- 🔧 fix: Agent Capability Checks & DocumentDB Compatibility for Agent Resource Removal by **@danny-avila** in [#6726](https://github.com/danny-avila/LibreChat/pull/6726)
- 🔄 fix: Improve audio MIME type detection and handling by **@berry-13** in [#6707](https://github.com/danny-avila/LibreChat/pull/6707)
- 🪺 fix: Update Role Handling due to New Schema Shape by **@danny-avila** in [#6774](https://github.com/danny-avila/LibreChat/pull/6774)
- 🗨️ fix: Show ModelSpec Greeting by **@berry-13** in [#6770](https://github.com/danny-avila/LibreChat/pull/6770)
- 🔧 fix: Keyv and Proxy Issues, and More Memory Optimizations by **@danny-avila** in [#6867](https://github.com/danny-avila/LibreChat/pull/6867)
- ✨ fix: Implement dynamic text sizing for greeting and name display by **@berry-13** in [#6833](https://github.com/danny-avila/LibreChat/pull/6833)
- 📝 fix: Mistral OCR Image Support and Azure Agent Titles by **@danny-avila** in [#6901](https://github.com/danny-avila/LibreChat/pull/6901)
- 📢 fix: Invalid `engineTTS` and Conversation State on Navigation by **@berry-13** in [#6904](https://github.com/danny-avila/LibreChat/pull/6904)
- 🛠️ fix: Improve Accessibility and Display of Conversation Menu by **@danny-avila** in [#6913](https://github.com/danny-avila/LibreChat/pull/6913)
- 🔧 fix: Agent Resource Form, Convo Menu Style, Ensure Draft Clears on Submission by **@danny-avila** in [#6925](https://github.com/danny-avila/LibreChat/pull/6925)
- 🔀 fix: MCP Improvements, Auto-Save Drafts, Artifact Markup by **@danny-avila** in [#7040](https://github.com/danny-avila/LibreChat/pull/7040)
- 🐋 fix: Improve Deepseek Compatbility by **@danny-avila** in [#7132](https://github.com/danny-avila/LibreChat/pull/7132)
- 🐙 fix: Add Redis Ping Interval to Prevent Connection Drops by **@peeeteeer** in [#7127](https://github.com/danny-avila/LibreChat/pull/7127)
- 🪄 feat: Agent Artifacts by **@danny-avila** in [#5804](https://github.com/danny-avila/LibreChat/pull/5804)
### ⚙️ Other Changes
- 📦 refactor: Move DB Models to `@librechat/data-schemas` by **@rubentalstra** in [#6210](https://github.com/danny-avila/LibreChat/pull/6210)
- 📦 chore: Patch `axios` to address CVE-2025-27152 by **@danny-avila** in [#6222](https://github.com/danny-avila/LibreChat/pull/6222)
- ⚠️ refactor: Use Error Content Part Instead Of Throwing Error for Agents by **@danny-avila** in [#6262](https://github.com/danny-avila/LibreChat/pull/6262)
- 🏃‍♂️ refactor: Improve Agent Run Context & Misc. Changes by **@danny-avila** in [#6448](https://github.com/danny-avila/LibreChat/pull/6448)
- 📝 docs: librechat.example.yaml by **@ineiti** in [#6442](https://github.com/danny-avila/LibreChat/pull/6442)
- 🏃‍♂️ refactor: More Agent Context Improvements during Run by **@danny-avila** in [#6477](https://github.com/danny-avila/LibreChat/pull/6477)
- 🔃 refactor: Allow streaming for `o1` models by **@danny-avila** in [#6509](https://github.com/danny-avila/LibreChat/pull/6509)
- 🔧 chore: `Vite` Plugin Upgrades & Config Optimizations by **@rubentalstra** in [#6547](https://github.com/danny-avila/LibreChat/pull/6547)
- 🔧 refactor: Consolidate Logging, Model Selection & Actions Optimizations, Minor Fixes by **@danny-avila** in [#6553](https://github.com/danny-avila/LibreChat/pull/6553)
- 🎨 style: Address Minor UI Refresh Issues by **@berry-13** in [#6552](https://github.com/danny-avila/LibreChat/pull/6552)
- 🔧 refactor: Enhance Model & Endpoint Configurations with Global Indicators 🌍 by **@berry-13** in [#6578](https://github.com/danny-avila/LibreChat/pull/6578)
- 💬 style: Chat UI, Greeting, and Message adjustments by **@berry-13** in [#6612](https://github.com/danny-avila/LibreChat/pull/6612)
- ⚡ refactor: DocumentDB Compatibility for Balance Updates by **@danny-avila** in [#6673](https://github.com/danny-avila/LibreChat/pull/6673)
- 🧹 chore: Update ESLint rules for React hooks by **@rubentalstra** in [#6685](https://github.com/danny-avila/LibreChat/pull/6685)
- 🪙 chore: Update Gemini Pricing by **@RedwindA** in [#6731](https://github.com/danny-avila/LibreChat/pull/6731)
- 🪺 refactor: Nest Permission fields for Roles by **@rubentalstra** in [#6487](https://github.com/danny-avila/LibreChat/pull/6487)
- 📦 chore: Update `caniuse-lite` dependency to version 1.0.30001706 by **@rubentalstra** in [#6482](https://github.com/danny-avila/LibreChat/pull/6482)
- ⚙️ refactor: OAuth Flow Signal, Type Safety, Tool Progress & Updated Packages by **@danny-avila** in [#6752](https://github.com/danny-avila/LibreChat/pull/6752)
- 📦 chore: bump vite from 6.2.3 to 6.2.5 by **@dependabot[bot]** in [#6745](https://github.com/danny-avila/LibreChat/pull/6745)
- 💾 chore: Enhance Local Storage Handling and Update MCP SDK by **@danny-avila** in [#6809](https://github.com/danny-avila/LibreChat/pull/6809)
- 🤖 refactor: Improve Agents Memory Usage, Bump Keyv, Grok 3 by **@danny-avila** in [#6850](https://github.com/danny-avila/LibreChat/pull/6850)
- 💾 refactor: Enhance Memory In Image Encodings & Client Disposal by **@danny-avila** in [#6852](https://github.com/danny-avila/LibreChat/pull/6852)
- 🔁 refactor: Token Event Handler and Standardize `maxTokens` Key by **@danny-avila** in [#6886](https://github.com/danny-avila/LibreChat/pull/6886)
- 🔍 refactor: Search & Message Retrieval by **@berry-13** in [#6903](https://github.com/danny-avila/LibreChat/pull/6903)
- 🎨 style: standardize dropdown styling & fix z-Index layering by **@berry-13** in [#6939](https://github.com/danny-avila/LibreChat/pull/6939)
- 📙 docs: CONTRIBUTING.md by **@dblock** in [#6831](https://github.com/danny-avila/LibreChat/pull/6831)
- 🧭 refactor: Modernize Nav/Header by **@danny-avila** in [#7094](https://github.com/danny-avila/LibreChat/pull/7094)
- 🪶 refactor: Chat Input Focus for Conversation Navigations & ChatForm Optimizations by **@danny-avila** in [#7100](https://github.com/danny-avila/LibreChat/pull/7100)
- 🔃 refactor: Streamline Navigation, Message Loading UX by **@danny-avila** in [#7118](https://github.com/danny-avila/LibreChat/pull/7118)
- 📜 docs: Unreleased changelog by **@github-actions[bot]** in [#6265](https://github.com/danny-avila/LibreChat/pull/6265)
[See full release details][release-v0.7.8-rc1]
[release-v0.7.8-rc1]: https://github.com/danny-avila/LibreChat/releases/tag/v0.7.8-rc1
- 🔄 chore: Enforce 18next Language Keys by **@rubentalstra** in [#5803](https://github.com/danny-avila/LibreChat/pull/5803)
- 🔃 refactor: Parent Message ID Handling on Error, Update Translations, Bump Agents by **@danny-avila** in [#5833](https://github.com/danny-avila/LibreChat/pull/5833)
---

View File

@@ -1,11 +1,10 @@
# v0.7.8
# v0.7.7
# Base node image
FROM node:20-alpine AS node
# Install jemalloc
RUN apk add --no-cache jemalloc
RUN apk add --no-cache python3 py3-pip uv
# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2

View File

@@ -1,5 +1,5 @@
# Dockerfile.multi
# v0.7.8
# v0.7.7
# Base for all builds
FROM node:20-alpine AS base-min

View File

@@ -396,13 +396,13 @@ class AnthropicClient extends BaseClient {
const formattedMessages = orderedMessages.map((message, i) => {
const formattedMessage = this.useMessages
? formatMessage({
message,
endpoint: EModelEndpoint.anthropic,
})
message,
endpoint: EModelEndpoint.anthropic,
})
: {
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
content: message?.content ?? message.text,
};
author: message.isCreatedByUser ? this.userLabel : this.assistantLabel,
content: message?.content ?? message.text,
};
const needsTokenCount = this.contextStrategy && !orderedMessages[i].tokenCount;
/* If tokens were never counted, or, is a Vision request and the message has files, count again */
@@ -680,7 +680,7 @@ class AnthropicClient extends BaseClient {
}
getCompletion() {
logger.debug("AnthropicClient doesn't use getCompletion (all handled in sendCompletion)");
logger.debug('AnthropicClient doesn\'t use getCompletion (all handled in sendCompletion)');
}
/**
@@ -888,7 +888,7 @@ class AnthropicClient extends BaseClient {
}
getBuildMessagesOptions() {
logger.debug("AnthropicClient doesn't use getBuildMessagesOptions");
logger.debug('AnthropicClient doesn\'t use getBuildMessagesOptions');
}
getEncoding() {

View File

@@ -63,15 +63,15 @@ class BaseClient {
}
setOptions() {
throw new Error("Method 'setOptions' must be implemented.");
throw new Error('Method \'setOptions\' must be implemented.');
}
async getCompletion() {
throw new Error("Method 'getCompletion' must be implemented.");
throw new Error('Method \'getCompletion\' must be implemented.');
}
async sendCompletion() {
throw new Error("Method 'sendCompletion' must be implemented.");
throw new Error('Method \'sendCompletion\' must be implemented.');
}
getSaveOptions() {
@@ -237,11 +237,11 @@ class BaseClient {
const userMessage = opts.isEdited
? this.currentMessages[this.currentMessages.length - 2]
: this.createUserMessage({
messageId: userMessageId,
parentMessageId,
conversationId,
text: message,
});
messageId: userMessageId,
parentMessageId,
conversationId,
text: message,
});
if (typeof opts?.getReqData === 'function') {
opts.getReqData({

View File

@@ -140,7 +140,8 @@ class GoogleClient extends BaseClient {
this.options.attachments?.then((attachments) => this.checkVisionRequest(attachments));
/** @type {boolean} Whether using a "GenerativeAI" Model */
this.isGenerativeModel = /gemini|learnlm|gemma/.test(this.modelOptions.model);
this.isGenerativeModel =
this.modelOptions.model.includes('gemini') || this.modelOptions.model.includes('learnlm');
this.maxContextTokens =
this.options.maxContextTokens ??

View File

@@ -475,9 +475,7 @@ class OpenAIClient extends BaseClient {
promptPrefix = this.augmentedPrompt + promptPrefix;
}
const noSystemModelRegex = /\b(o1-preview|o1-mini)\b/i.test(this.modelOptions.model);
if (promptPrefix && !noSystemModelRegex) {
if (promptPrefix && this.isOmni !== true) {
promptPrefix = `Instructions:\n${promptPrefix.trim()}`;
instructions = {
role: 'system',
@@ -505,7 +503,7 @@ class OpenAIClient extends BaseClient {
};
/** EXPERIMENTAL */
if (promptPrefix && noSystemModelRegex) {
if (promptPrefix && this.isOmni === true) {
const lastUserMessageIndex = payload.findLastIndex((message) => message.role === 'user');
if (lastUserMessageIndex !== -1) {
if (Array.isArray(payload[lastUserMessageIndex].content)) {
@@ -1229,9 +1227,9 @@ ${convo}
opts.baseURL = this.langchainProxy
? constructAzureURL({
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
baseURL: this.langchainProxy,
azureOptions: this.azure,
})
: this.azureEndpoint.split(/(?<!\/)\/(chat|completion)\//)[0];
opts.defaultQuery = { 'api-version': this.azure.azureOpenAIApiVersion };
@@ -1285,14 +1283,6 @@ ${convo}
modelOptions.messages[0].role = 'user';
}
if (
(this.options.endpoint === EModelEndpoint.openAI ||
this.options.endpoint === EModelEndpoint.azureOpenAI) &&
modelOptions.stream === true
) {
modelOptions.stream_options = { include_usage: true };
}
if (this.options.addParams && typeof this.options.addParams === 'object') {
const addParams = { ...this.options.addParams };
modelOptions = {
@@ -1395,6 +1385,12 @@ ${convo}
...modelOptions,
stream: true,
};
if (
this.options.endpoint === EModelEndpoint.openAI ||
this.options.endpoint === EModelEndpoint.azureOpenAI
) {
params.stream_options = { include_usage: true };
}
const stream = await openai.beta.chat.completions
.stream(params)
.on('abort', () => {

View File

@@ -43,39 +43,9 @@ class TavilySearchResults extends Tool {
.boolean()
.optional()
.describe('Whether to include answers in the search results. Default is False.'),
include_raw_content: z
.boolean()
.optional()
.describe('Whether to include raw content in the search results. Default is False.'),
include_domains: z
.array(z.string())
.optional()
.describe('A list of domains to specifically include in the search results.'),
exclude_domains: z
.array(z.string())
.optional()
.describe('A list of domains to specifically exclude from the search results.'),
topic: z
.enum(['general', 'news', 'finance'])
.optional()
.describe(
'The category of the search. Use news ONLY if query SPECIFCALLY mentions the word "news".',
),
time_range: z
.enum(['day', 'week', 'month', 'year', 'd', 'w', 'm', 'y'])
.optional()
.describe('The time range back from the current date to filter results.'),
days: z
.number()
.min(1)
.optional()
.describe('Number of days back from the current date to include. Only if topic is news.'),
include_image_descriptions: z
.boolean()
.optional()
.describe(
'When include_images is true, also add a descriptive text for each image. Default is false.',
),
// include_raw_content: z.boolean().optional().describe('Whether to include raw content in the search results. Default is False.'),
// include_domains: z.array(z.string()).optional().describe('A list of domains to specifically include in the search results.'),
// exclude_domains: z.array(z.string()).optional().describe('A list of domains to specifically exclude from the search results.'),
});
}

View File

@@ -0,0 +1,30 @@
const { loadSpecs } = require('./loadSpecs');
function transformSpec(input) {
return {
name: input.name_for_human,
pluginKey: input.name_for_model,
description: input.description_for_human,
icon: input?.logo_url ?? 'https://placehold.co/70x70.png',
// TODO: add support for authentication
isAuthRequired: 'false',
authConfig: [],
};
}
async function addOpenAPISpecs(availableTools) {
try {
const specs = (await loadSpecs({})).map(transformSpec);
if (specs.length > 0) {
return [...specs, ...availableTools];
}
return availableTools;
} catch (error) {
return availableTools;
}
}
module.exports = {
transformSpec,
addOpenAPISpecs,
};

View File

@@ -0,0 +1,76 @@
const { addOpenAPISpecs, transformSpec } = require('./addOpenAPISpecs');
const { loadSpecs } = require('./loadSpecs');
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
jest.mock('./loadSpecs');
jest.mock('../dynamic/OpenAPIPlugin');
describe('transformSpec', () => {
it('should transform input spec to a desired format', () => {
const input = {
name_for_human: 'Human Name',
name_for_model: 'Model Name',
description_for_human: 'Human Description',
logo_url: 'https://example.com/logo.png',
};
const expectedOutput = {
name: 'Human Name',
pluginKey: 'Model Name',
description: 'Human Description',
icon: 'https://example.com/logo.png',
isAuthRequired: 'false',
authConfig: [],
};
expect(transformSpec(input)).toEqual(expectedOutput);
});
it('should use default icon if logo_url is not provided', () => {
const input = {
name_for_human: 'Human Name',
name_for_model: 'Model Name',
description_for_human: 'Human Description',
};
const expectedOutput = {
name: 'Human Name',
pluginKey: 'Model Name',
description: 'Human Description',
icon: 'https://placehold.co/70x70.png',
isAuthRequired: 'false',
authConfig: [],
};
expect(transformSpec(input)).toEqual(expectedOutput);
});
});
describe('addOpenAPISpecs', () => {
it('should add specs to available tools', async () => {
const availableTools = ['Tool1', 'Tool2'];
const specs = [
{
name_for_human: 'Human Name',
name_for_model: 'Model Name',
description_for_human: 'Human Description',
logo_url: 'https://example.com/logo.png',
},
];
loadSpecs.mockResolvedValue(specs);
createOpenAPIPlugin.mockReturnValue('Plugin');
const result = await addOpenAPISpecs(availableTools);
expect(result).toEqual([...specs.map(transformSpec), ...availableTools]);
});
it('should return available tools if specs loading fails', async () => {
const availableTools = ['Tool1', 'Tool2'];
loadSpecs.mockRejectedValue(new Error('Failed to load specs'));
const result = await addOpenAPISpecs(availableTools);
expect(result).toEqual(availableTools);
});
});

View File

@@ -24,6 +24,7 @@ const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/pro
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { createMCPTool } = require('~/server/services/MCP');
const { loadSpecs } = require('./loadSpecs');
const { logger } = require('~/config');
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
@@ -231,6 +232,7 @@ const loadTools = async ({
/** @type {Record<string, string>} */
const toolContextMap = {};
const remainingTools = [];
const appTools = options.req?.app?.locals?.availableTools ?? {};
for (const tool of tools) {
@@ -290,6 +292,30 @@ const loadTools = async ({
requestedTools[tool] = toolInstance;
continue;
}
if (functions === true) {
remainingTools.push(tool);
}
}
let specs = null;
if (useSpecs === true && functions === true && remainingTools.length > 0) {
specs = await loadSpecs({
llm: model,
user,
message: options.message,
memory: options.memory,
signal: options.signal,
tools: remainingTools,
map: true,
verbose: false,
});
}
for (const tool of remainingTools) {
if (specs && specs[tool]) {
requestedTools[tool] = specs[tool];
}
}
if (returnMap) {

View File

@@ -0,0 +1,117 @@
const fs = require('fs');
const path = require('path');
const { z } = require('zod');
const { logger } = require('~/config');
const { createOpenAPIPlugin } = require('~/app/clients/tools/dynamic/OpenAPIPlugin');
// The minimum Manifest definition
const ManifestDefinition = z.object({
schema_version: z.string().optional(),
name_for_human: z.string(),
name_for_model: z.string(),
description_for_human: z.string(),
description_for_model: z.string(),
auth: z.object({}).optional(),
api: z.object({
// Spec URL or can be the filename of the OpenAPI spec yaml file,
// located in api\app\clients\tools\.well-known\openapi
url: z.string(),
type: z.string().optional(),
is_user_authenticated: z.boolean().nullable().optional(),
has_user_authentication: z.boolean().nullable().optional(),
}),
// use to override any params that the LLM will consistently get wrong
params: z.object({}).optional(),
logo_url: z.string().optional(),
contact_email: z.string().optional(),
legal_info_url: z.string().optional(),
});
function validateJson(json) {
try {
return ManifestDefinition.parse(json);
} catch (error) {
logger.debug('[validateJson] manifest parsing error', error);
return false;
}
}
// omit the LLM to return the well known jsons as objects
async function loadSpecs({ llm, user, message, tools = [], map = false, memory, signal }) {
const directoryPath = path.join(__dirname, '..', '.well-known');
let files = [];
for (let i = 0; i < tools.length; i++) {
const filePath = path.join(directoryPath, tools[i] + '.json');
try {
// If the access Promise is resolved, it means that the file exists
// Then we can add it to the files array
await fs.promises.access(filePath, fs.constants.F_OK);
files.push(tools[i] + '.json');
} catch (err) {
logger.error(`[loadSpecs] File ${tools[i] + '.json'} does not exist`, err);
}
}
if (files.length === 0) {
files = (await fs.promises.readdir(directoryPath)).filter(
(file) => path.extname(file) === '.json',
);
}
const validJsons = [];
const constructorMap = {};
logger.debug('[validateJson] files', files);
for (const file of files) {
if (path.extname(file) === '.json') {
const filePath = path.join(directoryPath, file);
const fileContent = await fs.promises.readFile(filePath, 'utf8');
const json = JSON.parse(fileContent);
if (!validateJson(json)) {
logger.debug('[validateJson] Invalid json', json);
continue;
}
if (llm && map) {
constructorMap[json.name_for_model] = async () =>
await createOpenAPIPlugin({
data: json,
llm,
message,
memory,
signal,
user,
});
continue;
}
if (llm) {
validJsons.push(createOpenAPIPlugin({ data: json, llm }));
continue;
}
validJsons.push(json);
}
}
if (map) {
return constructorMap;
}
const plugins = (await Promise.all(validJsons)).filter((plugin) => plugin);
// logger.debug('[validateJson] plugins', plugins);
// logger.debug(plugins[0].name);
return plugins;
}
module.exports = {
loadSpecs,
validateJson,
ManifestDefinition,
};

View File

@@ -0,0 +1,101 @@
const fs = require('fs');
const { validateJson, loadSpecs, ManifestDefinition } = require('./loadSpecs');
const { createOpenAPIPlugin } = require('../dynamic/OpenAPIPlugin');
jest.mock('../dynamic/OpenAPIPlugin');
describe('ManifestDefinition', () => {
it('should validate correct json', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 'http://test.com',
},
};
expect(() => ManifestDefinition.parse(json)).not.toThrow();
});
it('should not validate incorrect json', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 123, // incorrect type
},
};
expect(() => ManifestDefinition.parse(json)).toThrow();
});
});
describe('validateJson', () => {
it('should return parsed json if valid', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 'http://test.com',
},
};
expect(validateJson(json)).toEqual(json);
});
it('should return false if json is not valid', () => {
const json = {
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 123, // incorrect type
},
};
expect(validateJson(json)).toEqual(false);
});
});
describe('loadSpecs', () => {
beforeEach(() => {
jest.spyOn(fs.promises, 'readdir').mockResolvedValue(['test.json']);
jest.spyOn(fs.promises, 'readFile').mockResolvedValue(
JSON.stringify({
name_for_human: 'Test',
name_for_model: 'Test',
description_for_human: 'Test',
description_for_model: 'Test',
api: {
url: 'http://test.com',
},
}),
);
createOpenAPIPlugin.mockResolvedValue({});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should return plugins', async () => {
const plugins = await loadSpecs({ llm: true, verbose: false });
expect(plugins).toHaveLength(1);
expect(createOpenAPIPlugin).toHaveBeenCalledTimes(1);
});
it('should return constructorMap if map is true', async () => {
const plugins = await loadSpecs({ llm: {}, map: true, verbose: false });
expect(plugins).toHaveProperty('Test');
expect(createOpenAPIPlugin).not.toHaveBeenCalled();
});
});

View File

@@ -75,12 +75,6 @@ if (REDIS_URI && isEnabled(USE_REDIS)) {
} else {
keyvRedis = new KeyvRedis(REDIS_URI, keyvOpts);
}
const pingInterval = setInterval(() => {
logger.debug('KeyvRedis ping');
keyvRedis.client.ping().catch(err => logger.error('Redis keep-alive ping failed:', err));
}, 5 * 60 * 1000);
keyvRedis.on('ready', () => {
logger.info('KeyvRedis connection ready');
});
@@ -91,7 +85,6 @@ if (REDIS_URI && isEnabled(USE_REDIS)) {
logger.info('KeyvRedis connection ended');
});
keyvRedis.on('close', () => {
clearInterval(pingInterval);
logger.info('KeyvRedis connection closed');
});
keyvRedis.on('error', (err) => logger.error('KeyvRedis connection error:', err));

View File

@@ -308,7 +308,7 @@ const getListAgents = async (searchParameter) => {
* This function also updates the corresponding projects to include or exclude the agent ID.
*
* @param {Object} params - Parameters for updating the agent's projects.
* @param {MongoUser} params.user - Parameters for updating the agent's projects.
* @param {import('librechat-data-provider').TUser} params.user - Parameters for updating the agent's projects.
* @param {string} params.agentId - The ID of the agent to update.
* @param {string[]} [params.projectIds] - Array of project IDs to add to the agent.
* @param {string[]} [params.removeProjectIds] - Array of project IDs to remove from the agent.

View File

@@ -111,15 +111,10 @@ const tokenValues = Object.assign(
/* cohere doesn't have rates for the older command models,
so this was from https://artificialanalysis.ai/models/command-light/providers */
command: { prompt: 0.38, completion: 0.38 },
gemma: { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-2': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-3': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemma-3-27b': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.0-flash-lite': { prompt: 0.075, completion: 0.3 },
'gemini-2.0-flash': { prompt: 0.1, completion: 0.4 },
'gemini-2.0': { prompt: 0, completion: 0 }, // https://ai.google.dev/pricing
'gemini-2.5-pro': { prompt: 1.25, completion: 10 },
'gemini-2.5-flash': { prompt: 0.15, completion: 3.5 },
'gemini-2.5-pro-preview-03-25': { prompt: 1.25, completion: 10 },
'gemini-2.5': { prompt: 0, completion: 0 }, // Free for a period of time
'gemini-1.5-flash-8b': { prompt: 0.075, completion: 0.3 },
'gemini-1.5-flash': { prompt: 0.15, completion: 0.6 },

View File

@@ -488,9 +488,6 @@ describe('getCacheMultiplier', () => {
describe('Google Model Tests', () => {
const googleModels = [
'gemini-2.5-pro-preview-05-06',
'gemini-2.5-flash-preview-04-17',
'gemini-2.5-exp',
'gemini-2.0-flash-lite-preview-02-05',
'gemini-2.0-flash-001',
'gemini-2.0-flash-exp',
@@ -528,9 +525,6 @@ describe('Google Model Tests', () => {
it('should map to the correct model keys', () => {
const expected = {
'gemini-2.5-pro-preview-05-06': 'gemini-2.5-pro',
'gemini-2.5-flash-preview-04-17': 'gemini-2.5-flash',
'gemini-2.5-exp': 'gemini-2.5',
'gemini-2.0-flash-lite-preview-02-05': 'gemini-2.0-flash-lite',
'gemini-2.0-flash-001': 'gemini-2.0-flash',
'gemini-2.0-flash-exp': 'gemini-2.0-flash',

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/backend",
"version": "v0.7.8",
"version": "v0.7.7",
"description": "",
"scripts": {
"start": "echo 'please run this from the root directory'",
@@ -43,12 +43,12 @@
"@google/generative-ai": "^0.23.0",
"@googleapis/youtube": "^20.0.0",
"@keyv/redis": "^4.3.3",
"@langchain/community": "^0.3.42",
"@langchain/core": "^0.3.51",
"@langchain/google-genai": "^0.2.5",
"@langchain/google-vertexai": "^0.2.5",
"@langchain/community": "^0.3.39",
"@langchain/core": "^0.3.43",
"@langchain/google-genai": "^0.2.2",
"@langchain/google-vertexai": "^0.2.3",
"@langchain/textsplitters": "^0.1.0",
"@librechat/agents": "^2.4.314",
"@librechat/agents": "^2.4.22",
"@librechat/data-schemas": "*",
"@waylaidwanderer/fetch-event-source": "^3.0.1",
"axios": "^1.8.2",
@@ -90,7 +90,7 @@
"nanoid": "^3.3.7",
"nodemailer": "^6.9.15",
"ollama": "^0.5.0",
"openai": "^4.96.2",
"openai": "^4.47.1",
"openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2",
"passport": "^0.6.0",
@@ -116,6 +116,6 @@
"jest": "^29.7.0",
"mongodb-memory-server": "^10.1.3",
"nodemon": "^3.0.3",
"supertest": "^7.1.0"
"supertest": "^7.0.0"
}
}

View File

@@ -128,7 +128,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
clientRef = new WeakRef(client);
getAbortData = () => {
const currentClient = clientRef?.deref();
const currentClient = clientRef.deref();
const currentText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
@@ -228,7 +228,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
if (!client?.skipSaveUserMessage && latestUserMessage) {
await saveMessage(req, latestUserMessage, {
context: "api/server/controllers/AskController.js - don't skip saving user message",
context: 'api/server/controllers/AskController.js - don\'t skip saving user message',
});
}
@@ -255,7 +255,7 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
logger.error('[AskController] Error handling request', error);
let partialText = '';
try {
const currentClient = clientRef?.deref();
const currentClient = clientRef.deref();
partialText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
} catch (getTextError) {
@@ -268,7 +268,6 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
conversationId: reqDataContext.conversationId,
messageId: reqDataContext.responseMessageId,
parentMessageId: overrideParentMessageId ?? reqDataContext.userMessageId ?? parentMessageId,
userMessageId: reqDataContext.userMessageId,
})
.catch((err) => {
logger.error('[AskController] Error in `handleAbortError` during catch block', err);

View File

@@ -123,7 +123,7 @@ const EditController = async (req, res, next, initializeClient) => {
clientRef = new WeakRef(client);
getAbortData = () => {
const currentClient = clientRef?.deref();
const currentClient = clientRef.deref();
const currentText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
@@ -219,7 +219,7 @@ const EditController = async (req, res, next, initializeClient) => {
logger.error('[EditController] Error handling request', error);
let partialText = '';
try {
const currentClient = clientRef?.deref();
const currentClient = clientRef.deref();
partialText =
currentClient?.getStreamText != null ? currentClient.getStreamText() : getPartialText();
} catch (getTextError) {
@@ -232,7 +232,6 @@ const EditController = async (req, res, next, initializeClient) => {
conversationId,
messageId: reqDataContext.responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
userMessageId,
})
.catch((err) => {
logger.error('[EditController] Error in `handleAbortError` during catch block', err);

View File

@@ -1,5 +1,5 @@
const { CacheKeys, AuthType } = require('librechat-data-provider');
const { getToolkitKey } = require('~/server/services/ToolService');
const { addOpenAPISpecs } = require('~/app/clients/tools/util/addOpenAPISpecs');
const { getCustomConfig } = require('~/server/services/Config');
const { availableTools } = require('~/app/clients/tools');
const { getMCPManager } = require('~/config');
@@ -69,7 +69,7 @@ const getAvailablePluginsController = async (req, res) => {
);
}
let plugins = authenticatedPlugins;
let plugins = await addOpenAPISpecs(authenticatedPlugins);
if (includedTools.length > 0) {
plugins = plugins.filter((plugin) => includedTools.includes(plugin.pluginKey));
@@ -105,11 +105,11 @@ const getAvailableTools = async (req, res) => {
return;
}
let pluginManifest = availableTools;
const pluginManifest = availableTools;
const customConfig = await getCustomConfig();
if (customConfig?.mcpServers != null) {
const mcpManager = getMCPManager();
pluginManifest = await mcpManager.loadManifestTools(pluginManifest);
await mcpManager.loadManifestTools(pluginManifest);
}
/** @type {TPlugin[]} */
@@ -128,7 +128,7 @@ const getAvailableTools = async (req, res) => {
(plugin) =>
toolDefinitions[plugin.pluginKey] !== undefined ||
(plugin.toolkit === true &&
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
Object.keys(toolDefinitions).some((key) => key.startsWith(`${plugin.pluginKey}_`))),
);
await cache.set(CacheKeys.TOOLS, tools);

View File

@@ -14,6 +14,15 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { saveBase64Image } = require('~/server/services/Files/process');
const { logger, sendEvent } = require('~/config');
/** @typedef {import('@librechat/agents').Graph} Graph */
/** @typedef {import('@librechat/agents').EventHandler} EventHandler */
/** @typedef {import('@librechat/agents').ModelEndData} ModelEndData */
/** @typedef {import('@librechat/agents').ToolEndData} ToolEndData */
/** @typedef {import('@librechat/agents').ToolEndCallback} ToolEndCallback */
/** @typedef {import('@librechat/agents').ChatModelStreamHandler} ChatModelStreamHandler */
/** @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator */
/** @typedef {import('@librechat/agents').GraphEvents} GraphEvents */
class ModelEndHandler {
/**
* @param {Array<UsageMetadata>} collectedUsage
@@ -29,7 +38,7 @@ class ModelEndHandler {
* @param {string} event
* @param {ModelEndData | undefined} data
* @param {Record<string, unknown> | undefined} metadata
* @param {StandardGraph} graph
* @param {Graph} graph
* @returns
*/
handle(event, data, metadata, graph) {
@@ -52,10 +61,7 @@ class ModelEndHandler {
}
this.collectedUsage.push(usage);
const streamingDisabled = !!(
graph.clientOptions?.disableStreaming || graph?.boundModel?.disableStreaming
);
if (!streamingDisabled) {
if (!graph.clientOptions?.disableStreaming) {
return;
}
if (!data.output.content) {

View File

@@ -58,7 +58,7 @@ const payloadParser = ({ req, agent, endpoint }) => {
const legacyContentEndpoints = new Set([KnownEndpoints.groq, KnownEndpoints.deepseek]);
const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi];
const noSystemModelRegex = [/\b(o\d)\b/gi];
// const { processMemory, memoryInstructions } = require('~/server/services/Endpoints/agents/memory');
// const { getFormattedMemories } = require('~/models/Memory');
@@ -148,13 +148,19 @@ class AgentClient extends BaseClient {
* @param {MongoFile[]} attachments
*/
checkVisionRequest(attachments) {
logger.info(
'[api/server/controllers/agents/client.js #checkVisionRequest] not implemented',
attachments,
);
// if (!attachments) {
// return;
// }
// const availableModels = this.options.modelsConfig?.[this.options.endpoint];
// if (!availableModels) {
// return;
// }
// let visionRequestDetected = false;
// for (const file of attachments) {
// if (file?.type?.includes('image')) {
@@ -165,11 +171,13 @@ class AgentClient extends BaseClient {
// if (!visionRequestDetected) {
// return;
// }
// this.isVisionModel = validateVisionModel({ model: this.modelOptions.model, availableModels });
// if (this.isVisionModel) {
// delete this.modelOptions.stop;
// return;
// }
// for (const model of availableModels) {
// if (!validateVisionModel({ model, availableModels })) {
// continue;
@@ -179,12 +187,14 @@ class AgentClient extends BaseClient {
// delete this.modelOptions.stop;
// return;
// }
// if (!availableModels.includes(this.defaultVisionModel)) {
// return;
// }
// if (!validateVisionModel({ model: this.defaultVisionModel, availableModels })) {
// return;
// }
// this.modelOptions.model = this.defaultVisionModel;
// this.isVisionModel = true;
// delete this.modelOptions.stop;
@@ -663,7 +673,7 @@ class AgentClient extends BaseClient {
this.indexTokenCountMap,
toolSet,
);
if (legacyContentEndpoints.has(this.options.agent.endpoint?.toLowerCase())) {
if (legacyContentEndpoints.has(this.options.agent.endpoint)) {
initialMessages = formatContentStrings(initialMessages);
}
@@ -718,14 +728,12 @@ class AgentClient extends BaseClient {
}
if (noSystemMessages === true && systemContent?.length) {
const latestMessageContent = _messages.pop().content;
let latestMessage = _messages.pop().content;
if (typeof latestMessage !== 'string') {
latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n');
_messages.push(new HumanMessage({ content: latestMessageContent }));
} else {
const text = [systemContent, latestMessageContent].join('\n');
_messages.push(new HumanMessage(text));
latestMessage = latestMessage[0].text;
}
latestMessage = [systemContent, latestMessage].join('\n');
_messages.push(new HumanMessage(latestMessage));
}
let messages = _messages;

View File

@@ -259,7 +259,6 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
sender,
messageId: responseMessageId,
parentMessageId: overrideParentMessageId ?? userMessageId ?? parentMessageId,
userMessageId,
})
.catch((err) => {
logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);

View File

@@ -119,7 +119,7 @@ const chatV1 = async (req, res) => {
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === EModelEndpoint.azureAssistants
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(req, res, messageData, errorMessage);
@@ -379,8 +379,8 @@ const chatV1 = async (req, res) => {
body.additional_instructions ? `${body.additional_instructions}\n` : ''
}The user has uploaded ${imageCount} image${pluralized}.
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
plural ? '' : 'a '
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
plural ? '' : 'a '
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
return files;
};
@@ -576,8 +576,6 @@ const chatV1 = async (req, res) => {
thread_id,
model: assistant_id,
endpoint,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
};
sendMessage(res, {

View File

@@ -428,8 +428,6 @@ const chatV2 = async (req, res) => {
thread_id,
model: assistant_id,
endpoint,
spec: endpointOption.spec,
iconURL: endpointOption.iconURL,
};
sendMessage(res, {

View File

@@ -311,7 +311,7 @@ const handleAbortError = async (res, req, error, data) => {
} else {
logger.error('[handleAbortError] AI response error; aborting request:', error);
}
const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
const { sender, conversationId, messageId, parentMessageId, partialText } = data;
if (error.stack && error.stack.includes('google')) {
logger.warn(
@@ -344,10 +344,10 @@ const handleAbortError = async (res, req, error, data) => {
parentMessageId,
text: errorText,
user: req.user.id,
shouldSaveMessage: true,
spec: endpointOption?.spec,
iconURL: endpointOption?.iconURL,
modelLabel: endpointOption?.modelLabel,
shouldSaveMessage: userMessageId != null,
model: endpointOption?.modelOptions?.model || req.body?.model,
};

View File

@@ -21,7 +21,6 @@ const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
const { getFiles, batchUpdateFiles } = require('~/models/File');
const { getAssistant } = require('~/models/Assistant');
const { getAgent } = require('~/models/Agent');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
@@ -95,7 +94,7 @@ router.delete('/', async (req, res) => {
});
}
/* Handle agent unlinking even if no valid files to delete */
/* Handle entity unlinking even if no valid files to delete */
if (req.body.agent_id && req.body.tool_resource && dbFiles.length === 0) {
const agent = await getAgent({
id: req.body.agent_id,
@@ -105,21 +104,7 @@ router.delete('/', async (req, res) => {
const agentFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
await processDeleteRequest({ req, files: agentFiles });
res.status(200).json({ message: 'File associations removed successfully from agent' });
return;
}
/* Handle assistant unlinking even if no valid files to delete */
if (req.body.assistant_id && req.body.tool_resource && dbFiles.length === 0) {
const assistant = await getAssistant({
id: req.body.assistant_id,
});
const toolResourceFiles = assistant.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
const assistantFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
await processDeleteRequest({ req, files: assistantFiles });
res.status(200).json({ message: 'File associations removed successfully from assistant' });
res.status(200).json({ message: 'File associations removed successfully' });
return;
}

View File

@@ -1,17 +1,40 @@
const { Keyv } = require('keyv');
const express = require('express');
const { MeiliSearch } = require('meilisearch');
const { Conversation } = require('~/models/Conversation');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const { Message } = require('~/models/Message');
const { isEnabled } = require('~/server/utils');
const keyvRedis = require('~/cache/keyvRedis');
const router = express.Router();
const expiration = 60 * 1000;
const cache = isEnabled(process.env.USE_REDIS)
? new Keyv({ store: keyvRedis })
: new Keyv({ namespace: 'search', ttl: expiration });
router.use(requireJwtAuth);
router.get('/enable', async function (req, res) {
if (!isEnabled(process.env.SEARCH)) {
return res.send(false);
}
router.get('/sync', async function (req, res) {
await Message.syncWithMeili();
await Conversation.syncWithMeili();
res.send('synced');
});
router.get('/test', async function (req, res) {
const { q } = req.query;
const messages = (
await Message.meiliSearch(q, { attributesToHighlight: ['text'] }, true)
).hits.map((message) => {
const { _formatted, ...rest } = message;
return { ...rest, searchResult: true, text: _formatted.text };
});
res.send(messages);
});
router.get('/enable', async function (req, res) {
let result = false;
try {
const client = new MeiliSearch({
host: process.env.MEILI_HOST,
@@ -19,7 +42,8 @@ router.get('/enable', async function (req, res) {
});
const { status } = await client.health();
return res.send(status === 'available');
result = status === 'available' && !!process.env.SEARCH;
return res.send(result);
} catch (error) {
return res.send(false);
}

View File

@@ -146,7 +146,7 @@ async function createActionTool({
/** @type {import('librechat-data-provider').ActionMetadataRuntime} */
const metadata = action.metadata;
const executor = requestBuilder.createExecutor();
const preparedExecutor = executor.setParams(toolInput ?? {});
const preparedExecutor = executor.setParams(toolInput);
if (metadata.auth && metadata.auth.type !== AuthTypeEnum.None) {
try {

View File

@@ -56,7 +56,7 @@ const logoutUser = async (req, refreshToken) => {
try {
req.session.destroy();
} catch (destroyErr) {
logger.debug('[logoutUser] Failed to destroy session.', destroyErr);
logger.error('[logoutUser] Failed to destroy session.', destroyErr);
}
return { status: 200, message: 'Logout successful' };

View File

@@ -6,7 +6,6 @@ const {
EToolResources,
getResponseSender,
AgentCapabilities,
replaceSpecialVars,
providerEndpointMap,
} = require('librechat-data-provider');
const {
@@ -233,13 +232,6 @@ const initializeAgentOptions = async ({
endpointOption: _endpointOption,
});
if (
agent.endpoint === EModelEndpoint.azureOpenAI &&
options.llmConfig?.azureOpenAIApiInstanceName == null
) {
agent.provider = Providers.OPENAI;
}
if (options.provider != null) {
agent.provider = options.provider;
}
@@ -254,13 +246,6 @@ const initializeAgentOptions = async ({
agent.model_parameters.model = agent.model;
}
if (agent.instructions && agent.instructions !== '') {
agent.instructions = replaceSpecialVars({
text: agent.instructions,
user: req.user,
});
}
if (typeof agent.artifacts === 'string' && agent.artifacts !== '') {
agent.additional_instructions = generateArtifactsPrompt({
endpoint: agent.provider,

View File

@@ -3,6 +3,7 @@ const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts');
const { getAssistant } = require('~/models/Assistant');
const buildOptions = async (endpoint, parsedBody) => {
const { promptPrefix, assistant_id, iconURL, greeting, spec, artifacts, ...modelOptions } =
parsedBody;
const endpointOption = removeNullishValues({

View File

@@ -132,8 +132,6 @@ async function saveUserMessage(req, params) {
* @param {string} params.endpoint - The conversation endpoint
* @param {string} params.parentMessageId - The latest user message that triggered this response.
* @param {string} [params.instructions] - Optional: from preset for `instructions` field.
* @param {string} [params.spec] - Optional: Model spec identifier.
* @param {string} [params.iconURL]
* Overrides the instructions of the assistant.
* @param {string} [params.promptPrefix] - Optional: from preset for `additional_instructions` field.
* @return {Promise<Run>} A promise that resolves to the created run object.
@@ -156,8 +154,6 @@ async function saveAssistantMessage(req, params) {
text: params.text,
unfinished: false,
// tokenCount,
iconURL: params.iconURL,
spec: params.spec,
});
await saveConvo(
@@ -169,8 +165,6 @@ async function saveAssistantMessage(req, params) {
instructions: params.instructions,
assistant_id: params.assistant_id,
model: params.model,
iconURL: params.iconURL,
spec: params.spec,
},
{ context: 'api/server/services/Threads/manage.js #saveAssistantMessage' },
);

View File

@@ -8,7 +8,6 @@ const {
ErrorTypes,
ContentTypes,
imageGenTools,
EToolResources,
EModelEndpoint,
actionDelimiter,
ImageVisionTool,
@@ -37,30 +36,6 @@ const { redactMessage } = require('~/config/parsers');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
/**
* @param {string} toolName
* @returns {string | undefined} toolKey
*/
function getToolkitKey(toolName) {
/** @type {string|undefined} */
let toolkitKey;
for (const toolkit of toolkits) {
if (toolName.startsWith(EToolResources.image_edit)) {
const splitMatches = toolkit.pluginKey.split('_');
const suffix = splitMatches[splitMatches.length - 1];
if (toolName.endsWith(suffix)) {
toolkitKey = toolkit.pluginKey;
break;
}
}
if (toolName.startsWith(toolkit.pluginKey)) {
toolkitKey = toolkit.pluginKey;
break;
}
}
return toolkitKey;
}
/**
* Loads and formats tools from the specified tool directory.
*
@@ -133,7 +108,7 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] })
tools.push(formattedTool);
}
/** Basic Tools & Toolkits; schema: { input: string } */
/** Basic Tools; schema: { input: string } */
const basicToolInstances = [
new Calculator(),
...createOpenAIImageTools({ override: true }),
@@ -142,7 +117,9 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] })
for (const toolInstance of basicToolInstances) {
const formattedTool = formatToOpenAIAssistantTool(toolInstance);
let toolName = formattedTool[Tools.function].name;
toolName = getToolkitKey(toolName) ?? toolName;
toolName = toolkits.some((toolkit) => toolName.startsWith(toolkit.pluginKey))
? toolName.split('_')[0]
: toolName;
if (filter.has(toolName) && included.size === 0) {
continue;
}
@@ -705,7 +682,6 @@ async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey })
}
module.exports = {
getToolkitKey,
loadAgentTools,
loadAndFormatTools,
processRequiredActions,

View File

@@ -70,13 +70,7 @@ const sendError = async (req, res, options, callback) => {
}
if (shouldSaveMessage) {
await saveMessage(
req,
{ ...errorMessage, user },
{
context: 'api/server/utils/streamResponse.js - sendError',
},
);
await saveMessage(req, { ...errorMessage, user });
}
if (!errorMessage.error) {

View File

@@ -43,60 +43,6 @@
* @memberof typedefs
*/
/**
* @exports Graph
* @typedef {import('@librechat/agents').Graph} Graph
* @memberof typedefs
*/
/**
* @exports StandardGraph
* @typedef {import('@librechat/agents').StandardGraph} StandardGraph
* @memberof typedefs
*/
/**
* @exports EventHandler
* @typedef {import('@librechat/agents').EventHandler} EventHandler
* @memberof typedefs
*/
/**
* @exports ModelEndData
* @typedef {import('@librechat/agents').ModelEndData} ModelEndData
* @memberof typedefs
*/
/**
* @exports ToolEndData
* @typedef {import('@librechat/agents').ToolEndData} ToolEndData
* @memberof typedefs
*/
/**
* @exports ToolEndCallback
* @typedef {import('@librechat/agents').ToolEndCallback} ToolEndCallback
* @memberof typedefs
*/
/**
* @exports ChatModelStreamHandler
* @typedef {import('@librechat/agents').ChatModelStreamHandler} ChatModelStreamHandler
* @memberof typedefs
*/
/**
* @exports ContentAggregator
* @typedef {import('@librechat/agents').ContentAggregatorResult['aggregateContent']} ContentAggregator
* @memberof typedefs
*/
/**
* @exports GraphEvents
* @typedef {import('@librechat/agents').GraphEvents} GraphEvents
* @memberof typedefs
*/
/**
* @exports AgentRun
* @typedef {import('@librechat/agents').Run} AgentRun
@@ -151,6 +97,12 @@
* @memberof typedefs
*/
/**
* @exports ToolEndData
* @typedef {import('@librechat/agents').ToolEndData} ToolEndData
* @memberof typedefs
*/
/**
* @exports BaseMessage
* @typedef {import('@langchain/core/messages').BaseMessage} BaseMessage

View File

@@ -60,16 +60,10 @@ const cohereModels = {
const googleModels = {
/* Max I/O is combined so we subtract the amount from max response tokens for actual total */
gemma: 8196,
'gemma-2': 32768,
'gemma-3': 32768,
'gemma-3-27b': 131072,
gemini: 30720, // -2048 from max
'gemini-pro-vision': 12288,
'gemini-exp': 2000000,
'gemini-2.5': 1000000, // 1M input tokens, 64k output tokens
'gemini-2.5-pro': 1000000,
'gemini-2.5-flash': 1000000,
'gemini-2.0': 2000000,
'gemini-2.0-flash': 1000000,
'gemini-2.0-flash-lite': 1000000,
@@ -241,15 +235,12 @@ const modelMaxOutputs = {
system_default: 1024,
};
/** Outputs from https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-names */
const anthropicMaxOutputs = {
'claude-3-haiku': 4096,
'claude-3-sonnet': 4096,
'claude-3-opus': 4096,
'claude-3.5-sonnet': 8192,
'claude-3-5-sonnet': 8192,
'claude-3.7-sonnet': 128000,
'claude-3-7-sonnet': 128000,
};
const maxOutputTokensMap = {

View File

@@ -1,6 +1,6 @@
{
"name": "@librechat/frontend",
"version": "v0.7.8",
"version": "v0.7.7",
"description": "",
"type": "module",
"scripts": {
@@ -141,7 +141,7 @@
"tailwindcss": "^3.4.1",
"ts-jest": "^29.2.5",
"typescript": "^5.3.3",
"vite": "^6.3.4",
"vite": "^6.2.5",
"vite-plugin-compression2": "^1.3.3",
"vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-pwa": "^0.21.2"

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useCallback, useRef, useState } from 'react';
import throttle from 'lodash/throttle';
import { visit } from 'unist-util-visit';
import { useSetRecoilState } from 'recoil';
import { useLocation } from 'react-router-dom';
import type { Pluggable } from 'unified';
import type { Artifact } from '~/common';
import { useMessageContext, useArtifactContext } from '~/Providers';
@@ -46,7 +45,6 @@ export function Artifact({
children: React.ReactNode | { props: { children: React.ReactNode } };
node: unknown;
}) {
const location = useLocation();
const { messageId } = useMessageContext();
const { getNextIndex, resetCounter } = useArtifactContext();
const artifactIndex = useRef(getNextIndex(false)).current;
@@ -88,10 +86,6 @@ export function Artifact({
lastUpdateTime: now,
};
if (!location.pathname.includes('/c/')) {
return setArtifact(currentArtifact);
}
setArtifacts((prevArtifacts) => {
if (
prevArtifacts?.[artifactKey] != null &&
@@ -116,7 +110,6 @@ export function Artifact({
props.identifier,
messageId,
artifactIndex,
location.pathname,
]);
useEffect(() => {

View File

@@ -1,52 +1,15 @@
import { useEffect, useRef } from 'react';
import debounce from 'lodash/debounce';
import { useLocation } from 'react-router-dom';
import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import type { Artifact } from '~/common';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { getFileType, logger } from '~/utils';
import { useLocalize } from '~/hooks';
import { getFileType } from '~/utils';
import store from '~/store';
const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
const localize = useLocalize();
const location = useLocation();
const setVisible = useSetRecoilState(store.artifactsVisibility);
const [artifacts, setArtifacts] = useRecoilState(store.artifactsState);
const setVisible = useSetRecoilState(store.artifactsVisible);
const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId);
const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId);
const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts);
const debouncedSetVisibleRef = useRef(
debounce((artifactToSet: Artifact) => {
logger.log(
'artifacts_visibility',
'Setting artifact to visible state from Artifact button',
artifactToSet,
);
setVisibleArtifacts((prev) => ({
...prev,
[artifactToSet.id]: artifactToSet,
}));
}, 750),
);
useEffect(() => {
if (artifact == null || artifact?.id == null || artifact.id === '') {
return;
}
if (!location.pathname.includes('/c/')) {
return;
}
const debouncedSetVisible = debouncedSetVisibleRef.current;
debouncedSetVisible(artifact);
return () => {
debouncedSetVisible.cancel();
};
}, [artifact, location.pathname]);
if (artifact === null || artifact === undefined) {
return null;
}
@@ -57,14 +20,8 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {
<button
type="button"
onClick={() => {
if (!location.pathname.includes('/c/')) {
return;
}
resetCurrentArtifactId();
setVisible(true);
if (artifacts?.[artifact.id] == null) {
setArtifacts(visibleArtifacts);
}
setTimeout(() => {
setCurrentArtifactId(artifact.id);
}, 15);

View File

@@ -1,7 +1,7 @@
import { useRef, useState, useEffect } from 'react';
import { RefreshCw } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import * as Tabs from '@radix-ui/react-tabs';
import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw, X } from 'lucide-react';
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
@@ -18,7 +18,7 @@ export default function Artifacts() {
const previewRef = useRef<SandpackPreviewRef>();
const [isVisible, setIsVisible] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisibility);
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
useEffect(() => {
setIsVisible(true);
@@ -48,26 +48,37 @@ export default function Artifacts() {
setTimeout(() => setIsRefreshing(false), 750);
};
const closeArtifacts = () => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
};
return (
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
{/* Main Parent */}
<div className="flex h-full w-full items-center justify-center">
{/* Main Container */}
<div
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-500 ease-in-out ${
isVisible ? 'scale-100 opacity-100 blur-0' : 'scale-105 opacity-0 blur-sm'
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
isVisible
? 'translate-x-0 scale-100 opacity-100'
: 'translate-x-full scale-95 opacity-0'
}`}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border-medium bg-surface-primary-alt p-2">
<div className="flex items-center">
<button className="mr-2 text-text-secondary" onClick={closeArtifacts}>
<ArrowLeft className="h-4 w-4" />
<button
className="mr-2 text-text-secondary"
onClick={() => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z" />
</svg>
</button>
<h3 className="truncate text-sm text-text-primary">{currentArtifact.title}</h3>
</div>
@@ -107,8 +118,22 @@ export default function Artifacts() {
{localize('com_ui_code')}
</Tabs.Trigger>
</Tabs.List>
<button className="text-text-secondary" onClick={closeArtifacts}>
<X className="h-4 w-4" />
<button
className="text-text-secondary"
onClick={() => {
setIsVisible(false);
setTimeout(() => setArtifactsVisible(false), 300);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z" />
</svg>
</button>
</div>
</div>
@@ -124,13 +149,29 @@ export default function Artifacts() {
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
<div className="flex items-center">
<button onClick={() => cycleArtifact('prev')} className="mr-2 text-text-secondary">
<ChevronLeft className="h-4 w-4" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M165.66,202.34a8,8,0,0,1-11.32,11.32l-80-80a8,8,0,0,1,0-11.32l80-80a8,8,0,0,1,11.32,11.32L91.31,128Z" />
</svg>
</button>
<span className="text-xs">{`${currentIndex + 1} / ${
orderedArtifactIds.length
}`}</span>
<button onClick={() => cycleArtifact('next')} className="ml-2 text-text-secondary">
<ChevronRight className="h-4 w-4" />
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
viewBox="0 0 256 256"
>
<path d="M181.66,133.66l-80,80a8,8,0,0,1-11.32-11.32L164.69,128,90.34,53.66a8,8,0,0,1,11.32-11.32l80,80A8,8,0,0,1,181.66,133.66Z" />
</svg>
</button>
</div>
<div className="flex items-center gap-2">

View File

@@ -35,7 +35,7 @@ export const CodeMarkdown = memo(
const [userScrolled, setUserScrolled] = useState(false);
const currentContent = content;
const rehypePlugins = [
[rehypeKatex],
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{

View File

@@ -2,33 +2,22 @@ import { memo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router-dom';
import { Constants } from 'librechat-data-provider';
import { useGetMessagesByConvoId } from 'librechat-data-provider/react-query';
import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { useChatHelpers, useAddedResponse, useSSE } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import MessagesView from './Messages/MessagesView';
import { Spinner } from '~/components/svg';
import Presentation from './Presentation';
import { buildTree, cn } from '~/utils';
import ChatForm from './Input/ChatForm';
import { buildTree } from '~/utils';
import Landing from './Landing';
import Header from './Header';
import Footer from './Footer';
import store from '~/store';
function LoadingSpinner() {
return (
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
</div>
);
}
function ChatView({ index = 0 }: { index?: number }) {
const { conversationId } = useParams();
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
@@ -59,15 +48,16 @@ function ChatView({ index = 0 }: { index?: number }) {
});
let content: JSX.Element | null | undefined;
const isLandingPage =
(!messagesTree || messagesTree.length === 0) &&
(conversationId === Constants.NEW_CONVO || !conversationId);
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
const isLandingPage = !messagesTree || messagesTree.length === 0;
if (isLoading && conversationId !== Constants.NEW_CONVO) {
content = <LoadingSpinner />;
} else if ((isLoading || isNavigating) && !isLandingPage) {
content = <LoadingSpinner />;
if (isLoading && conversationId !== 'new') {
content = (
<div className="relative flex-1 overflow-hidden overflow-y-auto">
<div className="relative flex h-full items-center justify-center">
<Spinner className="text-text-primary" />
</div>
</div>
);
} else if (!isLandingPage) {
content = <MessagesView messagesTree={messagesTree} />;
} else {
@@ -81,28 +71,27 @@ function ChatView({ index = 0 }: { index?: number }) {
<Presentation>
<div className="flex h-full w-full flex-col">
{!isLoading && <Header />}
<>
<div
className={cn(
'flex flex-col',
isLandingPage
? 'flex-1 items-center justify-end sm:justify-center'
: 'h-full overflow-y-auto',
)}
>
{isLandingPage ? (
<>
<div className="flex flex-1 flex-col items-center justify-end sm:justify-center">
{content}
<div className="w-full max-w-3xl transition-all duration-200 xl:max-w-4xl">
<ChatForm index={index} />
<ConversationStarters />
</div>
</div>
<Footer />
</>
) : (
<div className="flex h-full flex-col overflow-y-auto">
{content}
<div
className={cn(
'w-full',
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
<div className="w-full">
<ChatForm index={index} />
{isLandingPage ? <ConversationStarters /> : <Footer />}
<Footer />
</div>
</div>
{isLandingPage && <Footer />}
</>
)}
</div>
</Presentation>
</AddedChatContext.Provider>

View File

@@ -44,6 +44,15 @@ export default function ExportAndShareMenu({
};
const dropdownItems: t.MenuItemProps[] = [
{
label: localize('com_endpoint_export'),
onClick: exportHandler,
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: exportButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_ui_share'),
onClick: shareHandler,
@@ -54,15 +63,6 @@ export default function ExportAndShareMenu({
ref: shareButtonRef,
render: (props) => <button {...props} />,
},
{
label: localize('com_endpoint_export'),
onClick: exportHandler,
icon: <Upload className="icon-md mr-2 text-text-secondary" />,
/** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */
hideOnClick: false,
ref: exportButtonRef,
render: (props) => <button {...props} />,
},
];
return (
@@ -70,7 +70,6 @@ export default function ExportAndShareMenu({
<DropdownPopup
menuId={menuId}
focusLoop={true}
unmountOnHide={true}
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
trigger={
@@ -82,7 +81,7 @@ export default function ExportAndShareMenu({
aria-label="Export options"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Share2
<Upload
className="icon-md text-text-secondary"
aria-hidden="true"
focusable="false"

View File

@@ -36,7 +36,7 @@ export default function Header() {
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center gap-2">
<div className="mx-2 flex items-center gap-2">
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
{!navVisible && <HeaderNewChat />}
{<ModelSelector startupConfig={startupConfig} />}

View File

@@ -15,7 +15,6 @@ import {
useHandleKeyUp,
useQueryParams,
useSubmitMessage,
useFocusChatEffect,
} from '~/hooks';
import { mainTextareaId, BadgeItem } from '~/common';
import AttachFileChat from './Files/AttachFileChat';
@@ -37,7 +36,6 @@ import store from '~/store';
const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const submitButtonRef = useRef<HTMLButtonElement>(null);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
useFocusChatEffect(textAreaRef);
const [isCollapsed, setIsCollapsed] = useState(false);
const [, setIsScrollable] = useState(false);
@@ -45,6 +43,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false);
const [backupBadges, setBackupBadges] = useState<Pick<BadgeItem, 'id'>[]>([]);
const search = useRecoilValue(store.search);
const SpeechToText = useRecoilValue(store.speechToText);
const TextToSpeech = useRecoilValue(store.textToSpeech);
const chatDirection = useRecoilValue(store.chatDirection);
@@ -108,10 +107,6 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
);
const handleContainerClick = useCallback(() => {
/** Check if the device is a touchscreen */
if (window.matchMedia?.('(pointer: coarse)').matches) {
return;
}
textAreaRef.current?.focus();
}, []);
@@ -130,20 +125,13 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
});
const { submitMessage, submitPrompt } = useSubmitMessage();
const handleKeyUp = useHandleKeyUp({
index,
textAreaRef,
setShowPlusPopover,
setShowMentionPopover,
});
const {
isNotAppendable,
handlePaste,
handleKeyDown,
handleCompositionStart,
handleCompositionEnd,
} = useTextarea({
const { handlePaste, handleKeyDown, handleCompositionStart, handleCompositionEnd } = useTextarea({
textAreaRef,
submitButtonRef,
setIsScrollable,
@@ -163,6 +151,12 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
const textValue = useWatch({ control: methods.control, name: 'text' });
useEffect(() => {
if (!search.isSearching && textAreaRef.current && !disableInputs) {
textAreaRef.current.focus();
}
}, [search.isSearching, disableInputs]);
useEffect(() => {
if (textAreaRef.current) {
const style = window.getComputedStyle(textAreaRef.current);
@@ -262,7 +256,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
ref(e);
(textAreaRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = e;
}}
disabled={disableInputs || isNotAppendable}
disabled={disableInputs}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
@@ -282,7 +276,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
className={cn(
baseClasses,
removeFocusRings,
'transition-[max-height] duration-200 disabled:cursor-not-allowed',
'transition-[max-height] duration-200',
)}
/>
<div className="flex flex-col items-start justify-start pt-1.5">
@@ -317,7 +311,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
methods={methods}
ask={submitMessage}
textAreaRef={textAreaRef}
disabled={disableInputs || isNotAppendable}
disabled={disableInputs}
isSubmitting={isSubmitting}
/>
)}
@@ -329,7 +323,7 @@ const ChatForm = memo(({ index = 0 }: { index?: number }) => {
<SendButton
ref={submitButtonRef}
control={methods.control}
disabled={filesLoading || isSubmitting || disableInputs || isNotAppendable}
disabled={filesLoading || isSubmitting || disableInputs}
/>
)
)}

View File

@@ -41,9 +41,9 @@ const CollapseChat = ({
)}
>
{isCollapsed ? (
<ChevronUp className="h-full w-full" />
) : (
<ChevronDown className="h-full w-full" />
) : (
<ChevronUp className="h-full w-full" />
)}
</button>
}

View File

@@ -119,7 +119,6 @@ const AttachFile = ({ disabled }: AttachFileProps) => {
isOpen={isPopoverActive}
setIsOpen={setIsPopoverActive}
modal={true}
unmountOnHide={true}
trigger={menuTrigger}
items={dropdownItems}
iconClassName="mr-0"

View File

@@ -31,8 +31,7 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
select: (data) => {
const serverNames = new Set<string>();
data.forEach((tool) => {
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
if (isMCP && tool.chatMenu !== false) {
if (tool.pluginKey.includes(Constants.mcp_delimiter)) {
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
serverNames.add(parts[parts.length - 1]);
}

View File

@@ -44,7 +44,7 @@ export default function PopoverButtons({
const endpoint = overrideEndpoint ?? endpointType ?? _endpoint ?? '';
const model = overrideModel ?? _model;
const isGenerativeModel = /gemini|learnlm|gemma/.test(model ?? '') ?? false;
const isGenerativeModel = model?.toLowerCase().includes('gemini') ?? false;
const isChatModel = (!isGenerativeModel && model?.toLowerCase().includes('chat')) ?? false;
const isTextModel = !isGenerativeModel && !isChatModel && /code|text/.test(model ?? '');
@@ -133,6 +133,7 @@ export default function PopoverButtons({
</Button>
))}
</div>
{/* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */}
{disabled ? null : (
<div className="flex w-[150px] items-center justify-end">
{additionalButtons[settingsView].map((button, index) => (

View File

@@ -201,7 +201,7 @@ function PromptsCommand({
<div className="popover border-token-border-light rounded-2xl border bg-surface-tertiary-alt p-2 shadow-lg">
<input
// The user expects focus to transition to the input field when the popover is opened
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={inputRef}
placeholder={localize('com_ui_command_usage_placeholder')}

View File

@@ -160,7 +160,6 @@ const BookmarkMenu: FC = () => {
focusLoop={true}
menuId={menuId}
isOpen={isMenuOpen}
unmountOnHide={true}
setIsOpen={setIsMenuOpen}
keyPrefix={`${conversationId}-bookmark-`}
trigger={

View File

@@ -20,7 +20,6 @@ export default function HeaderNewChat() {
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};

View File

@@ -113,9 +113,9 @@ const EditMessage = ({
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text: data.text,
}
...msg,
text: data.text,
}
: msg,
),
);

View File

@@ -184,7 +184,7 @@ const Markdown = memo(({ content = '', isLatestMessage }: TContentProps) => {
const rehypePlugins = useMemo(
() => [
[rehypeKatex],
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{

View File

@@ -13,7 +13,7 @@ import { langSubset } from '~/utils';
const MarkdownLite = memo(
({ content = '', codeExecution = true }: { content?: string; codeExecution?: boolean }) => {
const rehypePlugins: PluggableList = [
[rehypeKatex],
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{

View File

@@ -35,7 +35,7 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) =>
} else {
return <>{text}</>;
}
}, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]);
}, [isCreatedByUser, enableUserMsgMarkdown, text, showCursorState, isLatestMessage]);
return (
<div

View File

@@ -14,9 +14,10 @@ export default function MessagesView({
messagesTree?: TMessage[] | null;
}) {
const localize = useLocalize();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const maximizeChatSpace = useRecoilValue(store.maximizeChatSpace);
const fontSize = useRecoilValue(store.fontSize);
const { screenshotTargetRef } = useScreenshot();
const scrollButtonPreference = useRecoilValue(store.showScrollButton);
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const {

View File

@@ -13,7 +13,7 @@ export default function SearchButtons({ message }: { message: TMessage }) {
const localize = useLocalize();
const queryClient = useQueryClient();
const search = useRecoilValue(store.search);
const { navigateToConvo } = useNavigateToConvo();
const { navigateWithLastTools } = useNavigateToConvo();
const conversationId = message.conversationId ?? '';
const clickHandler = async (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -39,13 +39,14 @@ export default function SearchButtons({ message }: { message: TMessage }) {
}
document.title = title;
navigateToConvo(
navigateWithLastTools(
cachedConvo ??
({
conversationId,
title,
} as TConversation),
{ resetLatestMessage: true },
true,
true,
);
};

View File

@@ -12,7 +12,7 @@ import store from '~/store';
export default function Presentation({ children }: { children: React.ReactNode }) {
const artifacts = useRecoilValue(store.artifactsState);
const artifactsVisibility = useRecoilValue(store.artifactsVisibility);
const artifactsVisible = useRecoilValue(store.artifactsVisible);
const setFilesToDelete = useSetFilesToDelete();
@@ -64,7 +64,7 @@ export default function Presentation({ children }: { children: React.ReactNode }
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
artifacts={
artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? (
artifactsVisible === true && Object.keys(artifacts ?? {}).length > 0 ? (
<EditorProvider>
<Artifacts />
</EditorProvider>

View File

@@ -220,7 +220,6 @@ const Conversations: FC<ConversationsProps> = ({
role="list"
aria-label="Conversations"
onRowsRendered={handleRowsRendered}
tabIndex={-1}
/>
)}
</AutoSizer>

View File

@@ -31,11 +31,11 @@ export default function Conversation({
const params = useParams();
const localize = useLocalize();
const { showToast } = useToastContext();
const { navigateToConvo } = useNavigateToConvo();
const { data: endpointsConfig } = useGetEndpointsQuery();
const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]);
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const activeConvos = useRecoilValue(store.allConversationsSelector);
const { data: endpointsConfig } = useGetEndpointsQuery();
const { navigateWithLastTools } = useNavigateToConvo();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { conversationId, title = '' } = conversation;
@@ -118,10 +118,10 @@ export default function Conversation({
document.title = title;
}
navigateToConvo(conversation, {
currentConvoId,
resetLatestMessage: !(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
});
navigateWithLastTools(
conversation,
!(conversationId ?? '') || conversationId === Constants.NEW_CONVO,
);
};
const convoOptionsProps = {

View File

@@ -235,11 +235,10 @@ function ConvoOptions({
<DeleteButton
title={title ?? ''}
retainView={retainView}
triggerRef={deleteButtonRef}
setMenuOpen={setIsPopoverActive}
showDeleteDialog={showDeleteDialog}
conversationId={conversationId ?? ''}
showDeleteDialog={showDeleteDialog}
setShowDeleteDialog={setShowDeleteDialog}
triggerRef={deleteButtonRef}
/>
)}
</>

View File

@@ -4,12 +4,13 @@ import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import type { TMessage } from 'librechat-data-provider';
import {
Button,
Spinner,
Label,
OGDialog,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
OGDialogHeader,
Button,
Spinner,
} from '~/components';
import { useDeleteConversationMutation } from '~/data-provider';
import { useLocalize, useNewConvo } from '~/hooks';
@@ -23,17 +24,14 @@ type DeleteButtonProps = {
showDeleteDialog?: boolean;
setShowDeleteDialog?: (value: boolean) => void;
triggerRef?: React.RefObject<HTMLButtonElement>;
setMenuOpen?: React.Dispatch<React.SetStateAction<boolean>>;
};
export function DeleteConversationDialog({
setShowDeleteDialog,
conversationId,
setMenuOpen,
retainView,
title,
}: {
setMenuOpen?: React.Dispatch<React.SetStateAction<boolean>>;
setShowDeleteDialog: (value: boolean) => void;
conversationId: string;
retainView: () => void;
@@ -53,7 +51,6 @@ export function DeleteConversationDialog({
newConversation();
navigate('/c/new', { replace: true });
}
setMenuOpen?.(false);
retainView();
},
onError: () => {
@@ -101,7 +98,6 @@ export default function DeleteButton({
conversationId,
retainView,
title,
setMenuOpen,
showDeleteDialog,
setShowDeleteDialog,
triggerRef,
@@ -119,7 +115,6 @@ export default function DeleteButton({
<DeleteConversationDialog
setShowDeleteDialog={setShowDeleteDialog}
conversationId={conversationId}
setMenuOpen={setMenuOpen}
retainView={retainView}
title={title}
/>

View File

@@ -1,13 +1,21 @@
import React, { useState, useRef } from 'react';
import { useRecoilState } from 'recoil';
import * as Ariakit from '@ariakit/react';
import { VisuallyHidden } from '@ariakit/react';
import { GitFork, InfoIcon } from 'lucide-react';
import * as Popover from '@radix-ui/react-popover';
import { ForkOptions } 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 { TranslationKeys, useLocalize, useNavigateToConvo } from '~/hooks';
import { useForkConvoMutation } from '~/data-provider';
import { useToastContext } from '~/Providers';
import { ESide } from '~/common';
import { cn } from '~/utils';
import store from '~/store';
@@ -16,11 +24,11 @@ interface PopoverButtonProps {
setting: ForkOptions;
onClick: (setting: ForkOptions) => void;
setActiveSetting: React.Dispatch<React.SetStateAction<TranslationKeys>>;
sideOffset?: number;
timeoutRef: React.MutableRefObject<NodeJS.Timeout | null>;
hoverInfo?: React.ReactNode | string;
hoverTitle?: React.ReactNode | string;
hoverDescription?: React.ReactNode | string;
label: string;
}
const optionLabels: Record<ForkOptions, TranslationKeys> = {
@@ -30,84 +38,57 @@ const optionLabels: Record<ForkOptions, TranslationKeys> = {
[ForkOptions.DEFAULT]: 'com_ui_fork_from_message',
};
const chevronDown = (
<svg width="1em" height="1em" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
</svg>
);
const PopoverButton: React.FC<PopoverButtonProps> = ({
children,
setting,
onClick,
setActiveSetting,
sideOffset = 30,
timeoutRef,
hoverInfo,
hoverTitle,
hoverDescription,
label,
}) => {
const localize = useLocalize();
return (
<Ariakit.HovercardProvider>
<div className="flex flex-col items-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
onClick={() => 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[ForkOptions.DEFAULT]);
}, 175);
}}
className="mx-1 max-w-14 flex-1 rounded-lg border-2 border-border-medium bg-surface-secondary text-text-secondary transition duration-300 ease-in-out hover:border-border-xheavy hover:bg-surface-hover hover:text-text-primary"
aria-label={label}
>
{children}
<VisuallyHidden>{label}</VisuallyHidden>
</Ariakit.Button>
<HoverCard openDelay={200}>
<Popover.Close
onClick={() => onClick(setting)}
onMouseEnter={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_details_about', { 0: label })}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<Ariakit.Hovercard
gutter={16}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
setActiveSetting(optionLabels[setting]);
}}
onMouseLeave={() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setActiveSetting(optionLabels[ForkOptions.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}
</Popover.Close>
{((hoverInfo != null && hoverInfo !== '') ||
(hoverTitle != null && hoverTitle !== '') ||
(hoverDescription != null && hoverDescription !== '')) && (
<HoverCardPortal>
<HoverCardContent side="right" className="z-[999] w-80 dark:bg-gray-700" sideOffset={sideOffset}>
<div className="space-y-2">
<p className="flex flex-col gap-2 text-sm text-text-secondary">
<p className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-300">
{hoverInfo && hoverInfo}
{hoverTitle && <span className="flex flex-wrap gap-1 font-bold">{hoverTitle}</span>}
{hoverDescription && hoverDescription}
</p>
</div>
</Ariakit.Hovercard>
)}
</div>
</Ariakit.HovercardProvider>
</HoverCardContent>
</HoverCardPortal>
)}
</HoverCard>
);
};
@@ -133,9 +114,6 @@ export default function Fork({
const [activeSetting, setActiveSetting] = useState(optionLabels.default);
const [splitAtTarget, setSplitAtTarget] = useRecoilState(store.splitAtTarget);
const [rememberGlobal, setRememberGlobal] = useRecoilState(store.rememberDefaultFork);
const popoverStore = Ariakit.usePopoverStore({
placement: 'top',
});
const forkConvo = useForkConvoMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
@@ -179,230 +157,173 @@ export default function Fork({
};
return (
<>
<Ariakit.PopoverAnchor
store={popoverStore}
render={
<button
className={cn(
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast
? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100'
: '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
} else {
popoverStore.toggle();
}
}}
type="button"
aria-label={localize('com_ui_fork')}
<Popover.Root>
<Popover.Trigger asChild>
<button
className={cn(
'hover-button active rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-500 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible ',
'data-[state=open]:active focus:opacity-100 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-500 data-[state=open]:dark:bg-gray-700 data-[state=open]:dark:text-gray-200',
!isLast ? 'data-[state=open]:opacity-100 md:opacity-0 md:group-hover:opacity-100' : '',
)}
onClick={(e) => {
if (rememberGlobal) {
e.preventDefault();
forkConvo.mutate({
messageId,
splitAtTarget,
conversationId,
option: forkSetting,
latestMessageId,
});
}
}}
type="button"
title={localize('com_ui_fork')}
>
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
</Popover.Trigger>
<Popover.Portal>
<div dir="ltr">
<Popover.Content
side="top"
role="menu"
className="bg-token-surface-primary flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg bg-white p-2 px-3 shadow-lg dark:bg-gray-700"
style={{ outline: 'none', pointerEvents: 'auto', boxSizing: 'border-box' }}
tabIndex={-1}
sideOffset={5}
align="center"
>
<GitFork className="h-4 w-4 hover:text-gray-500 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
</button>
}
/>
<Ariakit.Popover
store={popoverStore}
gutter={5}
className="flex min-h-[120px] min-w-[215px] flex-col gap-3 overflow-hidden rounded-lg border border-border-heavy bg-surface-secondary p-2 px-3 shadow-lg"
style={{
outline: 'none',
pointerEvents: 'auto',
zIndex: 50,
}}
portal={true}
unmountOnHide={true}
>
<div className="flex h-8 w-full items-center justify-center text-sm text-text-primary">
{localize(activeSetting)}
<Ariakit.HovercardProvider>
<div className="ml-auto flex h-6 w-6 items-center justify-center gap-1">
<Ariakit.HovercardAnchor
render={
<button
className="flex h-5 w-5 items-center rounded-full text-text-secondary"
aria-label={localize('com_ui_fork_info_button_label')}
<div className="flex h-6 w-full items-center justify-center text-sm dark:text-gray-200">
{localize(activeSetting )}
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<InfoIcon className="ml-auto flex h-4 w-4 gap-2 text-gray-500 dark:text-white/50" />
</HoverCardTrigger>
<HoverCardPortal>
<HoverCardContent
side="right"
className="z-[999] w-80 dark:bg-gray-700"
sideOffset={19}
>
<InfoIcon />
</button>
<div className="flex flex-col gap-2 space-y-2 text-sm text-gray-600 dark:text-gray-300">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
sideOffset={155}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>{localize('com_ui_fork_more_info_options')}</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
hoverDescription={localize('com_ui_fork_info_visible')}
>
<HoverCardTrigger asChild>
<GitCommit className="h-full w-full rotate-90 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={90}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<HoverCardTrigger asChild>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</HoverCardTrigger>
</PopoverButton>
<PopoverButton
sideOffset={25}
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(
optionLabels[ForkOptions.TARGET_LEVEL],
)} (${localize('com_endpoint_default')})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<HoverCardTrigger asChild>
<ListTree className="h-full w-full p-2" />
</HoverCardTrigger>
</PopoverButton>
</div>
<Ariakit.Hovercard
gutter={19}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="flex flex-col gap-2 space-y-2 text-sm text-text-secondary">
<span>{localize('com_ui_fork_info_1')}</span>
<span>{localize('com_ui_fork_info_2')}</span>
<span>
{localize('com_ui_fork_info_3', {
0: localize('com_ui_fork_split_target'),
})}
</span>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</div>
<div className="flex h-full w-full items-center justify-center gap-1">
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.DIRECT_PATH}
label={localize(optionLabels[ForkOptions.DIRECT_PATH])}
hoverTitle={
<>
<GitCommit className="h-5 w-5 rotate-90" />
{localize(optionLabels[ForkOptions.DIRECT_PATH])}
</>
}
hoverDescription={localize('com_ui_fork_info_visible')}
>
<GitCommit className="h-full w-full rotate-90 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.INCLUDE_BRANCHES}
label={localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
hoverTitle={
<>
<GitBranchPlus className="h-4 w-4 rotate-180" />
{localize(optionLabels[ForkOptions.INCLUDE_BRANCHES])}
</>
}
hoverDescription={localize('com_ui_fork_info_branches')}
>
<GitBranchPlus className="h-full w-full rotate-180 p-2" />
</PopoverButton>
<PopoverButton
setActiveSetting={setActiveSetting}
timeoutRef={timeoutRef}
onClick={onClick}
setting={ForkOptions.TARGET_LEVEL}
label={localize(optionLabels[ForkOptions.TARGET_LEVEL])}
hoverTitle={
<>
<ListTree className="h-5 w-5" />
{`${localize(
optionLabels[ForkOptions.TARGET_LEVEL],
)} (${localize('com_endpoint_default')})`}
</>
}
hoverDescription={localize('com_ui_fork_info_target')}
>
<ListTree className="h-full w-full p-2" />
</PopoverButton>
</div>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary">
<Ariakit.Checkbox
id="split-target-checkbox"
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
checked={splitAtTarget}
onChange={(event) => setSplitAtTarget(event.target.checked)}
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_split_target')}
onCheckedChange={(checked: boolean) => setSplitAtTarget(checked)}
className="m-2 transition duration-300 ease-in-out"
/>
<label htmlFor="split-target-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_split_target')}
</label>
{localize('com_ui_fork_split_target')}
</div>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_split_target', {
0: localize('com_ui_fork_split_target'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={32}
className="z-[999] w-80 select-none rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_start')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
<Ariakit.HovercardProvider>
<div className="flex items-center">
<Ariakit.HovercardAnchor
render={
<div
onClick={() => setRemember((prev) => !prev)}
className="flex h-6 w-full select-none items-center justify-start rounded-md text-sm text-text-secondary hover:text-text-primary"
>
<Ariakit.Checkbox
id="remember-checkbox"
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_start"
langCode={true}
sideOffset={20}
/>
</HoverCard>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<div className="flex h-6 w-full items-center justify-start text-sm dark:text-gray-300 dark:hover:text-gray-200">
<Checkbox
checked={remember}
onChange={(event) => {
const checked = event.target.checked;
console.log('checked', checked);
onCheckedChange={(checked: boolean) => {
if (checked) {
showToast({
message: localize('com_ui_fork_remember_checked'),
status: 'info',
});
}
return setRemember(checked);
setRemember(checked);
}}
className="m-2 h-4 w-4 rounded-sm border border-primary ring-offset-background transition duration-300 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
aria-label={localize('com_ui_fork_remember')}
className="m-2 transition duration-300 ease-in-out"
/>
<label htmlFor="remember-checkbox" className="ml-2 cursor-pointer">
{localize('com_ui_fork_remember')}
</label>
{localize('com_ui_fork_remember')}
</div>
}
/>
<Ariakit.HovercardDisclosure className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring">
<VisuallyHidden>
{localize('com_ui_fork_more_info_remember', {
0: localize('com_ui_fork_remember'),
})}
</VisuallyHidden>
{chevronDown}
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
gutter={14}
className="z-[999] w-80 rounded-md border border-border-medium bg-surface-secondary p-4 text-text-primary shadow-md"
portal={true}
unmountOnHide={true}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_fork_info_remember')}</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
</Ariakit.Popover>
</>
</HoverCardTrigger>
<OptionHover
side={ESide.Right}
description="com_ui_fork_info_remember"
langCode={true}
sideOffset={20}
/>
</HoverCard>
</Popover.Content>
</div>
</Popover.Portal>
</Popover.Root>
);
}

View File

@@ -34,7 +34,10 @@ function getOpenAIColor(_model: string | null | undefined) {
function getGoogleIcon(model: string | null | undefined, size: number) {
if (model?.toLowerCase().includes('code') === true) {
return <CodeyIcon size={size * 0.75} />;
} else if (/gemini|learnlm|gemma/.test(model?.toLowerCase() ?? '')) {
} else if (
model?.toLowerCase().includes('gemini') === true ||
model?.toLowerCase().includes('learnlm') === true
) {
return <GeminiIcon size={size * 0.7} />;
} else {
return <PaLMIcon size={size * 0.7} />;
@@ -49,8 +52,6 @@ function getGoogleModelName(model: string | null | undefined) {
model?.toLowerCase().includes('learnlm') === true
) {
return 'Gemini';
} else if (model?.toLowerCase().includes('gemma') === true) {
return 'Gemma';
} else {
return 'PaLM2';
}

View File

@@ -61,7 +61,6 @@ export default function MobileNav({
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}}
>

View File

@@ -4,8 +4,8 @@ import { useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { QueryKeys, Constants } from 'librechat-data-provider';
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
import { createChatSearchParams, getDefaultModelSpec, getModelSpecPreset } from '~/utils';
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
import { TooltipAnchor, Button } from '~/components/ui';
import { useLocalize, useNewConvo } from '~/hooks';
import store from '~/store';
@@ -28,6 +28,7 @@ export default function NewChat({
const { newConversation: newConvo } = useNewConvo(index);
const navigate = useNavigate();
const localize = useLocalize();
const defaultPreset = useRecoilValue(store.defaultPreset);
const { conversation } = store.useCreateConversationAtom(index);
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
@@ -40,14 +41,18 @@ export default function NewChat({
[QueryKeys.messages, conversation?.conversationId ?? Constants.NEW_CONVO],
[],
);
queryClient.invalidateQueries([QueryKeys.messages]);
const startupConfig = queryClient.getQueryData<TStartupConfig>([QueryKeys.startupConfig]);
const defaultSpec = getDefaultModelSpec(startupConfig);
const preset = defaultSpec != null ? getModelSpecPreset(defaultSpec) : defaultPreset;
const params = createChatSearchParams(preset ?? conversation);
const newRoute = params.size > 0 ? `/c/new?${params.toString()}` : '/c/new';
newConvo();
navigate('/c/new', { state: { focusChat: true } });
navigate(newRoute);
if (isSmallScreen) {
toggleNav();
}
},
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
[queryClient, conversation, newConvo, navigate, toggleNav, defaultPreset, isSmallScreen],
);
return (

View File

@@ -1,7 +1,7 @@
import React, { forwardRef, useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { forwardRef, useState, useCallback, useMemo, useEffect, Ref } from 'react';
import debounce from 'lodash/debounce';
import { useRecoilState } from 'recoil';
import { Search, X } from 'lucide-react';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useLocation, useNavigate } from 'react-router-dom';
@@ -13,7 +13,7 @@ type SearchBarProps = {
isSmallScreen?: boolean;
};
const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivElement>) => {
const SearchBar = forwardRef((props: SearchBarProps, ref: Ref<HTMLDivElement>) => {
const localize = useLocalize();
const location = useLocation();
const queryClient = useQueryClient();
@@ -21,11 +21,11 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
const { isSmallScreen } = props;
const [text, setText] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [showClearIcon, setShowClearIcon] = useState(false);
const { newConversation } = useNewConvo();
const [search, setSearchState] = useRecoilState(store.search);
const setSearchState = useSetRecoilState(store.search);
const search = useRecoilValue(store.search);
const clearSearch = useCallback(() => {
if (location.pathname.includes('/search')) {
@@ -44,7 +44,6 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
isTyping: false,
}));
clearSearch();
inputRef.current?.focus();
}, [setSearchState, clearSearch]);
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -109,7 +108,6 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
<Search className="absolute left-3 h-4 w-4 text-text-secondary group-focus-within:text-text-primary group-hover:text-text-primary" />
<input
type="text"
ref={inputRef}
className="m-0 mr-0 w-full border-none bg-transparent p-0 pl-7 text-sm leading-tight placeholder-text-secondary placeholder-opacity-100 focus-visible:outline-none group-focus-within:placeholder-text-primary group-hover:placeholder-text-primary"
value={text}
onChange={onChange}
@@ -124,20 +122,14 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
autoComplete="off"
dir="auto"
/>
<button
type="button"
aria-label={`${localize('com_ui_clear')} ${localize('com_ui_search')}`}
<X
className={cn(
'absolute right-[7px] flex h-5 w-5 items-center justify-center rounded-full border-none bg-transparent p-0 transition-opacity duration-200',
'absolute right-[7px] h-5 w-5 cursor-pointer transition-opacity duration-200',
showClearIcon ? 'opacity-100' : 'opacity-0',
isSmallScreen === true ? 'right-[16px]' : '',
)}
onClick={clearText}
tabIndex={showClearIcon ? 0 : -1}
disabled={!showClearIcon}
>
<X className="h-5 w-5 cursor-pointer" />
</button>
/>
</div>
);
});

View File

@@ -44,7 +44,6 @@ export const ForkSettings = () => {
options={forkOptions}
sizeClasses="w-[200px]"
testId="fork-setting-dropdown"
className="z-[50]"
/>
</div>
</div>

View File

@@ -102,8 +102,8 @@ export default function LanguageSTTDropdown() {
onChange={handleSelect}
options={languageOptions}
sizeClasses="[--anchor-max-height:256px]"
anchor="bottom start"
testId="LanguageSTTDropdown"
className="z-50"
/>
</div>
);

View File

@@ -66,7 +66,7 @@ const AdminSettings = () => {
const [confirmAdminUseChange, setConfirmAdminUseChange] = useState<{
newValue: boolean;
callback: (value: boolean) => void;
} | null>(null);
} | null>(null);
const { mutate, isLoading } = useUpdatePromptPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
@@ -166,7 +166,6 @@ const AdminSettings = () => {
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="prompt-role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
@@ -192,11 +191,11 @@ const AdminSettings = () => {
setValue={setValue}
{...(selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE
? {
confirmChange: (
newValue: boolean,
onChange: (value: boolean) => void,
) => setConfirmAdminUseChange({ newValue, callback: onChange }),
}
confirmChange: (
newValue: boolean,
onChange: (value: boolean) => void,
) => setConfirmAdminUseChange({ newValue, callback: onChange }),
}
: {})}
/>
{selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && (

View File

@@ -28,9 +28,9 @@ const categoryColorMap: Record<string, string> = {
code: 'text-red-500',
misc: 'text-blue-300',
shop: 'text-purple-400',
idea: 'text-yellow-500/90 dark:text-yellow-300 ',
idea: 'text-yellow-300',
write: 'text-purple-400',
travel: 'text-yellow-500/90 dark:text-yellow-300 ',
travel: 'text-yellow-300',
finance: 'text-orange-400',
roleplay: 'text-orange-400',
teach_or_explain: 'text-blue-300',

View File

@@ -1,6 +1,4 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { ReactNode } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { LocalStorageKeys } from 'librechat-data-provider';
import { Dropdown } from '~/components/ui';
@@ -17,7 +15,6 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
onValueChange,
className = '',
}) => {
const { t } = useTranslation();
const formContext = useFormContext();
const { categories, emptyCategory } = useCategories();
@@ -35,25 +32,13 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
[watchedCategory, categories, currentCategory, emptyCategory],
);
const displayCategory = useMemo(() => {
if (!categoryOption.value && !('icon' in categoryOption)) {
return {
...categoryOption,
icon: (<span className="i-heroicons-tag" />) as ReactNode,
label: categoryOption.label || t('com_ui_empty_category'),
};
}
return categoryOption;
}, [categoryOption, t]);
return formContext ? (
<Controller
name="category"
control={control}
render={() => (
<Dropdown
value={displayCategory.value ?? ''}
label={displayCategory.value ? undefined : t('com_ui_category')}
value={categoryOption.value ?? ''}
onChange={(value: string) => {
setValue('category', value, { shouldDirty: false });
localStorage.setItem(LocalStorageKeys.LAST_PROMPT_CATEGORY, value);
@@ -63,12 +48,10 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={() => (
renderValue={(option) => (
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span>{option.label}</span>
</div>
)}
/>
@@ -85,12 +68,10 @@ const CategorySelector: React.FC<CategorySelectorProps> = ({
ariaLabel="Prompt's category selector"
className={className}
options={categories || []}
renderValue={() => (
renderValue={(option) => (
<div className="flex items-center space-x-2">
{'icon' in displayCategory && displayCategory.icon != null && (
<span>{displayCategory.icon as ReactNode}</span>
)}
<span>{displayCategory.label}</span>
{option.icon != null && <span>{option.icon as React.ReactNode}</span>}
<span>{option.label}</span>
</div>
)}
/>

View File

@@ -57,7 +57,7 @@ function ChatGroupItem({
snippet={
typeof group.oneliner === 'string' && group.oneliner.length > 0
? group.oneliner
: (group.productionPrompt?.prompt ?? '')
: group.productionPrompt?.prompt ?? ''
}
>
<div className="flex flex-row items-center gap-2">
@@ -83,11 +83,7 @@ function ChatGroupItem({
className="z-50 inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border-medium bg-transparent p-0 text-sm font-medium transition-all duration-300 ease-in-out hover:border-border-heavy hover:bg-surface-hover focus:border-border-heavy focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
>
<MenuIcon className="icon-md text-text-secondary" aria-hidden="true" />
<span className="sr-only">
{localize('com_ui_sr_actions_menu', { 0: group.name }) +
' ' +
localize('com_ui_prompt')}
</span>
<span className="sr-only">Open actions menu for {group.name}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@@ -7,7 +7,6 @@ import PromptVariables from '~/components/Prompts/PromptVariables';
import { Button, TextareaAutosize, Input } from '~/components/ui';
import Description from '~/components/Prompts/Description';
import { useLocalize, useHasAccess } from '~/hooks';
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
import Command from '~/components/Prompts/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils';
@@ -133,8 +132,7 @@ const CreatePromptForm = ({
<div className="flex w-full flex-col gap-4 md:mt-[1.075rem]">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-medium py-2 pl-4 pr-1 text-base font-semibold dark:text-gray-200">
<span>{localize('com_ui_prompt_text')}*</span>
<VariablesDropdown fieldName="prompt" className="mr-2" />
{localize('com_ui_prompt_text')}*
</h2>
<div className="min-h-32 rounded-b-lg border border-border-medium p-4 transition-all duration-150">
<Controller

View File

@@ -31,7 +31,7 @@ const VariableDialog: React.FC<VariableDialogProps> = ({ open, onClose, group })
return (
<OGDialog open={open} onOpenChange={handleOpenChange}>
<OGDialogContent className="max-h-[90vh] max-w-full overflow-y-auto bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:max-w-[60vw]">
<OGDialogContent className="max-w-full bg-white dark:border-gray-700 dark:bg-gray-850 dark:text-gray-300 md:max-w-3xl">
<OGDialogTitle>{group.name}</OGDialogTitle>
<VariableForm group={group} onClose={onClose} />
</OGDialogContent>

View File

@@ -5,14 +5,18 @@ import supersub from 'remark-supersub';
import rehypeKatex from 'rehype-katex';
import ReactMarkdown from 'react-markdown';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import { useForm, useFieldArray, Controller, useWatch } from 'react-hook-form';
import type { TPromptGroup } from 'librechat-data-provider';
import { cn, wrapVariable, defaultTextProps, extractVariableInfo } from '~/utils';
import {
cn,
wrapVariable,
defaultTextProps,
replaceSpecialVars,
extractVariableInfo,
} from '~/utils';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { TextareaAutosize, InputCombobox, Button } from '~/components/ui';
import { useAuthContext, useLocalize, useSubmitMessage } from '~/hooks';
import { PromptVariableGfm } from '../Markdown';
type FieldType = 'text' | 'select';
@@ -111,12 +115,9 @@ export default function VariableForm({
allVariables.forEach((variable) => {
const placeholder = `{{${variable}}}`;
const fieldIndex = variableIndexMap.get(variable) as string | number;
const fieldValue = fieldValues[fieldIndex].value as string | undefined;
if (fieldValue === placeholder || fieldValue === '' || !fieldValue) {
return;
}
const highlightText = fieldValue !== '' ? `**${fieldValue}**` : placeholder;
tempText = tempText.replaceAll(placeholder, highlightText);
const fieldValue = fieldValues[fieldIndex].value as string;
const highlightText = fieldValue !== '' ? fieldValue : placeholder;
tempText = tempText.replaceAll(placeholder, `**${highlightText}**`);
});
return tempText;
};
@@ -140,19 +141,19 @@ export default function VariableForm({
return (
<div className="mx-auto p-1 md:container">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-surface-tertiary p-4 text-text-secondary dark:bg-surface-primary sm:max-w-full md:max-h-96">
<div className="mb-6 max-h-screen max-w-[90vw] overflow-auto rounded-md bg-gray-100 p-4 text-text-secondary dark:bg-gray-700/50 sm:max-w-full md:max-h-80">
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex],
[rehypeKatex, { output: 'mathml' }],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
/** @ts-ignore */
components={{ code: codeNoExecution, p: PromptVariableGfm }}
className="markdown prose dark:prose-invert light my-1 max-h-[50vh] max-w-full break-words dark:text-text-secondary"
components={{ code: codeNoExecution }}
className="prose dark:prose-invert light dark:text-gray-70 my-1 max-h-[50vh] break-words"
>
{generateHighlightedMarkdown()}
</ReactMarkdown>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { handleDoubleClick } from '~/utils';
export const CodeVariableGfm: React.ElementType = ({ children }: { children: React.ReactNode }) => {
export const CodeVariableGfm = ({ children }: { children: React.ReactNode }) => {
return (
<code
onDoubleClick={handleDoubleClick}
@@ -29,10 +29,7 @@ export const PromptVariableGfm = ({
const parts = child.split(regex);
return parts.map((part, index) =>
index % 2 === 1 ? (
<b
key={index}
className="ml-[0.5] rounded-lg bg-amber-100 p-[1px] font-medium text-yellow-800 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90"
>
<b key={index} className="rounded-md bg-yellow-100/90 p-1 text-gray-700">
{`{{${part}}}`}
</b>
) : (

View File

@@ -13,7 +13,7 @@ const PreviewPrompt = ({
}) => {
return (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="max-h-[90vh] w-11/12 max-w-full overflow-y-auto md:max-w-[60vw]">
<OGDialogContent className="w-11/12 max-w-5xl">
<div className="p-2">
<PromptDetails group={group} />
</div>

View File

@@ -5,13 +5,13 @@ import rehypeKatex from 'rehype-katex';
import remarkMath from 'remark-math';
import supersub from 'remark-supersub';
import rehypeHighlight from 'rehype-highlight';
import { replaceSpecialVars } from 'librechat-data-provider';
import type { TPromptGroup } from 'librechat-data-provider';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import { useLocalize, useAuthContext } from '~/hooks';
import CategoryIcon from './Groups/CategoryIcon';
import PromptVariables from './PromptVariables';
import { PromptVariableGfm } from './Markdown';
import { replaceSpecialVars } from '~/utils';
import { Label } from '~/components/ui';
import Description from './Description';
import Command from './Command';
@@ -46,7 +46,7 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
<div className="flex h-full max-h-screen flex-col overflow-y-auto md:flex-row">
<div className="flex flex-1 flex-col gap-4 p-0 md:max-h-[calc(100vh-150px)] md:p-2">
<div>
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary">
<h2 className="flex items-center justify-between rounded-t-lg border border-border-light py-2 pl-4 text-base font-semibold text-text-primary ">
{localize('com_ui_prompt_text')}
</h2>
<div className="group relative min-h-32 rounded-b-lg border border-border-light p-4 transition-all duration-150">
@@ -59,13 +59,13 @@ const PromptDetails = ({ group }: { group?: TPromptGroup }) => {
]}
rehypePlugins={[
/** @ts-ignore */
[rehypeKatex],
[rehypeKatex, { output: 'mathml' }],
/** @ts-ignore */
[rehypeHighlight, { ignoreMissing: true }],
]}
/** @ts-ignore */
components={{ p: PromptVariableGfm, code: codeNoExecution }}
className="markdown prose dark:prose-invert light dark:text-gray-70 my-1 break-words"
className="prose dark:prose-invert light dark:text-gray-70 my-1"
>
{mainText}
</ReactMarkdown>

View File

@@ -12,7 +12,6 @@ import ReactMarkdown from 'react-markdown';
import { codeNoExecution } from '~/components/Chat/Messages/Content/Markdown';
import AlwaysMakeProd from '~/components/Prompts/Groups/AlwaysMakeProd';
import { SaveIcon, CrossIcon } from '~/components/svg';
import VariablesDropdown from './VariablesDropdown';
import { TextareaAutosize } from '~/components/ui';
import { PromptVariableGfm } from './Markdown';
import { PromptsEditorMode } from '~/common';
@@ -43,7 +42,7 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
}, [isEditing, prompt]);
const rehypePlugins: PluggableList = [
[rehypeKatex],
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
{
@@ -60,11 +59,10 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
<span className="max-w-[200px] truncate sm:max-w-none">
{localize('com_ui_prompt_text')}
</span>
<div className="flex flex-shrink-0 flex-row items-center gap-3 sm:gap-6">
<div className="flex flex-shrink-0 flex-row gap-3 sm:gap-6">
{editorMode === PromptsEditorMode.ADVANCED && (
<AlwaysMakeProd className="hidden sm:flex" />
)}
<VariablesDropdown fieldName={name} />
<button
type="button"
onClick={() => setIsEditing((prev) => !prev)}
@@ -107,7 +105,6 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
isEditing ? (
<TextareaAutosize
{...field}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
className="w-full resize-none overflow-y-auto rounded bg-transparent text-sm text-text-primary focus:outline-none sm:text-base"
minRows={3}
@@ -126,8 +123,8 @@ const PromptEditor: React.FC<Props> = ({ name, isEditing, setIsEditing }) => {
style={{ minHeight: '4.5em', maxHeight: '21em', overflow: 'auto' }}
>
<ReactMarkdown
/** @ts-ignore */
remarkPlugins={[
/** @ts-ignore */
supersub,
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],

View File

@@ -1,18 +1,18 @@
import React, { useMemo } from 'react';
import { Variable } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { specialVariables } from 'librechat-data-provider';
import { cn, extractUniqueVariables } from '~/utils';
import { CodeVariableGfm } from './Markdown';
import { Separator } from '~/components/ui';
import { useLocalize } from '~/hooks';
const specialVariableClasses =
'bg-amber-100 text-yellow-800 border-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90';
const specialVariables = {
current_date: true,
current_user: true,
};
const components: {
[nodeType: string]: React.ElementType;
} = { code: CodeVariableGfm };
const specialVariableClasses =
'bg-yellow-500/25 text-yellow-600 dark:border-yellow-500/50 dark:bg-transparent dark:text-yellow-500/90';
const PromptVariables = ({
promptText,
@@ -52,7 +52,7 @@ const PromptVariables = ({
</div>
) : (
<div className="text-sm text-text-secondary">
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_variables_info')}
</ReactMarkdown>
</div>
@@ -65,8 +65,8 @@ const PromptVariables = ({
{localize('com_ui_special_variables')}
</span>
<span className="text-sm text-text-secondary">
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
{localize('com_ui_special_variables_more_info')}
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_special_variables_info')}
</ReactMarkdown>
</span>
</div>
@@ -75,7 +75,7 @@ const PromptVariables = ({
{localize('com_ui_dropdown_variables')}
</span>
<span className="text-sm text-text-secondary">
<ReactMarkdown components={components} className="markdown prose dark:prose-invert">
<ReactMarkdown components={{ code: CodeVariableGfm }}>
{localize('com_ui_dropdown_variables_info')}
</ReactMarkdown>
</span>

View File

@@ -1,75 +0,0 @@
import { useState, useId } from 'react';
import { PlusCircle } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { useFormContext } from 'react-hook-form';
import { specialVariables } from 'librechat-data-provider';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import { DropdownPopup } from '~/components';
import { useLocalize } from '~/hooks';
interface VariableOption {
label: TSpecialVarLabel;
value: string;
}
const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({
label: `com_ui_special_var_${key}` as TSpecialVarLabel,
value: `{{${key}}}`,
}));
interface VariablesDropdownProps {
fieldName?: string;
className?: string;
}
export default function VariablesDropdown({
fieldName = 'prompt',
className = '',
}: VariablesDropdownProps) {
const menuId = useId();
const localize = useLocalize();
const methods = useFormContext();
const { setValue, getValues } = methods;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const handleAddVariable = (label: TSpecialVarLabel, value: string) => {
const currentText = getValues(fieldName) || '';
const spacer = currentText.length > 0 ? '\n\n' : '';
const prefix = localize(label);
setValue(fieldName, currentText + spacer + prefix + ': ' + value);
setIsMenuOpen(false);
};
return (
<div
className={className}
title={`${localize('com_ui_add')} ${localize('com_ui_special_variables')}`}
>
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label={`${localize('com_ui_add')} ${localize('com_ui_special_variables')}`}
className="flex h-8 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<PlusCircle className="mr-1 h-3 w-3 text-text-secondary" aria-hidden={true} />
{localize('com_ui_special_variables')}
</Menu.MenuButton>
}
items={variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}))}
menuId={menuId}
className="z-30"
/>
</div>
);
}

View File

@@ -1,7 +1,6 @@
import { useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import MultiMessage from './MultiMessage';
import { useLocalize } from '~/hooks';
export default function MessagesView({
messagesTree: _messagesTree,
@@ -10,7 +9,6 @@ export default function MessagesView({
messagesTree?: TMessage[] | null;
conversationId: string;
}) {
const localize = useLocalize();
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
return (
<div className="flex-1 pb-[50px]">
@@ -25,7 +23,7 @@ export default function MessagesView({
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
{localize('com_ui_nothing_found')}
Nothing found
</div>
) : (
<>

View File

@@ -157,7 +157,6 @@ const AdminSettings = () => {
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}

View File

@@ -10,7 +10,6 @@ import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { icons } from '~/hooks/Endpoint/Icons';
import { processAgentOption } from '~/utils';
import Instructions from './Instructions';
import AgentAvatar from './AgentAvatar';
import FileContext from './FileContext';
import { useLocalize } from '~/hooks';
@@ -229,7 +228,39 @@ export default function AgentConfig({
/>
</div>
{/* Instructions */}
<Instructions />
<div className="mb-4">
<label className={labelClass} htmlFor="instructions">
{localize('com_ui_instructions')}
</label>
<Controller
name="instructions"
control={control}
render={({ field, fieldState: { error } }) => (
<>
<textarea
{...field}
value={field.value ?? ''}
// maxLength={32768}
className={cn(inputClass, 'min-h-[100px] resize-y')}
id="instructions"
placeholder={localize('com_agents_instructions_placeholder')}
rows={3}
aria-label="Agent instructions"
aria-required="true"
aria-invalid={error ? 'true' : 'false'}
/>
{error && (
<span
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
>
{localize('com_ui_field_required')}
</span>
)}
</>
)}
/>
</div>
{/* Model and Provider */}
<div className="mb-4">
<label className={labelClass} htmlFor="provider">

View File

@@ -4,7 +4,6 @@ import { useWatch, useForm, FormProvider } from 'react-hook-form';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import {
Tools,
Constants,
SystemRoles,
EModelEndpoint,
isAssistantsEndpoint,
@@ -46,7 +45,7 @@ export default function AgentPanel({
const modelsQuery = useGetModelsQuery();
const agentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
enabled: !!(current_agent_id ?? '') && current_agent_id !== Constants.EPHEMERAL_AGENT_ID,
enabled: !!(current_agent_id ?? ''),
});
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);

View File

@@ -1,127 +0,0 @@
import React, { useState, useId } from 'react';
import { PlusCircle } from 'lucide-react';
import * as Menu from '@ariakit/react/menu';
import { specialVariables } from 'librechat-data-provider';
import type { TSpecialVarLabel } from 'librechat-data-provider';
import { Controller, useFormContext } from 'react-hook-form';
import type { AgentForm } from '~/common';
import { cn, defaultTextProps, removeFocusOutlines } from '~/utils';
// import ControlCombobox from '~/components/ui/ControlCombobox';
import { DropdownPopup } from '~/components';
import { useLocalize } from '~/hooks';
const inputClass = cn(
defaultTextProps,
'flex w-full px-3 py-2 border-border-light bg-surface-secondary focus-visible:ring-2 focus-visible:ring-ring-primary',
removeFocusOutlines,
);
interface VariableOption {
label: TSpecialVarLabel;
value: string;
}
const variableOptions: VariableOption[] = Object.keys(specialVariables).map((key) => ({
label: `com_ui_special_var_${key}` as TSpecialVarLabel,
value: `{{${key}}}`,
}));
export default function Instructions() {
const menuId = useId();
const localize = useLocalize();
const methods = useFormContext<AgentForm>();
const { control, setValue, getValues } = methods;
const [isMenuOpen, setIsMenuOpen] = useState(false);
const handleAddVariable = (label: TSpecialVarLabel, value: string) => {
const currentInstructions = getValues('instructions') || '';
const spacer = currentInstructions.length > 0 ? '\n' : '';
const prefix = localize(label);
setValue('instructions', currentInstructions + spacer + prefix + ': ' + value);
setIsMenuOpen(false);
};
return (
<div className="mb-4">
<div className="mb-2 flex items-center">
<label className="text-token-text-primary flex-grow font-medium" htmlFor="instructions">
{localize('com_ui_instructions')}
</label>
<div className="ml-auto" title="Add variables to instructions">
{/* ControlCombobox implementation
<ControlCombobox
selectedValue=""
displayValue="Add variables"
items={variableOptions.map((option) => ({
label: option.label,
value: option.value,
}))}
setValue={handleAddVariable}
ariaLabel="Add variable to instructions"
searchPlaceholder="Search variables"
selectPlaceholder="Add"
isCollapsed={false}
SelectIcon={<PlusCircle className="h-3 w-3 text-text-secondary" />}
containerClassName="w-fit"
className="h-7 gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
iconSide="left"
showCarat={false}
/>
*/}
<DropdownPopup
portal={true}
mountByState={true}
unmountOnHide={true}
preserveTabOrder={true}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
trigger={
<Menu.MenuButton
id="variables-menu-button"
aria-label="Add variable to instructions"
className="flex h-7 items-center gap-1 rounded-md border border-border-medium bg-surface-secondary px-2 py-0 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-tertiary"
>
<PlusCircle className="mr-1 h-3 w-3 text-text-secondary" aria-hidden={true} />
{localize('com_ui_variables')}
</Menu.MenuButton>
}
items={variableOptions.map((option) => ({
label: localize(option.label) || option.label,
onClick: () => handleAddVariable(option.label, option.value),
}))}
menuId={menuId}
className="z-30"
/>
</div>
</div>
<Controller
name="instructions"
control={control}
render={({ field, fieldState: { error } }) => (
<>
<textarea
{...field}
value={field.value ?? ''}
className={cn(inputClass, 'min-h-[100px] resize-y')}
id="instructions"
placeholder={localize('com_agents_instructions_placeholder')}
rows={3}
aria-label="Agent instructions"
aria-required="true"
aria-invalid={error ? 'true' : 'false'}
/>
{error && (
<span
className="text-sm text-red-500 transition duration-300 ease-in-out"
role="alert"
>
{localize('com_ui_field_required')}
</span>
)}
</>
)}
/>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, HoverCard, HoverCardTrigger } from '~/components/ui';
import ControlCombobox from '~/components/ui/ControlCombobox';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { TranslationKeys, useLocalize, useParameterEffects, useUpdateSearchParams } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
@@ -33,6 +33,7 @@ function DynamicCombobox({
}: DynamicSettingProps & { isCollapsed?: boolean; SelectIcon?: React.ReactNode }) {
const localize = useLocalize();
const { preset } = useChatContext();
const updateSearchParams = useUpdateSearchParams();
const [inputValue, setInputValue] = useState<string | null>(null);
const selectedValue = useMemo(() => {
@@ -59,8 +60,10 @@ function DynamicCombobox({
} else {
setOption(settingKey)(value);
}
updateSearchParams({ [settingKey]: value });
},
[optionType, setOption, settingKey],
[optionType, setOption, settingKey, updateSearchParams],
);
useParameterEffects({
@@ -93,7 +96,7 @@ function DynamicCombobox({
htmlFor={`${settingKey}-dynamic-combobox`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}: {defaultValue})
@@ -105,10 +108,14 @@ function DynamicCombobox({
<ControlCombobox
displayValue={selectedValue}
selectPlaceholder={
selectPlaceholderCode === true ? localize(selectPlaceholder as TranslationKeys) : selectPlaceholder
selectPlaceholderCode === true
? localize(selectPlaceholder as TranslationKeys)
: selectPlaceholder
}
searchPlaceholder={
searchPlaceholderCode === true ? localize(searchPlaceholder as TranslationKeys) : searchPlaceholder
searchPlaceholderCode === true
? localize(searchPlaceholder as TranslationKeys)
: searchPlaceholder
}
isCollapsed={isCollapsed}
ariaLabel={settingKey}
@@ -120,7 +127,11 @@ function DynamicCombobox({
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={ESide.Left}
/>
)}

View File

@@ -1,6 +1,12 @@
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
import {
useLocalize,
useDebouncedInput,
useParameterEffects,
TranslationKeys,
useUpdateSearchParams,
} from '~/hooks';
import { Label, Input, HoverCard, HoverCardTrigger } from '~/components/ui';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
@@ -26,6 +32,7 @@ function DynamicInput({
}: DynamicSettingProps) {
const localize = useLocalize();
const { preset } = useChatContext();
const updateSearchParams = useUpdateSearchParams();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
@@ -47,6 +54,7 @@ function DynamicInput({
const value = e.target.value;
if (type !== 'number') {
setInputValue(e);
updateSearchParams({ [settingKey]: value });
return;
}
@@ -55,6 +63,7 @@ function DynamicInput({
} else if (!isNaN(Number(value))) {
setInputValue(e, true);
}
updateSearchParams({ [settingKey]: value });
};
return (

View File

@@ -2,7 +2,13 @@ import { useMemo, useCallback } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Slider, HoverCard, Input, InputNumber, HoverCardTrigger } from '~/components/ui';
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
import {
useLocalize,
useDebouncedInput,
useParameterEffects,
TranslationKeys,
useUpdateSearchParams,
} from '~/hooks';
import { cn, defaultTextProps, optionText } from '~/utils';
import { ESide, defaultDebouncedDelay } from '~/common';
import { useChatContext } from '~/Providers';
@@ -31,6 +37,7 @@ function DynamicSlider({
() => (!range && options && options.length > 0) ?? false,
[options, range],
);
const updateSearchParams = useUpdateSearchParams();
const [setInputValue, inputValue, setLocalValue] = useDebouncedInput<string | number>({
optionKey: optionType !== OptionTypes.Custom ? settingKey : undefined,
@@ -60,20 +67,26 @@ function DynamicSlider({
const enumToNumeric = useMemo(() => {
if (isEnum && options) {
return options.reduce((acc, mapping, index) => {
acc[mapping] = index;
return acc;
}, {} as Record<string, number>);
return options.reduce(
(acc, mapping, index) => {
acc[mapping] = index;
return acc;
},
{} as Record<string, number>,
);
}
return {};
}, [isEnum, options]);
const valueToEnumOption = useMemo(() => {
if (isEnum && options) {
return options.reduce((acc, option, index) => {
acc[index] = option;
return acc;
}, {} as Record<number, string>);
return options.reduce(
(acc, option, index) => {
acc[index] = option;
return acc;
},
{} as Record<number, string>,
);
}
return {};
}, [isEnum, options]);
@@ -85,8 +98,10 @@ function DynamicSlider({
} else {
setInputValue(value);
}
updateSearchParams({ [settingKey]: value.toString() });
},
[isEnum, setInputValue, valueToEnumOption],
[isEnum, setInputValue, valueToEnumOption, updateSearchParams, settingKey],
);
const max = useMemo(() => {
@@ -117,7 +132,7 @@ function DynamicSlider({
htmlFor={`${settingKey}-dynamic-setting`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}: {defaultValue})
@@ -132,7 +147,7 @@ function DynamicSlider({
onChange={(value) => setInputValue(Number(value))}
max={range ? range.max : (options?.length ?? 0) - 1}
min={range ? range.min : 0}
step={range ? range.step ?? 1 : 1}
step={range ? (range.step ?? 1) : 1}
controls={false}
className={cn(
defaultTextProps,
@@ -164,19 +179,23 @@ function DynamicSlider({
value={[
isEnum
? enumToNumeric[(selectedValue as number) ?? '']
: (inputValue as number) ?? (defaultValue as number),
: ((inputValue as number) ?? (defaultValue as number)),
]}
onValueChange={(value) => handleValueChange(value[0])}
onDoubleClick={() => setInputValue(defaultValue as string | number)}
max={max}
min={range ? range.min : 0}
step={range ? range.step ?? 1 : 1}
step={range ? (range.step ?? 1) : 1}
className="flex h-4 w-full"
/>
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={ESide.Left}
/>
)}

View File

@@ -2,7 +2,7 @@ import { useState, useMemo } from 'react';
import { OptionTypes } from 'librechat-data-provider';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { Label, Switch, HoverCard, HoverCardTrigger } from '~/components/ui';
import { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { TranslationKeys, useLocalize, useParameterEffects, useUpdateSearchParams } from '~/hooks';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
@@ -23,6 +23,7 @@ function DynamicSwitch({
}: DynamicSettingProps) {
const localize = useLocalize();
const { preset } = useChatContext();
const updateSearchParams = useUpdateSearchParams();
const [inputValue, setInputValue] = useState<boolean>(!!(defaultValue as boolean | undefined));
useParameterEffects({
preset,
@@ -47,6 +48,7 @@ function DynamicSwitch({
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setInputValue(checked);
updateSearchParams({ [settingKey]: checked.toString() });
return;
}
setOption(settingKey)(checked);
@@ -65,7 +67,7 @@ function DynamicSwitch({
htmlFor={`${settingKey}-dynamic-switch`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
({localize('com_endpoint_default')}:{' '}
@@ -84,7 +86,11 @@ function DynamicSwitch({
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={ESide.Left}
/>
)}

View File

@@ -3,8 +3,8 @@ 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 { TranslationKeys, useLocalize, useParameterEffects } from '~/hooks';
import { cn, defaultTextProps } from '~/utils';
import { TranslationKeys, useLocalize, useParameterEffects, useUpdateSearchParams } from '~/hooks';
import { cn } from '~/utils';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
@@ -30,6 +30,7 @@ function DynamicTags({
const localize = useLocalize();
const { preset } = useChatContext();
const { showToast } = useToastContext();
const updateSearchParams = useUpdateSearchParams();
const inputRef = useRef<HTMLInputElement>(null);
const [tagText, setTagText] = useState<string>('');
const [tags, setTags] = useState<string[] | undefined>(
@@ -41,11 +42,13 @@ function DynamicTags({
if (optionType === OptionTypes.Custom) {
// TODO: custom logic, add to payload but not to conversation
setTags(update);
updateSearchParams({ [settingKey]: update.join(',') });
return;
}
setOption(settingKey)(update);
updateSearchParams({ [settingKey]: update.join(',') });
},
[optionType, setOption, settingKey],
[optionType, setOption, settingKey, updateSearchParams],
);
const onTagClick = useCallback(() => {
@@ -75,7 +78,7 @@ function DynamicTags({
if (minTags != null && currentTags.length <= minTags) {
showToast({
message: localize('com_ui_min_tags',{ 0: minTags + '' }),
message: localize('com_ui_min_tags', { 0: minTags + '' }),
status: 'warning',
});
return;
@@ -126,7 +129,7 @@ function DynamicTags({
htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium"
>
{labelCode ? localize(label as TranslationKeys) ?? label : label || settingKey}{' '}
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
<small className="opacity-40">
(
@@ -174,7 +177,11 @@ function DynamicTags({
}
}}
onChange={(e) => setTagText(e.target.value)}
placeholder={placeholderCode ? localize(placeholder as TranslationKeys) ?? placeholder : placeholder}
placeholder={
placeholderCode
? (localize(placeholder as TranslationKeys) ?? placeholder)
: placeholder
}
className={cn('flex h-10 max-h-10 border-none bg-surface-secondary px-3 py-2')}
/>
</div>
@@ -182,7 +189,11 @@ function DynamicTags({
</HoverCardTrigger>
{description && (
<OptionHover
description={descriptionCode ? localize(description as TranslationKeys) ?? description : description}
description={
descriptionCode
? (localize(description as TranslationKeys) ?? description)
: description
}
side={descriptionSide as ESide}
/>
)}

View File

@@ -8,7 +8,7 @@ interface OGDialogProps extends DialogPrimitive.DialogProps {
}
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
({ children, triggerRef, onOpenChange, ...props }, _ref) => {
({ children, triggerRef, onOpenChange, ...props }) => {
const handleOpenChange = (open: boolean) => {
if (!open && triggerRef?.current) {
setTimeout(() => {
@@ -71,7 +71,6 @@ const DialogContent = React.forwardRef<
{showCloseButton && (
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
{/* eslint-disable-next-line i18next/no-literal-string */}
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}

View File

@@ -90,37 +90,33 @@ const SplitText: React.FC<SplitTextProps> = ({
}, [inView, text, onLineCountChange]);
return (
<>
<span className="sr-only">{text}</span>
<p
ref={ref}
className={`split-parent inline overflow-hidden ${className}`}
style={{ textAlign, whiteSpace: 'normal', wordWrap: 'break-word' }}
aria-hidden="true"
>
{words.map((word, wordIndex) => (
<span key={wordIndex} style={{ display: 'inline-block', whiteSpace: 'nowrap' }}>
{word.map((letter, letterIndex) => {
const index =
words.slice(0, wordIndex).reduce((acc, w) => acc + w.length, 0) + letterIndex;
<p
ref={ref}
className={`split-parent inline overflow-hidden ${className}`}
style={{ textAlign, whiteSpace: 'normal', wordWrap: 'break-word' }}
>
{words.map((word, wordIndex) => (
<span key={wordIndex} style={{ display: 'inline-block', whiteSpace: 'nowrap' }}>
{word.map((letter, letterIndex) => {
const index =
words.slice(0, wordIndex).reduce((acc, w) => acc + w.length, 0) + letterIndex;
return (
<animated.span
key={index}
style={springs[index] as unknown as React.CSSProperties}
className="inline-block transform transition-opacity will-change-transform"
>
{letter}
</animated.span>
);
})}
{wordIndex < words.length - 1 && (
<span style={{ display: 'inline-block', width: '0.3em' }}>&nbsp;</span>
)}
</span>
))}
</p>
</>
return (
<animated.span
key={index}
style={springs[index] as unknown as React.CSSProperties}
className="inline-block transform transition-opacity will-change-transform"
>
{letter}
</animated.span>
);
})}
{wordIndex < words.length - 1 && (
<span style={{ display: 'inline-block', width: '0.3em' }}>&nbsp;</span>
)}
</span>
))}
</p>
);
};

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