diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0a36356f3e..2a45c2c16e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -11,7 +11,7 @@ - [ ] **Dependencies:** Are there any new dependencies? Have you updated the dependency versions in the documentation? - [ ] **Testing:** Have you written and run sufficient tests for validating the changes? - [ ] **Code review:** Have you performed a self-review of your code, addressing any coding standard issues and ensuring adherence to the project's coding standards? -- [ ] **Label:** To cleary categorize this pull request, assign a relevant label to the pull request title, using one of the following: +- [ ] **Prefix:** To cleary categorize this pull request, prefix the pull request title, using one of the following: - **BREAKING CHANGE**: Significant changes that may affect compatibility - **build**: Changes that affect the build system or external dependencies - **ci**: Changes to our continuous integration processes or workflows diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index 86d27f4dc7..da40f56ffe 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -70,8 +70,10 @@ jobs: images: ${{ env.FULL_IMAGE_NAME }} tags: | type=ref,event=branch + ${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }} flavor: | prefix=cache-${{ matrix.platform }}- + latest=false - name: Build Docker image (latest) uses: docker/build-push-action@v5 @@ -158,8 +160,10 @@ jobs: images: ${{ env.FULL_IMAGE_NAME }} tags: | type=ref,event=branch + ${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }} flavor: | prefix=cache-cuda-${{ matrix.platform }}- + latest=false - name: Build Docker image (cuda) uses: docker/build-push-action@v5 @@ -247,8 +251,10 @@ jobs: images: ${{ env.FULL_IMAGE_NAME }} tags: | type=ref,event=branch + ${{ github.ref_type == 'tag' && 'type=raw,value=main' || '' }} flavor: | prefix=cache-ollama-${{ matrix.platform }}- + latest=false - name: Build Docker image (ollama) uses: docker/build-push-action@v5 diff --git a/.github/workflows/release-pypi.yml b/.github/workflows/release-pypi.yml index b786329c25..70a19c64a6 100644 --- a/.github/workflows/release-pypi.yml +++ b/.github/workflows/release-pypi.yml @@ -4,7 +4,6 @@ on: push: branches: - main # or whatever branch you want to use - - dev jobs: release: diff --git a/CHANGELOG.md b/CHANGELOG.md index 98656a309e..bfff72eed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,190 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.4] - 2024-06-12 + +### Fixed + +- **🔒 Mixed Content with HTTPS Issue**: Resolved a problem where mixed content (HTTP and HTTPS) was causing security warnings and blocking resources on HTTPS sites. +- **🔍 Web Search Issue**: Addressed the problem where web search functionality was not working correctly. The 'ENABLE_RAG_LOCAL_WEB_FETCH' option has been reintroduced to restore proper web searching capabilities. +- **💾 RAG Template Not Being Saved**: Fixed an issue where the RAG template was not being saved correctly, ensuring your custom templates are now preserved as expected. + +## [0.3.3] - 2024-06-12 + +### Added + +- **🛠️ Native Python Function Calling**: Introducing native Python function calling within Open WebUI. We’ve also included a built-in code editor to seamlessly develop and integrate function code within the 'Tools' workspace. With this, you can significantly enhance your LLM’s capabilities by creating custom RAG pipelines, web search tools, and even agent-like features such as sending Discord messages. +- **🌐 DuckDuckGo Integration**: Added DuckDuckGo as a web search provider, giving you more search options. +- **🌏 Enhanced Translations**: Improved translations for Vietnamese and Chinese languages, making the interface more accessible. + +### Fixed + +- **🔗 Web Search URL Error Handling**: Fixed the issue where a single URL error would disrupt the data loading process in Web Search mode. Now, such errors will be handled gracefully to ensure uninterrupted data loading. +- **🖥️ Frontend Responsiveness**: Resolved the problem where the frontend would stop responding if the backend encounters an error while downloading a model. Improved error handling to maintain frontend stability. +- **🔧 Dependency Issues in pip**: Fixed issues related to pip installations, ensuring all dependencies are correctly managed to prevent installation errors. + +## [0.3.2] - 2024-06-10 + +### Added + +- **🔍 Web Search Query Status**: The web search query will now persist in the results section to aid in easier debugging and tracking of search queries. +- **🌐 New Web Search Provider**: We have added Serply as a new option for web search providers, giving you more choices for your search needs. +- **🌏 Improved Translations**: We've enhanced translations for Chinese and Portuguese. + +### Fixed + +- **🎤 Audio File Upload Issue**: The bug that prevented audio files from being uploaded in chat input has been fixed, ensuring smooth communication. +- **💬 Message Input Handling**: Improved the handling of message inputs by instantly clearing images and text after sending, along with immediate visual indications when a response message is loading, enhancing user feedback. +- **⚙️ Parameter Registration and Validation**: Fixed the issue where parameters were not registering in certain cases and addressed the problem where users were unable to save due to invalid input errors. + +## [0.3.1] - 2024-06-09 + +### Fixed + +- **💬 Chat Functionality**: Resolved the issue where chat functionality was not working for specific models. + +## [0.3.0] - 2024-06-09 + +### Added + +- **📚 Knowledge Support for Models**: Attach documents directly to models from the models workspace, enhancing the information available to each model. +- **🎙️ Hands-Free Voice Call Feature**: Initiate voice calls without needing to use your hands, making interactions more seamless. +- **📹 Video Call Feature**: Enable video calls with supported vision models like Llava and GPT-4o, adding a visual dimension to your communications. +- **🎛️ Enhanced UI for Voice Recording**: Improved user interface for the voice recording feature, making it more intuitive and user-friendly. +- **🌐 External STT Support**: Now support for external Speech-To-Text services, providing more flexibility in choosing your STT provider. +- **⚙️ Unified Settings**: Consolidated settings including document settings under a new admin settings section for easier management. +- **🌑 Dark Mode Splash Screen**: A new splash screen for dark mode, ensuring a consistent and visually appealing experience for dark mode users. +- **📥 Upload Pipeline**: Directly upload pipelines from the admin settings > pipelines section, streamlining the pipeline management process. +- **🌍 Improved Language Support**: Enhanced support for Chinese and Ukrainian languages, better catering to a global user base. + +### Fixed + +- **🛠️ Playground Issue**: Fixed the playground not functioning properly, ensuring a smoother user experience. +- **🔥 Temperature Parameter Issue**: Corrected the issue where the temperature value '0' was not being passed correctly. +- **📝 Prompt Input Clearing**: Resolved prompt input textarea not being cleared right away, ensuring a clean slate for new inputs. +- **✨ Various UI Styling Issues**: Fixed numerous user interface styling problems for a more cohesive look. +- **👥 Active Users Display**: Fixed active users showing active sessions instead of actual users, now reflecting accurate user activity. +- **🌐 Community Platform Compatibility**: The Community Platform is back online and fully compatible with Open WebUI. + +### Changed + +- **📝 RAG Implementation**: Updated the RAG (Retrieval-Augmented Generation) implementation to use a system prompt for context, instead of overriding the user's prompt. +- **🔄 Settings Relocation**: Moved Models, Connections, Audio, and Images settings to the admin settings for better organization. +- **✍️ Improved Title Generation**: Enhanced the default prompt for title generation, yielding better results. +- **🔧 Backend Task Management**: Tasks like title generation and search query generation are now managed on the backend side and controlled only by the admin. +- **🔍 Editable Search Query Prompt**: You can now edit the search query generation prompt, offering more control over how queries are generated. +- **📏 Prompt Length Threshold**: Set the prompt length threshold for search query generation from the admin settings, giving more customization options. +- **📣 Settings Consolidation**: Merged the Banners admin setting with the Interface admin setting for a more streamlined settings area. + +## [0.2.5] - 2024-06-05 + +### Added + +- **👥 Active Users Indicator**: Now you can see how many people are currently active and what they are running. This helps you gauge when performance might slow down due to a high number of users. +- **🗂️ Create Ollama Modelfile**: The option to create a modelfile for Ollama has been reintroduced in the Settings > Models section, making it easier to manage your models. +- **⚙️ Default Model Setting**: Added an option to set the default model from Settings > Interface. This feature is now easily accessible, especially convenient for mobile users as it was previously hidden. +- **🌐 Enhanced Translations**: We've improved the Chinese translations and added support for Turkmen and Norwegian languages to make the interface more accessible globally. + +### Fixed + +- **📱 Mobile View Improvements**: The UI now uses dvh (dynamic viewport height) instead of vh (viewport height), providing a better and more responsive experience for mobile users. + +## [0.2.4] - 2024-06-03 + +### Added + +- **👤 Improved Account Pending Page**: The account pending page now displays admin details by default to avoid confusion. You can disable this feature in the admin settings if needed. +- **🌐 HTTP Proxy Support**: We have enabled the use of the 'http_proxy' environment variable in OpenAI and Ollama API calls, making it easier to configure network settings. +- **❓ Quick Access to Documentation**: You can now easily access Open WebUI documents via a question mark button located at the bottom right corner of the screen (available on larger screens like PCs). +- **🌍 Enhanced Translation**: Improvements have been made to translations. + +### Fixed + +- **🔍 SearxNG Web Search**: Fixed the issue where the SearxNG web search functionality was not working properly. + +## [0.2.3] - 2024-06-03 + +### Added + +- **📁 Export Chat as JSON**: You can now export individual chats as JSON files from the navbar menu by navigating to 'Download > Export Chat'. This makes sharing specific conversations easier. +- **✏️ Edit Titles with Double Click**: Double-click on titles to rename them quickly and efficiently. +- **🧩 Batch Multiple Embeddings**: Introduced 'RAG_EMBEDDING_OPENAI_BATCH_SIZE' to process multiple embeddings in a batch, enhancing performance for large datasets. +- **🌍 Improved Translations**: Enhanced the translation quality across various languages for a better user experience. + +### Fixed + +- **🛠️ Modelfile Migration Script**: Fixed an issue where the modelfile migration script would fail if an invalid modelfile was encountered. +- **💬 Zhuyin Input Method on Mac**: Resolved an issue where using the Zhuyin input method in the Web UI on a Mac caused text to send immediately upon pressing the enter key, leading to incorrect input. +- **🔊 Local TTS Voice Selection**: Fixed the issue where the selected local Text-to-Speech (TTS) voice was not being displayed in settings. + +## [0.2.2] - 2024-06-02 + +### Added + +- **🌊 Mermaid Rendering Support**: We've included support for Mermaid rendering. This allows you to create beautiful diagrams and flowcharts directly within Open WebUI. +- **🔄 New Environment Variable 'RESET_CONFIG_ON_START'**: Introducing a new environment variable: 'RESET_CONFIG_ON_START'. Set this variable to reset your configuration settings upon starting the application, making it easier to revert to default settings. + +### Fixed + +- **🔧 Pipelines Filter Issue**: We've addressed an issue with the pipelines where filters were not functioning as expected. + +## [0.2.1] - 2024-06-02 + +### Added + +- **🖱️ Single Model Export Button**: Easily export models with just one click using the new single model export button. +- **🖥️ Advanced Parameters Support**: Added support for 'num_thread', 'use_mmap', and 'use_mlock' parameters for Ollama. +- **🌐 Improved Vietnamese Translation**: Enhanced Vietnamese language support for a better user experience for our Vietnamese-speaking community. + +### Fixed + +- **🔧 OpenAI URL API Save Issue**: Corrected a problem preventing the saving of OpenAI URL API settings. +- **🚫 Display Issue with Disabled Ollama API**: Fixed the display bug causing models to appear in settings when the Ollama API was disabled. + +### Changed + +- **💡 Versioning Update**: As a reminder from our previous update, version 0.2.y will focus primarily on bug fixes, while major updates will be designated as 0.x from now on for better version tracking. + +## [0.2.0] - 2024-06-01 + +### Added + +- **🔧 Pipelines Support**: Open WebUI now includes a plugin framework for enhanced customization and functionality (https://github.com/open-webui/pipelines). Easily add custom logic and integrate Python libraries, from AI agents to home automation APIs. +- **🔗 Function Calling via Pipelines**: Integrate function calling seamlessly through Pipelines. +- **⚖️ User Rate Limiting via Pipelines**: Implement user-specific rate limits to manage API usage efficiently. +- **📊 Usage Monitoring with Langfuse**: Track and analyze usage statistics with Langfuse integration through Pipelines. +- **🕒 Conversation Turn Limits**: Set limits on conversation turns to manage interactions better through Pipelines. +- **🛡️ Toxic Message Filtering**: Automatically filter out toxic messages to maintain a safe environment using Pipelines. +- **🔍 Web Search Support**: Introducing built-in web search capabilities via RAG API, allowing users to search using SearXNG, Google Programmatic Search Engine, Brave Search, serpstack, and serper. Activate it effortlessly by adding necessary variables from Document settings > Web Params. +- **🗂️ Models Workspace**: Create and manage model presets for both Ollama/OpenAI API. Note: The old Modelfiles workspace is deprecated. +- **🛠️ Model Builder Feature**: Build and edit all models with persistent builder mode. +- **🏷️ Model Tagging Support**: Organize models with tagging features in the models workspace. +- **📋 Model Ordering Support**: Effortlessly organize models by dragging and dropping them into the desired positions within the models workspace. +- **📈 OpenAI Generation Stats**: Access detailed generation statistics for OpenAI models. +- **📅 System Prompt Variables**: New variables added: '{{CURRENT_DATE}}' and '{{USER_NAME}}' for dynamic prompts. +- **📢 Global Banner Support**: Manage global banners from admin settings > banners. +- **🗃️ Enhanced Archived Chats Modal**: Search and export archived chats easily. +- **📂 Archive All Button**: Quickly archive all chats from settings > chats. +- **🌐 Improved Translations**: Added and improved translations for French, Croatian, Cebuano, and Vietnamese. + +### Fixed + +- **🔍 Archived Chats Visibility**: Resolved issue with archived chats not showing in the admin panel. +- **💬 Message Styling**: Fixed styling issues affecting message appearance. +- **🔗 Shared Chat Responses**: Corrected the issue where shared chat response messages were not readonly. +- **🖥️ UI Enhancement**: Fixed the scrollbar overlapping issue with the message box in the user interface. + +### Changed + +- **💾 User Settings Storage**: User settings are now saved on the backend, ensuring consistency across all devices. +- **📡 Unified API Requests**: The API request for getting models is now unified to '/api/models' for easier usage. +- **🔄 Versioning Update**: Our versioning will now follow the format 0.x for major updates and 0.x.y for patches. +- **📦 Export All Chats (All Users)**: Moved this functionality to the Admin Panel settings for better organization and accessibility. + +### Removed + +- **🚫 Bundled LiteLLM Support Deprecated**: Migrate your LiteLLM config.yaml to a self-hosted LiteLLM instance. LiteLLM can still be added via OpenAI Connections. Download the LiteLLM config.yaml from admin settings > database > export LiteLLM config.yaml. + ## [0.1.125] - 2024-05-19 ### Added diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..37ac5263cb --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,77 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contribute to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission +- **Spamming of any kind** +- Aggressive sales tactics targeting our community members are strictly prohibited. You can mention your product if it's relevant to the discussion, but under no circumstances should you push it forcefully +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, spamming, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hello@openwebui.com. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Temporary Ban + +**Community Impact**: Any violation of community standards, including but not limited to inappropriate language, unprofessional behavior, harassment, or spamming. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 2. Permanent Ban + +**Community Impact**: Repeated or severe violations of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile index be5c1da411..2bd05e6baa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,6 @@ ARG USE_OLLAMA ARG USE_CUDA_VER ARG USE_EMBEDDING_MODEL ARG USE_RERANKING_MODEL -ARG BUILD_HASH ARG UID ARG GID @@ -154,6 +153,7 @@ HEALTHCHECK CMD curl --silent --fail http://localhost:8080/health | jq -e '.stat USER $UID:$GID +ARG BUILD_HASH ENV WEBUI_BUILD_VERSION=${BUILD_HASH} CMD [ "bash", "start.sh"] diff --git a/README.md b/README.md index 313f314b51..444002fbc6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s) [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck) -Open WebUI is an extensible, feature-rich, and user-friendly self-hosted WebUI designed to operate entirely offline. It supports various LLM runners, including Ollama and OpenAI-compatible APIs. For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/). +Open WebUI is an [extensible](https://github.com/open-webui/pipelines), feature-rich, and user-friendly self-hosted WebUI designed to operate entirely offline. It supports various LLM runners, including Ollama and OpenAI-compatible APIs. For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/). ![Open WebUI Demo](./demo.gif) @@ -19,7 +19,9 @@ Open WebUI is an extensible, feature-rich, and user-friendly self-hosted WebUI d - 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience with support for both `:ollama` and `:cuda` tagged images. -- 🤝 **OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**. +- 🤝 **Ollama/OpenAI API Integration**: Effortlessly integrate OpenAI-compatible APIs for versatile conversations alongside Ollama models. Customize the OpenAI API URL to link with **LMStudio, GroqCloud, Mistral, OpenRouter, and more**. + +- 🧩 **Pipelines, Open WebUI Plugin Support**: Seamlessly integrate custom logic and Python libraries into Open WebUI using [Pipelines Plugin Framework](https://github.com/open-webui/pipelines). Launch your Pipelines instance, set the OpenAI URL to the Pipelines URL, and explore endless possibilities. [Examples](https://github.com/open-webui/pipelines/tree/main/examples) include **Function Calling**, User **Rate Limiting** to control access, **Usage Monitoring** with tools like Langfuse, **Live Translation with LibreTranslate** for multilingual support, **Toxic Message Filtering** and much more. - 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices. @@ -27,11 +29,15 @@ Open WebUI is an extensible, feature-rich, and user-friendly self-hosted WebUI d - ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction. -- 🧩 **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration. +- 🎤📹 **Hands-Free Voice/Video Call**: Experience seamless communication with integrated hands-free voice and video call features, allowing for a more dynamic and interactive chat environment. + +- 🛠️ **Model Builder**: Easily create Ollama models via the Web UI. Create and add custom characters/agents, customize chat elements, and import models effortlessly through [Open WebUI Community](https://openwebui.com/) integration. + +- 🐍 **Native Python Function Calling Tool**: Enhance your LLMs with built-in code editor support in the tools workspace. Bring Your Own Function (BYOF) by simply adding your pure Python functions, enabling seamless integration with LLMs. - 📚 **Local RAG Integration**: Dive into the future of chat interactions with groundbreaking Retrieval Augmented Generation (RAG) support. This feature seamlessly integrates document interactions into your chat experience. You can load documents directly into the chat or add files to your document library, effortlessly accessing them using the `#` command before a query. -- 🔍 **Web Search for RAG**: Perform web searches using providers like `SearXNG`, `Google PSE`, `Brave Search`, `serpstack`, and `serper`, and inject the results directly into your chat experience. +- 🔍 **Web Search for RAG**: Perform web searches using providers like `SearXNG`, `Google PSE`, `Brave Search`, `serpstack`, `serper`, and `Serply` and inject the results directly into your chat experience. - 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by a URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions. @@ -144,10 +150,19 @@ docker run --rm --volume /var/run/docker.sock:/var/run/docker.sock containrrr/wa In the last part of the command, replace `open-webui` with your container name if it is different. -### Moving from Ollama WebUI to Open WebUI - Check our Migration Guide available in our [Open WebUI Documentation](https://docs.openwebui.com/migration/). +### Using the Dev Branch 🌙 + +> [!WARNING] +> The `:dev` branch contains the latest unstable features and changes. Use it at your own risk as it may have bugs or incomplete features. + +If you want to try out the latest bleeding-edge features and are okay with occasional instability, you can use the `:dev` tag like this: + +```bash +docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --restart always ghcr.io/open-webui/open-webui:dev +``` + ## What's Next? 🌟 Discover upcoming features on our roadmap in the [Open WebUI Documentation](https://docs.openwebui.com/roadmap/). diff --git a/backend/apps/audio/main.py b/backend/apps/audio/main.py index 0f65a551e2..663e20c97b 100644 --- a/backend/apps/audio/main.py +++ b/backend/apps/audio/main.py @@ -17,13 +17,12 @@ from fastapi.middleware.cors import CORSMiddleware from faster_whisper import WhisperModel from pydantic import BaseModel - +import uuid import requests import hashlib from pathlib import Path import json - from constants import ERROR_MESSAGES from utils.utils import ( decode_token, @@ -41,10 +40,15 @@ from config import ( WHISPER_MODEL_DIR, WHISPER_MODEL_AUTO_UPDATE, DEVICE_TYPE, - AUDIO_OPENAI_API_BASE_URL, - AUDIO_OPENAI_API_KEY, - AUDIO_OPENAI_API_MODEL, - AUDIO_OPENAI_API_VOICE, + AUDIO_STT_OPENAI_API_BASE_URL, + AUDIO_STT_OPENAI_API_KEY, + AUDIO_TTS_OPENAI_API_BASE_URL, + AUDIO_TTS_OPENAI_API_KEY, + AUDIO_STT_ENGINE, + AUDIO_STT_MODEL, + AUDIO_TTS_ENGINE, + AUDIO_TTS_MODEL, + AUDIO_TTS_VOICE, AppConfig, ) @@ -61,10 +65,17 @@ app.add_middleware( ) app.state.config = AppConfig() -app.state.config.OPENAI_API_BASE_URL = AUDIO_OPENAI_API_BASE_URL -app.state.config.OPENAI_API_KEY = AUDIO_OPENAI_API_KEY -app.state.config.OPENAI_API_MODEL = AUDIO_OPENAI_API_MODEL -app.state.config.OPENAI_API_VOICE = AUDIO_OPENAI_API_VOICE + +app.state.config.STT_OPENAI_API_BASE_URL = AUDIO_STT_OPENAI_API_BASE_URL +app.state.config.STT_OPENAI_API_KEY = AUDIO_STT_OPENAI_API_KEY +app.state.config.STT_ENGINE = AUDIO_STT_ENGINE +app.state.config.STT_MODEL = AUDIO_STT_MODEL + +app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL +app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY +app.state.config.TTS_ENGINE = AUDIO_TTS_ENGINE +app.state.config.TTS_MODEL = AUDIO_TTS_MODEL +app.state.config.TTS_VOICE = AUDIO_TTS_VOICE # setting device type for whisper model whisper_device_type = DEVICE_TYPE if DEVICE_TYPE and DEVICE_TYPE == "cuda" else "cpu" @@ -74,41 +85,101 @@ SPEECH_CACHE_DIR = Path(CACHE_DIR).joinpath("./audio/speech/") SPEECH_CACHE_DIR.mkdir(parents=True, exist_ok=True) -class OpenAIConfigUpdateForm(BaseModel): - url: str - key: str - model: str - speaker: str +class TTSConfigForm(BaseModel): + OPENAI_API_BASE_URL: str + OPENAI_API_KEY: str + ENGINE: str + MODEL: str + VOICE: str + + +class STTConfigForm(BaseModel): + OPENAI_API_BASE_URL: str + OPENAI_API_KEY: str + ENGINE: str + MODEL: str + + +class AudioConfigUpdateForm(BaseModel): + tts: TTSConfigForm + stt: STTConfigForm + + +from pydub import AudioSegment +from pydub.utils import mediainfo + + +def is_mp4_audio(file_path): + """Check if the given file is an MP4 audio file.""" + if not os.path.isfile(file_path): + print(f"File not found: {file_path}") + return False + + info = mediainfo(file_path) + if ( + info.get("codec_name") == "aac" + and info.get("codec_type") == "audio" + and info.get("codec_tag_string") == "mp4a" + ): + return True + return False + + +def convert_mp4_to_wav(file_path, output_path): + """Convert MP4 audio file to WAV format.""" + audio = AudioSegment.from_file(file_path, format="mp4") + audio.export(output_path, format="wav") + print(f"Converted {file_path} to {output_path}") @app.get("/config") -async def get_openai_config(user=Depends(get_admin_user)): +async def get_audio_config(user=Depends(get_admin_user)): return { - "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL, - "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY, - "OPENAI_API_MODEL": app.state.config.OPENAI_API_MODEL, - "OPENAI_API_VOICE": app.state.config.OPENAI_API_VOICE, + "tts": { + "OPENAI_API_BASE_URL": app.state.config.TTS_OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.TTS_OPENAI_API_KEY, + "ENGINE": app.state.config.TTS_ENGINE, + "MODEL": app.state.config.TTS_MODEL, + "VOICE": app.state.config.TTS_VOICE, + }, + "stt": { + "OPENAI_API_BASE_URL": app.state.config.STT_OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.STT_OPENAI_API_KEY, + "ENGINE": app.state.config.STT_ENGINE, + "MODEL": app.state.config.STT_MODEL, + }, } @app.post("/config/update") -async def update_openai_config( - form_data: OpenAIConfigUpdateForm, user=Depends(get_admin_user) +async def update_audio_config( + form_data: AudioConfigUpdateForm, user=Depends(get_admin_user) ): - if form_data.key == "": - raise HTTPException(status_code=400, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND) + app.state.config.TTS_OPENAI_API_BASE_URL = form_data.tts.OPENAI_API_BASE_URL + app.state.config.TTS_OPENAI_API_KEY = form_data.tts.OPENAI_API_KEY + app.state.config.TTS_ENGINE = form_data.tts.ENGINE + app.state.config.TTS_MODEL = form_data.tts.MODEL + app.state.config.TTS_VOICE = form_data.tts.VOICE - app.state.config.OPENAI_API_BASE_URL = form_data.url - app.state.config.OPENAI_API_KEY = form_data.key - app.state.config.OPENAI_API_MODEL = form_data.model - app.state.config.OPENAI_API_VOICE = form_data.speaker + app.state.config.STT_OPENAI_API_BASE_URL = form_data.stt.OPENAI_API_BASE_URL + app.state.config.STT_OPENAI_API_KEY = form_data.stt.OPENAI_API_KEY + app.state.config.STT_ENGINE = form_data.stt.ENGINE + app.state.config.STT_MODEL = form_data.stt.MODEL return { - "status": True, - "OPENAI_API_BASE_URL": app.state.config.OPENAI_API_BASE_URL, - "OPENAI_API_KEY": app.state.config.OPENAI_API_KEY, - "OPENAI_API_MODEL": app.state.config.OPENAI_API_MODEL, - "OPENAI_API_VOICE": app.state.config.OPENAI_API_VOICE, + "tts": { + "OPENAI_API_BASE_URL": app.state.config.TTS_OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.TTS_OPENAI_API_KEY, + "ENGINE": app.state.config.TTS_ENGINE, + "MODEL": app.state.config.TTS_MODEL, + "VOICE": app.state.config.TTS_VOICE, + }, + "stt": { + "OPENAI_API_BASE_URL": app.state.config.STT_OPENAI_API_BASE_URL, + "OPENAI_API_KEY": app.state.config.STT_OPENAI_API_KEY, + "ENGINE": app.state.config.STT_ENGINE, + "MODEL": app.state.config.STT_MODEL, + }, } @@ -125,13 +196,21 @@ async def speech(request: Request, user=Depends(get_verified_user)): return FileResponse(file_path) headers = {} - headers["Authorization"] = f"Bearer {app.state.config.OPENAI_API_KEY}" + headers["Authorization"] = f"Bearer {app.state.config.TTS_OPENAI_API_KEY}" headers["Content-Type"] = "application/json" + try: + body = body.decode("utf-8") + body = json.loads(body) + body["model"] = app.state.config.TTS_MODEL + body = json.dumps(body).encode("utf-8") + except Exception as e: + pass + r = None try: r = requests.post( - url=f"{app.state.config.OPENAI_API_BASE_URL}/audio/speech", + url=f"{app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech", data=body, headers=headers, stream=True, @@ -181,41 +260,110 @@ def transcribe( ) try: - filename = file.filename - file_path = f"{UPLOAD_DIR}/{filename}" + ext = file.filename.split(".")[-1] + + id = uuid.uuid4() + filename = f"{id}.{ext}" + + file_dir = f"{CACHE_DIR}/audio/transcriptions" + os.makedirs(file_dir, exist_ok=True) + file_path = f"{file_dir}/{filename}" + + print(filename) + contents = file.file.read() with open(file_path, "wb") as f: f.write(contents) f.close() - whisper_kwargs = { - "model_size_or_path": WHISPER_MODEL, - "device": whisper_device_type, - "compute_type": "int8", - "download_root": WHISPER_MODEL_DIR, - "local_files_only": not WHISPER_MODEL_AUTO_UPDATE, - } + if app.state.config.STT_ENGINE == "": + whisper_kwargs = { + "model_size_or_path": WHISPER_MODEL, + "device": whisper_device_type, + "compute_type": "int8", + "download_root": WHISPER_MODEL_DIR, + "local_files_only": not WHISPER_MODEL_AUTO_UPDATE, + } - log.debug(f"whisper_kwargs: {whisper_kwargs}") + log.debug(f"whisper_kwargs: {whisper_kwargs}") - try: - model = WhisperModel(**whisper_kwargs) - except: - log.warning( - "WhisperModel initialization failed, attempting download with local_files_only=False" + try: + model = WhisperModel(**whisper_kwargs) + except: + log.warning( + "WhisperModel initialization failed, attempting download with local_files_only=False" + ) + whisper_kwargs["local_files_only"] = False + model = WhisperModel(**whisper_kwargs) + + segments, info = model.transcribe(file_path, beam_size=5) + log.info( + "Detected language '%s' with probability %f" + % (info.language, info.language_probability) ) - whisper_kwargs["local_files_only"] = False - model = WhisperModel(**whisper_kwargs) - segments, info = model.transcribe(file_path, beam_size=5) - log.info( - "Detected language '%s' with probability %f" - % (info.language, info.language_probability) - ) + transcript = "".join([segment.text for segment in list(segments)]) - transcript = "".join([segment.text for segment in list(segments)]) + data = {"text": transcript.strip()} - return {"text": transcript.strip()} + # save the transcript to a json file + transcript_file = f"{file_dir}/{id}.json" + with open(transcript_file, "w") as f: + json.dump(data, f) + + print(data) + + return data + + elif app.state.config.STT_ENGINE == "openai": + if is_mp4_audio(file_path): + print("is_mp4_audio") + os.rename(file_path, file_path.replace(".wav", ".mp4")) + # Convert MP4 audio file to WAV format + convert_mp4_to_wav(file_path.replace(".wav", ".mp4"), file_path) + + headers = {"Authorization": f"Bearer {app.state.config.STT_OPENAI_API_KEY}"} + + files = {"file": (filename, open(file_path, "rb"))} + data = {"model": "whisper-1"} + + print(files, data) + + r = None + try: + r = requests.post( + url=f"{app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions", + headers=headers, + files=files, + data=data, + ) + + r.raise_for_status() + + data = r.json() + + # save the transcript to a json file + transcript_file = f"{file_dir}/{id}.json" + with open(transcript_file, "w") as f: + json.dump(data, f) + + print(data) + return data + except Exception as e: + log.exception(e) + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = r.json() + if "error" in res: + error_detail = f"External: {res['error']['message']}" + except: + error_detail = f"External: {e}" + + raise HTTPException( + status_code=r.status_code if r != None else 500, + detail=error_detail, + ) except Exception as e: log.exception(e) diff --git a/backend/apps/ollama/main.py b/backend/apps/ollama/main.py index 1c2bea6833..1447554188 100644 --- a/backend/apps/ollama/main.py +++ b/backend/apps/ollama/main.py @@ -29,6 +29,8 @@ import time from urllib.parse import urlparse from typing import Optional, List, Union +from starlette.background import BackgroundTask + from apps.webui.models.models import Models from apps.webui.models.users import Users from constants import ERROR_MESSAGES @@ -39,8 +41,6 @@ from utils.utils import ( get_admin_user, ) -from utils.models import get_model_id_from_custom_model_id - from config import ( SRC_LOG_LEVELS, @@ -75,9 +75,6 @@ app.state.config.OLLAMA_BASE_URLS = OLLAMA_BASE_URLS app.state.MODELS = {} -REQUEST_POOL = [] - - # TODO: Implement a more intelligent load balancing mechanism for distributing requests among multiple backend instances. # Current implementation uses a simple round-robin approach (random.choice). Consider incorporating algorithms like weighted round-robin, # least connections, or least response time for better resource utilization and performance optimization. @@ -132,20 +129,10 @@ async def update_ollama_api_url(form_data: UrlUpdateForm, user=Depends(get_admin return {"OLLAMA_BASE_URLS": app.state.config.OLLAMA_BASE_URLS} -@app.get("/cancel/{request_id}") -async def cancel_ollama_request(request_id: str, user=Depends(get_current_user)): - if user: - if request_id in REQUEST_POOL: - REQUEST_POOL.remove(request_id) - return True - else: - raise HTTPException(status_code=401, detail=ERROR_MESSAGES.ACCESS_PROHIBITED) - - async def fetch_url(url): timeout = aiohttp.ClientTimeout(total=5) try: - async with aiohttp.ClientSession(timeout=timeout) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get(url) as response: return await response.json() except Exception as e: @@ -154,6 +141,45 @@ async def fetch_url(url): return None +async def cleanup_response( + response: Optional[aiohttp.ClientResponse], + session: Optional[aiohttp.ClientSession], +): + if response: + response.close() + if session: + await session.close() + + +async def post_streaming_url(url: str, payload: str): + r = None + try: + session = aiohttp.ClientSession(trust_env=True) + r = await session.post(url, data=payload) + r.raise_for_status() + + return StreamingResponse( + r.content, + status_code=r.status, + headers=dict(r.headers), + background=BackgroundTask(cleanup_response, response=r, session=session), + ) + except Exception as e: + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = await r.json() + if "error" in res: + error_detail = f"Ollama: {res['error']}" + except: + error_detail = f"Ollama: {e}" + + raise HTTPException( + status_code=r.status if r else 500, + detail=error_detail, + ) + + def merge_models_lists(model_lists): merged_models = {} @@ -246,54 +272,57 @@ async def get_ollama_tags( @app.get("/api/version") @app.get("/api/version/{url_idx}") async def get_ollama_versions(url_idx: Optional[int] = None): + if app.state.config.ENABLE_OLLAMA_API: + if url_idx == None: - if url_idx == None: + # returns lowest version + tasks = [ + fetch_url(f"{url}/api/version") + for url in app.state.config.OLLAMA_BASE_URLS + ] + responses = await asyncio.gather(*tasks) + responses = list(filter(lambda x: x is not None, responses)) - # returns lowest version - tasks = [ - fetch_url(f"{url}/api/version") for url in app.state.config.OLLAMA_BASE_URLS - ] - responses = await asyncio.gather(*tasks) - responses = list(filter(lambda x: x is not None, responses)) + if len(responses) > 0: + lowest_version = min( + responses, + key=lambda x: tuple( + map(int, re.sub(r"^v|-.*", "", x["version"]).split(".")) + ), + ) - if len(responses) > 0: - lowest_version = min( - responses, - key=lambda x: tuple( - map(int, re.sub(r"^v|-.*", "", x["version"]).split(".")) - ), - ) - - return {"version": lowest_version["version"]} + return {"version": lowest_version["version"]} + else: + raise HTTPException( + status_code=500, + detail=ERROR_MESSAGES.OLLAMA_NOT_FOUND, + ) else: - raise HTTPException( - status_code=500, - detail=ERROR_MESSAGES.OLLAMA_NOT_FOUND, - ) + url = app.state.config.OLLAMA_BASE_URLS[url_idx] + + r = None + try: + r = requests.request(method="GET", url=f"{url}/api/version") + r.raise_for_status() + + return r.json() + except Exception as e: + log.exception(e) + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = r.json() + if "error" in res: + error_detail = f"Ollama: {res['error']}" + except: + error_detail = f"Ollama: {e}" + + raise HTTPException( + status_code=r.status_code if r else 500, + detail=error_detail, + ) else: - url = app.state.config.OLLAMA_BASE_URLS[url_idx] - - r = None - try: - r = requests.request(method="GET", url=f"{url}/api/version") - r.raise_for_status() - - return r.json() - except Exception as e: - log.exception(e) - error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" - except: - error_detail = f"Ollama: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + return {"version": False} class ModelNameForm(BaseModel): @@ -313,65 +342,7 @@ async def pull_model( # Admin should be able to pull models from any source payload = {**form_data.model_dump(exclude_none=True), "insecure": True} - def get_request(): - nonlocal url - nonlocal r - - request_id = str(uuid.uuid4()) - try: - REQUEST_POOL.append(request_id) - - def stream_content(): - try: - yield json.dumps({"id": request_id, "done": False}) + "\n" - - for chunk in r.iter_content(chunk_size=8192): - if request_id in REQUEST_POOL: - yield chunk - else: - log.warning("User: canceled request") - break - finally: - if hasattr(r, "close"): - r.close() - if request_id in REQUEST_POOL: - REQUEST_POOL.remove(request_id) - - r = requests.request( - method="POST", - url=f"{url}/api/pull", - data=json.dumps(payload), - stream=True, - ) - - r.raise_for_status() - - return StreamingResponse( - stream_content(), - status_code=r.status_code, - headers=dict(r.headers), - ) - except Exception as e: - raise e - - try: - return await run_in_threadpool(get_request) - - except Exception as e: - log.exception(e) - error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" - except: - error_detail = f"Ollama: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + return await post_streaming_url(f"{url}/api/pull", json.dumps(payload)) class PushModelForm(BaseModel): @@ -399,50 +370,9 @@ async def push_model( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.debug(f"url: {url}") - r = None - - def get_request(): - nonlocal url - nonlocal r - try: - - def stream_content(): - for chunk in r.iter_content(chunk_size=8192): - yield chunk - - r = requests.request( - method="POST", - url=f"{url}/api/push", - data=form_data.model_dump_json(exclude_none=True).encode(), - ) - - r.raise_for_status() - - return StreamingResponse( - stream_content(), - status_code=r.status_code, - headers=dict(r.headers), - ) - except Exception as e: - raise e - - try: - return await run_in_threadpool(get_request) - except Exception as e: - log.exception(e) - error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" - except: - error_detail = f"Ollama: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + return await post_streaming_url( + f"{url}/api/push", form_data.model_dump_json(exclude_none=True).encode() + ) class CreateModelForm(BaseModel): @@ -461,53 +391,9 @@ async def create_model( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - r = None - - def get_request(): - nonlocal url - nonlocal r - try: - - def stream_content(): - for chunk in r.iter_content(chunk_size=8192): - yield chunk - - r = requests.request( - method="POST", - url=f"{url}/api/create", - data=form_data.model_dump_json(exclude_none=True).encode(), - stream=True, - ) - - r.raise_for_status() - - log.debug(f"r: {r}") - - return StreamingResponse( - stream_content(), - status_code=r.status_code, - headers=dict(r.headers), - ) - except Exception as e: - raise e - - try: - return await run_in_threadpool(get_request) - except Exception as e: - log.exception(e) - error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" - except: - error_detail = f"Ollama: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + return await post_streaming_url( + f"{url}/api/create", form_data.model_dump_json(exclude_none=True).encode() + ) class CopyModelForm(BaseModel): @@ -797,66 +683,9 @@ async def generate_completion( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - r = None - - def get_request(): - nonlocal form_data - nonlocal r - - request_id = str(uuid.uuid4()) - try: - REQUEST_POOL.append(request_id) - - def stream_content(): - try: - if form_data.stream: - yield json.dumps({"id": request_id, "done": False}) + "\n" - - for chunk in r.iter_content(chunk_size=8192): - if request_id in REQUEST_POOL: - yield chunk - else: - log.warning("User: canceled request") - break - finally: - if hasattr(r, "close"): - r.close() - if request_id in REQUEST_POOL: - REQUEST_POOL.remove(request_id) - - r = requests.request( - method="POST", - url=f"{url}/api/generate", - data=form_data.model_dump_json(exclude_none=True).encode(), - stream=True, - ) - - r.raise_for_status() - - return StreamingResponse( - stream_content(), - status_code=r.status_code, - headers=dict(r.headers), - ) - except Exception as e: - raise e - - try: - return await run_in_threadpool(get_request) - except Exception as e: - error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" - except: - error_detail = f"Ollama: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + return await post_streaming_url( + f"{url}/api/generate", form_data.model_dump_json(exclude_none=True).encode() + ) class ChatMessage(BaseModel): @@ -897,7 +726,6 @@ async def generate_chat_completion( model_info = Models.get_model_by_id(model_id) if model_info: - print(model_info) if model_info.base_model_id: payload["model"] = model_info.base_model_id @@ -906,44 +734,77 @@ async def generate_chat_completion( if model_info.params: payload["options"] = {} - payload["options"]["mirostat"] = model_info.params.get("mirostat", None) - payload["options"]["mirostat_eta"] = model_info.params.get( - "mirostat_eta", None - ) - payload["options"]["mirostat_tau"] = model_info.params.get( - "mirostat_tau", None - ) - payload["options"]["num_ctx"] = model_info.params.get("num_ctx", None) + if model_info.params.get("mirostat", None): + payload["options"]["mirostat"] = model_info.params.get("mirostat", None) - payload["options"]["repeat_last_n"] = model_info.params.get( - "repeat_last_n", None - ) - payload["options"]["repeat_penalty"] = model_info.params.get( - "frequency_penalty", None - ) + if model_info.params.get("mirostat_eta", None): + payload["options"]["mirostat_eta"] = model_info.params.get( + "mirostat_eta", None + ) - payload["options"]["temperature"] = model_info.params.get( - "temperature", None - ) - payload["options"]["seed"] = model_info.params.get("seed", None) + if model_info.params.get("mirostat_tau", None): - payload["options"]["stop"] = ( - [ - bytes(stop, "utf-8").decode("unicode_escape") - for stop in model_info.params["stop"] - ] - if model_info.params.get("stop", None) - else None - ) + payload["options"]["mirostat_tau"] = model_info.params.get( + "mirostat_tau", None + ) - payload["options"]["tfs_z"] = model_info.params.get("tfs_z", None) + if model_info.params.get("num_ctx", None): + payload["options"]["num_ctx"] = model_info.params.get("num_ctx", None) - payload["options"]["num_predict"] = model_info.params.get( - "max_tokens", None - ) - payload["options"]["top_k"] = model_info.params.get("top_k", None) + if model_info.params.get("repeat_last_n", None): + payload["options"]["repeat_last_n"] = model_info.params.get( + "repeat_last_n", None + ) - payload["options"]["top_p"] = model_info.params.get("top_p", None) + if model_info.params.get("frequency_penalty", None): + payload["options"]["repeat_penalty"] = model_info.params.get( + "frequency_penalty", None + ) + + if model_info.params.get("temperature", None) is not None: + payload["options"]["temperature"] = model_info.params.get( + "temperature", None + ) + + if model_info.params.get("seed", None): + payload["options"]["seed"] = model_info.params.get("seed", None) + + if model_info.params.get("stop", None): + payload["options"]["stop"] = ( + [ + bytes(stop, "utf-8").decode("unicode_escape") + for stop in model_info.params["stop"] + ] + if model_info.params.get("stop", None) + else None + ) + + if model_info.params.get("tfs_z", None): + payload["options"]["tfs_z"] = model_info.params.get("tfs_z", None) + + if model_info.params.get("max_tokens", None): + payload["options"]["num_predict"] = model_info.params.get( + "max_tokens", None + ) + + if model_info.params.get("top_k", None): + payload["options"]["top_k"] = model_info.params.get("top_k", None) + + if model_info.params.get("top_p", None): + payload["options"]["top_p"] = model_info.params.get("top_p", None) + + if model_info.params.get("use_mmap", None): + payload["options"]["use_mmap"] = model_info.params.get("use_mmap", None) + + if model_info.params.get("use_mlock", None): + payload["options"]["use_mlock"] = model_info.params.get( + "use_mlock", None + ) + + if model_info.params.get("num_thread", None): + payload["options"]["num_thread"] = model_info.params.get( + "num_thread", None + ) if model_info.params.get("system", None): # Check if the payload already has a system message @@ -981,73 +842,18 @@ async def generate_chat_completion( print(payload) - r = None - - def get_request(): - nonlocal payload - nonlocal r - - request_id = str(uuid.uuid4()) - try: - REQUEST_POOL.append(request_id) - - def stream_content(): - try: - if payload.get("stream", None): - yield json.dumps({"id": request_id, "done": False}) + "\n" - - for chunk in r.iter_content(chunk_size=8192): - if request_id in REQUEST_POOL: - yield chunk - else: - log.warning("User: canceled request") - break - finally: - if hasattr(r, "close"): - r.close() - if request_id in REQUEST_POOL: - REQUEST_POOL.remove(request_id) - - r = requests.request( - method="POST", - url=f"{url}/api/chat", - data=json.dumps(payload), - stream=True, - ) - - r.raise_for_status() - - return StreamingResponse( - stream_content(), - status_code=r.status_code, - headers=dict(r.headers), - ) - except Exception as e: - log.exception(e) - raise e - - try: - return await run_in_threadpool(get_request) - except Exception as e: - error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" - except: - error_detail = f"Ollama: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + return await post_streaming_url(f"{url}/api/chat", json.dumps(payload)) # TODO: we should update this part once Ollama supports other types +class OpenAIChatMessageContent(BaseModel): + type: str + model_config = ConfigDict(extra="allow") + + class OpenAIChatMessage(BaseModel): role: str - content: str + content: Union[str, OpenAIChatMessageContent] model_config = ConfigDict(extra="allow") @@ -1075,7 +881,6 @@ async def generate_openai_chat_completion( model_info = Models.get_model_by_id(model_id) if model_info: - print(model_info) if model_info.base_model_id: payload["model"] = model_info.base_model_id @@ -1132,68 +937,7 @@ async def generate_openai_chat_completion( url = app.state.config.OLLAMA_BASE_URLS[url_idx] log.info(f"url: {url}") - r = None - - def get_request(): - nonlocal payload - nonlocal r - - request_id = str(uuid.uuid4()) - try: - REQUEST_POOL.append(request_id) - - def stream_content(): - try: - if payload.get("stream"): - yield json.dumps( - {"request_id": request_id, "done": False} - ) + "\n" - - for chunk in r.iter_content(chunk_size=8192): - if request_id in REQUEST_POOL: - yield chunk - else: - log.warning("User: canceled request") - break - finally: - if hasattr(r, "close"): - r.close() - if request_id in REQUEST_POOL: - REQUEST_POOL.remove(request_id) - - r = requests.request( - method="POST", - url=f"{url}/v1/chat/completions", - data=json.dumps(payload), - stream=True, - ) - - r.raise_for_status() - - return StreamingResponse( - stream_content(), - status_code=r.status_code, - headers=dict(r.headers), - ) - except Exception as e: - raise e - - try: - return await run_in_threadpool(get_request) - except Exception as e: - error_detail = "Open WebUI: Server Connection Error" - if r is not None: - try: - res = r.json() - if "error" in res: - error_detail = f"Ollama: {res['error']}" - except: - error_detail = f"Ollama: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, - detail=error_detail, - ) + return await post_streaming_url(f"{url}/v1/chat/completions", json.dumps(payload)) @app.get("/v1/models") @@ -1305,7 +1049,7 @@ async def download_file_stream( timeout = aiohttp.ClientTimeout(total=600) # Set the timeout - async with aiohttp.ClientSession(timeout=timeout) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get(file_url, headers=headers) as response: total_size = int(response.headers.get("content-length", 0)) + current_size @@ -1522,7 +1266,7 @@ async def deprecated_proxy( if path == "generate": data = json.loads(body.decode("utf-8")) - if not ("stream" in data and data["stream"] == False): + if data.get("stream", True): yield json.dumps({"id": request_id, "done": False}) + "\n" elif path == "chat": diff --git a/backend/apps/openai/main.py b/backend/apps/openai/main.py index 9bf76818fd..93f913dea2 100644 --- a/backend/apps/openai/main.py +++ b/backend/apps/openai/main.py @@ -9,6 +9,7 @@ import json import logging from pydantic import BaseModel +from starlette.background import BackgroundTask from apps.webui.models.models import Models from apps.webui.models.users import Users @@ -185,7 +186,7 @@ async def fetch_url(url, key): timeout = aiohttp.ClientTimeout(total=5) try: headers = {"Authorization": f"Bearer {key}"} - async with aiohttp.ClientSession(timeout=timeout) as session: + async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session: async with session.get(url, headers=headers) as response: return await response.json() except Exception as e: @@ -194,6 +195,16 @@ async def fetch_url(url, key): return None +async def cleanup_response( + response: Optional[aiohttp.ClientResponse], + session: Optional[aiohttp.ClientSession], +): + if response: + response.close() + if session: + await session.close() + + def merge_models_lists(model_lists): log.debug(f"merge_models_lists {model_lists}") merged_list = [] @@ -228,6 +239,27 @@ async def get_all_models(raw: bool = False): ) or not app.state.config.ENABLE_OPENAI_API: models = {"data": []} else: + # Check if API KEYS length is same than API URLS length + if len(app.state.config.OPENAI_API_KEYS) != len( + app.state.config.OPENAI_API_BASE_URLS + ): + # if there are more keys than urls, remove the extra keys + if len(app.state.config.OPENAI_API_KEYS) > len( + app.state.config.OPENAI_API_BASE_URLS + ): + app.state.config.OPENAI_API_KEYS = app.state.config.OPENAI_API_KEYS[ + : len(app.state.config.OPENAI_API_BASE_URLS) + ] + # if there are more urls than keys, add empty keys + else: + app.state.config.OPENAI_API_KEYS += [ + "" + for _ in range( + len(app.state.config.OPENAI_API_BASE_URLS) + - len(app.state.config.OPENAI_API_KEYS) + ) + ] + tasks = [ fetch_url(f"{url}/models", app.state.config.OPENAI_API_KEYS[idx]) for idx, url in enumerate(app.state.config.OPENAI_API_BASE_URLS) @@ -313,113 +345,155 @@ async def get_models(url_idx: Optional[int] = None, user=Depends(get_current_use ) +@app.post("/chat/completions") +@app.post("/chat/completions/{url_idx}") +async def generate_chat_completion( + form_data: dict, + url_idx: Optional[int] = None, + user=Depends(get_verified_user), +): + idx = 0 + payload = {**form_data} + + model_id = form_data.get("model") + model_info = Models.get_model_by_id(model_id) + + if model_info: + if model_info.base_model_id: + payload["model"] = model_info.base_model_id + + model_info.params = model_info.params.model_dump() + + if model_info.params: + if model_info.params.get("temperature", None) is not None: + payload["temperature"] = float(model_info.params.get("temperature")) + + if model_info.params.get("top_p", None): + payload["top_p"] = int(model_info.params.get("top_p", None)) + + if model_info.params.get("max_tokens", None): + payload["max_tokens"] = int(model_info.params.get("max_tokens", None)) + + if model_info.params.get("frequency_penalty", None): + payload["frequency_penalty"] = int( + model_info.params.get("frequency_penalty", None) + ) + + if model_info.params.get("seed", None): + payload["seed"] = model_info.params.get("seed", None) + + if model_info.params.get("stop", None): + payload["stop"] = ( + [ + bytes(stop, "utf-8").decode("unicode_escape") + for stop in model_info.params["stop"] + ] + if model_info.params.get("stop", None) + else None + ) + + if model_info.params.get("system", None): + # Check if the payload already has a system message + # If not, add a system message to the payload + if payload.get("messages"): + for message in payload["messages"]: + if message.get("role") == "system": + message["content"] = ( + model_info.params.get("system", None) + message["content"] + ) + break + else: + payload["messages"].insert( + 0, + { + "role": "system", + "content": model_info.params.get("system", None), + }, + ) + + else: + pass + + model = app.state.MODELS[payload.get("model")] + idx = model["urlIdx"] + + if "pipeline" in model and model.get("pipeline"): + payload["user"] = {"name": user.name, "id": user.id} + + # Check if the model is "gpt-4-vision-preview" and set "max_tokens" to 4000 + # This is a workaround until OpenAI fixes the issue with this model + if payload.get("model") == "gpt-4-vision-preview": + if "max_tokens" not in payload: + payload["max_tokens"] = 4000 + log.debug("Modified payload:", payload) + + # Convert the modified body back to JSON + payload = json.dumps(payload) + + print(payload) + + url = app.state.config.OPENAI_API_BASE_URLS[idx] + key = app.state.config.OPENAI_API_KEYS[idx] + + print(payload) + + headers = {} + headers["Authorization"] = f"Bearer {key}" + headers["Content-Type"] = "application/json" + + r = None + session = None + streaming = False + + try: + session = aiohttp.ClientSession(trust_env=True) + r = await session.request( + method="POST", + url=f"{url}/chat/completions", + data=payload, + headers=headers, + ) + + r.raise_for_status() + + # Check if response is SSE + if "text/event-stream" in r.headers.get("Content-Type", ""): + streaming = True + return StreamingResponse( + r.content, + status_code=r.status, + headers=dict(r.headers), + background=BackgroundTask( + cleanup_response, response=r, session=session + ), + ) + else: + response_data = await r.json() + return response_data + except Exception as e: + log.exception(e) + error_detail = "Open WebUI: Server Connection Error" + if r is not None: + try: + res = await r.json() + print(res) + if "error" in res: + error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" + except: + error_detail = f"External: {e}" + raise HTTPException(status_code=r.status if r else 500, detail=error_detail) + finally: + if not streaming and session: + if r: + r.close() + await session.close() + + @app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"]) async def proxy(path: str, request: Request, user=Depends(get_verified_user)): idx = 0 body = await request.body() - # TODO: Remove below after gpt-4-vision fix from Open AI - # Try to decode the body of the request from bytes to a UTF-8 string (Require add max_token to fix gpt-4-vision) - - payload = None - - try: - if "chat/completions" in path: - body = body.decode("utf-8") - body = json.loads(body) - - payload = {**body} - - model_id = body.get("model") - model_info = Models.get_model_by_id(model_id) - - if model_info: - print(model_info) - if model_info.base_model_id: - payload["model"] = model_info.base_model_id - - model_info.params = model_info.params.model_dump() - - if model_info.params: - if model_info.params.get("temperature", None): - payload["temperature"] = int( - model_info.params.get("temperature") - ) - - if model_info.params.get("top_p", None): - payload["top_p"] = int(model_info.params.get("top_p", None)) - - if model_info.params.get("max_tokens", None): - payload["max_tokens"] = int( - model_info.params.get("max_tokens", None) - ) - - if model_info.params.get("frequency_penalty", None): - payload["frequency_penalty"] = int( - model_info.params.get("frequency_penalty", None) - ) - - if model_info.params.get("seed", None): - payload["seed"] = model_info.params.get("seed", None) - - if model_info.params.get("stop", None): - payload["stop"] = ( - [ - bytes(stop, "utf-8").decode("unicode_escape") - for stop in model_info.params["stop"] - ] - if model_info.params.get("stop", None) - else None - ) - - if model_info.params.get("system", None): - # Check if the payload already has a system message - # If not, add a system message to the payload - if payload.get("messages"): - for message in payload["messages"]: - if message.get("role") == "system": - message["content"] = ( - model_info.params.get("system", None) - + message["content"] - ) - break - else: - payload["messages"].insert( - 0, - { - "role": "system", - "content": model_info.params.get("system", None), - }, - ) - else: - pass - - model = app.state.MODELS[payload.get("model")] - - idx = model["urlIdx"] - - if "pipeline" in model and model.get("pipeline"): - payload["user"] = {"name": user.name, "id": user.id} - payload["title"] = ( - True - if payload["stream"] == False and payload["max_tokens"] == 50 - else False - ) - - # Check if the model is "gpt-4-vision-preview" and set "max_tokens" to 4000 - # This is a workaround until OpenAI fixes the issue with this model - if payload.get("model") == "gpt-4-vision-preview": - if "max_tokens" not in payload: - payload["max_tokens"] = 4000 - log.debug("Modified payload:", payload) - - # Convert the modified body back to JSON - payload = json.dumps(payload) - - except json.JSONDecodeError as e: - log.error("Error loading request body into a dictionary:", e) - - print(payload) url = app.state.config.OPENAI_API_BASE_URLS[idx] key = app.state.config.OPENAI_API_KEYS[idx] @@ -431,40 +505,48 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)): headers["Content-Type"] = "application/json" r = None + session = None + streaming = False try: - r = requests.request( + session = aiohttp.ClientSession(trust_env=True) + r = await session.request( method=request.method, url=target_url, - data=payload if payload else body, + data=body, headers=headers, - stream=True, ) r.raise_for_status() # Check if response is SSE if "text/event-stream" in r.headers.get("Content-Type", ""): + streaming = True return StreamingResponse( - r.iter_content(chunk_size=8192), - status_code=r.status_code, + r.content, + status_code=r.status, headers=dict(r.headers), + background=BackgroundTask( + cleanup_response, response=r, session=session + ), ) else: - response_data = r.json() + response_data = await r.json() return response_data except Exception as e: log.exception(e) error_detail = "Open WebUI: Server Connection Error" if r is not None: try: - res = r.json() + res = await r.json() print(res) if "error" in res: error_detail = f"External: {res['error']['message'] if 'message' in res['error'] else res['error']}" except: error_detail = f"External: {e}" - - raise HTTPException( - status_code=r.status_code if r else 500, detail=error_detail - ) + raise HTTPException(status_code=r.status if r else 500, detail=error_detail) + finally: + if not streaming and session: + if r: + r.close() + await session.close() diff --git a/backend/apps/rag/main.py b/backend/apps/rag/main.py index cc6cf81a32..0e493eaaa3 100644 --- a/backend/apps/rag/main.py +++ b/backend/apps/rag/main.py @@ -8,12 +8,15 @@ from fastapi import ( Form, ) from fastapi.middleware.cors import CORSMiddleware +import requests import os, shutil, logging, re +from datetime import datetime from pathlib import Path -from typing import List, Union, Sequence +from typing import List, Union, Sequence, Iterator, Any from chromadb.utils.batch_utils import create_batches +from langchain_core.documents import Document from langchain_community.document_loaders import ( WebBaseLoader, @@ -30,6 +33,7 @@ from langchain_community.document_loaders import ( UnstructuredExcelLoader, UnstructuredPowerPointLoader, YoutubeLoader, + OutlookMessageLoader, ) from langchain.text_splitter import RecursiveCharacterTextSplitter @@ -59,9 +63,17 @@ from apps.rag.utils import ( query_doc_with_hybrid_search, query_collection, query_collection_with_hybrid_search, - search_web, ) +from apps.rag.search.brave import search_brave +from apps.rag.search.google_pse import search_google_pse +from apps.rag.search.main import SearchResult +from apps.rag.search.searxng import search_searxng +from apps.rag.search.serper import search_serper +from apps.rag.search.serpstack import search_serpstack +from apps.rag.search.serply import search_serply +from apps.rag.search.duckduckgo import search_duckduckgo + from utils.misc import ( calculate_sha256, calculate_sha256_string, @@ -71,6 +83,7 @@ from utils.misc import ( from utils.utils import get_current_user, get_admin_user from config import ( + AppConfig, ENV, SRC_LOG_LEVELS, UPLOAD_DIR, @@ -96,8 +109,19 @@ from config import ( RAG_TEMPLATE, ENABLE_RAG_LOCAL_WEB_FETCH, YOUTUBE_LOADER_LANGUAGE, + ENABLE_RAG_WEB_SEARCH, + RAG_WEB_SEARCH_ENGINE, + SEARXNG_QUERY_URL, + GOOGLE_PSE_API_KEY, + GOOGLE_PSE_ENGINE_ID, + BRAVE_SEARCH_API_KEY, + SERPSTACK_API_KEY, + SERPSTACK_HTTPS, + SERPER_API_KEY, + SERPLY_API_KEY, + RAG_WEB_SEARCH_RESULT_COUNT, RAG_WEB_SEARCH_CONCURRENT_REQUESTS, - AppConfig, + RAG_EMBEDDING_OPENAI_BATCH_SIZE, ) from constants import ERROR_MESSAGES @@ -122,6 +146,7 @@ app.state.config.CHUNK_OVERLAP = CHUNK_OVERLAP app.state.config.RAG_EMBEDDING_ENGINE = RAG_EMBEDDING_ENGINE app.state.config.RAG_EMBEDDING_MODEL = RAG_EMBEDDING_MODEL +app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE = RAG_EMBEDDING_OPENAI_BATCH_SIZE app.state.config.RAG_RERANKING_MODEL = RAG_RERANKING_MODEL app.state.config.RAG_TEMPLATE = RAG_TEMPLATE @@ -136,6 +161,21 @@ app.state.config.YOUTUBE_LOADER_LANGUAGE = YOUTUBE_LOADER_LANGUAGE app.state.YOUTUBE_LOADER_TRANSLATION = None +app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH +app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE + +app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL +app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY +app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID +app.state.config.BRAVE_SEARCH_API_KEY = BRAVE_SEARCH_API_KEY +app.state.config.SERPSTACK_API_KEY = SERPSTACK_API_KEY +app.state.config.SERPSTACK_HTTPS = SERPSTACK_HTTPS +app.state.config.SERPER_API_KEY = SERPER_API_KEY +app.state.config.SERPLY_API_KEY = SERPLY_API_KEY +app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = RAG_WEB_SEARCH_RESULT_COUNT +app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = RAG_WEB_SEARCH_CONCURRENT_REQUESTS + + def update_embedding_model( embedding_model: str, update_model: bool = False, @@ -181,6 +221,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function( app.state.sentence_transformer_ef, app.state.config.OPENAI_API_KEY, app.state.config.OPENAI_API_BASE_URL, + app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE, ) origins = ["*"] @@ -217,6 +258,7 @@ async def get_status(): "embedding_engine": app.state.config.RAG_EMBEDDING_ENGINE, "embedding_model": app.state.config.RAG_EMBEDDING_MODEL, "reranking_model": app.state.config.RAG_RERANKING_MODEL, + "openai_batch_size": app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE, } @@ -229,6 +271,7 @@ async def get_embedding_config(user=Depends(get_admin_user)): "openai_config": { "url": app.state.config.OPENAI_API_BASE_URL, "key": app.state.config.OPENAI_API_KEY, + "batch_size": app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE, }, } @@ -244,6 +287,7 @@ async def get_reraanking_config(user=Depends(get_admin_user)): class OpenAIConfigForm(BaseModel): url: str key: str + batch_size: Optional[int] = None class EmbeddingModelUpdateForm(BaseModel): @@ -264,9 +308,14 @@ async def update_embedding_config( app.state.config.RAG_EMBEDDING_MODEL = form_data.embedding_model if app.state.config.RAG_EMBEDDING_ENGINE in ["ollama", "openai"]: - if form_data.openai_config != None: + if form_data.openai_config is not None: app.state.config.OPENAI_API_BASE_URL = form_data.openai_config.url app.state.config.OPENAI_API_KEY = form_data.openai_config.key + app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE = ( + form_data.openai_config.batch_size + if form_data.openai_config.batch_size + else 1 + ) update_embedding_model(app.state.config.RAG_EMBEDDING_MODEL) @@ -276,6 +325,7 @@ async def update_embedding_config( app.state.sentence_transformer_ef, app.state.config.OPENAI_API_KEY, app.state.config.OPENAI_API_BASE_URL, + app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE, ) return { @@ -285,6 +335,7 @@ async def update_embedding_config( "openai_config": { "url": app.state.config.OPENAI_API_BASE_URL, "key": app.state.config.OPENAI_API_KEY, + "batch_size": app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE, }, } except Exception as e: @@ -332,11 +383,27 @@ async def get_rag_config(user=Depends(get_admin_user)): "chunk_size": app.state.config.CHUNK_SIZE, "chunk_overlap": app.state.config.CHUNK_OVERLAP, }, - "web_loader_ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, "youtube": { "language": app.state.config.YOUTUBE_LOADER_LANGUAGE, "translation": app.state.YOUTUBE_LOADER_TRANSLATION, }, + "web": { + "ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, + "search": { + "enabled": app.state.config.ENABLE_RAG_WEB_SEARCH, + "engine": app.state.config.RAG_WEB_SEARCH_ENGINE, + "searxng_query_url": app.state.config.SEARXNG_QUERY_URL, + "google_pse_api_key": app.state.config.GOOGLE_PSE_API_KEY, + "google_pse_engine_id": app.state.config.GOOGLE_PSE_ENGINE_ID, + "brave_search_api_key": app.state.config.BRAVE_SEARCH_API_KEY, + "serpstack_api_key": app.state.config.SERPSTACK_API_KEY, + "serpstack_https": app.state.config.SERPSTACK_HTTPS, + "serper_api_key": app.state.config.SERPER_API_KEY, + "serply_api_key": app.state.config.SERPLY_API_KEY, + "result_count": app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + "concurrent_requests": app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, + }, + }, } @@ -350,11 +417,31 @@ class YoutubeLoaderConfig(BaseModel): translation: Optional[str] = None +class WebSearchConfig(BaseModel): + enabled: bool + engine: Optional[str] = None + searxng_query_url: Optional[str] = None + google_pse_api_key: Optional[str] = None + google_pse_engine_id: Optional[str] = None + brave_search_api_key: Optional[str] = None + serpstack_api_key: Optional[str] = None + serpstack_https: Optional[bool] = None + serper_api_key: Optional[str] = None + serply_api_key: Optional[str] = None + result_count: Optional[int] = None + concurrent_requests: Optional[int] = None + + +class WebConfig(BaseModel): + search: WebSearchConfig + web_loader_ssl_verification: Optional[bool] = None + + class ConfigUpdateForm(BaseModel): pdf_extract_images: Optional[bool] = None chunk: Optional[ChunkParamUpdateForm] = None - web_loader_ssl_verification: Optional[bool] = None youtube: Optional[YoutubeLoaderConfig] = None + web: Optional[WebConfig] = None @app.post("/config/update") @@ -365,35 +452,37 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ else app.state.config.PDF_EXTRACT_IMAGES ) - app.state.config.CHUNK_SIZE = ( - form_data.chunk.chunk_size - if form_data.chunk is not None - else app.state.config.CHUNK_SIZE - ) + if form_data.chunk is not None: + app.state.config.CHUNK_SIZE = form_data.chunk.chunk_size + app.state.config.CHUNK_OVERLAP = form_data.chunk.chunk_overlap - app.state.config.CHUNK_OVERLAP = ( - form_data.chunk.chunk_overlap - if form_data.chunk is not None - else app.state.config.CHUNK_OVERLAP - ) + if form_data.youtube is not None: + app.state.config.YOUTUBE_LOADER_LANGUAGE = form_data.youtube.language + app.state.YOUTUBE_LOADER_TRANSLATION = form_data.youtube.translation - app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( - form_data.web_loader_ssl_verification - if form_data.web_loader_ssl_verification != None - else app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION - ) + if form_data.web is not None: + app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION = ( + form_data.web.web_loader_ssl_verification + ) - app.state.config.YOUTUBE_LOADER_LANGUAGE = ( - form_data.youtube.language - if form_data.youtube is not None - else app.state.config.YOUTUBE_LOADER_LANGUAGE - ) - - app.state.YOUTUBE_LOADER_TRANSLATION = ( - form_data.youtube.translation - if form_data.youtube is not None - else app.state.YOUTUBE_LOADER_TRANSLATION - ) + app.state.config.ENABLE_RAG_WEB_SEARCH = form_data.web.search.enabled + app.state.config.RAG_WEB_SEARCH_ENGINE = form_data.web.search.engine + app.state.config.SEARXNG_QUERY_URL = form_data.web.search.searxng_query_url + app.state.config.GOOGLE_PSE_API_KEY = form_data.web.search.google_pse_api_key + app.state.config.GOOGLE_PSE_ENGINE_ID = ( + form_data.web.search.google_pse_engine_id + ) + app.state.config.BRAVE_SEARCH_API_KEY = ( + form_data.web.search.brave_search_api_key + ) + app.state.config.SERPSTACK_API_KEY = form_data.web.search.serpstack_api_key + app.state.config.SERPSTACK_HTTPS = form_data.web.search.serpstack_https + app.state.config.SERPER_API_KEY = form_data.web.search.serper_api_key + app.state.config.SERPLY_API_KEY = form_data.web.search.serply_api_key + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT = form_data.web.search.result_count + app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS = ( + form_data.web.search.concurrent_requests + ) return { "status": True, @@ -402,11 +491,27 @@ async def update_rag_config(form_data: ConfigUpdateForm, user=Depends(get_admin_ "chunk_size": app.state.config.CHUNK_SIZE, "chunk_overlap": app.state.config.CHUNK_OVERLAP, }, - "web_loader_ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, "youtube": { "language": app.state.config.YOUTUBE_LOADER_LANGUAGE, "translation": app.state.YOUTUBE_LOADER_TRANSLATION, }, + "web": { + "ssl_verification": app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION, + "search": { + "enabled": app.state.config.ENABLE_RAG_WEB_SEARCH, + "engine": app.state.config.RAG_WEB_SEARCH_ENGINE, + "searxng_query_url": app.state.config.SEARXNG_QUERY_URL, + "google_pse_api_key": app.state.config.GOOGLE_PSE_API_KEY, + "google_pse_engine_id": app.state.config.GOOGLE_PSE_ENGINE_ID, + "brave_search_api_key": app.state.config.BRAVE_SEARCH_API_KEY, + "serpstack_api_key": app.state.config.SERPSTACK_API_KEY, + "serpstack_https": app.state.config.SERPSTACK_HTTPS, + "serper_api_key": app.state.config.SERPER_API_KEY, + "serply_api_key": app.state.config.SERPLY_API_KEY, + "result_count": app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + "concurrent_requests": app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS, + }, + }, } @@ -599,7 +704,7 @@ def get_web_loader(url: Union[str, Sequence[str]], verify_ssl: bool = True): # Check if the URL is valid if not validate_url(url): raise ValueError(ERROR_MESSAGES.INVALID_URL) - return WebBaseLoader( + return SafeWebBaseLoader( url, verify_ssl=verify_ssl, requests_per_second=RAG_WEB_SEARCH_CONCURRENT_REQUESTS, @@ -642,17 +747,107 @@ def resolve_hostname(hostname): return ipv4_addresses, ipv6_addresses +def search_web(engine: str, query: str) -> list[SearchResult]: + """Search the web using a search engine and return the results as a list of SearchResult objects. + Will look for a search engine API key in environment variables in the following order: + - SEARXNG_QUERY_URL + - GOOGLE_PSE_API_KEY + GOOGLE_PSE_ENGINE_ID + - BRAVE_SEARCH_API_KEY + - SERPSTACK_API_KEY + - SERPER_API_KEY + - SERPLY_API_KEY + + Args: + query (str): The query to search for + """ + + # TODO: add playwright to search the web + if engine == "searxng": + if app.state.config.SEARXNG_QUERY_URL: + return search_searxng( + app.state.config.SEARXNG_QUERY_URL, + query, + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + ) + else: + raise Exception("No SEARXNG_QUERY_URL found in environment variables") + elif engine == "google_pse": + if ( + app.state.config.GOOGLE_PSE_API_KEY + and app.state.config.GOOGLE_PSE_ENGINE_ID + ): + return search_google_pse( + app.state.config.GOOGLE_PSE_API_KEY, + app.state.config.GOOGLE_PSE_ENGINE_ID, + query, + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + ) + else: + raise Exception( + "No GOOGLE_PSE_API_KEY or GOOGLE_PSE_ENGINE_ID found in environment variables" + ) + elif engine == "brave": + if app.state.config.BRAVE_SEARCH_API_KEY: + return search_brave( + app.state.config.BRAVE_SEARCH_API_KEY, + query, + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + ) + else: + raise Exception("No BRAVE_SEARCH_API_KEY found in environment variables") + elif engine == "serpstack": + if app.state.config.SERPSTACK_API_KEY: + return search_serpstack( + app.state.config.SERPSTACK_API_KEY, + query, + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + https_enabled=app.state.config.SERPSTACK_HTTPS, + ) + else: + raise Exception("No SERPSTACK_API_KEY found in environment variables") + elif engine == "serper": + if app.state.config.SERPER_API_KEY: + return search_serper( + app.state.config.SERPER_API_KEY, + query, + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + ) + else: + raise Exception("No SERPER_API_KEY found in environment variables") + elif engine == "serply": + if app.state.config.SERPLY_API_KEY: + return search_serply( + app.state.config.SERPLY_API_KEY, + query, + app.state.config.RAG_WEB_SEARCH_RESULT_COUNT, + ) + else: + raise Exception("No SERPLY_API_KEY found in environment variables") + elif engine == "duckduckgo": + return search_duckduckgo(query, app.state.config.RAG_WEB_SEARCH_RESULT_COUNT) + else: + raise Exception("No search engine API key found in environment variables") + + @app.post("/web/search") def store_web_search(form_data: SearchForm, user=Depends(get_current_user)): try: - try: - web_results = search_web(form_data.query) - except Exception as e: - log.exception(e) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=ERROR_MESSAGES.WEB_SEARCH_ERROR, - ) + logging.info( + f"trying to web search with {app.state.config.RAG_WEB_SEARCH_ENGINE, form_data.query}" + ) + web_results = search_web( + app.state.config.RAG_WEB_SEARCH_ENGINE, form_data.query + ) + except Exception as e: + log.exception(e) + + print(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e), + ) + + try: urls = [result.link for result in web_results] loader = get_web_loader(urls) data = loader.load() @@ -710,6 +905,13 @@ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> b texts = [doc.page_content for doc in docs] metadatas = [doc.metadata for doc in docs] + # ChromaDB does not like datetime formats + # for meta-data so convert them to string. + for metadata in metadatas: + for key, value in metadata.items(): + if isinstance(value, datetime): + metadata[key] = str(value) + try: if overwrite: for collection in CHROMA_CLIENT.list_collections(): @@ -725,6 +927,7 @@ def store_docs_in_vector_db(docs, collection_name, overwrite: bool = False) -> b app.state.sentence_transformer_ef, app.state.config.OPENAI_API_KEY, app.state.config.OPENAI_API_BASE_URL, + app.state.config.RAG_EMBEDDING_OPENAI_BATCH_SIZE, ) embedding_texts = list(map(lambda x: x.replace("\n", " "), texts)) @@ -795,6 +998,7 @@ def get_loader(filename: str, file_content_type: str, file_path: str): "swift", "vue", "svelte", + "msg", ] if file_ext == "pdf": @@ -829,6 +1033,8 @@ def get_loader(filename: str, file_content_type: str, file_path: str): "application/vnd.openxmlformats-officedocument.presentationml.presentation", ] or file_ext in ["ppt", "pptx"]: loader = UnstructuredPowerPointLoader(file_path) + elif file_ext == "msg": + loader = OutlookMessageLoader(file_path) elif file_ext in known_source_ext or ( file_content_type and file_content_type.find("text/") >= 0 ): @@ -994,6 +1200,30 @@ def reset_vector_db(user=Depends(get_admin_user)): CHROMA_CLIENT.reset() +@app.get("/reset/uploads") +def reset_upload_dir(user=Depends(get_admin_user)) -> bool: + folder = f"{UPLOAD_DIR}" + try: + # Check if the directory exists + if os.path.exists(folder): + # Iterate over all the files and directories in the specified directory + for filename in os.listdir(folder): + file_path = os.path.join(folder, filename) + try: + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) # Remove the file or link + elif os.path.isdir(file_path): + shutil.rmtree(file_path) # Remove the directory + except Exception as e: + print(f"Failed to delete {file_path}. Reason: {e}") + else: + print(f"The directory {folder} does not exist") + except Exception as e: + print(f"Failed to process the directory {folder}. Reason: {e}") + + return True + + @app.get("/reset") def reset(user=Depends(get_admin_user)) -> bool: folder = f"{UPLOAD_DIR}" @@ -1015,6 +1245,33 @@ def reset(user=Depends(get_admin_user)) -> bool: return True +class SafeWebBaseLoader(WebBaseLoader): + """WebBaseLoader with enhanced error handling for URLs.""" + + def lazy_load(self) -> Iterator[Document]: + """Lazy load text from the url(s) in web_path with error handling.""" + for path in self.web_paths: + try: + soup = self._scrape(path, bs_kwargs=self.bs_kwargs) + text = soup.get_text(**self.bs_get_text_kwargs) + + # Build metadata + metadata = {"source": path} + if title := soup.find("title"): + metadata["title"] = title.get_text() + if description := soup.find("meta", attrs={"name": "description"}): + metadata["description"] = description.get( + "content", "No description found." + ) + if html := soup.find("html"): + metadata["language"] = html.get("lang", "No language found.") + + yield Document(page_content=text, metadata=metadata) + except Exception as e: + # Log the error and continue with the next URL + log.error(f"Error loading {path}: {e}") + + if ENV == "dev": @app.get("/ef") diff --git a/backend/apps/rag/search/brave.py b/backend/apps/rag/search/brave.py index 50e364ca3a..4e0f56807f 100644 --- a/backend/apps/rag/search/brave.py +++ b/backend/apps/rag/search/brave.py @@ -3,13 +3,13 @@ import logging import requests from apps.rag.search.main import SearchResult -from config import SRC_LOG_LEVELS, RAG_WEB_SEARCH_RESULT_COUNT +from config import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -def search_brave(api_key: str, query: str) -> list[SearchResult]: +def search_brave(api_key: str, query: str, count: int) -> list[SearchResult]: """Search using Brave's Search API and return the results as a list of SearchResult objects. Args: @@ -22,7 +22,7 @@ def search_brave(api_key: str, query: str) -> list[SearchResult]: "Accept-Encoding": "gzip", "X-Subscription-Token": api_key, } - params = {"q": query, "count": RAG_WEB_SEARCH_RESULT_COUNT} + params = {"q": query, "count": count} response = requests.get(url, headers=headers, params=params) response.raise_for_status() @@ -33,5 +33,5 @@ def search_brave(api_key: str, query: str) -> list[SearchResult]: SearchResult( link=result["url"], title=result.get("title"), snippet=result.get("snippet") ) - for result in results[:RAG_WEB_SEARCH_RESULT_COUNT] + for result in results[:count] ] diff --git a/backend/apps/rag/search/duckduckgo.py b/backend/apps/rag/search/duckduckgo.py new file mode 100644 index 0000000000..188ae2bea4 --- /dev/null +++ b/backend/apps/rag/search/duckduckgo.py @@ -0,0 +1,46 @@ +import logging + +from apps.rag.search.main import SearchResult +from duckduckgo_search import DDGS +from config import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_duckduckgo(query: str, count: int) -> list[SearchResult]: + """ + Search using DuckDuckGo's Search API and return the results as a list of SearchResult objects. + Args: + query (str): The query to search for + count (int): The number of results to return + + Returns: + List[SearchResult]: A list of search results + """ + # Use the DDGS context manager to create a DDGS object + with DDGS() as ddgs: + # Use the ddgs.text() method to perform the search + ddgs_gen = ddgs.text( + query, safesearch="moderate", max_results=count, backend="api" + ) + # Check if there are search results + if ddgs_gen: + # Convert the search results into a list + search_results = [r for r in ddgs_gen] + + # Create an empty list to store the SearchResult objects + results = [] + # Iterate over each search result + for result in search_results: + # Create a SearchResult object and append it to the results list + results.append( + SearchResult( + link=result["href"], + title=result.get("title"), + snippet=result.get("body"), + ) + ) + print(results) + # Return the list of search results + return results diff --git a/backend/apps/rag/search/google_pse.py b/backend/apps/rag/search/google_pse.py index 9cc22402da..7ff54c7850 100644 --- a/backend/apps/rag/search/google_pse.py +++ b/backend/apps/rag/search/google_pse.py @@ -4,14 +4,14 @@ import logging import requests from apps.rag.search.main import SearchResult -from config import SRC_LOG_LEVELS, RAG_WEB_SEARCH_RESULT_COUNT +from config import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_google_pse( - api_key: str, search_engine_id: str, query: str + api_key: str, search_engine_id: str, query: str, count: int ) -> list[SearchResult]: """Search using Google's Programmable Search Engine API and return the results as a list of SearchResult objects. @@ -27,7 +27,7 @@ def search_google_pse( "cx": search_engine_id, "q": query, "key": api_key, - "num": RAG_WEB_SEARCH_RESULT_COUNT, + "num": count, } response = requests.request("GET", url, headers=headers, params=params) diff --git a/backend/apps/rag/search/searxng.py b/backend/apps/rag/search/searxng.py index 9848439ad4..c8ad888133 100644 --- a/backend/apps/rag/search/searxng.py +++ b/backend/apps/rag/search/searxng.py @@ -1,28 +1,68 @@ import logging - import requests +from typing import List + from apps.rag.search.main import SearchResult -from config import SRC_LOG_LEVELS, RAG_WEB_SEARCH_RESULT_COUNT +from config import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -def search_searxng(query_url: str, query: str) -> list[SearchResult]: - """Search a SearXNG instance for a query and return the results as a list of SearchResult objects. +def search_searxng( + query_url: str, query: str, count: int, **kwargs +) -> List[SearchResult]: + """ + Search a SearXNG instance for a given query and return the results as a list of SearchResult objects. + + The function allows passing additional parameters such as language or time_range to tailor the search result. Args: - query_url (str): The URL of the SearXNG instance to search. Must contain "" as a placeholder - query (str): The query to search for - """ - url = query_url.replace("", query) - if "&format=json" not in url: - url += "&format=json" - log.debug(f"searching {url}") + query_url (str): The base URL of the SearXNG server. + query (str): The search term or question to find in the SearXNG database. + count (int): The maximum number of results to retrieve from the search. - r = requests.get( - url, + Keyword Args: + language (str): Language filter for the search results; e.g., "en-US". Defaults to an empty string. + safesearch (int): Safe search filter for safer web results; 0 = off, 1 = moderate, 2 = strict. Defaults to 1 (moderate). + time_range (str): Time range for filtering results by date; e.g., "2023-04-05..today" or "all-time". Defaults to ''. + categories: (Optional[List[str]]): Specific categories within which the search should be performed, defaulting to an empty string if not provided. + + Returns: + List[SearchResult]: A list of SearchResults sorted by relevance score in descending order. + + Raise: + requests.exceptions.RequestException: If a request error occurs during the search process. + """ + + # Default values for optional parameters are provided as empty strings or None when not specified. + language = kwargs.get("language", "en-US") + safesearch = kwargs.get("safesearch", "1") + time_range = kwargs.get("time_range", "") + categories = "".join(kwargs.get("categories", [])) + + params = { + "q": query, + "format": "json", + "pageno": 1, + "safesearch": safesearch, + "language": language, + "time_range": time_range, + "categories": categories, + "theme": "simple", + "image_proxy": 0, + } + + # Legacy query format + if "" in query_url: + # Strip all query parameters from the URL + query_url = query_url.split("?")[0] + + log.debug(f"searching {query_url}") + + response = requests.get( + query_url, headers={ "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot", "Accept": "text/html", @@ -30,15 +70,17 @@ def search_searxng(query_url: str, query: str) -> list[SearchResult]: "Accept-Language": "en-US,en;q=0.5", "Connection": "keep-alive", }, + params=params, ) - r.raise_for_status() - json_response = r.json() + response.raise_for_status() # Raise an exception for HTTP errors. + + json_response = response.json() results = json_response.get("results", []) sorted_results = sorted(results, key=lambda x: x.get("score", 0), reverse=True) return [ SearchResult( link=result["url"], title=result.get("title"), snippet=result.get("content") ) - for result in sorted_results[:RAG_WEB_SEARCH_RESULT_COUNT] + for result in sorted_results[:count] ] diff --git a/backend/apps/rag/search/serper.py b/backend/apps/rag/search/serper.py index 49146b3045..150da6e074 100644 --- a/backend/apps/rag/search/serper.py +++ b/backend/apps/rag/search/serper.py @@ -4,13 +4,13 @@ import logging import requests from apps.rag.search.main import SearchResult -from config import SRC_LOG_LEVELS, RAG_WEB_SEARCH_RESULT_COUNT +from config import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) -def search_serper(api_key: str, query: str) -> list[SearchResult]: +def search_serper(api_key: str, query: str, count: int) -> list[SearchResult]: """Search using serper.dev's API and return the results as a list of SearchResult objects. Args: @@ -35,5 +35,5 @@ def search_serper(api_key: str, query: str) -> list[SearchResult]: title=result.get("title"), snippet=result.get("description"), ) - for result in results[:RAG_WEB_SEARCH_RESULT_COUNT] + for result in results[:count] ] diff --git a/backend/apps/rag/search/serply.py b/backend/apps/rag/search/serply.py new file mode 100644 index 0000000000..fccf70ecd8 --- /dev/null +++ b/backend/apps/rag/search/serply.py @@ -0,0 +1,68 @@ +import json +import logging + +import requests +from urllib.parse import urlencode + +from apps.rag.search.main import SearchResult +from config import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["RAG"]) + + +def search_serply( + api_key: str, + query: str, + count: int, + hl: str = "us", + limit: int = 10, + device_type: str = "desktop", + proxy_location: str = "US", +) -> list[SearchResult]: + """Search using serper.dev's API and return the results as a list of SearchResult objects. + + Args: + api_key (str): A serply.io API key + query (str): The query to search for + hl (str): Host Language code to display results in (reference https://developers.google.com/custom-search/docs/xml_results?hl=en#wsInterfaceLanguages) + limit (int): The maximum number of results to return [10-100, defaults to 10] + """ + log.info("Searching with Serply") + + url = "https://api.serply.io/v1/search/" + + query_payload = { + "q": query, + "language": "en", + "num": limit, + "gl": proxy_location.upper(), + "hl": hl.lower(), + } + + url = f"{url}{urlencode(query_payload)}" + headers = { + "X-API-KEY": api_key, + "X-User-Agent": device_type, + "User-Agent": "open-webui", + "X-Proxy-Location": proxy_location, + } + + response = requests.request("GET", url, headers=headers) + response.raise_for_status() + + json_response = response.json() + log.info(f"results from serply search: {json_response}") + + results = sorted( + json_response.get("results", []), key=lambda x: x.get("realPosition", 0) + ) + + return [ + SearchResult( + link=result["link"], + title=result.get("title"), + snippet=result.get("description"), + ) + for result in results[:count] + ] diff --git a/backend/apps/rag/search/serpstack.py b/backend/apps/rag/search/serpstack.py index 68222aa2bc..0d247d1ab0 100644 --- a/backend/apps/rag/search/serpstack.py +++ b/backend/apps/rag/search/serpstack.py @@ -4,14 +4,14 @@ import logging import requests from apps.rag.search.main import SearchResult -from config import SRC_LOG_LEVELS, RAG_WEB_SEARCH_RESULT_COUNT +from config import SRC_LOG_LEVELS log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) def search_serpstack( - api_key: str, query: str, https_enabled: bool = True + api_key: str, query: str, count: int, https_enabled: bool = True ) -> list[SearchResult]: """Search using serpstack.com's and return the results as a list of SearchResult objects. @@ -39,5 +39,5 @@ def search_serpstack( SearchResult( link=result["url"], title=result.get("title"), snippet=result.get("snippet") ) - for result in results[:RAG_WEB_SEARCH_RESULT_COUNT] + for result in results[:count] ] diff --git a/backend/apps/rag/search/testdata/serply.json b/backend/apps/rag/search/testdata/serply.json new file mode 100644 index 0000000000..0fc2a31e4d --- /dev/null +++ b/backend/apps/rag/search/testdata/serply.json @@ -0,0 +1,206 @@ +{ + "ads": [], + "ads_count": 0, + "answers": [], + "results": [ + { + "title": "Apple", + "link": "https://www.apple.com/", + "description": "Discover the innovative world of Apple and shop everything iPhone, iPad, Apple Watch, Mac, and Apple TV, plus explore accessories, entertainment, ...", + "additional_links": [ + { + "text": "AppleApplehttps://www.apple.com", + "href": "https://www.apple.com/" + } + ], + "cite": {}, + "subdomains": [ + { + "title": "Support", + "link": "https://support.apple.com/", + "description": "SupportContact - iPhone Support - Billing and Subscriptions - Apple Repair" + }, + { + "title": "Store", + "link": "https://www.apple.com/store", + "description": "StoreShop iPhone - Shop iPad - App Store - Shop Mac - ..." + }, + { + "title": "Mac", + "link": "https://www.apple.com/mac/", + "description": "MacMacBook Air - MacBook Pro - iMac - Compare Mac models - Mac mini" + }, + { + "title": "iPad", + "link": "https://www.apple.com/ipad/", + "description": "iPadShop iPad - iPad Pro - iPad Air - Compare iPad models - ..." + }, + { + "title": "Watch", + "link": "https://www.apple.com/watch/", + "description": "WatchShop Apple Watch - Series 9 - SE - Ultra 2 - Nike - Hermès - ..." + } + ], + "realPosition": 1 + }, + { + "title": "Apple", + "link": "https://www.apple.com/", + "description": "Discover the innovative world of Apple and shop everything iPhone, iPad, Apple Watch, Mac, and Apple TV, plus explore accessories, entertainment, ...", + "additional_links": [ + { + "text": "AppleApplehttps://www.apple.com", + "href": "https://www.apple.com/" + } + ], + "cite": {}, + "realPosition": 2 + }, + { + "title": "Apple Inc.", + "link": "https://en.wikipedia.org/wiki/Apple_Inc.", + "description": "Apple Inc. (formerly Apple Computer, Inc.) is an American multinational corporation and technology company headquartered in Cupertino, California, ...", + "additional_links": [ + { + "text": "Apple Inc.Wikipediahttps://en.wikipedia.org › wiki › Apple_Inc", + "href": "https://en.wikipedia.org/wiki/Apple_Inc." + }, + { + "text": "", + "href": "https://en.wikipedia.org/wiki/Apple_Inc." + }, + { + "text": "History", + "href": "https://en.wikipedia.org/wiki/History_of_Apple_Inc." + }, + { + "text": "List of Apple products", + "href": "https://en.wikipedia.org/wiki/List_of_Apple_products" + }, + { + "text": "Litigation involving Apple Inc.", + "href": "https://en.wikipedia.org/wiki/Litigation_involving_Apple_Inc." + }, + { + "text": "Apple Park", + "href": "https://en.wikipedia.org/wiki/Apple_Park" + } + ], + "cite": { + "domain": "https://en.wikipedia.org › wiki › Apple_Inc", + "span": " › wiki › Apple_Inc" + }, + "realPosition": 3 + }, + { + "title": "Apple Inc. (AAPL) Company Profile & Facts", + "link": "https://finance.yahoo.com/quote/AAPL/profile/", + "description": "Apple Inc. designs, manufactures, and markets smartphones, personal computers, tablets, wearables, and accessories worldwide. The company offers iPhone, a line ...", + "additional_links": [ + { + "text": "Apple Inc. (AAPL) Company Profile & FactsYahoo Financehttps://finance.yahoo.com › quote › AAPL › profile", + "href": "https://finance.yahoo.com/quote/AAPL/profile/" + } + ], + "cite": { + "domain": "https://finance.yahoo.com › quote › AAPL › profile", + "span": " › quote › AAPL › profile" + }, + "realPosition": 4 + }, + { + "title": "Apple Inc - Company Profile and News", + "link": "https://www.bloomberg.com/profile/company/AAPL:US", + "description": "Apple Inc. Apple Inc. designs, manufactures, and markets smartphones, personal computers, tablets, wearables and accessories, and sells a variety of related ...", + "additional_links": [ + { + "text": "Apple Inc - Company Profile and NewsBloomberghttps://www.bloomberg.com › company › AAPL:US", + "href": "https://www.bloomberg.com/profile/company/AAPL:US" + }, + { + "text": "", + "href": "https://www.bloomberg.com/profile/company/AAPL:US" + } + ], + "cite": { + "domain": "https://www.bloomberg.com › company › AAPL:US", + "span": " › company › AAPL:US" + }, + "realPosition": 5 + }, + { + "title": "Apple Inc. | History, Products, Headquarters, & Facts", + "link": "https://www.britannica.com/money/Apple-Inc", + "description": "May 22, 2024 — Apple Inc. is an American multinational technology company that revolutionized the technology sector through its innovation of computer ...", + "additional_links": [ + { + "text": "Apple Inc. | History, Products, Headquarters, & FactsBritannicahttps://www.britannica.com › money › Apple-Inc", + "href": "https://www.britannica.com/money/Apple-Inc" + }, + { + "text": "", + "href": "https://www.britannica.com/money/Apple-Inc" + } + ], + "cite": { + "domain": "https://www.britannica.com › money › Apple-Inc", + "span": " › money › Apple-Inc" + }, + "realPosition": 6 + } + ], + "shopping_ads": [], + "places": [ + { + "title": "Apple Inc." + }, + { + "title": "Apple Inc" + }, + { + "title": "Apple Inc" + } + ], + "related_searches": { + "images": [], + "text": [ + { + "title": "apple inc full form", + "link": "https://www.google.com/search?sca_esv=6b6df170a5c9891b&sca_upv=1&q=Apple+Inc+full+form&sa=X&ved=2ahUKEwjLxuSJwM-GAxUHODQIHYuJBhgQ1QJ6BAhPEAE" + }, + { + "title": "apple company history", + "link": "https://www.google.com/search?sca_esv=6b6df170a5c9891b&sca_upv=1&q=Apple+company+history&sa=X&ved=2ahUKEwjLxuSJwM-GAxUHODQIHYuJBhgQ1QJ6BAhOEAE" + }, + { + "title": "apple store", + "link": "https://www.google.com/search?sca_esv=6b6df170a5c9891b&sca_upv=1&q=Apple+Store&sa=X&ved=2ahUKEwjLxuSJwM-GAxUHODQIHYuJBhgQ1QJ6BAhQEAE" + }, + { + "title": "apple id", + "link": "https://www.google.com/search?sca_esv=6b6df170a5c9891b&sca_upv=1&q=Apple+id&sa=X&ved=2ahUKEwjLxuSJwM-GAxUHODQIHYuJBhgQ1QJ6BAhSEAE" + }, + { + "title": "apple inc industry", + "link": "https://www.google.com/search?sca_esv=6b6df170a5c9891b&sca_upv=1&q=Apple+Inc+industry&sa=X&ved=2ahUKEwjLxuSJwM-GAxUHODQIHYuJBhgQ1QJ6BAhREAE" + }, + { + "title": "apple login", + "link": "https://www.google.com/search?sca_esv=6b6df170a5c9891b&sca_upv=1&q=Apple+login&sa=X&ved=2ahUKEwjLxuSJwM-GAxUHODQIHYuJBhgQ1QJ6BAhTEAE" + } + ] + }, + "image_results": [], + "carousel": [], + "total": 2450000000, + "knowledge_graph": "", + "related_questions": [ + "What does the Apple Inc do?", + "Why did Apple change to Apple Inc?", + "Who owns Apple Inc.?", + "What is Apple Inc best known for?" + ], + "carousel_count": 0, + "ts": 2.491065263748169, + "device_type": null +} diff --git a/backend/apps/rag/utils.py b/backend/apps/rag/utils.py index d5a826acce..d0570f7482 100644 --- a/backend/apps/rag/utils.py +++ b/backend/apps/rag/utils.py @@ -2,7 +2,7 @@ import os import logging import requests -from typing import List +from typing import List, Union from apps.ollama.main import ( generate_ollama_embeddings, @@ -20,23 +20,8 @@ from langchain.retrievers import ( from typing import Optional -from apps.rag.search.brave import search_brave -from apps.rag.search.google_pse import search_google_pse -from apps.rag.search.main import SearchResult -from apps.rag.search.searxng import search_searxng -from apps.rag.search.serper import search_serper -from apps.rag.search.serpstack import search_serpstack -from config import ( - SRC_LOG_LEVELS, - CHROMA_CLIENT, - SEARXNG_QUERY_URL, - GOOGLE_PSE_API_KEY, - GOOGLE_PSE_ENGINE_ID, - BRAVE_SEARCH_API_KEY, - SERPSTACK_API_KEY, - SERPSTACK_HTTPS, - SERPER_API_KEY, -) +from utils.misc import get_last_user_message, add_or_update_system_message +from config import SRC_LOG_LEVELS, CHROMA_CLIENT log = logging.getLogger(__name__) log.setLevel(SRC_LOG_LEVELS["RAG"]) @@ -214,6 +199,7 @@ def get_embedding_function( embedding_function, openai_key, openai_url, + batch_size, ): if embedding_engine == "": return lambda query: embedding_function.encode(query).tolist() @@ -237,17 +223,22 @@ def get_embedding_function( def generate_multiple(query, f): if isinstance(query, list): - return [f(q) for q in query] + if embedding_engine == "openai": + embeddings = [] + for i in range(0, len(query), batch_size): + embeddings.extend(f(query[i : i + batch_size])) + return embeddings + else: + return [f(q) for q in query] else: return f(query) return lambda query: generate_multiple(query, func) -def rag_messages( +def get_rag_context( docs, messages, - template, embedding_function, k, reranking_function, @@ -255,31 +246,7 @@ def rag_messages( hybrid_search, ): log.debug(f"docs: {docs} {messages} {embedding_function} {reranking_function}") - - last_user_message_idx = None - for i in range(len(messages) - 1, -1, -1): - if messages[i]["role"] == "user": - last_user_message_idx = i - break - - user_message = messages[last_user_message_idx] - - if isinstance(user_message["content"], list): - # Handle list content input - content_type = "list" - query = "" - for content_item in user_message["content"]: - if content_item["type"] == "text": - query = content_item["text"] - break - elif isinstance(user_message["content"], str): - # Handle text content input - content_type = "text" - query = user_message["content"] - else: - # Fallback in case the input does not match expected types - content_type = None - query = "" + query = get_last_user_message(messages) extracted_collections = [] relevant_contexts = [] @@ -350,33 +317,7 @@ def rag_messages( context_string = context_string.strip() - ra_content = rag_template( - template=template, - context=context_string, - query=query, - ) - - log.debug(f"ra_content: {ra_content}") - - if content_type == "list": - new_content = [] - for content_item in user_message["content"]: - if content_item["type"] == "text": - # Update the text item's content with ra_content - new_content.append({"type": "text", "text": ra_content}) - else: - # Keep other types of content as they are - new_content.append(content_item) - new_user_message = {**user_message, "content": new_content} - else: - new_user_message = { - **user_message, - "content": ra_content, - } - - messages[last_user_message_idx] = new_user_message - - return messages, citations + return context_string, citations def get_model_path(model: str, update_model: bool = False): @@ -418,8 +359,22 @@ def get_model_path(model: str, update_model: bool = False): def generate_openai_embeddings( - model: str, text: str, key: str, url: str = "https://api.openai.com/v1" + model: str, + text: Union[str, list[str]], + key: str, + url: str = "https://api.openai.com/v1", ): + if isinstance(text, list): + embeddings = generate_openai_batch_embeddings(model, text, key, url) + else: + embeddings = generate_openai_batch_embeddings(model, [text], key, url) + + return embeddings[0] if isinstance(text, str) else embeddings + + +def generate_openai_batch_embeddings( + model: str, texts: list[str], key: str, url: str = "https://api.openai.com/v1" +) -> Optional[list[list[float]]]: try: r = requests.post( f"{url}/embeddings", @@ -427,12 +382,12 @@ def generate_openai_embeddings( "Content-Type": "application/json", "Authorization": f"Bearer {key}", }, - json={"input": text, "model": model}, + json={"input": texts, "model": model}, ) r.raise_for_status() data = r.json() if "data" in data: - return data["data"][0]["embedding"] + return [elem["embedding"] for elem in data["data"]] else: raise "Something went wrong :/" except Exception as e: @@ -536,31 +491,3 @@ class RerankCompressor(BaseDocumentCompressor): ) final_results.append(doc) return final_results - - -def search_web(query: str) -> list[SearchResult]: - """Search the web using a search engine and return the results as a list of SearchResult objects. - Will look for a search engine API key in environment variables in the following order: - - SEARXNG_QUERY_URL - - GOOGLE_PSE_API_KEY + GOOGLE_PSE_ENGINE_ID - - BRAVE_SEARCH_API_KEY - - SERPSTACK_API_KEY - - SERPER_API_KEY - - Args: - query (str): The query to search for - """ - - # TODO: add playwright to search the web - if SEARXNG_QUERY_URL: - return search_searxng(SEARXNG_QUERY_URL, query) - elif GOOGLE_PSE_API_KEY and GOOGLE_PSE_ENGINE_ID: - return search_google_pse(GOOGLE_PSE_API_KEY, GOOGLE_PSE_ENGINE_ID, query) - elif BRAVE_SEARCH_API_KEY: - return search_brave(BRAVE_SEARCH_API_KEY, query) - elif SERPSTACK_API_KEY: - return search_serpstack(SERPSTACK_API_KEY, query, https_enabled=SERPSTACK_HTTPS) - elif SERPER_API_KEY: - return search_serper(SERPER_API_KEY, query) - else: - raise Exception("No search engine API key found in environment variables") diff --git a/backend/apps/socket/main.py b/backend/apps/socket/main.py new file mode 100644 index 0000000000..123ff31cd2 --- /dev/null +++ b/backend/apps/socket/main.py @@ -0,0 +1,139 @@ +import socketio +import asyncio + + +from apps.webui.models.users import Users +from utils.utils import decode_token + +sio = socketio.AsyncServer(cors_allowed_origins=[], async_mode="asgi") +app = socketio.ASGIApp(sio, socketio_path="/ws/socket.io") + +# Dictionary to maintain the user pool + +SESSION_POOL = {} +USER_POOL = {} +USAGE_POOL = {} +# Timeout duration in seconds +TIMEOUT_DURATION = 3 + + +@sio.event +async def connect(sid, environ, auth): + user = None + if auth and "token" in auth: + data = decode_token(auth["token"]) + + if data is not None and "id" in data: + user = Users.get_user_by_id(data["id"]) + + if user: + SESSION_POOL[sid] = user.id + if user.id in USER_POOL: + USER_POOL[user.id].append(sid) + else: + USER_POOL[user.id] = [sid] + + print(f"user {user.name}({user.id}) connected with session ID {sid}") + + await sio.emit("user-count", {"count": len(set(USER_POOL))}) + await sio.emit("usage", {"models": get_models_in_use()}) + + +@sio.on("user-join") +async def user_join(sid, data): + print("user-join", sid, data) + + auth = data["auth"] if "auth" in data else None + + if auth and "token" in auth: + data = decode_token(auth["token"]) + + if data is not None and "id" in data: + user = Users.get_user_by_id(data["id"]) + + if user: + + SESSION_POOL[sid] = user.id + if user.id in USER_POOL: + USER_POOL[user.id].append(sid) + else: + USER_POOL[user.id] = [sid] + + print(f"user {user.name}({user.id}) connected with session ID {sid}") + + await sio.emit("user-count", {"count": len(set(USER_POOL))}) + + +@sio.on("user-count") +async def user_count(sid): + await sio.emit("user-count", {"count": len(set(USER_POOL))}) + + +def get_models_in_use(): + # Aggregate all models in use + models_in_use = [] + for model_id, data in USAGE_POOL.items(): + models_in_use.append(model_id) + + return models_in_use + + +@sio.on("usage") +async def usage(sid, data): + + model_id = data["model"] + + # Cancel previous callback if there is one + if model_id in USAGE_POOL: + USAGE_POOL[model_id]["callback"].cancel() + + # Store the new usage data and task + + if model_id in USAGE_POOL: + USAGE_POOL[model_id]["sids"].append(sid) + USAGE_POOL[model_id]["sids"] = list(set(USAGE_POOL[model_id]["sids"])) + + else: + USAGE_POOL[model_id] = {"sids": [sid]} + + # Schedule a task to remove the usage data after TIMEOUT_DURATION + USAGE_POOL[model_id]["callback"] = asyncio.create_task( + remove_after_timeout(sid, model_id) + ) + + # Broadcast the usage data to all clients + await sio.emit("usage", {"models": get_models_in_use()}) + + +async def remove_after_timeout(sid, model_id): + try: + await asyncio.sleep(TIMEOUT_DURATION) + if model_id in USAGE_POOL: + print(USAGE_POOL[model_id]["sids"]) + USAGE_POOL[model_id]["sids"].remove(sid) + USAGE_POOL[model_id]["sids"] = list(set(USAGE_POOL[model_id]["sids"])) + + if len(USAGE_POOL[model_id]["sids"]) == 0: + del USAGE_POOL[model_id] + + # Broadcast the usage data to all clients + await sio.emit("usage", {"models": get_models_in_use()}) + except asyncio.CancelledError: + # Task was cancelled due to new 'usage' event + pass + + +@sio.event +async def disconnect(sid): + if sid in SESSION_POOL: + user_id = SESSION_POOL[sid] + del SESSION_POOL[sid] + + USER_POOL[user_id].remove(sid) + + if len(USER_POOL[user_id]) == 0: + del USER_POOL[user_id] + + await sio.emit("user-count", {"count": len(USER_POOL)}) + else: + print(f"Unknown session ID {sid} disconnected") diff --git a/backend/apps/webui/internal/migrations/012_add_tools.py b/backend/apps/webui/internal/migrations/012_add_tools.py new file mode 100644 index 0000000000..4a68eea552 --- /dev/null +++ b/backend/apps/webui/internal/migrations/012_add_tools.py @@ -0,0 +1,61 @@ +"""Peewee migrations -- 009_add_models.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['table_name'] # Return model in current state by name + > Model = migrator.ModelClass # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.run(func, *args, **kwargs) # Run python function with the given args + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.add_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + > migrator.add_constraint(model, name, sql) + > migrator.drop_index(model, *col_names) + > migrator.drop_not_null(model, *field_names) + > migrator.drop_constraints(model, *constraints) + +""" + +from contextlib import suppress + +import peewee as pw +from peewee_migrate import Migrator + + +with suppress(ImportError): + import playhouse.postgres_ext as pw_pext + + +def migrate(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your migrations here.""" + + @migrator.create_model + class Tool(pw.Model): + id = pw.TextField(unique=True) + user_id = pw.TextField() + + name = pw.TextField() + content = pw.TextField() + specs = pw.TextField() + + meta = pw.TextField() + + created_at = pw.BigIntegerField(null=False) + updated_at = pw.BigIntegerField(null=False) + + class Meta: + table_name = "tool" + + +def rollback(migrator: Migrator, database: pw.Database, *, fake=False): + """Write your rollback migrations here.""" + + migrator.remove_model("tool") diff --git a/backend/apps/webui/main.py b/backend/apps/webui/main.py index b823859a62..62a0a7a7b5 100644 --- a/backend/apps/webui/main.py +++ b/backend/apps/webui/main.py @@ -6,6 +6,7 @@ from apps.webui.routers import ( users, chats, documents, + tools, models, prompts, configs, @@ -14,6 +15,8 @@ from apps.webui.routers import ( ) from config import ( WEBUI_BUILD_HASH, + SHOW_ADMIN_DETAILS, + ADMIN_EMAIL, WEBUI_AUTH, DEFAULT_MODELS, DEFAULT_PROMPT_SUGGESTIONS, @@ -24,8 +27,8 @@ from config import ( WEBUI_AUTH_TRUSTED_EMAIL_HEADER, JWT_EXPIRES_IN, WEBUI_BANNERS, - AppConfig, ENABLE_COMMUNITY_SHARING, + AppConfig, ) app = FastAPI() @@ -36,6 +39,12 @@ app.state.config = AppConfig() app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN +app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER + + +app.state.config.SHOW_ADMIN_DETAILS = SHOW_ADMIN_DETAILS +app.state.config.ADMIN_EMAIL = ADMIN_EMAIL + app.state.config.DEFAULT_MODELS = DEFAULT_MODELS app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS @@ -47,7 +56,7 @@ app.state.config.BANNERS = WEBUI_BANNERS app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING app.state.MODELS = {} -app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER +app.state.TOOLS = {} app.add_middleware( @@ -63,6 +72,7 @@ app.include_router(users.router, prefix="/users", tags=["users"]) app.include_router(chats.router, prefix="/chats", tags=["chats"]) app.include_router(documents.router, prefix="/documents", tags=["documents"]) +app.include_router(tools.router, prefix="/tools", tags=["tools"]) app.include_router(models.router, prefix="/models", tags=["models"]) app.include_router(prompts.router, prefix="/prompts", tags=["prompts"]) app.include_router(memories.router, prefix="/memories", tags=["memories"]) diff --git a/backend/apps/webui/models/chats.py b/backend/apps/webui/models/chats.py index d4597f16db..a6f1ae9233 100644 --- a/backend/apps/webui/models/chats.py +++ b/backend/apps/webui/models/chats.py @@ -298,6 +298,15 @@ class ChatTable: # .limit(limit).offset(skip) ] + def get_archived_chats_by_user_id(self, user_id: str) -> List[ChatModel]: + return [ + ChatModel(**model_to_dict(chat)) + for chat in Chat.select() + .where(Chat.archived == True) + .where(Chat.user_id == user_id) + .order_by(Chat.updated_at.desc()) + ] + def delete_chat_by_id(self, id: str) -> bool: try: query = Chat.delete().where((Chat.id == id)) diff --git a/backend/apps/webui/models/tools.py b/backend/apps/webui/models/tools.py new file mode 100644 index 0000000000..e2db1e35f6 --- /dev/null +++ b/backend/apps/webui/models/tools.py @@ -0,0 +1,132 @@ +from pydantic import BaseModel +from peewee import * +from playhouse.shortcuts import model_to_dict +from typing import List, Union, Optional +import time +import logging +from apps.webui.internal.db import DB, JSONField + +import json + +from config import SRC_LOG_LEVELS + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +#################### +# Tools DB Schema +#################### + + +class Tool(Model): + id = CharField(unique=True) + user_id = CharField() + name = TextField() + content = TextField() + specs = JSONField() + meta = JSONField() + updated_at = BigIntegerField() + created_at = BigIntegerField() + + class Meta: + database = DB + + +class ToolMeta(BaseModel): + description: Optional[str] = None + + +class ToolModel(BaseModel): + id: str + user_id: str + name: str + content: str + specs: List[dict] + meta: ToolMeta + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + +#################### +# Forms +#################### + + +class ToolResponse(BaseModel): + id: str + user_id: str + name: str + meta: ToolMeta + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + +class ToolForm(BaseModel): + id: str + name: str + content: str + meta: ToolMeta + + +class ToolsTable: + def __init__(self, db): + self.db = db + self.db.create_tables([Tool]) + + def insert_new_tool( + self, user_id: str, form_data: ToolForm, specs: List[dict] + ) -> Optional[ToolModel]: + tool = ToolModel( + **{ + **form_data.model_dump(), + "specs": specs, + "user_id": user_id, + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + + try: + result = Tool.create(**tool.model_dump()) + if result: + return tool + else: + return None + except Exception as e: + print(f"Error creating tool: {e}") + return None + + def get_tool_by_id(self, id: str) -> Optional[ToolModel]: + try: + tool = Tool.get(Tool.id == id) + return ToolModel(**model_to_dict(tool)) + except: + return None + + def get_tools(self) -> List[ToolModel]: + return [ToolModel(**model_to_dict(tool)) for tool in Tool.select()] + + def update_tool_by_id(self, id: str, updated: dict) -> Optional[ToolModel]: + try: + query = Tool.update( + **updated, + updated_at=int(time.time()), + ).where(Tool.id == id) + query.execute() + + tool = Tool.get(Tool.id == id) + return ToolModel(**model_to_dict(tool)) + except: + return None + + def delete_tool_by_id(self, id: str) -> bool: + try: + query = Tool.delete().where((Tool.id == id)) + query.execute() # Remove the rows, return number of rows removed. + + return True + except: + return False + + +Tools = ToolsTable(DB) diff --git a/backend/apps/webui/routers/auths.py b/backend/apps/webui/routers/auths.py index ce9b92061a..d45879a243 100644 --- a/backend/apps/webui/routers/auths.py +++ b/backend/apps/webui/routers/auths.py @@ -269,73 +269,88 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)): raise HTTPException(500, detail=ERROR_MESSAGES.DEFAULT(err)) +############################ +# GetAdminDetails +############################ + + +@router.get("/admin/details") +async def get_admin_details(request: Request, user=Depends(get_current_user)): + if request.app.state.config.SHOW_ADMIN_DETAILS: + admin_email = request.app.state.config.ADMIN_EMAIL + admin_name = None + + print(admin_email, admin_name) + + if admin_email: + admin = Users.get_user_by_email(admin_email) + if admin: + admin_name = admin.name + else: + admin = Users.get_first_user() + if admin: + admin_email = admin.email + admin_name = admin.name + + return { + "name": admin_name, + "email": admin_email, + } + else: + raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED) + + ############################ # ToggleSignUp ############################ -@router.get("/signup/enabled", response_model=bool) -async def get_sign_up_status(request: Request, user=Depends(get_admin_user)): - return request.app.state.config.ENABLE_SIGNUP +@router.get("/admin/config") +async def get_admin_config(request: Request, user=Depends(get_admin_user)): + return { + "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, + "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, + "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, + "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, + "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, + } -@router.get("/signup/enabled/toggle", response_model=bool) -async def toggle_sign_up(request: Request, user=Depends(get_admin_user)): - request.app.state.config.ENABLE_SIGNUP = not request.app.state.config.ENABLE_SIGNUP - return request.app.state.config.ENABLE_SIGNUP +class AdminConfig(BaseModel): + SHOW_ADMIN_DETAILS: bool + ENABLE_SIGNUP: bool + DEFAULT_USER_ROLE: str + JWT_EXPIRES_IN: str + ENABLE_COMMUNITY_SHARING: bool -############################ -# Default User Role -############################ - - -@router.get("/signup/user/role") -async def get_default_user_role(request: Request, user=Depends(get_admin_user)): - return request.app.state.config.DEFAULT_USER_ROLE - - -class UpdateRoleForm(BaseModel): - role: str - - -@router.post("/signup/user/role") -async def update_default_user_role( - request: Request, form_data: UpdateRoleForm, user=Depends(get_admin_user) +@router.post("/admin/config") +async def update_admin_config( + request: Request, form_data: AdminConfig, user=Depends(get_admin_user) ): - if form_data.role in ["pending", "user", "admin"]: - request.app.state.config.DEFAULT_USER_ROLE = form_data.role - return request.app.state.config.DEFAULT_USER_ROLE + request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS + request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP + if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]: + request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE -############################ -# JWT Expiration -############################ - - -@router.get("/token/expires") -async def get_token_expires_duration(request: Request, user=Depends(get_admin_user)): - return request.app.state.config.JWT_EXPIRES_IN - - -class UpdateJWTExpiresDurationForm(BaseModel): - duration: str - - -@router.post("/token/expires/update") -async def update_token_expires_duration( - request: Request, - form_data: UpdateJWTExpiresDurationForm, - user=Depends(get_admin_user), -): pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$" # Check if the input string matches the pattern - if re.match(pattern, form_data.duration): - request.app.state.config.JWT_EXPIRES_IN = form_data.duration - return request.app.state.config.JWT_EXPIRES_IN - else: - return request.app.state.config.JWT_EXPIRES_IN + if re.match(pattern, form_data.JWT_EXPIRES_IN): + request.app.state.config.JWT_EXPIRES_IN = form_data.JWT_EXPIRES_IN + + request.app.state.config.ENABLE_COMMUNITY_SHARING = ( + form_data.ENABLE_COMMUNITY_SHARING + ) + + return { + "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS, + "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP, + "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE, + "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN, + "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING, + } ############################ diff --git a/backend/apps/webui/routers/chats.py b/backend/apps/webui/routers/chats.py index 5d52f40c96..9d1cceaa1f 100644 --- a/backend/apps/webui/routers/chats.py +++ b/backend/apps/webui/routers/chats.py @@ -113,6 +113,19 @@ async def get_user_chats(user=Depends(get_current_user)): ] +############################ +# GetArchivedChats +############################ + + +@router.get("/all/archived", response_model=List[ChatResponse]) +async def get_user_chats(user=Depends(get_current_user)): + return [ + ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)}) + for chat in Chats.get_archived_chats_by_user_id(user.id) + ] + + ############################ # GetAllChatsInDB ############################ @@ -148,7 +161,7 @@ async def get_archived_session_user_chat_list( ############################ -@router.post("/archive/all", response_model=List[ChatTitleIdResponse]) +@router.post("/archive/all", response_model=bool) async def archive_all_chats(user=Depends(get_current_user)): return Chats.archive_all_chats_by_user_id(user.id) @@ -288,6 +301,32 @@ async def delete_chat_by_id(request: Request, id: str, user=Depends(get_current_ return result +############################ +# CloneChat +############################ + + +@router.get("/{id}/clone", response_model=Optional[ChatResponse]) +async def clone_chat_by_id(id: str, user=Depends(get_current_user)): + chat = Chats.get_chat_by_id_and_user_id(id, user.id) + if chat: + + chat_body = json.loads(chat.chat) + updated_chat = { + **chat_body, + "originalChatId": chat.id, + "branchPointMessageId": chat_body["history"]["currentId"], + "title": f"Clone of {chat.title}", + } + + chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat})) + return ChatResponse(**{**chat.model_dump(), "chat": json.loads(chat.chat)}) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT() + ) + + ############################ # ArchiveChat ############################ diff --git a/backend/apps/webui/routers/documents.py b/backend/apps/webui/routers/documents.py index c5447a3fe6..311455390a 100644 --- a/backend/apps/webui/routers/documents.py +++ b/backend/apps/webui/routers/documents.py @@ -73,7 +73,7 @@ async def create_new_doc(form_data: DocumentForm, user=Depends(get_admin_user)): ############################ -@router.get("/name/{name}", response_model=Optional[DocumentResponse]) +@router.get("/doc", response_model=Optional[DocumentResponse]) async def get_doc_by_name(name: str, user=Depends(get_current_user)): doc = Documents.get_doc_by_name(name) @@ -105,7 +105,7 @@ class TagDocumentForm(BaseModel): tags: List[dict] -@router.post("/name/{name}/tags", response_model=Optional[DocumentResponse]) +@router.post("/doc/tags", response_model=Optional[DocumentResponse]) async def tag_doc_by_name(form_data: TagDocumentForm, user=Depends(get_current_user)): doc = Documents.update_doc_content_by_name(form_data.name, {"tags": form_data.tags}) @@ -128,7 +128,7 @@ async def tag_doc_by_name(form_data: TagDocumentForm, user=Depends(get_current_u ############################ -@router.post("/name/{name}/update", response_model=Optional[DocumentResponse]) +@router.post("/doc/update", response_model=Optional[DocumentResponse]) async def update_doc_by_name( name: str, form_data: DocumentUpdateForm, user=Depends(get_admin_user) ): @@ -152,7 +152,7 @@ async def update_doc_by_name( ############################ -@router.delete("/name/{name}/delete", response_model=bool) +@router.delete("/doc/delete", response_model=bool) async def delete_doc_by_name(name: str, user=Depends(get_admin_user)): result = Documents.delete_doc_by_name(name) return result diff --git a/backend/apps/webui/routers/tools.py b/backend/apps/webui/routers/tools.py new file mode 100644 index 0000000000..b68ed32ee0 --- /dev/null +++ b/backend/apps/webui/routers/tools.py @@ -0,0 +1,183 @@ +from fastapi import Depends, FastAPI, HTTPException, status, Request +from datetime import datetime, timedelta +from typing import List, Union, Optional + +from fastapi import APIRouter +from pydantic import BaseModel +import json + +from apps.webui.models.tools import Tools, ToolForm, ToolModel, ToolResponse +from apps.webui.utils import load_toolkit_module_by_id + +from utils.utils import get_current_user, get_admin_user +from utils.tools import get_tools_specs +from constants import ERROR_MESSAGES + +from importlib import util +import os + +from config import DATA_DIR + + +TOOLS_DIR = f"{DATA_DIR}/tools" +os.makedirs(TOOLS_DIR, exist_ok=True) + + +router = APIRouter() + +############################ +# GetToolkits +############################ + + +@router.get("/", response_model=List[ToolResponse]) +async def get_toolkits(user=Depends(get_current_user)): + toolkits = [toolkit for toolkit in Tools.get_tools()] + return toolkits + + +############################ +# ExportToolKits +############################ + + +@router.get("/export", response_model=List[ToolModel]) +async def get_toolkits(user=Depends(get_admin_user)): + toolkits = [toolkit for toolkit in Tools.get_tools()] + return toolkits + + +############################ +# CreateNewToolKit +############################ + + +@router.post("/create", response_model=Optional[ToolResponse]) +async def create_new_toolkit( + request: Request, form_data: ToolForm, user=Depends(get_admin_user) +): + if not form_data.id.isidentifier(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only alphanumeric characters and underscores are allowed in the id", + ) + + form_data.id = form_data.id.lower() + + toolkit = Tools.get_tool_by_id(form_data.id) + if toolkit == None: + toolkit_path = os.path.join(TOOLS_DIR, f"{form_data.id}.py") + try: + with open(toolkit_path, "w") as tool_file: + tool_file.write(form_data.content) + + toolkit_module = load_toolkit_module_by_id(form_data.id) + + TOOLS = request.app.state.TOOLS + TOOLS[form_data.id] = toolkit_module + + specs = get_tools_specs(TOOLS[form_data.id]) + toolkit = Tools.insert_new_tool(user.id, form_data, specs) + + if toolkit: + return toolkit + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error creating toolkit"), + ) + except Exception as e: + print(e) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.ID_TAKEN, + ) + + +############################ +# GetToolkitById +############################ + + +@router.get("/id/{id}", response_model=Optional[ToolModel]) +async def get_toolkit_by_id(id: str, user=Depends(get_admin_user)): + toolkit = Tools.get_tool_by_id(id) + + if toolkit: + return toolkit + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# UpdateToolkitById +############################ + + +@router.post("/id/{id}/update", response_model=Optional[ToolModel]) +async def update_toolkit_by_id( + request: Request, id: str, form_data: ToolForm, user=Depends(get_admin_user) +): + toolkit_path = os.path.join(TOOLS_DIR, f"{id}.py") + + try: + with open(toolkit_path, "w") as tool_file: + tool_file.write(form_data.content) + + toolkit_module = load_toolkit_module_by_id(id) + + TOOLS = request.app.state.TOOLS + TOOLS[id] = toolkit_module + + specs = get_tools_specs(TOOLS[id]) + + updated = { + **form_data.model_dump(exclude={"id"}), + "specs": specs, + } + + print(updated) + toolkit = Tools.update_tool_by_id(id, updated) + + if toolkit: + return toolkit + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error updating toolkit"), + ) + + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(e), + ) + + +############################ +# DeleteToolkitById +############################ + + +@router.delete("/id/{id}/delete", response_model=bool) +async def delete_toolkit_by_id(request: Request, id: str, user=Depends(get_admin_user)): + result = Tools.delete_tool_by_id(id) + + if result: + TOOLS = request.app.state.TOOLS + if id in TOOLS: + del TOOLS[id] + + # delete the toolkit file + toolkit_path = os.path.join(TOOLS_DIR, f"{id}.py") + os.remove(toolkit_path) + + return result diff --git a/backend/apps/webui/routers/users.py b/backend/apps/webui/routers/users.py index cd17e3a7c0..eccafde103 100644 --- a/backend/apps/webui/routers/users.py +++ b/backend/apps/webui/routers/users.py @@ -19,7 +19,12 @@ from apps.webui.models.users import ( from apps.webui.models.auths import Auths from apps.webui.models.chats import Chats -from utils.utils import get_verified_user, get_password_hash, get_admin_user +from utils.utils import ( + get_verified_user, + get_password_hash, + get_current_user, + get_admin_user, +) from constants import ERROR_MESSAGES from config import SRC_LOG_LEVELS diff --git a/backend/apps/webui/routers/utils.py b/backend/apps/webui/routers/utils.py index b95fe88347..8f6d663b47 100644 --- a/backend/apps/webui/routers/utils.py +++ b/backend/apps/webui/routers/utils.py @@ -7,6 +7,8 @@ from pydantic import BaseModel from fpdf import FPDF import markdown +import black + from apps.webui.internal.db import DB from utils.utils import get_admin_user @@ -26,6 +28,21 @@ async def get_gravatar( return get_gravatar_url(email) +class CodeFormatRequest(BaseModel): + code: str + + +@router.post("/code/format") +async def format_code(request: CodeFormatRequest): + try: + formatted_code = black.format_str(request.code, mode=black.Mode()) + return {"code": formatted_code} + except black.NothingChanged: + return {"code": request.code} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + class MarkdownForm(BaseModel): md: str @@ -107,3 +124,12 @@ async def download_db(user=Depends(get_admin_user)): media_type="application/octet-stream", filename="webui.db", ) + + +@router.get("/litellm/config") +async def download_litellm_config_yaml(user=Depends(get_admin_user)): + return FileResponse( + f"{DATA_DIR}/litellm/config.yaml", + media_type="application/octet-stream", + filename="config.yaml", + ) diff --git a/backend/apps/webui/utils.py b/backend/apps/webui/utils.py new file mode 100644 index 0000000000..19a8615bc3 --- /dev/null +++ b/backend/apps/webui/utils.py @@ -0,0 +1,23 @@ +from importlib import util +import os + +from config import TOOLS_DIR + + +def load_toolkit_module_by_id(toolkit_id): + toolkit_path = os.path.join(TOOLS_DIR, f"{toolkit_id}.py") + spec = util.spec_from_file_location(toolkit_id, toolkit_path) + module = util.module_from_spec(spec) + + try: + spec.loader.exec_module(module) + print(f"Loaded module: {module.__name__}") + if hasattr(module, "Tools"): + return module.Tools() + else: + raise Exception("No Tools class found") + except Exception as e: + print(f"Error loading module: {toolkit_id}") + # Move the file to the error folder + os.rename(toolkit_path, f"{toolkit_path}.error") + raise e diff --git a/backend/config.py b/backend/config.py index dfdeee7322..30a23f29ee 100644 --- a/backend/config.py +++ b/backend/config.py @@ -180,6 +180,17 @@ WEBUI_BUILD_HASH = os.environ.get("WEBUI_BUILD_HASH", "dev-build") DATA_DIR = Path(os.getenv("DATA_DIR", BACKEND_DIR / "data")).resolve() FRONTEND_BUILD_DIR = Path(os.getenv("FRONTEND_BUILD_DIR", BASE_DIR / "build")).resolve() +RESET_CONFIG_ON_START = ( + os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true" +) +if RESET_CONFIG_ON_START: + try: + os.remove(f"{DATA_DIR}/config.json") + with open(f"{DATA_DIR}/config.json", "w") as f: + f.write("{}") + except: + pass + try: CONFIG_DATA = json.loads((DATA_DIR / "config.json").read_text()) except: @@ -295,7 +306,11 @@ STATIC_DIR = Path(os.getenv("STATIC_DIR", BACKEND_DIR / "static")).resolve() frontend_favicon = FRONTEND_BUILD_DIR / "favicon.png" if frontend_favicon.exists(): - shutil.copyfile(frontend_favicon, STATIC_DIR / "favicon.png") + try: + shutil.copyfile(frontend_favicon, STATIC_DIR / "favicon.png") + except Exception as e: + logging.error(f"An error occurred: {e}") + else: logging.warning(f"Frontend favicon not found at {frontend_favicon}") @@ -353,6 +368,14 @@ DOCS_DIR = os.getenv("DOCS_DIR", f"{DATA_DIR}/docs") Path(DOCS_DIR).mkdir(parents=True, exist_ok=True) +#################################### +# Tools DIR +#################################### + +TOOLS_DIR = os.getenv("TOOLS_DIR", f"{DATA_DIR}/tools") +Path(TOOLS_DIR).mkdir(parents=True, exist_ok=True) + + #################################### # LITELLM_CONFIG #################################### @@ -590,6 +613,92 @@ WEBUI_BANNERS = PersistentConfig( [BannerModel(**banner) for banner in json.loads("[]")], ) + +SHOW_ADMIN_DETAILS = PersistentConfig( + "SHOW_ADMIN_DETAILS", + "auth.admin.show", + os.environ.get("SHOW_ADMIN_DETAILS", "true").lower() == "true", +) + +ADMIN_EMAIL = PersistentConfig( + "ADMIN_EMAIL", + "auth.admin.email", + os.environ.get("ADMIN_EMAIL", None), +) + + +#################################### +# TASKS +#################################### + + +TASK_MODEL = PersistentConfig( + "TASK_MODEL", + "task.model.default", + os.environ.get("TASK_MODEL", ""), +) + +TASK_MODEL_EXTERNAL = PersistentConfig( + "TASK_MODEL_EXTERNAL", + "task.model.external", + os.environ.get("TASK_MODEL_EXTERNAL", ""), +) + +TITLE_GENERATION_PROMPT_TEMPLATE = PersistentConfig( + "TITLE_GENERATION_PROMPT_TEMPLATE", + "task.title.prompt_template", + os.environ.get( + "TITLE_GENERATION_PROMPT_TEMPLATE", + """Here is the query: +{{prompt:middletruncate:8000}} + +Create a concise, 3-5 word phrase with an emoji as a title for the previous query. Suitable Emojis for the summary can be used to enhance understanding but avoid quotation marks or special formatting. RESPOND ONLY WITH THE TITLE TEXT. + +Examples of titles: +📉 Stock Market Trends +🍪 Perfect Chocolate Chip Recipe +Evolution of Music Streaming +Remote Work Productivity Tips +Artificial Intelligence in Healthcare +🎮 Video Game Development Insights""", + ), +) + + +SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE = PersistentConfig( + "SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE", + "task.search.prompt_template", + os.environ.get( + "SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE", + """You are tasked with generating web search queries. Give me an appropriate query to answer my question for google search. Answer with only the query. Today is {{CURRENT_DATE}}. + +Question: +{{prompt:end:4000}}""", + ), +) + +SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD = PersistentConfig( + "SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD", + "task.search.prompt_length_threshold", + int( + os.environ.get( + "SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD", + 100, + ) + ), +) + +TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = PersistentConfig( + "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE", + "task.tools.prompt_template", + os.environ.get( + "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE", + """Tools: {{TOOLS}} +If a function tool doesn't match the query, return an empty string. Else, pick a function tool, fill in the parameters from the function tool's schema, and return it in the format { "name": \"functionName\", "parameters": { "key": "value" } }. Only pick a function if the user asks. Only return the object. Do not return any other text.""", + ), +) + + #################################### # WEBUI_SECRET_KEY #################################### @@ -672,6 +781,12 @@ RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE = ( os.environ.get("RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE", "").lower() == "true" ) +RAG_EMBEDDING_OPENAI_BATCH_SIZE = PersistentConfig( + "RAG_EMBEDDING_OPENAI_BATCH_SIZE", + "rag.embedding_openai_batch_size", + os.environ.get("RAG_EMBEDDING_OPENAI_BATCH_SIZE", 1), +) + RAG_RERANKING_MODEL = PersistentConfig( "RAG_RERANKING_MODEL", "rag.reranking_model", @@ -766,28 +881,81 @@ YOUTUBE_LOADER_LANGUAGE = PersistentConfig( os.getenv("YOUTUBE_LOADER_LANGUAGE", "en").split(","), ) -SEARXNG_QUERY_URL = os.getenv("SEARXNG_QUERY_URL", "") -GOOGLE_PSE_API_KEY = os.getenv("GOOGLE_PSE_API_KEY", "") -GOOGLE_PSE_ENGINE_ID = os.getenv("GOOGLE_PSE_ENGINE_ID", "") -BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "") -SERPSTACK_API_KEY = os.getenv("SERPSTACK_API_KEY", "") -SERPSTACK_HTTPS = os.getenv("SERPSTACK_HTTPS", "True").lower() == "true" -SERPER_API_KEY = os.getenv("SERPER_API_KEY", "") - -RAG_WEB_SEARCH_ENABLED = ( - SEARXNG_QUERY_URL != "" - or (GOOGLE_PSE_API_KEY != "" and GOOGLE_PSE_ENGINE_ID != "") - or BRAVE_SEARCH_API_KEY != "" - or SERPSTACK_API_KEY != "" - or SERPER_API_KEY != "" +ENABLE_RAG_WEB_SEARCH = PersistentConfig( + "ENABLE_RAG_WEB_SEARCH", + "rag.web.search.enable", + os.getenv("ENABLE_RAG_WEB_SEARCH", "False").lower() == "true", ) -RAG_WEB_SEARCH_RESULT_COUNT = int(os.getenv("RAG_WEB_SEARCH_RESULT_COUNT", "3")) -RAG_WEB_SEARCH_CONCURRENT_REQUESTS = int( - os.getenv("RAG_WEB_SEARCH_CONCURRENT_REQUESTS", "10") +RAG_WEB_SEARCH_ENGINE = PersistentConfig( + "RAG_WEB_SEARCH_ENGINE", + "rag.web.search.engine", + os.getenv("RAG_WEB_SEARCH_ENGINE", ""), ) +SEARXNG_QUERY_URL = PersistentConfig( + "SEARXNG_QUERY_URL", + "rag.web.search.searxng_query_url", + os.getenv("SEARXNG_QUERY_URL", ""), +) + +GOOGLE_PSE_API_KEY = PersistentConfig( + "GOOGLE_PSE_API_KEY", + "rag.web.search.google_pse_api_key", + os.getenv("GOOGLE_PSE_API_KEY", ""), +) + +GOOGLE_PSE_ENGINE_ID = PersistentConfig( + "GOOGLE_PSE_ENGINE_ID", + "rag.web.search.google_pse_engine_id", + os.getenv("GOOGLE_PSE_ENGINE_ID", ""), +) + +BRAVE_SEARCH_API_KEY = PersistentConfig( + "BRAVE_SEARCH_API_KEY", + "rag.web.search.brave_search_api_key", + os.getenv("BRAVE_SEARCH_API_KEY", ""), +) + +SERPSTACK_API_KEY = PersistentConfig( + "SERPSTACK_API_KEY", + "rag.web.search.serpstack_api_key", + os.getenv("SERPSTACK_API_KEY", ""), +) + +SERPSTACK_HTTPS = PersistentConfig( + "SERPSTACK_HTTPS", + "rag.web.search.serpstack_https", + os.getenv("SERPSTACK_HTTPS", "True").lower() == "true", +) + +SERPER_API_KEY = PersistentConfig( + "SERPER_API_KEY", + "rag.web.search.serper_api_key", + os.getenv("SERPER_API_KEY", ""), +) + +SERPLY_API_KEY = PersistentConfig( + "SERPLY_API_KEY", + "rag.web.search.serply_api_key", + os.getenv("SERPLY_API_KEY", ""), +) + + +RAG_WEB_SEARCH_RESULT_COUNT = PersistentConfig( + "RAG_WEB_SEARCH_RESULT_COUNT", + "rag.web.search.result_count", + int(os.getenv("RAG_WEB_SEARCH_RESULT_COUNT", "3")), +) + +RAG_WEB_SEARCH_CONCURRENT_REQUESTS = PersistentConfig( + "RAG_WEB_SEARCH_CONCURRENT_REQUESTS", + "rag.web.search.concurrent_requests", + int(os.getenv("RAG_WEB_SEARCH_CONCURRENT_REQUESTS", "10")), +) + + #################################### # Transcribe #################################### @@ -855,25 +1023,59 @@ IMAGE_GENERATION_MODEL = PersistentConfig( # Audio #################################### -AUDIO_OPENAI_API_BASE_URL = PersistentConfig( - "AUDIO_OPENAI_API_BASE_URL", - "audio.openai.api_base_url", - os.getenv("AUDIO_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), +AUDIO_STT_OPENAI_API_BASE_URL = PersistentConfig( + "AUDIO_STT_OPENAI_API_BASE_URL", + "audio.stt.openai.api_base_url", + os.getenv("AUDIO_STT_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), ) -AUDIO_OPENAI_API_KEY = PersistentConfig( - "AUDIO_OPENAI_API_KEY", - "audio.openai.api_key", - os.getenv("AUDIO_OPENAI_API_KEY", OPENAI_API_KEY), + +AUDIO_STT_OPENAI_API_KEY = PersistentConfig( + "AUDIO_STT_OPENAI_API_KEY", + "audio.stt.openai.api_key", + os.getenv("AUDIO_STT_OPENAI_API_KEY", OPENAI_API_KEY), ) -AUDIO_OPENAI_API_MODEL = PersistentConfig( - "AUDIO_OPENAI_API_MODEL", - "audio.openai.api_model", - os.getenv("AUDIO_OPENAI_API_MODEL", "tts-1"), + +AUDIO_STT_ENGINE = PersistentConfig( + "AUDIO_STT_ENGINE", + "audio.stt.engine", + os.getenv("AUDIO_STT_ENGINE", ""), ) -AUDIO_OPENAI_API_VOICE = PersistentConfig( - "AUDIO_OPENAI_API_VOICE", - "audio.openai.api_voice", - os.getenv("AUDIO_OPENAI_API_VOICE", "alloy"), + +AUDIO_STT_MODEL = PersistentConfig( + "AUDIO_STT_MODEL", + "audio.stt.model", + os.getenv("AUDIO_STT_MODEL", "whisper-1"), +) + +AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig( + "AUDIO_TTS_OPENAI_API_BASE_URL", + "audio.tts.openai.api_base_url", + os.getenv("AUDIO_TTS_OPENAI_API_BASE_URL", OPENAI_API_BASE_URL), +) +AUDIO_TTS_OPENAI_API_KEY = PersistentConfig( + "AUDIO_TTS_OPENAI_API_KEY", + "audio.tts.openai.api_key", + os.getenv("AUDIO_TTS_OPENAI_API_KEY", OPENAI_API_KEY), +) + + +AUDIO_TTS_ENGINE = PersistentConfig( + "AUDIO_TTS_ENGINE", + "audio.tts.engine", + os.getenv("AUDIO_TTS_ENGINE", ""), +) + + +AUDIO_TTS_MODEL = PersistentConfig( + "AUDIO_TTS_MODEL", + "audio.tts.model", + os.getenv("AUDIO_TTS_MODEL", "tts-1"), +) + +AUDIO_TTS_VOICE = PersistentConfig( + "AUDIO_TTS_VOICE", + "audio.tts.voice", + os.getenv("AUDIO_TTS_VOICE", "alloy"), ) diff --git a/backend/constants.py b/backend/constants.py index abf34b75e2..f1eed43d37 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -32,6 +32,7 @@ class ERROR_MESSAGES(str, Enum): COMMAND_TAKEN = "Uh-oh! This command is already registered. Please choose another command string." FILE_EXISTS = "Uh-oh! This file is already registered. Please choose another file." + ID_TAKEN = "Uh-oh! This id is already registered. Please choose another id string." MODEL_ID_TAKEN = "Uh-oh! This model id is already registered. Please choose another model id string." NAME_TAG_TAKEN = "Uh-oh! This name tag is already registered. Please choose another name tag string." @@ -82,5 +83,9 @@ class ERROR_MESSAGES(str, Enum): ) WEB_SEARCH_ERROR = ( - "Oops! Something went wrong while searching the web. Please try again later." + lambda err="": f"{err if err else 'Oops! Something went wrong while searching the web.'}" + ) + + OLLAMA_API_DISABLED = ( + "The Ollama API is disabled. Please enable it to use this feature." ) diff --git a/backend/main.py b/backend/main.py index 3ffb5bdd7a..de8827d12d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,8 +9,12 @@ import logging import aiohttp import requests import mimetypes +import shutil +import os +import inspect +import asyncio -from fastapi import FastAPI, Request, Depends, status +from fastapi import FastAPI, Request, Depends, status, UploadFile, File, Form from fastapi.staticfiles import StaticFiles from fastapi.responses import JSONResponse from fastapi import HTTPException @@ -20,26 +24,48 @@ from starlette.exceptions import HTTPException as StarletteHTTPException from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import StreamingResponse, Response -from apps.ollama.main import app as ollama_app, get_all_models as get_ollama_models -from apps.openai.main import app as openai_app, get_all_models as get_openai_models + +from apps.socket.main import app as socket_app +from apps.ollama.main import ( + app as ollama_app, + OpenAIChatCompletionForm, + get_all_models as get_ollama_models, + generate_openai_chat_completion as generate_ollama_chat_completion, +) +from apps.openai.main import ( + app as openai_app, + get_all_models as get_openai_models, + generate_chat_completion as generate_openai_chat_completion, +) from apps.audio.main import app as audio_app from apps.images.main import app as images_app from apps.rag.main import app as rag_app from apps.webui.main import app as webui_app -import asyncio + from pydantic import BaseModel from typing import List, Optional from apps.webui.models.models import Models, ModelModel +from apps.webui.models.tools import Tools +from apps.webui.utils import load_toolkit_module_by_id + + from utils.utils import ( get_admin_user, get_verified_user, get_current_user, get_http_authorization_cred, ) -from apps.rag.utils import rag_messages +from utils.task import ( + title_generation_template, + search_query_generation_template, + tools_function_calling_generation_template, +) +from utils.misc import get_last_user_message, add_or_update_system_message + +from apps.rag.utils import get_rag_context, rag_template from config import ( CONFIG_DATA, @@ -60,9 +86,14 @@ from config import ( SRC_LOG_LEVELS, WEBHOOK_URL, ENABLE_ADMIN_EXPORT, - RAG_WEB_SEARCH_ENABLED, - AppConfig, WEBUI_BUILD_HASH, + TASK_MODEL, + TASK_MODEL_EXTERNAL, + TITLE_GENERATION_PROMPT_TEMPLATE, + SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE, + SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD, + TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + AppConfig, ) from constants import ERROR_MESSAGES @@ -116,27 +147,133 @@ app.state.config.ENABLE_OLLAMA_API = ENABLE_OLLAMA_API app.state.config.ENABLE_MODEL_FILTER = ENABLE_MODEL_FILTER app.state.config.MODEL_FILTER_LIST = MODEL_FILTER_LIST - app.state.config.WEBHOOK_URL = WEBHOOK_URL +app.state.config.TASK_MODEL = TASK_MODEL +app.state.config.TASK_MODEL_EXTERNAL = TASK_MODEL_EXTERNAL +app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE +app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE = ( + SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE +) +app.state.config.SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD = ( + SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD +) +app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = ( + TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE +) + app.state.MODELS = {} origins = ["*"] -# Custom middleware to add security headers -# class SecurityHeadersMiddleware(BaseHTTPMiddleware): -# async def dispatch(self, request: Request, call_next): -# response: Response = await call_next(request) -# response.headers["Cross-Origin-Opener-Policy"] = "same-origin" -# response.headers["Cross-Origin-Embedder-Policy"] = "require-corp" -# return response + +async def get_function_call_response(messages, tool_id, template, task_model_id, user): + tool = Tools.get_tool_by_id(tool_id) + tools_specs = json.dumps(tool.specs, indent=2) + content = tools_function_calling_generation_template(template, tools_specs) + + user_message = get_last_user_message(messages) + prompt = ( + "History:\n" + + "\n".join( + [ + f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\"" + for message in messages[::-1][:4] + ] + ) + + f"\nQuery: {user_message}" + ) + + print(prompt) + + payload = { + "model": task_model_id, + "messages": [ + {"role": "system", "content": content}, + {"role": "user", "content": f"Query: {prompt}"}, + ], + "stream": False, + } + + try: + payload = filter_pipeline(payload, user) + except Exception as e: + raise e + + model = app.state.MODELS[task_model_id] + + response = None + try: + if model["owned_by"] == "ollama": + response = await generate_ollama_chat_completion( + OpenAIChatCompletionForm(**payload), user=user + ) + else: + response = await generate_openai_chat_completion(payload, user=user) + + content = None + + if hasattr(response, "body_iterator"): + async for chunk in response.body_iterator: + data = json.loads(chunk.decode("utf-8")) + content = data["choices"][0]["message"]["content"] + + # Cleanup any remaining background tasks if necessary + if response.background is not None: + await response.background() + else: + content = response["choices"][0]["message"]["content"] + + # Parse the function response + if content is not None: + print(f"content: {content}") + result = json.loads(content) + print(result) + + # Call the function + if "name" in result: + if tool_id in webui_app.state.TOOLS: + toolkit_module = webui_app.state.TOOLS[tool_id] + else: + toolkit_module = load_toolkit_module_by_id(tool_id) + webui_app.state.TOOLS[tool_id] = toolkit_module + + function = getattr(toolkit_module, result["name"]) + function_result = None + try: + # Get the signature of the function + sig = inspect.signature(function) + # Check if '__user__' is a parameter of the function + if "__user__" in sig.parameters: + # Call the function with the '__user__' parameter included + function_result = function( + **{ + **result["parameters"], + "__user__": { + "id": user.id, + "email": user.email, + "name": user.name, + "role": user.role, + }, + } + ) + else: + # Call the function without modifying the parameters + function_result = function(**result["parameters"]) + except Exception as e: + print(e) + + # Add the function result to the system prompt + if function_result: + return function_result + except Exception as e: + print(f"Error: {e}") + + return None -# app.add_middleware(SecurityHeadersMiddleware) - - -class RAGMiddleware(BaseHTTPMiddleware): +class ChatCompletionMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): return_citations = False @@ -153,35 +290,98 @@ class RAGMiddleware(BaseHTTPMiddleware): # Parse string to JSON data = json.loads(body_str) if body_str else {} + user = get_current_user( + get_http_authorization_cred(request.headers.get("Authorization")) + ) + + # Remove the citations from the body return_citations = data.get("citations", False) if "citations" in data: del data["citations"] - # Example: Add a new key-value pair or modify existing ones - # data["modified"] = True # Example modification + # Set the task model + task_model_id = data["model"] + if task_model_id not in app.state.MODELS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model not found", + ) + + # Check if the user has a custom task model + # If the user has a custom task model, use that model + if app.state.MODELS[task_model_id]["owned_by"] == "ollama": + if ( + app.state.config.TASK_MODEL + and app.state.config.TASK_MODEL in app.state.MODELS + ): + task_model_id = app.state.config.TASK_MODEL + else: + if ( + app.state.config.TASK_MODEL_EXTERNAL + and app.state.config.TASK_MODEL_EXTERNAL in app.state.MODELS + ): + task_model_id = app.state.config.TASK_MODEL_EXTERNAL + + prompt = get_last_user_message(data["messages"]) + context = "" + + # If tool_ids field is present, call the functions + if "tool_ids" in data: + print(data["tool_ids"]) + for tool_id in data["tool_ids"]: + print(tool_id) + try: + response = await get_function_call_response( + messages=data["messages"], + tool_id=tool_id, + template=app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + task_model_id=task_model_id, + user=user, + ) + + if response: + context += ("\n" if context != "" else "") + response + except Exception as e: + print(f"Error: {e}") + del data["tool_ids"] + + print(f"tool_context: {context}") + + # If docs field is present, generate RAG completions if "docs" in data: data = {**data} - data["messages"], citations = rag_messages( + rag_context, citations = get_rag_context( docs=data["docs"], messages=data["messages"], - template=rag_app.state.config.RAG_TEMPLATE, embedding_function=rag_app.state.EMBEDDING_FUNCTION, k=rag_app.state.config.TOP_K, reranking_function=rag_app.state.sentence_transformer_rf, r=rag_app.state.config.RELEVANCE_THRESHOLD, hybrid_search=rag_app.state.config.ENABLE_RAG_HYBRID_SEARCH, ) + + if rag_context: + context += ("\n" if context != "" else "") + rag_context + del data["docs"] - log.debug( - f"data['messages']: {data['messages']}, citations: {citations}" + log.debug(f"rag_context: {rag_context}, citations: {citations}") + + if context != "": + system_prompt = rag_template( + rag_app.state.config.RAG_TEMPLATE, context, prompt + ) + + print(system_prompt) + + data["messages"] = add_or_update_system_message( + f"\n{system_prompt}", data["messages"] ) modified_body_bytes = json.dumps(data).encode("utf-8") # Replace the request body with the modified one request._body = modified_body_bytes - # Set custom header to ensure content-length matches new body length request.headers.__dict__["_list"] = [ (b"content-length", str(len(modified_body_bytes)).encode("utf-8")), @@ -224,7 +424,77 @@ class RAGMiddleware(BaseHTTPMiddleware): yield data -app.add_middleware(RAGMiddleware) +app.add_middleware(ChatCompletionMiddleware) + + +def filter_pipeline(payload, user): + user = {"id": user.id, "name": user.name, "role": user.role} + model_id = payload["model"] + filters = [ + model + for model in app.state.MODELS.values() + if "pipeline" in model + and "type" in model["pipeline"] + and model["pipeline"]["type"] == "filter" + and ( + model["pipeline"]["pipelines"] == ["*"] + or any( + model_id == target_model_id + for target_model_id in model["pipeline"]["pipelines"] + ) + ) + ] + sorted_filters = sorted(filters, key=lambda x: x["pipeline"]["priority"]) + + model = app.state.MODELS[model_id] + + if "pipeline" in model: + sorted_filters.append(model) + + for filter in sorted_filters: + r = None + try: + urlIdx = filter["urlIdx"] + + url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx] + key = openai_app.state.config.OPENAI_API_KEYS[urlIdx] + + if key != "": + headers = {"Authorization": f"Bearer {key}"} + r = requests.post( + f"{url}/{filter['id']}/filter/inlet", + headers=headers, + json={ + "user": user, + "body": payload, + }, + ) + + r.raise_for_status() + payload = r.json() + except Exception as e: + # Handle connection error here + print(f"Connection error: {e}") + + if r is not None: + try: + res = r.json() + except: + pass + if "detail" in res: + raise Exception(r.status_code, res["detail"]) + + else: + pass + + if "pipeline" not in app.state.MODELS[model_id]: + if "chat_id" in payload: + del payload["chat_id"] + + if "title" in payload: + del payload["title"] + + return payload class PipelineMiddleware(BaseHTTPMiddleware): @@ -242,76 +512,17 @@ class PipelineMiddleware(BaseHTTPMiddleware): # Parse string to JSON data = json.loads(body_str) if body_str else {} - model_id = data["model"] - filters = [ - model - for model in app.state.MODELS.values() - if "pipeline" in model - and "type" in model["pipeline"] - and model["pipeline"]["type"] == "filter" - and ( - model["pipeline"]["pipelines"] == ["*"] - or any( - model_id == target_model_id - for target_model_id in model["pipeline"]["pipelines"] - ) + user = get_current_user( + get_http_authorization_cred(request.headers.get("Authorization")) + ) + + try: + data = filter_pipeline(data, user) + except Exception as e: + return JSONResponse( + status_code=e.args[0], + content={"detail": e.args[1]}, ) - ] - sorted_filters = sorted(filters, key=lambda x: x["pipeline"]["priority"]) - - user = None - if len(sorted_filters) > 0: - try: - user = get_current_user( - get_http_authorization_cred( - request.headers.get("Authorization") - ) - ) - user = {"id": user.id, "name": user.name, "role": user.role} - except: - pass - - for filter in sorted_filters: - r = None - try: - urlIdx = filter["urlIdx"] - - url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx] - key = openai_app.state.config.OPENAI_API_KEYS[urlIdx] - - if key != "": - headers = {"Authorization": f"Bearer {key}"} - r = requests.post( - f"{url}/{filter['id']}/filter/inlet", - headers=headers, - json={ - "user": user, - "body": data, - }, - ) - - r.raise_for_status() - data = r.json() - except Exception as e: - # Handle connection error here - print(f"Connection error: {e}") - - if r is not None: - try: - res = r.json() - if "detail" in res: - return JSONResponse( - status_code=r.status_code, - content=res, - ) - except: - pass - - else: - pass - - if "chat_id" in data: - del data["chat_id"] modified_body_bytes = json.dumps(data).encode("utf-8") # Replace the request body with the modified one @@ -368,6 +579,9 @@ async def update_embedding_function(request: Request, call_next): return response +app.mount("/ws", socket_app) + + app.mount("/ollama", ollama_app) app.mount("/openai", openai_app) @@ -469,6 +683,237 @@ async def get_models(user=Depends(get_verified_user)): return {"data": models} +@app.get("/api/task/config") +async def get_task_config(user=Depends(get_verified_user)): + return { + "TASK_MODEL": app.state.config.TASK_MODEL, + "TASK_MODEL_EXTERNAL": app.state.config.TASK_MODEL_EXTERNAL, + "TITLE_GENERATION_PROMPT_TEMPLATE": app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, + "SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE": app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE, + "SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD": app.state.config.SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD, + "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + } + + +class TaskConfigForm(BaseModel): + TASK_MODEL: Optional[str] + TASK_MODEL_EXTERNAL: Optional[str] + TITLE_GENERATION_PROMPT_TEMPLATE: str + SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE: str + SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD: int + TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE: str + + +@app.post("/api/task/config/update") +async def update_task_config(form_data: TaskConfigForm, user=Depends(get_admin_user)): + app.state.config.TASK_MODEL = form_data.TASK_MODEL + app.state.config.TASK_MODEL_EXTERNAL = form_data.TASK_MODEL_EXTERNAL + app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = ( + form_data.TITLE_GENERATION_PROMPT_TEMPLATE + ) + app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE = ( + form_data.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE + ) + app.state.config.SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD = ( + form_data.SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD + ) + app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = ( + form_data.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE + ) + + return { + "TASK_MODEL": app.state.config.TASK_MODEL, + "TASK_MODEL_EXTERNAL": app.state.config.TASK_MODEL_EXTERNAL, + "TITLE_GENERATION_PROMPT_TEMPLATE": app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE, + "SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE": app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE, + "SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD": app.state.config.SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD, + "TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE": app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE, + } + + +@app.post("/api/task/title/completions") +async def generate_title(form_data: dict, user=Depends(get_verified_user)): + print("generate_title") + + model_id = form_data["model"] + if model_id not in app.state.MODELS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model not found", + ) + + # Check if the user has a custom task model + # If the user has a custom task model, use that model + if app.state.MODELS[model_id]["owned_by"] == "ollama": + if app.state.config.TASK_MODEL: + task_model_id = app.state.config.TASK_MODEL + if task_model_id in app.state.MODELS: + model_id = task_model_id + else: + if app.state.config.TASK_MODEL_EXTERNAL: + task_model_id = app.state.config.TASK_MODEL_EXTERNAL + if task_model_id in app.state.MODELS: + model_id = task_model_id + + print(model_id) + model = app.state.MODELS[model_id] + + template = app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE + + content = title_generation_template( + template, form_data["prompt"], user.model_dump() + ) + + payload = { + "model": model_id, + "messages": [{"role": "user", "content": content}], + "stream": False, + "max_tokens": 50, + "chat_id": form_data.get("chat_id", None), + "title": True, + } + + print(payload) + + try: + payload = filter_pipeline(payload, user) + except Exception as e: + return JSONResponse( + status_code=e.args[0], + content={"detail": e.args[1]}, + ) + + if model["owned_by"] == "ollama": + return await generate_ollama_chat_completion( + OpenAIChatCompletionForm(**payload), user=user + ) + else: + return await generate_openai_chat_completion(payload, user=user) + + +@app.post("/api/task/query/completions") +async def generate_search_query(form_data: dict, user=Depends(get_verified_user)): + print("generate_search_query") + + if len(form_data["prompt"]) < app.state.config.SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Skip search query generation for short prompts (< {app.state.config.SEARCH_QUERY_PROMPT_LENGTH_THRESHOLD} characters)", + ) + + model_id = form_data["model"] + if model_id not in app.state.MODELS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model not found", + ) + + # Check if the user has a custom task model + # If the user has a custom task model, use that model + if app.state.MODELS[model_id]["owned_by"] == "ollama": + if app.state.config.TASK_MODEL: + task_model_id = app.state.config.TASK_MODEL + if task_model_id in app.state.MODELS: + model_id = task_model_id + else: + if app.state.config.TASK_MODEL_EXTERNAL: + task_model_id = app.state.config.TASK_MODEL_EXTERNAL + if task_model_id in app.state.MODELS: + model_id = task_model_id + + print(model_id) + model = app.state.MODELS[model_id] + + template = app.state.config.SEARCH_QUERY_GENERATION_PROMPT_TEMPLATE + + content = search_query_generation_template( + template, form_data["prompt"], user.model_dump() + ) + + payload = { + "model": model_id, + "messages": [{"role": "user", "content": content}], + "stream": False, + "max_tokens": 30, + } + + print(payload) + + try: + payload = filter_pipeline(payload, user) + except Exception as e: + return JSONResponse( + status_code=e.args[0], + content={"detail": e.args[1]}, + ) + + if model["owned_by"] == "ollama": + return await generate_ollama_chat_completion( + OpenAIChatCompletionForm(**payload), user=user + ) + else: + return await generate_openai_chat_completion(payload, user=user) + + +@app.post("/api/task/tools/completions") +async def get_tools_function_calling(form_data: dict, user=Depends(get_verified_user)): + print("get_tools_function_calling") + + model_id = form_data["model"] + if model_id not in app.state.MODELS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model not found", + ) + + # Check if the user has a custom task model + # If the user has a custom task model, use that model + if app.state.MODELS[model_id]["owned_by"] == "ollama": + if app.state.config.TASK_MODEL: + task_model_id = app.state.config.TASK_MODEL + if task_model_id in app.state.MODELS: + model_id = task_model_id + else: + if app.state.config.TASK_MODEL_EXTERNAL: + task_model_id = app.state.config.TASK_MODEL_EXTERNAL + if task_model_id in app.state.MODELS: + model_id = task_model_id + + print(model_id) + template = app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE + + try: + context = await get_function_call_response( + form_data["messages"], form_data["tool_id"], template, model_id, user + ) + return context + except Exception as e: + return JSONResponse( + status_code=e.args[0], + content={"detail": e.args[1]}, + ) + + +@app.post("/api/chat/completions") +async def generate_chat_completions(form_data: dict, user=Depends(get_verified_user)): + model_id = form_data["model"] + if model_id not in app.state.MODELS: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Model not found", + ) + + model = app.state.MODELS[model_id] + print(model) + + if model["owned_by"] == "ollama": + return await generate_ollama_chat_completion( + OpenAIChatCompletionForm(**form_data), user=user + ) + else: + return await generate_openai_chat_completion(form_data, user=user) + + @app.post("/api/chat/completed") async def chat_completed(form_data: dict, user=Depends(get_verified_user)): data = form_data @@ -490,6 +935,13 @@ async def chat_completed(form_data: dict, user=Depends(get_verified_user)): ] sorted_filters = sorted(filters, key=lambda x: x["pipeline"]["priority"]) + print(model_id) + + if model_id in app.state.MODELS: + model = app.state.MODELS[model_id] + if "pipeline" in model: + sorted_filters = [model] + sorted_filters + for filter in sorted_filters: r = None try: @@ -537,7 +989,11 @@ async def get_pipelines_list(user=Depends(get_admin_user)): responses = await get_openai_models(raw=True) print(responses) - urlIdxs = [idx for idx, response in enumerate(responses) if "pipelines" in response] + urlIdxs = [ + idx + for idx, response in enumerate(responses) + if response != None and "pipelines" in response + ] return { "data": [ @@ -550,6 +1006,63 @@ async def get_pipelines_list(user=Depends(get_admin_user)): } +@app.post("/api/pipelines/upload") +async def upload_pipeline( + urlIdx: int = Form(...), file: UploadFile = File(...), user=Depends(get_admin_user) +): + print("upload_pipeline", urlIdx, file.filename) + # Check if the uploaded file is a python file + if not file.filename.endswith(".py"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Only Python (.py) files are allowed.", + ) + + upload_folder = f"{CACHE_DIR}/pipelines" + os.makedirs(upload_folder, exist_ok=True) + file_path = os.path.join(upload_folder, file.filename) + + try: + # Save the uploaded file + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + + url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx] + key = openai_app.state.config.OPENAI_API_KEYS[urlIdx] + + headers = {"Authorization": f"Bearer {key}"} + + with open(file_path, "rb") as f: + files = {"file": f} + r = requests.post(f"{url}/pipelines/upload", headers=headers, files=files) + + r.raise_for_status() + data = r.json() + + return {**data} + except Exception as e: + # Handle connection error here + print(f"Connection error: {e}") + + detail = "Pipeline not found" + if r is not None: + try: + res = r.json() + if "detail" in res: + detail = res["detail"] + except: + pass + + raise HTTPException( + status_code=(r.status_code if r is not None else status.HTTP_404_NOT_FOUND), + detail=detail, + ) + finally: + # Ensure the file is deleted after the upload is completed or on failure + if os.path.exists(file_path): + os.remove(file_path) + + class AddPipelineForm(BaseModel): url: str urlIdx: int @@ -811,11 +1324,20 @@ async def get_app_config(): "auth": WEBUI_AUTH, "auth_trusted_header": bool(webui_app.state.AUTH_TRUSTED_EMAIL_HEADER), "enable_signup": webui_app.state.config.ENABLE_SIGNUP, - "enable_web_search": RAG_WEB_SEARCH_ENABLED, + "enable_web_search": rag_app.state.config.ENABLE_RAG_WEB_SEARCH, "enable_image_generation": images_app.state.config.ENABLED, "enable_community_sharing": webui_app.state.config.ENABLE_COMMUNITY_SHARING, "enable_admin_export": ENABLE_ADMIN_EXPORT, }, + "audio": { + "tts": { + "engine": audio_app.state.config.TTS_ENGINE, + "voice": audio_app.state.config.TTS_VOICE, + }, + "stt": { + "engine": audio_app.state.config.STT_ENGINE, + }, + }, } @@ -860,23 +1382,7 @@ class UrlForm(BaseModel): async def update_webhook_url(form_data: UrlForm, user=Depends(get_admin_user)): app.state.config.WEBHOOK_URL = form_data.url webui_app.state.WEBHOOK_URL = app.state.config.WEBHOOK_URL - - return { - "url": app.state.config.WEBHOOK_URL, - } - - -@app.get("/api/community_sharing", response_model=bool) -async def get_community_sharing_status(request: Request, user=Depends(get_admin_user)): - return webui_app.state.config.ENABLE_COMMUNITY_SHARING - - -@app.get("/api/community_sharing/toggle", response_model=bool) -async def toggle_community_sharing(request: Request, user=Depends(get_admin_user)): - webui_app.state.config.ENABLE_COMMUNITY_SHARING = ( - not webui_app.state.config.ENABLE_COMMUNITY_SHARING - ) - return webui_app.state.config.ENABLE_COMMUNITY_SHARING + return {"url": app.state.config.WEBHOOK_URL} @app.get("/api/version") @@ -894,7 +1400,7 @@ async def get_app_changelog(): @app.get("/api/version/updates") async def get_app_latest_release_version(): try: - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: async with session.get( "https://api.github.com/repos/open-webui/open-webui/releases/latest" ) as response: diff --git a/backend/requirements.txt b/backend/requirements.txt index 7a3668428f..be0e32c7d2 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -56,4 +56,8 @@ PyJWT[crypto]==2.8.0 black==24.4.2 langfuse==2.33.0 youtube-transcript-api==0.6.2 -pytube==15.0.0 \ No newline at end of file +pytube==15.0.0 + +extract_msg +pydub +duckduckgo-search~=6.1.5 \ No newline at end of file diff --git a/backend/start.sh b/backend/start.sh index 15fc568d3c..16a004e45c 100755 --- a/backend/start.sh +++ b/backend/start.sh @@ -20,12 +20,12 @@ if test "$WEBUI_SECRET_KEY $WEBUI_JWT_SECRET_KEY" = " "; then WEBUI_SECRET_KEY=$(cat "$KEY_FILE") fi -if [ "$USE_OLLAMA_DOCKER" = "true" ]; then +if [[ "${USE_OLLAMA_DOCKER,,}" == "true" ]]; then echo "USE_OLLAMA is set to true, starting ollama serve." ollama serve & fi -if [ "$USE_CUDA_DOCKER" = "true" ]; then +if [[ "${USE_CUDA_DOCKER,,}" == "true" ]]; then echo "CUDA is enabled, appending LD_LIBRARY_PATH to include torch/cudnn & cublas libraries." export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/local/lib/python3.11/site-packages/torch/lib:/usr/local/lib/python3.11/site-packages/nvidia/cudnn/lib" fi diff --git a/backend/start_windows.bat b/backend/start_windows.bat index d56c91916e..b2498f9c2b 100644 --- a/backend/start_windows.bat +++ b/backend/start_windows.bat @@ -8,6 +8,7 @@ cd /d "%SCRIPT_DIR%" || exit /b SET "KEY_FILE=.webui_secret_key" IF "%PORT%"=="" SET PORT=8080 +IF "%HOST%"=="" SET HOST=0.0.0.0 SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" SET "WEBUI_JWT_SECRET_KEY=%WEBUI_JWT_SECRET_KEY%" @@ -29,4 +30,4 @@ IF "%WEBUI_SECRET_KEY%%WEBUI_JWT_SECRET_KEY%" == " " ( :: Execute uvicorn SET "WEBUI_SECRET_KEY=%WEBUI_SECRET_KEY%" -uvicorn main:app --host 0.0.0.0 --port "%PORT%" --forwarded-allow-ips '*' +uvicorn main:app --host "%HOST%" --port "%PORT%" --forwarded-allow-ips '*' diff --git a/backend/utils/misc.py b/backend/utils/misc.py index fca9412637..c3c65d3f5e 100644 --- a/backend/utils/misc.py +++ b/backend/utils/misc.py @@ -3,7 +3,48 @@ import hashlib import json import re from datetime import timedelta -from typing import Optional +from typing import Optional, List + + +def get_last_user_message(messages: List[dict]) -> str: + for message in reversed(messages): + if message["role"] == "user": + if isinstance(message["content"], list): + for item in message["content"]: + if item["type"] == "text": + return item["text"] + return message["content"] + return None + + +def get_last_assistant_message(messages: List[dict]) -> str: + for message in reversed(messages): + if message["role"] == "assistant": + if isinstance(message["content"], list): + for item in message["content"]: + if item["type"] == "text": + return item["text"] + return message["content"] + return None + + +def add_or_update_system_message(content: str, messages: List[dict]): + """ + Adds a new system message at the beginning of the messages list + or updates the existing system message at the beginning. + + :param msg: The message to be added or appended. + :param messages: The list of message dictionaries. + :return: The updated list of message dictionaries. + """ + + if messages and messages[0].get("role") == "system": + messages[0]["content"] += f"{content}\n{messages[0]['content']}" + else: + # Insert at the beginning + messages.insert(0, {"role": "system", "content": content}) + + return messages def get_gravatar_url(email): @@ -123,11 +164,25 @@ def parse_ollama_modelfile(model_text): "repeat_penalty": float, "temperature": float, "seed": int, - "stop": str, "tfs_z": float, "num_predict": int, "top_k": int, "top_p": float, + "num_keep": int, + "typical_p": float, + "presence_penalty": float, + "frequency_penalty": float, + "penalize_newline": bool, + "numa": bool, + "num_batch": int, + "num_gpu": int, + "main_gpu": int, + "low_vram": bool, + "f16_kv": bool, + "vocab_only": bool, + "use_mmap": bool, + "use_mlock": bool, + "num_thread": int, } data = {"base_model_id": None, "params": {}} @@ -156,10 +211,18 @@ def parse_ollama_modelfile(model_text): param_match = re.search(rf"PARAMETER {param} (.+)", model_text, re.IGNORECASE) if param_match: value = param_match.group(1) - if param_type == int: - value = int(value) - elif param_type == float: - value = float(value) + + try: + if param_type == int: + value = int(value) + elif param_type == float: + value = float(value) + elif param_type == bool: + value = value.lower() == "true" + except Exception as e: + print(e) + continue + data["params"][param] = value # Parse adapter @@ -171,8 +234,14 @@ def parse_ollama_modelfile(model_text): system_desc_match = re.search( r'SYSTEM\s+"""(.+?)"""', model_text, re.DOTALL | re.IGNORECASE ) + system_desc_match_single = re.search( + r"SYSTEM\s+([^\n]+)", model_text, re.IGNORECASE + ) + if system_desc_match: data["params"]["system"] = system_desc_match.group(1).strip() + elif system_desc_match_single: + data["params"]["system"] = system_desc_match_single.group(1).strip() # Parse messages messages = [] diff --git a/backend/utils/models.py b/backend/utils/models.py deleted file mode 100644 index c4d675d295..0000000000 --- a/backend/utils/models.py +++ /dev/null @@ -1,10 +0,0 @@ -from apps.webui.models.models import Models, ModelModel, ModelForm, ModelResponse - - -def get_model_id_from_custom_model_id(id: str): - model = Models.get_model_by_id(id) - - if model: - return model.id - else: - return id diff --git a/backend/utils/task.py b/backend/utils/task.py new file mode 100644 index 0000000000..615febcdcd --- /dev/null +++ b/backend/utils/task.py @@ -0,0 +1,117 @@ +import re +import math + +from datetime import datetime +from typing import Optional + + +def prompt_template( + template: str, user_name: str = None, current_location: str = None +) -> str: + # Get the current date + current_date = datetime.now() + + # Format the date to YYYY-MM-DD + formatted_date = current_date.strftime("%Y-%m-%d") + + # Replace {{CURRENT_DATE}} in the template with the formatted date + template = template.replace("{{CURRENT_DATE}}", formatted_date) + + if user_name: + # Replace {{USER_NAME}} in the template with the user's name + template = template.replace("{{USER_NAME}}", user_name) + + if current_location: + # Replace {{CURRENT_LOCATION}} in the template with the current location + template = template.replace("{{CURRENT_LOCATION}}", current_location) + + return template + + +def title_generation_template( + template: str, prompt: str, user: Optional[dict] = None +) -> str: + def replacement_function(match): + full_match = match.group(0) + start_length = match.group(1) + end_length = match.group(2) + middle_length = match.group(3) + + if full_match == "{{prompt}}": + return prompt + elif start_length is not None: + return prompt[: int(start_length)] + elif end_length is not None: + return prompt[-int(end_length) :] + elif middle_length is not None: + middle_length = int(middle_length) + if len(prompt) <= middle_length: + return prompt + start = prompt[: math.ceil(middle_length / 2)] + end = prompt[-math.floor(middle_length / 2) :] + return f"{start}...{end}" + return "" + + template = re.sub( + r"{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}", + replacement_function, + template, + ) + + template = prompt_template( + template, + **( + {"user_name": user.get("name"), "current_location": user.get("location")} + if user + else {} + ), + ) + + return template + + +def search_query_generation_template( + template: str, prompt: str, user: Optional[dict] = None +) -> str: + + def replacement_function(match): + full_match = match.group(0) + start_length = match.group(1) + end_length = match.group(2) + middle_length = match.group(3) + + if full_match == "{{prompt}}": + return prompt + elif start_length is not None: + return prompt[: int(start_length)] + elif end_length is not None: + return prompt[-int(end_length) :] + elif middle_length is not None: + middle_length = int(middle_length) + if len(prompt) <= middle_length: + return prompt + start = prompt[: math.ceil(middle_length / 2)] + end = prompt[-math.floor(middle_length / 2) :] + return f"{start}...{end}" + return "" + + template = re.sub( + r"{{prompt}}|{{prompt:start:(\d+)}}|{{prompt:end:(\d+)}}|{{prompt:middletruncate:(\d+)}}", + replacement_function, + template, + ) + + template = prompt_template( + template, + **( + {"user_name": user.get("name"), "current_location": user.get("location")} + if user + else {} + ), + ) + return template + + +def tools_function_calling_generation_template(template: str, tools_specs: str) -> str: + template = template.replace("{{TOOLS}}", tools_specs) + return template diff --git a/backend/utils/tools.py b/backend/utils/tools.py new file mode 100644 index 0000000000..5fef2a2b68 --- /dev/null +++ b/backend/utils/tools.py @@ -0,0 +1,73 @@ +import inspect +from typing import get_type_hints, List, Dict, Any + + +def doc_to_dict(docstring): + lines = docstring.split("\n") + description = lines[1].strip() + param_dict = {} + + for line in lines: + if ":param" in line: + line = line.replace(":param", "").strip() + param, desc = line.split(":", 1) + param_dict[param.strip()] = desc.strip() + ret_dict = {"description": description, "params": param_dict} + return ret_dict + + +def get_tools_specs(tools) -> List[dict]: + function_list = [ + {"name": func, "function": getattr(tools, func)} + for func in dir(tools) + if callable(getattr(tools, func)) and not func.startswith("__") + ] + + specs = [] + for function_item in function_list: + function_name = function_item["name"] + function = function_item["function"] + + function_doc = doc_to_dict(function.__doc__ or function_name) + specs.append( + { + "name": function_name, + # TODO: multi-line desc? + "description": function_doc.get("description", function_name), + "parameters": { + "type": "object", + "properties": { + param_name: { + "type": param_annotation.__name__.lower(), + **( + { + "enum": ( + str(param_annotation.__args__) + if hasattr(param_annotation, "__args__") + else None + ) + } + if hasattr(param_annotation, "__args__") + else {} + ), + "description": function_doc.get("params", {}).get( + param_name, param_name + ), + } + for param_name, param_annotation in get_type_hints( + function + ).items() + if param_name != "return" and param_name != "__user__" + }, + "required": [ + name + for name, param in inspect.signature( + function + ).parameters.items() + if param.default is param.empty + ], + }, + } + ) + + return specs diff --git a/cypress/e2e/settings.cy.ts b/cypress/e2e/settings.cy.ts index 5db232faa7..4ea9169807 100644 --- a/cypress/e2e/settings.cy.ts +++ b/cypress/e2e/settings.cy.ts @@ -28,19 +28,6 @@ describe('Settings', () => { }); }); - context('Connections', () => { - it('user can open the Connections modal and hit save', () => { - cy.get('button').contains('Connections').click(); - cy.get('button').contains('Save').click(); - }); - }); - - context('Models', () => { - it('user can open the Models modal', () => { - cy.get('button').contains('Models').click(); - }); - }); - context('Interface', () => { it('user can open the Interface modal and hit save', () => { cy.get('button').contains('Interface').click(); @@ -55,14 +42,6 @@ describe('Settings', () => { }); }); - context('Images', () => { - it('user can open the Images modal and hit save', () => { - cy.get('button').contains('Images').click(); - // Currently fails because the backend requires a valid URL - // cy.get('button').contains('Save').click(); - }); - }); - context('Chats', () => { it('user can open the Chats modal', () => { cy.get('button').contains('Chats').click(); diff --git a/demo.gif b/demo.gif index 5a3f36269d..6e56b74a0a 100644 Binary files a/demo.gif and b/demo.gif differ diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 3e82c979c8..325964b1a9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -41,10 +41,11 @@ Looking to contribute? Great! Here's how you can help: We welcome pull requests. Before submitting one, please: -1. Discuss your idea or issue in the [issues section](https://github.com/open-webui/open-webui/issues). +1. Open a discussion regarding your ideas [here](https://github.com/open-webui/open-webui/discussions/new/choose). 2. Follow the project's coding standards and include tests for new features. 3. Update documentation as necessary. 4. Write clear, descriptive commit messages. +5. It's essential to complete your pull request in a timely manner. We move fast, and having PRs hang around too long is not feasible. If you can't get it done within a reasonable time frame, we may have to close it to keep the project moving forward. ### 📚 Documentation & Tutorials diff --git a/package-lock.json b/package-lock.json index 5f969e59d7..f5b9d6a788 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,21 @@ { "name": "open-webui", - "version": "0.2.0.dev3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.2.0.dev3", + "version": "0.3.4", "dependencies": { + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-python": "^6.1.6", + "@codemirror/theme-one-dark": "^6.1.2", "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^1.3.1", "async": "^3.2.5", "bits-ui": "^0.19.7", + "codemirror": "^6.0.1", "dayjs": "^1.11.10", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", @@ -23,7 +27,10 @@ "js-sha256": "^0.10.1", "katex": "^0.16.9", "marked": "^9.1.0", + "mermaid": "^10.9.1", "pyodide": "^0.26.0-alpha.4", + "socket.io-client": "^4.7.5", + "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "uuid": "^9.0.1" @@ -100,6 +107,124 @@ "node": ">=6.9.0" } }, + "node_modules/@braintree/sanitize-url": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-6.0.4.tgz", + "integrity": "sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==" + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.16.2.tgz", + "integrity": "sha512-MjfDrHy0gHKlPWsvSsikhO1+BOh+eBHNgfH1OXs1+DAf30IonQldgMM3kxLDTG9ktE7kDLaA1j/l7KMPA4KNfw==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + }, + "peerDependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.0.tgz", + "integrity": "sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", + "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.1.6.tgz", + "integrity": "sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/language": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.2.tgz", + "integrity": "sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.1.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.0.tgz", + "integrity": "sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.6", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.6.tgz", + "integrity": "sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.4.1.tgz", + "integrity": "sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==" + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.2.tgz", + "integrity": "sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.28.0.tgz", + "integrity": "sha512-fo7CelaUDKWIyemw4b+J57cWuRkOu4SWCCPfNDkPvfWkGjM9D5racHQXr4EQeYCD6zEBIBxGCeaKkQo+ysl0gA==", + "dependencies": { + "@codemirror/state": "^6.4.0", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@colors/colors": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", @@ -817,6 +942,47 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lezer/common": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.1.tgz", + "integrity": "sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.0.tgz", + "integrity": "sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.4.16", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.16.tgz", + "integrity": "sha512-84UXR3N7s11MPQHWgMnjb9571fr19MmXnr5zTv2XX0gHXXUvW3uPJ8GCjKrfTXmSdfktjRK0ayKklw+A13rk4g==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.1.tgz", + "integrity": "sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.14.tgz", + "integrity": "sha512-ykDOb2Ti24n76PJsSa4ZoDF0zH12BSw1LGfQXCYJhJyOGiFTfGaX0Du66Ze72R+u/P35U+O6I9m8TFXov1JzsA==", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@melt-ui/svelte": { "version": "0.76.0", "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.76.0.tgz", @@ -1207,6 +1373,11 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@sveltejs/adapter-auto": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-2.1.1.tgz", @@ -1347,6 +1518,32 @@ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.4.tgz", "integrity": "sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==" }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.0.3.tgz", + "integrity": "sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==" + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -1358,12 +1555,25 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "dependencies": { + "@types/unist": "^2" + } + }, "node_modules/@types/minimatch": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.5.tgz", "integrity": "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==", "dev": true }, + "node_modules/@types/ms": { + "version": "0.7.34", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.34.tgz", + "integrity": "sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==" + }, "node_modules/@types/node": { "version": "20.11.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz", @@ -1408,6 +1618,11 @@ "integrity": "sha512-MQ1AnmTLOncwEf9IVU+B2e4Hchrku5N67NkgcAHW0p3sdzPe0FNMANxEm6OJUzPniEQGkeT3OROLlCwZJLWFZA==", "dev": true }, + "node_modules/@types/unist": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz", + "integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==" + }, "node_modules/@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -2167,12 +2382,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2458,6 +2673,15 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/check-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", @@ -2703,6 +2927,20 @@ "plain-tag": "^0.1.3" } }, + "node_modules/codemirror": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", + "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/coincident": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/coincident/-/coincident-1.2.3.tgz", @@ -2817,6 +3055,19 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/cose-base": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz", + "integrity": "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==", + "dependencies": { + "layout-base": "^1.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3003,6 +3254,447 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/cytoscape": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.29.2.tgz", + "integrity": "sha512-2G1ycU28Nh7OHT9rkXRLpCDP30MKH1dXJORZuBhtEhEW7pKwgPi77ImqlCWinouyE1PNepIOGZBOrE84DG7LyQ==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/cytoscape-cose-bilkent": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cytoscape-cose-bilkent/-/cytoscape-cose-bilkent-4.1.0.tgz", + "integrity": "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==", + "dependencies": { + "cose-base": "^1.0.0" + }, + "peerDependencies": { + "cytoscape": "^3.2.0" + } + }, + "node_modules/d3": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.9.0.tgz", + "integrity": "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz", + "integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-sankey": { + "version": "0.12.3", + "resolved": "https://registry.npmjs.org/d3-sankey/-/d3-sankey-0.12.3.tgz", + "integrity": "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==", + "dependencies": { + "d3-array": "1 - 2", + "d3-shape": "^1.2.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-sankey/node_modules/d3-path": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", + "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" + }, + "node_modules/d3-sankey/node_modules/d3-shape": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", + "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", + "dependencies": { + "d3-path": "1" + } + }, + "node_modules/d3-sankey/node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==" + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre-d3-es": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz", + "integrity": "sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A==", + "dependencies": { + "d3": "^7.8.2", + "lodash-es": "^4.17.21" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3042,6 +3734,18 @@ } } }, + "node_modules/decode-named-character-reference": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz", + "integrity": "sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -3085,6 +3789,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3122,6 +3834,14 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/diff": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.0.tgz", + "integrity": "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", @@ -3202,6 +3922,11 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.5.tgz", + "integrity": "sha512-lwG+n5h8QNpxtyrJW/gJWckL+1/DQiYMX8f7t8Z2AZTPw1esVrqjI63i7Zc2Gz0aKzLVMYC1V1PL/ky+aY/NgA==" + }, "node_modules/domutils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", @@ -3238,6 +3963,11 @@ "integrity": "sha512-XzWNH4ZSa9BwVUQSDorPWAUQ5WGuYz7zJUNpNif40zFCiCl20t8zgylmreNmn26h5kiyw2lg7RfTmeMBsDklqg==", "dev": true }, + "node_modules/elkjs": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.9.3.tgz", + "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -3253,6 +3983,46 @@ "once": "^1.4.0" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -3836,9 +4606,9 @@ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==" }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -4512,7 +5282,6 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -4621,6 +5390,14 @@ "node": ">=10" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4970,6 +5747,11 @@ "json-buffer": "3.0.1" } }, + "node_modules/khroma": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", + "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -4984,6 +5766,11 @@ "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", "dev": true }, + "node_modules/layout-base": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", + "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==" + }, "node_modules/lazy-ass": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz", @@ -5139,6 +5926,11 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "dev": true }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", @@ -5326,6 +6118,41 @@ "node": "*" } }, + "node_modules/mdast-util-from-markdown": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", + "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "mdast-util-to-string": "^3.1.0", + "micromark": "^3.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-decode-string": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "unist-util-stringify-position": "^3.0.0", + "uvu": "^0.5.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", + "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", + "dependencies": { + "@types/mdast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.0.30", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", @@ -5346,6 +6173,454 @@ "node": ">= 8" } }, + "node_modules/mermaid": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-10.9.1.tgz", + "integrity": "sha512-Mx45Obds5W1UkW1nv/7dHRsbfMM1aOKA2+Pxs/IGHNonygDHwmng8xTHyS9z4KWVi0rbko8gjiBmuwwXQ7tiNA==", + "dependencies": { + "@braintree/sanitize-url": "^6.0.1", + "@types/d3-scale": "^4.0.3", + "@types/d3-scale-chromatic": "^3.0.0", + "cytoscape": "^3.28.1", + "cytoscape-cose-bilkent": "^4.1.0", + "d3": "^7.4.0", + "d3-sankey": "^0.12.3", + "dagre-d3-es": "7.0.10", + "dayjs": "^1.11.7", + "dompurify": "^3.0.5", + "elkjs": "^0.9.0", + "katex": "^0.16.9", + "khroma": "^2.0.0", + "lodash-es": "^4.17.21", + "mdast-util-from-markdown": "^1.3.0", + "non-layered-tidy-tree-layout": "^2.0.2", + "stylis": "^4.1.3", + "ts-dedent": "^2.2.0", + "uuid": "^9.0.0", + "web-worker": "^1.2.0" + } + }, + "node_modules/micromark": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", + "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "micromark-core-commonmark": "^1.0.1", + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-combine-extensions": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-sanitize-uri": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", + "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-factory-destination": "^1.0.0", + "micromark-factory-label": "^1.0.0", + "micromark-factory-space": "^1.0.0", + "micromark-factory-title": "^1.0.0", + "micromark-factory-whitespace": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-chunked": "^1.0.0", + "micromark-util-classify-character": "^1.0.0", + "micromark-util-html-tag-name": "^1.0.0", + "micromark-util-normalize-identifier": "^1.0.0", + "micromark-util-resolve-all": "^1.0.0", + "micromark-util-subtokenize": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.1", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", + "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", + "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", + "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", + "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", + "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", + "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", + "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", + "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", + "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^1.0.0", + "micromark-util-decode-numeric-character-reference": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", + "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", + "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", + "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", + "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", + "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-encode": "^1.0.0", + "micromark-util-symbol": "^1.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", + "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^1.0.0", + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0", + "uvu": "^0.5.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, "node_modules/micromatch": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", @@ -5525,6 +6800,11 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", "dev": true }, + "node_modules/non-layered-tidy-tree-layout": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/non-layered-tidy-tree-layout/-/non-layered-tidy-tree-layout-2.0.2.tgz", + "integrity": "sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==" + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -6600,6 +7880,11 @@ "node": "*" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, "node_modules/rollup": { "version": "3.29.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", @@ -6647,6 +7932,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -6676,8 +7966,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sander": { "version": "0.5.1", @@ -6883,6 +8172,32 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz", + "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sorcery": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", @@ -6913,6 +8228,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sortablejs": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz", + "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==" + }, "node_modules/source-map-js": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", @@ -7135,6 +8455,16 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/style-mod": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", + "integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==" + }, + "node_modules/stylis": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.2.tgz", + "integrity": "sha512-bhtUjWd/z6ltJiQwg0dUfxEJ+W+jdqQd8TbWLWyeIJHlnsqmGLRFFd8e5mA0AZi/zx90smXRlN66YMTcaSFifg==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -7704,6 +9034,14 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-dedent": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", + "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", + "engines": { + "node": ">=6.10" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -7820,6 +9158,18 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "devOptional": true }, + "node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -7905,6 +9255,23 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/uvu": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", + "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", + "dependencies": { + "dequal": "^2.0.0", + "diff": "^5.0.0", + "kleur": "^4.0.3", + "sade": "^1.7.3" + }, + "bin": { + "uvu": "bin.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/value-or-function": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-4.0.0.tgz", @@ -8837,6 +10204,11 @@ "he": "^1.2.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/walk-sync": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", @@ -8874,6 +10246,11 @@ "node": "*" } }, + "node_modules/web-worker": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.3.0.tgz", + "integrity": "sha512-BSR9wyRsy/KOValMgd5kMyr3JzpdeoR9KVId8u5GVlTTAtNChlsE4yTxeY7zMdNSyOmoKBv8NH2qeRY9Tg+IaA==" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -9024,6 +10401,14 @@ } } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 3f5b5cb6d2..bf353ef7f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.2.0.dev3", + "version": "0.3.4", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", @@ -16,7 +16,7 @@ "format:backend": "black . --exclude \".venv/|/venv/\"", "i18n:parse": "i18next --config i18next-parser.config.ts && prettier --write \"src/lib/i18n/**/*.{js,json}\"", "cy:open": "cypress open", - "test:frontend": "vitest", + "test:frontend": "vitest --passWithNoTests", "pyodide:fetch": "node scripts/prepare-pyodide.js" }, "devDependencies": { @@ -48,10 +48,14 @@ }, "type": "module", "dependencies": { + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-python": "^6.1.6", + "@codemirror/theme-one-dark": "^6.1.2", "@pyscript/core": "^0.4.32", "@sveltejs/adapter-node": "^1.3.1", "async": "^3.2.5", "bits-ui": "^0.19.7", + "codemirror": "^6.0.1", "dayjs": "^1.11.10", "eventsource-parser": "^1.1.2", "file-saver": "^2.0.5", @@ -63,7 +67,10 @@ "js-sha256": "^0.10.1", "katex": "^0.16.9", "marked": "^9.1.0", + "mermaid": "^10.9.1", "pyodide": "^0.26.0-alpha.4", + "socket.io-client": "^4.7.5", + "sortablejs": "^1.15.2", "svelte-sonner": "^0.3.19", "tippy.js": "^6.3.7", "uuid": "^9.0.1" diff --git a/pyproject.toml b/pyproject.toml index 004ce374b4..4571e5b616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,6 @@ dependencies = [ "PyMySQL==1.1.0", "bcrypt==4.1.3", - "litellm[proxy]==1.37.20", - "boto3==1.34.110", "argon2-cffi==23.1.0", @@ -66,6 +64,10 @@ dependencies = [ "langfuse==2.33.0", "youtube-transcript-api==0.6.2", "pytube==15.0.0", + "extract_msg", + "pydub", + "duckduckgo-search~=6.1.5" + ] readme = "README.md" requires-python = ">= 3.11, < 3.12.0a1" diff --git a/requirements-dev.lock b/requirements-dev.lock index 39b1d0ef02..6aa26dad44 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -12,7 +12,6 @@ aiohttp==3.9.5 # via langchain # via langchain-community - # via litellm # via open-webui aiosignal==1.3.1 # via aiohttp @@ -20,11 +19,9 @@ annotated-types==0.6.0 # via pydantic anyio==4.3.0 # via httpx - # via openai # via starlette # via watchfiles apscheduler==3.10.4 - # via litellm # via open-webui argon2-cffi==23.1.0 # via open-webui @@ -38,7 +35,6 @@ av==11.0.0 # via faster-whisper backoff==2.2.1 # via langfuse - # via litellm # via posthog # via unstructured bcrypt==4.1.3 @@ -46,6 +42,7 @@ bcrypt==4.1.3 # via open-webui # via passlib beautifulsoup4==4.12.3 + # via extract-msg # via unstructured bidict==0.23.1 # via python-socketio @@ -83,17 +80,20 @@ chromadb==0.5.0 # via open-webui click==8.1.7 # via black + # via duckduckgo-search # via flask - # via litellm # via nltk # via peewee-migrate - # via rq # via typer # via uvicorn +colorclass==2.2.2 + # via oletools coloredlogs==15.0.1 # via onnxruntime +compressed-rtf==1.0.6 + # via extract-msg cryptography==42.0.7 - # via litellm + # via msoffcrypto-tool # via pyjwt ctranslate2==4.2.1 # via faster-whisper @@ -109,33 +109,34 @@ defusedxml==0.7.1 deprecated==1.2.14 # via opentelemetry-api # via opentelemetry-exporter-otlp-proto-grpc -distro==1.9.0 - # via openai dnspython==2.6.1 # via email-validator docx2txt==0.8 # via open-webui +duckduckgo-search==6.1.5 + # via open-webui +easygui==0.98.3 + # via oletools +ebcdic==1.1.1 + # via extract-msg ecdsa==0.19.0 # via python-jose email-validator==2.1.1 # via fastapi - # via pydantic emoji==2.11.1 # via unstructured et-xmlfile==1.1.0 # via openpyxl +extract-msg==0.48.5 + # via open-webui fake-useragent==1.5.1 # via open-webui fastapi==0.111.0 # via chromadb - # via fastapi-sso # via langchain-chroma - # via litellm # via open-webui fastapi-cli==0.0.4 # via fastapi -fastapi-sso==0.10.0 - # via litellm faster-whisper==1.0.2 # via open-webui filelock==3.14.0 @@ -191,8 +192,6 @@ grpcio==1.63.0 # via opentelemetry-exporter-otlp-proto-grpc grpcio-status==1.62.2 # via google-api-core -gunicorn==22.0.0 - # via litellm h11==0.14.0 # via httpcore # via uvicorn @@ -206,9 +205,7 @@ httptools==0.6.1 # via uvicorn httpx==0.27.0 # via fastapi - # via fastapi-sso # via langfuse - # via openai huggingface-hub==0.23.0 # via faster-whisper # via sentence-transformers @@ -225,7 +222,6 @@ idna==3.7 # via unstructured-client # via yarl importlib-metadata==7.0.0 - # via litellm # via opentelemetry-api importlib-resources==6.4.0 # via chromadb @@ -234,7 +230,6 @@ itsdangerous==2.2.0 jinja2==3.1.4 # via fastapi # via flask - # via litellm # via torch jmespath==1.0.1 # via boto3 @@ -272,8 +267,8 @@ langsmith==0.1.57 # via langchain # via langchain-community # via langchain-core -litellm==1.37.20 - # via open-webui +lark==1.1.8 + # via rtfde lxml==5.2.2 # via unstructured markdown==3.6 @@ -294,6 +289,8 @@ monotonic==1.6 # via posthog mpmath==1.3.0 # via sympy +msoffcrypto-tool==5.4.1 + # via oletools multidict==6.0.5 # via aiohttp # via yarl @@ -325,15 +322,19 @@ numpy==1.26.4 # via transformers # via unstructured oauthlib==3.2.2 - # via fastapi-sso # via kubernetes # via requests-oauthlib +olefile==0.47 + # via extract-msg + # via msoffcrypto-tool + # via oletools +oletools==0.60.1 + # via pcodedmp + # via rtfde onnxruntime==1.17.3 # via chromadb # via faster-whisper # via rapidocr-onnxruntime -openai==1.28.1 - # via litellm opencv-python==4.9.0.80 # via rapidocr-onnxruntime opencv-python-headless==4.9.0.80 @@ -375,15 +376,14 @@ ordered-set==4.1.0 # via deepdiff orjson==3.10.3 # via chromadb + # via duckduckgo-search # via fastapi # via langsmith - # via litellm overrides==7.7.0 # via chromadb packaging==23.2 # via black # via build - # via gunicorn # via huggingface-hub # via langchain-core # via langfuse @@ -397,6 +397,8 @@ passlib==1.7.4 # via open-webui pathspec==0.12.1 # via black +pcodedmp==1.2.6 + # via oletools peewee==3.17.5 # via open-webui # via peewee-migrate @@ -437,27 +439,27 @@ pycparser==2.22 pydantic==2.7.1 # via chromadb # via fastapi - # via fastapi-sso # via google-generativeai # via langchain # via langchain-core # via langfuse # via langsmith # via open-webui - # via openai pydantic-core==2.18.2 # via pydantic +pydub==0.25.1 + # via open-webui pygments==2.18.0 # via rich pyjwt==2.8.0 - # via litellm # via open-webui pymysql==1.1.0 # via open-webui pypandoc==1.13 # via open-webui -pyparsing==3.1.2 +pyparsing==2.4.7 # via httplib2 + # via oletools pypdf==4.2.0 # via open-webui # via unstructured-client @@ -465,6 +467,8 @@ pypika==0.48.9 # via chromadb pyproject-hooks==1.1.0 # via build +pyreqwest-impersonate==0.4.7 + # via duckduckgo-search python-dateutil==2.9.0.post0 # via botocore # via kubernetes @@ -472,7 +476,6 @@ python-dateutil==2.9.0.post0 # via posthog # via unstructured-client python-dotenv==1.0.1 - # via litellm # via uvicorn python-engineio==4.9.0 # via python-socketio @@ -484,7 +487,6 @@ python-magic==0.4.27 # via unstructured python-multipart==0.0.9 # via fastapi - # via litellm # via open-webui python-socketio==5.11.2 # via open-webui @@ -503,7 +505,6 @@ pyyaml==6.0.1 # via langchain # via langchain-community # via langchain-core - # via litellm # via rapidocr-onnxruntime # via transformers # via uvicorn @@ -513,11 +514,10 @@ rapidfuzz==3.9.0 # via unstructured rapidocr-onnxruntime==1.3.22 # via open-webui -redis==5.0.4 - # via rq +red-black-tree-mod==1.20 + # via extract-msg regex==2024.5.10 # via nltk - # via tiktoken # via transformers requests==2.32.2 # via chromadb @@ -527,11 +527,9 @@ requests==2.32.2 # via langchain # via langchain-community # via langsmith - # via litellm # via open-webui # via posthog # via requests-oauthlib - # via tiktoken # via transformers # via unstructured # via unstructured-client @@ -540,11 +538,11 @@ requests-oauthlib==2.0.0 # via kubernetes rich==13.7.1 # via typer -rq==1.16.2 - # via litellm rsa==4.9 # via google-auth # via python-jose +rtfde==0.1.1 + # via extract-msg s3transfer==0.10.1 # via boto3 safetensors==0.4.3 @@ -577,7 +575,6 @@ six==1.16.0 sniffio==1.3.1 # via anyio # via httpx - # via openai soupsieve==2.5 # via beautifulsoup4 sqlalchemy==2.0.30 @@ -597,12 +594,9 @@ tenacity==8.3.0 # via langchain-core threadpoolctl==3.5.0 # via scikit-learn -tiktoken==0.6.0 - # via litellm tokenizers==0.15.2 # via chromadb # via faster-whisper - # via litellm # via transformers torch==2.3.0 # via sentence-transformers @@ -611,7 +605,6 @@ tqdm==4.66.4 # via google-generativeai # via huggingface-hub # via nltk - # via openai # via sentence-transformers # via transformers transformers==4.39.3 @@ -624,7 +617,6 @@ typing-extensions==4.11.0 # via fastapi # via google-generativeai # via huggingface-hub - # via openai # via opentelemetry-sdk # via pydantic # via pydantic-core @@ -641,6 +633,7 @@ tzdata==2024.1 # via pandas tzlocal==5.2 # via apscheduler + # via extract-msg ujson==5.10.0 # via fastapi unstructured==0.14.0 @@ -657,7 +650,6 @@ urllib3==2.2.1 uvicorn==0.22.0 # via chromadb # via fastapi - # via litellm # via open-webui uvloop==0.19.0 # via uvicorn diff --git a/requirements.lock b/requirements.lock index 39b1d0ef02..6aa26dad44 100644 --- a/requirements.lock +++ b/requirements.lock @@ -12,7 +12,6 @@ aiohttp==3.9.5 # via langchain # via langchain-community - # via litellm # via open-webui aiosignal==1.3.1 # via aiohttp @@ -20,11 +19,9 @@ annotated-types==0.6.0 # via pydantic anyio==4.3.0 # via httpx - # via openai # via starlette # via watchfiles apscheduler==3.10.4 - # via litellm # via open-webui argon2-cffi==23.1.0 # via open-webui @@ -38,7 +35,6 @@ av==11.0.0 # via faster-whisper backoff==2.2.1 # via langfuse - # via litellm # via posthog # via unstructured bcrypt==4.1.3 @@ -46,6 +42,7 @@ bcrypt==4.1.3 # via open-webui # via passlib beautifulsoup4==4.12.3 + # via extract-msg # via unstructured bidict==0.23.1 # via python-socketio @@ -83,17 +80,20 @@ chromadb==0.5.0 # via open-webui click==8.1.7 # via black + # via duckduckgo-search # via flask - # via litellm # via nltk # via peewee-migrate - # via rq # via typer # via uvicorn +colorclass==2.2.2 + # via oletools coloredlogs==15.0.1 # via onnxruntime +compressed-rtf==1.0.6 + # via extract-msg cryptography==42.0.7 - # via litellm + # via msoffcrypto-tool # via pyjwt ctranslate2==4.2.1 # via faster-whisper @@ -109,33 +109,34 @@ defusedxml==0.7.1 deprecated==1.2.14 # via opentelemetry-api # via opentelemetry-exporter-otlp-proto-grpc -distro==1.9.0 - # via openai dnspython==2.6.1 # via email-validator docx2txt==0.8 # via open-webui +duckduckgo-search==6.1.5 + # via open-webui +easygui==0.98.3 + # via oletools +ebcdic==1.1.1 + # via extract-msg ecdsa==0.19.0 # via python-jose email-validator==2.1.1 # via fastapi - # via pydantic emoji==2.11.1 # via unstructured et-xmlfile==1.1.0 # via openpyxl +extract-msg==0.48.5 + # via open-webui fake-useragent==1.5.1 # via open-webui fastapi==0.111.0 # via chromadb - # via fastapi-sso # via langchain-chroma - # via litellm # via open-webui fastapi-cli==0.0.4 # via fastapi -fastapi-sso==0.10.0 - # via litellm faster-whisper==1.0.2 # via open-webui filelock==3.14.0 @@ -191,8 +192,6 @@ grpcio==1.63.0 # via opentelemetry-exporter-otlp-proto-grpc grpcio-status==1.62.2 # via google-api-core -gunicorn==22.0.0 - # via litellm h11==0.14.0 # via httpcore # via uvicorn @@ -206,9 +205,7 @@ httptools==0.6.1 # via uvicorn httpx==0.27.0 # via fastapi - # via fastapi-sso # via langfuse - # via openai huggingface-hub==0.23.0 # via faster-whisper # via sentence-transformers @@ -225,7 +222,6 @@ idna==3.7 # via unstructured-client # via yarl importlib-metadata==7.0.0 - # via litellm # via opentelemetry-api importlib-resources==6.4.0 # via chromadb @@ -234,7 +230,6 @@ itsdangerous==2.2.0 jinja2==3.1.4 # via fastapi # via flask - # via litellm # via torch jmespath==1.0.1 # via boto3 @@ -272,8 +267,8 @@ langsmith==0.1.57 # via langchain # via langchain-community # via langchain-core -litellm==1.37.20 - # via open-webui +lark==1.1.8 + # via rtfde lxml==5.2.2 # via unstructured markdown==3.6 @@ -294,6 +289,8 @@ monotonic==1.6 # via posthog mpmath==1.3.0 # via sympy +msoffcrypto-tool==5.4.1 + # via oletools multidict==6.0.5 # via aiohttp # via yarl @@ -325,15 +322,19 @@ numpy==1.26.4 # via transformers # via unstructured oauthlib==3.2.2 - # via fastapi-sso # via kubernetes # via requests-oauthlib +olefile==0.47 + # via extract-msg + # via msoffcrypto-tool + # via oletools +oletools==0.60.1 + # via pcodedmp + # via rtfde onnxruntime==1.17.3 # via chromadb # via faster-whisper # via rapidocr-onnxruntime -openai==1.28.1 - # via litellm opencv-python==4.9.0.80 # via rapidocr-onnxruntime opencv-python-headless==4.9.0.80 @@ -375,15 +376,14 @@ ordered-set==4.1.0 # via deepdiff orjson==3.10.3 # via chromadb + # via duckduckgo-search # via fastapi # via langsmith - # via litellm overrides==7.7.0 # via chromadb packaging==23.2 # via black # via build - # via gunicorn # via huggingface-hub # via langchain-core # via langfuse @@ -397,6 +397,8 @@ passlib==1.7.4 # via open-webui pathspec==0.12.1 # via black +pcodedmp==1.2.6 + # via oletools peewee==3.17.5 # via open-webui # via peewee-migrate @@ -437,27 +439,27 @@ pycparser==2.22 pydantic==2.7.1 # via chromadb # via fastapi - # via fastapi-sso # via google-generativeai # via langchain # via langchain-core # via langfuse # via langsmith # via open-webui - # via openai pydantic-core==2.18.2 # via pydantic +pydub==0.25.1 + # via open-webui pygments==2.18.0 # via rich pyjwt==2.8.0 - # via litellm # via open-webui pymysql==1.1.0 # via open-webui pypandoc==1.13 # via open-webui -pyparsing==3.1.2 +pyparsing==2.4.7 # via httplib2 + # via oletools pypdf==4.2.0 # via open-webui # via unstructured-client @@ -465,6 +467,8 @@ pypika==0.48.9 # via chromadb pyproject-hooks==1.1.0 # via build +pyreqwest-impersonate==0.4.7 + # via duckduckgo-search python-dateutil==2.9.0.post0 # via botocore # via kubernetes @@ -472,7 +476,6 @@ python-dateutil==2.9.0.post0 # via posthog # via unstructured-client python-dotenv==1.0.1 - # via litellm # via uvicorn python-engineio==4.9.0 # via python-socketio @@ -484,7 +487,6 @@ python-magic==0.4.27 # via unstructured python-multipart==0.0.9 # via fastapi - # via litellm # via open-webui python-socketio==5.11.2 # via open-webui @@ -503,7 +505,6 @@ pyyaml==6.0.1 # via langchain # via langchain-community # via langchain-core - # via litellm # via rapidocr-onnxruntime # via transformers # via uvicorn @@ -513,11 +514,10 @@ rapidfuzz==3.9.0 # via unstructured rapidocr-onnxruntime==1.3.22 # via open-webui -redis==5.0.4 - # via rq +red-black-tree-mod==1.20 + # via extract-msg regex==2024.5.10 # via nltk - # via tiktoken # via transformers requests==2.32.2 # via chromadb @@ -527,11 +527,9 @@ requests==2.32.2 # via langchain # via langchain-community # via langsmith - # via litellm # via open-webui # via posthog # via requests-oauthlib - # via tiktoken # via transformers # via unstructured # via unstructured-client @@ -540,11 +538,11 @@ requests-oauthlib==2.0.0 # via kubernetes rich==13.7.1 # via typer -rq==1.16.2 - # via litellm rsa==4.9 # via google-auth # via python-jose +rtfde==0.1.1 + # via extract-msg s3transfer==0.10.1 # via boto3 safetensors==0.4.3 @@ -577,7 +575,6 @@ six==1.16.0 sniffio==1.3.1 # via anyio # via httpx - # via openai soupsieve==2.5 # via beautifulsoup4 sqlalchemy==2.0.30 @@ -597,12 +594,9 @@ tenacity==8.3.0 # via langchain-core threadpoolctl==3.5.0 # via scikit-learn -tiktoken==0.6.0 - # via litellm tokenizers==0.15.2 # via chromadb # via faster-whisper - # via litellm # via transformers torch==2.3.0 # via sentence-transformers @@ -611,7 +605,6 @@ tqdm==4.66.4 # via google-generativeai # via huggingface-hub # via nltk - # via openai # via sentence-transformers # via transformers transformers==4.39.3 @@ -624,7 +617,6 @@ typing-extensions==4.11.0 # via fastapi # via google-generativeai # via huggingface-hub - # via openai # via opentelemetry-sdk # via pydantic # via pydantic-core @@ -641,6 +633,7 @@ tzdata==2024.1 # via pandas tzlocal==5.2 # via apscheduler + # via extract-msg ujson==5.10.0 # via fastapi unstructured==0.14.0 @@ -657,7 +650,6 @@ urllib3==2.2.1 uvicorn==0.22.0 # via chromadb # via fastapi - # via litellm # via open-webui uvloop==0.19.0 # via uvicorn diff --git a/src/app.css b/src/app.css index f7c14bcbd6..da1d961e53 100644 --- a/src/app.css +++ b/src/app.css @@ -92,10 +92,18 @@ select { visibility: hidden; } +.scrollbar-hidden::-webkit-scrollbar-corner { + display: none; +} + .scrollbar-none::-webkit-scrollbar { display: none; /* for Chrome, Safari and Opera */ } +.scrollbar-none::-webkit-scrollbar-corner { + display: none; +} + .scrollbar-none { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ @@ -111,3 +119,16 @@ input::-webkit-inner-spin-button { input[type='number'] { -moz-appearance: textfield; /* Firefox */ } + +.cm-editor { + height: 100%; + width: 100%; +} + +.cm-scroller { + @apply scrollbar-hidden; +} + +.cm-editor.cm-focused { + outline: none; +} diff --git a/src/app.html b/src/app.html index 138fb2829d..a79343df53 100644 --- a/src/app.html +++ b/src/app.html @@ -32,6 +32,9 @@ } else if (localStorage.theme && localStorage.theme === 'system') { systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches; document.documentElement.classList.add(systemTheme ? 'dark' : 'light'); + } else if (localStorage.theme && localStorage.theme === 'her') { + document.documentElement.classList.add('dark'); + document.documentElement.classList.add('her'); } else { document.documentElement.classList.add('dark'); } @@ -59,15 +62,7 @@
+
+ + +
+
+ +
+
+
+
+ + diff --git a/src/lib/apis/audio/index.ts b/src/lib/apis/audio/index.ts index 7bd8981fec..9716c552a7 100644 --- a/src/lib/apis/audio/index.ts +++ b/src/lib/apis/audio/index.ts @@ -98,7 +98,7 @@ export const synthesizeOpenAISpeech = async ( token: string = '', speaker: string = 'alloy', text: string = '', - model: string = 'tts-1' + model?: string ) => { let error = null; @@ -109,9 +109,9 @@ export const synthesizeOpenAISpeech = async ( 'Content-Type': 'application/json' }, body: JSON.stringify({ - model: model, input: text, - voice: speaker + voice: speaker, + ...(model && { model }) }) }) .then(async (res) => { diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 26feb29b6e..e202115b4a 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -1,5 +1,87 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; +export const getAdminDetails = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/details`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getAdminConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateAdminConfig = async (token: string, body: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/auths/admin/config`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify(body) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getSessionUser = async (token: string) => { let error = null; diff --git a/src/lib/apis/chats/index.ts b/src/lib/apis/chats/index.ts index 834e29d296..b046f1b10d 100644 --- a/src/lib/apis/chats/index.ts +++ b/src/lib/apis/chats/index.ts @@ -162,6 +162,37 @@ export const getAllChats = async (token: string) => { return res; }; +export const getAllArchivedChats = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/all/archived`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const getAllUserChats = async (token: string) => { let error = null; @@ -325,6 +356,44 @@ export const getChatByShareId = async (token: string, share_id: string) => { return res; }; +export const cloneChatById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err; + + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const shareChatById = async (token: string, id: string) => { let error = null; diff --git a/src/lib/apis/documents/index.ts b/src/lib/apis/documents/index.ts index 21e0a26431..9d42feb19f 100644 --- a/src/lib/apis/documents/index.ts +++ b/src/lib/apis/documents/index.ts @@ -76,7 +76,10 @@ export const getDocs = async (token: string = '') => { export const getDocByName = async (token: string, name: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/documents/name/${name}`, { + const searchParams = new URLSearchParams(); + searchParams.append('name', name); + + const res = await fetch(`${WEBUI_API_BASE_URL}/documents/docs?${searchParams.toString()}`, { method: 'GET', headers: { Accept: 'application/json', @@ -113,7 +116,10 @@ type DocUpdateForm = { export const updateDocByName = async (token: string, name: string, form: DocUpdateForm) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/documents/name/${name}/update`, { + const searchParams = new URLSearchParams(); + searchParams.append('name', name); + + const res = await fetch(`${WEBUI_API_BASE_URL}/documents/doc/update?${searchParams.toString()}`, { method: 'POST', headers: { Accept: 'application/json', @@ -154,7 +160,10 @@ type TagDocForm = { export const tagDocByName = async (token: string, name: string, form: TagDocForm) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/documents/name/${name}/tags`, { + const searchParams = new URLSearchParams(); + searchParams.append('name', name); + + const res = await fetch(`${WEBUI_API_BASE_URL}/documents/doc/tags?${searchParams.toString()}`, { method: 'POST', headers: { Accept: 'application/json', @@ -190,7 +199,10 @@ export const tagDocByName = async (token: string, name: string, form: TagDocForm export const deleteDocByName = async (token: string, name: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/documents/name/${name}/delete`, { + const searchParams = new URLSearchParams(); + searchParams.append('name', name); + + const res = await fetch(`${WEBUI_API_BASE_URL}/documents/doc/delete?${searchParams.toString()}`, { method: 'DELETE', headers: { Accept: 'application/json', diff --git a/src/lib/apis/index.ts b/src/lib/apis/index.ts index 63a0c21b95..c40815611e 100644 --- a/src/lib/apis/index.ts +++ b/src/lib/apis/index.ts @@ -29,8 +29,24 @@ export const getModels = async (token: string = '') => { models = models .filter((models) => models) + // Sort the models .sort((a, b) => { - // Compare case-insensitively + // Check if models have position property + const aHasPosition = a.info?.meta?.position !== undefined; + const bHasPosition = b.info?.meta?.position !== undefined; + + // If both a and b have the position property + if (aHasPosition && bHasPosition) { + return a.info.meta.position - b.info.meta.position; + } + + // If only a has the position property, it should come first + if (aHasPosition) return -1; + + // If only b has the position property, it should come first + if (bHasPosition) return 1; + + // Compare case-insensitively by name for models without position property const lowerA = a.name.toLowerCase(); const lowerB = b.name.toLowerCase(); @@ -39,8 +55,8 @@ export const getModels = async (token: string = '') => { // If same case-insensitively, sort by original strings, // lowercase will come before uppercase due to ASCII values - if (a < b) return -1; - if (a > b) return 1; + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; return 0; // They are equal }); @@ -88,6 +104,147 @@ export const chatCompleted = async (token: string, body: ChatCompletedForm) => { return res; }; +export const getTaskConfig = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/task/config`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + error = err; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateTaskConfig = async (token: string, config: object) => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/task/config/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...(token && { authorization: `Bearer ${token}` }) + }, + body: JSON.stringify(config) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const generateTitle = async ( + token: string = '', + model: string, + prompt: string, + chat_id?: string +) => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/task/title/completions`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + model: model, + prompt: prompt, + ...(chat_id && { chat_id: chat_id }) + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + if ('detail' in err) { + error = err.detail; + } + return null; + }); + + if (error) { + throw error; + } + + return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat'; +}; + +export const generateSearchQuery = async ( + token: string = '', + model: string, + messages: object[], + prompt: string +) => { + let error = null; + + const res = await fetch(`${WEBUI_BASE_URL}/api/task/query/completions`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + model: model, + messages: messages, + prompt: prompt + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + if ('detail' in err) { + error = err.detail; + } + return null; + }); + + if (error) { + throw error; + } + + return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? prompt; +}; + export const getPipelinesList = async (token: string = '') => { let error = null; @@ -117,6 +274,43 @@ export const getPipelinesList = async (token: string = '') => { return pipelines; }; +export const uploadPipeline = async (token: string, file: File, urlIdx: string) => { + let error = null; + + // Create a new FormData object to handle the file upload + const formData = new FormData(); + formData.append('file', file); + formData.append('urlIdx', urlIdx); + + const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/upload`, { + method: 'POST', + headers: { + ...(token && { authorization: `Bearer ${token}` }) + // 'Content-Type': 'multipart/form-data' is not needed as Fetch API will set it automatically + }, + body: formData + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + if ('detail' in err) { + error = err.detail; + } else { + error = err; + } + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const downloadPipeline = async (token: string, url: string, urlIdx: string) => { let error = null; diff --git a/src/lib/apis/memories/index.ts b/src/lib/apis/memories/index.ts index 6cbb89f143..44b24e2937 100644 --- a/src/lib/apis/memories/index.ts +++ b/src/lib/apis/memories/index.ts @@ -3,7 +3,7 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; export const getMemories = async (token: string) => { let error = null; - const res = await fetch(`${WEBUI_API_BASE_URL}/memories`, { + const res = await fetch(`${WEBUI_API_BASE_URL}/memories/`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/src/lib/apis/ollama/index.ts b/src/lib/apis/ollama/index.ts index 7bd0ed20a1..084d2d5f18 100644 --- a/src/lib/apis/ollama/index.ts +++ b/src/lib/apis/ollama/index.ts @@ -1,5 +1,5 @@ import { OLLAMA_API_BASE_URL } from '$lib/constants'; -import { promptTemplate } from '$lib/utils'; +import { titleGenerationTemplate } from '$lib/utils'; export const getOllamaConfig = async (token: string = '') => { let error = null; @@ -212,7 +212,7 @@ export const generateTitle = async ( ) => { let error = null; - template = promptTemplate(template, prompt); + template = titleGenerationTemplate(template, prompt); console.log(template); @@ -369,42 +369,29 @@ export const generateChatCompletion = async (token: string = '', body: object) = return [res, controller]; }; -export const cancelOllamaRequest = async (token: string = '', requestId: string) => { +export const createModel = async ( + token: string, + tagName: string, + content: string, + urlIdx: string | null = null +) => { let error = null; - const res = await fetch(`${OLLAMA_API_BASE_URL}/cancel/${requestId}`, { - method: 'GET', - headers: { - 'Content-Type': 'text/event-stream', - Authorization: `Bearer ${token}` + const res = await fetch( + `${OLLAMA_API_BASE_URL}/api/create${urlIdx !== null ? `/${urlIdx}` : ''}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + name: tagName, + modelfile: content + }) } - }).catch((err) => { - error = err; - return null; - }); - - if (error) { - throw error; - } - - return res; -}; - -export const createModel = async (token: string, tagName: string, content: string) => { - let error = null; - - const res = await fetch(`${OLLAMA_API_BASE_URL}/api/create`, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - Authorization: `Bearer ${token}` - }, - body: JSON.stringify({ - name: tagName, - modelfile: content - }) - }).catch((err) => { + ).catch((err) => { error = err; return null; }); @@ -461,8 +448,10 @@ export const deleteModel = async (token: string, tagName: string, urlIdx: string export const pullModel = async (token: string, tagName: string, urlIdx: string | null = null) => { let error = null; + const controller = new AbortController(); const res = await fetch(`${OLLAMA_API_BASE_URL}/api/pull${urlIdx !== null ? `/${urlIdx}` : ''}`, { + signal: controller.signal, method: 'POST', headers: { Accept: 'application/json', @@ -485,7 +474,7 @@ export const pullModel = async (token: string, tagName: string, urlIdx: string | if (error) { throw error; } - return res; + return [res, controller]; }; export const downloadModel = async ( diff --git a/src/lib/apis/openai/index.ts b/src/lib/apis/openai/index.ts index ddeab495ca..2a52ebb320 100644 --- a/src/lib/apis/openai/index.ts +++ b/src/lib/apis/openai/index.ts @@ -1,5 +1,5 @@ import { OPENAI_API_BASE_URL } from '$lib/constants'; -import { promptTemplate } from '$lib/utils'; +import { titleGenerationTemplate } from '$lib/utils'; import { type Model, models, settings } from '$lib/stores'; export const getOpenAIConfig = async (token: string = '') => { @@ -336,11 +336,12 @@ export const generateTitle = async ( template: string, model: string, prompt: string, + chat_id?: string, url: string = OPENAI_API_BASE_URL ) => { let error = null; - template = promptTemplate(template, prompt); + template = titleGenerationTemplate(template, prompt); console.log(template); @@ -361,7 +362,9 @@ export const generateTitle = async ( ], stream: false, // Restricting the max tokens to 50 to avoid long titles - max_tokens: 50 + max_tokens: 50, + ...(chat_id && { chat_id: chat_id }), + title: true }) }) .then(async (res) => { diff --git a/src/lib/apis/rag/index.ts b/src/lib/apis/rag/index.ts index 15085260b3..ca68827a3b 100644 --- a/src/lib/apis/rag/index.ts +++ b/src/lib/apis/rag/index.ts @@ -359,6 +359,32 @@ export const scanDocs = async (token: string) => { return res; }; +export const resetUploadDir = async (token: string) => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/reset/uploads`, { + method: 'GET', + headers: { + Accept: 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const resetVectorDB = async (token: string) => { let error = null; @@ -415,6 +441,7 @@ export const getEmbeddingConfig = async (token: string) => { type OpenAIConfigForm = { key: string; url: string; + batch_size: number; }; type EmbeddingModelUpdateForm = { @@ -518,8 +545,10 @@ export const runWebSearch = async ( token: string, query: string, collection_name?: string -): Promise => { - return await fetch(`${RAG_API_BASE_URL}/web/search`, { +): Promise => { + let error = null; + + const res = await fetch(`${RAG_API_BASE_URL}/web/search`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -536,8 +565,15 @@ export const runWebSearch = async ( }) .catch((err) => { console.log(err); - return undefined; + error = err.detail; + return null; }); + + if (error) { + throw error; + } + + return res; }; export interface SearchDocument { diff --git a/src/lib/apis/tools/index.ts b/src/lib/apis/tools/index.ts new file mode 100644 index 0000000000..9c620e7b50 --- /dev/null +++ b/src/lib/apis/tools/index.ts @@ -0,0 +1,193 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const createNewTool = async (token: string, tool: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...tool + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getTools = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const exportTools = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/export`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getToolById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${id}`, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateToolById = async (token: string, id: string, tool: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...tool + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteToolById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${id}/delete`, { + method: 'DELETE', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + + console.log(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; diff --git a/src/lib/apis/utils/index.ts b/src/lib/apis/utils/index.ts index 3f28d9444d..f99ac56410 100644 --- a/src/lib/apis/utils/index.ts +++ b/src/lib/apis/utils/index.ts @@ -22,6 +22,39 @@ export const getGravatarUrl = async (email: string) => { return res; }; +export const formatPythonCode = async (code: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/utils/code/format`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code: code + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + console.log(err); + + error = err; + if (err.detail) { + error = err.detail; + } + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const downloadChatAsPDF = async (chat: object) => { let error = null; @@ -108,3 +141,39 @@ export const downloadDatabase = async (token: string) => { throw error; } }; + +export const downloadLiteLLMConfig = async (token: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/utils/litellm/config`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + }) + .then(async (response) => { + if (!response.ok) { + throw await response.json(); + } + return response.blob(); + }) + .then((blob) => { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'config.yaml'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + }) + .catch((err) => { + console.log(err); + error = err.detail; + return null; + }); + + if (error) { + throw error; + } +}; diff --git a/src/lib/components/admin/Settings.svelte b/src/lib/components/admin/Settings.svelte new file mode 100644 index 0000000000..5538a11cf5 --- /dev/null +++ b/src/lib/components/admin/Settings.svelte @@ -0,0 +1,390 @@ + + +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ {#if selectedTab === 'general'} + { + toast.success($i18n.t('Settings saved successfully!')); + }} + /> + {:else if selectedTab === 'users'} + { + toast.success($i18n.t('Settings saved successfully!')); + }} + /> + {:else if selectedTab === 'connections'} + { + toast.success($i18n.t('Settings saved successfully!')); + }} + /> + {:else if selectedTab === 'models'} + + {:else if selectedTab === 'documents'} + { + toast.success($i18n.t('Settings saved successfully!')); + }} + /> + {:else if selectedTab === 'web'} + { + toast.success($i18n.t('Settings saved successfully!')); + + await tick(); + await config.set(await getBackendConfig()); + }} + /> + {:else if selectedTab === 'interface'} + { + toast.success($i18n.t('Settings saved successfully!')); + }} + /> + {:else if selectedTab === 'audio'} +
+
diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte new file mode 100644 index 0000000000..d38402aa05 --- /dev/null +++ b/src/lib/components/admin/Settings/Audio.svelte @@ -0,0 +1,302 @@ + + +
{ + await updateConfigHandler(); + dispatch('save'); + }} +> +
+
+
+
{$i18n.t('STT Settings')}
+ +
+
{$i18n.t('Speech-to-Text Engine')}
+
+ +
+
+ + {#if STT_ENGINE === 'openai'} +
+
+ + + +
+
+ +
+ +
+
{$i18n.t('STT Model')}
+
+
+ + + + +
+
+
+ {/if} +
+ +
+ +
+
{$i18n.t('TTS Settings')}
+ +
+
{$i18n.t('Text-to-Speech Engine')}
+
+ +
+
+ + {#if TTS_ENGINE === 'openai'} +
+
+ + + +
+
+ {/if} + +
+ + {#if TTS_ENGINE === ''} +
+
{$i18n.t('TTS Voice')}
+
+
+ +
+
+
+ {:else if TTS_ENGINE === 'openai'} +
+
+
{$i18n.t('TTS Voice')}
+
+
+ + + + {#each voices as voice} + +
+
+
+
+
{$i18n.t('TTS Model')}
+
+
+ + + + {#each models as model} + +
+
+
+
+ {/if} +
+
+
+
+ +
+
diff --git a/src/lib/components/admin/Settings/Banners.svelte b/src/lib/components/admin/Settings/Banners.svelte deleted file mode 100644 index e69a8ebb12..0000000000 --- a/src/lib/components/admin/Settings/Banners.svelte +++ /dev/null @@ -1,137 +0,0 @@ - - -
{ - updateBanners(); - saveHandler(); - }} -> -
-
-
-
- {$i18n.t('Banners')} -
- - -
-
- {#each banners as banner, bannerIdx} -
-
- - - - -
- - - -
-
- - -
- {/each} -
-
-
-
- -
-
diff --git a/src/lib/components/chat/Settings/Connections.svelte b/src/lib/components/admin/Settings/Connections.svelte similarity index 87% rename from src/lib/components/chat/Settings/Connections.svelte rename to src/lib/components/admin/Settings/Connections.svelte index 35345a76f5..669fe8aae0 100644 --- a/src/lib/components/chat/Settings/Connections.svelte +++ b/src/lib/components/admin/Settings/Connections.svelte @@ -1,6 +1,6 @@ @@ -190,7 +220,7 @@ saveHandler(); }} > -
+
{$i18n.t('General Settings')}
@@ -282,6 +312,30 @@ required />
+
+
{$i18n.t('Embedding Batch Size')}
+
+ +
+
+ +
+
{/if}
@@ -303,7 +357,7 @@
-
+
@@ -321,10 +375,8 @@ {#if !embeddingModel} {/if} - {#each $models.filter((m) => m.id && !m.external) as model} - + {#each $models.filter((m) => m.id && m.ollama && !(m?.preset ?? false)) as model} + {/each}
@@ -469,99 +521,319 @@ {/if}
-
+
- {#if showResetConfirm} -
-
- - - +
{$i18n.t('Query Params')}
+ +
+
+
{$i18n.t('Top K')}
+ +
+ - - {$i18n.t('Are you sure?')} +
-
+ {#if querySettings.hybrid === true} +
+
+ {$i18n.t('Minimum Score')} +
+ +
+ +
+
+ {/if} +
+ + {#if querySettings.hybrid === true} +
+ {$i18n.t( + 'Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.' + )} +
+ +
+ {/if} + +
+
{$i18n.t('RAG Template')}
+