فهرست منبع

Merge remote-tracking branch 'upstream/dev' into feat/oauth

Jun Siang Cheah 11 ماه پیش
والد
کامیت
ae376ec8fe
100فایلهای تغییر یافته به همراه10296 افزوده شده و 2600 حذف شده
  1. 1 1
      .github/pull_request_template.md
  2. 6 0
      .github/workflows/docker-build.yaml
  3. 0 1
      .github/workflows/release-pypi.yml
  4. 109 0
      CHANGELOG.md
  5. 77 0
      CODE_OF_CONDUCT.md
  6. 1 1
      Dockerfile
  7. 15 69
      README.md
  8. 170 423
      backend/apps/ollama/main.py
  9. 95 43
      backend/apps/openai/main.py
  10. 297 48
      backend/apps/rag/main.py
  11. 37 0
      backend/apps/rag/search/brave.py
  12. 45 0
      backend/apps/rag/search/google_pse.py
  13. 9 0
      backend/apps/rag/search/main.py
  14. 83 0
      backend/apps/rag/search/searxng.py
  15. 39 0
      backend/apps/rag/search/serper.py
  16. 43 0
      backend/apps/rag/search/serpstack.py
  17. 998 0
      backend/apps/rag/search/testdata/brave.json
  18. 442 0
      backend/apps/rag/search/testdata/google_pse.json
  19. 476 0
      backend/apps/rag/search/testdata/searxng.json
  20. 190 0
      backend/apps/rag/search/testdata/serper.json
  21. 276 0
      backend/apps/rag/search/testdata/serpstack.json
  22. 28 6
      backend/apps/rag/utils.py
  23. 132 0
      backend/apps/socket/main.py
  24. 7 0
      backend/apps/webui/main.py
  25. 9 0
      backend/apps/webui/models/chats.py
  26. 60 45
      backend/apps/webui/routers/auths.py
  27. 39 0
      backend/apps/webui/routers/chats.py
  28. 0 1
      backend/apps/webui/routers/models.py
  29. 6 1
      backend/apps/webui/routers/users.py
  30. 9 0
      backend/apps/webui/routers/utils.py
  31. 101 0
      backend/config.py
  32. 8 0
      backend/constants.py
  33. 472 20
      backend/main.py
  34. 27 5
      backend/utils/misc.py
  35. BIN
      demo.gif
  36. 1 0
      docs/CONTRIBUTING.md
  37. 1198 100
      package-lock.json
  38. 4 1
      package.json
  39. 82 0
      src/lib/apis/auths/index.ts
  40. 69 0
      src/lib/apis/chats/index.ts
  41. 312 3
      src/lib/apis/index.ts
  42. 27 38
      src/lib/apis/ollama/index.ts
  43. 87 25
      src/lib/apis/openai/index.ts
  44. 68 0
      src/lib/apis/rag/index.ts
  45. 15 1
      src/lib/apis/streaming/index.ts
  46. 36 0
      src/lib/apis/utils/index.ts
  47. 37 4
      src/lib/components/admin/Settings/Database.svelte
  48. 82 161
      src/lib/components/admin/Settings/General.svelte
  49. 405 0
      src/lib/components/admin/Settings/Pipelines.svelte
  50. 37 4
      src/lib/components/admin/SettingsModal.svelte
  51. 307 119
      src/lib/components/chat/Chat.svelte
  52. 545 544
      src/lib/components/chat/MessageInput.svelte
  53. 75 0
      src/lib/components/chat/MessageInput/InputMenu.svelte
  54. 5 5
      src/lib/components/chat/Messages.svelte
  55. 1 1
      src/lib/components/chat/Messages/CodeBlock.svelte
  56. 3 0
      src/lib/components/chat/Messages/CompareMessages.svelte
  57. 1 1
      src/lib/components/chat/Messages/Placeholder.svelte
  58. 93 30
      src/lib/components/chat/Messages/ResponseMessage.svelte
  59. 62 0
      src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte
  60. 1 1
      src/lib/components/chat/Messages/Skeleton.svelte
  61. 2 2
      src/lib/components/chat/Messages/UserMessage.svelte
  62. 128 95
      src/lib/components/chat/ModelSelector/Selector.svelte
  63. 92 1
      src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte
  64. 35 11
      src/lib/components/chat/Settings/Audio.svelte
  65. 159 31
      src/lib/components/chat/Settings/Connections.svelte
  66. 68 22
      src/lib/components/chat/Settings/Interface.svelte
  67. 593 413
      src/lib/components/chat/Settings/Models.svelte
  68. 1 1
      src/lib/components/common/Banner.svelte
  69. 2 0
      src/lib/components/common/Dropdown.svelte
  70. 1 1
      src/lib/components/common/Tags.svelte
  71. 20 18
      src/lib/components/common/Tags/TagInput.svelte
  72. 4 3
      src/lib/components/common/Tags/TagList.svelte
  73. 4 0
      src/lib/components/common/Tooltip.svelte
  74. 207 74
      src/lib/components/documents/Settings/General.svelte
  75. 223 40
      src/lib/components/documents/Settings/WebParams.svelte
  76. 7 2
      src/lib/components/documents/SettingsModal.svelte
  77. 19 0
      src/lib/components/icons/ArrowDownTray.svelte
  78. 15 0
      src/lib/components/icons/ChevronUp.svelte
  79. 14 0
      src/lib/components/icons/DocumentArrowUpSolid.svelte
  80. 19 0
      src/lib/components/icons/DocumentDuplicate.svelte
  81. 19 0
      src/lib/components/icons/EllipsisHorizontal.svelte
  82. 9 0
      src/lib/components/icons/GlobeAltSolid.svelte
  83. 19 0
      src/lib/components/icons/Keyboard.svelte
  84. 19 0
      src/lib/components/icons/Lifebuoy.svelte
  85. 19 0
      src/lib/components/icons/QuestionMarkCircle.svelte
  86. 40 0
      src/lib/components/layout/Help.svelte
  87. 60 0
      src/lib/components/layout/Help/HelpMenu.svelte
  88. 1 1
      src/lib/components/layout/Navbar.svelte
  89. 15 1
      src/lib/components/layout/Navbar/Menu.svelte
  90. 59 0
      src/lib/components/layout/Overlay/AccountPending.svelte
  91. 30 2
      src/lib/components/layout/Sidebar.svelte
  92. 7 1
      src/lib/components/layout/Sidebar/ArchivedChatsModal.svelte
  93. 20 8
      src/lib/components/layout/Sidebar/ChatMenu.svelte
  94. 34 3
      src/lib/components/layout/Sidebar/UserMenu.svelte
  95. 148 77
      src/lib/components/workspace/Models.svelte
  96. 144 0
      src/lib/components/workspace/Models/ModelMenu.svelte
  97. 1 13
      src/lib/components/workspace/Playground.svelte
  98. 2 1
      src/lib/constants.ts
  99. 91 39
      src/lib/i18n/locales/ar-BH/translation.json
  100. 86 38
      src/lib/i18n/locales/bg-BG/translation.json

+ 1 - 1
.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

+ 6 - 0
.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

+ 0 - 1
.github/workflows/release-pypi.yml

@@ -4,7 +4,6 @@ on:
   push:
     branches:
       - main # or whatever branch you want to use
-      - dev
 
 jobs:
   release:

+ 109 - 0
CHANGELOG.md

@@ -5,6 +5,115 @@ 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.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

+ 77 - 0
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.

+ 1 - 1
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"]

+ 15 - 69
README.md

@@ -11,97 +11,43 @@
 [![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)
 
-## Features ⭐
+## Key Features of Open WebUI
 
-- 🖥️ **Intuitive Interface**: Our chat interface takes inspiration from ChatGPT, ensuring a user-friendly experience.
+- 🚀 **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.
 
-- 📱 **Responsive Design**: Enjoy a seamless experience on both desktop and mobile devices.
+- 🤝 **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**.
 
-- ⚡ **Swift Responsiveness**: Enjoy fast and responsive performance.
+- 🧩 **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.
 
-- 🚀 **Effortless Setup**: Install seamlessly using Docker or Kubernetes (kubectl, kustomize or helm) for a hassle-free experience.
+- 📱 **Responsive Design**: Enjoy a seamless experience across Desktop PC, Laptop, and Mobile devices.
 
-- 🌈 **Theme Customization**: Choose from a variety of themes to personalize your Open WebUI experience.
-
-- 💻 **Code Syntax Highlighting**: Enjoy enhanced code readability with our syntax highlighting feature.
+- 📱 **Progressive Web App (PWA) for Mobile**: Enjoy a native app-like experience on your mobile device with our PWA, providing offline access on localhost and a seamless user interface.
 
 - ✒️🔢 **Full Markdown and LaTeX Support**: Elevate your LLM experience with comprehensive Markdown and LaTeX capabilities for enriched interaction.
 
-- 📚 **Local RAG Integration**: Dive into the future of chat interactions with the 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 `#` command in the prompt. In its alpha phase, occasional issues may arise as we actively refine and enhance this feature to ensure optimal performance and reliability.
-
-- 🔍 **RAG Embedding Support**: Change the RAG embedding model directly in document settings, enhancing document processing. This feature supports Ollama and OpenAI models.
-
-- 🌐 **Web Browsing Capability**: Seamlessly integrate websites into your chat experience using the `#` command followed by the URL. This feature allows you to incorporate web content directly into your conversations, enhancing the richness and depth of your interactions.
-
-- 📜 **Prompt Preset Support**: Instantly access preset prompts using the `/` command in the chat input. Load predefined conversation starters effortlessly and expedite your interactions. Effortlessly import prompts through [Open WebUI Community](https://openwebui.com/) integration.
-
-- 👍👎 **RLHF Annotation**: Empower your messages by rating them with thumbs up and thumbs down, followed by the option to provide textual feedback, facilitating the creation of datasets for Reinforcement Learning from Human Feedback (RLHF). Utilize your messages to train or fine-tune models, all while ensuring the confidentiality of locally saved data.
-
-- 🏷️ **Conversation Tagging**: Effortlessly categorize and locate specific chats for quick reference and streamlined data collection.
-
-- 📥🗑️ **Download/Delete Models**: Easily download or remove models directly from the web UI.
-
-- 🔄 **Update All Ollama Models**: Easily update locally installed models all at once with a convenient button, streamlining model management.
+- 🛠️ **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.
 
-- ⬆️ **GGUF File Model Creation**: Effortlessly create Ollama models by uploading GGUF files directly from the web UI. Streamlined process with options to upload from your machine or download GGUF files from Hugging Face.
+- 📚 **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.
 
-- 🤖 **Multiple Model Support**: Seamlessly switch between different chat models for diverse interactions.
+- 🔍 **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.
 
-- 🔄 **Multi-Modal Support**: Seamlessly engage with models that support multimodal interactions, including images (e.g., LLava).
+- 🌐 **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.
 
-- 🧩 **Modelfile Builder**: Easily create Ollama modelfiles via the web UI. Create and add characters/agents, customize chat elements, and import modelfiles effortlessly through [Open WebUI Community](https://openwebui.com/) integration.
+- 🎨 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using options such as AUTOMATIC1111 API or ComfyUI (local), and OpenAI's DALL-E (external), enriching your chat experience with dynamic visual content.
 
 - ⚙️ **Many Models Conversations**: Effortlessly engage with various models simultaneously, harnessing their unique strengths for optimal responses. Enhance your experience by leveraging a diverse set of models in parallel.
 
-- 💬 **Collaborative Chat**: Harness the collective intelligence of multiple models by seamlessly orchestrating group conversations. Use the `@` command to specify the model, enabling dynamic and diverse dialogues within your chat interface. Immerse yourself in the collective intelligence woven into your chat environment.
-
-- 🗨️ **Local Chat Sharing**: Generate and share chat links seamlessly between users, enhancing collaboration and communication.
-
-- 🔄 **Regeneration History Access**: Easily revisit and explore your entire regeneration history.
-
-- 📜 **Chat History**: Effortlessly access and manage your conversation history.
-
-- 📬 **Archive Chats**: Effortlessly store away completed conversations with LLMs for future reference, maintaining a tidy and clutter-free chat interface while allowing for easy retrieval and reference.
-
-- 📤📥 **Import/Export Chat History**: Seamlessly move your chat data in and out of the platform.
-
-- 🗣️ **Voice Input Support**: Engage with your model through voice interactions; enjoy the convenience of talking to your model directly. Additionally, explore the option for sending voice input automatically after 3 seconds of silence for a streamlined experience.
-
-- 🔊 **Configurable Text-to-Speech Endpoint**: Customize your Text-to-Speech experience with configurable OpenAI endpoints.
-
-- ⚙️ **Fine-Tuned Control with Advanced Parameters**: Gain a deeper level of control by adjusting parameters such as temperature and defining your system prompts to tailor the conversation to your specific preferences and needs.
-
-- 🎨🤖 **Image Generation Integration**: Seamlessly incorporate image generation capabilities using options such as AUTOMATIC1111 API (local), ComfyUI (local), and DALL-E, enriching your chat experience with dynamic visual content.
-
-- 🤝 **OpenAI API Integration**: Effortlessly integrate OpenAI-compatible API for versatile conversations alongside Ollama models. Customize the API Base URL to link with **LMStudio, Mistral, OpenRouter, and more**.
-
-- ✨ **Multiple OpenAI-Compatible API Support**: Seamlessly integrate and customize various OpenAI-compatible APIs, enhancing the versatility of your chat interactions.
-
-- 🔑 **API Key Generation Support**: Generate secret keys to leverage Open WebUI with OpenAI libraries, simplifying integration and development.
-
-- 🔗 **External Ollama Server Connection**: Seamlessly link to an external Ollama server hosted on a different address by configuring the environment variable.
-
-- 🔀 **Multiple Ollama Instance Load Balancing**: Effortlessly distribute chat requests across multiple Ollama instances for enhanced performance and reliability.
-
-- 👥 **Multi-User Management**: Easily oversee and administer users via our intuitive admin panel, streamlining user management processes.
-
-- 🔗 **Webhook Integration**: Subscribe to new user sign-up events via webhook (compatible with Google Chat and Microsoft Teams), providing real-time notifications and automation capabilities.
-
-- 🛡️ **Model Whitelisting**: Admins can whitelist models for users with the 'user' role, enhancing security and access control.
-
-- 📧 **Trusted Email Authentication**: Authenticate using a trusted email header, adding an additional layer of security and authentication.
-
 - 🔐 **Role-Based Access Control (RBAC)**: Ensure secure access with restricted permissions; only authorized individuals can access your Ollama, and exclusive model creation/pulling rights are reserved for administrators.
 
-- 🔒 **Backend Reverse Proxy Support**: Bolster security through direct communication between Open WebUI backend and Ollama. This key feature eliminates the need to expose Ollama over LAN. Requests made to the '/ollama/api' route from the web UI are seamlessly redirected to Ollama from the backend, enhancing overall system security.
-
 - 🌐🌍 **Multilingual Support**: Experience Open WebUI in your preferred language with our internationalization (i18n) support. Join us in expanding our supported languages! We're actively seeking contributors!
 
-- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates and new features.
+- 🌟 **Continuous Updates**: We are committed to improving Open WebUI with regular updates, fixes, and new features.
+
+Want to learn more about Open WebUI's features? Check out our [Open WebUI documentation](https://docs.openwebui.com/features) for a comprehensive overview!
 
 ## 🔗 Also Check Out Open WebUI Community!
 

+ 170 - 423
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
@@ -75,9 +77,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 +131,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 +143,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 = {}
 
@@ -219,6 +247,8 @@ async def get_ollama_tags(
         return models
     else:
         url = app.state.config.OLLAMA_BASE_URLS[url_idx]
+
+        r = None
         try:
             r = requests.request(method="GET", url=f"{url}/api/tags")
             r.raise_for_status()
@@ -244,52 +274,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:
+
+            # 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 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))
-
-        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,
-            )
-    else:
-        url = app.state.config.OLLAMA_BASE_URLS[url_idx]
-        try:
-            r = requests.request(method="GET", url=f"{url}/api/version")
-            r.raise_for_status()
+            url = app.state.config.OLLAMA_BASE_URLS[url_idx]
 
-            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}"
+            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,
-            )
+                raise HTTPException(
+                    status_code=r.status_code if r else 500,
+                    detail=error_detail,
+                )
+    else:
+        return {"version": False}
 
 
 class ModelNameForm(BaseModel):
@@ -309,65 +344,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):
@@ -395,50 +372,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):
@@ -457,53 +393,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):
@@ -793,66 +685,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):
@@ -902,44 +737,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
+                )
+
+            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):
+                payload["options"]["temperature"] = model_info.params.get(
+                    "temperature", None
+                )
 
-            payload["options"]["top_p"] = model_info.params.get("top_p", 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
@@ -977,67 +845,7 @@ 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
@@ -1128,68 +936,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")
@@ -1301,7 +1048,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
 
@@ -1518,7 +1265,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":

+ 95 - 43
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
@@ -184,19 +185,26 @@ async def speech(request: Request, user=Depends(get_verified_user)):
 async def fetch_url(url, key):
     timeout = aiohttp.ClientTimeout(total=5)
     try:
-        if key != "":
-            headers = {"Authorization": f"Bearer {key}"}
-            async with aiohttp.ClientSession(timeout=timeout) as session:
-                async with session.get(url, headers=headers) as response:
-                    return await response.json()
-        else:
-            return None
+        headers = {"Authorization": f"Bearer {key}"}
+        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:
         # Handle connection error here
         log.error(f"Connection error: {e}")
         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 = []
@@ -222,7 +230,7 @@ def merge_models_lists(model_lists):
     return merged_list
 
 
-async def get_all_models():
+async def get_all_models(raw: bool = False):
     log.info("get_all_models()")
 
     if (
@@ -231,6 +239,27 @@ async def get_all_models():
     ) 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)
@@ -239,6 +268,9 @@ async def get_all_models():
         responses = await asyncio.gather(*tasks)
         log.debug(f"get_all_models:responses() {responses}")
 
+        if raw:
+            return responses
+
         models = {
             "data": merge_models_lists(
                 list(
@@ -277,11 +309,16 @@ async def get_models(url_idx: Optional[int] = None, user=Depends(get_current_use
         return models
     else:
         url = app.state.config.OPENAI_API_BASE_URLS[url_idx]
+        key = app.state.config.OPENAI_API_KEYS[url_idx]
+
+        headers = {}
+        headers["Authorization"] = f"Bearer {key}"
+        headers["Content-Type"] = "application/json"
 
         r = None
 
         try:
-            r = requests.request(method="GET", url=f"{url}/models")
+            r = requests.request(method="GET", url=f"{url}/models", headers=headers)
             r.raise_for_status()
 
             response_data = r.json()
@@ -336,21 +373,36 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
                 model_info.params = model_info.params.model_dump()
 
                 if model_info.params:
-                    payload["temperature"] = model_info.params.get("temperature", None)
-                    payload["top_p"] = model_info.params.get("top_p", None)
-                    payload["max_tokens"] = model_info.params.get("max_tokens", None)
-                    payload["frequency_penalty"] = model_info.params.get(
-                        "frequency_penalty", None
-                    )
-                    payload["seed"] = model_info.params.get("seed", 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("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
@@ -374,18 +426,12 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
             else:
                 pass
 
-            print(app.state.MODELS)
             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
@@ -407,47 +453,53 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
 
     target_url = f"{url}/{path}"
 
-    if key == "":
-        raise HTTPException(status_code=401, detail=ERROR_MESSAGES.API_KEY_NOT_FOUND)
-
     headers = {}
     headers["Authorization"] = f"Bearer {key}"
     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,
             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()

+ 297 - 48
backend/apps/rag/main.py

@@ -11,7 +11,7 @@ from fastapi.middleware.cors import CORSMiddleware
 import os, shutil, logging, re
 
 from pathlib import Path
-from typing import List
+from typing import List, Union, Sequence
 
 from chromadb.utils.batch_utils import create_batches
 
@@ -61,6 +61,14 @@ from apps.rag.utils import (
     query_collection_with_hybrid_search,
 )
 
+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 utils.misc import (
     calculate_sha256,
     calculate_sha256_string,
@@ -70,6 +78,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,
@@ -95,7 +104,18 @@ from config import (
     RAG_TEMPLATE,
     ENABLE_RAG_LOCAL_WEB_FETCH,
     YOUTUBE_LOADER_LANGUAGE,
-    AppConfig,
+    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,
+    RAG_WEB_SEARCH_RESULT_COUNT,
+    RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
+    RAG_EMBEDDING_OPENAI_BATCH_SIZE,
 )
 
 from constants import ERROR_MESSAGES
@@ -120,6 +140,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
 
@@ -134,6 +155,20 @@ 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.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,
@@ -179,6 +214,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 = ["*"]
@@ -201,6 +237,10 @@ class UrlForm(CollectionNameForm):
     url: str
 
 
+class SearchForm(CollectionNameForm):
+    query: str
+
+
 @app.get("/")
 async def get_status():
     return {
@@ -211,6 +251,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,
     }
 
 
@@ -223,6 +264,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,
         },
     }
 
@@ -238,6 +280,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):
@@ -258,9 +301,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)
 
@@ -270,6 +318,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 {
@@ -279,6 +328,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:
@@ -326,11 +376,26 @@ 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,
+                "result_count": app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
+                "concurrent_requests": app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
+            },
+        },
     }
 
 
@@ -344,11 +409,30 @@ 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
+    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")
@@ -359,35 +443,36 @@ 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
-    )
-
-    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.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.YOUTUBE_LOADER_LANGUAGE = (
-        form_data.youtube.language
-        if form_data.youtube is not None
-        else app.state.config.YOUTUBE_LOADER_LANGUAGE
-    )
+    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.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.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,
@@ -396,11 +481,26 @@ 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,
+                "result_count": app.state.config.RAG_WEB_SEARCH_RESULT_COUNT,
+                "concurrent_requests": app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
+            },
+        },
     }
 
 
@@ -589,24 +689,40 @@ def store_web(form_data: UrlForm, user=Depends(get_current_user)):
         )
 
 
-def get_web_loader(url: str, verify_ssl: bool = True):
+def get_web_loader(url: Union[str, Sequence[str]], verify_ssl: bool = True):
     # Check if the URL is valid
-    if isinstance(validators.url(url), validators.ValidationError):
+    if not validate_url(url):
         raise ValueError(ERROR_MESSAGES.INVALID_URL)
-    if not ENABLE_RAG_LOCAL_WEB_FETCH:
-        # Local web fetch is disabled, filter out any URLs that resolve to private IP addresses
-        parsed_url = urllib.parse.urlparse(url)
-        # Get IPv4 and IPv6 addresses
-        ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)
-        # Check if any of the resolved addresses are private
-        # This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader
-        for ip in ipv4_addresses:
-            if validators.ipv4(ip, private=True):
-                raise ValueError(ERROR_MESSAGES.INVALID_URL)
-        for ip in ipv6_addresses:
-            if validators.ipv6(ip, private=True):
-                raise ValueError(ERROR_MESSAGES.INVALID_URL)
-    return WebBaseLoader(url, verify_ssl=verify_ssl)
+    return WebBaseLoader(
+        url,
+        verify_ssl=verify_ssl,
+        requests_per_second=RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
+        continue_on_failure=True,
+    )
+
+
+def validate_url(url: Union[str, Sequence[str]]):
+    if isinstance(url, str):
+        if isinstance(validators.url(url), validators.ValidationError):
+            raise ValueError(ERROR_MESSAGES.INVALID_URL)
+        if not ENABLE_RAG_LOCAL_WEB_FETCH:
+            # Local web fetch is disabled, filter out any URLs that resolve to private IP addresses
+            parsed_url = urllib.parse.urlparse(url)
+            # Get IPv4 and IPv6 addresses
+            ipv4_addresses, ipv6_addresses = resolve_hostname(parsed_url.hostname)
+            # Check if any of the resolved addresses are private
+            # This is technically still vulnerable to DNS rebinding attacks, as we don't control WebBaseLoader
+            for ip in ipv4_addresses:
+                if validators.ipv4(ip, private=True):
+                    raise ValueError(ERROR_MESSAGES.INVALID_URL)
+            for ip in ipv6_addresses:
+                if validators.ipv6(ip, private=True):
+                    raise ValueError(ERROR_MESSAGES.INVALID_URL)
+        return True
+    elif isinstance(url, Sequence):
+        return all(validate_url(u) for u in url)
+    else:
+        return False
 
 
 def resolve_hostname(hostname):
@@ -620,6 +736,114 @@ 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
+
+    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")
+    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:
+        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()
+
+        collection_name = form_data.collection_name
+        if collection_name == "":
+            collection_name = calculate_sha256_string(form_data.query)[:63]
+
+        store_data_in_vector_db(data, collection_name, overwrite=True)
+        return {
+            "status": True,
+            "collection_name": collection_name,
+            "filenames": urls,
+        }
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.DEFAULT(e),
+        )
+
+
 def store_data_in_vector_db(data, collection_name, overwrite: bool = False) -> bool:
 
     text_splitter = RecursiveCharacterTextSplitter(
@@ -670,6 +894,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))
@@ -939,6 +1164,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}"

+ 37 - 0
backend/apps/rag/search/brave.py

@@ -0,0 +1,37 @@
+import logging
+
+import requests
+
+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_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:
+        api_key (str): A Brave Search API key
+        query (str): The query to search for
+    """
+    url = "https://api.search.brave.com/res/v1/web/search"
+    headers = {
+        "Accept": "application/json",
+        "Accept-Encoding": "gzip",
+        "X-Subscription-Token": api_key,
+    }
+    params = {"q": query, "count": count}
+
+    response = requests.get(url, headers=headers, params=params)
+    response.raise_for_status()
+
+    json_response = response.json()
+    results = json_response.get("web", {}).get("results", [])
+    return [
+        SearchResult(
+            link=result["url"], title=result.get("title"), snippet=result.get("snippet")
+        )
+        for result in results[:count]
+    ]

+ 45 - 0
backend/apps/rag/search/google_pse.py

@@ -0,0 +1,45 @@
+import json
+import logging
+
+import requests
+
+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_google_pse(
+    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.
+
+    Args:
+        api_key (str): A Programmable Search Engine API key
+        search_engine_id (str): A Programmable Search Engine ID
+        query (str): The query to search for
+    """
+    url = "https://www.googleapis.com/customsearch/v1"
+
+    headers = {"Content-Type": "application/json"}
+    params = {
+        "cx": search_engine_id,
+        "q": query,
+        "key": api_key,
+        "num": count,
+    }
+
+    response = requests.request("GET", url, headers=headers, params=params)
+    response.raise_for_status()
+
+    json_response = response.json()
+    results = json_response.get("items", [])
+    return [
+        SearchResult(
+            link=result["link"],
+            title=result.get("title"),
+            snippet=result.get("snippet"),
+        )
+        for result in results
+    ]

+ 9 - 0
backend/apps/rag/search/main.py

@@ -0,0 +1,9 @@
+from typing import Optional
+
+from pydantic import BaseModel
+
+
+class SearchResult(BaseModel):
+    link: str
+    title: Optional[str]
+    snippet: Optional[str]

+ 83 - 0
backend/apps/rag/search/searxng.py

@@ -0,0 +1,83 @@
+import logging
+import requests
+
+from typing import List
+
+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_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 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.
+
+    Keyword Args:
+        language (str): Language filter for the search results; e.g., "en-US". Defaults to an empty string.
+        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")
+    time_range = kwargs.get("time_range", "")
+    categories = "".join(kwargs.get("categories", []))
+
+    params = {
+        "q": query,
+        "format": "json",
+        "pageno": 1,
+        "language": language,
+        "time_range": time_range,
+        "categories": categories,
+        "theme": "simple",
+        "image_proxy": 0,
+    }
+
+    # Legacy query format
+    if "<query>" 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",
+            "Accept-Encoding": "gzip, deflate",
+            "Accept-Language": "en-US,en;q=0.5",
+            "Connection": "keep-alive",
+        },
+        params=params,
+    )
+
+    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[:count]
+    ]

+ 39 - 0
backend/apps/rag/search/serper.py

@@ -0,0 +1,39 @@
+import json
+import logging
+
+import requests
+
+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_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:
+        api_key (str): A serper.dev API key
+        query (str): The query to search for
+    """
+    url = "https://google.serper.dev/search"
+
+    payload = json.dumps({"q": query})
+    headers = {"X-API-KEY": api_key, "Content-Type": "application/json"}
+
+    response = requests.request("POST", url, headers=headers, data=payload)
+    response.raise_for_status()
+
+    json_response = response.json()
+    results = sorted(
+        json_response.get("organic", []), key=lambda x: x.get("position", 0)
+    )
+    return [
+        SearchResult(
+            link=result["link"],
+            title=result.get("title"),
+            snippet=result.get("description"),
+        )
+        for result in results[:count]
+    ]

+ 43 - 0
backend/apps/rag/search/serpstack.py

@@ -0,0 +1,43 @@
+import json
+import logging
+
+import requests
+
+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_serpstack(
+    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.
+
+    Args:
+        api_key (str): A serpstack.com API key
+        query (str): The query to search for
+        https_enabled (bool): Whether to use HTTPS or HTTP for the API request
+    """
+    url = f"{'https' if https_enabled else 'http'}://api.serpstack.com/search"
+
+    headers = {"Content-Type": "application/json"}
+    params = {
+        "access_key": api_key,
+        "query": query,
+    }
+
+    response = requests.request("POST", url, headers=headers, params=params)
+    response.raise_for_status()
+
+    json_response = response.json()
+    results = sorted(
+        json_response.get("organic_results", []), key=lambda x: x.get("position", 0)
+    )
+    return [
+        SearchResult(
+            link=result["url"], title=result.get("title"), snippet=result.get("snippet")
+        )
+        for result in results[:count]
+    ]

+ 998 - 0
backend/apps/rag/search/testdata/brave.json

@@ -0,0 +1,998 @@
+{
+	"query": {
+		"original": "python",
+		"show_strict_warning": false,
+		"is_navigational": true,
+		"is_news_breaking": false,
+		"spellcheck_off": true,
+		"country": "us",
+		"bad_results": false,
+		"should_fallback": false,
+		"postal_code": "",
+		"city": "",
+		"header_country": "",
+		"more_results_available": true,
+		"state": ""
+	},
+	"mixed": {
+		"type": "mixed",
+		"main": [
+			{
+				"type": "web",
+				"index": 0,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 1,
+				"all": false
+			},
+			{
+				"type": "news",
+				"all": true
+			},
+			{
+				"type": "web",
+				"index": 2,
+				"all": false
+			},
+			{
+				"type": "videos",
+				"all": true
+			},
+			{
+				"type": "web",
+				"index": 3,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 4,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 5,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 6,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 7,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 8,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 9,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 10,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 11,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 12,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 13,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 14,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 15,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 16,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 17,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 18,
+				"all": false
+			},
+			{
+				"type": "web",
+				"index": 19,
+				"all": false
+			}
+		],
+		"top": [],
+		"side": []
+	},
+	"news": {
+		"type": "news",
+		"results": [
+			{
+				"title": "Google lays off staff from Flutter, Dart and Python teams weeks before its developer conference | TechCrunch",
+				"url": "https://techcrunch.com/2024/05/01/google-lays-off-staff-from-flutter-dart-python-weeks-before-its-developer-conference/",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Google told TechCrunch that Flutter will have new updates to share at I/O this year.",
+				"page_age": "2024-05-02T17:40:05",
+				"family_friendly": true,
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "techcrunch.com",
+					"hostname": "techcrunch.com",
+					"favicon": "https://imgs.search.brave.com/N6VSEVahheQOb7lqfb47dhUOB4XD-6sfQOP94sCe3Oo/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZGI5Njk0Yzlk/YWM3ZWMwZjg1MTM1/NmIyMWEyNzBjZDZj/ZDQyNmFlNGU0NDRi/MDgyYjQwOGU0Y2Qy/ZWMwNWQ2ZC90ZWNo/Y3J1bmNoLmNvbS8",
+					"path": "› 2024  › 05  › 01  › google-lays-off-staff-from-flutter-dart-python-weeks-before-its-developer-conference"
+				},
+				"breaking": false,
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/gCI5UG8muOEOZDAx9vpu6L6r6R00mD7jOF08-biFoyQ/rs:fit:200:200:1/g:ce/aHR0cHM6Ly90ZWNo/Y3J1bmNoLmNvbS93/cC1jb250ZW50L3Vw/bG9hZHMvMjAxOC8x/MS9HZXR0eUltYWdl/cy0xMDAyNDg0NzQ2/LmpwZz9yZXNpemU9/MTIwMCw4MDA"
+				},
+				"age": "3 days ago",
+				"extra_snippets": [
+					"Ahead of Google’s annual I/O developer conference in May, the tech giant has laid off staff across key teams like Flutter, Dart, Python and others, according to reports from affected employees shared on social media. Google confirmed the layoffs to TechCrunch, but not the specific teams, roles or how many people were let go.",
+					"In a separate post on Reddit, another commenter noted the Python team affected by the layoffs were those who managed the internal Python runtimes and toolchains and worked with OSS Python. Included in this group were “multiple current and former core devs and steering council members,” they said.",
+					"Meanwhile, others shared on Y Combinator’s Hacker News, where a Python team member detailed their specific duties on the technical front and noted that, for years, much of the work was done with fewer than 10 people. Another Hacker News commenter said their early years on the Python team were spent paying down internal technical debt accumulated from not having a strong Python strategy.",
+					"CNBC reports that a total of 200 people were let go across Google’s “Core” teams, which included those working on Python, app platforms, and other engineering roles. Some jobs were being shifted to India and Mexico, it said, citing internal documents."
+				]
+			}
+		],
+		"mutated_by_goggles": false
+	},
+	"type": "search",
+	"videos": {
+		"type": "videos",
+		"results": [
+			{
+				"type": "video_result",
+				"url": "https://www.youtube.com/watch?v=b093aqAZiPU",
+				"title": "👩‍💻 Python for Beginners Tutorial - YouTube",
+				"description": "In this step-by-step Python for beginner's tutorial, learn how you can get started programming in Python. In this video, I assume that you are completely new...",
+				"age": "March 25, 2021",
+				"page_age": "2021-03-25T10:00:08",
+				"video": {},
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "youtube.com",
+					"hostname": "www.youtube.com",
+					"favicon": "https://imgs.search.brave.com/Ux4Hee4evZhvjuTKwtapBycOGjGDci2Gvn2pbSzvbC0/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v",
+					"path": "› watch"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/tZI4Do4_EYcTCsD_MvE3Jx8FzjIXwIJ5ZuKhwiWTyZs/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9pLnl0/aW1nLmNvbS92aS9i/MDkzYXFBWmlQVS9t/YXhyZXNkZWZhdWx0/LmpwZw"
+				}
+			},
+			{
+				"type": "video_result",
+				"url": "https://www.youtube.com/watch?v=rfscVS0vtbw",
+				"title": "Learn Python - Full Course for Beginners [Tutorial] - YouTube",
+				"description": "This course will give you a full introduction into all of the core concepts in python. Follow along with the videos and you'll be a python programmer in no t...",
+				"age": "July 11, 2018",
+				"page_age": "2018-07-11T18:00:42",
+				"video": {},
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "youtube.com",
+					"hostname": "www.youtube.com",
+					"favicon": "https://imgs.search.brave.com/Ux4Hee4evZhvjuTKwtapBycOGjGDci2Gvn2pbSzvbC0/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v",
+					"path": "› watch"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/65zkx_kPU_zJb-4nmvvY-q5-ZZwzceChz-N00V8cqvk/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9pLnl0/aW1nLmNvbS92aS9y/ZnNjVlMwdnRidy9t/YXhyZXNkZWZhdWx0/LmpwZw"
+				}
+			},
+			{
+				"type": "video_result",
+				"url": "https://www.youtube.com/watch?v=_uQrJ0TkZlc",
+				"title": "Python Tutorial - Python Full Course for Beginners - YouTube",
+				"description": "Become a Python pro! 🚀 This comprehensive tutorial takes you from beginner to hero, covering the basics, machine learning, and web development projects.🚀 W...",
+				"age": "February 18, 2019",
+				"page_age": "2019-02-18T15:00:08",
+				"video": {},
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "youtube.com",
+					"hostname": "www.youtube.com",
+					"favicon": "https://imgs.search.brave.com/Ux4Hee4evZhvjuTKwtapBycOGjGDci2Gvn2pbSzvbC0/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v",
+					"path": "› watch"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/Djiv1pXLq1ClqBSE_86jQnEYR8bW8UJP6Cs7LrgyQzQ/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9pLnl0/aW1nLmNvbS92aS9f/dVFySjBUa1psYy9t/YXhyZXNkZWZhdWx0/LmpwZw"
+				}
+			},
+			{
+				"type": "video_result",
+				"url": "https://www.youtube.com/watch?v=wRKgzC-MhIc",
+				"title": "[] and {} vs list() and dict(), which is better?",
+				"description": "Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.",
+				"video": {},
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "youtube.com",
+					"hostname": "www.youtube.com",
+					"favicon": "https://imgs.search.brave.com/Ux4Hee4evZhvjuTKwtapBycOGjGDci2Gvn2pbSzvbC0/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v",
+					"path": "› watch"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/Hw9ep2Pio13X1VZjRw_h9R2VH_XvZFOuGlQJVnVkeq0/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9pLnl0/aW1nLmNvbS92aS93/UktnekMtTWhJYy9o/cWRlZmF1bHQuanBn"
+				}
+			},
+			{
+				"type": "video_result",
+				"url": "https://www.youtube.com/watch?v=LWdsF79H1Pg",
+				"title": "print() vs. return in Python Functions - YouTube",
+				"description": "In this video, you will learn the differences between the return statement and the print function when they are used inside Python functions. We will see an ...",
+				"age": "June 11, 2022",
+				"page_age": "2022-06-11T21:33:26",
+				"video": {},
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "youtube.com",
+					"hostname": "www.youtube.com",
+					"favicon": "https://imgs.search.brave.com/Ux4Hee4evZhvjuTKwtapBycOGjGDci2Gvn2pbSzvbC0/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v",
+					"path": "› watch"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/ebglnr5_jwHHpvon3WU-5hzt0eHdTZSVGg3Ts6R38xY/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9pLnl0/aW1nLmNvbS92aS9M/V2RzRjc5SDFQZy9t/YXhyZXNkZWZhdWx0/LmpwZw"
+				}
+			},
+			{
+				"type": "video_result",
+				"url": "https://www.youtube.com/watch?v=AovxLr8jUH4",
+				"title": "Python Tutorial for Beginners 5 - Python print() and input() Function ...",
+				"description": "In this Video I am going to show How to use print() Function and input() Function in Python. In python The print() function is used to print the specified ...",
+				"age": "August 28, 2018",
+				"page_age": "2018-08-28T20:11:09",
+				"video": {},
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "youtube.com",
+					"hostname": "www.youtube.com",
+					"favicon": "https://imgs.search.brave.com/Ux4Hee4evZhvjuTKwtapBycOGjGDci2Gvn2pbSzvbC0/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTkyZTZiMWU3/YzU3Nzc5YjExYzUy/N2VhZTIxOWNlYjM5/ZGVjN2MyZDY4Nzdh/ZDYzMTYxNmI5N2Rk/Y2Q3N2FkNy93d3cu/eW91dHViZS5jb20v",
+					"path": "› watch"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/nCoLEcWkKtiecprWbS6nufwGCaSbPH7o0-sMeIkFmjI/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9pLnl0/aW1nLmNvbS92aS9B/b3Z4THI4alVINC9o/cWRlZmF1bHQuanBn"
+				}
+			}
+		],
+		"mutated_by_goggles": false
+	},
+	"web": {
+		"type": "search",
+		"results": [
+			{
+				"title": "Welcome to Python.org",
+				"url": "https://www.python.org",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "The official home of the <strong>Python</strong> Programming Language",
+				"page_age": "2023-09-09T15:55:05",
+				"profile": {
+					"name": "Python",
+					"url": "https://www.python.org",
+					"long_name": "python.org",
+					"img": "https://imgs.search.brave.com/vBaRH-v6oPS4csO4cdvuKhZ7-xDVvydin3oe3zXYxAI/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvNTJjMzZjNDBj/MmIzODgwMGUyOTRj/Y2E5MjM3YjRkYTZj/YWI1Yzk1NTlmYTgw/ZDBjNzM0MGMxZjQz/YWFjNTczYy93d3cu/cHl0aG9uLm9yZy8"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "python.org",
+					"hostname": "www.python.org",
+					"favicon": "https://imgs.search.brave.com/vBaRH-v6oPS4csO4cdvuKhZ7-xDVvydin3oe3zXYxAI/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvNTJjMzZjNDBj/MmIzODgwMGUyOTRj/Y2E5MjM3YjRkYTZj/YWI1Yzk1NTlmYTgw/ZDBjNzM0MGMxZjQz/YWFjNTczYy93d3cu/cHl0aG9uLm9yZy8",
+					"path": ""
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/GGfNfe5rxJ8QWEoxXniSLc0-POLU3qPyTIpuqPdbmXk/rs:fit:200:200:1/g:ce/aHR0cHM6Ly93d3cu/cHl0aG9uLm9yZy9z/dGF0aWMvb3Blbmdy/YXBoLWljb24tMjAw/eDIwMC5wbmc",
+					"original": "https://www.python.org/static/opengraph-icon-200x200.png",
+					"logo": false
+				},
+				"age": "September 9, 2023",
+				"cluster_type": "generic",
+				"cluster": [
+					{
+						"title": "Downloads",
+						"url": "https://www.python.org/downloads/",
+						"is_source_local": false,
+						"is_source_both": false,
+						"description": "The official home of the <strong>Python</strong> Programming Language",
+						"family_friendly": true
+					},
+					{
+						"title": "Macos",
+						"url": "https://www.python.org/downloads/macos/",
+						"is_source_local": false,
+						"is_source_both": false,
+						"description": "The official home of the <strong>Python</strong> Programming Language",
+						"family_friendly": true
+					},
+					{
+						"title": "Windows",
+						"url": "https://www.python.org/downloads/windows/",
+						"is_source_local": false,
+						"is_source_both": false,
+						"description": "The official home of the <strong>Python</strong> Programming Language",
+						"family_friendly": true
+					},
+					{
+						"title": "Getting Started",
+						"url": "https://www.python.org/about/gettingstarted/",
+						"is_source_local": false,
+						"is_source_both": false,
+						"description": "The official home of the <strong>Python</strong> Programming Language",
+						"family_friendly": true
+					}
+				],
+				"extra_snippets": [
+					"Calculations are simple with Python, and expression syntax is straightforward: the operators +, -, * and / work as expected; parentheses () can be used for grouping. More about simple math functions in Python 3.",
+					"The core of extensible programming is defining functions. Python allows mandatory and optional arguments, keyword arguments, and even arbitrary argument lists. More about defining functions in Python 3",
+					"Lists (known as arrays in other languages) are one of the compound data types that Python understands. Lists can be indexed, sliced and manipulated with other built-in functions. More about lists in Python 3",
+					"# Python 3: Simple output (with Unicode) >>> print(\"Hello, I'm Python!\") Hello, I'm Python! # Input, assignment >>> name = input('What is your name?\\n') >>> print('Hi, %s.' % name) What is your name? Python Hi, Python."
+				]
+			},
+			{
+				"title": "Python (programming language) - Wikipedia",
+				"url": "https://en.wikipedia.org/wiki/Python_(programming_language)",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "<strong>Python</strong> is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. <strong>Python</strong> is dynamically typed and garbage-collected. It supports multiple programming paradigms, including structured (particularly procedural), ...",
+				"page_age": "2024-05-01T12:54:03",
+				"profile": {
+					"name": "Wikipedia",
+					"url": "https://en.wikipedia.org/wiki/Python_(programming_language)",
+					"long_name": "en.wikipedia.org",
+					"img": "https://imgs.search.brave.com/0kxnVOiqv-faZvOJc7zpym4Zin1CTs1f1svfNZSzmfU/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvNjQwNGZhZWY0/ZTQ1YWUzYzQ3MDUw/MmMzMGY3NTQ0ZjNj/NDUwMDk5ZTI3MWRk/NWYyNTM4N2UwOTE0/NTI3ZDQzNy9lbi53/aWtpcGVkaWEub3Jn/Lw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "en.wikipedia.org",
+					"hostname": "en.wikipedia.org",
+					"favicon": "https://imgs.search.brave.com/0kxnVOiqv-faZvOJc7zpym4Zin1CTs1f1svfNZSzmfU/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvNjQwNGZhZWY0/ZTQ1YWUzYzQ3MDUw/MmMzMGY3NTQ0ZjNj/NDUwMDk5ZTI3MWRk/NWYyNTM4N2UwOTE0/NTI3ZDQzNy9lbi53/aWtpcGVkaWEub3Jn/Lw",
+					"path": "› wiki  › Python_(programming_language)"
+				},
+				"age": "4 days ago",
+				"extra_snippets": [
+					"Python is dynamically typed and garbage-collected. It supports multiple programming paradigms, including structured (particularly procedural), object-oriented and functional programming. It is often described as a \"batteries included\" language due to its comprehensive standard library.",
+					"Guido van Rossum began working on Python in the late 1980s as a successor to the ABC programming language and first released it in 1991 as Python 0.9.0. Python 2.0 was released in 2000. Python 3.0, released in 2008, was a major revision not completely backward-compatible with earlier versions. Python 2.7.18, released in 2020, was the last release of Python 2.",
+					"Python was invented in the late 1980s by Guido van Rossum at Centrum Wiskunde & Informatica (CWI) in the Netherlands as a successor to the ABC programming language, which was inspired by SETL, capable of exception handling and interfacing with the Amoeba operating system.",
+					"Python consistently ranks as one of the most popular programming languages, and has gained widespread use in the machine learning community."
+				]
+			},
+			{
+				"title": "Python Tutorial",
+				"url": "https://www.w3schools.com/python/",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "W3Schools offers free online tutorials, references and exercises in all the major languages of the web. Covering popular subjects like HTML, CSS, JavaScript, <strong>Python</strong>, SQL, Java, and many, many more.",
+				"page_age": "2017-12-07T00:00:00",
+				"profile": {
+					"name": "W3Schools",
+					"url": "https://www.w3schools.com/python/",
+					"long_name": "w3schools.com",
+					"img": "https://imgs.search.brave.com/JwO5r7z3HTBkU29vgNH_4rrSWLf2M4-8FMWNvbxrKX8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjVlMGVkZDVj/ZGMyZWRmMzAwODRi/ZDAwZGE4NWI3NmU4/MjRhNjEzOGFhZWY3/ZGViMjY1OWY2ZDYw/YTZiOGUyZS93d3cu/dzNzY2hvb2xzLmNv/bS8"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "w3schools.com",
+					"hostname": "www.w3schools.com",
+					"favicon": "https://imgs.search.brave.com/JwO5r7z3HTBkU29vgNH_4rrSWLf2M4-8FMWNvbxrKX8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjVlMGVkZDVj/ZGMyZWRmMzAwODRi/ZDAwZGE4NWI3NmU4/MjRhNjEzOGFhZWY3/ZGViMjY1OWY2ZDYw/YTZiOGUyZS93d3cu/dzNzY2hvb2xzLmNv/bS8",
+					"path": "› python"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/EMfp8dodbJehmj0yCJh8317RHuaumsddnHI4bujvFcg/rs:fit:200:200:1/g:ce/aHR0cHM6Ly93d3cu/dzNzY2hvb2xzLmNv/bS9pbWFnZXMvdzNz/Y2hvb2xzX2xvZ29f/NDM2XzIucG5n",
+					"original": "https://www.w3schools.com/images/w3schools_logo_436_2.png",
+					"logo": true
+				},
+				"age": "December 7, 2017",
+				"extra_snippets": [
+					"Well organized and easy to understand Web building tutorials with lots of examples of how to use HTML, CSS, JavaScript, SQL, Python, PHP, Bootstrap, Java, XML and more.",
+					"HTML CSS JAVASCRIPT SQL PYTHON JAVA PHP HOW TO W3.CSS C C++ C# BOOTSTRAP REACT MYSQL JQUERY EXCEL XML DJANGO NUMPY PANDAS NODEJS R TYPESCRIPT ANGULAR GIT POSTGRESQL MONGODB ASP AI GO KOTLIN SASS VUE DSA GEN AI SCIPY AWS CYBERSECURITY DATA SCIENCE",
+					"Python Variables Variable Names Assign Multiple Values Output Variables Global Variables Variable Exercises Python Data Types Python Numbers Python Casting Python Strings",
+					"Python Strings Slicing Strings Modify Strings Concatenate Strings Format Strings Escape Characters String Methods String Exercises Python Booleans Python Operators Python Lists"
+				]
+			},
+			{
+				"title": "Online Python - IDE, Editor, Compiler, Interpreter",
+				"url": "https://www.online-python.com/",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Build and Run your <strong>Python</strong> code instantly. Online-<strong>Python</strong> is a quick and easy tool that helps you to build, compile, test your <strong>python</strong> programs.",
+				"profile": {
+					"name": "Online-python",
+					"url": "https://www.online-python.com/",
+					"long_name": "online-python.com",
+					"img": "https://imgs.search.brave.com/kfaEvapwHxSsRObO52-I-otYFPHpG1h7UXJyUqDM2Ec/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZGYxODdjNWQ0/NjZjZTNiMjk5NDY1/MWI5MTgyYjU3Y2Q3/MTI3NGM5MjUzY2Fi/OGQ3MTQ4MmIxMTQx/ZTcxNWFhMC93d3cu/b25saW5lLXB5dGhv/bi5jb20v"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "online-python.com",
+					"hostname": "www.online-python.com",
+					"favicon": "https://imgs.search.brave.com/kfaEvapwHxSsRObO52-I-otYFPHpG1h7UXJyUqDM2Ec/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZGYxODdjNWQ0/NjZjZTNiMjk5NDY1/MWI5MTgyYjU3Y2Q3/MTI3NGM5MjUzY2Fi/OGQ3MTQ4MmIxMTQx/ZTcxNWFhMC93d3cu/b25saW5lLXB5dGhv/bi5jb20v",
+					"path": ""
+				},
+				"extra_snippets": [
+					"Build, run, and share Python code online for free with the help of online-integrated python's development environment (IDE). It is one of the most efficient, dependable, and potent online compilers for the Python programming language. It is not necessary for you to bother about establishing a Python environment in your local.",
+					"It is one of the most efficient, dependable, and potent online compilers for the Python programming language. It is not necessary for you to bother about establishing a Python environment in your local. Now You can immediately execute the Python code in the web browser of your choice.",
+					"It is not necessary for you to bother about establishing a Python environment in your local. Now You can immediately execute the Python code in the web browser of your choice. Using this Python editor is simple and quick to get up and running with. Simply type in the programme, and then press the RUN button!",
+					"Now You can immediately execute the Python code in the web browser of your choice. Using this Python editor is simple and quick to get up and running with. Simply type in the programme, and then press the RUN button! The code can be saved online by choosing the SHARE option, which also gives you the ability to access your code from any location providing you have internet access."
+				]
+			},
+			{
+				"title": "Python · GitHub",
+				"url": "https://github.com/python",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Repositories related to the <strong>Python</strong> Programming language - <strong>Python</strong>",
+				"page_age": "2023-03-06T00:00:00",
+				"profile": {
+					"name": "GitHub",
+					"url": "https://github.com/python",
+					"long_name": "github.com",
+					"img": "https://imgs.search.brave.com/v8685zI4XInM0zxlNI2s7oE_2Sb-EL7lAy81WXbkQD8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "github.com",
+					"hostname": "github.com",
+					"favicon": "https://imgs.search.brave.com/v8685zI4XInM0zxlNI2s7oE_2Sb-EL7lAy81WXbkQD8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw",
+					"path": "› python"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/POoaRfu_7gfp-D_O3qMNJrwDqJNbiDu1HuBpNJ_MpVQ/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9hdmF0/YXJzLmdpdGh1YnVz/ZXJjb250ZW50LmNv/bS91LzE1MjU5ODE_/cz0yMDAmYW1wO3Y9/NA",
+					"original": "https://avatars.githubusercontent.com/u/1525981?s=200&amp;v=4",
+					"logo": false
+				},
+				"age": "March 6, 2023",
+				"extra_snippets": ["Configuration for Python planets (e.g. http://planetpython.org)"]
+			},
+			{
+				"title": "Online Python Compiler (Interpreter)",
+				"url": "https://www.programiz.com/python-programming/online-compiler/",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Write and run <strong>Python</strong> code using our online compiler (interpreter). You can use <strong>Python</strong> Shell like IDLE, and take inputs from the user in our <strong>Python</strong> compiler.",
+				"page_age": "2020-06-02T00:00:00",
+				"profile": {
+					"name": "Programiz",
+					"url": "https://www.programiz.com/python-programming/online-compiler/",
+					"long_name": "programiz.com",
+					"img": "https://imgs.search.brave.com/ozj4JFayZ3Fs5c9eTp7M5g12azQ_Hblgu4dpTuHRz6U/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMGJlN2U1YjVi/Y2M3ZDU5OGMwMWNi/M2Q3YjhjOTM1ZTFk/Y2NkZjE4NGQwOGIx/MTQ4NjI2YmNhODVj/MzFkMmJhYy93d3cu/cHJvZ3JhbWl6LmNv/bS8"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "programiz.com",
+					"hostname": "www.programiz.com",
+					"favicon": "https://imgs.search.brave.com/ozj4JFayZ3Fs5c9eTp7M5g12azQ_Hblgu4dpTuHRz6U/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMGJlN2U1YjVi/Y2M3ZDU5OGMwMWNi/M2Q3YjhjOTM1ZTFk/Y2NkZjE4NGQwOGIx/MTQ4NjI2YmNhODVj/MzFkMmJhYy93d3cu/cHJvZ3JhbWl6LmNv/bS8",
+					"path": "› python-programming  › online-compiler"
+				},
+				"age": "June 2, 2020",
+				"extra_snippets": [
+					"Python Online Compiler Online R Compiler SQL Online Editor Online HTML/CSS Editor Online Java Compiler C Online Compiler C++ Online Compiler C# Online Compiler JavaScript Online Compiler Online GoLang Compiler Online PHP Compiler Online Swift Compiler Online Rust Compiler",
+					"# Online Python compiler (interpreter) to run Python online. # Write Python 3 code in this online editor and run it. print(\"Try programiz.pro\")"
+				]
+			},
+			{
+				"title": "Python Developer",
+				"url": "https://twitter.com/Python_Dv/status/1786763460992544791",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "<strong>Python</strong> Developer",
+				"page_age": "2024-05-04T14:30:03",
+				"profile": {
+					"name": "X",
+					"url": "https://twitter.com/Python_Dv/status/1786763460992544791",
+					"long_name": "twitter.com",
+					"img": "https://imgs.search.brave.com/Zq483bGX0GnSgym-1P7iyOyEDX3PkDZSNT8m56F862A/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvN2MxOTUxNzhj/OTY1ZTQ3N2I0MjJk/MTY5NGM0MTRlYWVi/MjU1YWE2NDUwYmQ2/YTA2MDFhMDlkZDEx/NTAzZGNiNi90d2l0/dGVyLmNvbS8"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "twitter.com",
+					"hostname": "twitter.com",
+					"favicon": "https://imgs.search.brave.com/Zq483bGX0GnSgym-1P7iyOyEDX3PkDZSNT8m56F862A/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvN2MxOTUxNzhj/OTY1ZTQ3N2I0MjJk/MTY5NGM0MTRlYWVi/MjU1YWE2NDUwYmQ2/YTA2MDFhMDlkZDEx/NTAzZGNiNi90d2l0/dGVyLmNvbS8",
+					"path": "› Python_Dv  › status  › 1786763460992544791"
+				},
+				"age": "20 hours ago"
+			},
+			{
+				"title": "input table name? - python script - KNIME Extensions - KNIME Community Forum",
+				"url": "https://forum.knime.com/t/input-table-name-python-script/78978",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Hi, when running a <strong>python</strong> script node, I get the error seen on the screenshot Same happens with this code too: The script input is output from the csv reader node. How can I get the right name for that table? Best wishes, Dario",
+				"page_age": "2024-05-04T09:20:44",
+				"profile": {
+					"name": "Knime",
+					"url": "https://forum.knime.com/t/input-table-name-python-script/78978",
+					"long_name": "forum.knime.com",
+					"img": "https://imgs.search.brave.com/WQoOhAD5i6uEhJ-qXvlWMJwbGA52f2Ycc_ns36EK698/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTAxNzMxNjFl/MzJjNzU5NzRkOTMz/Mjg4NDU2OWUxM2Rj/YzVkOGM3MzIwNzI2/YTY1NzYxNzA1MDE5/NzQzOWU3NC9mb3J1/bS5rbmltZS5jb20v"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "article",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "forum.knime.com",
+					"hostname": "forum.knime.com",
+					"favicon": "https://imgs.search.brave.com/WQoOhAD5i6uEhJ-qXvlWMJwbGA52f2Ycc_ns36EK698/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTAxNzMxNjFl/MzJjNzU5NzRkOTMz/Mjg4NDU2OWUxM2Rj/YzVkOGM3MzIwNzI2/YTY1NzYxNzA1MDE5/NzQzOWU3NC9mb3J1/bS5rbmltZS5jb20v",
+					"path": "  › knime extensions"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/DtEl38dcvuM1kGfhN0T5HfOrsMJcztWNyriLvtDJmKI/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9mb3J1/bS1jZG4ua25pbWUu/Y29tL3VwbG9hZHMv/ZGVmYXVsdC9vcmln/aW5hbC8zWC9lLzYv/ZTY0M2M2NzFlNzAz/MDg2MjkwMWY2YzJh/OWFjOWI5ZmEwM2M3/ZjMwZi5wbmc",
+					"original": "https://forum-cdn.knime.com/uploads/default/original/3X/e/6/e643c671e7030862901f6c2a9ac9b9fa03c7f30f.png",
+					"logo": false
+				},
+				"age": "1 day ago",
+				"extra_snippets": [
+					"Hi, when running a python script node, I get the error seen on the screenshot Same happens with this code too: The script input is output from the csv reader node. How can I get the right name for that table? …"
+				]
+			},
+			{
+				"title": "What does the Double Star operator mean in Python? - GeeksforGeeks",
+				"url": "https://www.geeksforgeeks.org/what-does-the-double-star-operator-mean-in-python/",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "A Computer Science portal for geeks. It contains well written, well thought and well explained computer science and programming articles, quizzes and practice/competitive programming/company interview Questions.",
+				"page_age": "2023-03-14T17:15:04",
+				"profile": {
+					"name": "GeeksforGeeks",
+					"url": "https://www.geeksforgeeks.org/what-does-the-double-star-operator-mean-in-python/",
+					"long_name": "geeksforgeeks.org",
+					"img": "https://imgs.search.brave.com/fhzcfv5xltx6-YBvJI9RZgS7xZo0dPNaASsrB8YOsCs/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjBhOGQ3MmNi/ZWE5N2EwMmZjYzA1/ZTI0ZTFhMGUyMTE0/MGM0ZTBmMWZlM2Y2/Yzk2ODMxZTRhYTBi/NDdjYTE0OS93d3cu/Z2Vla3Nmb3JnZWVr/cy5vcmcv"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "article",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "geeksforgeeks.org",
+					"hostname": "www.geeksforgeeks.org",
+					"favicon": "https://imgs.search.brave.com/fhzcfv5xltx6-YBvJI9RZgS7xZo0dPNaASsrB8YOsCs/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjBhOGQ3MmNi/ZWE5N2EwMmZjYzA1/ZTI0ZTFhMGUyMTE0/MGM0ZTBmMWZlM2Y2/Yzk2ODMxZTRhYTBi/NDdjYTE0OS93d3cu/Z2Vla3Nmb3JnZWVr/cy5vcmcv",
+					"path": "› what-does-the-double-star-operator-mean-in-python"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/GcR-j_dLbyHkbHEI3ffLMi6xpXGhF_2Z8POIoqtokhM/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9tZWRp/YS5nZWVrc2Zvcmdl/ZWtzLm9yZy93cC1j/b250ZW50L3VwbG9h/ZHMvZ2ZnXzIwMFgy/MDAtMTAweDEwMC5w/bmc",
+					"original": "https://media.geeksforgeeks.org/wp-content/uploads/gfg_200X200-100x100.png",
+					"logo": false
+				},
+				"age": "March 14, 2023",
+				"extra_snippets": [
+					"Difference between / vs. // operator in Python",
+					"Double Star or (**) is one of the Arithmetic Operator (Like +, -, *, **, /, //, %) in Python Language. It is also known as Power Operator.",
+					"The time complexity of the given Python program is O(n), where n is the number of key-value pairs in the input dictionary.",
+					"Inplace Operators in Python | Set 2 (ixor(), iand(), ipow(),…)"
+				]
+			},
+			{
+				"title": "r/Python",
+				"url": "https://www.reddit.com/r/Python/",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "The official <strong>Python</strong> community for Reddit! Stay up to date with the latest news, packages, and meta information relating to the <strong>Python</strong> programming language. --- If you have questions or are new to <strong>Python</strong> use r/LearnPython",
+				"page_age": "2022-12-30T16:25:02",
+				"profile": {
+					"name": "Reddit",
+					"url": "https://www.reddit.com/r/Python/",
+					"long_name": "reddit.com",
+					"img": "https://imgs.search.brave.com/mAZYEK9Wi13WLDUge7XZ8YuDTwm6DP6gBjvz1GdYZVY/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvN2ZiNTU0M2Nj/MTFhZjRiYWViZDlk/MjJiMjBjMzFjMDRk/Y2IzYWI0MGI0MjVk/OGY5NzQzOGQ5NzQ5/NWJhMWI0NC93d3cu/cmVkZGl0LmNvbS8"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "reddit.com",
+					"hostname": "www.reddit.com",
+					"favicon": "https://imgs.search.brave.com/mAZYEK9Wi13WLDUge7XZ8YuDTwm6DP6gBjvz1GdYZVY/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvN2ZiNTU0M2Nj/MTFhZjRiYWViZDlk/MjJiMjBjMzFjMDRk/Y2IzYWI0MGI0MjVk/OGY5NzQzOGQ5NzQ5/NWJhMWI0NC93d3cu/cmVkZGl0LmNvbS8",
+					"path": "› r  › Python"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/zWd10t3zg34ciHiAB-K5WWK3h_H4LedeDot9BVX7Ydo/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9zdHls/ZXMucmVkZGl0bWVk/aWEuY29tL3Q1XzJx/aDB5L3N0eWxlcy9j/b21tdW5pdHlJY29u/X2NpZmVobDR4dDdu/YzEucG5n",
+					"original": "https://styles.redditmedia.com/t5_2qh0y/styles/communityIcon_cifehl4xt7nc1.png",
+					"logo": false
+				},
+				"age": "December 30, 2022",
+				"extra_snippets": [
+					"r/Python: The official Python community for Reddit! Stay up to date with the latest news, packages, and meta information relating to the Python…",
+					"By default, Python allows you to import and use anything, anywhere. Over time, this results in modules that were intended to be separate getting tightly coupled together, and domain boundaries breaking down. We experienced this first-hand at a unicorn startup, where the eng team paused development for over a year in an attempt to split up packages into independent services.",
+					"Hello r/Python! It's time to share what you've been working on! Whether it's a work-in-progress, a completed masterpiece, or just a rough idea, let us know what you're up to!",
+					"Whether it's your job, your hobby, or your passion project, all Python-related work is welcome here."
+				]
+			},
+			{
+				"title": "GitHub - python/cpython: The Python programming language",
+				"url": "https://github.com/python/cpython",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "The <strong>Python</strong> programming language. Contribute to <strong>python</strong>/cpython development by creating an account on GitHub.",
+				"page_age": "2022-10-29T00:00:00",
+				"profile": {
+					"name": "GitHub",
+					"url": "https://github.com/python/cpython",
+					"long_name": "github.com",
+					"img": "https://imgs.search.brave.com/v8685zI4XInM0zxlNI2s7oE_2Sb-EL7lAy81WXbkQD8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "software",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "github.com",
+					"hostname": "github.com",
+					"favicon": "https://imgs.search.brave.com/v8685zI4XInM0zxlNI2s7oE_2Sb-EL7lAy81WXbkQD8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw",
+					"path": "› python  › cpython"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/BJbWFRUqgP-tKIyGK9ByXjuYjHO2mtYigUOEFNz_gXk/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9vcGVu/Z3JhcGguZ2l0aHVi/YXNzZXRzLmNvbS82/MTY5YmJkNTQ0YzAy/NDg0MGU4NDdjYTU1/YTU3ZGZmMDA2ZDAw/YWQ1NDIzOTFmYTQ3/YmJjODg3OWM0NWYw/MTZhL3B5dGhvbi9j/cHl0aG9u",
+					"original": "https://opengraph.githubassets.com/6169bbd544c024840e847ca55a57dff006d00ad542391fa47bbc8879c45f016a/python/cpython",
+					"logo": false
+				},
+				"age": "October 29, 2022",
+				"extra_snippets": [
+					"You can pass many options to the configure script; run ./configure --help to find out more. On macOS case-insensitive file systems and on Cygwin, the executable is called python.exe; elsewhere it's just python.",
+					"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or useable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
+					"To get an optimized build of Python, configure --enable-optimizations before you run make. This sets the default make targets up to enable Profile Guided Optimization (PGO) and may be used to auto-enable Link Time Optimization (LTO) on some platforms. For more details, see the sections below.",
+					"Copyright © 2001-2024 Python Software Foundation. All rights reserved."
+				]
+			},
+			{
+				"title": "5. Data Structures — Python 3.12.3 documentation",
+				"url": "https://docs.python.org/3/tutorial/datastructures.html",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "This chapter describes some things you’ve learned about already in more detail, and adds some new things as well. More on Lists: The list data type has some more methods. Here are all of the method...",
+				"page_age": "2023-07-04T00:00:00",
+				"profile": {
+					"name": "Python documentation",
+					"url": "https://docs.python.org/3/tutorial/datastructures.html",
+					"long_name": "docs.python.org",
+					"img": "https://imgs.search.brave.com/F5Ym7eSElhGdGUFKLRxDj9Z_tc180ldpeMvQ2Q6ARbA/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTUzOTFjOGVi/YTcyOTVmODA3ODIy/YjE2NzFjY2ViMjhl/NzRlY2JhYTc5YjNm/ZjhmODAyZWI2OGUw/ZjU4NDVlNy9kb2Nz/LnB5dGhvbi5vcmcv"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "docs.python.org",
+					"hostname": "docs.python.org",
+					"favicon": "https://imgs.search.brave.com/F5Ym7eSElhGdGUFKLRxDj9Z_tc180ldpeMvQ2Q6ARbA/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTUzOTFjOGVi/YTcyOTVmODA3ODIy/YjE2NzFjY2ViMjhl/NzRlY2JhYTc5YjNm/ZjhmODAyZWI2OGUw/ZjU4NDVlNy9kb2Nz/LnB5dGhvbi5vcmcv",
+					"path": "› 3  › tutorial  › datastructures.html"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/Y7GrMRF8WorDIMLuOl97XC8ltYpoOCqNwWF2pQIIKls/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9kb2Nz/LnB5dGhvbi5vcmcv/My9fc3RhdGljL29n/LWltYWdlLnBuZw",
+					"original": "https://docs.python.org/3/_static/og-image.png",
+					"logo": false
+				},
+				"age": "July 4, 2023",
+				"extra_snippets": [
+					"You might have noticed that methods like insert, remove or sort that only modify the list have no return value printed – they return the default None. [1] This is a design principle for all mutable data structures in Python.",
+					"We saw that lists and strings have many common properties, such as indexing and slicing operations. They are two examples of sequence data types (see Sequence Types — list, tuple, range). Since Python is an evolving language, other sequence data types may be added. There is also another standard sequence data type: the tuple.",
+					"Python also includes a data type for sets. A set is an unordered collection with no duplicate elements. Basic uses include membership testing and eliminating duplicate entries. Set objects also support mathematical operations like union, intersection, difference, and symmetric difference.",
+					"Another useful data type built into Python is the dictionary (see Mapping Types — dict). Dictionaries are sometimes found in other languages as “associative memories” or “associative arrays”. Unlike sequences, which are indexed by a range of numbers, dictionaries are indexed by keys, which can be any immutable type; strings and numbers can always be keys."
+				]
+			},
+			{
+				"title": "Something wrong with python packages / AUR Issues, Discussion & PKGBUILD Requests / Arch Linux Forums",
+				"url": "https://bbs.archlinux.org/viewtopic.php?id=295466",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Big <strong>Python</strong> updates require <strong>Python</strong> packages to be rebuild. For some reason they didn&#x27;t think a bump that made it necessary to rebuild half the official repo was a news post.",
+				"page_age": "2024-05-04T08:30:02",
+				"profile": {
+					"name": "Archlinux",
+					"url": "https://bbs.archlinux.org/viewtopic.php?id=295466",
+					"long_name": "bbs.archlinux.org",
+					"img": "https://imgs.search.brave.com/3au9oqkzSri_aLEec3jo-0bFgLuICkydrWfjFcC8lkI/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvNWNkODM1MWJl/ZmJhMzkzNzYzMDkz/NmEyMWMxNjI5MjNk/NGJmZjFhNTBlZDNl/Mzk5MzJjOGZkYjZl/MjNmY2IzNS9iYnMu/YXJjaGxpbnV4Lm9y/Zy8"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "bbs.archlinux.org",
+					"hostname": "bbs.archlinux.org",
+					"favicon": "https://imgs.search.brave.com/3au9oqkzSri_aLEec3jo-0bFgLuICkydrWfjFcC8lkI/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvNWNkODM1MWJl/ZmJhMzkzNzYzMDkz/NmEyMWMxNjI5MjNk/NGJmZjFhNTBlZDNl/Mzk5MzJjOGZkYjZl/MjNmY2IzNS9iYnMu/YXJjaGxpbnV4Lm9y/Zy8",
+					"path": "› viewtopic.php"
+				},
+				"age": "1 day ago",
+				"extra_snippets": [
+					"Traceback (most recent call last): File \"/usr/lib/python3.12/importlib/metadata/__init__.py\", line 397, in from_name return next(cls.discover(name=name)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ StopIteration During handling of the above exception, another exception occurred: Traceback (most recent call last): File \"/usr/bin/informant\", line 33, in <module> sys.exit(load_entry_point('informant==0.5.0', 'console_scripts', 'informant')()) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File \"/usr/bin/informant\", line 22, in importlib_load_entry_point for entry_point in distribution(dis"
+				]
+			},
+			{
+				"title": "Introduction to Python",
+				"url": "https://www.w3schools.com/python/python_intro.asp",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "W3Schools offers free online tutorials, references and exercises in all the major languages of the web. Covering popular subjects like HTML, CSS, JavaScript, <strong>Python</strong>, SQL, Java, and many, many more.",
+				"profile": {
+					"name": "W3Schools",
+					"url": "https://www.w3schools.com/python/python_intro.asp",
+					"long_name": "w3schools.com",
+					"img": "https://imgs.search.brave.com/JwO5r7z3HTBkU29vgNH_4rrSWLf2M4-8FMWNvbxrKX8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjVlMGVkZDVj/ZGMyZWRmMzAwODRi/ZDAwZGE4NWI3NmU4/MjRhNjEzOGFhZWY3/ZGViMjY1OWY2ZDYw/YTZiOGUyZS93d3cu/dzNzY2hvb2xzLmNv/bS8"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "w3schools.com",
+					"hostname": "www.w3schools.com",
+					"favicon": "https://imgs.search.brave.com/JwO5r7z3HTBkU29vgNH_4rrSWLf2M4-8FMWNvbxrKX8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjVlMGVkZDVj/ZGMyZWRmMzAwODRi/ZDAwZGE4NWI3NmU4/MjRhNjEzOGFhZWY3/ZGViMjY1OWY2ZDYw/YTZiOGUyZS93d3cu/dzNzY2hvb2xzLmNv/bS8",
+					"path": "› python  › python_intro.asp"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/EMfp8dodbJehmj0yCJh8317RHuaumsddnHI4bujvFcg/rs:fit:200:200:1/g:ce/aHR0cHM6Ly93d3cu/dzNzY2hvb2xzLmNv/bS9pbWFnZXMvdzNz/Y2hvb2xzX2xvZ29f/NDM2XzIucG5n",
+					"original": "https://www.w3schools.com/images/w3schools_logo_436_2.png",
+					"logo": true
+				},
+				"extra_snippets": [
+					"Well organized and easy to understand Web building tutorials with lots of examples of how to use HTML, CSS, JavaScript, SQL, Python, PHP, Bootstrap, Java, XML and more.",
+					"HTML CSS JAVASCRIPT SQL PYTHON JAVA PHP HOW TO W3.CSS C C++ C# BOOTSTRAP REACT MYSQL JQUERY EXCEL XML DJANGO NUMPY PANDAS NODEJS R TYPESCRIPT ANGULAR GIT POSTGRESQL MONGODB ASP AI GO KOTLIN SASS VUE DSA GEN AI SCIPY AWS CYBERSECURITY DATA SCIENCE",
+					"Python Variables Variable Names Assign Multiple Values Output Variables Global Variables Variable Exercises Python Data Types Python Numbers Python Casting Python Strings",
+					"Python Strings Slicing Strings Modify Strings Concatenate Strings Format Strings Escape Characters String Methods String Exercises Python Booleans Python Operators Python Lists"
+				]
+			},
+			{
+				"title": "bug: AUR package wants to use python but does not find any preset version · Issue #1740 · asdf-vm/asdf",
+				"url": "https://github.com/asdf-vm/asdf/issues/1740",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Describe the Bug I am not sure why this is happening, I am trying to install tlpui from AUR and it fails, here are some logs to help: ==&gt; Making package: tlpui 2:1.6.5-1 (Mi 10 apr 2024 23:19:15 +0...",
+				"page_age": "2024-05-04T06:45:04",
+				"profile": {
+					"name": "GitHub",
+					"url": "https://github.com/asdf-vm/asdf/issues/1740",
+					"long_name": "github.com",
+					"img": "https://imgs.search.brave.com/v8685zI4XInM0zxlNI2s7oE_2Sb-EL7lAy81WXbkQD8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "software",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "github.com",
+					"hostname": "github.com",
+					"favicon": "https://imgs.search.brave.com/v8685zI4XInM0zxlNI2s7oE_2Sb-EL7lAy81WXbkQD8/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYWQyNWM1NjA5/ZjZmZjNlYzI2MDNk/N2VkNmJhYjE2MzZl/MDY5ZTMxMDUzZmY1/NmU3NWIzNWVmMjk0/NTBjMjJjZi9naXRo/dWIuY29tLw",
+					"path": "› asdf-vm  › asdf  › issues  › 1740"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/KrLW5s_2n4jyP8XLbc3ZPVBaLD963tQgWzG9EWPZlQs/rs:fit:200:200:1/g:ce/aHR0cHM6Ly9vcGVu/Z3JhcGguZ2l0aHVi/YXNzZXRzLmNvbS81/MTE0ZTdkOGIwODM2/YmQ2MTY3NzQ1ZGI4/MmZjMGE3OGUyMjcw/MGFlY2ZjMWZkODBl/MDYzZTNiN2ZjOWNj/NzYyL2FzZGYtdm0v/YXNkZi9pc3N1ZXMv/MTc0MA",
+					"original": "https://opengraph.githubassets.com/5114e7d8b0836bd6167745db82fc0a78e22700aecfc1fd80e063e3b7fc9cc762/asdf-vm/asdf/issues/1740",
+					"logo": false
+				},
+				"age": "1 day ago",
+				"extra_snippets": [
+					"==> Starting build()... No preset version installed for command python Please install a version by running one of the following: asdf install python 3.8 or add one of the following versions in your config file at /home/ferret/.tool-versions python 3.11.0 python 3.12.1 python 3.12.3 ==> ERROR: A failure occurred in build(). Aborting...",
+					"-> error making: tlpui-exit status 4 -> Failed to install the following packages. Manual intervention is required: tlpui - exit status 4 ferret@FX505DT in ~ $ cat /home/ferret/.tool-versions nodejs 21.6.0 python 3.12.3 ferret@FX505DT in ~ $ python -V Python 3.12.3 ferret@FX505DT in ~ $ which python /home/ferret/.asdf/shims/python",
+					"Describe the Bug I am not sure why this is happening, I am trying to install tlpui from AUR and it fails, here are some logs to help: ==> Making package: tlpui 2:1.6.5-1 (Mi 10 apr 2024 23:19:15 +0300) ==> Retrieving sources... -> Found ..."
+				]
+			},
+			{
+				"title": "What are python.exe and python3.exe, and why do they appear to point to App Installer? | Windows 11 Forum",
+				"url": "https://www.elevenforum.com/t/what-are-python-exe-and-python3-exe-and-why-do-they-appear-to-point-to-app-installer.24886/",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "I was looking at App execution aliases (Settings &gt; Apps &gt; Advanced app settings &gt; App execution aliases) on my new computer -- my first Windows 11 computer. Why are <strong>python</strong>.exe and python3.exe listed as App Installer? I assume that App Installer refers to installation of Microsoft Store / UWP...",
+				"page_age": "2024-05-03T17:30:04",
+				"profile": {
+					"name": "Windows 11 Forum",
+					"url": "https://www.elevenforum.com/t/what-are-python-exe-and-python3-exe-and-why-do-they-appear-to-point-to-app-installer.24886/",
+					"long_name": "elevenforum.com",
+					"img": "https://imgs.search.brave.com/XVRAYMEj6Im8i7jV5RxeTwpiRPtY9IWg4wRIuh-WhEw/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZjk5MDZkMDIw/M2U1OWIwNjM5Y2U1/M2U2NzNiNzVkNTA5/NzA5OTI1ZTFmOTc4/MzU3OTlhYzU5OTVi/ZGNjNTY4MS93d3cu/ZWxldmVuZm9ydW0u/Y29tLw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "elevenforum.com",
+					"hostname": "www.elevenforum.com",
+					"favicon": "https://imgs.search.brave.com/XVRAYMEj6Im8i7jV5RxeTwpiRPtY9IWg4wRIuh-WhEw/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvZjk5MDZkMDIw/M2U1OWIwNjM5Y2U1/M2U2NzNiNzVkNTA5/NzA5OTI1ZTFmOTc4/MzU3OTlhYzU5OTVi/ZGNjNTY4MS93d3cu/ZWxldmVuZm9ydW0u/Y29tLw",
+					"path": "  › windows support forums  › apps and software"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/DVoFcE6d_-lx3BVGNS-RZK_lZzxQ8VhwZVf3AVqEJFA/rs:fit:200:200:1/g:ce/aHR0cHM6Ly93d3cu/ZWxldmVuZm9ydW0u/Y29tL2RhdGEvYXNz/ZXRzL2xvZ28vbWV0/YTEtMjAxLnBuZw",
+					"original": "https://www.elevenforum.com/data/assets/logo/meta1-201.png",
+					"logo": true
+				},
+				"age": "2 days ago",
+				"extra_snippets": [
+					"Why are python.exe and python3.exe listed as App Installer? I assume that App Installer refers to installation of Microsoft Store / UWP apps, but if that's the case, then why are they called python.exe and python3.exe? Or are python.exe and python3.exe simply serving as aliases / pointers pointing to App Installer, which is itself a Microsoft Store App?",
+					"Or are python.exe and python3.exe simply serving as aliases / pointers pointing to App Installer, which is itself a Microsoft Store App? I wish to soon install Python, along with an integrated development editor (IDE), on my machine, so that I can code in Python.",
+					"I wish to soon install Python, along with an integrated development editor (IDE), on my machine, so that I can code in Python. But is a Python interpreter already on my computer as suggested, if obliquely, by the presence of python.exe and python3.exe? I kind of doubt it."
+				]
+			},
+			{
+				"title": "How to Watermark Your Images Using Python OpenCV in ...",
+				"url": "https://medium.com/@daily_data_prep/how-to-watermark-your-images-using-python-opencv-in-bulk-e472085389a1",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Medium is an open platform where readers find dynamic thinking, and where expert and undiscovered voices can share their writing on any topic.",
+				"page_age": "2024-05-03T14:05:06",
+				"profile": {
+					"name": "Medium",
+					"url": "https://medium.com/@daily_data_prep/how-to-watermark-your-images-using-python-opencv-in-bulk-e472085389a1",
+					"long_name": "medium.com",
+					"img": "https://imgs.search.brave.com/qvE2kIQCiAsnPv2C6P9xM5J2VVWdm55g-A-2Q_yIJ0g/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTZhYmQ1N2Q4/NDg4ZDcyODIyMDZi/MzFmOWNhNjE3Y2E4/Y2YzMThjNjljNDIx/ZjllZmNhYTcwODhl/YTcwNDEzYy9tZWRp/dW0uY29tLw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "medium.com",
+					"hostname": "medium.com",
+					"favicon": "https://imgs.search.brave.com/qvE2kIQCiAsnPv2C6P9xM5J2VVWdm55g-A-2Q_yIJ0g/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvOTZhYmQ1N2Q4/NDg4ZDcyODIyMDZi/MzFmOWNhNjE3Y2E4/Y2YzMThjNjljNDIx/ZjllZmNhYTcwODhl/YTcwNDEzYy9tZWRp/dW0uY29tLw",
+					"path": "› @daily_data_prep  › how-to-watermark-your-images-using-python-opencv-in-bulk-e472085389a1"
+				},
+				"age": "2 days ago"
+			},
+			{
+				"title": "Increment and Decrement Operators in Python?",
+				"url": "https://www.tutorialspoint.com/increment-and-decrement-operators-in-python",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Increment and Decrement Operators in <strong>Python</strong> - <strong>Python</strong> does not have unary increment/decrement operator (++/--). Instead to increment a value, usea += 1to decrement a value, use −a -= 1Example&gt;&gt;&gt; a = 0 &gt;&gt;&gt; &gt;&gt;&gt; #Increment &gt;&gt;&gt; a +=1 &gt;&gt;&gt; &gt;&gt;&gt; #Decrement &gt;&gt;&gt; a -= 1 &gt;&gt;&gt; &gt;&gt;&gt; #value of a &gt;&gt;&gt; a 0Python ...",
+				"page_age": "2023-08-23T00:00:00",
+				"profile": {
+					"name": "Tutorialspoint",
+					"url": "https://www.tutorialspoint.com/increment-and-decrement-operators-in-python",
+					"long_name": "tutorialspoint.com",
+					"img": "https://imgs.search.brave.com/Wt8BSkivPlFwcU5yBtf7YzuvTuRExyd_502cdABCS5c/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjcyYjAzYmVl/ODU4MzZiMjJiYTFh/MjJhZDNmNWE4YzA5/MDgyYTZhMDg3NTYw/M2NiY2NiZTUxN2I5/MjU1MWFmMS93d3cu/dHV0b3JpYWxzcG9p/bnQuY29tLw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "tutorialspoint.com",
+					"hostname": "www.tutorialspoint.com",
+					"favicon": "https://imgs.search.brave.com/Wt8BSkivPlFwcU5yBtf7YzuvTuRExyd_502cdABCS5c/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYjcyYjAzYmVl/ODU4MzZiMjJiYTFh/MjJhZDNmNWE4YzA5/MDgyYTZhMDg3NTYw/M2NiY2NiZTUxN2I5/MjU1MWFmMS93d3cu/dHV0b3JpYWxzcG9p/bnQuY29tLw",
+					"path": "› increment-and-decrement-operators-in-python"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/ddG5vyZGLVudvecEbQJPeG8tGuaZ7g3Xz6Gyjdl5WA8/rs:fit:200:200:1/g:ce/aHR0cHM6Ly93d3cu/dHV0b3JpYWxzcG9p/bnQuY29tL2ltYWdl/cy90cF9sb2dvXzQz/Ni5wbmc",
+					"original": "https://www.tutorialspoint.com/images/tp_logo_436.png",
+					"logo": true
+				},
+				"age": "August 23, 2023",
+				"extra_snippets": [
+					"Increment and Decrement Operators in Python - Python does not have unary increment/decrement operator (++/--). Instead to increment a value, usea += 1to decrement a value, use −a -= 1Example>>> a = 0 >>> >>> #Increment >>> a +=1 >>> >>> #Decrement >>> a -= 1 >>> >>> #value of a >>> a 0Python does not provide multiple ways to do the same thing",
+					"So what above statement means in python is: create an object of type int having value 1 and give the name a to it. The object is an instance of int having value 1 and the name a refers to it. The assigned name a and the object to which it refers are distinct.",
+					"Python does not provide multiple ways to do the same thing .",
+					"However, be careful if you are coming from a language like C, Python doesn’t have \"variables\" in the sense that C does, instead python uses names and objects and in python integers (int’s) are immutable."
+				]
+			},
+			{
+				"title": "Gumroad – How not to suck at Python / SideFX Houdini | CG Persia",
+				"url": "https://cgpersia.com/2024/05/gumroad-how-not-to-suck-at-python-sidefx-houdini-195370.html",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "Info: This course is made for artists or TD (technical director) willing to learn <strong>Python</strong> to improve their workflows inside SideFX Houdini, get faster in production and develop all the tools you always wished you had.",
+				"page_age": "2024-05-03T08:35:03",
+				"profile": {
+					"name": "Cgpersia",
+					"url": "https://cgpersia.com/2024/05/gumroad-how-not-to-suck-at-python-sidefx-houdini-195370.html",
+					"long_name": "cgpersia.com",
+					"img": "https://imgs.search.brave.com/VjyaopAm-M9sWvM7n-KnGZ3T5swIOwwE80iF5QVqQPg/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYmE0MzQ4NmI2/NjFhMTA1ZDBiN2Iw/ZWNiNDUxNjUwYjdh/MGE5ZjQ0ZjIxNzll/NmVkZDE2YzYyMDBh/NDNiMDgwMy9jZ3Bl/cnNpYS5jb20v"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "cgpersia.com",
+					"hostname": "cgpersia.com",
+					"favicon": "https://imgs.search.brave.com/VjyaopAm-M9sWvM7n-KnGZ3T5swIOwwE80iF5QVqQPg/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvYmE0MzQ4NmI2/NjFhMTA1ZDBiN2Iw/ZWNiNDUxNjUwYjdh/MGE5ZjQ0ZjIxNzll/NmVkZDE2YzYyMDBh/NDNiMDgwMy9jZ3Bl/cnNpYS5jb20v",
+					"path": "› 2024  › 05  › gumroad-how-not-to-suck-at-python-sidefx-houdini-195370.html"
+				},
+				"age": "2 days ago",
+				"extra_snippets": [
+					"Posted in: 2D, CG Releases, Downloads, Learning, Tutorials, Videos. Tagged: Gumroad, Python, Sidefx. Leave a Comment",
+					"01 – Python – Fundamentals Get the Fundamentals of python before starting the fun stuff ! 02 – Python Construction Part02 digging further into python concepts 03 – Houdini – Python Basics Applying some basic python in Houdini and starting to make tools !",
+					"02 – Python Construction Part02 digging further into python concepts 03 – Houdini – Python Basics Applying some basic python in Houdini and starting to make tools ! 04 – Houdini – Python Intermediate Applying some more advanced python in Houdini to make tools ! 05 – Houdini – Python Expert Using QtDesigner in combinaison with Houdini Python/Pyside to create advanced tools."
+				]
+			},
+			{
+				"title": "How to install Python: The complete Python programmer’s guide",
+				"url": "https://www.pluralsight.com/resources/blog/software-development/python-installation-guide",
+				"is_source_local": false,
+				"is_source_both": false,
+				"description": "An easy guide on how set up your operating system so you can program in <strong>Python</strong>, and how to update or uninstall it. For Linux, Windows, and macOS.",
+				"page_age": "2024-05-02T07:30:02",
+				"profile": {
+					"name": "Pluralsight",
+					"url": "https://www.pluralsight.com/resources/blog/software-development/python-installation-guide",
+					"long_name": "pluralsight.com",
+					"img": "https://imgs.search.brave.com/zvwQNSVu9-jR2CRlNcsTzxjaXKPlXNuh-Jo9-0yA1OE/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTNkNWQyNjk3/M2Q0NzYyMmUyNDc3/ZjYwMWFlZDI5YTI4/ODhmYzc2MDkzMjAy/MjNkMWY1MDE3NTQw/MzI5NWVkZS93d3cu/cGx1cmFsc2lnaHQu/Y29tLw"
+				},
+				"language": "en",
+				"family_friendly": true,
+				"type": "search_result",
+				"subtype": "generic",
+				"meta_url": {
+					"scheme": "https",
+					"netloc": "pluralsight.com",
+					"hostname": "www.pluralsight.com",
+					"favicon": "https://imgs.search.brave.com/zvwQNSVu9-jR2CRlNcsTzxjaXKPlXNuh-Jo9-0yA1OE/rs:fit:32:32:1/g:ce/aHR0cDovL2Zhdmlj/b25zLnNlYXJjaC5i/cmF2ZS5jb20vaWNv/bnMvMTNkNWQyNjk3/M2Q0NzYyMmUyNDc3/ZjYwMWFlZDI5YTI4/ODhmYzc2MDkzMjAy/MjNkMWY1MDE3NTQw/MzI5NWVkZS93d3cu/cGx1cmFsc2lnaHQu/Y29tLw",
+					"path": "  › blog  › blog"
+				},
+				"thumbnail": {
+					"src": "https://imgs.search.brave.com/xrv5PHH2Bzmq2rcIYzk__8h5RqCj6kS3I6SGCNw5dZM/rs:fit:200:200:1/g:ce/aHR0cHM6Ly93d3cu/cGx1cmFsc2lnaHQu/Y29tL2NvbnRlbnQv/ZGFtL3BzL2ltYWdl/cy9yZXNvdXJjZS1j/ZW50ZXIvYmxvZy9o/ZWFkZXItaGVyby1p/bWFnZXMvUHl0aG9u/LndlYnA",
+					"original": "https://www.pluralsight.com/content/dam/ps/images/resource-center/blog/header-hero-images/Python.webp",
+					"logo": false
+				},
+				"age": "3 days ago",
+				"extra_snippets": [
+					"Whether it’s your first time programming or you’re a seasoned programmer, you’ll have to install or update Python every now and then --- or if necessary, uninstall it. In this article, you'll learn how to do just that.",
+					"Some systems come with Python, so to start off, we’ll first check to see if it’s installed on your system before we proceed. To do that, we’ll need to open a terminal. Since you might be new to programming, let’s go over how to open a terminal for Linux, Windows, and macOS.",
+					"Before we dive into setting up your system so you can program in Python, let’s talk terminal basics and benefits.",
+					"However, let’s focus on why we need it for working with Python. We use a terminal, or command line, to:"
+				]
+			}
+		],
+		"family_friendly": true
+	}
+}

+ 442 - 0
backend/apps/rag/search/testdata/google_pse.json

@@ -0,0 +1,442 @@
+{
+	"kind": "customsearch#search",
+	"url": {
+		"type": "application/json",
+		"template": "https://www.googleapis.com/customsearch/v1?q={searchTerms}&num={count?}&start={startIndex?}&lr={language?}&safe={safe?}&cx={cx?}&sort={sort?}&filter={filter?}&gl={gl?}&cr={cr?}&googlehost={googleHost?}&c2coff={disableCnTwTranslation?}&hq={hq?}&hl={hl?}&siteSearch={siteSearch?}&siteSearchFilter={siteSearchFilter?}&exactTerms={exactTerms?}&excludeTerms={excludeTerms?}&linkSite={linkSite?}&orTerms={orTerms?}&dateRestrict={dateRestrict?}&lowRange={lowRange?}&highRange={highRange?}&searchType={searchType}&fileType={fileType?}&rights={rights?}&imgSize={imgSize?}&imgType={imgType?}&imgColorType={imgColorType?}&imgDominantColor={imgDominantColor?}&alt=json"
+	},
+	"queries": {
+		"request": [
+			{
+				"title": "Google Custom Search - lectures",
+				"totalResults": "2450000000",
+				"searchTerms": "lectures",
+				"count": 10,
+				"startIndex": 1,
+				"inputEncoding": "utf8",
+				"outputEncoding": "utf8",
+				"safe": "off",
+				"cx": "0473ef98502d44e18"
+			}
+		],
+		"nextPage": [
+			{
+				"title": "Google Custom Search - lectures",
+				"totalResults": "2450000000",
+				"searchTerms": "lectures",
+				"count": 10,
+				"startIndex": 11,
+				"inputEncoding": "utf8",
+				"outputEncoding": "utf8",
+				"safe": "off",
+				"cx": "0473ef98502d44e18"
+			}
+		]
+	},
+	"context": {
+		"title": "LLM Search"
+	},
+	"searchInformation": {
+		"searchTime": 0.445959,
+		"formattedSearchTime": "0.45",
+		"totalResults": "2450000000",
+		"formattedTotalResults": "2,450,000,000"
+	},
+	"items": [
+		{
+			"kind": "customsearch#result",
+			"title": "The Feynman Lectures on Physics",
+			"htmlTitle": "The Feynman \u003cb\u003eLectures\u003c/b\u003e on Physics",
+			"link": "https://www.feynmanlectures.caltech.edu/",
+			"displayLink": "www.feynmanlectures.caltech.edu",
+			"snippet": "This edition has been designed for ease of reading on devices of any size or shape; text, figures and equations can all be zoomed without degradation.",
+			"htmlSnippet": "This edition has been designed for ease of reading on devices of any size or shape; text, figures and equations can all be zoomed without degradation.",
+			"cacheId": "CyXMWYWs9UEJ",
+			"formattedUrl": "https://www.feynmanlectures.caltech.edu/",
+			"htmlFormattedUrl": "https://www.feynman\u003cb\u003electures\u003c/b\u003e.caltech.edu/",
+			"pagemap": {
+				"metatags": [
+					{
+						"viewport": "width=device-width, initial-scale=1.0"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Video Lectures",
+			"htmlTitle": "Video \u003cb\u003eLectures\u003c/b\u003e",
+			"link": "https://www.reddit.com/r/lectures/",
+			"displayLink": "www.reddit.com",
+			"snippet": "r/lectures: This subreddit is all about video lectures, talks and interesting public speeches. The topics include mathematics, physics, computer…",
+			"htmlSnippet": "r/\u003cb\u003electures\u003c/b\u003e: This subreddit is all about video \u003cb\u003electures\u003c/b\u003e, talks and interesting public speeches. The topics include mathematics, physics, computer…",
+			"formattedUrl": "https://www.reddit.com/r/lectures/",
+			"htmlFormattedUrl": "https://www.reddit.com/r/\u003cb\u003electures\u003c/b\u003e/",
+			"pagemap": {
+				"cse_thumbnail": [
+					{
+						"src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTZtOjhfkgUKQbL3DZxe5F6OVsgeDNffleObjJ7n9RllKQTSsimax7VIaY&s",
+						"width": "192",
+						"height": "192"
+					}
+				],
+				"metatags": [
+					{
+						"og:image": "https://www.redditstatic.com/shreddit/assets/favicon/192x192.png",
+						"theme-color": "#000000",
+						"og:image:width": "256",
+						"og:type": "website",
+						"twitter:card": "summary",
+						"twitter:title": "r/lectures",
+						"og:site_name": "Reddit",
+						"og:title": "r/lectures",
+						"og:image:height": "256",
+						"bingbot": "noarchive",
+						"msapplication-navbutton-color": "#000000",
+						"og:description": "This subreddit is all about video lectures, talks and interesting public speeches.\n\nThe topics include mathematics, physics, computer science, programming, engineering, biology, medicine, economics, politics, social sciences, and any other subjects!",
+						"twitter:image": "https://www.redditstatic.com/shreddit/assets/favicon/192x192.png",
+						"apple-mobile-web-app-status-bar-style": "black",
+						"twitter:site": "@reddit",
+						"viewport": "width=device-width, initial-scale=1, viewport-fit=cover",
+						"apple-mobile-web-app-capable": "yes",
+						"og:ttl": "600",
+						"og:url": "https://www.reddit.com/r/lectures/"
+					}
+				],
+				"cse_image": [
+					{
+						"src": "https://www.redditstatic.com/shreddit/assets/favicon/192x192.png"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Lectures & Discussions | Flint Institute of Arts",
+			"htmlTitle": "\u003cb\u003eLectures\u003c/b\u003e &amp; Discussions | Flint Institute of Arts",
+			"link": "https://flintarts.org/events/lectures",
+			"displayLink": "flintarts.org",
+			"snippet": "It will trace the intricate relationship between jewelry, attire, and the expression of personal identity, social hierarchy, and spiritual belief systems that ...",
+			"htmlSnippet": "It will trace the intricate relationship between jewelry, attire, and the expression of personal identity, social hierarchy, and spiritual belief systems that&nbsp;...",
+			"cacheId": "jvpb9DxrfxoJ",
+			"formattedUrl": "https://flintarts.org/events/lectures",
+			"htmlFormattedUrl": "https://flintarts.org/events/\u003cb\u003electures\u003c/b\u003e",
+			"pagemap": {
+				"cse_thumbnail": [
+					{
+						"src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcS23tMtAeNhJbOWdGxShYsmnyzFdzOC9Hb7lRykA9Pw72z1IlKTkjTdZw&s",
+						"width": "447",
+						"height": "113"
+					}
+				],
+				"metatags": [
+					{
+						"og:image": "https://flintarts.org/uploads/images/page-headers/_headerImage/nightshot.jpg",
+						"og:type": "website",
+						"viewport": "width=device-width, initial-scale=1",
+						"og:title": "Lectures & Discussions | Flint Institute of Arts",
+						"og:description": "The Flint Institute of Arts is the second largest art museum in Michigan and one of the largest museum art schools in the nation."
+					}
+				],
+				"cse_image": [
+					{
+						"src": "https://flintarts.org/uploads/images/page-headers/_headerImage/nightshot.jpg"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Mandel Lectures | Mandel Center for the Humanities ... - Waltham",
+			"htmlTitle": "Mandel \u003cb\u003eLectures\u003c/b\u003e | Mandel Center for the Humanities ... - Waltham",
+			"link": "https://www.brandeis.edu/mandel-center-humanities/mandel-lectures.html",
+			"displayLink": "www.brandeis.edu",
+			"snippet": "Past Lectures · Lecture 1: \"Invisible Music: The Sonic Idea of Black Revolution From Captivity to Reconstruction\" · Lecture 2: \"Solidarity in Sound: Grassroots ...",
+			"htmlSnippet": "Past \u003cb\u003eLectures\u003c/b\u003e &middot; \u003cb\u003eLecture\u003c/b\u003e 1: &quot;Invisible Music: The Sonic Idea of Black Revolution From Captivity to Reconstruction&quot; &middot; \u003cb\u003eLecture\u003c/b\u003e 2: &quot;Solidarity in Sound: Grassroots&nbsp;...",
+			"cacheId": "cQLOZr0kgEEJ",
+			"formattedUrl": "https://www.brandeis.edu/mandel-center-humanities/mandel-lectures.html",
+			"htmlFormattedUrl": "https://www.brandeis.edu/mandel-center-humanities/mandel-\u003cb\u003electures\u003c/b\u003e.html",
+			"pagemap": {
+				"cse_thumbnail": [
+					{
+						"src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQWlU7bcJ5pIHk7RBCk2QKE-48ejF7hyPV0pr-20_cBt2BGdfKtiYXBuyw&s",
+						"width": "275",
+						"height": "183"
+					}
+				],
+				"metatags": [
+					{
+						"og:image": "https://www.brandeis.edu/mandel-center-humanities/events/events-images/mlhzumba",
+						"twitter:card": "summary_large_image",
+						"viewport": "width=device-width,initial-scale=1,minimum-scale=1",
+						"og:title": "Mandel Lectures in the Humanities",
+						"og:url": "https://www.brandeis.edu/mandel-center-humanities/mandel-lectures.html",
+						"og:description": "Annual Lecture Series",
+						"twitter:image": "https://www.brandeis.edu/mandel-center-humanities/events/events-images/mlhzumba"
+					}
+				],
+				"cse_image": [
+					{
+						"src": "https://www.brandeis.edu/mandel-center-humanities/events/events-images/mlhzumba"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Brian Douglas - YouTube",
+			"htmlTitle": "Brian Douglas - YouTube",
+			"link": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+			"displayLink": "www.youtube.com",
+			"snippet": "Welcome to Control Systems Lectures! This collection of videos is intended to supplement a first year controls class, not replace it.",
+			"htmlSnippet": "Welcome to Control Systems \u003cb\u003eLectures\u003c/b\u003e! This collection of videos is intended to supplement a first year controls class, not replace it.",
+			"cacheId": "NEROyBHolL0J",
+			"formattedUrl": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+			"htmlFormattedUrl": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+			"pagemap": {
+				"hcard": [
+					{
+						"fn": "Brian Douglas",
+						"url": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg"
+					}
+				],
+				"cse_thumbnail": [
+					{
+						"src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcR7G0CeCBz_wVTZgjnhEr2QbiKP7f3uYzKitZYn74Mi32cDmVxvsegJoLI&s",
+						"width": "225",
+						"height": "225"
+					}
+				],
+				"imageobject": [
+					{
+						"width": "900",
+						"url": "https://yt3.googleusercontent.com/ytc/AIdro_nLo68wetImbwGUYP3stve_iKmAEccjhqB-q4o79xdInN4=s900-c-k-c0x00ffffff-no-rj",
+						"height": "900"
+					}
+				],
+				"person": [
+					{
+						"name": "Brian Douglas",
+						"url": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg"
+					}
+				],
+				"metatags": [
+					{
+						"apple-itunes-app": "app-id=544007664, app-argument=https://m.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg?referring_app=com.apple.mobilesafari-smartbanner, affiliate-data=ct=smart_app_banner_polymer&pt=9008",
+						"og:image": "https://yt3.googleusercontent.com/ytc/AIdro_nLo68wetImbwGUYP3stve_iKmAEccjhqB-q4o79xdInN4=s900-c-k-c0x00ffffff-no-rj",
+						"twitter:app:url:iphone": "vnd.youtube://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+						"twitter:app:id:googleplay": "com.google.android.youtube",
+						"theme-color": "rgb(255, 255, 255)",
+						"og:image:width": "900",
+						"twitter:card": "summary",
+						"og:site_name": "YouTube",
+						"twitter:url": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+						"twitter:app:url:ipad": "vnd.youtube://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+						"al:android:package": "com.google.android.youtube",
+						"twitter:app:name:googleplay": "YouTube",
+						"al:ios:url": "vnd.youtube://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+						"twitter:app:id:iphone": "544007664",
+						"og:description": "Welcome to Control Systems Lectures!  This collection of videos is intended to supplement a first year controls class, not replace it.  My goal is to take specific concepts in controls and expand on them in order to provide an intuitive understanding which will ultimately make you a better controls engineer.  \n\nI'm glad you made it to my channel and I hope you find it useful.\n\nShoot me a message at controlsystemlectures@gmail.com, leave a comment or question and I'll get back to you if I can. Don't forget to subscribe!\n \nTwitter: @BrianBDouglas for engineering tweets and announcement of new videos.\nWebpage: http://engineeringmedia.com\n\nHere is the hardware/software I use: http://www.youtube.com/watch?v=m-M5_mIyHe4\n\nHere's a list of my favorite references: http://bit.ly/2skvmWd\n\n--Brian",
+						"al:ios:app_store_id": "544007664",
+						"twitter:image": "https://yt3.googleusercontent.com/ytc/AIdro_nLo68wetImbwGUYP3stve_iKmAEccjhqB-q4o79xdInN4=s900-c-k-c0x00ffffff-no-rj",
+						"twitter:site": "@youtube",
+						"og:type": "profile",
+						"twitter:title": "Brian Douglas",
+						"al:ios:app_name": "YouTube",
+						"og:title": "Brian Douglas",
+						"og:image:height": "900",
+						"twitter:app:id:ipad": "544007664",
+						"al:web:url": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg?feature=applinks",
+						"al:android:url": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg?feature=applinks",
+						"fb:app_id": "87741124305",
+						"twitter:app:url:googleplay": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+						"twitter:app:name:ipad": "YouTube",
+						"viewport": "width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no,",
+						"twitter:description": "Welcome to Control Systems Lectures!  This collection of videos is intended to supplement a first year controls class, not replace it.  My goal is to take specific concepts in controls and expand on them in order to provide an intuitive understanding which will ultimately make you a better controls engineer.  \n\nI'm glad you made it to my channel and I hope you find it useful.\n\nShoot me a message at controlsystemlectures@gmail.com, leave a comment or question and I'll get back to you if I can. Don't forget to subscribe!\n \nTwitter: @BrianBDouglas for engineering tweets and announcement of new videos.\nWebpage: http://engineeringmedia.com\n\nHere is the hardware/software I use: http://www.youtube.com/watch?v=m-M5_mIyHe4\n\nHere's a list of my favorite references: http://bit.ly/2skvmWd\n\n--Brian",
+						"og:url": "https://www.youtube.com/channel/UCq0imsn84ShAe9PBOFnoIrg",
+						"al:android:app_name": "YouTube",
+						"twitter:app:name:iphone": "YouTube"
+					}
+				],
+				"cse_image": [
+					{
+						"src": "https://yt3.googleusercontent.com/ytc/AIdro_nLo68wetImbwGUYP3stve_iKmAEccjhqB-q4o79xdInN4=s900-c-k-c0x00ffffff-no-rj"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Lecture - Wikipedia",
+			"htmlTitle": "\u003cb\u003eLecture\u003c/b\u003e - Wikipedia",
+			"link": "https://en.wikipedia.org/wiki/Lecture",
+			"displayLink": "en.wikipedia.org",
+			"snippet": "Lecture ... For the academic rank, see Lecturer. A lecture (from Latin: lēctūra 'reading') is an oral presentation intended to present information or teach people ...",
+			"htmlSnippet": "\u003cb\u003eLecture\u003c/b\u003e ... For the academic rank, see \u003cb\u003eLecturer\u003c/b\u003e. A \u003cb\u003electure\u003c/b\u003e (from Latin: lēctūra &#39;reading&#39;) is an oral presentation intended to present information or teach people&nbsp;...",
+			"cacheId": "d9Pjta02fmgJ",
+			"formattedUrl": "https://en.wikipedia.org/wiki/Lecture",
+			"htmlFormattedUrl": "https://en.wikipedia.org/wiki/Lecture",
+			"pagemap": {
+				"metatags": [
+					{
+						"referrer": "origin",
+						"og:image": "https://upload.wikimedia.org/wikipedia/commons/thumb/2/26/ADFA_Lecture_Theatres.jpg/1200px-ADFA_Lecture_Theatres.jpg",
+						"theme-color": "#eaecf0",
+						"og:image:width": "1200",
+						"og:type": "website",
+						"viewport": "width=device-width, initial-scale=1.0, user-scalable=yes, minimum-scale=0.25, maximum-scale=5.0",
+						"og:title": "Lecture - Wikipedia",
+						"og:image:height": "799",
+						"format-detection": "telephone=no"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Mount Wilson Observatory | Lectures",
+			"htmlTitle": "Mount Wilson Observatory | \u003cb\u003eLectures\u003c/b\u003e",
+			"link": "https://www.mtwilson.edu/lectures/",
+			"displayLink": "www.mtwilson.edu",
+			"snippet": "Talks & Telescopes: August 24, 2024 – Panel: The Triumph of Hubble ... Compelling talks followed by picnicking and convivial stargazing through both the big ...",
+			"htmlSnippet": "Talks &amp; Telescopes: August 24, 2024 – Panel: The Triumph of Hubble ... Compelling talks followed by picnicking and convivial stargazing through both the big&nbsp;...",
+			"cacheId": "wdXI0azqx5UJ",
+			"formattedUrl": "https://www.mtwilson.edu/lectures/",
+			"htmlFormattedUrl": "https://www.mtwilson.edu/\u003cb\u003electures\u003c/b\u003e/",
+			"pagemap": {
+				"metatags": [
+					{
+						"viewport": "width=device-width,initial-scale=1,user-scalable=no"
+					}
+				],
+				"webpage": [
+					{
+						"image": "http://www.mtwilson.edu/wp-content/uploads/2016/09/Logo.jpg",
+						"url": "https://www.facebook.com/WilsonObs"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Lectures | NBER",
+			"htmlTitle": "\u003cb\u003eLectures\u003c/b\u003e | NBER",
+			"link": "https://www.nber.org/research/lectures",
+			"displayLink": "www.nber.org",
+			"snippet": "Results 1 - 50 of 354 ... Among featured events at the NBER Summer Institute are the Martin Feldstein Lecture, which examines a current issue involving economic ...",
+			"htmlSnippet": "Results 1 - 50 of 354 \u003cb\u003e...\u003c/b\u003e Among featured events at the NBER Summer Institute are the Martin Feldstein \u003cb\u003eLecture\u003c/b\u003e, which examines a current issue involving economic&nbsp;...",
+			"cacheId": "CvvP3U3nb44J",
+			"formattedUrl": "https://www.nber.org/research/lectures",
+			"htmlFormattedUrl": "https://www.nber.org/research/\u003cb\u003electures\u003c/b\u003e",
+			"pagemap": {
+				"cse_thumbnail": [
+					{
+						"src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcTmeViEZyV1YmFEFLhcA6WdgAG3v3RV6tB93ncyxSJ5JPst_p2aWrL7D1k&s",
+						"width": "310",
+						"height": "163"
+					}
+				],
+				"metatags": [
+					{
+						"og:image": "https://www.nber.org/sites/default/files/2022-06/NBER-FB-Share-Tile-1200.jpg",
+						"og:site_name": "NBER",
+						"handheldfriendly": "true",
+						"viewport": "width=device-width, initial-scale=1.0",
+						"og:title": "Lectures",
+						"mobileoptimized": "width",
+						"og:url": "https://www.nber.org/research/lectures"
+					}
+				],
+				"cse_image": [
+					{
+						"src": "https://www.nber.org/sites/default/files/2022-06/NBER-FB-Share-Tile-1200.jpg"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "STUDENTS CANNOT ACCESS RECORDED LECTURES ... - Solved",
+			"htmlTitle": "STUDENTS CANNOT ACCESS RECORDED LECTURES ... - Solved",
+			"link": "https://community.canvaslms.com/t5/Canvas-Question-Forum/STUDENTS-CANNOT-ACCESS-RECORDED-LECTURES/td-p/190358",
+			"displayLink": "community.canvaslms.com",
+			"snippet": "Mar 19, 2020 ... I believe the issue is that students were not invited. Are you trying to capture your screen? If not, there is an option to just record your web ...",
+			"htmlSnippet": "Mar 19, 2020 \u003cb\u003e...\u003c/b\u003e I believe the issue is that students were not invited. Are you trying to capture your screen? If not, there is an option to just record your web&nbsp;...",
+			"cacheId": "wqrynQXX61sJ",
+			"formattedUrl": "https://community.canvaslms.com/t5/Canvas...LECTURES/td-p/190358",
+			"htmlFormattedUrl": "https://community.canvaslms.com/t5/Canvas...\u003cb\u003eLECTURES\u003c/b\u003e/td-p/190358",
+			"pagemap": {
+				"cse_thumbnail": [
+					{
+						"src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcRUqXau3N8LfKgSD7OJOvV7xzGarLKRU-ckWXy1ZQ1p4CLPsedvLKmLMhk&s",
+						"width": "310",
+						"height": "163"
+					}
+				],
+				"metatags": [
+					{
+						"og:image": "https://community.canvaslms.com/html/@6A1FDD4D5FF35E4BBB4083A1022FA0DB/assets/CommunityPreview23.png",
+						"og:type": "article",
+						"article:section": "Canvas Question Forum",
+						"article:published_time": "2020-03-19T15:50:03.409Z",
+						"og:site_name": "Instructure Community",
+						"article:modified_time": "2020-03-19T13:55:53-07:00",
+						"viewport": "width=device-width, initial-scale=1.0, user-scalable=yes",
+						"og:title": "STUDENTS CANNOT ACCESS RECORDED LECTURES",
+						"og:url": "https://community.canvaslms.com/t5/Canvas-Question-Forum/STUDENTS-CANNOT-ACCESS-RECORDED-LECTURES/m-p/190358#M93667",
+						"og:description": "I can access and see my recorded lectures but my students can't. They have an error message when they try to open the recorded presentation or notes.",
+						"article:author": "https://community.canvaslms.com/t5/user/viewprofilepage/user-id/794287",
+						"twitter:image": "https://community.canvaslms.com/html/@6A1FDD4D5FF35E4BBB4083A1022FA0DB/assets/CommunityPreview23.png"
+					}
+				],
+				"cse_image": [
+					{
+						"src": "https://community.canvaslms.com/html/@6A1FDD4D5FF35E4BBB4083A1022FA0DB/assets/CommunityPreview23.png"
+					}
+				]
+			}
+		},
+		{
+			"kind": "customsearch#result",
+			"title": "Public Lecture Series - Sam Fox School of Design & Visual Arts",
+			"htmlTitle": "Public \u003cb\u003eLecture\u003c/b\u003e Series - Sam Fox School of Design &amp; Visual Arts",
+			"link": "https://samfoxschool.wustl.edu/calendar/series/2-public-lecture-series",
+			"displayLink": "samfoxschool.wustl.edu",
+			"snippet": "The Sam Fox School's Spring 2024 Public Lecture Series highlights design and art as catalysts for change. Renowned speakers will delve into themes like ...",
+			"htmlSnippet": "The Sam Fox School&#39;s Spring 2024 Public \u003cb\u003eLecture\u003c/b\u003e Series highlights design and art as catalysts for change. Renowned speakers will delve into themes like&nbsp;...",
+			"cacheId": "B-cgQG0j6tUJ",
+			"formattedUrl": "https://samfoxschool.wustl.edu/calendar/series/2-public-lecture-series",
+			"htmlFormattedUrl": "https://samfoxschool.wustl.edu/calendar/series/2-public-lecture-series",
+			"pagemap": {
+				"cse_thumbnail": [
+					{
+						"src": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQSmHaGianm-64m-qauYjkPK_Q0JKWe-7yom4m1ogFYTmpWArA7k6dmk0sR&s",
+						"width": "307",
+						"height": "164"
+					}
+				],
+				"website": [
+					{
+						"name": "Public Lecture Series - Sam Fox School of Design & Visual Arts — Washington University in St. Louis"
+					}
+				],
+				"metatags": [
+					{
+						"og:image": "https://dvsp0hlm0xrn3.cloudfront.net/assets/default_og_image-44e73dee4b9d1e2c6a6295901371270c8ec5899eaed48ee8167a9b12f1b0f8b3.jpg",
+						"og:type": "website",
+						"og:site_name": "Sam Fox School of Design & Visual Arts — Washington University in St. Louis",
+						"viewport": "width=device-width, initial-scale=1.0",
+						"og:title": "Public Lecture Series - Sam Fox School of Design & Visual Arts — Washington University in St. Louis",
+						"csrf-token": "jBQsfZGY3RH8NVs0-KVDBYB-2N2kib4UYZHYdrShfTdLkvzfSvGeOaMrRKTRdYBPRKzdcGIuP7zwm9etqX_uvg",
+						"csrf-param": "authenticity_token",
+						"og:description": "The Sam Fox School's Spring 2024 Public Lecture Series highlights design and art as catalysts for change. Renowned speakers will delve into themes like social equity, resilient cities, and the impact of emerging technologies on contemporary life. Speakers include artists, architects, designers, and critics of the highest caliber, widely recognized for their research-based practices and multidisciplinary approaches to their fields."
+					}
+				],
+				"cse_image": [
+					{
+						"src": "https://dvsp0hlm0xrn3.cloudfront.net/assets/default_og_image-44e73dee4b9d1e2c6a6295901371270c8ec5899eaed48ee8167a9b12f1b0f8b3.jpg"
+					}
+				]
+			}
+		}
+	]
+}

+ 476 - 0
backend/apps/rag/search/testdata/searxng.json

@@ -0,0 +1,476 @@
+{
+	"query": "python",
+	"number_of_results": 116000000,
+	"results": [
+		{
+			"url": "https://www.python.org/",
+			"title": "Welcome to Python.org",
+			"content": "Python is a versatile and powerful language that lets you work quickly and integrate systems more effectively. Learn how to get started, download the latest version, access documentation, find jobs, and join the Python community.",
+			"engine": "bing",
+			"parsed_url": ["https", "www.python.org", "/", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "qwant", "duckduckgo"],
+			"positions": [1, 1, 1],
+			"score": 9.0,
+			"category": "general"
+		},
+		{
+			"url": "https://wiki.nerdvpn.de/wiki/Python_(programming_language)",
+			"title": "Python (programming language) - Wikipedia",
+			"content": "Python is a high-level, general-purpose programming language. Its design philosophy emphasizes code readability with the use of significant indentation. Python is dynamically typed and garbage-collected. It supports multiple programming paradigms, including structured (particularly procedural), object-oriented and functional programming.",
+			"engine": "bing",
+			"parsed_url": ["https", "wiki.nerdvpn.de", "/wiki/Python_(programming_language)", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "qwant", "duckduckgo"],
+			"positions": [4, 3, 2],
+			"score": 3.25,
+			"category": "general"
+		},
+		{
+			"url": "https://docs.python.org/3/tutorial/index.html",
+			"title": "The Python Tutorial \u2014 Python 3.12.3 documentation",
+			"content": "3 days ago \u00b7 Python is an easy to learn, powerful programming language. It has efficient high-level data structures and a simple but effective approach to object-oriented programming. Python\u2019s elegant syntax and dynamic typing, together with its interpreted nature, make it an ideal language for scripting and rapid application development in many \u2026",
+			"engine": "bing",
+			"parsed_url": ["https", "docs.python.org", "/3/tutorial/index.html", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "qwant", "duckduckgo"],
+			"positions": [5, 5, 3],
+			"score": 2.2,
+			"category": "general"
+		},
+		{
+			"url": "https://www.python.org/downloads/",
+			"title": "Download Python | Python.org",
+			"content": "Python is a popular programming language for various purposes. Find the latest version of Python for different operating systems, download release notes, and learn about the development process.",
+			"engine": "bing",
+			"parsed_url": ["https", "www.python.org", "/downloads/", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "duckduckgo"],
+			"positions": [2, 2],
+			"score": 2.0,
+			"category": "general"
+		},
+		{
+			"url": "https://www.python.org/about/gettingstarted/",
+			"title": "Python For Beginners | Python.org",
+			"content": "Learn the basics of Python, a popular and easy-to-use programming language, from installing it to using it for various purposes. Find out how to access online documentation, tutorials, books, code samples, and more resources to help you get started with Python.",
+			"engine": "bing",
+			"parsed_url": ["https", "www.python.org", "/about/gettingstarted/", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "qwant", "duckduckgo"],
+			"positions": [9, 4, 4],
+			"score": 1.8333333333333333,
+			"category": "general"
+		},
+		{
+			"url": "https://www.python.org/shell/",
+			"title": "Welcome to Python.org",
+			"content": "Python is a versatile and easy-to-use programming language that lets you work quickly. Learn more about Python, download the latest version, access documentation, find jobs, and join the community.",
+			"engine": "bing",
+			"parsed_url": ["https", "www.python.org", "/shell/", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "qwant", "duckduckgo"],
+			"positions": [3, 10, 8],
+			"score": 1.675,
+			"category": "general"
+		},
+		{
+			"url": "https://realpython.com/",
+			"title": "Python Tutorials \u2013 Real Python",
+			"content": "Real Python offers comprehensive and up-to-date tutorials, books, and courses for Python developers of all skill levels. Whether you want to learn Python basics, web development, data science, machine learning, or more, you can find clear and practical guides and code examples here.",
+			"engine": "bing",
+			"parsed_url": ["https", "realpython.com", "/", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "qwant", "duckduckgo"],
+			"positions": [6, 6, 5],
+			"score": 1.6,
+			"category": "general"
+		},
+		{
+			"url": "https://wiki.nerdvpn.de/wiki/Python",
+			"title": "Python",
+			"content": "Topics referred to by the same term",
+			"engine": "wikipedia",
+			"parsed_url": ["https", "wiki.nerdvpn.de", "/wiki/Python", "", "", ""],
+			"template": "default.html",
+			"engines": ["wikipedia"],
+			"positions": [1],
+			"score": 1.0,
+			"category": "general"
+		},
+		{
+			"title": "Online Python - IDE, Editor, Compiler, Interpreter",
+			"content": "Online Python IDE is a free online tool that lets you write, execute, and share Python code in the web browser. Learn about Python, its features, and its popularity as a general-purpose programming language for web development, data science, and more.",
+			"url": "https://www.online-python.com/",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "www.online-python.com", "/", "", "", ""],
+			"template": "default.html",
+			"engines": ["qwant", "duckduckgo"],
+			"positions": [8, 6],
+			"score": 0.5833333333333333,
+			"category": "general"
+		},
+		{
+			"url": "https://micropython.org/",
+			"title": "MicroPython - Python for microcontrollers",
+			"content": "MicroPython is a full Python compiler and runtime that runs on the bare-metal. You get an interactive prompt (the REPL) to execute commands immediately, along ...",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": ["https", "micropython.org", "/", "", "", ""],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [1],
+			"score": 1.0,
+			"category": "general"
+		},
+		{
+			"url": "https://dictionary.cambridge.org/uk/dictionary/english/python",
+			"title": "PYTHON | \u0417\u043d\u0430\u0447\u0435\u043d\u043d\u044f \u0432 \u0430\u043d\u0433\u043b\u0456\u0439\u0441\u044c\u043a\u0456\u0439 \u043c\u043e\u0432\u0456 - Cambridge Dictionary",
+			"content": "Apr 17, 2024 \u2014 \u0412\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u043d\u044f PYTHON: 1. a very large snake that kills animals for food by wrapping itself around them and crushing them\u2026. \u0414\u0456\u0437\u043d\u0430\u0439\u0442\u0435\u0441\u044f \u0431\u0456\u043b\u044c\u0448\u0435.",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": [
+				"https",
+				"dictionary.cambridge.org",
+				"/uk/dictionary/english/python",
+				"",
+				"",
+				""
+			],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [2],
+			"score": 0.5,
+			"category": "general"
+		},
+		{
+			"url": "https://www.codetoday.co.uk/code",
+			"title": "Web-based Python Editor (with Turtle graphics)",
+			"content": "Quick way of starting to write Python code, including drawing with Turtle, provided by CodeToday using Trinket.io Ideal for young children to start ...",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": ["https", "www.codetoday.co.uk", "/code", "", "", ""],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [3],
+			"score": 0.3333333333333333,
+			"category": "general"
+		},
+		{
+			"url": "https://snapcraft.io/docs/python-plugin",
+			"title": "The python plugin | Snapcraft documentation",
+			"content": "The python plugin can be used by either Python 2 or Python 3 based parts using a setup.py script for building the project, or using a package published to ...",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": ["https", "snapcraft.io", "/docs/python-plugin", "", "", ""],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [4],
+			"score": 0.25,
+			"category": "general"
+		},
+		{
+			"url": "https://www.developer-tech.com/categories/developer-languages/developer-languages-python/",
+			"title": "Latest Python Developer News",
+			"content": "Python's status as the primary language for AI and machine learning projects, from its extensive data-handling capabilities to its flexibility and ...",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": [
+				"https",
+				"www.developer-tech.com",
+				"/categories/developer-languages/developer-languages-python/",
+				"",
+				"",
+				""
+			],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [5],
+			"score": 0.2,
+			"category": "general"
+		},
+		{
+			"url": "https://subjectguides.york.ac.uk/coding/python",
+			"title": "Coding: a Practical Guide - Python - Subject Guides",
+			"content": "Python is a coding language used for a wide range of things, including working with data, building systems and software, and even creating games.",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": ["https", "subjectguides.york.ac.uk", "/coding/python", "", "", ""],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [6],
+			"score": 0.16666666666666666,
+			"category": "general"
+		},
+		{
+			"url": "https://hub.salford.ac.uk/psytech/python/getting-started-python/",
+			"title": "Getting Started - Python - Salford PsyTech Home - The Hub",
+			"content": "Python in itself is a very friendly programming language, when we get to grips with writing code, once you grasp the logic, it will become very intuitive.",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": [
+				"https",
+				"hub.salford.ac.uk",
+				"/psytech/python/getting-started-python/",
+				"",
+				"",
+				""
+			],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [7],
+			"score": 0.14285714285714285,
+			"category": "general"
+		},
+		{
+			"url": "https://snapcraft.io/docs/python-apps",
+			"title": "Python apps | Snapcraft documentation",
+			"content": "Snapcraft can be used to package and distribute Python applications in a way that enables convenient installation by users. The process of creating a snap ...",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": ["https", "snapcraft.io", "/docs/python-apps", "", "", ""],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [8],
+			"score": 0.125,
+			"category": "general"
+		},
+		{
+			"url": "https://anvil.works/",
+			"title": "Anvil | Build Web Apps with Nothing but Python",
+			"content": "Anvil is a free Python-based drag-and-drop web app builder.\u200eSign Up \u00b7 \u200eSign in \u00b7 \u200ePricing \u00b7 \u200eForum",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": ["https", "anvil.works", "/", "", "", ""],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [9],
+			"score": 0.1111111111111111,
+			"category": "general"
+		},
+		{
+			"url": "https://docs.python.org/",
+			"title": "Python 3.12.3 documentation",
+			"content": "3 days ago \u00b7 This is the official documentation for Python 3.12.3. Documentation sections: What's new in Python 3.12? Or all \"What's new\" documents since Python 2.0. Tutorial. Start here: a tour of Python's syntax and features. Library reference. Standard library and builtins. Language reference.",
+			"engine": "bing",
+			"parsed_url": ["https", "docs.python.org", "/", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing", "duckduckgo"],
+			"positions": [7, 13],
+			"score": 0.43956043956043955,
+			"category": "general"
+		},
+		{
+			"title": "How to Use Python: Your First Steps - Real Python",
+			"content": "Learn the basics of Python syntax, installation, error handling, and more in this tutorial. You'll also code your first Python program and test your knowledge with a quiz.",
+			"url": "https://realpython.com/python-first-steps/",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "realpython.com", "/python-first-steps/", "", "", ""],
+			"template": "default.html",
+			"engines": ["qwant", "duckduckgo"],
+			"positions": [14, 7],
+			"score": 0.42857142857142855,
+			"category": "general"
+		},
+		{
+			"title": "The Python Tutorial \u2014 Python 3.11.8 documentation",
+			"content": "This tutorial introduces the reader informally to the basic concepts and features of the Python language and system. It helps to have a Python interpreter handy for hands-on experience, but all examples are self-contained, so the tutorial can be read off-line as well. For a description of standard objects and modules, see The Python Standard ...",
+			"url": "https://docs.python.org/3.11/tutorial/",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "docs.python.org", "/3.11/tutorial/", "", "", ""],
+			"template": "default.html",
+			"engines": ["duckduckgo"],
+			"positions": [7],
+			"score": 0.14285714285714285,
+			"category": "general"
+		},
+		{
+			"url": "https://realpython.com/python-introduction/",
+			"title": "Introduction to Python 3 \u2013 Real Python",
+			"content": "Python programming language, including a brief history of the development of Python and reasons why you might select Python as your language of choice.",
+			"engine": "bing",
+			"parsed_url": ["https", "realpython.com", "/python-introduction/", "", "", ""],
+			"template": "default.html",
+			"engines": ["bing"],
+			"positions": [8],
+			"score": 0.125,
+			"category": "general"
+		},
+		{
+			"title": "Our Documentation | Python.org",
+			"content": "Find online or download Python's documentation, tutorials, and guides for beginners and advanced users. Learn how to port from Python 2 to Python 3, contribute to Python, and access Python videos and books.",
+			"url": "https://www.python.org/doc/",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "www.python.org", "/doc/", "", "", ""],
+			"template": "default.html",
+			"engines": ["duckduckgo"],
+			"positions": [9],
+			"score": 0.1111111111111111,
+			"category": "general"
+		},
+		{
+			"title": "Welcome to Python.org",
+			"url": "http://www.get-python.org/shell/",
+			"content": "The mission of the Python Software Foundation is to promote, protect, and advance the Python programming language, and to support and facilitate the growth of a diverse and international community of Python programmers. Learn more. Become a Member Donate to the PSF.",
+			"engine": "qwant",
+			"parsed_url": ["http", "www.get-python.org", "/shell/", "", "", ""],
+			"template": "default.html",
+			"engines": ["qwant"],
+			"positions": [9],
+			"score": 0.1111111111111111,
+			"category": "general"
+		},
+		{
+			"title": "About Python\u2122 | Python.org",
+			"content": "Python is a powerful, fast, and versatile programming language that runs on various platforms and is easy to learn. Learn how to get started, explore the applications, and join the community of Python programmers and users.",
+			"url": "https://www.python.org/about/",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "www.python.org", "/about/", "", "", ""],
+			"template": "default.html",
+			"engines": ["duckduckgo"],
+			"positions": [11],
+			"score": 0.09090909090909091,
+			"category": "general"
+		},
+		{
+			"title": "Online Python Compiler (Interpreter) - Programiz",
+			"content": "Write and run Python code using this online tool. You can use Python Shell like IDLE, and take inputs from the user in our Python compiler.",
+			"url": "https://www.programiz.com/python-programming/online-compiler/",
+			"engine": "duckduckgo",
+			"parsed_url": [
+				"https",
+				"www.programiz.com",
+				"/python-programming/online-compiler/",
+				"",
+				"",
+				""
+			],
+			"template": "default.html",
+			"engines": ["duckduckgo"],
+			"positions": [12],
+			"score": 0.08333333333333333,
+			"category": "general"
+		},
+		{
+			"title": "Welcome to Python.org",
+			"content": "Python is a versatile and powerful language that lets you work quickly and integrate systems more effectively. Download the latest version, read the documentation, find jobs, events, success stories, and more on Python.org.",
+			"url": "https://www.python.org/?downloads",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "www.python.org", "/", "", "downloads", ""],
+			"template": "default.html",
+			"engines": ["duckduckgo"],
+			"positions": [15],
+			"score": 0.06666666666666667,
+			"category": "general"
+		},
+		{
+			"url": "https://www.matillion.com/blog/the-importance-of-python-and-its-growing-influence-on-data-productivty-a-matillion-perspective",
+			"title": "The Importance of Python and its Growing Influence on ...",
+			"content": "Jan 30, 2024 \u2014 The synergy of low-code functionality with Python's versatility empowers data professionals to orchestrate complex transformations seamlessly.",
+			"img_src": null,
+			"engine": "google",
+			"parsed_url": [
+				"https",
+				"www.matillion.com",
+				"/blog/the-importance-of-python-and-its-growing-influence-on-data-productivty-a-matillion-perspective",
+				"",
+				"",
+				""
+			],
+			"template": "default.html",
+			"engines": ["google"],
+			"positions": [10],
+			"score": 0.1,
+			"category": "general"
+		},
+		{
+			"title": "BeginnersGuide - Python Wiki",
+			"content": "This is the program that reads Python programs and carries out their instructions; you need it before you can do any Python programming. Mac and Linux distributions may include an outdated version of Python (Python 2), but you should install an updated one (Python 3). See BeginnersGuide/Download for instructions to download the correct version ...",
+			"url": "https://wiki.python.org/moin/BeginnersGuide",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "wiki.python.org", "/moin/BeginnersGuide", "", "", ""],
+			"template": "default.html",
+			"engines": ["duckduckgo"],
+			"positions": [16],
+			"score": 0.0625,
+			"category": "general"
+		},
+		{
+			"title": "Learn Python - Free Interactive Python Tutorial",
+			"content": "Learn Python from scratch or improve your skills with this website that offers tutorials, exercises, tests and certification. Explore topics such as basics, data science, advanced features and more with DataCamp.",
+			"url": "https://www.learnpython.org/",
+			"engine": "duckduckgo",
+			"parsed_url": ["https", "www.learnpython.org", "/", "", "", ""],
+			"template": "default.html",
+			"engines": ["duckduckgo"],
+			"positions": [17],
+			"score": 0.058823529411764705,
+			"category": "general"
+		}
+	],
+	"answers": [],
+	"corrections": [],
+	"infoboxes": [
+		{
+			"infobox": "Python",
+			"id": "https://en.wikipedia.org/wiki/Python_(programming_language)",
+			"content": "general-purpose programming language",
+			"img_src": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/6f/.PY_file_recreation.png/500px-.PY_file_recreation.png",
+			"urls": [
+				{
+					"title": "Official website",
+					"url": "https://www.python.org/",
+					"official": true
+				},
+				{
+					"title": "Wikipedia (en)",
+					"url": "https://en.wikipedia.org/wiki/Python_(programming_language)"
+				},
+				{
+					"title": "Wikidata",
+					"url": "http://www.wikidata.org/entity/Q28865"
+				}
+			],
+			"attributes": [
+				{
+					"label": "Inception",
+					"value": "Wednesday, February 20, 1991",
+					"entity": "P571"
+				},
+				{
+					"label": "Developer",
+					"value": "Python Software Foundation, Guido van Rossum",
+					"entity": "P178"
+				},
+				{
+					"label": "Copyright license",
+					"value": "Python Software Foundation License",
+					"entity": "P275"
+				},
+				{
+					"label": "Programmed in",
+					"value": "C, Python",
+					"entity": "P277"
+				},
+				{
+					"label": "Software version identifier",
+					"value": "3.12.3, 3.13.0a6",
+					"entity": "P348"
+				}
+			],
+			"engine": "wikidata",
+			"engines": ["wikidata"]
+		}
+	],
+	"suggestions": [
+		"python turtle",
+		"micro python tutorial",
+		"python docs",
+		"python compiler",
+		"snapcraft python",
+		"micropython vs python",
+		"python online",
+		"python download"
+	],
+	"unresponsive_engines": []
+}

+ 190 - 0
backend/apps/rag/search/testdata/serper.json

@@ -0,0 +1,190 @@
+{
+	"searchParameters": {
+		"q": "apple inc",
+		"gl": "us",
+		"hl": "en",
+		"autocorrect": true,
+		"page": 1,
+		"type": "search"
+	},
+	"knowledgeGraph": {
+		"title": "Apple",
+		"type": "Technology company",
+		"website": "http://www.apple.com/",
+		"imageUrl": "https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcQwGQRv5TjjkycpctY66mOg_e2-npacrmjAb6_jAWhzlzkFE3OTjxyzbA&s=0",
+		"description": "Apple Inc. is an American multinational technology company specializing in consumer electronics, software and online services headquartered in Cupertino, California, United States.",
+		"descriptionSource": "Wikipedia",
+		"descriptionLink": "https://en.wikipedia.org/wiki/Apple_Inc.",
+		"attributes": {
+			"Headquarters": "Cupertino, CA",
+			"CEO": "Tim Cook (Aug 24, 2011–)",
+			"Founded": "April 1, 1976, Los Altos, CA",
+			"Sales": "1 (800) 692-7753",
+			"Products": "iPhone, Apple Watch, iPad, and more",
+			"Founders": "Steve Jobs, Steve Wozniak, and Ronald Wayne",
+			"Subsidiaries": "Apple Store, Beats Electronics, Beddit, and more"
+		}
+	},
+	"organic": [
+		{
+			"title": "Apple",
+			"link": "https://www.apple.com/",
+			"snippet": "Discover the innovative world of Apple and shop everything iPhone, iPad, Apple Watch, Mac, and Apple TV, plus explore accessories, entertainment, ...",
+			"sitelinks": [
+				{
+					"title": "Support",
+					"link": "https://support.apple.com/"
+				},
+				{
+					"title": "iPhone",
+					"link": "https://www.apple.com/iphone/"
+				},
+				{
+					"title": "Apple makes business better.",
+					"link": "https://www.apple.com/business/"
+				},
+				{
+					"title": "Mac",
+					"link": "https://www.apple.com/mac/"
+				}
+			],
+			"position": 1
+		},
+		{
+			"title": "Apple Inc. - Wikipedia",
+			"link": "https://en.wikipedia.org/wiki/Apple_Inc.",
+			"snippet": "Apple Inc. is an American multinational technology company specializing in consumer electronics, software and online services headquartered in Cupertino, ...",
+			"attributes": {
+				"Products": "AirPods; Apple Watch; iPad; iPhone; Mac",
+				"Founders": "Steve Jobs; Steve Wozniak; Ronald Wayne",
+				"Founded": "April 1, 1976; 46 years ago in Los Altos, California, U.S",
+				"Industry": "Consumer electronics; Software services; Online services"
+			},
+			"sitelinks": [
+				{
+					"title": "History",
+					"link": "https://en.wikipedia.org/wiki/History_of_Apple_Inc."
+				},
+				{
+					"title": "Timeline of Apple Inc. products",
+					"link": "https://en.wikipedia.org/wiki/Timeline_of_Apple_Inc._products"
+				},
+				{
+					"title": "List of software by Apple Inc.",
+					"link": "https://en.wikipedia.org/wiki/List_of_software_by_Apple_Inc."
+				},
+				{
+					"title": "Apple Store",
+					"link": "https://en.wikipedia.org/wiki/Apple_Store"
+				}
+			],
+			"position": 2
+		},
+		{
+			"title": "Apple Inc. | History, Products, Headquarters, & Facts | Britannica",
+			"link": "https://www.britannica.com/topic/Apple-Inc",
+			"snippet": "Apple Inc., formerly Apple Computer, Inc., American manufacturer of personal computers, smartphones, tablet computers, computer peripherals, ...",
+			"date": "Aug 31, 2022",
+			"attributes": {
+				"Related People": "Steve Jobs Steve Wozniak Jony Ive Tim Cook Angela Ahrendts",
+				"Date": "1976 - present",
+				"Areas Of Involvement": "peripheral device"
+			},
+			"position": 3
+		},
+		{
+			"title": "AAPL: Apple Inc Stock Price Quote - NASDAQ GS - Bloomberg.com",
+			"link": "https://www.bloomberg.com/quote/AAPL:US",
+			"snippet": "Stock analysis for Apple Inc (AAPL:NASDAQ GS) including stock price, stock chart, company news, key statistics, fundamentals and company profile.",
+			"position": 4
+		},
+		{
+			"title": "Apple Inc. (AAPL) Company Profile & Facts - Yahoo Finance",
+			"link": "https://finance.yahoo.com/quote/AAPL/profile/",
+			"snippet": "Apple Inc. designs, manufactures, and markets smartphones, personal computers, tablets, wearables, and accessories worldwide. It also sells various related ...",
+			"position": 5
+		},
+		{
+			"title": "AAPL | Apple Inc. Stock Price & News - WSJ",
+			"link": "https://www.wsj.com/market-data/quotes/AAPL",
+			"snippet": "Apple, Inc. engages in the design, manufacture, and sale of smartphones, personal computers, tablets, wearables and accessories, and other varieties of ...",
+			"position": 6
+		},
+		{
+			"title": "Apple Inc Company Profile - Apple Inc Overview - GlobalData",
+			"link": "https://www.globaldata.com/company-profile/apple-inc/",
+			"snippet": "Apple Inc (Apple) designs, manufactures, and markets smartphones, tablets, personal computers (PCs), portable and wearable devices. The company also offers ...",
+			"position": 7
+		},
+		{
+			"title": "Apple Inc (AAPL) Stock Price & News - Google Finance",
+			"link": "https://www.google.com/finance/quote/AAPL:NASDAQ?hl=en",
+			"snippet": "Get the latest Apple Inc (AAPL) real-time quote, historical performance, charts, and other financial information to help you make more informed trading and ...",
+			"position": 8
+		}
+	],
+	"peopleAlsoAsk": [
+		{
+			"question": "What does Apple Inc mean?",
+			"snippet": "Apple Inc., formerly Apple Computer, Inc., American manufacturer of personal\ncomputers, smartphones, tablet computers, computer peripherals, and computer\nsoftware. It was the first successful personal computer company and the\npopularizer of the graphical user interface.\nAug 31, 2022",
+			"title": "Apple Inc. | History, Products, Headquarters, & Facts | Britannica",
+			"link": "https://www.britannica.com/topic/Apple-Inc"
+		},
+		{
+			"question": "Is Apple and Apple Inc same?",
+			"snippet": "Apple was founded as Apple Computer Company on April 1, 1976, by Steve Jobs,\nSteve Wozniak and Ronald Wayne to develop and sell Wozniak's Apple I personal\ncomputer. It was incorporated by Jobs and Wozniak as Apple Computer, Inc.",
+			"title": "Apple Inc. - Wikipedia",
+			"link": "https://en.wikipedia.org/wiki/Apple_Inc."
+		},
+		{
+			"question": "Who owns Apple Inc?",
+			"snippet": "Apple Inc. is owned by two main institutional investors (Vanguard Group and\nBlackRock, Inc). While its major individual shareholders comprise people like\nArt Levinson, Tim Cook, Bruce Sewell, Al Gore, Johny Sroujli, and others.",
+			"title": "Who Owns Apple In 2022? - FourWeekMBA",
+			"link": "https://fourweekmba.com/who-owns-apple/"
+		},
+		{
+			"question": "What products does Apple Inc offer?",
+			"snippet": "APPLE FOOTER\nStore.\nMac.\niPad.\niPhone.\nWatch.\nAirPods.\nTV & Home.\nAirTag.",
+			"title": "More items...",
+			"link": "https://www.apple.com/business/"
+		}
+	],
+	"relatedSearches": [
+		{
+			"query": "Who invented the iPhone"
+		},
+		{
+			"query": "Apple Inc competitors"
+		},
+		{
+			"query": "Apple iPad"
+		},
+		{
+			"query": "iPhones"
+		},
+		{
+			"query": "Apple Inc us"
+		},
+		{
+			"query": "Apple company history"
+		},
+		{
+			"query": "Apple Store"
+		},
+		{
+			"query": "Apple customer service"
+		},
+		{
+			"query": "Apple Watch"
+		},
+		{
+			"query": "Apple Inc Industry"
+		},
+		{
+			"query": "Apple Inc registered address"
+		},
+		{
+			"query": "Apple Inc Bloomberg"
+		}
+	]
+}

+ 276 - 0
backend/apps/rag/search/testdata/serpstack.json

@@ -0,0 +1,276 @@
+{
+	"request": {
+		"success": true,
+		"total_time_taken": 3.4,
+		"processed_timestamp": 1714968442,
+		"search_url": "http://www.google.com/search?q=mcdonalds\u0026gl=us\u0026hl=en\u0026safe=0\u0026num=10"
+	},
+	"search_parameters": {
+		"engine": "google",
+		"type": "web",
+		"device": "desktop",
+		"auto_location": "1",
+		"google_domain": "google.com",
+		"gl": "us",
+		"hl": "en",
+		"safe": "0",
+		"news_type": "all",
+		"exclude_autocorrected_results": "0",
+		"images_color": "any",
+		"page": "1",
+		"num": "10",
+		"output": "json",
+		"csv_fields": "search_parameters.query,organic_results.position,organic_results.title,organic_results.url,organic_results.domain",
+		"query": "mcdonalds",
+		"action": "search",
+		"access_key": "aac48e007e15c532bb94ffb34532a4b2",
+		"error": {}
+	},
+	"search_information": {
+		"total_results": 1170000000,
+		"time_taken_displayed": 0.49,
+		"detected_location": {},
+		"did_you_mean": {},
+		"no_results_for_original_query": false,
+		"showing_results_for": {}
+	},
+	"organic_results": [
+		{
+			"position": 1,
+			"title": "Our Full McDonald\u0027s Food Menu",
+			"snippet": "",
+			"prerender": false,
+			"cached_page_url": {},
+			"related_pages_url": {},
+			"url": "https://www.mcdonalds.com/us/en-us/full-menu.html",
+			"domain": "www.mcdonalds.com",
+			"displayed_url": "https://www.mcdonalds.com \u203a en-us \u203a full-menu"
+		},
+		{
+			"position": 2,
+			"title": "McDonald\u0027s",
+			"snippet": "McDonald\u0027s is the world\u0027s largest fast food restaurant chain, serving over 69 million customers daily in over 100 countries in more than 40,000 outlets as of\u00a0...",
+			"prerender": false,
+			"cached_page_url": {},
+			"related_pages_url": {},
+			"url": "https://en.wikipedia.org/wiki/McDonald%27s",
+			"domain": "en.wikipedia.org",
+			"displayed_url": "https://en.wikipedia.org \u203a wiki \u203a McDonald\u0027s"
+		},
+		{
+			"position": 3,
+			"title": "Restaurants Near Me: Nearby McDonald\u0027s Locations",
+			"snippet": "",
+			"prerender": false,
+			"cached_page_url": {},
+			"related_pages_url": {},
+			"url": "https://www.mcdonalds.com/us/en-us/restaurant-locator.html",
+			"domain": "www.mcdonalds.com",
+			"displayed_url": "https://www.mcdonalds.com \u203a en-us \u203a restaurant-locator"
+		},
+		{
+			"position": 4,
+			"title": "Download the McDonald\u0027s App: Deals, Promotions \u0026 ...",
+			"snippet": "Download the McDonald\u0027s app for Mobile Order \u0026 Pay, exclusive deals and coupons, menu information and special promotions.",
+			"prerender": false,
+			"cached_page_url": {},
+			"related_pages_url": {},
+			"url": "https://www.mcdonalds.com/us/en-us/download-app.html",
+			"domain": "www.mcdonalds.com",
+			"displayed_url": "https://www.mcdonalds.com \u203a en-us \u203a download-app"
+		},
+		{
+			"position": 5,
+			"title": "McDonald\u0027s Restaurant Careers in the US",
+			"snippet": "McDonald\u0027s restaurant jobs are one-of-a-kind \u2013 just like you. Restaurants are hiring across all levels, from Crew team to Management. Apply today!",
+			"prerender": false,
+			"cached_page_url": {},
+			"related_pages_url": {},
+			"url": "https://jobs.mchire.com/",
+			"domain": "jobs.mchire.com",
+			"displayed_url": "https://jobs.mchire.com"
+		}
+	],
+	"inline_images": [
+		{
+			"image_url": "https://serpstack-assets.apilayer.net/2418910010831954152.png",
+			"title": ""
+		}
+	],
+	"local_results": [
+		{
+			"position": 1,
+			"title": "McDonald\u0027s",
+			"coordinates": {
+				"latitude": 0,
+				"longitude": 0
+			},
+			"address": "",
+			"rating": 0,
+			"reviews": 0,
+			"type": "",
+			"price": {},
+			"url": 0
+		},
+		{
+			"position": 2,
+			"title": "McDonald\u0027s",
+			"coordinates": {
+				"latitude": 0,
+				"longitude": 0
+			},
+			"address": "",
+			"rating": 0,
+			"reviews": 0,
+			"type": "",
+			"price": {},
+			"url": 0
+		},
+		{
+			"position": 3,
+			"title": "McDonald\u0027s",
+			"coordinates": {
+				"latitude": 0,
+				"longitude": 0
+			},
+			"address": "",
+			"rating": 0,
+			"reviews": 0,
+			"type": "",
+			"price": {},
+			"url": 0
+		}
+	],
+	"top_stories": [
+		{
+			"block_position": 1,
+			"title": "Menu nutrition",
+			"url": "/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026q=mcdonald%27s+double+quarter+pounder+with+cheese\u0026stick=H4sIAAAAAAAAAONgFuLUz9U3ME-vLDBX4tVP1zc0TCsuNE0ytjTTUs5OttJPy89P0c9NzSuNLyjKL8tMSS2yAvNS80qKMlOLF7Hq5ian5Ocl5qSoFyuk5Jcm5aQqFJYmFpWkFikU5JfmATUolGeWZCgkZ6SmFqcCAM4ilJtxAAAA\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4Qri56BAh0EAM",
+			"source": "",
+			"uploaded": "",
+			"uploaded_utc": "2024-05-06T04:07:22.082Z"
+		},
+		{
+			"block_position": 2,
+			"title": "Profiles",
+			"url": "https://www.instagram.com/McDonalds",
+			"source": "",
+			"uploaded": "",
+			"uploaded_utc": "2024-05-06T04:07:22.082Z"
+		},
+		{
+			"block_position": 3,
+			"title": "People also search for",
+			"url": "/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026si=ACC90nzx_D3_zUKRnpAjmO0UBLNxnt7EyN4YYdru6U3bxLI-L5Wg8IL2sxPFxxcDEhVbocy-LJPZIvZySijw0ho2hfZ-KtV-sSEEJ9lw7JuEkXHDnRK5y4Dm8aqbiLwugbLbslwjG3hO_gpDTFZK2VoUGZPy2nrmOBCy0G3PoOfoiEtct2GSZlUz0uufG-xP8emtNzQKQpvjkAm5Zmi57iVZueiD62upz7-x2N3dAbwtm6FkInAPRw1yR91zuT7F3lEaPblTW3LaRwCDC0bvaRCh9x4N9zHgY1OOQa_rzts2jf5WpXcuw4Y%3D\u0026q=Burger+King\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4Qs9oBKAB6BAhzEAI",
+			"source": "",
+			"uploaded": "",
+			"uploaded_utc": "2024-05-06T04:07:22.082Z"
+		}
+	],
+	"related_questions": [
+		{
+			"question": "What\u0027s a number 7 at McDonald\u0027s?What\u0027s a number 7 at McDonald\u0027s?What\u0027s a number 7 at McDonald\u0027s?",
+			"answer": "",
+			"title": "",
+			"displayed_url": ""
+		},
+		{
+			"question": "Why is McDonald\u0027s changing their name?Why is McDonald\u0027s changing their name?Why is McDonald\u0027s changing their name?",
+			"answer": "",
+			"title": "",
+			"displayed_url": ""
+		},
+		{
+			"question": "What is the oldest still running Mcdonalds?What is the oldest still running Mcdonalds?What is the oldest still running Mcdonalds?",
+			"answer": "",
+			"title": "",
+			"displayed_url": ""
+		},
+		{
+			"question": "Why is McDonald\u0027s now WcDonald\u0027s?Why is McDonald\u0027s now WcDonald\u0027s?Why is McDonald\u0027s now WcDonald\u0027s?",
+			"answer": "",
+			"title": "",
+			"displayed_url": ""
+		}
+	],
+	"knowledge_graph": {
+		"title": "",
+		"type": "Fast-food restaurant company",
+		"image_urls": ["https://serpstack-assets.apilayer.net/2418910010831954152.png"],
+		"description": "McDonald\u0027s Corporation is an American multinational fast food chain, founded in 1940 as a restaurant operated by Richard and Maurice McDonald, in San Bernardino, California, United States.",
+		"source": {
+			"name": "Wikipedia",
+			"url": "https://en.wikipedia.org/wiki/McDonald\u0027s"
+		},
+		"people_also_search_for": [],
+		"known_attributes": [
+			{
+				"attribute": "kc:/business/business_operation:founder",
+				"link": "http://www.google.com/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026q=Ray+Kroc\u0026si=ACC90nzx_D3_zUKRnpAjmO0UBLNxnt7EyN4YYdru6U3bxLI-LxARWRdbk5SkoY2sDn5Qq7yOmqYGei6qZ7sfJhsjZXBPgjMlLbS7824rpJOm69GzqVWMdoNIZiFX2T4A2td14sZOn4a1BexZLtZXHU7NZdF6VsWbGMVuiSYtXdev7uaUjEJKumiwlqTAATTebOriYTEBuSzC\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4QmxMoAHoECHgQAg",
+				"name": "Founder: ",
+				"value": "Ray Kroc"
+			},
+			{
+				"attribute": "kc:/organization/organization:ceo",
+				"link": "http://www.google.com/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026q=Chris+Kempczinski\u0026si=ACC90nwLLwns5sISZcdzuISy7t-NHozt8Cbt6G3WNQfC9ekAgKFbjdEFCDgxLbt57EDZGosYDGiZuq1AcBhA6IhTOSZxfVSySuGQ3VDwmmTA7Z93n3K3596jAuZH9VVv5h8PyvKJSuGuSsQWviJTl3eKj2UL1ZIWuDgkjyVMnC47rN7j0G9PlHRCCLdQF7VDQ1gubTiC4onXqLRBTbwAj6a--PD6Jv_NoA%3D%3D\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4QmxMoAHoECHUQAg",
+				"name": "CEO: ",
+				"value": "Chris Kempczinski (Nov 1, 2019\u2013)"
+			},
+			{
+				"attribute": "kc:/business/employer:revenue",
+				"link": "",
+				"name": "Revenue: ",
+				"value": "25.49\u00a0billion USD (2023)"
+			},
+			{
+				"attribute": "kc:/organization/organization:founded",
+				"link": "http://www.google.com/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026q=Des+Plaines\u0026si=ACC90nyvvWro6QmnyY1IfSdgk5wwjB1r8BGd_IWRjXqmKPQqm_yqLtI_DBi5PXGOtg_Z3qrzzEP6mcih1nN7h5A7v6OefnEJiC7a8dBR-v9LxlRubfyR6vlMr3fZ3TmVKWwz9FRpvZb1eYNt-RM7KIDKQlwGEIgINvzhxjUrv6uxSmceduzxd8W7Pkz71XGwxF0F8OlSzHlx\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4QmxMoAHoECG4QAg",
+				"name": "Founded: ",
+				"value": "April 15, 1955, Des Plaines, IL"
+			},
+			{
+				"attribute": "kc:/organization/organization:headquarters",
+				"link": "http://www.google.com/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026q=Chicago\u0026si=ACC90nyvvWro6QmnyY1IfSdgk5wwjB1r8BGd_IWRjXqmKPQqm-46AEJ_kJbUIEvsvEEZqteiYJvXVXs2ScRNDvFFpjfeAaW3dxtpTGCgcsf5RMdi6IdzOdtjJMN3ZaFwqZOmdi7tC6r0Mh1O9bnP3HrVDB9hH02m7aA6f70dCAfTdpOFnGxDU6wVMAI5MxWBE3wTugtUDOK-\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4QmxMoAHoECHYQAg",
+				"name": "Headquarters: ",
+				"value": "Chicago, IL"
+			},
+			{
+				"attribute": "kc:/organization/organization:president",
+				"link": "http://www.google.com/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026q=Chris+Kempczinski\u0026si=ACC90nwLLwns5sISZcdzuISy7t-NHozt8Cbt6G3WNQfC9ekAgKFbjdEFCDgxLbt57EDZGosYDGiZuq1AcBhA6IhTOSZxfVSySuGQ3VDwmmTA7Z93n3K3596jAuZH9VVv5h8PyvKJSuGuSsQWviJTl3eKj2UL1ZIWuDgkjyVMnC47rN7j0G9PlHRCCLdQF7VDQ1gubTiC4onXqLRBTbwAj6a--PD6Jv_NoA%3D%3D\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4QmxMoAHoECHEQAg",
+				"name": "President: ",
+				"value": "Chris Kempczinski"
+			}
+		],
+		"website": "https://www.mcdonalds.com/us/en-us.html",
+		"profiles": [
+			{
+				"name": "Instagram",
+				"url": "https://www.instagram.com/McDonalds"
+			},
+			{
+				"name": "X (Twitter)",
+				"url": "https://twitter.com/McDonalds"
+			},
+			{
+				"name": "Facebook",
+				"url": "https://www.facebook.com/McDonaldsUS"
+			},
+			{
+				"name": "YouTube",
+				"url": "https://www.youtube.com/user/McDonaldsUS"
+			},
+			{
+				"name": "Pinterest",
+				"url": "https://www.pinterest.com/mcdonalds"
+			}
+		],
+		"founded": "April 15, 1955, Des Plaines, IL",
+		"headquarters": "Chicago, IL",
+		"founders": [
+			{
+				"name": "Ray Kroc",
+				"link": "http://www.google.com/search?safe=0\u0026sca_esv=c9c7fd42856085e2\u0026sca_upv=1\u0026gl=us\u0026hl=en\u0026q=Ray+Kroc\u0026si=ACC90nzx_D3_zUKRnpAjmO0UBLNxnt7EyN4YYdru6U3bxLI-LxARWRdbk5SkoY2sDn5Qq7yOmqYGei6qZ7sfJhsjZXBPgjMlLbS7824rpJOm69GzqVWMdoNIZiFX2T4A2td14sZOn4a1BexZLtZXHU7NZdF6VsWbGMVuiSYtXdev7uaUjEJKumiwlqTAATTebOriYTEBuSzC\u0026sa=X\u0026ved=2ahUKEwjF55alk_iFAxXlamwGHbqgAs4QmxMoAHoECHgQAg"
+			}
+		]
+	}
+}

+ 28 - 6
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,
@@ -19,9 +19,10 @@ from langchain.retrievers import (
 )
 
 from typing import Optional
-from config import SRC_LOG_LEVELS, CHROMA_CLIENT
 
 
+from config import SRC_LOG_LEVELS, CHROMA_CLIENT
+
 log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["RAG"])
 
@@ -198,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()
@@ -221,7 +223,13 @@ 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)
 
@@ -402,8 +410,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",
@@ -411,12 +433,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:

+ 132 - 0
backend/apps/socket/main.py

@@ -0,0 +1,132 @@
+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
+
+
+USER_POOL = {}
+USAGE_POOL = {}
+# Timeout duration in seconds
+TIMEOUT_DURATION = 3
+
+
+@sio.event
+async def connect(sid, environ, auth):
+    print("connect ", sid)
+
+    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:
+            USER_POOL[sid] = user.id
+            print(f"user {user.name}({user.id}) connected with session ID {sid}")
+
+            print(len(set(USER_POOL)))
+            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:
+            USER_POOL[sid] = user.id
+            print(f"user {user.name}({user.id}) connected with session ID {sid}")
+
+            print(len(set(USER_POOL)))
+            await sio.emit("user-count", {"count": len(set(USER_POOL))})
+
+
+@sio.on("user-count")
+async def user_count(sid):
+    print("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)
+    print(f"Models in use: {models_in_use}")
+
+    return models_in_use
+
+
+@sio.on("usage")
+async def usage(sid, data):
+    print(f'Received "usage" event from {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:
+        print("remove_after_timeout", sid, model_id)
+        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]
+
+            print(f"Removed usage data for {model_id} due to timeout")
+            # 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 USER_POOL:
+        disconnected_user = USER_POOL.pop(sid)
+        print(f"user {disconnected_user} disconnected with session ID {sid}")
+
+        await sio.emit("user-count", {"count": len(USER_POOL)})
+    else:
+        print(f"Unknown session ID {sid} disconnected")

+ 7 - 0
backend/apps/webui/main.py

@@ -16,6 +16,8 @@ from apps.webui.routers import (
 )
 from config import (
     WEBUI_BUILD_HASH,
+    SHOW_ADMIN_DETAILS,
+    ADMIN_EMAIL,
     WEBUI_AUTH,
     DEFAULT_MODELS,
     DEFAULT_PROMPT_SUGGESTIONS,
@@ -39,6 +41,11 @@ app.state.config = AppConfig()
 app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
 app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
 
+
+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
 app.state.config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE

+ 9 - 0
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))

+ 60 - 45
backend/apps/webui/routers/auths.py

@@ -272,72 +272,87 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
 
 
 ############################
-# ToggleSignUp
+# GetAdminDetails
 ############################
 
 
-@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/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
 
-@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
+        return {
+            "name": admin_name,
+            "email": admin_email,
+        }
+    else:
+        raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED)
 
 
 ############################
-# Default User Role
+# ToggleSignUp
 ############################
 
 
-@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
+@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,
+    }
 
 
-class UpdateRoleForm(BaseModel):
-    role: str
+class AdminConfig(BaseModel):
+    SHOW_ADMIN_DETAILS: bool
+    ENABLE_SIGNUP: bool
+    DEFAULT_USER_ROLE: str
+    JWT_EXPIRES_IN: str
+    ENABLE_COMMUNITY_SHARING: bool
 
 
-@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
-
-
-############################
-# 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
+    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
 
-@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,
+    }
 
 
 ############################

+ 39 - 0
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
 ############################
@@ -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
 ############################

+ 0 - 1
backend/apps/webui/routers/models.py

@@ -82,7 +82,6 @@ async def update_model_by_id(
     else:
         if form_data.id in request.app.state.MODELS:
             model = Models.insert_new_model(form_data, user.id)
-            print(model)
             if model:
                 return model
             else:

+ 6 - 1
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

+ 9 - 0
backend/apps/webui/routers/utils.py

@@ -107,3 +107,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",
+    )

+ 101 - 0
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:
@@ -703,6 +714,7 @@ ENABLE_COMMUNITY_SHARING = PersistentConfig(
     os.environ.get("ENABLE_COMMUNITY_SHARING", "True").lower() == "true",
 )
 
+
 class BannerModel(BaseModel):
     id: str
     type: str
@@ -718,6 +730,20 @@ 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),
+)
+
+
 ####################################
 # WEBUI_SECRET_KEY
 ####################################
@@ -805,6 +831,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",
@@ -899,6 +931,75 @@ YOUTUBE_LOADER_LANGUAGE = PersistentConfig(
     os.getenv("YOUTUBE_LOADER_LANGUAGE", "en").split(","),
 )
 
+
+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_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", ""),
+)
+
+
+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
 ####################################

+ 8 - 0
backend/constants.py

@@ -80,3 +80,11 @@ class ERROR_MESSAGES(str, Enum):
     INVALID_URL = (
         "Oops! The URL you provided is invalid. Please double-check and try again."
     )
+
+    WEB_SEARCH_ERROR = (
+        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."
+    )

+ 472 - 20
backend/main.py

@@ -16,6 +16,7 @@ import mimetypes
 
 from fastapi import FastAPI, Request, Depends, status
 from fastapi.staticfiles import StaticFiles
+from fastapi.responses import JSONResponse
 from fastapi import HTTPException
 from fastapi.middleware.wsgi import WSGIMiddleware
 from fastapi.middleware.cors import CORSMiddleware
@@ -24,6 +25,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
 from starlette.middleware.sessions import SessionMiddleware
 from starlette.responses import StreamingResponse, Response, RedirectResponse
 
+
+from apps.socket.main import app as socket_app
 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
 
@@ -43,6 +46,8 @@ from utils.misc import parse_duration
 from utils.utils import (
     get_admin_user,
     get_verified_user,
+    get_current_user,
+    get_http_authorization_cred,
     get_password_hash,
     create_token,
 )
@@ -136,7 +141,6 @@ app.state.MODELS = {}
 
 origins = ["*"]
 
-
 # Custom middleware to add security headers
 # class SecurityHeadersMiddleware(BaseHTTPMiddleware):
 #     async def dispatch(self, request: Request, call_next):
@@ -154,7 +158,8 @@ class RAGMiddleware(BaseHTTPMiddleware):
         return_citations = False
 
         if request.method == "POST" and (
-            "/api/chat" in request.url.path or "/chat/completions" in request.url.path
+            "/ollama/api/chat" in request.url.path
+            or "/chat/completions" in request.url.path
         ):
             log.debug(f"request.url.path: {request.url.path}")
 
@@ -239,6 +244,124 @@ class RAGMiddleware(BaseHTTPMiddleware):
 app.add_middleware(RAGMiddleware)
 
 
+class PipelineMiddleware(BaseHTTPMiddleware):
+    async def dispatch(self, request: Request, call_next):
+        if request.method == "POST" and (
+            "/ollama/api/chat" in request.url.path
+            or "/chat/completions" in request.url.path
+        ):
+            log.debug(f"request.url.path: {request.url.path}")
+
+            # Read the original request body
+            body = await request.body()
+            # Decode body to string
+            body_str = body.decode("utf-8")
+            # 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"]
+                    )
+                )
+            ]
+            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
+
+            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": 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 "pipeline" not in app.state.MODELS[model_id]:
+                if "chat_id" in data:
+                    del data["chat_id"]
+
+                if "title" in data:
+                    del data["title"]
+
+            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")),
+                *[
+                    (k, v)
+                    for k, v in request.headers.raw
+                    if k.lower() != b"content-length"
+                ],
+            ]
+
+        response = await call_next(request)
+        return response
+
+    async def _receive(self, body: bytes):
+        return {"type": "http.request", "body": body, "more_body": False}
+
+
+app.add_middleware(PipelineMiddleware)
+
+
 app.add_middleware(
     CORSMiddleware,
     allow_origins=origins,
@@ -271,6 +394,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)
 
@@ -351,6 +477,14 @@ async def get_all_models():
 @app.get("/api/models")
 async def get_models(user=Depends(get_verified_user)):
     models = await get_all_models()
+
+    # Filter out filter pipelines
+    models = [
+        model
+        for model in models
+        if "pipeline" not in model or model["pipeline"].get("type", None) != "filter"
+    ]
+
     if app.state.config.ENABLE_MODEL_FILTER:
         if user.role == "user":
             models = list(
@@ -364,6 +498,339 @@ async def get_models(user=Depends(get_verified_user)):
     return {"data": models}
 
 
+@app.post("/api/chat/completed")
+async def chat_completed(form_data: dict, user=Depends(get_verified_user)):
+    data = form_data
+    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"]
+            )
+        )
+    ]
+    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:
+            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/outlet",
+                    headers=headers,
+                    json={
+                        "user": {"id": user.id, "name": user.name, "role": user.role},
+                        "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
+
+    return data
+
+
+@app.get("/api/pipelines/list")
+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 response != None and "pipelines" in response
+    ]
+
+    return {
+        "data": [
+            {
+                "url": openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx],
+                "idx": urlIdx,
+            }
+            for urlIdx in urlIdxs
+        ]
+    }
+
+
+class AddPipelineForm(BaseModel):
+    url: str
+    urlIdx: int
+
+
+@app.post("/api/pipelines/add")
+async def add_pipeline(form_data: AddPipelineForm, user=Depends(get_admin_user)):
+
+    r = None
+    try:
+        urlIdx = form_data.urlIdx
+
+        url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx]
+        key = openai_app.state.config.OPENAI_API_KEYS[urlIdx]
+
+        headers = {"Authorization": f"Bearer {key}"}
+        r = requests.post(
+            f"{url}/pipelines/add", headers=headers, json={"url": form_data.url}
+        )
+
+        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,
+        )
+
+
+class DeletePipelineForm(BaseModel):
+    id: str
+    urlIdx: int
+
+
+@app.delete("/api/pipelines/delete")
+async def delete_pipeline(form_data: DeletePipelineForm, user=Depends(get_admin_user)):
+
+    r = None
+    try:
+        urlIdx = form_data.urlIdx
+
+        url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx]
+        key = openai_app.state.config.OPENAI_API_KEYS[urlIdx]
+
+        headers = {"Authorization": f"Bearer {key}"}
+        r = requests.delete(
+            f"{url}/pipelines/delete", headers=headers, json={"id": form_data.id}
+        )
+
+        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,
+        )
+
+
+@app.get("/api/pipelines")
+async def get_pipelines(urlIdx: Optional[int] = None, user=Depends(get_admin_user)):
+    r = None
+    try:
+        urlIdx
+
+        url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx]
+        key = openai_app.state.config.OPENAI_API_KEYS[urlIdx]
+
+        headers = {"Authorization": f"Bearer {key}"}
+        r = requests.get(f"{url}/pipelines", headers=headers)
+
+        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,
+        )
+
+
+@app.get("/api/pipelines/{pipeline_id}/valves")
+async def get_pipeline_valves(
+    urlIdx: Optional[int], pipeline_id: str, user=Depends(get_admin_user)
+):
+    models = await get_all_models()
+    r = None
+    try:
+
+        url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx]
+        key = openai_app.state.config.OPENAI_API_KEYS[urlIdx]
+
+        headers = {"Authorization": f"Bearer {key}"}
+        r = requests.get(f"{url}/{pipeline_id}/valves", headers=headers)
+
+        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,
+        )
+
+
+@app.get("/api/pipelines/{pipeline_id}/valves/spec")
+async def get_pipeline_valves_spec(
+    urlIdx: Optional[int], pipeline_id: str, user=Depends(get_admin_user)
+):
+    models = await get_all_models()
+
+    r = None
+    try:
+        url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx]
+        key = openai_app.state.config.OPENAI_API_KEYS[urlIdx]
+
+        headers = {"Authorization": f"Bearer {key}"}
+        r = requests.get(f"{url}/{pipeline_id}/valves/spec", headers=headers)
+
+        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,
+        )
+
+
+@app.post("/api/pipelines/{pipeline_id}/valves/update")
+async def update_pipeline_valves(
+    urlIdx: Optional[int],
+    pipeline_id: str,
+    form_data: dict,
+    user=Depends(get_admin_user),
+):
+    models = await get_all_models()
+
+    r = None
+    try:
+        url = openai_app.state.config.OPENAI_API_BASE_URLS[urlIdx]
+        key = openai_app.state.config.OPENAI_API_KEYS[urlIdx]
+
+        headers = {"Authorization": f"Bearer {key}"}
+        r = requests.post(
+            f"{url}/{pipeline_id}/valves/update",
+            headers=headers,
+            json={**form_data},
+        )
+
+        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,
+        )
+
+
 @app.get("/api/config")
 async def get_app_config():
     # Checking and Handling the Absence of 'ui' in CONFIG_DATA
@@ -384,9 +851,10 @@ 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_app.state.config.ENABLE_RAG_WEB_SEARCH,
             "enable_image_generation": images_app.state.config.ENABLED,
-            "enable_admin_export": ENABLE_ADMIN_EXPORT,
             "enable_community_sharing": webui_app.state.config.ENABLE_COMMUNITY_SHARING,
+            "enable_admin_export": ENABLE_ADMIN_EXPORT,
         },
         "oauth": {
             "providers": {
@@ -438,23 +906,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")

+ 27 - 5
backend/utils/misc.py

@@ -123,11 +123,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 +170,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

BIN
demo.gif


+ 1 - 0
docs/CONTRIBUTING.md

@@ -45,6 +45,7 @@ We welcome pull requests. Before submitting one, please:
 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
 

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 1198 - 100
package-lock.json


+ 4 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "open-webui",
-	"version": "0.2.0.dev2",
+	"version": "0.2.5",
 	"private": true,
 	"scripts": {
 		"dev": "npm run pyodide:fetch && vite dev --host",
@@ -63,7 +63,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"

+ 82 - 0
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;
 

+ 69 - 0
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;
 

+ 312 - 3
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
 		});
@@ -49,6 +65,299 @@ export const getModels = async (token: string = '') => {
 	return models;
 };
 
+type ChatCompletedForm = {
+	model: string;
+	messages: string[];
+	chat_id: string;
+};
+
+export const chatCompleted = async (token: string, body: ChatCompletedForm) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_BASE_URL}/api/chat/completed`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { 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);
+			if ('detail' in err) {
+				error = err.detail;
+			} else {
+				error = err;
+			}
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getPipelinesList = async (token: string = '') => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/list`, {
+		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;
+	}
+
+	let pipelines = res?.data ?? [];
+	return pipelines;
+};
+
+export const downloadPipeline = async (token: string, url: string, urlIdx: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/add`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		},
+		body: JSON.stringify({
+			url: url,
+			urlIdx: urlIdx
+		})
+	})
+		.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 deletePipeline = async (token: string, id: string, urlIdx: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines/delete`, {
+		method: 'DELETE',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		},
+		body: JSON.stringify({
+			id: id,
+			urlIdx: urlIdx
+		})
+	})
+		.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 getPipelines = async (token: string, urlIdx?: string) => {
+	let error = null;
+
+	const searchParams = new URLSearchParams();
+	if (urlIdx !== undefined) {
+		searchParams.append('urlIdx', urlIdx);
+	}
+
+	const res = await fetch(`${WEBUI_BASE_URL}/api/pipelines?${searchParams.toString()}`, {
+		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;
+	}
+
+	let pipelines = res?.data ?? [];
+	return pipelines;
+};
+
+export const getPipelineValves = async (token: string, pipeline_id: string, urlIdx: string) => {
+	let error = null;
+
+	const searchParams = new URLSearchParams();
+	if (urlIdx !== undefined) {
+		searchParams.append('urlIdx', urlIdx);
+	}
+
+	const res = await fetch(
+		`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves?${searchParams.toString()}`,
+		{
+			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 getPipelineValvesSpec = async (token: string, pipeline_id: string, urlIdx: string) => {
+	let error = null;
+
+	const searchParams = new URLSearchParams();
+	if (urlIdx !== undefined) {
+		searchParams.append('urlIdx', urlIdx);
+	}
+
+	const res = await fetch(
+		`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves/spec?${searchParams.toString()}`,
+		{
+			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 updatePipelineValves = async (
+	token: string = '',
+	pipeline_id: string,
+	valves: object,
+	urlIdx: string
+) => {
+	let error = null;
+
+	const searchParams = new URLSearchParams();
+	if (urlIdx !== undefined) {
+		searchParams.append('urlIdx', urlIdx);
+	}
+
+	const res = await fetch(
+		`${WEBUI_BASE_URL}/api/pipelines/${pipeline_id}/valves/update?${searchParams.toString()}`,
+		{
+			method: 'POST',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				...(token && { authorization: `Bearer ${token}` })
+			},
+			body: JSON.stringify(valves)
+		}
+	)
+		.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 getBackendConfig = async () => {
 	let error = null;
 

+ 27 - 38
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;
@@ -135,10 +135,10 @@ export const updateOllamaUrls = async (token: string = '', urls: string[]) => {
 	return res.OLLAMA_BASE_URLS;
 };
 
-export const getOllamaVersion = async (token: string = '') => {
+export const getOllamaVersion = async (token: string, urlIdx?: number) => {
 	let error = null;
 
-	const res = await fetch(`${OLLAMA_API_BASE_URL}/api/version`, {
+	const res = await fetch(`${OLLAMA_API_BASE_URL}/api/version${urlIdx ? `/${urlIdx}` : ''}`, {
 		method: 'GET',
 		headers: {
 			Accept: 'application/json',
@@ -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 (

+ 87 - 25
src/lib/apis/openai/index.ts

@@ -1,5 +1,6 @@
 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 = '') => {
 	let error = null;
@@ -202,17 +203,20 @@ export const updateOpenAIKeys = async (token: string = '', keys: string[]) => {
 	return res.OPENAI_API_KEYS;
 };
 
-export const getOpenAIModels = async (token: string = '') => {
+export const getOpenAIModels = async (token: string, urlIdx?: number) => {
 	let error = null;
 
-	const res = await fetch(`${OPENAI_API_BASE_URL}/models`, {
-		method: 'GET',
-		headers: {
-			Accept: 'application/json',
-			'Content-Type': 'application/json',
-			...(token && { authorization: `Bearer ${token}` })
+	const res = await fetch(
+		`${OPENAI_API_BASE_URL}/models${typeof urlIdx === 'number' ? `/${urlIdx}` : ''}`,
+		{
+			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();
@@ -226,20 +230,7 @@ export const getOpenAIModels = async (token: string = '') => {
 		throw error;
 	}
 
-	const models = Array.isArray(res) ? res : res?.data ?? null;
-
-	return models
-		? models
-				.map((model) => ({
-					id: model.id,
-					name: model.name ?? model.id,
-					external: true,
-					custom_info: model.custom_info
-				}))
-				.sort((a, b) => {
-					return a.name.localeCompare(b.name);
-				})
-		: models;
+	return res;
 };
 
 export const getOpenAIModelsDirect = async (
@@ -345,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);
 
@@ -370,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) => {
@@ -391,3 +385,71 @@ export const generateTitle = async (
 
 	return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat';
 };
+
+export const generateSearchQuery = async (
+	token: string = '',
+	model: string,
+	previousMessages: string[],
+	prompt: string,
+	url: string = OPENAI_API_BASE_URL
+): Promise<string | undefined> => {
+	let error = null;
+
+	// TODO: Allow users to specify the prompt
+	// Get the current date in the format "January 20, 2024"
+	const currentDate = new Intl.DateTimeFormat('en-US', {
+		year: 'numeric',
+		month: 'long',
+		day: '2-digit'
+	}).format(new Date());
+
+	const res = await fetch(`${url}/chat/completions`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			model: model,
+			// Few shot prompting
+			messages: [
+				{
+					role: 'assistant',
+					content: `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 ${currentDate}.`
+				},
+				{
+					role: 'user',
+					content: prompt
+				}
+				// {
+				// 	role: 'user',
+				// 	content:
+				// 		(previousMessages.length > 0
+				// 			? `Previous Questions:\n${previousMessages.join('\n')}\n\n`
+				// 			: '') + `Current Question: ${prompt}`
+				// }
+			],
+			stream: false,
+			// Restricting the max tokens to 30 to avoid long search queries
+			max_tokens: 30
+		})
+	})
+		.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 undefined;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? undefined;
+};

+ 68 - 0
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 = {
@@ -513,3 +540,44 @@ export const updateRerankingConfig = async (token: string, payload: RerankingMod
 
 	return res;
 };
+
+export const runWebSearch = async (
+	token: string,
+	query: string,
+	collection_name?: string
+): Promise<SearchDocument | null> => {
+	let error = null;
+
+	const res = await fetch(`${RAG_API_BASE_URL}/web/search`, {
+		method: 'POST',
+		headers: {
+			'Content-Type': 'application/json',
+			Authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			query,
+			collection_name: collection_name ?? ''
+		})
+	})
+		.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 interface SearchDocument {
+	status: boolean;
+	collection_name: string;
+	filenames: string[];
+}

+ 15 - 1
src/lib/apis/streaming/index.ts

@@ -8,6 +8,16 @@ type TextStreamUpdate = {
 	citations?: any;
 	// eslint-disable-next-line @typescript-eslint/no-explicit-any
 	error?: any;
+	usage?: ResponseUsage;
+};
+
+type ResponseUsage = {
+	/** Including images and tools if any */
+	prompt_tokens: number;
+	/** The tokens generated */
+	completion_tokens: number;
+	/** Sum of the above two fields */
+	total_tokens: number;
 };
 
 // createOpenAITextStream takes a responseBody with a SSE response,
@@ -59,7 +69,11 @@ async function* openAIStreamToIterator(
 				continue;
 			}
 
-			yield { done: false, value: parsedData.choices?.[0]?.delta?.content ?? '' };
+			yield {
+				done: false,
+				value: parsedData.choices?.[0]?.delta?.content ?? '',
+				usage: parsedData.usage
+			};
 		} catch (e) {
 			console.error('Error extracting delta from SSE event:', e);
 		}

+ 36 - 0
src/lib/apis/utils/index.ts

@@ -108,3 +108,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;
+	}
+};

+ 37 - 4
src/lib/components/admin/Settings/Database.svelte

@@ -2,7 +2,7 @@
 	import fileSaver from 'file-saver';
 	const { saveAs } = fileSaver;
 
-	import { downloadDatabase } from '$lib/apis/utils';
+	import { downloadDatabase, downloadLiteLLMConfig } from '$lib/apis/utils';
 	import { onMount, getContext } from 'svelte';
 	import { config, user } from '$lib/stores';
 	import { toast } from 'svelte-sonner';
@@ -68,10 +68,8 @@
 					</button>
 				</div>
 
-				<hr class=" dark:border-gray-700 my-1" />
-
 				<button
-					class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+					class=" flex rounded-md py-2 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
 					on:click={() => {
 						exportAllUserChats();
 					}}
@@ -96,6 +94,41 @@
 					</div>
 				</button>
 			{/if}
+
+			<hr class=" dark:border-gray-850 my-1" />
+
+			<div class="  flex w-full justify-between">
+				<!-- <div class=" self-center text-xs font-medium">{$i18n.t('Allow Chat Deletion')}</div> -->
+
+				<button
+					class=" flex rounded-md py-1.5 px-3 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+					type="button"
+					on:click={() => {
+						downloadLiteLLMConfig(localStorage.token).catch((error) => {
+							toast.error(error);
+						});
+					}}
+				>
+					<div class=" self-center mr-3">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 24 24"
+							fill="currentColor"
+							class="size-4"
+						>
+							<path
+								fill-rule="evenodd"
+								d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875Zm5.845 17.03a.75.75 0 0 0 1.06 0l3-3a.75.75 0 1 0-1.06-1.06l-1.72 1.72V12a.75.75 0 0 0-1.5 0v4.19l-1.72-1.72a.75.75 0 0 0-1.06 1.06l3 3Z"
+								clip-rule="evenodd"
+							/>
+							<path
+								d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
+							/>
+						</svg>
+					</div>
+					<div class=" self-center text-sm font-medium">Export LiteLLM config.yaml</div>
+				</button>
+			</div>
 		</div>
 	</div>
 

+ 82 - 161
src/lib/components/admin/Settings/General.svelte

@@ -6,61 +6,44 @@
 		updateWebhookUrl
 	} from '$lib/apis';
 	import {
+		getAdminConfig,
 		getDefaultUserRole,
 		getJWTExpiresDuration,
 		getSignUpEnabledStatus,
 		toggleSignUpEnabledStatus,
+		updateAdminConfig,
 		updateDefaultUserRole,
 		updateJWTExpiresDuration
 	} from '$lib/apis/auths';
+	import Switch from '$lib/components/common/Switch.svelte';
 	import { onMount, getContext } from 'svelte';
 
 	const i18n = getContext('i18n');
 
 	export let saveHandler: Function;
-	let signUpEnabled = true;
-	let defaultUserRole = 'pending';
-	let JWTExpiresIn = '';
 
+	let adminConfig = null;
 	let webhookUrl = '';
-	let communitySharingEnabled = true;
 
-	const toggleSignUpEnabled = async () => {
-		signUpEnabled = await toggleSignUpEnabledStatus(localStorage.token);
-	};
-
-	const updateDefaultUserRoleHandler = async (role) => {
-		defaultUserRole = await updateDefaultUserRole(localStorage.token, role);
-	};
-
-	const updateJWTExpiresDurationHandler = async (duration) => {
-		JWTExpiresIn = await updateJWTExpiresDuration(localStorage.token, duration);
-	};
-
-	const updateWebhookUrlHandler = async () => {
+	const updateHandler = async () => {
 		webhookUrl = await updateWebhookUrl(localStorage.token, webhookUrl);
-	};
+		const res = await updateAdminConfig(localStorage.token, adminConfig);
 
-	const toggleCommunitySharingEnabled = async () => {
-		communitySharingEnabled = await toggleCommunitySharingEnabledStatus(localStorage.token);
+		if (res) {
+			toast.success(i18n.t('Settings updated successfully'));
+		} else {
+			toast.error(i18n.t('Failed to update settings'));
+		}
 	};
 
 	onMount(async () => {
 		await Promise.all([
 			(async () => {
-				signUpEnabled = await getSignUpEnabledStatus(localStorage.token);
-			})(),
-			(async () => {
-				defaultUserRole = await getDefaultUserRole(localStorage.token);
-			})(),
-			(async () => {
-				JWTExpiresIn = await getJWTExpiresDuration(localStorage.token);
+				adminConfig = await getAdminConfig(localStorage.token);
 			})(),
+
 			(async () => {
 				webhookUrl = await getWebhookUrl(localStorage.token);
-			})(),
-			(async () => {
-				communitySharingEnabled = await getCommunitySharingEnabledStatus(localStorage.token);
 			})()
 		]);
 	});
@@ -69,156 +52,94 @@
 <form
 	class="flex flex-col h-full justify-between space-y-3 text-sm"
 	on:submit|preventDefault={() => {
-		updateJWTExpiresDurationHandler(JWTExpiresIn);
-		updateWebhookUrlHandler();
+		updateHandler();
 		saveHandler();
 	}}
 >
-	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-80">
-		<div>
-			<div class=" mb-2 text-sm font-medium">{$i18n.t('General Settings')}</div>
-
-			<div class="  flex w-full justify-between">
-				<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
-
-				<button
-					class="p-1 px-3 text-xs flex rounded transition"
-					on:click={() => {
-						toggleSignUpEnabled();
-					}}
-					type="button"
-				>
-					{#if signUpEnabled}
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 16 16"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
-							/>
-						</svg>
-						<span class="ml-2 self-center">{$i18n.t('Enabled')}</span>
-					{:else}
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 16 16"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								fill-rule="evenodd"
-								d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
-								clip-rule="evenodd"
-							/>
-						</svg>
-
-						<span class="ml-2 self-center">{$i18n.t('Disabled')}</span>
-					{/if}
-				</button>
-			</div>
+	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[22rem]">
+		{#if adminConfig !== null}
+			<div>
+				<div class=" mb-3 text-sm font-medium">{$i18n.t('General Settings')}</div>
+
+				<div class="  flex w-full justify-between pr-2">
+					<div class=" self-center text-xs font-medium">{$i18n.t('Enable New Sign Ups')}</div>
 
-			<div class=" flex w-full justify-between">
-				<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
-				<div class="flex items-center relative">
-					<select
-						class="dark:bg-gray-900 w-fit pr-8 rounded py-2 px-2 text-xs bg-transparent outline-none text-right"
-						bind:value={defaultUserRole}
-						placeholder="Select a theme"
-						on:change={(e) => {
-							updateDefaultUserRoleHandler(e.target.value);
-						}}
-					>
-						<option value="pending">{$i18n.t('pending')}</option>
-						<option value="user">{$i18n.t('user')}</option>
-						<option value="admin">{$i18n.t('admin')}</option>
-					</select>
+					<Switch bind:state={adminConfig.ENABLE_SIGNUP} />
 				</div>
-			</div>
 
-			<div class="  flex w-full justify-between">
-				<div class=" self-center text-xs font-medium">{$i18n.t('Enable Community Sharing')}</div>
-
-				<button
-					class="p-1 px-3 text-xs flex rounded transition"
-					on:click={() => {
-						toggleCommunitySharingEnabled();
-					}}
-					type="button"
-				>
-					{#if communitySharingEnabled}
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 16 16"
-							fill="currentColor"
-							class="w-4 h-4"
+				<div class="  my-3 flex w-full justify-between">
+					<div class=" self-center text-xs font-medium">{$i18n.t('Default User Role')}</div>
+					<div class="flex items-center relative">
+						<select
+							class="dark:bg-gray-900 w-fit pr-8 rounded px-2 text-xs bg-transparent outline-none text-right"
+							bind:value={adminConfig.DEFAULT_USER_ROLE}
+							placeholder="Select a role"
 						>
-							<path
-								d="M11.5 1A3.5 3.5 0 0 0 8 4.5V7H2.5A1.5 1.5 0 0 0 1 8.5v5A1.5 1.5 0 0 0 2.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 9.5 7V4.5a2 2 0 1 1 4 0v1.75a.75.75 0 0 0 1.5 0V4.5A3.5 3.5 0 0 0 11.5 1Z"
-							/>
-						</svg>
-						<span class="ml-2 self-center">{$i18n.t('Enabled')}</span>
-					{:else}
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 16 16"
-							fill="currentColor"
-							class="w-4 h-4"
-						>
-							<path
-								fill-rule="evenodd"
-								d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7A1.5 1.5 0 0 0 3 8.5v5A1.5 1.5 0 0 0 4.5 15h7a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 11.5 7V4.5A3.5 3.5 0 0 0 8 1Zm2 6V4.5a2 2 0 1 0-4 0V7h4Z"
-								clip-rule="evenodd"
-							/>
-						</svg>
-
-						<span class="ml-2 self-center">{$i18n.t('Disabled')}</span>
-					{/if}
-				</button>
-			</div>
+							<option value="pending">{$i18n.t('pending')}</option>
+							<option value="user">{$i18n.t('user')}</option>
+							<option value="admin">{$i18n.t('admin')}</option>
+						</select>
+					</div>
+				</div>
 
-			<hr class=" dark:border-gray-700 my-3" />
+				<hr class=" dark:border-gray-850 my-2" />
 
-			<div class=" w-full justify-between">
-				<div class="flex w-full justify-between">
-					<div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div>
-				</div>
+				<div class="my-3 flex w-full items-center justify-between pr-2">
+					<div class=" self-center text-xs font-medium">
+						{$i18n.t('Show Admin Details in Account Pending Overlay')}
+					</div>
 
-				<div class="flex mt-2 space-x-2">
-					<input
-						class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
-						type="text"
-						placeholder={`https://example.com/webhook`}
-						bind:value={webhookUrl}
-					/>
+					<Switch bind:state={adminConfig.SHOW_ADMIN_DETAILS} />
 				</div>
-			</div>
 
-			<hr class=" dark:border-gray-700 my-3" />
+				<div class="my-3 flex w-full items-center justify-between pr-2">
+					<div class=" self-center text-xs font-medium">{$i18n.t('Enable Community Sharing')}</div>
 
-			<div class=" w-full justify-between">
-				<div class="flex w-full justify-between">
-					<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
+					<Switch bind:state={adminConfig.ENABLE_COMMUNITY_SHARING} />
 				</div>
 
-				<div class="flex mt-2 space-x-2">
-					<input
-						class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
-						type="text"
-						placeholder={`e.g.) "30m","1h", "10d". `}
-						bind:value={JWTExpiresIn}
-					/>
+				<hr class=" dark:border-gray-850 my-2" />
+
+				<div class=" w-full justify-between">
+					<div class="flex w-full justify-between">
+						<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
+					</div>
+
+					<div class="flex mt-2 space-x-2">
+						<input
+							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+							type="text"
+							placeholder={`e.g.) "30m","1h", "10d". `}
+							bind:value={adminConfig.JWT_EXPIRES_IN}
+						/>
+					</div>
+
+					<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
+						{$i18n.t('Valid time units:')}
+						<span class=" text-gray-300 font-medium"
+							>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
+						>
+					</div>
 				</div>
 
-				<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
-					{$i18n.t('Valid time units:')}
-					<span class=" text-gray-300 font-medium"
-						>{$i18n.t("'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.")}</span
-					>
+				<hr class=" dark:border-gray-850 my-2" />
+
+				<div class=" w-full justify-between">
+					<div class="flex w-full justify-between">
+						<div class=" self-center text-xs font-medium">{$i18n.t('Webhook URL')}</div>
+					</div>
+
+					<div class="flex mt-2 space-x-2">
+						<input
+							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+							type="text"
+							placeholder={`https://example.com/webhook`}
+							bind:value={webhookUrl}
+						/>
+					</div>
 				</div>
 			</div>
-		</div>
+		{/if}
 	</div>
 
 	<div class="flex justify-end pt-3 text-sm font-medium">

+ 405 - 0
src/lib/components/admin/Settings/Pipelines.svelte

@@ -0,0 +1,405 @@
+<script lang="ts">
+	import { v4 as uuidv4 } from 'uuid';
+
+	import { toast } from 'svelte-sonner';
+	import { models } from '$lib/stores';
+	import { getContext, onMount, tick } from 'svelte';
+	import type { Writable } from 'svelte/store';
+	import type { i18n as i18nType } from 'i18next';
+	import {
+		getPipelineValves,
+		getPipelineValvesSpec,
+		updatePipelineValves,
+		getPipelines,
+		getModels,
+		getPipelinesList,
+		downloadPipeline,
+		deletePipeline
+	} from '$lib/apis';
+
+	import Spinner from '$lib/components/common/Spinner.svelte';
+
+	const i18n: Writable<i18nType> = getContext('i18n');
+
+	export let saveHandler: Function;
+
+	let downloading = false;
+
+	let PIPELINES_LIST = null;
+	let selectedPipelinesUrlIdx = '';
+
+	let pipelines = null;
+
+	let valves = null;
+	let valves_spec = null;
+	let selectedPipelineIdx = null;
+
+	let pipelineDownloadUrl = '';
+
+	const updateHandler = async () => {
+		const pipeline = pipelines[selectedPipelineIdx];
+
+		if (pipeline && (pipeline?.valves ?? false)) {
+			for (const property in valves_spec.properties) {
+				if (valves_spec.properties[property]?.type === 'array') {
+					valves[property] = valves[property].split(',').map((v) => v.trim());
+				}
+			}
+
+			const res = await updatePipelineValves(
+				localStorage.token,
+				pipeline.id,
+				valves,
+				selectedPipelinesUrlIdx
+			).catch((error) => {
+				toast.error(error);
+			});
+
+			if (res) {
+				toast.success('Valves updated successfully');
+				setPipelines();
+				models.set(await getModels(localStorage.token));
+				saveHandler();
+			}
+		} else {
+			toast.error('No valves to update');
+		}
+	};
+
+	const getValves = async (idx) => {
+		valves = null;
+		valves_spec = null;
+
+		valves_spec = await getPipelineValvesSpec(
+			localStorage.token,
+			pipelines[idx].id,
+			selectedPipelinesUrlIdx
+		);
+		valves = await getPipelineValves(
+			localStorage.token,
+			pipelines[idx].id,
+			selectedPipelinesUrlIdx
+		);
+
+		for (const property in valves_spec.properties) {
+			if (valves_spec.properties[property]?.type === 'array') {
+				valves[property] = valves[property].join(',');
+			}
+		}
+	};
+
+	const setPipelines = async () => {
+		pipelines = null;
+		valves = null;
+		valves_spec = null;
+
+		if (PIPELINES_LIST.length > 0) {
+			console.log(selectedPipelinesUrlIdx);
+			pipelines = await getPipelines(localStorage.token, selectedPipelinesUrlIdx);
+
+			if (pipelines.length > 0) {
+				selectedPipelineIdx = 0;
+				await getValves(selectedPipelineIdx);
+			}
+		} else {
+			pipelines = [];
+		}
+	};
+
+	const addPipelineHandler = async () => {
+		downloading = true;
+		const res = await downloadPipeline(
+			localStorage.token,
+			pipelineDownloadUrl,
+			selectedPipelinesUrlIdx
+		).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		if (res) {
+			toast.success('Pipeline downloaded successfully');
+			setPipelines();
+			models.set(await getModels(localStorage.token));
+		}
+
+		downloading = false;
+	};
+
+	const deletePipelineHandler = async () => {
+		const res = await deletePipeline(
+			localStorage.token,
+			pipelines[selectedPipelineIdx].id,
+			selectedPipelinesUrlIdx
+		).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		if (res) {
+			toast.success('Pipeline deleted successfully');
+			setPipelines();
+			models.set(await getModels(localStorage.token));
+		}
+	};
+
+	onMount(async () => {
+		PIPELINES_LIST = await getPipelinesList(localStorage.token);
+		console.log(PIPELINES_LIST);
+
+		if (PIPELINES_LIST.length > 0) {
+			selectedPipelinesUrlIdx = PIPELINES_LIST[0]['idx'].toString();
+		}
+
+		await setPipelines();
+	});
+</script>
+
+<form
+	class="flex flex-col h-full justify-between space-y-3 text-sm"
+	on:submit|preventDefault={async () => {
+		updateHandler();
+	}}
+>
+	<div class="  pr-1.5 overflow-y-scroll max-h-80 h-full">
+		{#if PIPELINES_LIST !== null}
+			<div class="flex w-full justify-between mb-2">
+				<div class=" self-center text-sm font-semibold">
+					{$i18n.t('Manage Pipelines')}
+				</div>
+			</div>
+
+			{#if PIPELINES_LIST.length > 0}
+				<div class="space-y-1">
+					<div class="flex gap-2">
+						<div class="flex-1">
+							<select
+								class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+								bind:value={selectedPipelinesUrlIdx}
+								placeholder={$i18n.t('Select a pipeline url')}
+								on:change={async () => {
+									await tick();
+									await setPipelines();
+								}}
+							>
+								<option value="" selected disabled class="bg-gray-100 dark:bg-gray-700"
+									>{$i18n.t('Select a pipeline url')}</option
+								>
+
+								{#each PIPELINES_LIST as pipelines, idx}
+									<option value={pipelines.idx.toString()} class="bg-gray-100 dark:bg-gray-700"
+										>{pipelines.url}</option
+									>
+								{/each}
+							</select>
+						</div>
+					</div>
+				</div>
+
+				<div class=" my-2">
+					<div class=" mb-2 text-sm font-medium">
+						{$i18n.t('Install from Github URL')}
+					</div>
+					<div class="flex w-full">
+						<div class="flex-1 mr-2">
+							<input
+								class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+								placeholder={$i18n.t('Enter Github Raw URL')}
+								bind:value={pipelineDownloadUrl}
+							/>
+						</div>
+						<button
+							class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
+							on:click={() => {
+								addPipelineHandler();
+							}}
+							disabled={downloading}
+							type="button"
+						>
+							{#if downloading}
+								<div class="self-center">
+									<svg
+										class=" w-4 h-4"
+										viewBox="0 0 24 24"
+										fill="currentColor"
+										xmlns="http://www.w3.org/2000/svg"
+									>
+										<style>
+											.spinner_ajPY {
+												transform-origin: center;
+												animation: spinner_AtaB 0.75s infinite linear;
+											}
+
+											@keyframes spinner_AtaB {
+												100% {
+													transform: rotate(360deg);
+												}
+											}
+										</style>
+										<path
+											d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+											opacity=".25"
+										/>
+										<path
+											d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+											class="spinner_ajPY"
+										/>
+									</svg>
+								</div>
+							{:else}
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 16 16"
+									fill="currentColor"
+									class="w-4 h-4"
+								>
+									<path
+										d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
+									/>
+									<path
+										d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+									/>
+								</svg>
+							{/if}
+						</button>
+					</div>
+
+					<div class="mt-2 text-xs text-gray-500">
+						<span class=" font-semibold dark:text-gray-200">Warning:</span> Pipelines are a plugin
+						system with arbitrary code execution —
+						<span class=" font-medium dark:text-gray-400"
+							>don't fetch random pipelines from sources you don't trust.</span
+						>
+					</div>
+				</div>
+
+				<hr class=" dark:border-gray-800 my-3 w-full" />
+
+				{#if pipelines !== null}
+					{#if pipelines.length > 0}
+						<div class="flex w-full justify-between mb-2">
+							<div class=" self-center text-sm font-semibold">
+								{$i18n.t('Pipelines Valves')}
+							</div>
+						</div>
+						<div class="space-y-1">
+							{#if pipelines.length > 0}
+								<div class="flex gap-2">
+									<div class="flex-1">
+										<select
+											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											bind:value={selectedPipelineIdx}
+											placeholder={$i18n.t('Select a pipeline')}
+											on:change={async () => {
+												await tick();
+												await getValves(selectedPipelineIdx);
+											}}
+										>
+											{#each pipelines as pipeline, idx}
+												<option value={idx} class="bg-gray-100 dark:bg-gray-700"
+													>{pipeline.name} ({pipeline.type ?? 'pipe'})</option
+												>
+											{/each}
+										</select>
+									</div>
+
+									<button
+										class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
+										on:click={() => {
+											deletePipelineHandler();
+										}}
+										type="button"
+									>
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 16 16"
+											fill="currentColor"
+											class="w-4 h-4"
+										>
+											<path
+												fill-rule="evenodd"
+												d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
+												clip-rule="evenodd"
+											/>
+										</svg>
+									</button>
+								</div>
+							{/if}
+
+							<div class="space-y-1">
+								{#if pipelines[selectedPipelineIdx].valves}
+									{#if valves}
+										{#each Object.keys(valves_spec.properties) as property, idx}
+											<div class=" py-0.5 w-full justify-between">
+												<div class="flex w-full justify-between">
+													<div class=" self-center text-xs font-medium">
+														{valves_spec.properties[property].title}
+													</div>
+
+													<button
+														class="p-1 px-3 text-xs flex rounded transition"
+														type="button"
+														on:click={() => {
+															valves[property] = (valves[property] ?? null) === null ? '' : null;
+														}}
+													>
+														{#if (valves[property] ?? null) === null}
+															<span class="ml-2 self-center"> {$i18n.t('None')} </span>
+														{:else}
+															<span class="ml-2 self-center"> {$i18n.t('Custom')} </span>
+														{/if}
+													</button>
+												</div>
+
+												{#if (valves[property] ?? null) !== null}
+													<div class="flex mt-0.5 space-x-2">
+														<div class=" flex-1">
+															<input
+																class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+																type="text"
+																placeholder={valves_spec.properties[property].title}
+																bind:value={valves[property]}
+																autocomplete="off"
+															/>
+														</div>
+													</div>
+												{/if}
+											</div>
+										{/each}
+									{:else}
+										<Spinner className="size-5" />
+									{/if}
+								{:else}
+									<div>No valves</div>
+								{/if}
+							</div>
+						</div>
+					{:else if pipelines.length === 0}
+						<div>Pipelines Not Detected</div>
+					{/if}
+				{:else}
+					<div class="flex justify-center">
+						<div class="my-auto">
+							<Spinner className="size-4" />
+						</div>
+					</div>
+				{/if}
+			{/if}
+		{:else}
+			<div class="flex justify-center h-full">
+				<div class="my-auto">
+					<Spinner className="size-6" />
+				</div>
+			</div>
+		{/if}
+	</div>
+
+	<div class="flex justify-end pt-3 text-sm font-medium">
+		<button
+			class=" px-4 py-2 bg-emerald-700 hover:bg-emerald-800 text-gray-100 transition rounded-lg"
+			type="submit"
+		>
+			Save
+		</button>
+	</div>
+</form>

+ 37 - 4
src/lib/components/admin/SettingsModal.svelte

@@ -8,6 +8,7 @@
 
 	import Banners from '$lib/components/admin/Settings/Banners.svelte';
 	import { toast } from 'svelte-sonner';
+	import Pipelines from './Settings/Pipelines.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -149,33 +150,65 @@
 					</div>
 					<div class=" self-center">{$i18n.t('Banners')}</div>
 				</button>
+
+				<button
+					class="px-2.5 py-2.5 min-w-fit rounded-lg flex-1 md:flex-none flex text-right transition {selectedTab ===
+					'pipelines'
+						? 'bg-gray-200 dark:bg-gray-700'
+						: ' hover:bg-gray-300 dark:hover:bg-gray-800'}"
+					on:click={() => {
+						selectedTab = 'pipelines';
+					}}
+				>
+					<div class=" self-center mr-2">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 24 24"
+							fill="currentColor"
+							class="size-4"
+						>
+							<path
+								d="M11.644 1.59a.75.75 0 0 1 .712 0l9.75 5.25a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.712 0l-9.75-5.25a.75.75 0 0 1 0-1.32l9.75-5.25Z"
+							/>
+							<path
+								d="m3.265 10.602 7.668 4.129a2.25 2.25 0 0 0 2.134 0l7.668-4.13 1.37.739a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.71 0l-9.75-5.25a.75.75 0 0 1 0-1.32l1.37-.738Z"
+							/>
+							<path
+								d="m10.933 19.231-7.668-4.13-1.37.739a.75.75 0 0 0 0 1.32l9.75 5.25c.221.12.489.12.71 0l9.75-5.25a.75.75 0 0 0 0-1.32l-1.37-.738-7.668 4.13a2.25 2.25 0 0 1-2.134-.001Z"
+							/>
+						</svg>
+					</div>
+					<div class=" self-center">{$i18n.t('Pipelines')}</div>
+				</button>
 			</div>
 			<div class="flex-1 md:min-h-[380px]">
 				{#if selectedTab === 'general'}
 					<General
 						saveHandler={() => {
-							show = false;
 							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>
 				{:else if selectedTab === 'users'}
 					<Users
 						saveHandler={() => {
-							show = false;
 							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>
 				{:else if selectedTab === 'db'}
 					<Database
 						saveHandler={() => {
-							show = false;
 							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>
 				{:else if selectedTab === 'banners'}
 					<Banners
 						saveHandler={() => {
-							show = false;
+							toast.success($i18n.t('Settings saved successfully!'));
+						}}
+					/>
+				{:else if selectedTab === 'pipelines'}
+					<Pipelines
+						saveHandler={() => {
 							toast.success($i18n.t('Settings saved successfully!'));
 						}}
 					/>

+ 307 - 119
src/lib/components/chat/Chat.svelte

@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { v4 as uuidv4 } from 'uuid';
 	import { toast } from 'svelte-sonner';
+	import mermaid from 'mermaid';
 
 	import { getContext, onMount, tick } from 'svelte';
 	import { goto } from '$app/navigation';
@@ -16,11 +17,18 @@
 		showSidebar,
 		tags as _tags,
 		WEBUI_NAME,
-		banners
+		banners,
+		user,
+		socket
 	} from '$lib/stores';
-	import { convertMessagesToHistory, copyToClipboard, splitStream } from '$lib/utils';
+	import {
+		convertMessagesToHistory,
+		copyToClipboard,
+		promptTemplate,
+		splitStream
+	} from '$lib/utils';
 
-	import { cancelOllamaRequest, generateChatCompletion } from '$lib/apis/ollama';
+	import { generateChatCompletion } from '$lib/apis/ollama';
 	import {
 		addTagById,
 		createNewChat,
@@ -31,7 +39,11 @@
 		getTagsById,
 		updateChatById
 	} from '$lib/apis/chats';
-	import { generateOpenAIChatCompletion, generateTitle } from '$lib/apis/openai';
+	import {
+		generateOpenAIChatCompletion,
+		generateSearchQuery,
+		generateTitle
+	} from '$lib/apis/openai';
 
 	import MessageInput from '$lib/components/chat/MessageInput.svelte';
 	import Messages from '$lib/components/chat/Messages.svelte';
@@ -41,8 +53,10 @@
 	import { queryMemory } from '$lib/apis/memories';
 	import type { Writable } from 'svelte/store';
 	import type { i18n as i18nType } from 'i18next';
+	import { runWebSearch } from '$lib/apis/rag';
 	import Banner from '../common/Banner.svelte';
 	import { getUserSettings } from '$lib/apis/users';
+	import { chatCompleted } from '$lib/apis';
 
 	const i18n: Writable<i18nType> = getContext('i18n');
 
@@ -53,13 +67,14 @@
 	let autoScroll = true;
 	let processing = '';
 	let messagesContainerElement: HTMLDivElement;
-	let currentRequestId = null;
 
 	let showModelSelector = true;
 
 	let selectedModels = [''];
 	let atSelectedModel: Model | undefined;
 
+	let webSearchEnabled = false;
+
 	let chat = null;
 	let tags = [];
 
@@ -116,10 +131,6 @@
 	//////////////////////////
 
 	const initNewChat = async () => {
-		if (currentRequestId !== null) {
-			await cancelOllamaRequest(localStorage.token, currentRequestId);
-			currentRequestId = null;
-		}
 		window.history.replaceState(history.state, '', `/`);
 		await chatId.set('');
 
@@ -228,6 +239,58 @@
 		}
 	};
 
+	const createMessagesList = (responseMessageId) => {
+		const message = history.messages[responseMessageId];
+		if (message.parentId) {
+			return [...createMessagesList(message.parentId), message];
+		} else {
+			return [message];
+		}
+	};
+
+	const chatCompletedHandler = async (modelId, messages) => {
+		await mermaid.run({
+			querySelector: '.mermaid'
+		});
+
+		const res = await chatCompleted(localStorage.token, {
+			model: modelId,
+			messages: messages.map((m) => ({
+				id: m.id,
+				role: m.role,
+				content: m.content,
+				timestamp: m.timestamp
+			})),
+			chat_id: $chatId
+		}).catch((error) => {
+			console.error(error);
+			return null;
+		});
+
+		if (res !== null) {
+			// Update chat history with the new messages
+			for (const message of res.messages) {
+				history.messages[message.id] = {
+					...history.messages[message.id],
+					...(history.messages[message.id].content !== message.content
+						? { originalContent: history.messages[message.id].content }
+						: {}),
+					...message
+				};
+			}
+		}
+	};
+
+	const getChatEventEmitter = async (modelId: string, chatId: string = '') => {
+		return setInterval(() => {
+			$socket?.emit('usage', {
+				action: 'chat',
+				model: modelId,
+				chat_id: chatId
+			});
+		}, 1000);
+	};
+
 	//////////////////////////
 	// Ollama functions
 	//////////////////////////
@@ -399,11 +462,21 @@
 					}
 					responseMessage.userContext = userContext;
 
+					const chatEventEmitter = await getChatEventEmitter(model.id, _chatId);
+
+					if (webSearchEnabled) {
+						await getWebSearchResults(model.id, parentId, responseMessageId);
+					}
+
 					if (model?.owned_by === 'openai') {
 						await sendPromptOpenAI(model, prompt, responseMessageId, _chatId);
 					} else if (model) {
 						await sendPromptOllama(model, prompt, responseMessageId, _chatId);
 					}
+
+					console.log('chatEventEmitter', chatEventEmitter);
+
+					if (chatEventEmitter) clearInterval(chatEventEmitter);
 				} else {
 					toast.error($i18n.t(`Model {{modelId}} not found`, { modelId }));
 				}
@@ -413,8 +486,80 @@
 		await chats.set(await getChatList(localStorage.token));
 	};
 
+	const getWebSearchResults = async (model: string, parentId: string, responseId: string) => {
+		const responseMessage = history.messages[responseId];
+
+		responseMessage.status = {
+			done: false,
+			action: 'web_search',
+			description: $i18n.t('Generating search query')
+		};
+		messages = messages;
+
+		const prompt = history.messages[parentId].content;
+		let searchQuery = prompt;
+		if (prompt.length > 100) {
+			searchQuery = await generateChatSearchQuery(model, prompt);
+			if (!searchQuery) {
+				toast.warning($i18n.t('No search query generated'));
+				responseMessage.status = {
+					...responseMessage.status,
+					done: true,
+					error: true,
+					description: 'No search query generated'
+				};
+				messages = messages;
+				return;
+			}
+		}
+
+		responseMessage.status = {
+			...responseMessage.status,
+			description: $i18n.t("Searching the web for '{{searchQuery}}'", { searchQuery })
+		};
+		messages = messages;
+
+		const results = await runWebSearch(localStorage.token, searchQuery).catch((error) => {
+			console.log(error);
+			toast.error(error);
+
+			return null;
+		});
+
+		if (results) {
+			responseMessage.status = {
+				...responseMessage.status,
+				done: true,
+				description: $i18n.t('Searched {{count}} sites', { count: results.filenames.length }),
+				urls: results.filenames
+			};
+
+			if (responseMessage?.files ?? undefined === undefined) {
+				responseMessage.files = [];
+			}
+
+			responseMessage.files.push({
+				collection_name: results.collection_name,
+				name: searchQuery,
+				type: 'web_search_results',
+				urls: results.filenames
+			});
+
+			messages = messages;
+		} else {
+			responseMessage.status = {
+				...responseMessage.status,
+				done: true,
+				error: true,
+				description: 'No search results found'
+			};
+			messages = messages;
+		}
+	};
+
 	const sendPromptOllama = async (model, userPrompt, responseMessageId, _chatId) => {
 		model = model.id;
+
 		const responseMessage = history.messages[responseMessageId];
 
 		// Wait until history/message have been updated
@@ -427,7 +572,7 @@
 			$settings.system || (responseMessage?.userContext ?? null)
 				? {
 						role: 'system',
-						content: `${$settings?.system ?? ''}${
+						content: `${promptTemplate($settings?.system ?? '', $user.name)}${
 							responseMessage?.userContext ?? null
 								? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
 								: ''
@@ -475,7 +620,9 @@
 		const docs = messages
 			.filter((message) => message?.files ?? null)
 			.map((message) =>
-				message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
+				message.files.filter((item) =>
+					['doc', 'collection', 'web_search_results'].includes(item.type)
+				)
 			)
 			.flat(1);
 
@@ -496,7 +643,8 @@
 			format: $settings.requestFormat ?? undefined,
 			keep_alive: $settings.keepAlive ?? undefined,
 			docs: docs.length > 0 ? docs : undefined,
-			citations: docs.length > 0
+			citations: docs.length > 0,
+			chat_id: $chatId
 		});
 
 		if (res && res.ok) {
@@ -515,11 +663,11 @@
 
 					if (stopResponseFlag) {
 						controller.abort('User: Stop Response');
-						await cancelOllamaRequest(localStorage.token, currentRequestId);
+					} else {
+						const messages = createMessagesList(responseMessageId);
+						await chatCompletedHandler(model, messages);
 					}
 
-					currentRequestId = null;
-
 					break;
 				}
 
@@ -540,62 +688,58 @@
 								throw data;
 							}
 
-							if ('id' in data) {
-								console.log(data);
-								currentRequestId = data.id;
-							} else {
-								if (data.done == false) {
-									if (responseMessage.content == '' && data.message.content == '\n') {
-										continue;
-									} else {
-										responseMessage.content += data.message.content;
-										messages = messages;
-									}
+							if (data.done == false) {
+								if (responseMessage.content == '' && data.message.content == '\n') {
+									continue;
 								} else {
-									responseMessage.done = true;
-
-									if (responseMessage.content == '') {
-										responseMessage.error = true;
-										responseMessage.content =
-											'Oops! No text generated from Ollama, Please try again.';
-									}
-
-									responseMessage.context = data.context ?? null;
-									responseMessage.info = {
-										total_duration: data.total_duration,
-										load_duration: data.load_duration,
-										sample_count: data.sample_count,
-										sample_duration: data.sample_duration,
-										prompt_eval_count: data.prompt_eval_count,
-										prompt_eval_duration: data.prompt_eval_duration,
-										eval_count: data.eval_count,
-										eval_duration: data.eval_duration
-									};
+									responseMessage.content += data.message.content;
 									messages = messages;
+								}
+							} else {
+								responseMessage.done = true;
 
-									if ($settings.notificationEnabled && !document.hasFocus()) {
-										const notification = new Notification(
-											selectedModelfile
-												? `${
-														selectedModelfile.title.charAt(0).toUpperCase() +
-														selectedModelfile.title.slice(1)
-												  }`
-												: `${model}`,
-											{
-												body: responseMessage.content,
-												icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
-											}
-										);
-									}
-
-									if ($settings.responseAutoCopy) {
-										copyToClipboard(responseMessage.content);
-									}
-
-									if ($settings.responseAutoPlayback) {
-										await tick();
-										document.getElementById(`speak-button-${responseMessage.id}`)?.click();
-									}
+								if (responseMessage.content == '') {
+									responseMessage.error = {
+										code: 400,
+										content: `Oops! No text generated from Ollama, Please try again.`
+									};
+								}
+
+								responseMessage.context = data.context ?? null;
+								responseMessage.info = {
+									total_duration: data.total_duration,
+									load_duration: data.load_duration,
+									sample_count: data.sample_count,
+									sample_duration: data.sample_duration,
+									prompt_eval_count: data.prompt_eval_count,
+									prompt_eval_duration: data.prompt_eval_duration,
+									eval_count: data.eval_count,
+									eval_duration: data.eval_duration
+								};
+								messages = messages;
+
+								if ($settings.notificationEnabled && !document.hasFocus()) {
+									const notification = new Notification(
+										selectedModelfile
+											? `${
+													selectedModelfile.title.charAt(0).toUpperCase() +
+													selectedModelfile.title.slice(1)
+											  }`
+											: `${model}`,
+										{
+											body: responseMessage.content,
+											icon: selectedModelfile?.imageUrl ?? `${WEBUI_BASE_URL}/static/favicon.png`
+										}
+									);
+								}
+
+								if ($settings.responseAutoCopy) {
+									copyToClipboard(responseMessage.content);
+								}
+
+								if ($settings.responseAutoPlayback) {
+									await tick();
+									document.getElementById(`speak-button-${responseMessage.id}`)?.click();
 								}
 							}
 						}
@@ -629,24 +773,21 @@
 				console.log(error);
 				if ('detail' in error) {
 					toast.error(error.detail);
-					responseMessage.content = error.detail;
+					responseMessage.error = { content: error.detail };
 				} else {
 					toast.error(error.error);
-					responseMessage.content = error.error;
+					responseMessage.error = { content: error.error };
 				}
 			} else {
 				toast.error(
 					$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, { provider: 'Ollama' })
 				);
-				responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
-					provider: 'Ollama'
-				});
+				responseMessage.error = {
+					content: $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
+						provider: 'Ollama'
+					})
+				};
 			}
-
-			responseMessage.error = true;
-			responseMessage.content = $i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
-				provider: 'Ollama'
-			});
 			responseMessage.done = true;
 			messages = messages;
 		}
@@ -671,7 +812,9 @@
 		const docs = messages
 			.filter((message) => message?.files ?? null)
 			.map((message) =>
-				message.files.filter((item) => item.type === 'doc' || item.type === 'collection')
+				message.files.filter((item) =>
+					['doc', 'collection', 'web_search_results'].includes(item.type)
+				)
 			)
 			.flat(1);
 
@@ -685,11 +828,17 @@
 				{
 					model: model.id,
 					stream: true,
+					stream_options:
+						model.info?.meta?.capabilities?.usage ?? false
+							? {
+									include_usage: true
+							  }
+							: undefined,
 					messages: [
 						$settings.system || (responseMessage?.userContext ?? null)
 							? {
 									role: 'system',
-									content: `${$settings?.system ?? ''}${
+									content: `${promptTemplate($settings?.system ?? '', $user.name)}${
 										responseMessage?.userContext ?? null
 											? `\n\nUser Context:\n${(responseMessage?.userContext ?? []).join('\n')}`
 											: ''
@@ -741,7 +890,8 @@
 					frequency_penalty: $settings?.params?.frequency_penalty ?? undefined,
 					max_tokens: $settings?.params?.max_tokens ?? undefined,
 					docs: docs.length > 0 ? docs : undefined,
-					citations: docs.length > 0
+					citations: docs.length > 0,
+					chat_id: $chatId
 				},
 				`${OPENAI_API_BASE_URL}`
 			);
@@ -753,9 +903,10 @@
 
 			if (res && res.ok && res.body) {
 				const textStream = await createOpenAITextStream(res.body, $settings.splitLargeChunks);
+				let lastUsage = null;
 
 				for await (const update of textStream) {
-					const { value, done, citations, error } = update;
+					const { value, done, citations, error, usage } = update;
 					if (error) {
 						await handleOpenAIError(error, null, model, responseMessage);
 						break;
@@ -766,11 +917,19 @@
 
 						if (stopResponseFlag) {
 							controller.abort('User: Stop Response');
+						} else {
+							const messages = createMessagesList(responseMessageId);
+
+							await chatCompletedHandler(model.id, messages);
 						}
 
 						break;
 					}
 
+					if (usage) {
+						lastUsage = usage;
+					}
+
 					if (citations) {
 						responseMessage.citations = citations;
 						continue;
@@ -783,25 +942,29 @@
 						messages = messages;
 					}
 
-					if ($settings.notificationEnabled && !document.hasFocus()) {
-						const notification = new Notification(`OpenAI ${model}`, {
-							body: responseMessage.content,
-							icon: `${WEBUI_BASE_URL}/static/favicon.png`
-						});
+					if (autoScroll) {
+						scrollToBottom();
 					}
+				}
 
-					if ($settings.responseAutoCopy) {
-						copyToClipboard(responseMessage.content);
-					}
+				if ($settings.notificationEnabled && !document.hasFocus()) {
+					const notification = new Notification(`OpenAI ${model}`, {
+						body: responseMessage.content,
+						icon: `${WEBUI_BASE_URL}/static/favicon.png`
+					});
+				}
 
-					if ($settings.responseAutoPlayback) {
-						await tick();
-						document.getElementById(`speak-button-${responseMessage.id}`)?.click();
-					}
+				if ($settings.responseAutoCopy) {
+					copyToClipboard(responseMessage.content);
+				}
 
-					if (autoScroll) {
-						scrollToBottom();
-					}
+				if ($settings.responseAutoPlayback) {
+					await tick();
+					document.getElementById(`speak-button-${responseMessage.id}`)?.click();
+				}
+
+				if (lastUsage) {
+					responseMessage.info = { ...lastUsage, openai: true };
 				}
 
 				if ($chatId == _chatId) {
@@ -863,13 +1026,14 @@
 			errorMessage = innerError.message;
 		}
 
-		responseMessage.error = true;
-		responseMessage.content =
-			$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
-				provider: model.name ?? model.id
-			}) +
-			'\n' +
-			errorMessage;
+		responseMessage.error = {
+			content:
+				$i18n.t(`Uh-oh! There was an issue connecting to {{provider}}.`, {
+					provider: model.name ?? model.id
+				}) +
+				'\n' +
+				errorMessage
+		};
 		responseMessage.done = true;
 
 		messages = messages;
@@ -907,7 +1071,7 @@
 			const model = $models.filter((m) => m.id === responseMessage.model).at(0);
 
 			if (model) {
-				if (model?.external) {
+				if (model?.owned_by === 'openai') {
 					await sendPromptOpenAI(
 						model,
 						history.messages[responseMessage.parentId].content,
@@ -932,7 +1096,7 @@
 			const model = $models.find((model) => model.id === selectedModels[0]);
 
 			const titleModelId =
-				model?.external ?? false
+				model?.owned_by === 'openai' ?? false
 					? $settings?.title?.modelExternal ?? selectedModels[0]
 					: $settings?.title?.model ?? selectedModels[0];
 			const titleModel = $models.find((model) => model.id === titleModelId);
@@ -946,6 +1110,7 @@
 					) + ' {{prompt}}',
 				titleModelId,
 				userPrompt,
+				$chatId,
 				titleModel?.owned_by === 'openai' ?? false
 					? `${OPENAI_API_BASE_URL}`
 					: `${OLLAMA_API_BASE_URL}/v1`
@@ -957,6 +1122,29 @@
 		}
 	};
 
+	const generateChatSearchQuery = async (modelId: string, prompt: string) => {
+		const model = $models.find((model) => model.id === modelId);
+		const taskModelId =
+			model?.owned_by === 'openai' ?? false
+				? $settings?.title?.modelExternal ?? modelId
+				: $settings?.title?.model ?? modelId;
+		const taskModel = $models.find((model) => model.id === taskModelId);
+
+		const previousMessages = messages
+			.filter((message) => message.role === 'user')
+			.map((message) => message.content);
+
+		return await generateSearchQuery(
+			localStorage.token,
+			taskModelId,
+			previousMessages,
+			prompt,
+			taskModel?.owned_by === 'openai' ?? false
+				? `${OPENAI_API_BASE_URL}`
+				: `${OLLAMA_API_BASE_URL}/v1`
+		);
+	};
+
 	const setChatTitle = async (_chatId, _title) => {
 		if (_chatId === $chatId) {
 			title = _title;
@@ -1007,7 +1195,7 @@
 
 {#if !chatIdProp || (loaded && chatIdProp)}
 	<div
-		class="min-h-screen max-h-screen {$showSidebar
+		class="h-screen max-h-[100dvh] {$showSidebar
 			? 'md:max-w-[calc(100%-260px)]'
 			: ''} w-full max-w-full flex flex-col"
 	>
@@ -1020,7 +1208,7 @@
 			{initNewChat}
 		/>
 
-		{#if $banners.length > 0 && !$chatId && selectedModels.length <= 1}
+		{#if $banners.length > 0 && messages.length === 0 && !$chatId && selectedModels.length <= 1}
 			<div
 				class="absolute top-[4.25rem] w-full {$showSidebar ? 'md:max-w-[calc(100%-260px)]' : ''}"
 			>
@@ -1074,17 +1262,17 @@
 					/>
 				</div>
 			</div>
+			<MessageInput
+				bind:files
+				bind:prompt
+				bind:autoScroll
+				bind:webSearchEnabled
+				bind:atSelectedModel
+				{selectedModels}
+				{messages}
+				{submitPrompt}
+				{stopResponse}
+			/>
 		</div>
 	</div>
-
-	<MessageInput
-		bind:files
-		bind:prompt
-		bind:autoScroll
-		bind:atSelectedModel
-		{selectedModels}
-		{messages}
-		{submitPrompt}
-		{stopResponse}
-	/>
 {/if}

+ 545 - 544
src/lib/components/chat/MessageInput.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
 	import { onMount, tick, getContext } from 'svelte';
-	import { type Model, mobile, settings, showSidebar, models } from '$lib/stores';
+	import { type Model, mobile, settings, showSidebar, models, config } from '$lib/stores';
 	import { blobToFile, calculateSHA256, findWordIndices } from '$lib/utils';
 
 	import {
@@ -20,6 +20,8 @@
 	import Models from './MessageInput/Models.svelte';
 	import Tooltip from '../common/Tooltip.svelte';
 	import XMark from '$lib/components/icons/XMark.svelte';
+	import InputMenu from './MessageInput/InputMenu.svelte';
+	import { t } from 'i18next';
 
 	const i18n = getContext('i18n');
 
@@ -46,8 +48,8 @@
 
 	export let files = [];
 
-	export let fileUploadEnabled = true;
 	export let speechRecognitionEnabled = true;
+	export let webSearchEnabled = false;
 
 	export let prompt = '';
 	export let messages = [];
@@ -438,292 +440,212 @@
 	</div>
 {/if}
 
-<div class="fixed bottom-0 {$showSidebar ? 'left-0 md:left-[260px]' : 'left-0'} right-0">
-	<div class="w-full">
-		<div class=" -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center">
-			<div class="flex flex-col max-w-6xl px-2.5 md:px-6 w-full">
-				<div class="relative">
-					{#if autoScroll === false && messages.length > 0}
-						<div class=" absolute -top-12 left-0 right-0 flex justify-center z-30">
-							<button
-								class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
-								on:click={() => {
-									autoScroll = true;
-									scrollToBottom();
-								}}
-							>
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 20 20"
-									fill="currentColor"
-									class="w-5 h-5"
-								>
-									<path
-										fill-rule="evenodd"
-										d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
-										clip-rule="evenodd"
-									/>
-								</svg>
-							</button>
-						</div>
-					{/if}
-				</div>
-
-				<div class="w-full relative">
-					{#if prompt.charAt(0) === '/'}
-						<Prompts bind:this={promptsElement} bind:prompt />
-					{:else if prompt.charAt(0) === '#'}
-						<Documents
-							bind:this={documentsElement}
-							bind:prompt
-							on:youtube={(e) => {
-								console.log(e);
-								uploadYoutubeTranscription(e.detail);
-							}}
-							on:url={(e) => {
-								console.log(e);
-								uploadWeb(e.detail);
+<div class="w-full">
+	<div class=" -mb-0.5 mx-auto inset-x-0 bg-transparent flex justify-center">
+		<div class="flex flex-col max-w-6xl px-2.5 md:px-6 w-full">
+			<div class="relative">
+				{#if autoScroll === false && messages.length > 0}
+					<div class=" absolute -top-12 left-0 right-0 flex justify-center z-30">
+						<button
+							class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full"
+							on:click={() => {
+								autoScroll = true;
+								scrollToBottom();
 							}}
-							on:select={(e) => {
-								console.log(e);
-								files = [
-									...files,
-									{
-										type: e?.detail?.type ?? 'doc',
-										...e.detail,
-										upload_status: true
-									}
-								];
-							}}
-						/>
-					{/if}
+						>
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 20 20"
+								fill="currentColor"
+								class="w-5 h-5"
+							>
+								<path
+									fill-rule="evenodd"
+									d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
+									clip-rule="evenodd"
+								/>
+							</svg>
+						</button>
+					</div>
+				{/if}
+			</div>
 
-					<Models
-						bind:this={modelsElement}
+			<div class="w-full relative">
+				{#if prompt.charAt(0) === '/'}
+					<Prompts bind:this={promptsElement} bind:prompt />
+				{:else if prompt.charAt(0) === '#'}
+					<Documents
+						bind:this={documentsElement}
 						bind:prompt
-						bind:user
-						bind:chatInputPlaceholder
-						{messages}
+						on:youtube={(e) => {
+							console.log(e);
+							uploadYoutubeTranscription(e.detail);
+						}}
+						on:url={(e) => {
+							console.log(e);
+							uploadWeb(e.detail);
+						}}
 						on:select={(e) => {
-							atSelectedModel = e.detail;
-							chatTextAreaElement?.focus();
+							console.log(e);
+							files = [
+								...files,
+								{
+									type: e?.detail?.type ?? 'doc',
+									...e.detail,
+									upload_status: true
+								}
+							];
 						}}
 					/>
+				{/if}
+
+				<Models
+					bind:this={modelsElement}
+					bind:prompt
+					bind:user
+					bind:chatInputPlaceholder
+					{messages}
+					on:select={(e) => {
+						atSelectedModel = e.detail;
+						chatTextAreaElement?.focus();
+					}}
+				/>
 
-					{#if atSelectedModel !== undefined}
-						<div
-							class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900"
-						>
-							<div class="flex items-center gap-2 text-sm dark:text-gray-500">
-								<img
-									crossorigin="anonymous"
-									alt="model profile"
-									class="size-5 max-w-[28px] object-cover rounded-full"
-									src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
-										?.profile_image_url ??
-										($i18n.language === 'dg-DG'
-											? `/doge.png`
-											: `${WEBUI_BASE_URL}/static/favicon.png`)}
-								/>
-								<div>
-									Talking to <span class=" font-medium">{atSelectedModel.name}</span>
-								</div>
-							</div>
+				{#if atSelectedModel !== undefined}
+					<div
+						class="px-3 py-2.5 text-left w-full flex justify-between items-center absolute bottom-0 left-0 right-0 bg-gradient-to-t from-50% from-white dark:from-gray-900"
+					>
+						<div class="flex items-center gap-2 text-sm dark:text-gray-500">
+							<img
+								crossorigin="anonymous"
+								alt="model profile"
+								class="size-5 max-w-[28px] object-cover rounded-full"
+								src={$models.find((model) => model.id === atSelectedModel.id)?.info?.meta
+									?.profile_image_url ??
+									($i18n.language === 'dg-DG'
+										? `/doge.png`
+										: `${WEBUI_BASE_URL}/static/favicon.png`)}
+							/>
 							<div>
-								<button
-									class="flex items-center"
-									on:click={() => {
-										atSelectedModel = undefined;
-									}}
-								>
-									<XMark />
-								</button>
+								Talking to <span class=" font-medium">{atSelectedModel.name}</span>
 							</div>
 						</div>
-					{/if}
-				</div>
+						<div>
+							<button
+								class="flex items-center"
+								on:click={() => {
+									atSelectedModel = undefined;
+								}}
+							>
+								<XMark />
+							</button>
+						</div>
+					</div>
+				{/if}
 			</div>
 		</div>
+	</div>
 
-		<div class="bg-white dark:bg-gray-900">
-			<div class="max-w-6xl px-2.5 md:px-6 mx-auto inset-x-0">
-				<div class=" pb-2">
-					<input
-						bind:this={filesInputElement}
-						bind:files={inputFiles}
-						type="file"
-						hidden
-						multiple
-						on:change={async () => {
-							if (inputFiles && inputFiles.length > 0) {
-								const _inputFiles = Array.from(inputFiles);
-								_inputFiles.forEach((file) => {
-									if (
-										['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])
-									) {
-										if (visionCapableModels.length === 0) {
-											toast.error($i18n.t('Selected model(s) do not support image inputs'));
-											inputFiles = null;
-											filesInputElement.value = '';
-											return;
-										}
-										let reader = new FileReader();
-										reader.onload = (event) => {
-											files = [
-												...files,
-												{
-													type: 'image',
-													url: `${event.target.result}`
-												}
-											];
-											inputFiles = null;
-											filesInputElement.value = '';
-										};
-										reader.readAsDataURL(file);
-									} else if (
-										SUPPORTED_FILE_TYPE.includes(file['type']) ||
-										SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
-									) {
-										uploadDoc(file);
-										filesInputElement.value = '';
-									} else {
-										toast.error(
-											$i18n.t(
-												`Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
-												{ file_type: file['type'] }
-											)
-										);
-										uploadDoc(file);
+	<div class="bg-white dark:bg-gray-900">
+		<div class="max-w-6xl px-2.5 md:px-6 mx-auto inset-x-0">
+			<div class=" pb-2">
+				<input
+					bind:this={filesInputElement}
+					bind:files={inputFiles}
+					type="file"
+					hidden
+					multiple
+					on:change={async () => {
+						if (inputFiles && inputFiles.length > 0) {
+							const _inputFiles = Array.from(inputFiles);
+							_inputFiles.forEach((file) => {
+								if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
+									if (visionCapableModels.length === 0) {
+										toast.error($i18n.t('Selected model(s) do not support image inputs'));
+										inputFiles = null;
 										filesInputElement.value = '';
+										return;
 									}
-								});
-							} else {
-								toast.error($i18n.t(`File not found.`));
-							}
-						}}
-					/>
-					<form
-						dir={$settings?.chatDirection ?? 'LTR'}
-						class=" flex flex-col relative w-full rounded-3xl px-1.5 bg-gray-50 dark:bg-gray-850 dark:text-gray-100"
-						on:submit|preventDefault={() => {
-							// check if selectedModels support image input
-							submitPrompt(prompt, user);
-						}}
-					>
-						{#if files.length > 0}
-							<div class="mx-2 mt-2 mb-1 flex flex-wrap gap-2">
-								{#each files as file, fileIdx}
-									<div class=" relative group">
-										{#if file.type === 'image'}
-											<div class="relative">
-												<img
-													src={file.url}
-													alt="input"
-													class=" h-16 w-16 rounded-xl object-cover"
-												/>
-												{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
-													<Tooltip
-														className=" absolute top-1 left-1"
-														content={$i18n.t('{{ models }}', {
-															models: [...(atSelectedModel ? [atSelectedModel] : selectedModels)]
-																.filter((id) => !visionCapableModels.includes(id))
-																.join(', ')
-														})}
+									let reader = new FileReader();
+									reader.onload = (event) => {
+										files = [
+											...files,
+											{
+												type: 'image',
+												url: `${event.target.result}`
+											}
+										];
+										inputFiles = null;
+										filesInputElement.value = '';
+									};
+									reader.readAsDataURL(file);
+								} else if (
+									SUPPORTED_FILE_TYPE.includes(file['type']) ||
+									SUPPORTED_FILE_EXTENSIONS.includes(file.name.split('.').at(-1))
+								) {
+									uploadDoc(file);
+									filesInputElement.value = '';
+								} else {
+									toast.error(
+										$i18n.t(
+											`Unknown File Type '{{file_type}}', but accepting and treating as plain text`,
+											{ file_type: file['type'] }
+										)
+									);
+									uploadDoc(file);
+									filesInputElement.value = '';
+								}
+							});
+						} else {
+							toast.error($i18n.t(`File not found.`));
+						}
+					}}
+				/>
+				<form
+					dir={$settings?.chatDirection ?? 'LTR'}
+					class=" flex flex-col relative w-full rounded-3xl px-1.5 bg-gray-50 dark:bg-gray-850 dark:text-gray-100"
+					on:submit|preventDefault={() => {
+						// check if selectedModels support image input
+						submitPrompt(prompt, user);
+					}}
+				>
+					{#if files.length > 0}
+						<div class="mx-2 mt-2 mb-1 flex flex-wrap gap-2">
+							{#each files as file, fileIdx}
+								<div class=" relative group">
+									{#if file.type === 'image'}
+										<div class="relative">
+											<img src={file.url} alt="input" class=" h-16 w-16 rounded-xl object-cover" />
+											{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
+												<Tooltip
+													className=" absolute top-1 left-1"
+													content={$i18n.t('{{ models }}', {
+														models: [...(atSelectedModel ? [atSelectedModel] : selectedModels)]
+															.filter((id) => !visionCapableModels.includes(id))
+															.join(', ')
+													})}
+												>
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														viewBox="0 0 24 24"
+														fill="currentColor"
+														class="size-4 fill-yellow-300"
 													>
-														<svg
-															xmlns="http://www.w3.org/2000/svg"
-															viewBox="0 0 24 24"
-															fill="currentColor"
-															class="size-4 fill-yellow-300"
-														>
-															<path
-																fill-rule="evenodd"
-																d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
-																clip-rule="evenodd"
-															/>
-														</svg>
-													</Tooltip>
-												{/if}
-											</div>
-										{:else if file.type === 'doc'}
-											<div
-												class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
-											>
-												<div class="p-2.5 bg-red-400 text-white rounded-lg">
-													{#if file.upload_status}
-														<svg
-															xmlns="http://www.w3.org/2000/svg"
-															viewBox="0 0 24 24"
-															fill="currentColor"
-															class="w-6 h-6"
-														>
-															<path
-																fill-rule="evenodd"
-																d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z"
-																clip-rule="evenodd"
-															/>
-															<path
-																d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z"
-															/>
-														</svg>
-													{:else}
-														<svg
-															class=" w-6 h-6 translate-y-[0.5px]"
-															fill="currentColor"
-															viewBox="0 0 24 24"
-															xmlns="http://www.w3.org/2000/svg"
-															><style>
-																.spinner_qM83 {
-																	animation: spinner_8HQG 1.05s infinite;
-																}
-																.spinner_oXPr {
-																	animation-delay: 0.1s;
-																}
-																.spinner_ZTLf {
-																	animation-delay: 0.2s;
-																}
-																@keyframes spinner_8HQG {
-																	0%,
-																	57.14% {
-																		animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
-																		transform: translate(0);
-																	}
-																	28.57% {
-																		animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
-																		transform: translateY(-6px);
-																	}
-																	100% {
-																		transform: translate(0);
-																	}
-																}
-															</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
-																class="spinner_qM83 spinner_oXPr"
-																cx="12"
-																cy="12"
-																r="2.5"
-															/><circle
-																class="spinner_qM83 spinner_ZTLf"
-																cx="20"
-																cy="12"
-																r="2.5"
-															/></svg
-														>
-													{/if}
-												</div>
-
-												<div class="flex flex-col justify-center -space-y-0.5">
-													<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
-														{file.name}
-													</div>
-
-													<div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
-												</div>
-											</div>
-										{:else if file.type === 'collection'}
-											<div
-												class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
-											>
-												<div class="p-2.5 bg-red-400 text-white rounded-lg">
+														<path
+															fill-rule="evenodd"
+															d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
+															clip-rule="evenodd"
+														/>
+													</svg>
+												</Tooltip>
+											{/if}
+										</div>
+									{:else if file.type === 'doc'}
+										<div
+											class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
+										>
+											<div class="p-2.5 bg-red-400 text-white rounded-lg">
+												{#if file.upload_status}
 													<svg
 														xmlns="http://www.w3.org/2000/svg"
 														viewBox="0 0 24 24"
@@ -731,364 +653,443 @@
 														class="w-6 h-6"
 													>
 														<path
-															d="M7.5 3.375c0-1.036.84-1.875 1.875-1.875h.375a3.75 3.75 0 0 1 3.75 3.75v1.875C13.5 8.161 14.34 9 15.375 9h1.875A3.75 3.75 0 0 1 21 12.75v3.375C21 17.16 20.16 18 19.125 18h-9.75A1.875 1.875 0 0 1 7.5 16.125V3.375Z"
+															fill-rule="evenodd"
+															d="M5.625 1.5c-1.036 0-1.875.84-1.875 1.875v17.25c0 1.035.84 1.875 1.875 1.875h12.75c1.035 0 1.875-.84 1.875-1.875V12.75A3.75 3.75 0 0 0 16.5 9h-1.875a1.875 1.875 0 0 1-1.875-1.875V5.25A3.75 3.75 0 0 0 9 1.5H5.625ZM7.5 15a.75.75 0 0 1 .75-.75h7.5a.75.75 0 0 1 0 1.5h-7.5A.75.75 0 0 1 7.5 15Zm.75 2.25a.75.75 0 0 0 0 1.5H12a.75.75 0 0 0 0-1.5H8.25Z"
+															clip-rule="evenodd"
 														/>
 														<path
-															d="M15 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 17.25 7.5h-1.875A.375.375 0 0 1 15 7.125V5.25ZM4.875 6H6v10.125A3.375 3.375 0 0 0 9.375 19.5H16.5v1.125c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V7.875C3 6.839 3.84 6 4.875 6Z"
+															d="M12.971 1.816A5.23 5.23 0 0 1 14.25 5.25v1.875c0 .207.168.375.375.375H16.5a5.23 5.23 0 0 1 3.434 1.279 9.768 9.768 0 0 0-6.963-6.963Z"
 														/>
 													</svg>
-												</div>
-
-												<div class="flex flex-col justify-center -space-y-0.5">
-													<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
-														{file?.title ?? `#${file.name}`}
-													</div>
+												{:else}
+													<svg
+														class=" w-6 h-6 translate-y-[0.5px]"
+														fill="currentColor"
+														viewBox="0 0 24 24"
+														xmlns="http://www.w3.org/2000/svg"
+														><style>
+															.spinner_qM83 {
+																animation: spinner_8HQG 1.05s infinite;
+															}
+															.spinner_oXPr {
+																animation-delay: 0.1s;
+															}
+															.spinner_ZTLf {
+																animation-delay: 0.2s;
+															}
+															@keyframes spinner_8HQG {
+																0%,
+																57.14% {
+																	animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
+																	transform: translate(0);
+																}
+																28.57% {
+																	animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
+																	transform: translateY(-6px);
+																}
+																100% {
+																	transform: translate(0);
+																}
+															}
+														</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
+															class="spinner_qM83 spinner_oXPr"
+															cx="12"
+															cy="12"
+															r="2.5"
+														/><circle
+															class="spinner_qM83 spinner_ZTLf"
+															cx="20"
+															cy="12"
+															r="2.5"
+														/></svg
+													>
+												{/if}
+											</div>
 
-													<div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
+											<div class="flex flex-col justify-center -space-y-0.5">
+												<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
+													{file.name}
 												</div>
+
+												<div class=" text-gray-500 text-sm">{$i18n.t('Document')}</div>
 											</div>
-										{/if}
-
-										<div class=" absolute -top-1 -right-1">
-											<button
-												class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
-												type="button"
-												on:click={() => {
-													files.splice(fileIdx, 1);
-													files = files;
-												}}
-											>
+										</div>
+									{:else if file.type === 'collection'}
+										<div
+											class="h-16 w-[15rem] flex items-center space-x-3 px-2.5 dark:bg-gray-600 rounded-xl border border-gray-200 dark:border-none"
+										>
+											<div class="p-2.5 bg-red-400 text-white rounded-lg">
 												<svg
 													xmlns="http://www.w3.org/2000/svg"
-													viewBox="0 0 20 20"
+													viewBox="0 0 24 24"
 													fill="currentColor"
-													class="w-4 h-4"
+													class="w-6 h-6"
 												>
 													<path
-														d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+														d="M7.5 3.375c0-1.036.84-1.875 1.875-1.875h.375a3.75 3.75 0 0 1 3.75 3.75v1.875C13.5 8.161 14.34 9 15.375 9h1.875A3.75 3.75 0 0 1 21 12.75v3.375C21 17.16 20.16 18 19.125 18h-9.75A1.875 1.875 0 0 1 7.5 16.125V3.375Z"
+													/>
+													<path
+														d="M15 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 17.25 7.5h-1.875A.375.375 0 0 1 15 7.125V5.25ZM4.875 6H6v10.125A3.375 3.375 0 0 0 9.375 19.5H16.5v1.125c0 1.035-.84 1.875-1.875 1.875h-9.75A1.875 1.875 0 0 1 3 20.625V7.875C3 6.839 3.84 6 4.875 6Z"
 													/>
 												</svg>
-											</button>
+											</div>
+
+											<div class="flex flex-col justify-center -space-y-0.5">
+												<div class=" dark:text-gray-100 text-sm font-medium line-clamp-1">
+													{file?.title ?? `#${file.name}`}
+												</div>
+
+												<div class=" text-gray-500 text-sm">{$i18n.t('Collection')}</div>
+											</div>
 										</div>
-									</div>
-								{/each}
-							</div>
-						{/if}
+									{/if}
 
-						<div class=" flex">
-							{#if fileUploadEnabled}
-								<div class=" self-end mb-2 ml-1">
-									<Tooltip content={$i18n.t('Upload files')}>
+									<div class=" absolute -top-1 -right-1">
 										<button
-											class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
+											class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
 											type="button"
 											on:click={() => {
-												filesInputElement.click();
+												files.splice(fileIdx, 1);
+												files = files;
 											}}
 										>
 											<svg
 												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 16 16"
+												viewBox="0 0 20 20"
 												fill="currentColor"
-												class="w-[1.2rem] h-[1.2rem]"
+												class="w-4 h-4"
 											>
 												<path
-													d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
+													d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
 												/>
 											</svg>
 										</button>
-									</Tooltip>
+									</div>
 								</div>
-							{/if}
+							{/each}
+						</div>
+					{/if}
 
-							<textarea
-								id="chat-textarea"
-								bind:this={chatTextAreaElement}
-								class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-3 {fileUploadEnabled
-									? ''
-									: ' pl-4'} rounded-xl resize-none h-[48px]"
-								placeholder={chatInputPlaceholder !== ''
-									? chatInputPlaceholder
-									: isRecording
-									? $i18n.t('Listening...')
-									: $i18n.t('Send a Message')}
-								bind:value={prompt}
-								on:keypress={(e) => {
-									if (
-										!$mobile ||
-										!(
-											'ontouchstart' in window ||
-											navigator.maxTouchPoints > 0 ||
-											navigator.msMaxTouchPoints > 0
-										)
-									) {
-										if (e.keyCode == 13 && !e.shiftKey) {
-											e.preventDefault();
-										}
-										if (prompt !== '' && e.keyCode == 13 && !e.shiftKey) {
-											submitPrompt(prompt, user);
-										}
-									}
+					<div class=" flex">
+						<div class=" ml-1 self-end mb-2 flex space-x-1">
+							<InputMenu
+								bind:webSearchEnabled
+								uploadFilesHandler={() => {
+									filesInputElement.click();
+								}}
+								onClose={async () => {
+									await tick();
+									chatTextAreaElement?.focus();
 								}}
-								on:keydown={async (e) => {
-									const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
+							>
+								<button
+									class="bg-gray-50 hover:bg-gray-100 text-gray-800 dark:bg-gray-850 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-none focus:outline-none"
+									type="button"
+								>
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 16 16"
+										fill="currentColor"
+										class="size-5"
+									>
+										<path
+											d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z"
+										/>
+									</svg>
+								</button>
+							</InputMenu>
+						</div>
 
-									// Check if Ctrl + R is pressed
-									if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
+						<textarea
+							id="chat-textarea"
+							bind:this={chatTextAreaElement}
+							class="scrollbar-hidden bg-gray-50 dark:bg-gray-850 dark:text-gray-100 outline-none w-full py-3 px-3 rounded-xl resize-none h-[48px]"
+							placeholder={chatInputPlaceholder !== ''
+								? chatInputPlaceholder
+								: isRecording
+								? $i18n.t('Listening...')
+								: $i18n.t('Send a Message')}
+							bind:value={prompt}
+							on:keypress={(e) => {
+								if (
+									!$mobile ||
+									!(
+										'ontouchstart' in window ||
+										navigator.maxTouchPoints > 0 ||
+										navigator.msMaxTouchPoints > 0
+									)
+								) {
+									// Prevent Enter key from creating a new line
+									if (e.key === 'Enter' && !e.shiftKey) {
 										e.preventDefault();
-										console.log('regenerate');
-
-										const regenerateButton = [
-											...document.getElementsByClassName('regenerate-response-button')
-										]?.at(-1);
+									}
 
-										regenerateButton?.click();
+									// Submit the prompt when Enter key is pressed
+									if (prompt !== '' && e.key === 'Enter' && !e.shiftKey) {
+										submitPrompt(prompt, user);
 									}
+								}
+							}}
+							on:keydown={async (e) => {
+								const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
 
-									if (prompt === '' && e.key == 'ArrowUp') {
-										e.preventDefault();
+								// Check if Ctrl + R is pressed
+								if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
+									e.preventDefault();
+									console.log('regenerate');
 
-										const userMessageElement = [
-											...document.getElementsByClassName('user-message')
-										]?.at(-1);
+									const regenerateButton = [
+										...document.getElementsByClassName('regenerate-response-button')
+									]?.at(-1);
 
-										const editButton = [
-											...document.getElementsByClassName('edit-user-message-button')
-										]?.at(-1);
+									regenerateButton?.click();
+								}
 
-										console.log(userMessageElement);
+								if (prompt === '' && e.key == 'ArrowUp') {
+									e.preventDefault();
 
-										userMessageElement.scrollIntoView({ block: 'center' });
-										editButton?.click();
-									}
+									const userMessageElement = [
+										...document.getElementsByClassName('user-message')
+									]?.at(-1);
 
-									if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') {
-										e.preventDefault();
+									const editButton = [
+										...document.getElementsByClassName('edit-user-message-button')
+									]?.at(-1);
 
-										(promptsElement || documentsElement || modelsElement).selectUp();
+									console.log(userMessageElement);
 
-										const commandOptionButton = [
-											...document.getElementsByClassName('selected-command-option-button')
-										]?.at(-1);
-										commandOptionButton.scrollIntoView({ block: 'center' });
-									}
+									userMessageElement.scrollIntoView({ block: 'center' });
+									editButton?.click();
+								}
 
-									if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') {
-										e.preventDefault();
+								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowUp') {
+									e.preventDefault();
 
-										(promptsElement || documentsElement || modelsElement).selectDown();
+									(promptsElement || documentsElement || modelsElement).selectUp();
 
-										const commandOptionButton = [
-											...document.getElementsByClassName('selected-command-option-button')
-										]?.at(-1);
-										commandOptionButton.scrollIntoView({ block: 'center' });
-									}
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
+									commandOptionButton.scrollIntoView({ block: 'center' });
+								}
 
-									if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') {
-										e.preventDefault();
+								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'ArrowDown') {
+									e.preventDefault();
 
-										const commandOptionButton = [
-											...document.getElementsByClassName('selected-command-option-button')
-										]?.at(-1);
+									(promptsElement || documentsElement || modelsElement).selectDown();
 
-										if (commandOptionButton) {
-											commandOptionButton?.click();
-										} else {
-											document.getElementById('send-message-button')?.click();
-										}
-									}
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
+									commandOptionButton.scrollIntoView({ block: 'center' });
+								}
 
-									if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') {
-										e.preventDefault();
+								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Enter') {
+									e.preventDefault();
 
-										const commandOptionButton = [
-											...document.getElementsByClassName('selected-command-option-button')
-										]?.at(-1);
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
 
+									if (e.shiftKey) {
+										prompt = `${prompt}\n`;
+									} else if (commandOptionButton) {
 										commandOptionButton?.click();
-									} else if (e.key === 'Tab') {
-										const words = findWordIndices(prompt);
+									} else {
+										document.getElementById('send-message-button')?.click();
+									}
+								}
 
-										if (words.length > 0) {
-											const word = words.at(0);
-											const fullPrompt = prompt;
+								if (['/', '#', '@'].includes(prompt.charAt(0)) && e.key === 'Tab') {
+									e.preventDefault();
 
-											prompt = prompt.substring(0, word?.endIndex + 1);
-											await tick();
+									const commandOptionButton = [
+										...document.getElementsByClassName('selected-command-option-button')
+									]?.at(-1);
 
-											e.target.scrollTop = e.target.scrollHeight;
-											prompt = fullPrompt;
-											await tick();
+									commandOptionButton?.click();
+								} else if (e.key === 'Tab') {
+									const words = findWordIndices(prompt);
 
-											e.preventDefault();
-											e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
-										}
+									if (words.length > 0) {
+										const word = words.at(0);
+										const fullPrompt = prompt;
 
-										e.target.style.height = '';
-										e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
-									}
+										prompt = prompt.substring(0, word?.endIndex + 1);
+										await tick();
 
-									if (e.key === 'Escape') {
-										console.log('Escape');
-										atSelectedModel = undefined;
+										e.target.scrollTop = e.target.scrollHeight;
+										prompt = fullPrompt;
+										await tick();
+
+										e.preventDefault();
+										e.target.setSelectionRange(word?.startIndex, word.endIndex + 1);
 									}
-								}}
-								rows="1"
-								on:input={(e) => {
-									e.target.style.height = '';
-									e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
-									user = null;
-								}}
-								on:focus={(e) => {
+
 									e.target.style.height = '';
 									e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
-								}}
-								on:paste={(e) => {
-									const clipboardData = e.clipboardData || window.clipboardData;
-
-									if (clipboardData && clipboardData.items) {
-										for (const item of clipboardData.items) {
-											if (item.type.indexOf('image') !== -1) {
-												const blob = item.getAsFile();
-												const reader = new FileReader();
-
-												reader.onload = function (e) {
-													files = [
-														...files,
-														{
-															type: 'image',
-															url: `${e.target.result}`
-														}
-													];
-												};
+								}
 
-												reader.readAsDataURL(blob);
-											}
+								if (e.key === 'Escape') {
+									console.log('Escape');
+									atSelectedModel = undefined;
+								}
+							}}
+							rows="1"
+							on:input={(e) => {
+								e.target.style.height = '';
+								e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
+								user = null;
+							}}
+							on:focus={(e) => {
+								e.target.style.height = '';
+								e.target.style.height = Math.min(e.target.scrollHeight, 200) + 'px';
+							}}
+							on:paste={(e) => {
+								const clipboardData = e.clipboardData || window.clipboardData;
+
+								if (clipboardData && clipboardData.items) {
+									for (const item of clipboardData.items) {
+										if (item.type.indexOf('image') !== -1) {
+											const blob = item.getAsFile();
+											const reader = new FileReader();
+
+											reader.onload = function (e) {
+												files = [
+													...files,
+													{
+														type: 'image',
+														url: `${e.target.result}`
+													}
+												];
+											};
+
+											reader.readAsDataURL(blob);
 										}
 									}
-								}}
-							/>
+								}
+							}}
+						/>
 
-							<div class="self-end mb-2 flex space-x-1 mr-1">
-								{#if messages.length == 0 || messages.at(-1).done == true}
-									<Tooltip content={$i18n.t('Record voice')}>
-										{#if speechRecognitionEnabled}
-											<button
-												id="voice-input-button"
-												class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center"
-												type="button"
-												on:click={() => {
-													speechRecognitionHandler();
-												}}
-											>
-												{#if isRecording}
-													<svg
-														class=" w-5 h-5 translate-y-[0.5px]"
-														fill="currentColor"
-														viewBox="0 0 24 24"
-														xmlns="http://www.w3.org/2000/svg"
-														><style>
-															.spinner_qM83 {
-																animation: spinner_8HQG 1.05s infinite;
-															}
-															.spinner_oXPr {
-																animation-delay: 0.1s;
+						<div class="self-end mb-2 flex space-x-1 mr-1">
+							{#if messages.length == 0 || messages.at(-1).done == true}
+								<Tooltip content={$i18n.t('Record voice')}>
+									{#if speechRecognitionEnabled}
+										<button
+											id="voice-input-button"
+											class=" text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-850 transition rounded-full p-1.5 mr-0.5 self-center"
+											type="button"
+											on:click={() => {
+												speechRecognitionHandler();
+											}}
+										>
+											{#if isRecording}
+												<svg
+													class=" w-5 h-5 translate-y-[0.5px]"
+													fill="currentColor"
+													viewBox="0 0 24 24"
+													xmlns="http://www.w3.org/2000/svg"
+													><style>
+														.spinner_qM83 {
+															animation: spinner_8HQG 1.05s infinite;
+														}
+														.spinner_oXPr {
+															animation-delay: 0.1s;
+														}
+														.spinner_ZTLf {
+															animation-delay: 0.2s;
+														}
+														@keyframes spinner_8HQG {
+															0%,
+															57.14% {
+																animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
+																transform: translate(0);
 															}
-															.spinner_ZTLf {
-																animation-delay: 0.2s;
+															28.57% {
+																animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
+																transform: translateY(-6px);
 															}
-															@keyframes spinner_8HQG {
-																0%,
-																57.14% {
-																	animation-timing-function: cubic-bezier(0.33, 0.66, 0.66, 1);
-																	transform: translate(0);
-																}
-																28.57% {
-																	animation-timing-function: cubic-bezier(0.33, 0, 0.66, 0.33);
-																	transform: translateY(-6px);
-																}
-																100% {
-																	transform: translate(0);
-																}
+															100% {
+																transform: translate(0);
 															}
-														</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
-															class="spinner_qM83 spinner_oXPr"
-															cx="12"
-															cy="12"
-															r="2.5"
-														/><circle
-															class="spinner_qM83 spinner_ZTLf"
-															cx="20"
-															cy="12"
-															r="2.5"
-														/></svg
-													>
-												{:else}
-													<svg
-														xmlns="http://www.w3.org/2000/svg"
-														viewBox="0 0 20 20"
-														fill="currentColor"
-														class="w-5 h-5 translate-y-[0.5px]"
-													>
-														<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
-														<path
-															d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
-														/>
-													</svg>
-												{/if}
-											</button>
-										{/if}
-									</Tooltip>
-
-									<Tooltip content={$i18n.t('Send message')}>
-										<button
-											id="send-message-button"
-											class="{prompt !== ''
-												? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
-												: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
-											type="submit"
-											disabled={prompt === ''}
-										>
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 16 16"
-												fill="currentColor"
-												class="w-5 h-5"
-											>
-												<path
-													fill-rule="evenodd"
-													d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
-													clip-rule="evenodd"
-												/>
-											</svg>
+														}
+													</style><circle class="spinner_qM83" cx="4" cy="12" r="2.5" /><circle
+														class="spinner_qM83 spinner_oXPr"
+														cx="12"
+														cy="12"
+														r="2.5"
+													/><circle
+														class="spinner_qM83 spinner_ZTLf"
+														cx="20"
+														cy="12"
+														r="2.5"
+													/></svg
+												>
+											{:else}
+												<svg
+													xmlns="http://www.w3.org/2000/svg"
+													viewBox="0 0 20 20"
+													fill="currentColor"
+													class="w-5 h-5 translate-y-[0.5px]"
+												>
+													<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
+													<path
+														d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
+													/>
+												</svg>
+											{/if}
 										</button>
-									</Tooltip>
-								{:else}
+									{/if}
+								</Tooltip>
+
+								<Tooltip content={$i18n.t('Send message')}>
 									<button
-										class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
-										on:click={stopResponse}
+										id="send-message-button"
+										class="{prompt !== ''
+											? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
+											: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
+										type="submit"
+										disabled={prompt === ''}
 									>
 										<svg
 											xmlns="http://www.w3.org/2000/svg"
-											viewBox="0 0 24 24"
+											viewBox="0 0 16 16"
 											fill="currentColor"
 											class="w-5 h-5"
 										>
 											<path
 												fill-rule="evenodd"
-												d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
+												d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
 												clip-rule="evenodd"
 											/>
 										</svg>
 									</button>
-								{/if}
-							</div>
+								</Tooltip>
+							{:else}
+								<button
+									class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
+									on:click={stopResponse}
+								>
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 24 24"
+										fill="currentColor"
+										class="w-5 h-5"
+									>
+										<path
+											fill-rule="evenodd"
+											d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
+											clip-rule="evenodd"
+										/>
+									</svg>
+								</button>
+							{/if}
 						</div>
-					</form>
-
-					<div class="mt-1.5 text-xs text-gray-500 text-center">
-						{$i18n.t('LLMs can make mistakes. Verify important information.')}
 					</div>
+				</form>
+
+				<div class="mt-1.5 text-xs text-gray-500 text-center">
+					{$i18n.t('LLMs can make mistakes. Verify important information.')}
 				</div>
 			</div>
 		</div>

+ 75 - 0
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -0,0 +1,75 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { flyAndScale } from '$lib/utils/transitions';
+	import { getContext } from 'svelte';
+
+	import Dropdown from '$lib/components/common/Dropdown.svelte';
+	import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
+	import Pencil from '$lib/components/icons/Pencil.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Tags from '$lib/components/chat/Tags.svelte';
+	import Share from '$lib/components/icons/Share.svelte';
+	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
+	import DocumentArrowUpSolid from '$lib/components/icons/DocumentArrowUpSolid.svelte';
+	import Switch from '$lib/components/common/Switch.svelte';
+	import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
+	import { config } from '$lib/stores';
+
+	const i18n = getContext('i18n');
+
+	export let uploadFilesHandler: Function;
+	export let webSearchEnabled: boolean;
+
+	export let onClose: Function;
+
+	let show = false;
+</script>
+
+<Dropdown
+	bind:show
+	on:change={(e) => {
+		if (e.detail === false) {
+			onClose();
+		}
+	}}
+>
+	<Tooltip content={$i18n.t('More')}>
+		<slot />
+	</Tooltip>
+
+	<div slot="content">
+		<DropdownMenu.Content
+			class="w-full max-w-[190px] rounded-xl px-1 py-1  border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
+			sideOffset={15}
+			alignOffset={-8}
+			side="top"
+			align="start"
+			transition={flyAndScale}
+		>
+			{#if $config?.features?.enable_web_search}
+				<div
+					class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer rounded-xl"
+				>
+					<div class="flex-1 flex items-center gap-2">
+						<GlobeAltSolid />
+						<div class="flex items-center">{$i18n.t('Web Search')}</div>
+					</div>
+
+					<Switch bind:state={webSearchEnabled} />
+				</div>
+
+				<hr class="border-gray-100 dark:border-gray-800 my-1" />
+			{/if}
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-xl"
+				on:click={() => {
+					uploadFilesHandler();
+				}}
+			>
+				<DocumentArrowUpSolid />
+				<div class="flex items-center">{$i18n.t('Upload Files')}</div>
+			</DropdownMenu.Item>
+		</DropdownMenu.Content>
+	</div>
+</Dropdown>

+ 5 - 5
src/lib/components/chat/Messages.svelte

@@ -1,8 +1,7 @@
 <script lang="ts">
 	import { v4 as uuidv4 } from 'uuid';
-
 	import { chats, config, settings, user as _user, mobile } from '$lib/stores';
-	import { tick, getContext } from 'svelte';
+	import { tick, getContext, onMount } from 'svelte';
 
 	import { toast } from 'svelte-sonner';
 	import { getChatList, updateChatById } from '$lib/apis/chats';
@@ -242,7 +241,7 @@
 	};
 </script>
 
-<div class="h-full flex mb-16">
+<div class="h-full flex">
 	{#if messages.length == 0}
 		<Placeholder
 			modelIds={selectedModels}
@@ -285,9 +284,9 @@
 		<div class="w-full pt-2">
 			{#key chatId}
 				{#each messages as message, messageIdx}
-					<div class=" w-full {messageIdx === messages.length - 1 ? 'pb-28' : ''}">
+					<div class=" w-full {messageIdx === messages.length - 1 ? ' pb-12' : ''}">
 						<div
-							class="flex flex-col justify-between px-5 mb-3 {$settings?.fullScreenMode ?? null
+							class="flex flex-col justify-between px-5 mb-3 {$settings?.widescreenMode ?? null
 								? 'max-w-full'
 								: 'max-w-5xl'} mx-auto rounded-lg group"
 						>
@@ -340,6 +339,7 @@
 									<CompareMessages
 										bind:history
 										{messages}
+										{readOnly}
 										{chatId}
 										parentMessage={history.messages[message.parentId]}
 										{messageIdx}

+ 1 - 1
src/lib/components/chat/Messages/CodeBlock.svelte

@@ -215,7 +215,7 @@ __builtins__.input = input`);
 		<div class="p-1">{@html lang}</div>
 
 		<div class="flex items-center">
-			{#if lang === 'python' || (lang === '' && checkPythonCode(code))}
+			{#if lang.toLowerCase() === 'python' || lang.toLowerCase() === 'py' || (lang === '' && checkPythonCode(code))}
 				{#if executing}
 					<div class="copy-code-button bg-none border-none p-1 cursor-not-allowed">Running</div>
 				{:else}

+ 3 - 0
src/lib/components/chat/Messages/CompareMessages.svelte

@@ -13,6 +13,8 @@
 
 	export let parentMessage;
 
+	export let readOnly = false;
+
 	export let updateChatMessages: Function;
 	export let confirmEditResponseMessage: Function;
 	export let rateMessage: Function;
@@ -134,6 +136,7 @@
 						{confirmEditResponseMessage}
 						showPreviousMessage={() => showPreviousMessage(model)}
 						showNextMessage={() => showNextMessage(model)}
+						{readOnly}
 						{rateMessage}
 						{copyToClipboard}
 						{continueGeneration}

+ 1 - 1
src/lib/components/chat/Messages/Placeholder.svelte

@@ -64,7 +64,7 @@
 				</div>
 
 				<div in:fade={{ duration: 200, delay: 200 }}>
-					{#if models[selectedModelIdx]?.info}
+					{#if models[selectedModelIdx]?.info?.meta?.description ?? null}
 						<div class="mt-0.5 text-base font-normal text-gray-500 dark:text-gray-400 line-clamp-3">
 							{models[selectedModelIdx]?.info?.meta?.description}
 						</div>

+ 93 - 30
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -5,6 +5,7 @@
 	import tippy from 'tippy.js';
 	import auto_render from 'katex/dist/contrib/auto-render.mjs';
 	import 'katex/dist/katex.min.css';
+	import mermaid from 'mermaid';
 
 	import { fade } from 'svelte/transition';
 	import { createEventDispatcher } from 'svelte';
@@ -33,6 +34,8 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import RateComment from './RateComment.svelte';
 	import CitationsModal from '$lib/components/chat/Messages/CitationsModal.svelte';
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import WebSearchResults from './ResponseMessage/WebSearchResults.svelte';
 
 	export let message;
 	export let siblings;
@@ -106,8 +109,13 @@
 		renderLatex();
 
 		if (message.info) {
-			tooltipInstance = tippy(`#info-${message.id}`, {
-				content: `<span class="text-xs" id="tooltip-${message.id}">response_token/s: ${
+			let tooltipContent = '';
+			if (message.info.openai) {
+				tooltipContent = `prompt_tokens: ${message.info.prompt_tokens ?? 'N/A'}<br/>
+													completion_tokens: ${message.info.completion_tokens ?? 'N/A'}<br/>
+													total_tokens: ${message.info.total_tokens ?? 'N/A'}`;
+			} else {
+				tooltipContent = `response_token/s: ${
 					`${
 						Math.round(
 							((message.info.eval_count ?? 0) / (message.info.eval_duration / 1000000000)) * 100
@@ -137,9 +145,10 @@
                     eval_duration: ${
 											Math.round(((message.info.eval_duration ?? 0) / 1000000) * 100) / 100 ?? 'N/A'
 										}ms<br/>
-                    approximate_total: ${approximateToHumanReadable(
-											message.info.total_duration
-										)}</span>`,
+                    approximate_total: ${approximateToHumanReadable(message.info.total_duration)}`;
+			}
+			tooltipInstance = tippy(`#info-${message.id}`, {
+				content: `<span class="text-xs" id="tooltip-${message.id}">${tooltipContent}</span>`,
 				allowHTML: true
 			});
 		}
@@ -332,9 +341,24 @@
 		generatingImage = false;
 	};
 
+	$: if (!edit) {
+		(async () => {
+			await tick();
+			renderStyling();
+
+			await mermaid.run({
+				querySelector: '.mermaid'
+			});
+		})();
+	}
+
 	onMount(async () => {
 		await tick();
 		renderStyling();
+
+		await mermaid.run({
+			querySelector: '.mermaid'
+		});
 	});
 </script>
 
@@ -364,7 +388,7 @@
 				{/if}
 			</Name>
 
-			{#if message.files}
+			{#if (message?.files ?? []).filter((f) => f.type === 'image').length > 0}
 				<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
 					{#each message.files as file}
 						<div>
@@ -377,9 +401,35 @@
 			{/if}
 
 			<div
-				class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-p:m-0 prose-p:-mb-6 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-8 prose-ol:p-0 prose-li:-mb-4 whitespace-pre-line"
+				class="prose chat-{message.role} w-full max-w-full dark:prose-invert prose-headings:my-0 prose-headings:-mb-4 prose-p:m-0 prose-p:-mb-6 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-img:my-0 prose-ul:-my-4 prose-ol:-my-4 prose-li:-my-3 prose-ul:-mb-6 prose-ol:-mb-8 prose-ol:p-0 prose-li:-mb-4 whitespace-pre-line"
 			>
 				<div>
+					{#if message?.status}
+						<div class="flex items-center gap-2 pt-1 pb-1">
+							{#if message?.status?.done === false}
+								<div class="">
+									<Spinner className="size-4" />
+								</div>
+							{/if}
+
+							{#if message?.status?.action === 'web_search' && message?.status?.urls}
+								<WebSearchResults urls={message?.status?.urls}>
+									<div class="flex flex-col justify-center -space-y-0.5">
+										<div class="text-base line-clamp-1 text-wrap">
+											{message.status.description}
+										</div>
+									</div>
+								</WebSearchResults>
+							{:else}
+								<div class="flex flex-col justify-center -space-y-0.5">
+									<div class=" text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap">
+										{message.status.description}
+									</div>
+								</div>
+							{/if}
+						</div>
+					{/if}
+
 					{#if edit === true}
 						<div class="w-full bg-gray-50 dark:bg-gray-800 rounded-3xl px-5 py-3 my-2">
 							<textarea
@@ -417,7 +467,34 @@
 						</div>
 					{:else}
 						<div class="w-full">
-							{#if message?.error === true}
+							{#if message.content === '' && !message.error}
+								<Skeleton />
+							{:else if message.content && message.error !== true}
+								<!-- always show message contents even if there's an error -->
+								<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
+								{#each tokens as token, tokenIdx}
+									{#if token.type === 'code'}
+										{#if token.lang === 'mermaid'}
+											<pre class="mermaid">{revertSanitizedResponseContent(token.text)}</pre>
+										{:else}
+											<CodeBlock
+												id={`${message.id}-${tokenIdx}`}
+												lang={token?.lang ?? ''}
+												code={revertSanitizedResponseContent(token?.text ?? '')}
+											/>
+										{/if}
+									{:else}
+										{@html marked.parse(token.raw, {
+											...defaults,
+											gfm: true,
+											breaks: true,
+											renderer
+										})}
+									{/if}
+								{/each}
+							{/if}
+
+							{#if message.error}
 								<div
 									class="flex mt-2 mb-4 space-x-2 border px-4 py-3 border-red-800 bg-red-800/30 font-medium rounded-lg"
 								>
@@ -437,36 +514,22 @@
 									</svg>
 
 									<div class=" self-center">
-										{message.content}
+										{message?.error?.content ?? message.content}
 									</div>
 								</div>
-							{:else if message.content === ''}
-								<Skeleton />
-							{:else}
-								{#each tokens as token, tokenIdx}
-									{#if token.type === 'code'}
-										<CodeBlock
-											id={`${message.id}-${tokenIdx}`}
-											lang={token?.lang ?? ''}
-											code={revertSanitizedResponseContent(token?.text ?? '')}
-										/>
-									{:else}
-										{@html marked.parse(token.raw, {
-											...defaults,
-											gfm: true,
-											breaks: true,
-											renderer
-										})}
-									{/if}
-								{/each}
 							{/if}
 
 							{#if message.citations}
-								<div class="mt-1 mb-2 w-full flex gap-1 items-center">
+								<div class="mt-1 mb-2 w-full flex gap-1 items-center flex-wrap">
 									{#each message.citations.reduce((acc, citation) => {
 										citation.document.forEach((document, index) => {
 											const metadata = citation.metadata?.[index];
 											const id = metadata?.source ?? 'N/A';
+											let source = citation?.source;
+											// Check if ID looks like a URL
+											if (id.startsWith('http://') || id.startsWith('https://')) {
+												source = { name: id };
+											}
 
 											const existingSource = acc.find((item) => item.id === id);
 
@@ -474,7 +537,7 @@
 												existingSource.document.push(document);
 												existingSource.metadata.push(metadata);
 											} else {
-												acc.push( { id: id, source: citation?.source, document: [document], metadata: metadata ? [metadata] : [] } );
+												acc.push( { id: id, source: source, document: [document], metadata: metadata ? [metadata] : [] } );
 											}
 										});
 										return acc;

+ 62 - 0
src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte

@@ -0,0 +1,62 @@
+<script lang="ts">
+	import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
+	import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
+	import { Collapsible } from 'bits-ui';
+	import { slide } from 'svelte/transition';
+
+	export let urls = [];
+	let state = false;
+</script>
+
+<Collapsible.Root class="w-full space-y-1" bind:open={state}>
+	<Collapsible.Trigger>
+		<div
+			class="flex items-center gap-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition"
+		>
+			<slot />
+
+			{#if state}
+				<ChevronUp strokeWidth="3.5" className="size-3.5 " />
+			{:else}
+				<ChevronDown strokeWidth="3.5" className="size-3.5 " />
+			{/if}
+		</div>
+	</Collapsible.Trigger>
+
+	<Collapsible.Content
+		class=" text-sm border border-gray-300/30 dark:border-gray-700/50 rounded-xl"
+		transition={slide}
+	>
+		{#each urls as url, urlIdx}
+			<a
+				href={url}
+				target="_blank"
+				class="flex w-full items-center p-3 px-4 {urlIdx === urls.length - 1
+					? ''
+					: 'border-b border-gray-300/30 dark:border-gray-700/50'} group/item justify-between font-normal text-gray-800 dark:text-gray-300"
+			>
+				<div class=" line-clamp-1">
+					{url}
+				</div>
+
+				<div
+					class=" ml-1 text-white dark:text-gray-900 group-hover/item:text-gray-600 dark:group-hover/item:text-white transition"
+				>
+					<!--  -->
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						viewBox="0 0 16 16"
+						fill="currentColor"
+						class="size-4"
+					>
+						<path
+							fill-rule="evenodd"
+							d="M4.22 11.78a.75.75 0 0 1 0-1.06L9.44 5.5H5.75a.75.75 0 0 1 0-1.5h5.5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0V6.56l-5.22 5.22a.75.75 0 0 1-1.06 0Z"
+							clip-rule="evenodd"
+						/>
+					</svg>
+				</div>
+			</a>
+		{/each}
+	</Collapsible.Content>
+</Collapsible.Root>

+ 1 - 1
src/lib/components/chat/Messages/Skeleton.svelte

@@ -1,4 +1,4 @@
-<div class="w-full mt-3 mb-4">
+<div class="w-full mt-2 mb-4">
 	<div class="animate-pulse flex w-full">
 		<div class="space-y-2 w-full">
 			<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />

+ 2 - 2
src/lib/components/chat/Messages/UserMessage.svelte

@@ -196,7 +196,7 @@
 					<div class=" mt-2 mb-1 flex justify-end space-x-1.5 text-sm font-medium">
 						<button
 							id="close-edit-message-button"
-							class="px-4 py-2 bg-white hover:bg-gray-100 text-gray-800 transition rounded-3xl"
+							class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
 							on:click={() => {
 								cancelEditMessage();
 							}}
@@ -206,7 +206,7 @@
 
 						<button
 							id="save-edit-message-button"
-							class=" px-4 py-2 bg-gray-900 hover:bg-gray-850 text-gray-100 transition rounded-3xl"
+							class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
 							on:click={() => {
 								editMessageConfirmHandler();
 							}}

+ 128 - 95
src/lib/components/chat/ModelSelector/Selector.svelte

@@ -8,7 +8,7 @@
 	import Check from '$lib/components/icons/Check.svelte';
 	import Search from '$lib/components/icons/Search.svelte';
 
-	import { cancelOllamaRequest, deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
+	import { deleteModel, getOllamaVersion, pullModel } from '$lib/apis/ollama';
 
 	import { user, MODEL_DOWNLOAD_POOL, models, mobile } from '$lib/stores';
 	import { toast } from 'svelte-sonner';
@@ -42,9 +42,16 @@
 	let searchValue = '';
 	let ollamaVersion = null;
 
-	$: filteredItems = searchValue
-		? items.filter((item) => item.value.toLowerCase().includes(searchValue.toLowerCase()))
-		: items;
+	$: filteredItems = items.filter(
+		(item) =>
+			(searchValue
+				? item.value.toLowerCase().includes(searchValue.toLowerCase()) ||
+				  item.label.toLowerCase().includes(searchValue.toLowerCase()) ||
+				  (item.model?.info?.meta?.tags ?? []).some((tag) =>
+						tag.name.toLowerCase().includes(searchValue.toLowerCase())
+				  )
+				: true) && !(item.model?.info?.meta?.hidden ?? false)
+	);
 
 	const pullModelHandler = async () => {
 		const sanitizedModelTag = searchValue.trim().replace(/^ollama\s+(run|pull)\s+/, '');
@@ -65,10 +72,12 @@
 			return;
 		}
 
-		const res = await pullModel(localStorage.token, sanitizedModelTag, '0').catch((error) => {
-			toast.error(error);
-			return null;
-		});
+		const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch(
+			(error) => {
+				toast.error(error);
+				return null;
+			}
+		);
 
 		if (res) {
 			const reader = res.body
@@ -76,6 +85,16 @@
 				.pipeThrough(splitStream('\n'))
 				.getReader();
 
+			MODEL_DOWNLOAD_POOL.set({
+				...$MODEL_DOWNLOAD_POOL,
+				[sanitizedModelTag]: {
+					...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
+					abortController: controller,
+					reader,
+					done: false
+				}
+			});
+
 			while (true) {
 				try {
 					const { value, done } = await reader.read();
@@ -94,19 +113,6 @@
 								throw data.detail;
 							}
 
-							if (data.id) {
-								MODEL_DOWNLOAD_POOL.set({
-									...$MODEL_DOWNLOAD_POOL,
-									[sanitizedModelTag]: {
-										...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
-										requestId: data.id,
-										reader,
-										done: false
-									}
-								});
-								console.log(data);
-							}
-
 							if (data.status) {
 								if (data.digest) {
 									let downloadProgress = 0;
@@ -174,11 +180,12 @@
 	});
 
 	const cancelModelPullHandler = async (model: string) => {
-		const { reader, requestId } = $MODEL_DOWNLOAD_POOL[model];
+		const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
+		if (abortController) {
+			abortController.abort();
+		}
 		if (reader) {
 			await reader.cancel();
-
-			await cancelOllamaRequest(localStorage.token, requestId);
 			delete $MODEL_DOWNLOAD_POOL[model];
 			MODEL_DOWNLOAD_POOL.set({
 				...$MODEL_DOWNLOAD_POOL
@@ -245,87 +252,113 @@
 							show = false;
 						}}
 					>
-						<div class="flex items-center gap-2">
-							<div class="flex items-center">
-								<div class="line-clamp-1">
-									{item.label}
-								</div>
-								{#if item.model.owned_by === 'ollama' && (item.model.ollama?.details?.parameter_size ?? '') !== ''}
-									<div class="flex ml-1 items-center">
-										<Tooltip
-											content={`${
-												item.model.ollama?.details?.quantization_level
-													? item.model.ollama?.details?.quantization_level + ' '
-													: ''
-											}${
-												item.model.ollama?.size
-													? `(${(item.model.ollama?.size / 1024 ** 3).toFixed(1)}GB)`
-													: ''
-											}`}
-											className="self-end"
+						<div class="flex flex-col">
+							{#if $mobile && (item?.model?.info?.meta?.tags ?? []).length > 0}
+								<div class="flex gap-0.5 self-start h-full mb-0.5 -translate-x-1">
+									{#each item.model?.info?.meta.tags as tag}
+										<div
+											class=" text-xs font-black px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
 										>
-											<span class=" text-xs font-medium text-gray-600 dark:text-gray-400"
-												>{item.model.ollama?.details?.parameter_size ?? ''}</span
+											{tag.name}
+										</div>
+									{/each}
+								</div>
+							{/if}
+							<div class="flex items-center gap-2">
+								<div class="flex items-center">
+									<div class="line-clamp-1">
+										{item.label}
+									</div>
+									{#if item.model.owned_by === 'ollama' && (item.model.ollama?.details?.parameter_size ?? '') !== ''}
+										<div class="flex ml-1 items-center translate-y-[0.5px]">
+											<Tooltip
+												content={`${
+													item.model.ollama?.details?.quantization_level
+														? item.model.ollama?.details?.quantization_level + ' '
+														: ''
+												}${
+													item.model.ollama?.size
+														? `(${(item.model.ollama?.size / 1024 ** 3).toFixed(1)}GB)`
+														: ''
+												}`}
+												className="self-end"
 											>
-										</Tooltip>
+												<span
+													class=" text-xs font-medium text-gray-600 dark:text-gray-400 line-clamp-1"
+													>{item.model.ollama?.details?.parameter_size ?? ''}</span
+												>
+											</Tooltip>
+										</div>
+									{/if}
+								</div>
+
+								{#if !$mobile && (item?.model?.info?.meta?.tags ?? []).length > 0}
+									<div class="flex gap-0.5 self-center items-center h-full translate-y-[0.5px]">
+										{#each item.model?.info?.meta.tags as tag}
+											<div
+												class=" text-xs font-black px-1 rounded uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
+											>
+												{tag.name}
+											</div>
+										{/each}
 									</div>
 								{/if}
-							</div>
 
-							<!-- {JSON.stringify(item.info)} -->
+								<!-- {JSON.stringify(item.info)} -->
 
-							{#if item.model.owned_by === 'openai'}
-								<Tooltip content={`${'External'}`}>
-									<div class="">
-										<svg
-											xmlns="http://www.w3.org/2000/svg"
-											viewBox="0 0 16 16"
-											fill="currentColor"
-											class="size-3"
-										>
-											<path
-												fill-rule="evenodd"
-												d="M8.914 6.025a.75.75 0 0 1 1.06 0 3.5 3.5 0 0 1 0 4.95l-2 2a3.5 3.5 0 0 1-5.396-4.402.75.75 0 0 1 1.251.827 2 2 0 0 0 3.085 2.514l2-2a2 2 0 0 0 0-2.828.75.75 0 0 1 0-1.06Z"
-												clip-rule="evenodd"
-											/>
-											<path
-												fill-rule="evenodd"
-												d="M7.086 9.975a.75.75 0 0 1-1.06 0 3.5 3.5 0 0 1 0-4.95l2-2a3.5 3.5 0 0 1 5.396 4.402.75.75 0 0 1-1.251-.827 2 2 0 0 0-3.085-2.514l-2 2a2 2 0 0 0 0 2.828.75.75 0 0 1 0 1.06Z"
-												clip-rule="evenodd"
-											/>
-										</svg>
-									</div>
-								</Tooltip>
-							{/if}
+								{#if item.model.owned_by === 'openai'}
+									<Tooltip content={`${'External'}`}>
+										<div class="">
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												viewBox="0 0 16 16"
+												fill="currentColor"
+												class="size-3"
+											>
+												<path
+													fill-rule="evenodd"
+													d="M8.914 6.025a.75.75 0 0 1 1.06 0 3.5 3.5 0 0 1 0 4.95l-2 2a3.5 3.5 0 0 1-5.396-4.402.75.75 0 0 1 1.251.827 2 2 0 0 0 3.085 2.514l2-2a2 2 0 0 0 0-2.828.75.75 0 0 1 0-1.06Z"
+													clip-rule="evenodd"
+												/>
+												<path
+													fill-rule="evenodd"
+													d="M7.086 9.975a.75.75 0 0 1-1.06 0 3.5 3.5 0 0 1 0-4.95l2-2a3.5 3.5 0 0 1 5.396 4.402.75.75 0 0 1-1.251-.827 2 2 0 0 0-3.085-2.514l-2 2a2 2 0 0 0 0 2.828.75.75 0 0 1 0 1.06Z"
+													clip-rule="evenodd"
+												/>
+											</svg>
+										</div>
+									</Tooltip>
+								{/if}
 
-							{#if item.model?.info?.meta?.description}
-								<Tooltip
-									content={`${sanitizeResponseContent(
-										item.model?.info?.meta?.description
-									).replaceAll('\n', '<br>')}`}
-								>
-									<div class="">
-										<svg
-											xmlns="http://www.w3.org/2000/svg"
-											fill="none"
-											viewBox="0 0 24 24"
-											stroke-width="1.5"
-											stroke="currentColor"
-											class="w-4 h-4"
-										>
-											<path
-												stroke-linecap="round"
-												stroke-linejoin="round"
-												d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
-											/>
-										</svg>
-									</div>
-								</Tooltip>
-							{/if}
+								{#if item.model?.info?.meta?.description}
+									<Tooltip
+										content={`${sanitizeResponseContent(
+											item.model?.info?.meta?.description
+										).replaceAll('\n', '<br>')}`}
+									>
+										<div class="">
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												fill="none"
+												viewBox="0 0 24 24"
+												stroke-width="1.5"
+												stroke="currentColor"
+												class="w-4 h-4"
+											>
+												<path
+													stroke-linecap="round"
+													stroke-linejoin="round"
+													d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
+												/>
+											</svg>
+										</div>
+									</Tooltip>
+								{/if}
+							</div>
 						</div>
 
 						{#if value === item.value}
-							<div class="ml-auto">
+							<div class="ml-auto pl-2">
 								<Check />
 							</div>
 						{/if}

+ 92 - 1
src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte

@@ -20,6 +20,9 @@
 		tfs_z: '',
 		num_ctx: '',
 		max_tokens: '',
+		use_mmap: null,
+		use_mlock: null,
+		num_thread: null,
 		template: null
 	};
 
@@ -379,7 +382,7 @@
 
 	<div class=" py-0.5 w-full justify-between">
 		<div class="flex w-full justify-between">
-			<div class=" self-center text-xs font-medium">{$i18n.t('Frequencey Penalty')}</div>
+			<div class=" self-center text-xs font-medium">{$i18n.t('Frequency Penalty')}</div>
 
 			<button
 				class="p-1 px-3 text-xs flex rounded transition"
@@ -559,6 +562,7 @@
 			</div>
 		{/if}
 	</div>
+
 	<div class=" py-0.5 w-full justify-between">
 		<div class="flex w-full justify-between">
 			<div class=" self-center text-xs font-medium">{$i18n.t('Max Tokens (num_predict)')}</div>
@@ -604,6 +608,93 @@
 			</div>
 		{/if}
 	</div>
+
+	<div class=" py-0.5 w-full justify-between">
+		<div class="flex w-full justify-between">
+			<div class=" self-center text-xs font-medium">{$i18n.t('use_mmap (Ollama)')}</div>
+
+			<button
+				class="p-1 px-3 text-xs flex rounded transition"
+				type="button"
+				on:click={() => {
+					params.use_mmap = (params?.use_mmap ?? null) === null ? true : null;
+				}}
+			>
+				{#if (params?.use_mmap ?? null) === null}
+					<span class="ml-2 self-center">{$i18n.t('Default')}</span>
+				{:else}
+					<span class="ml-2 self-center">{$i18n.t('On')}</span>
+				{/if}
+			</button>
+		</div>
+	</div>
+
+	<div class=" py-0.5 w-full justify-between">
+		<div class="flex w-full justify-between">
+			<div class=" self-center text-xs font-medium">{$i18n.t('use_mlock (Ollama)')}</div>
+
+			<button
+				class="p-1 px-3 text-xs flex rounded transition"
+				type="button"
+				on:click={() => {
+					params.use_mlock = (params?.use_mlock ?? null) === null ? true : null;
+				}}
+			>
+				{#if (params?.use_mlock ?? null) === null}
+					<span class="ml-2 self-center">{$i18n.t('Default')}</span>
+				{:else}
+					<span class="ml-2 self-center">{$i18n.t('On')}</span>
+				{/if}
+			</button>
+		</div>
+	</div>
+
+	<div class=" py-0.5 w-full justify-between">
+		<div class="flex w-full justify-between">
+			<div class=" self-center text-xs font-medium">{$i18n.t('num_thread (Ollama)')}</div>
+
+			<button
+				class="p-1 px-3 text-xs flex rounded transition"
+				type="button"
+				on:click={() => {
+					params.num_thread = (params?.num_thread ?? null) === null ? 2 : null;
+				}}
+			>
+				{#if (params?.num_thread ?? null) === null}
+					<span class="ml-2 self-center">{$i18n.t('Default')}</span>
+				{:else}
+					<span class="ml-2 self-center">{$i18n.t('Custom')}</span>
+				{/if}
+			</button>
+		</div>
+
+		{#if (params?.num_thread ?? null) !== null}
+			<div class="flex mt-0.5 space-x-2">
+				<div class=" flex-1">
+					<input
+						id="steps-range"
+						type="range"
+						min="1"
+						max="256"
+						step="1"
+						bind:value={params.num_thread}
+						class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
+					/>
+				</div>
+				<div class="">
+					<input
+						bind:value={params.num_thread}
+						type="number"
+						class=" bg-transparent text-center w-14"
+						min="1"
+						max="256"
+						step="1"
+					/>
+				</div>
+			</div>
+		{/if}
+	</div>
+
 	<div class=" py-0.5 w-full justify-between">
 		<div class="flex w-full justify-between">
 			<div class=" self-center text-xs font-medium">{$i18n.t('Template')}</div>

+ 35 - 11
src/lib/components/chat/Settings/Audio.svelte

@@ -3,6 +3,7 @@
 	import { user, settings } from '$lib/stores';
 	import { createEventDispatcher, onMount, getContext } from 'svelte';
 	import { toast } from 'svelte-sonner';
+	import Switch from '$lib/components/common/Switch.svelte';
 	const dispatch = createEventDispatcher();
 
 	const i18n = getContext('i18n');
@@ -13,6 +14,7 @@
 
 	let OpenAIUrl = '';
 	let OpenAIKey = '';
+	let OpenAISpeaker = '';
 
 	let STTEngines = ['', 'openai'];
 	let STTEngine = '';
@@ -20,6 +22,7 @@
 	let conversationMode = false;
 	let speechAutoSend = false;
 	let responseAutoPlayback = false;
+	let nonLocalVoices = false;
 
 	let TTSEngines = ['', 'openai'];
 	let TTSEngine = '';
@@ -86,14 +89,14 @@
 				url: OpenAIUrl,
 				key: OpenAIKey,
 				model: model,
-				speaker: speaker
+				speaker: OpenAISpeaker
 			});
 
 			if (res) {
 				OpenAIUrl = res.OPENAI_API_BASE_URL;
 				OpenAIKey = res.OPENAI_API_KEY;
 				model = res.OPENAI_API_MODEL;
-				speaker = res.OPENAI_API_VOICE;
+				OpenAISpeaker = res.OPENAI_API_VOICE;
 			}
 		}
 	};
@@ -105,6 +108,7 @@
 
 		STTEngine = $settings?.audio?.STTEngine ?? '';
 		TTSEngine = $settings?.audio?.TTSEngine ?? '';
+		nonLocalVoices = $settings.audio?.nonLocalVoices ?? false;
 		speaker = $settings?.audio?.speaker ?? '';
 		model = $settings?.audio?.model ?? '';
 
@@ -122,7 +126,10 @@
 				OpenAIUrl = res.OPENAI_API_BASE_URL;
 				OpenAIKey = res.OPENAI_API_KEY;
 				model = res.OPENAI_API_MODEL;
-				speaker = res.OPENAI_API_VOICE;
+				OpenAISpeaker = res.OPENAI_API_VOICE;
+				if (TTSEngine === 'openai') {
+					speaker = OpenAISpeaker;
+				}
 			}
 		}
 	});
@@ -138,8 +145,14 @@
 			audio: {
 				STTEngine: STTEngine !== '' ? STTEngine : undefined,
 				TTSEngine: TTSEngine !== '' ? TTSEngine : undefined,
-				speaker: speaker !== '' ? speaker : undefined,
-				model: model !== '' ? model : undefined
+				speaker:
+					(TTSEngine === 'openai' ? OpenAISpeaker : speaker) !== ''
+						? TTSEngine === 'openai'
+							? OpenAISpeaker
+							: speaker
+						: undefined,
+				model: model !== '' ? model : undefined,
+				nonLocalVoices: nonLocalVoices
 			}
 		});
 		dispatch('save');
@@ -227,7 +240,7 @@
 						on:change={(e) => {
 							if (e.target.value === 'openai') {
 								getOpenAIVoices();
-								speaker = 'alloy';
+								OpenAISpeaker = 'alloy';
 								model = 'tts-1';
 							} else {
 								getWebAPIVoices();
@@ -290,16 +303,27 @@
 						<select
 							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
 							bind:value={speaker}
-							placeholder="Select a voice"
 						>
-							<option value="" selected>{$i18n.t('Default')}</option>
-							{#each voices.filter((v) => v.localService === true) as voice}
-								<option value={voice.name} class="bg-gray-100 dark:bg-gray-700">{voice.name}</option
+							<option value="" selected={speaker !== ''}>{$i18n.t('Default')}</option>
+							{#each voices.filter((v) => nonLocalVoices || v.localService === true) as voice}
+								<option
+									value={voice.name}
+									class="bg-gray-100 dark:bg-gray-700"
+									selected={speaker === voice.name}>{voice.name}</option
 								>
 							{/each}
 						</select>
 					</div>
 				</div>
+				<div class="flex items-center justify-between my-1.5">
+					<div class="text-xs">
+						{$i18n.t('Allow non-local voices')}
+					</div>
+
+					<div class="mt-1">
+						<Switch bind:state={nonLocalVoices} />
+					</div>
+				</div>
 			</div>
 		{:else if TTSEngine === 'openai'}
 			<div>
@@ -309,7 +333,7 @@
 						<input
 							list="voice-list"
 							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
-							bind:value={speaker}
+							bind:value={OpenAISpeaker}
 							placeholder="Select a voice"
 						/>
 

+ 159 - 31
src/lib/components/chat/Settings/Connections.svelte

@@ -1,6 +1,6 @@
 <script lang="ts">
 	import { models, user } from '$lib/stores';
-	import { createEventDispatcher, onMount, getContext } from 'svelte';
+	import { createEventDispatcher, onMount, getContext, tick } from 'svelte';
 	const dispatch = createEventDispatcher();
 
 	import {
@@ -13,6 +13,7 @@
 	import {
 		getOpenAIConfig,
 		getOpenAIKeys,
+		getOpenAIModels,
 		getOpenAIUrls,
 		updateOpenAIConfig,
 		updateOpenAIKeys,
@@ -21,6 +22,7 @@
 	import { toast } from 'svelte-sonner';
 	import Switch from '$lib/components/common/Switch.svelte';
 	import Spinner from '$lib/components/common/Spinner.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -32,27 +34,88 @@
 	let OPENAI_API_KEYS = [''];
 	let OPENAI_API_BASE_URLS = [''];
 
+	let pipelineUrls = {};
+
 	let ENABLE_OPENAI_API = null;
 	let ENABLE_OLLAMA_API = null;
 
-	const updateOpenAIHandler = async () => {
+	const verifyOpenAIHandler = async (idx) => {
 		OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS);
 		OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS);
 
+		const res = await getOpenAIModels(localStorage.token, idx).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		if (res) {
+			toast.success($i18n.t('Server connection verified'));
+			if (res.pipelines) {
+				pipelineUrls[OPENAI_API_BASE_URLS[idx]] = true;
+			}
+		}
+
 		await models.set(await getModels());
 	};
 
-	const updateOllamaUrlsHandler = async () => {
+	const verifyOllamaHandler = async (idx) => {
 		OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS);
 
-		const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
+		const res = await getOllamaVersion(localStorage.token, idx).catch((error) => {
 			toast.error(error);
 			return null;
 		});
 
-		if (ollamaVersion) {
+		if (res) {
 			toast.success($i18n.t('Server connection verified'));
-			await models.set(await getModels());
+		}
+
+		await models.set(await getModels());
+	};
+
+	const updateOpenAIHandler = async () => {
+		// Check if API KEYS length is same than API URLS length
+		if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
+			// if there are more keys than urls, remove the extra keys
+			if (OPENAI_API_KEYS.length > OPENAI_API_BASE_URLS.length) {
+				OPENAI_API_KEYS = OPENAI_API_KEYS.slice(0, OPENAI_API_BASE_URLS.length);
+			}
+
+			// if there are more urls than keys, add empty keys
+			if (OPENAI_API_KEYS.length < OPENAI_API_BASE_URLS.length) {
+				const diff = OPENAI_API_BASE_URLS.length - OPENAI_API_KEYS.length;
+				for (let i = 0; i < diff; i++) {
+					OPENAI_API_KEYS.push('');
+				}
+			}
+		}
+
+		OPENAI_API_BASE_URLS = await updateOpenAIUrls(localStorage.token, OPENAI_API_BASE_URLS);
+		OPENAI_API_KEYS = await updateOpenAIKeys(localStorage.token, OPENAI_API_KEYS);
+		await models.set(await getModels());
+	};
+
+	const updateOllamaUrlsHandler = async () => {
+		OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url) => url !== '');
+		console.log(OLLAMA_BASE_URLS);
+
+		if (OLLAMA_BASE_URLS.length === 0) {
+			ENABLE_OLLAMA_API = false;
+			await updateOllamaConfig(localStorage.token, ENABLE_OLLAMA_API);
+
+			toast.info($i18n.t('Ollama API disabled'));
+		} else {
+			OLLAMA_BASE_URLS = await updateOllamaUrls(localStorage.token, OLLAMA_BASE_URLS);
+
+			const ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => {
+				toast.error(error);
+				return null;
+			});
+
+			if (ollamaVersion) {
+				toast.success($i18n.t('Server connection verified'));
+				await models.set(await getModels());
+			}
 		}
 	};
 
@@ -70,6 +133,13 @@
 				})()
 			]);
 
+			OPENAI_API_BASE_URLS.forEach(async (url, idx) => {
+				const res = await getOpenAIModels(localStorage.token, idx);
+				if (res.pipelines) {
+					pipelineUrls[url] = true;
+				}
+			});
+
 			const ollamaConfig = await getOllamaConfig(localStorage.token);
 			const openaiConfig = await getOpenAIConfig(localStorage.token);
 
@@ -83,6 +153,8 @@
 	class="flex flex-col h-full justify-between text-sm"
 	on:submit|preventDefault={() => {
 		updateOpenAIHandler();
+		updateOllamaUrlsHandler();
+
 		dispatch('save');
 	}}
 >
@@ -107,13 +179,38 @@
 						<div class="flex flex-col gap-1">
 							{#each OPENAI_API_BASE_URLS as url, idx}
 								<div class="flex w-full gap-2">
-									<div class="flex-1">
+									<div class="flex-1 relative">
 										<input
-											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											class="w-full rounded-lg py-2 px-4 {pipelineUrls[url]
+												? 'pr-8'
+												: ''} text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
 											placeholder={$i18n.t('API Base URL')}
 											bind:value={url}
 											autocomplete="off"
 										/>
+
+										{#if pipelineUrls[url]}
+											<div class=" absolute top-2.5 right-2.5">
+												<Tooltip content="Pipelines">
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														viewBox="0 0 24 24"
+														fill="currentColor"
+														class="size-4"
+													>
+														<path
+															d="M11.644 1.59a.75.75 0 0 1 .712 0l9.75 5.25a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.712 0l-9.75-5.25a.75.75 0 0 1 0-1.32l9.75-5.25Z"
+														/>
+														<path
+															d="m3.265 10.602 7.668 4.129a2.25 2.25 0 0 0 2.134 0l7.668-4.13 1.37.739a.75.75 0 0 1 0 1.32l-9.75 5.25a.75.75 0 0 1-.71 0l-9.75-5.25a.75.75 0 0 1 0-1.32l1.37-.738Z"
+														/>
+														<path
+															d="m10.933 19.231-7.668-4.13-1.37.739a.75.75 0 0 0 0 1.32l9.75 5.25c.221.12.489.12.71 0l9.75-5.25a.75.75 0 0 0 0-1.32l-1.37-.738-7.668 4.13a2.25 2.25 0 0 1-2.134-.001Z"
+														/>
+													</svg>
+												</Tooltip>
+											</div>
+										{/if}
 									</div>
 
 									<div class="flex-1">
@@ -167,6 +264,31 @@
 											</button>
 										{/if}
 									</div>
+
+									<div class="flex">
+										<Tooltip content="Verify connection" className="self-start mt-0.5">
+											<button
+												class="self-center p-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
+												on:click={() => {
+													verifyOpenAIHandler(idx);
+												}}
+												type="button"
+											>
+												<svg
+													xmlns="http://www.w3.org/2000/svg"
+													viewBox="0 0 20 20"
+													fill="currentColor"
+													class="w-4 h-4"
+												>
+													<path
+														fill-rule="evenodd"
+														d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
+														clip-rule="evenodd"
+													/>
+												</svg>
+											</button>
+										</Tooltip>
+									</div>
 								</div>
 								<div class=" mb-1 text-xs text-gray-400 dark:text-gray-500">
 									{$i18n.t('WebUI will make requests to')}
@@ -189,6 +311,10 @@
 							bind:state={ENABLE_OLLAMA_API}
 							on:change={async () => {
 								updateOllamaConfig(localStorage.token, ENABLE_OLLAMA_API);
+
+								if (OLLAMA_BASE_URLS.length === 0) {
+									OLLAMA_BASE_URLS = [''];
+								}
 							}}
 						/>
 					</div>
@@ -245,32 +371,34 @@
 											</button>
 										{/if}
 									</div>
+
+									<div class="flex">
+										<Tooltip content="Verify connection" className="self-start mt-0.5">
+											<button
+												class="self-center p-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
+												on:click={() => {
+													verifyOllamaHandler(idx);
+												}}
+												type="button"
+											>
+												<svg
+													xmlns="http://www.w3.org/2000/svg"
+													viewBox="0 0 20 20"
+													fill="currentColor"
+													class="w-4 h-4"
+												>
+													<path
+														fill-rule="evenodd"
+														d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
+														clip-rule="evenodd"
+													/>
+												</svg>
+											</button>
+										</Tooltip>
+									</div>
 								</div>
 							{/each}
 						</div>
-
-						<div class="flex">
-							<button
-								class="self-center p-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-900 dark:hover:bg-gray-850 rounded-lg transition"
-								on:click={() => {
-									updateOllamaUrlsHandler();
-								}}
-								type="button"
-							>
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 20 20"
-									fill="currentColor"
-									class="w-4 h-4"
-								>
-									<path
-										fill-rule="evenodd"
-										d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0V5.36l-.31-.31A7 7 0 003.239 8.188a.75.75 0 101.448.389A5.5 5.5 0 0113.89 6.11l.311.31h-2.432a.75.75 0 000 1.5h4.243a.75.75 0 00.53-.219z"
-										clip-rule="evenodd"
-									/>
-								</svg>
-							</button>
-						</div>
 					</div>
 
 					<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">

+ 68 - 22
src/lib/components/chat/Settings/Interface.svelte

@@ -4,6 +4,7 @@
 	import { config, models, settings, user } from '$lib/stores';
 	import { createEventDispatcher, onMount, getContext } from 'svelte';
 	import { toast } from 'svelte-sonner';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	const dispatch = createEventDispatcher();
 
 	const i18n = getContext('i18n');
@@ -15,11 +16,12 @@
 	let responseAutoCopy = false;
 	let titleAutoGenerateModel = '';
 	let titleAutoGenerateModelExternal = '';
-	let fullScreenMode = false;
+	let widescreenMode = false;
 	let titleGenerationPrompt = '';
 	let splitLargeChunks = false;
 
 	// Interface
+	let defaultModelId = '';
 	let promptSuggestions = [];
 	let showUsername = false;
 	let chatBubble = true;
@@ -30,9 +32,9 @@
 		saveSettings({ splitLargeChunks: splitLargeChunks });
 	};
 
-	const toggleFullScreenMode = async () => {
-		fullScreenMode = !fullScreenMode;
-		saveSettings({ fullScreenMode: fullScreenMode });
+	const togglewidescreenMode = async () => {
+		widescreenMode = !widescreenMode;
+		saveSettings({ widescreenMode: widescreenMode });
 	};
 
 	const toggleChatBubble = async () => {
@@ -95,7 +97,8 @@
 				modelExternal:
 					titleAutoGenerateModelExternal !== '' ? titleAutoGenerateModelExternal : undefined,
 				prompt: titleGenerationPrompt ? titleGenerationPrompt : undefined
-			}
+			},
+			models: [defaultModelId]
 		});
 	};
 
@@ -113,9 +116,11 @@
 		responseAutoCopy = $settings.responseAutoCopy ?? false;
 		showUsername = $settings.showUsername ?? false;
 		chatBubble = $settings.chatBubble ?? true;
-		fullScreenMode = $settings.fullScreenMode ?? false;
+		widescreenMode = $settings.widescreenMode ?? false;
 		splitLargeChunks = $settings.splitLargeChunks ?? false;
 		chatDirection = $settings.chatDirection ?? 'LTR';
+
+		defaultModelId = ($settings?.models ?? ['']).at(0);
 	});
 </script>
 
@@ -194,16 +199,16 @@
 
 			<div>
 				<div class=" py-0.5 flex w-full justify-between">
-					<div class=" self-center text-xs font-medium">{$i18n.t('Full Screen Mode')}</div>
+					<div class=" self-center text-xs font-medium">{$i18n.t('Widescreen Mode')}</div>
 
 					<button
 						class="p-1 px-3 text-xs flex rounded transition"
 						on:click={() => {
-							toggleFullScreenMode();
+							togglewidescreenMode();
 						}}
 						type="button"
 					>
-						{#if fullScreenMode === true}
+						{#if widescreenMode === true}
 							<span class="ml-2 self-center">{$i18n.t('On')}</span>
 						{:else}
 							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
@@ -277,10 +282,55 @@
 			</div>
 		</div>
 
-		<hr class=" dark:border-gray-700" />
+		<hr class=" dark:border-gray-850" />
+
+		<div class=" space-y-1 mb-3">
+			<div class="mb-2">
+				<div class="flex justify-between items-center text-xs">
+					<div class=" text-xs font-medium">{$i18n.t('Default Model')}</div>
+				</div>
+			</div>
+
+			<div class="flex-1 mr-2">
+				<select
+					class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+					bind:value={defaultModelId}
+					placeholder="Select a model"
+				>
+					<option value="" disabled selected>{$i18n.t('Select a model')}</option>
+					{#each $models.filter((model) => model.id) as model}
+						<option value={model.id} class="bg-gray-100 dark:bg-gray-700">{model.name}</option>
+					{/each}
+				</select>
+			</div>
+		</div>
+
+		<hr class=" dark:border-gray-850" />
 
 		<div>
-			<div class=" mb-2.5 text-sm font-medium">{$i18n.t('Set Title Auto-Generation Model')}</div>
+			<div class=" mb-2.5 text-sm font-medium flex">
+				<div class=" mr-1">{$i18n.t('Set Task Model')}</div>
+				<Tooltip
+					content={$i18n.t(
+						'A task model is used when performing tasks such as generating titles for chats and web search queries'
+					)}
+				>
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						fill="none"
+						viewBox="0 0 24 24"
+						stroke-width="1.5"
+						stroke="currentColor"
+						class="w-5 h-5"
+					>
+						<path
+							stroke-linecap="round"
+							stroke-linejoin="round"
+							d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"
+						/>
+					</svg>
+				</Tooltip>
+			</div>
 			<div class="flex w-full gap-2 pr-2">
 				<div class="flex-1">
 					<div class=" text-xs mb-1">Local Models</div>
@@ -290,12 +340,10 @@
 						placeholder={$i18n.t('Select a model')}
 					>
 						<option value="" selected>{$i18n.t('Current Model')}</option>
-						{#each $models as model}
-							{#if model.size != null}
-								<option value={model.name} class="bg-gray-100 dark:bg-gray-700">
-									{model.name + ' (' + (model.size / 1024 ** 3).toFixed(1) + ' GB)'}
-								</option>
-							{/if}
+						{#each $models.filter((m) => m.owned_by === 'ollama') as model}
+							<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
+								{model.name}
+							</option>
 						{/each}
 					</select>
 				</div>
@@ -309,11 +357,9 @@
 					>
 						<option value="" selected>{$i18n.t('Current Model')}</option>
 						{#each $models as model}
-							{#if model.name !== 'hr'}
-								<option value={model.name} class="bg-gray-100 dark:bg-gray-700">
-									{model.name}
-								</option>
-							{/if}
+							<option value={model.id} class="bg-gray-100 dark:bg-gray-700">
+								{model.name}
+							</option>
 						{/each}
 					</select>
 				</div>

+ 593 - 413
src/lib/components/chat/Settings/Models.svelte

@@ -8,8 +8,8 @@
 		getOllamaUrls,
 		getOllamaVersion,
 		pullModel,
-		cancelOllamaRequest,
-		uploadModel
+		uploadModel,
+		getOllamaConfig
 	} from '$lib/apis/ollama';
 
 	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
@@ -28,6 +28,8 @@
 
 	// Models
 
+	let ollamaEnabled = null;
+
 	let OLLAMA_URLS = [];
 	let selectedOllamaUrlIdx: string | null = null;
 
@@ -41,6 +43,13 @@
 
 	let modelTransferring = false;
 	let modelTag = '';
+
+	let createModelLoading = false;
+	let createModelTag = '';
+	let createModelContent = '';
+	let createModelDigest = '';
+	let createModelPullProgress = null;
+
 	let digest = '';
 	let pullProgress = null;
 
@@ -67,12 +76,14 @@
 			console.log(model);
 
 			updateModelId = model.id;
-			const res = await pullModel(localStorage.token, model.id, selectedOllamaUrlIdx).catch(
-				(error) => {
-					toast.error(error);
-					return null;
-				}
-			);
+			const [res, controller] = await pullModel(
+				localStorage.token,
+				model.id,
+				selectedOllamaUrlIdx
+			).catch((error) => {
+				toast.error(error);
+				return null;
+			});
 
 			if (res) {
 				const reader = res.body
@@ -141,10 +152,12 @@
 			return;
 		}
 
-		const res = await pullModel(localStorage.token, sanitizedModelTag, '0').catch((error) => {
-			toast.error(error);
-			return null;
-		});
+		const [res, controller] = await pullModel(localStorage.token, sanitizedModelTag, '0').catch(
+			(error) => {
+				toast.error(error);
+				return null;
+			}
+		);
 
 		if (res) {
 			const reader = res.body
@@ -152,6 +165,16 @@
 				.pipeThrough(splitStream('\n'))
 				.getReader();
 
+			MODEL_DOWNLOAD_POOL.set({
+				...$MODEL_DOWNLOAD_POOL,
+				[sanitizedModelTag]: {
+					...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
+					abortController: controller,
+					reader,
+					done: false
+				}
+			});
+
 			while (true) {
 				try {
 					const { value, done } = await reader.read();
@@ -170,19 +193,6 @@
 								throw data.detail;
 							}
 
-							if (data.id) {
-								MODEL_DOWNLOAD_POOL.set({
-									...$MODEL_DOWNLOAD_POOL,
-									[sanitizedModelTag]: {
-										...$MODEL_DOWNLOAD_POOL[sanitizedModelTag],
-										requestId: data.id,
-										reader,
-										done: false
-									}
-								});
-								console.log(data);
-							}
-
 							if (data.status) {
 								if (data.digest) {
 									let downloadProgress = 0;
@@ -416,11 +426,12 @@
 	};
 
 	const cancelModelPullHandler = async (model: string) => {
-		const { reader, requestId } = $MODEL_DOWNLOAD_POOL[model];
+		const { reader, abortController } = $MODEL_DOWNLOAD_POOL[model];
+		if (abortController) {
+			abortController.abort();
+		}
 		if (reader) {
 			await reader.cancel();
-
-			await cancelOllamaRequest(localStorage.token, requestId);
 			delete $MODEL_DOWNLOAD_POOL[model];
 			MODEL_DOWNLOAD_POOL.set({
 				...$MODEL_DOWNLOAD_POOL
@@ -430,54 +441,216 @@
 		}
 	};
 
-	onMount(async () => {
-		await Promise.all([
-			(async () => {
-				OLLAMA_URLS = await getOllamaUrls(localStorage.token).catch((error) => {
-					toast.error(error);
-					return [];
-				});
+	const createModelHandler = async () => {
+		createModelLoading = true;
+		const res = await createModel(
+			localStorage.token,
+			createModelTag,
+			createModelContent,
+			selectedOllamaUrlIdx
+		).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		if (res && res.ok) {
+			const reader = res.body
+				.pipeThrough(new TextDecoderStream())
+				.pipeThrough(splitStream('\n'))
+				.getReader();
+
+			while (true) {
+				const { value, done } = await reader.read();
+				if (done) break;
+
+				try {
+					let lines = value.split('\n');
+
+					for (const line of lines) {
+						if (line !== '') {
+							console.log(line);
+							let data = JSON.parse(line);
+							console.log(data);
 
-				if (OLLAMA_URLS.length > 0) {
-					selectedOllamaUrlIdx = 0;
+							if (data.error) {
+								throw data.error;
+							}
+							if (data.detail) {
+								throw data.detail;
+							}
+
+							if (data.status) {
+								if (
+									!data.digest &&
+									!data.status.includes('writing') &&
+									!data.status.includes('sha256')
+								) {
+									toast.success(data.status);
+								} else {
+									if (data.digest) {
+										createModelDigest = data.digest;
+
+										if (data.completed) {
+											createModelPullProgress =
+												Math.round((data.completed / data.total) * 1000) / 10;
+										} else {
+											createModelPullProgress = 100;
+										}
+									}
+								}
+							}
+						}
+					}
+				} catch (error) {
+					console.log(error);
+					toast.error(error);
 				}
-			})(),
-			(async () => {
-				ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
-			})()
-		]);
+			}
+		}
+
+		models.set(await getModels());
+
+		createModelLoading = false;
+
+		createModelTag = '';
+		createModelContent = '';
+		createModelDigest = '';
+		createModelPullProgress = null;
+	};
+
+	onMount(async () => {
+		const ollamaConfig = await getOllamaConfig(localStorage.token);
+
+		if (ollamaConfig.ENABLE_OLLAMA_API) {
+			ollamaEnabled = true;
+
+			await Promise.all([
+				(async () => {
+					OLLAMA_URLS = await getOllamaUrls(localStorage.token).catch((error) => {
+						toast.error(error);
+						return [];
+					});
+
+					if (OLLAMA_URLS.length > 0) {
+						selectedOllamaUrlIdx = 0;
+					}
+				})(),
+				(async () => {
+					ollamaVersion = await getOllamaVersion(localStorage.token).catch((error) => false);
+				})()
+			]);
+		} else {
+			ollamaEnabled = false;
+			toast.error('Ollama API is disabled');
+		}
 	});
 </script>
 
 <div class="flex flex-col h-full justify-between text-sm">
-	<div class=" space-y-3 pr-1.5 overflow-y-scroll h-[24rem]">
-		{#if ollamaVersion !== null}
-			<div class="space-y-2 pr-1.5">
-				<div class="text-sm font-medium">{$i18n.t('Manage Ollama Models')}</div>
-
-				{#if OLLAMA_URLS.length > 0}
-					<div class="flex gap-2">
-						<div class="flex-1 pb-1">
-							<select
-								class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
-								bind:value={selectedOllamaUrlIdx}
-								placeholder={$i18n.t('Select an Ollama instance')}
-							>
-								{#each OLLAMA_URLS as url, idx}
-									<option value={idx} class="bg-gray-100 dark:bg-gray-700">{url}</option>
-								{/each}
-							</select>
+	<div class=" space-y-3 pr-1.5 overflow-y-scroll max-h-[27rem]">
+		{#if ollamaEnabled}
+			{#if ollamaVersion !== null}
+				<div class="space-y-2 pr-1.5">
+					<div class="text-sm font-medium">{$i18n.t('Manage Ollama Models')}</div>
+
+					{#if OLLAMA_URLS.length > 0}
+						<div class="flex gap-2">
+							<div class="flex-1 pb-1">
+								<select
+									class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+									bind:value={selectedOllamaUrlIdx}
+									placeholder={$i18n.t('Select an Ollama instance')}
+								>
+									{#each OLLAMA_URLS as url, idx}
+										<option value={idx} class="bg-gray-100 dark:bg-gray-700">{url}</option>
+									{/each}
+								</select>
+							</div>
+
+							<div>
+								<div class="flex w-full justify-end">
+									<Tooltip content="Update All Models" placement="top">
+										<button
+											class="p-2.5 flex gap-2 items-center bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
+											on:click={() => {
+												updateModelsHandler();
+											}}
+										>
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												viewBox="0 0 16 16"
+												fill="currentColor"
+												class="w-4 h-4"
+											>
+												<path
+													d="M7 1a.75.75 0 0 1 .75.75V6h-1.5V1.75A.75.75 0 0 1 7 1ZM6.25 6v2.94L5.03 7.72a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l2.5-2.5a.75.75 0 1 0-1.06-1.06L7.75 8.94V6H10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.25Z"
+												/>
+												<path
+													d="M4.268 14A2 2 0 0 0 6 15h6a2 2 0 0 0 2-2v-3a2 2 0 0 0-1-1.732V11a3 3 0 0 1-3 3H4.268Z"
+												/>
+											</svg>
+										</button>
+									</Tooltip>
+								</div>
+							</div>
 						</div>
 
+						{#if updateModelId}
+							Updating "{updateModelId}" {updateProgress ? `(${updateProgress}%)` : ''}
+						{/if}
+					{/if}
+
+					<div class="space-y-2">
 						<div>
-							<div class="flex w-full justify-end">
-								<Tooltip content="Update All Models" placement="top">
-									<button
-										class="p-2.5 flex gap-2 items-center bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
-										on:click={() => {
-											updateModelsHandler();
-										}}
-									>
+							<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div>
+							<div class="flex w-full">
+								<div class="flex-1 mr-2">
+									<input
+										class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+										placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
+											modelTag: 'mistral:7b'
+										})}
+										bind:value={modelTag}
+									/>
+								</div>
+								<button
+									class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
+									on:click={() => {
+										pullModelHandler();
+									}}
+									disabled={modelTransferring}
+								>
+									{#if modelTransferring}
+										<div class="self-center">
+											<svg
+												class=" w-4 h-4"
+												viewBox="0 0 24 24"
+												fill="currentColor"
+												xmlns="http://www.w3.org/2000/svg"
+											>
+												<style>
+													.spinner_ajPY {
+														transform-origin: center;
+														animation: spinner_AtaB 0.75s infinite linear;
+													}
+
+													@keyframes spinner_AtaB {
+														100% {
+															transform: rotate(360deg);
+														}
+													}
+												</style>
+												<path
+													d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+													opacity=".25"
+												/>
+												<path
+													d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+													class="spinner_ajPY"
+												/>
+											</svg>
+										</div>
+									{:else}
 										<svg
 											xmlns="http://www.w3.org/2000/svg"
 											viewBox="0 0 16 16"
@@ -485,74 +658,111 @@
 											class="w-4 h-4"
 										>
 											<path
-												d="M7 1a.75.75 0 0 1 .75.75V6h-1.5V1.75A.75.75 0 0 1 7 1ZM6.25 6v2.94L5.03 7.72a.75.75 0 0 0-1.06 1.06l2.5 2.5a.75.75 0 0 0 1.06 0l2.5-2.5a.75.75 0 1 0-1.06-1.06L7.75 8.94V6H10a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2.25Z"
+												d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
 											/>
 											<path
-												d="M4.268 14A2 2 0 0 0 6 15h6a2 2 0 0 0 2-2v-3a2 2 0 0 0-1-1.732V11a3 3 0 0 1-3 3H4.268Z"
+												d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
 											/>
 										</svg>
-									</button>
-								</Tooltip>
+									{/if}
+								</button>
 							</div>
-						</div>
-					</div>
 
-					{#if updateModelId}
-						Updating "{updateModelId}" {updateProgress ? `(${updateProgress}%)` : ''}
-					{/if}
-				{/if}
-
-				<div class="space-y-2">
-					<div>
-						<div class=" mb-2 text-sm font-medium">{$i18n.t('Pull a model from Ollama.com')}</div>
-						<div class="flex w-full">
-							<div class="flex-1 mr-2">
-								<input
-									class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
-									placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
-										modelTag: 'mistral:7b'
-									})}
-									bind:value={modelTag}
-								/>
+							<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
+								{$i18n.t('To access the available model names for downloading,')}
+								<a
+									class=" text-gray-500 dark:text-gray-300 font-medium underline"
+									href="https://ollama.com/library"
+									target="_blank">{$i18n.t('click here.')}</a
+								>
 							</div>
-							<button
-								class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
-								on:click={() => {
-									pullModelHandler();
-								}}
-								disabled={modelTransferring}
-							>
-								{#if modelTransferring}
-									<div class="self-center">
-										<svg
-											class=" w-4 h-4"
-											viewBox="0 0 24 24"
-											fill="currentColor"
-											xmlns="http://www.w3.org/2000/svg"
-										>
-											<style>
-												.spinner_ajPY {
-													transform-origin: center;
-													animation: spinner_AtaB 0.75s infinite linear;
-												}
-
-												@keyframes spinner_AtaB {
-													100% {
-														transform: rotate(360deg);
-													}
-												}
-											</style>
-											<path
-												d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-												opacity=".25"
-											/>
-											<path
-												d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-												class="spinner_ajPY"
-											/>
-										</svg>
-									</div>
-								{:else}
+
+							{#if Object.keys($MODEL_DOWNLOAD_POOL).length > 0}
+								{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
+									{#if 'pullProgress' in $MODEL_DOWNLOAD_POOL[model]}
+										<div class="flex flex-col">
+											<div class="font-medium mb-1">{model}</div>
+											<div class="">
+												<div class="flex flex-row justify-between space-x-4 pr-2">
+													<div class=" flex-1">
+														<div
+															class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+															style="width: {Math.max(
+																15,
+																$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0
+															)}%"
+														>
+															{$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0}%
+														</div>
+													</div>
+
+													<Tooltip content={$i18n.t('Cancel')}>
+														<button
+															class="text-gray-800 dark:text-gray-100"
+															on:click={() => {
+																cancelModelPullHandler(model);
+															}}
+														>
+															<svg
+																class="w-4 h-4 text-gray-800 dark:text-white"
+																aria-hidden="true"
+																xmlns="http://www.w3.org/2000/svg"
+																width="24"
+																height="24"
+																fill="currentColor"
+																viewBox="0 0 24 24"
+															>
+																<path
+																	stroke="currentColor"
+																	stroke-linecap="round"
+																	stroke-linejoin="round"
+																	stroke-width="2"
+																	d="M6 18 17.94 6M18 18 6.06 6"
+																/>
+															</svg>
+														</button>
+													</Tooltip>
+												</div>
+												{#if 'digest' in $MODEL_DOWNLOAD_POOL[model]}
+													<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+														{$MODEL_DOWNLOAD_POOL[model].digest}
+													</div>
+												{/if}
+											</div>
+										</div>
+									{/if}
+								{/each}
+							{/if}
+						</div>
+
+						<div>
+							<div class=" mb-2 text-sm font-medium">{$i18n.t('Delete a model')}</div>
+							<div class="flex w-full">
+								<div class="flex-1 mr-2">
+									<select
+										class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+										bind:value={deleteModelTag}
+										placeholder={$i18n.t('Select a model')}
+									>
+										{#if !deleteModelTag}
+											<option value="" disabled selected>{$i18n.t('Select a model')}</option>
+										{/if}
+										{#each $models.filter((m) => !(m?.preset ?? false) && m.owned_by === 'ollama' && (selectedOllamaUrlIdx === null ? true : (m?.ollama?.urls ?? []).includes(selectedOllamaUrlIdx))) as model}
+											<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
+												>{model.name +
+													' (' +
+													(model.ollama.size / 1024 ** 3).toFixed(1) +
+													' GB)'}</option
+											>
+										{/each}
+									</select>
+								</div>
+								<button
+									class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
+									on:click={() => {
+										deleteModelHandler();
+									}}
+								>
 									<svg
 										xmlns="http://www.w3.org/2000/svg"
 										viewBox="0 0 16 16"
@@ -560,330 +770,300 @@
 										class="w-4 h-4"
 									>
 										<path
-											d="M8.75 2.75a.75.75 0 0 0-1.5 0v5.69L5.03 6.22a.75.75 0 0 0-1.06 1.06l3.5 3.5a.75.75 0 0 0 1.06 0l3.5-3.5a.75.75 0 0 0-1.06-1.06L8.75 8.44V2.75Z"
-										/>
-										<path
-											d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+											fill-rule="evenodd"
+											d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
+											clip-rule="evenodd"
 										/>
 									</svg>
-								{/if}
-							</button>
+								</button>
+							</div>
 						</div>
 
-						<div class="mt-2 mb-1 text-xs text-gray-400 dark:text-gray-500">
-							{$i18n.t('To access the available model names for downloading,')}
-							<a
-								class=" text-gray-500 dark:text-gray-300 font-medium underline"
-								href="https://ollama.com/library"
-								target="_blank">{$i18n.t('click here.')}</a
-							>
-						</div>
+						<div>
+							<div class=" mb-2 text-sm font-medium">{$i18n.t('Create a model')}</div>
+							<div class="flex w-full">
+								<div class="flex-1 mr-2 flex flex-col gap-2">
+									<input
+										class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+										placeholder={$i18n.t('Enter model tag (e.g. {{modelTag}})', {
+											modelTag: 'my-modelfile'
+										})}
+										bind:value={createModelTag}
+										disabled={createModelLoading}
+									/>
 
-						{#if Object.keys($MODEL_DOWNLOAD_POOL).length > 0}
-							{#each Object.keys($MODEL_DOWNLOAD_POOL) as model}
-								{#if 'pullProgress' in $MODEL_DOWNLOAD_POOL[model]}
-									<div class="flex flex-col">
-										<div class="font-medium mb-1">{model}</div>
-										<div class="">
-											<div class="flex flex-row justify-between space-x-4 pr-2">
-												<div class=" flex-1">
-													<div
-														class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
-														style="width: {Math.max(
-															15,
-															$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0
-														)}%"
-													>
-														{$MODEL_DOWNLOAD_POOL[model].pullProgress ?? 0}%
-													</div>
-												</div>
+									<textarea
+										bind:value={createModelContent}
+										class="w-full rounded-lg py-2 px-4 text-sm bg-gray-100 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none scrollbar-hidden"
+										rows="6"
+										placeholder={`TEMPLATE """{{ .System }}\nUSER: {{ .Prompt }}\nASSISTANT: """\nPARAMETER num_ctx 4096\nPARAMETER stop "</s>"\nPARAMETER stop "USER:"\nPARAMETER stop "ASSISTANT:"`}
+										disabled={createModelLoading}
+									/>
+								</div>
 
-												<Tooltip content={$i18n.t('Cancel')}>
-													<button
-														class="text-gray-800 dark:text-gray-100"
-														on:click={() => {
-															cancelModelPullHandler(model);
-														}}
-													>
-														<svg
-															class="w-4 h-4 text-gray-800 dark:text-white"
-															aria-hidden="true"
-															xmlns="http://www.w3.org/2000/svg"
-															width="24"
-															height="24"
-															fill="currentColor"
-															viewBox="0 0 24 24"
-														>
-															<path
-																stroke="currentColor"
-																stroke-linecap="round"
-																stroke-linejoin="round"
-																stroke-width="2"
-																d="M6 18 17.94 6M18 18 6.06 6"
-															/>
-														</svg>
-													</button>
-												</Tooltip>
-											</div>
-											{#if 'digest' in $MODEL_DOWNLOAD_POOL[model]}
-												<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-													{$MODEL_DOWNLOAD_POOL[model].digest}
+								<div class="flex self-start">
+									<button
+										class="px-2.5 py-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition disabled:cursor-not-allowed"
+										on:click={() => {
+											createModelHandler();
+										}}
+										disabled={createModelLoading}
+									>
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 16 16"
+											fill="currentColor"
+											class="size-4"
+										>
+											<path
+												d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
+											/>
+											<path
+												d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
+											/>
+										</svg>
+									</button>
+								</div>
+							</div>
+
+							{#if createModelDigest !== ''}
+								<div class="flex flex-col mt-1">
+									<div class="font-medium mb-1">{createModelTag}</div>
+									<div class="">
+										<div class="flex flex-row justify-between space-x-4 pr-2">
+											<div class=" flex-1">
+												<div
+													class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+													style="width: {Math.max(15, createModelPullProgress ?? 0)}%"
+												>
+													{createModelPullProgress ?? 0}%
 												</div>
-											{/if}
+											</div>
 										</div>
+										{#if createModelDigest}
+											<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+												{createModelDigest}
+											</div>
+										{/if}
 									</div>
-								{/if}
-							{/each}
-						{/if}
-					</div>
+								</div>
+							{/if}
+						</div>
 
-					<div>
-						<div class=" mb-2 text-sm font-medium">{$i18n.t('Delete a model')}</div>
-						<div class="flex w-full">
-							<div class="flex-1 mr-2">
-								<select
-									class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
-									bind:value={deleteModelTag}
-									placeholder={$i18n.t('Select a model')}
+						<div class="pt-1">
+							<div class="flex justify-between items-center text-xs">
+								<div class=" text-sm font-medium">{$i18n.t('Experimental')}</div>
+								<button
+									class=" text-xs font-medium text-gray-500"
+									type="button"
+									on:click={() => {
+										showExperimentalOllama = !showExperimentalOllama;
+									}}>{showExperimentalOllama ? $i18n.t('Hide') : $i18n.t('Show')}</button
 								>
-									{#if !deleteModelTag}
-										<option value="" disabled selected>{$i18n.t('Select a model')}</option>
-									{/if}
-									{#each $models.filter((m) => !(m?.preset ?? false) && m.owned_by === 'ollama' && (selectedOllamaUrlIdx === null ? true : (m?.ollama?.urls ?? []).includes(selectedOllamaUrlIdx))) as model}
-										<option value={model.name} class="bg-gray-100 dark:bg-gray-700"
-											>{model.name +
-												' (' +
-												(model.ollama.size / 1024 ** 3).toFixed(1) +
-												' GB)'}</option
-										>
-									{/each}
-								</select>
 							</div>
-							<button
-								class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg transition"
-								on:click={() => {
-									deleteModelHandler();
-								}}
-							>
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 16 16"
-									fill="currentColor"
-									class="w-4 h-4"
-								>
-									<path
-										fill-rule="evenodd"
-										d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
-										clip-rule="evenodd"
-									/>
-								</svg>
-							</button>
 						</div>
-					</div>
 
-					<div class="pt-1">
-						<div class="flex justify-between items-center text-xs">
-							<div class=" text-sm font-medium">{$i18n.t('Experimental')}</div>
-							<button
-								class=" text-xs font-medium text-gray-500"
-								type="button"
-								on:click={() => {
-									showExperimentalOllama = !showExperimentalOllama;
-								}}>{showExperimentalOllama ? $i18n.t('Hide') : $i18n.t('Show')}</button
+						{#if showExperimentalOllama}
+							<form
+								on:submit|preventDefault={() => {
+									uploadModelHandler();
+								}}
 							>
-						</div>
-					</div>
+								<div class=" mb-2 flex w-full justify-between">
+									<div class="  text-sm font-medium">{$i18n.t('Upload a GGUF model')}</div>
 
-					{#if showExperimentalOllama}
-						<form
-							on:submit|preventDefault={() => {
-								uploadModelHandler();
-							}}
-						>
-							<div class=" mb-2 flex w-full justify-between">
-								<div class="  text-sm font-medium">{$i18n.t('Upload a GGUF model')}</div>
+									<button
+										class="p-1 px-3 text-xs flex rounded transition"
+										on:click={() => {
+											if (modelUploadMode === 'file') {
+												modelUploadMode = 'url';
+											} else {
+												modelUploadMode = 'file';
+											}
+										}}
+										type="button"
+									>
+										{#if modelUploadMode === 'file'}
+											<span class="ml-2 self-center">{$i18n.t('File Mode')}</span>
+										{:else}
+											<span class="ml-2 self-center">{$i18n.t('URL Mode')}</span>
+										{/if}
+									</button>
+								</div>
 
-								<button
-									class="p-1 px-3 text-xs flex rounded transition"
-									on:click={() => {
-										if (modelUploadMode === 'file') {
-											modelUploadMode = 'url';
-										} else {
-											modelUploadMode = 'file';
-										}
-									}}
-									type="button"
-								>
-									{#if modelUploadMode === 'file'}
-										<span class="ml-2 self-center">{$i18n.t('File Mode')}</span>
-									{:else}
-										<span class="ml-2 self-center">{$i18n.t('URL Mode')}</span>
-									{/if}
-								</button>
-							</div>
+								<div class="flex w-full mb-1.5">
+									<div class="flex flex-col w-full">
+										{#if modelUploadMode === 'file'}
+											<div
+												class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}"
+											>
+												<input
+													id="model-upload-input"
+													bind:this={modelUploadInputElement}
+													type="file"
+													bind:files={modelInputFile}
+													on:change={() => {
+														console.log(modelInputFile);
+													}}
+													accept=".gguf,.safetensors"
+													required
+													hidden
+												/>
 
-							<div class="flex w-full mb-1.5">
-								<div class="flex flex-col w-full">
-									{#if modelUploadMode === 'file'}
-										<div class="flex-1 {modelInputFile && modelInputFile.length > 0 ? 'mr-2' : ''}">
-											<input
-												id="model-upload-input"
-												bind:this={modelUploadInputElement}
-												type="file"
-												bind:files={modelInputFile}
-												on:change={() => {
-													console.log(modelInputFile);
-												}}
-												accept=".gguf,.safetensors"
-												required
-												hidden
-											/>
+												<button
+													type="button"
+													class="w-full rounded-lg text-left py-2 px-4 bg-white dark:text-gray-300 dark:bg-gray-850"
+													on:click={() => {
+														modelUploadInputElement.click();
+													}}
+												>
+													{#if modelInputFile && modelInputFile.length > 0}
+														{modelInputFile[0].name}
+													{:else}
+														{$i18n.t('Click here to select')}
+													{/if}
+												</button>
+											</div>
+										{:else}
+											<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
+												<input
+													class="w-full rounded-lg text-left py-2 px-4 bg-white dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
+													''
+														? 'mr-2'
+														: ''}"
+													type="url"
+													required
+													bind:value={modelFileUrl}
+													placeholder={$i18n.t('Type Hugging Face Resolve (Download) URL')}
+												/>
+											</div>
+										{/if}
+									</div>
 
-											<button
-												type="button"
-												class="w-full rounded-lg text-left py-2 px-4 bg-white dark:text-gray-300 dark:bg-gray-850"
-												on:click={() => {
-													modelUploadInputElement.click();
-												}}
-											>
-												{#if modelInputFile && modelInputFile.length > 0}
-													{modelInputFile[0].name}
-												{:else}
-													{$i18n.t('Click here to select')}
-												{/if}
-											</button>
-										</div>
-									{:else}
-										<div class="flex-1 {modelFileUrl !== '' ? 'mr-2' : ''}">
-											<input
-												class="w-full rounded-lg text-left py-2 px-4 bg-white dark:text-gray-300 dark:bg-gray-850 outline-none {modelFileUrl !==
-												''
-													? 'mr-2'
-													: ''}"
-												type="url"
-												required
-												bind:value={modelFileUrl}
-												placeholder={$i18n.t('Type Hugging Face Resolve (Download) URL')}
-											/>
-										</div>
-									{/if}
-								</div>
+									{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
+										<button
+											class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition"
+											type="submit"
+											disabled={modelTransferring}
+										>
+											{#if modelTransferring}
+												<div class="self-center">
+													<svg
+														class=" w-4 h-4"
+														viewBox="0 0 24 24"
+														fill="currentColor"
+														xmlns="http://www.w3.org/2000/svg"
+													>
+														<style>
+															.spinner_ajPY {
+																transform-origin: center;
+																animation: spinner_AtaB 0.75s infinite linear;
+															}
 
-								{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
-									<button
-										class="px-2.5 bg-gray-100 hover:bg-gray-200 text-gray-800 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-100 rounded-lg disabled:cursor-not-allowed transition"
-										type="submit"
-										disabled={modelTransferring}
-									>
-										{#if modelTransferring}
-											<div class="self-center">
+															@keyframes spinner_AtaB {
+																100% {
+																	transform: rotate(360deg);
+																}
+															}
+														</style>
+														<path
+															d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
+															opacity=".25"
+														/>
+														<path
+															d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
+															class="spinner_ajPY"
+														/>
+													</svg>
+												</div>
+											{:else}
 												<svg
-													class=" w-4 h-4"
-													viewBox="0 0 24 24"
-													fill="currentColor"
 													xmlns="http://www.w3.org/2000/svg"
+													viewBox="0 0 16 16"
+													fill="currentColor"
+													class="w-4 h-4"
 												>
-													<style>
-														.spinner_ajPY {
-															transform-origin: center;
-															animation: spinner_AtaB 0.75s infinite linear;
-														}
-
-														@keyframes spinner_AtaB {
-															100% {
-																transform: rotate(360deg);
-															}
-														}
-													</style>
 													<path
-														d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
-														opacity=".25"
+														d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
 													/>
 													<path
-														d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
-														class="spinner_ajPY"
+														d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
 													/>
 												</svg>
-											</div>
-										{:else}
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 16 16"
-												fill="currentColor"
-												class="w-4 h-4"
-											>
-												<path
-													d="M7.25 10.25a.75.75 0 0 0 1.5 0V4.56l2.22 2.22a.75.75 0 1 0 1.06-1.06l-3.5-3.5a.75.75 0 0 0-1.06 0l-3.5 3.5a.75.75 0 0 0 1.06 1.06l2.22-2.22v5.69Z"
-												/>
-												<path
-													d="M3.5 9.75a.75.75 0 0 0-1.5 0v1.5A2.75 2.75 0 0 0 4.75 14h6.5A2.75 2.75 0 0 0 14 11.25v-1.5a.75.75 0 0 0-1.5 0v1.5c0 .69-.56 1.25-1.25 1.25h-6.5c-.69 0-1.25-.56-1.25-1.25v-1.5Z"
-												/>
-											</svg>
-										{/if}
-									</button>
-								{/if}
-							</div>
+											{/if}
+										</button>
+									{/if}
+								</div>
 
-							{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
-								<div>
+								{#if (modelUploadMode === 'file' && modelInputFile && modelInputFile.length > 0) || (modelUploadMode === 'url' && modelFileUrl !== '')}
 									<div>
-										<div class=" my-2.5 text-sm font-medium">{$i18n.t('Modelfile Content')}</div>
-										<textarea
-											bind:value={modelFileContent}
-											class="w-full rounded-lg py-2 px-4 text-sm bg-gray-100 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none"
-											rows="6"
-										/>
+										<div>
+											<div class=" my-2.5 text-sm font-medium">{$i18n.t('Modelfile Content')}</div>
+											<textarea
+												bind:value={modelFileContent}
+												class="w-full rounded-lg py-2 px-4 text-sm bg-gray-100 dark:text-gray-100 dark:bg-gray-850 outline-none resize-none"
+												rows="6"
+											/>
+										</div>
 									</div>
+								{/if}
+								<div class=" mt-1 text-xs text-gray-400 dark:text-gray-500">
+									{$i18n.t('To access the GGUF models available for downloading,')}
+									<a
+										class=" text-gray-500 dark:text-gray-300 font-medium underline"
+										href="https://huggingface.co/models?search=gguf"
+										target="_blank">{$i18n.t('click here.')}</a
+									>
 								</div>
-							{/if}
-							<div class=" mt-1 text-xs text-gray-400 dark:text-gray-500">
-								{$i18n.t('To access the GGUF models available for downloading,')}
-								<a
-									class=" text-gray-500 dark:text-gray-300 font-medium underline"
-									href="https://huggingface.co/models?search=gguf"
-									target="_blank">{$i18n.t('click here.')}</a
-								>
-							</div>
 
-							{#if uploadMessage}
-								<div class="mt-2">
-									<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
+								{#if uploadMessage}
+									<div class="mt-2">
+										<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
 
-									<div class="w-full rounded-full dark:bg-gray-800">
-										<div
-											class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
-											style="width: 100%"
-										>
-											{uploadMessage}
+										<div class="w-full rounded-full dark:bg-gray-800">
+											<div
+												class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+												style="width: 100%"
+											>
+												{uploadMessage}
+											</div>
 										</div>
-									</div>
-									<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-										{modelFileDigest}
-									</div>
-								</div>
-							{:else if uploadProgress !== null}
-								<div class="mt-2">
-									<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
-
-									<div class="w-full rounded-full dark:bg-gray-800">
-										<div
-											class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
-											style="width: {Math.max(15, uploadProgress ?? 0)}%"
-										>
-											{uploadProgress ?? 0}%
+										<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+											{modelFileDigest}
 										</div>
 									</div>
-									<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
-										{modelFileDigest}
+								{:else if uploadProgress !== null}
+									<div class="mt-2">
+										<div class=" mb-2 text-xs">{$i18n.t('Upload Progress')}</div>
+
+										<div class="w-full rounded-full dark:bg-gray-800">
+											<div
+												class="dark:bg-gray-600 bg-gray-500 text-xs font-medium text-gray-100 text-center p-0.5 leading-none rounded-full"
+												style="width: {Math.max(15, uploadProgress ?? 0)}%"
+											>
+												{uploadProgress ?? 0}%
+											</div>
+										</div>
+										<div class="mt-1 text-xs dark:text-gray-500" style="font-size: 0.5rem;">
+											{modelFileDigest}
+										</div>
 									</div>
-								</div>
-							{/if}
-						</form>
-					{/if}
+								{/if}
+							</form>
+						{/if}
+					</div>
 				</div>
-			</div>
-		{:else if ollamaVersion === false}
-			<div>Ollama Not Detected</div>
+			{:else if ollamaVersion === false}
+				<div>Ollama Not Detected</div>
+			{:else}
+				<div class="flex h-full justify-center">
+					<div class="my-auto">
+						<Spinner className="size-6" />
+					</div>
+				</div>
+			{/if}
+		{:else if ollamaEnabled === false}
+			<div>Ollama API is disabled</div>
 		{:else}
 			<div class="flex h-full justify-center">
 				<div class="my-auto">

+ 1 - 1
src/lib/components/common/Banner.svelte

@@ -39,7 +39,7 @@
 {#if !dismissed}
 	{#if mounted}
 		<div
-			class=" top-0 left-0 right-0 p-2 mx-4 px-3 flex justify-center items-center relative rounded-xl border border-gray-100 dark:border-gray-850 text-gray-800 dark:text-gary-100 bg-white dark:bg-gray-900 backdrop-blur-xl z-40"
+			class=" top-0 left-0 right-0 p-2 mx-4 px-3 flex justify-center items-center relative rounded-xl border border-gray-50 dark:border-gray-850 text-gray-800 dark:text-gary-100 bg-white dark:bg-gray-900 backdrop-blur-xl z-30"
 			transition:fade={{ delay: 100, duration: 300 }}
 		>
 			<div class=" flex flex-col md:flex-row md:items-center flex-1 text-sm w-fit gap-1.5">

+ 2 - 0
src/lib/components/common/Dropdown.svelte

@@ -10,9 +10,11 @@
 
 <DropdownMenu.Root
 	bind:open={show}
+	closeFocus={false}
 	onOpenChange={(state) => {
 		dispatch('change', state);
 	}}
+	typeahead={false}
 >
 	<DropdownMenu.Trigger>
 		<slot />

+ 1 - 1
src/lib/components/common/Tags.svelte

@@ -11,7 +11,7 @@
 	export let addTag: Function;
 </script>
 
-<div class="flex flex-row flex-wrap gap-0.5 line-clamp-1">
+<div class="flex flex-row flex-wrap gap-1 line-clamp-1">
 	<TagList
 		{tags}
 		on:delete={(e) => {

+ 20 - 18
src/lib/components/common/Tags/TagInput.svelte

@@ -22,26 +22,12 @@
 	};
 </script>
 
-<div class="flex space-x-1 pl-1.5">
+<div class="flex {showTagInput ? 'flex-row-reverse' : ''}">
 	{#if showTagInput}
 		<div class="flex items-center">
-			<button type="button" on:click={addTagHandler}>
-				<svg
-					xmlns="http://www.w3.org/2000/svg"
-					viewBox="0 0 16 16"
-					fill="currentColor"
-					class="w-3 h-3"
-				>
-					<path
-						fill-rule="evenodd"
-						d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
-						clip-rule="evenodd"
-					/>
-				</svg>
-			</button>
 			<input
 				bind:value={tagName}
-				class=" pl-2 cursor-pointer self-center text-xs h-fit bg-transparent outline-none line-clamp-1 w-[5.5rem]"
+				class=" px-2 cursor-pointer self-center text-xs h-fit bg-transparent outline-none line-clamp-1 w-[5.5rem]"
 				placeholder={$i18n.t('Add a tag')}
 				list="tagOptions"
 				on:keydown={(event) => {
@@ -55,11 +41,27 @@
 					<option value={tag.name} />
 				{/each}
 			</datalist>
+
+			<button type="button" on:click={addTagHandler}>
+				<svg
+					xmlns="http://www.w3.org/2000/svg"
+					viewBox="0 0 16 16"
+					fill="currentColor"
+					stroke-width="2"
+					class="w-3 h-3"
+				>
+					<path
+						fill-rule="evenodd"
+						d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z"
+						clip-rule="evenodd"
+					/>
+				</svg>
+			</button>
 		</div>
 	{/if}
 
 	<button
-		class=" cursor-pointer self-center p-0.5 space-x-1 flex h-fit items-center dark:hover:bg-gray-700 rounded-full transition border dark:border-gray-600 border-dashed"
+		class=" cursor-pointer self-center p-0.5 flex h-fit items-center dark:hover:bg-gray-700 rounded-full transition border dark:border-gray-600 border-dashed"
 		type="button"
 		on:click={() => {
 			showTagInput = !showTagInput;
@@ -80,6 +82,6 @@
 	</button>
 
 	{#if label && !showTagInput}
-		<span class="text-xs pl-1.5 self-center">{label}</span>
+		<span class="text-xs pl-2 self-center">{label}</span>
 	{/if}
 </div>

+ 4 - 3
src/lib/components/common/Tags/TagList.svelte

@@ -7,22 +7,23 @@
 
 {#each tags as tag}
 	<div
-		class="px-2 py-0.5 space-x-1 flex h-fit items-center rounded-full transition border dark:border-gray-800 dark:text-white"
+		class="px-2 py-[0.5px] gap-0.5 flex justify-between h-fit items-center rounded-full transition border dark:border-gray-800 dark:text-white"
 	>
 		<div class=" text-[0.7rem] font-medium self-center line-clamp-1">
 			{tag.name}
 		</div>
 		<button
-			class=" m-auto self-center cursor-pointer"
+			class="h-full flex self-center cursor-pointer"
 			on:click={() => {
 				dispatch('delete', tag.name);
 			}}
+			type="button"
 		>
 			<svg
 				xmlns="http://www.w3.org/2000/svg"
 				viewBox="0 0 16 16"
 				fill="currentColor"
-				class="w-3 h-3"
+				class="size-3 m-auto self-center translate-y-[0.3px] translate-x-[3px]"
 			>
 				<path
 					d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z"

+ 4 - 0
src/lib/components/common/Tooltip.svelte

@@ -21,6 +21,10 @@
 				touch: touch
 			});
 		}
+	} else if (tooltipInstance && content === '') {
+		if (tooltipInstance) {
+			tooltipInstance.destroy();
+		}
 	}
 
 	onDestroy(() => {

+ 207 - 74
src/lib/components/documents/Settings/General.svelte

@@ -8,7 +8,8 @@
 		getEmbeddingConfig,
 		updateEmbeddingConfig,
 		getRerankingConfig,
-		updateRerankingConfig
+		updateRerankingConfig,
+		resetUploadDir
 	} from '$lib/apis/rag';
 
 	import { documents, models } from '$lib/stores';
@@ -24,6 +25,7 @@
 	let updateRerankingModelLoading = false;
 
 	let showResetConfirm = false;
+	let showResetUploadDirConfirm = false;
 
 	let embeddingEngine = '';
 	let embeddingModel = '';
@@ -31,6 +33,7 @@
 
 	let OpenAIKey = '';
 	let OpenAIUrl = '';
+	let OpenAIBatchSize = 1;
 
 	let querySettings = {
 		template: '',
@@ -92,7 +95,8 @@
 				? {
 						openai_config: {
 							key: OpenAIKey,
-							url: OpenAIUrl
+							url: OpenAIUrl,
+							batch_size: OpenAIBatchSize
 						}
 				  }
 				: {})
@@ -159,6 +163,7 @@
 
 			OpenAIKey = embeddingConfig.openai_config.key;
 			OpenAIUrl = embeddingConfig.openai_config.url;
+			OpenAIBatchSize = embeddingConfig.openai_config.batch_size ?? 1;
 		}
 	};
 
@@ -282,6 +287,30 @@
 						required
 					/>
 				</div>
+				<div class="flex mt-0.5 space-x-2">
+					<div class=" self-center text-xs font-medium">{$i18n.t('Embedding Batch Size')}</div>
+					<div class=" flex-1">
+						<input
+							id="steps-range"
+							type="range"
+							min="1"
+							max="2048"
+							step="1"
+							bind:value={OpenAIBatchSize}
+							class="w-full h-2 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
+						/>
+					</div>
+					<div class="">
+						<input
+							bind:value={OpenAIBatchSize}
+							type="number"
+							class=" bg-transparent text-center w-14"
+							min="-2"
+							max="16000"
+							step="1"
+						/>
+					</div>
+				</div>
 			{/if}
 
 			<div class=" flex w-full justify-between">
@@ -469,99 +498,203 @@
 			{/if}
 		</div>
 
-		<hr class=" dark:border-gray-700" />
+		<hr class=" dark:border-gray-850" />
 
-		{#if showResetConfirm}
-			<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
-				<div class="flex items-center space-x-3">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 16 16"
-						fill="currentColor"
-						class="w-4 h-4"
-					>
-						<path d="M2 3a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3Z" />
-						<path
-							fill-rule="evenodd"
-							d="M13 6H3v6a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V6ZM5.72 7.47a.75.75 0 0 1 1.06 0L8 8.69l1.22-1.22a.75.75 0 1 1 1.06 1.06L9.06 9.75l1.22 1.22a.75.75 0 1 1-1.06 1.06L8 10.81l-1.22 1.22a.75.75 0 0 1-1.06-1.06l1.22-1.22-1.22-1.22a.75.75 0 0 1 0-1.06Z"
-							clip-rule="evenodd"
-						/>
-					</svg>
-					<span>{$i18n.t('Are you sure?')}</span>
-				</div>
+		<div>
+			{#if showResetUploadDirConfirm}
+				<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
+					<div class="flex items-center space-x-3">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 24 24"
+							fill="currentColor"
+							class="size-4"
+						>
+							<path
+								fill-rule="evenodd"
+								d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z"
+								clip-rule="evenodd"
+							/>
+							<path
+								d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
+							/>
+						</svg>
+						<span>{$i18n.t('Are you sure?')}</span>
+					</div>
 
-				<div class="flex space-x-1.5 items-center">
-					<button
-						class="hover:text-white transition"
-						on:click={() => {
-							const res = resetVectorDB(localStorage.token).catch((error) => {
-								toast.error(error);
-								return null;
-							});
-
-							if (res) {
-								toast.success($i18n.t('Success'));
-							}
+					<div class="flex space-x-1.5 items-center">
+						<button
+							class="hover:text-white transition"
+							on:click={() => {
+								const res = resetUploadDir(localStorage.token).catch((error) => {
+									toast.error(error);
+									return null;
+								});
 
-							showResetConfirm = false;
-						}}
-					>
+								if (res) {
+									toast.success($i18n.t('Success'));
+								}
+
+								showResetUploadDirConfirm = false;
+							}}
+							type="button"
+						>
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 20 20"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path
+									fill-rule="evenodd"
+									d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
+									clip-rule="evenodd"
+								/>
+							</svg>
+						</button>
+						<button
+							class="hover:text-white transition"
+							type="button"
+							on:click={() => {
+								showResetUploadDirConfirm = false;
+							}}
+						>
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 20 20"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path
+									d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+								/>
+							</svg>
+						</button>
+					</div>
+				</div>
+			{:else}
+				<button
+					class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+					on:click={() => {
+						showResetUploadDirConfirm = true;
+					}}
+					type="button"
+				>
+					<div class=" self-center mr-3">
 						<svg
 							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
+							viewBox="0 0 24 24"
 							fill="currentColor"
-							class="w-4 h-4"
+							class="size-4"
 						>
 							<path
 								fill-rule="evenodd"
-								d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
+								d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM9.75 14.25a.75.75 0 0 0 0 1.5H15a.75.75 0 0 0 0-1.5H9.75Z"
 								clip-rule="evenodd"
 							/>
+							<path
+								d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
+							/>
 						</svg>
-					</button>
-					<button
-						class="hover:text-white transition"
-						on:click={() => {
-							showResetConfirm = false;
-						}}
-					>
+					</div>
+					<div class=" self-center text-sm font-medium">{$i18n.t('Reset Upload Directory')}</div>
+				</button>
+			{/if}
+
+			{#if showResetConfirm}
+				<div class="flex justify-between rounded-md items-center py-2 px-3.5 w-full transition">
+					<div class="flex items-center space-x-3">
 						<svg
 							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 20 20"
+							viewBox="0 0 16 16"
 							fill="currentColor"
 							class="w-4 h-4"
 						>
 							<path
-								d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+								fill-rule="evenodd"
+								d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
+								clip-rule="evenodd"
 							/>
 						</svg>
-					</button>
-				</div>
-			</div>
-		{:else}
-			<button
-				class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
-				on:click={() => {
-					showResetConfirm = true;
-				}}
-			>
-				<div class=" self-center mr-3">
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						viewBox="0 0 16 16"
-						fill="currentColor"
-						class="w-4 h-4"
-					>
-						<path
-							fill-rule="evenodd"
-							d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
-							clip-rule="evenodd"
-						/>
-					</svg>
+						<span>{$i18n.t('Are you sure?')}</span>
+					</div>
+
+					<div class="flex space-x-1.5 items-center">
+						<button
+							class="hover:text-white transition"
+							on:click={() => {
+								const res = resetVectorDB(localStorage.token).catch((error) => {
+									toast.error(error);
+									return null;
+								});
+
+								if (res) {
+									toast.success($i18n.t('Success'));
+								}
+
+								showResetConfirm = false;
+							}}
+							type="button"
+						>
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 20 20"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path
+									fill-rule="evenodd"
+									d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z"
+									clip-rule="evenodd"
+								/>
+							</svg>
+						</button>
+						<button
+							class="hover:text-white transition"
+							on:click={() => {
+								showResetConfirm = false;
+							}}
+							type="button"
+						>
+							<svg
+								xmlns="http://www.w3.org/2000/svg"
+								viewBox="0 0 20 20"
+								fill="currentColor"
+								class="w-4 h-4"
+							>
+								<path
+									d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+								/>
+							</svg>
+						</button>
+					</div>
 				</div>
-				<div class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
-			</button>
-		{/if}
+			{:else}
+				<button
+					class=" flex rounded-xl py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+					on:click={() => {
+						showResetConfirm = true;
+					}}
+					type="button"
+				>
+					<div class=" self-center mr-3">
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 16 16"
+							fill="currentColor"
+							class="w-4 h-4"
+						>
+							<path
+								fill-rule="evenodd"
+								d="M3.5 2A1.5 1.5 0 0 0 2 3.5v9A1.5 1.5 0 0 0 3.5 14h9a1.5 1.5 0 0 0 1.5-1.5v-7A1.5 1.5 0 0 0 12.5 4H9.621a1.5 1.5 0 0 1-1.06-.44L7.439 2.44A1.5 1.5 0 0 0 6.38 2H3.5Zm6.75 7.75a.75.75 0 0 0 0-1.5h-4.5a.75.75 0 0 0 0 1.5h4.5Z"
+								clip-rule="evenodd"
+							/>
+						</svg>
+					</div>
+					<div class=" self-center text-sm font-medium">{$i18n.t('Reset Vector Storage')}</div>
+				</button>
+			{/if}
+		</div>
 	</div>
 	<div class="flex justify-end pt-3 text-sm font-medium">
 		<button

+ 223 - 40
src/lib/components/documents/Settings/WebParams.svelte

@@ -1,5 +1,6 @@
 <script lang="ts">
 	import { getRAGConfig, updateRAGConfig } from '$lib/apis/rag';
+	import Switch from '$lib/components/common/Switch.svelte';
 
 	import { documents, models } from '$lib/stores';
 	import { onMount, getContext } from 'svelte';
@@ -9,14 +10,15 @@
 
 	export let saveHandler: Function;
 
-	let webLoaderSSLVerification = true;
+	let webConfig = null;
+	let webSearchEngines = ['searxng', 'google_pse', 'brave', 'serpstack', 'serper'];
 
 	let youtubeLanguage = 'en';
 	let youtubeTranslation = null;
 
 	const submitHandler = async () => {
 		const res = await updateRAGConfig(localStorage.token, {
-			web_loader_ssl_verification: webLoaderSSLVerification,
+			web: webConfig,
 			youtube: {
 				language: youtubeLanguage.split(',').map((lang) => lang.trim()),
 				translation: youtubeTranslation
@@ -28,7 +30,8 @@
 		const res = await getRAGConfig(localStorage.token);
 
 		if (res) {
-			webLoaderSSLVerification = res.web_loader_ssl_verification;
+			webConfig = res.web;
+
 			youtubeLanguage = res.youtube.language.join(',');
 			youtubeTranslation = res.youtube.translation;
 		}
@@ -37,59 +40,239 @@
 
 <form
 	class="flex flex-col h-full justify-between space-y-3 text-sm"
-	on:submit|preventDefault={() => {
-		submitHandler();
+	on:submit|preventDefault={async () => {
+		await submitHandler();
 		saveHandler();
 	}}
 >
 	<div class=" space-y-3 pr-1.5 overflow-y-scroll h-full max-h-[22rem]">
-		<div>
-			<div class=" mb-1 text-sm font-medium">
-				{$i18n.t('Web Loader Settings')}
-			</div>
-
+		{#if webConfig}
 			<div>
+				<div class=" mb-1 text-sm font-medium">
+					{$i18n.t('Web Search')}
+				</div>
+
+				<div>
+					<div class=" py-0.5 flex w-full justify-between">
+						<div class=" self-center text-xs font-medium">
+							{$i18n.t('Enable Web Search')}
+						</div>
+
+						<Switch bind:state={webConfig.search.enabled} />
+					</div>
+				</div>
+
 				<div class=" py-0.5 flex w-full justify-between">
-					<div class=" self-center text-xs font-medium">
-						{$i18n.t('Bypass SSL verification for Websites')}
+					<div class=" self-center text-xs font-medium">{$i18n.t('Web Search Engine')}</div>
+					<div class="flex items-center relative">
+						<select
+							class="dark:bg-gray-900 w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
+							bind:value={webConfig.search.engine}
+							placeholder="Select a engine"
+							required
+						>
+							<option disabled selected value="">Select a engine</option>
+							{#each webSearchEngines as engine}
+								<option value={engine}>{engine}</option>
+							{/each}
+						</select>
 					</div>
+				</div>
+
+				{#if webConfig.search.engine !== ''}
+					<div class="mt-1.5">
+						{#if webConfig.search.engine === 'searxng'}
+							<div>
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('Searxng Query URL')}
+								</div>
+
+								<div class="flex w-full">
+									<div class="flex-1">
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											type="text"
+											placeholder={$i18n.t('Enter Searxng Query URL')}
+											bind:value={webConfig.search.searxng_query_url}
+											autocomplete="off"
+										/>
+									</div>
+								</div>
+							</div>
+						{:else if webConfig.search.engine === 'google_pse'}
+							<div>
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('Google PSE API Key')}
+								</div>
+
+								<div class="flex w-full">
+									<div class="flex-1">
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											type="text"
+											placeholder={$i18n.t('Enter Google PSE API Key')}
+											bind:value={webConfig.search.google_pse_api_key}
+											autocomplete="off"
+										/>
+									</div>
+								</div>
+							</div>
+							<div class="mt-1.5">
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('Google PSE Engine Id')}
+								</div>
 
-					<button
-						class="p-1 px-3 text-xs flex rounded transition"
-						on:click={() => {
-							webLoaderSSLVerification = !webLoaderSSLVerification;
-							submitHandler();
-						}}
-						type="button"
-					>
-						{#if webLoaderSSLVerification === true}
-							<span class="ml-2 self-center">{$i18n.t('On')}</span>
-						{:else}
-							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+								<div class="flex w-full">
+									<div class="flex-1">
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											type="text"
+											placeholder={$i18n.t('Enter Google PSE Engine Id')}
+											bind:value={webConfig.search.google_pse_engine_id}
+											autocomplete="off"
+										/>
+									</div>
+								</div>
+							</div>
+						{:else if webConfig.search.engine === 'brave'}
+							<div>
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('Brave Search API Key')}
+								</div>
+
+								<div class="flex w-full">
+									<div class="flex-1">
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											type="text"
+											placeholder={$i18n.t('Enter Brave Search API Key')}
+											bind:value={webConfig.search.brave_search_api_key}
+											autocomplete="off"
+										/>
+									</div>
+								</div>
+							</div>
+						{:else if webConfig.search.engine === 'serpstack'}
+							<div>
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('Serpstack API Key')}
+								</div>
+
+								<div class="flex w-full">
+									<div class="flex-1">
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											type="text"
+											placeholder={$i18n.t('Enter Serpstack API Key')}
+											bind:value={webConfig.search.serpstack_api_key}
+											autocomplete="off"
+										/>
+									</div>
+								</div>
+							</div>
+						{:else if webConfig.search.engine === 'serper'}
+							<div>
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('Serper API Key')}
+								</div>
+
+								<div class="flex w-full">
+									<div class="flex-1">
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+											type="text"
+											placeholder={$i18n.t('Enter Serper API Key')}
+											bind:value={webConfig.search.serper_api_key}
+											autocomplete="off"
+										/>
+									</div>
+								</div>
+							</div>
 						{/if}
-					</button>
-				</div>
-			</div>
+					</div>
+				{/if}
+
+				{#if webConfig.search.enabled}
+					<div class="mt-2 flex gap-2 mb-1">
+						<div class="w-full">
+							<div class=" self-center text-xs font-medium mb-1">
+								{$i18n.t('Search Result Count')}
+							</div>
+
+							<input
+								class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+								placeholder={$i18n.t('Search Result Count')}
+								bind:value={webConfig.search.result_count}
+								required
+							/>
+						</div>
 
-			<div class=" mt-2 mb-1 text-sm font-medium">
-				{$i18n.t('Youtube Loader Settings')}
+						<div class="w-full">
+							<div class=" self-center text-xs font-medium mb-1">
+								{$i18n.t('Concurrent Requests')}
+							</div>
+
+							<input
+								class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+								placeholder={$i18n.t('Concurrent Requests')}
+								bind:value={webConfig.search.concurrent_requests}
+								required
+							/>
+						</div>
+					</div>
+				{/if}
 			</div>
 
+			<hr class=" dark:border-gray-850 my-2" />
+
 			<div>
-				<div class=" py-0.5 flex w-full justify-between">
-					<div class=" w-20 text-xs font-medium self-center">{$i18n.t('Language')}</div>
-					<div class=" flex-1 self-center">
-						<input
-							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
-							type="text"
-							placeholder={$i18n.t('Enter language codes')}
-							bind:value={youtubeLanguage}
-							autocomplete="off"
-						/>
+				<div class=" mb-1 text-sm font-medium">
+					{$i18n.t('Web Loader Settings')}
+				</div>
+
+				<div>
+					<div class=" py-0.5 flex w-full justify-between">
+						<div class=" self-center text-xs font-medium">
+							{$i18n.t('Bypass SSL verification for Websites')}
+						</div>
+
+						<button
+							class="p-1 px-3 text-xs flex rounded transition"
+							on:click={() => {
+								webConfig.ssl_verification = !webConfig.ssl_verification;
+								submitHandler();
+							}}
+							type="button"
+						>
+							{#if webConfig.ssl_verification === true}
+								<span class="ml-2 self-center">{$i18n.t('On')}</span>
+							{:else}
+								<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+							{/if}
+						</button>
+					</div>
+				</div>
+
+				<div class=" mt-2 mb-1 text-sm font-medium">
+					{$i18n.t('Youtube Loader Settings')}
+				</div>
+
+				<div>
+					<div class=" py-0.5 flex w-full justify-between">
+						<div class=" w-20 text-xs font-medium self-center">{$i18n.t('Language')}</div>
+						<div class=" flex-1 self-center">
+							<input
+								class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+								type="text"
+								placeholder={$i18n.t('Enter language codes')}
+								bind:value={youtubeLanguage}
+								autocomplete="off"
+							/>
+						</div>
 					</div>
 				</div>
 			</div>
-		</div>
+		{/if}
 	</div>
 	<div class="flex justify-end pt-3 text-sm font-medium">
 		<button

+ 7 - 2
src/lib/components/documents/SettingsModal.svelte

@@ -1,11 +1,13 @@
 <script>
-	import { getContext } from 'svelte';
+	import { getContext, tick } from 'svelte';
 	import Modal from '../common/Modal.svelte';
 	import General from './Settings/General.svelte';
 	import ChunkParams from './Settings/ChunkParams.svelte';
 	import QueryParams from './Settings/QueryParams.svelte';
 	import WebParams from './Settings/WebParams.svelte';
 	import { toast } from 'svelte-sonner';
+	import { config } from '$lib/stores';
+	import { getBackendConfig } from '$lib/apis';
 
 	const i18n = getContext('i18n');
 
@@ -171,8 +173,11 @@
 					/>
 				{:else if selectedTab === 'web'}
 					<WebParams
-						saveHandler={() => {
+						saveHandler={async () => {
 							toast.success($i18n.t('Settings saved successfully!'));
+
+							await tick();
+							await config.set(await getBackendConfig());
 						}}
 					/>
 				{/if}

+ 19 - 0
src/lib/components/icons/ArrowDownTray.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
+	/>
+</svg>

+ 15 - 0
src/lib/components/icons/ChevronUp.svelte

@@ -0,0 +1,15 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
+</svg>

+ 14 - 0
src/lib/components/icons/DocumentArrowUpSolid.svelte

@@ -0,0 +1,14 @@
+<script lang="ts">
+	export let className = 'size-4';
+</script>
+
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
+	<path
+		fill-rule="evenodd"
+		d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875Zm6.905 9.97a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72V18a.75.75 0 0 0 1.5 0v-4.19l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z"
+		clip-rule="evenodd"
+	/>
+	<path
+		d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z"
+	/>
+</svg>

+ 19 - 0
src/lib/components/icons/DocumentDuplicate.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"
+	/>
+</svg>

+ 19 - 0
src/lib/components/icons/EllipsisHorizontal.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
+	/>
+</svg>

+ 9 - 0
src/lib/components/icons/GlobeAltSolid.svelte

@@ -0,0 +1,9 @@
+<script lang="ts">
+	export let className = 'size-4';
+</script>
+
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class={className}>
+	<path
+		d="M21.721 12.752a9.711 9.711 0 0 0-.945-5.003 12.754 12.754 0 0 1-4.339 2.708 18.991 18.991 0 0 1-.214 4.772 17.165 17.165 0 0 0 5.498-2.477ZM14.634 15.55a17.324 17.324 0 0 0 .332-4.647c-.952.227-1.945.347-2.966.347-1.021 0-2.014-.12-2.966-.347a17.515 17.515 0 0 0 .332 4.647 17.385 17.385 0 0 0 5.268 0ZM9.772 17.119a18.963 18.963 0 0 0 4.456 0A17.182 17.182 0 0 1 12 21.724a17.18 17.18 0 0 1-2.228-4.605ZM7.777 15.23a18.87 18.87 0 0 1-.214-4.774 12.753 12.753 0 0 1-4.34-2.708 9.711 9.711 0 0 0-.944 5.004 17.165 17.165 0 0 0 5.498 2.477ZM21.356 14.752a9.765 9.765 0 0 1-7.478 6.817 18.64 18.64 0 0 0 1.988-4.718 18.627 18.627 0 0 0 5.49-2.098ZM2.644 14.752c1.682.971 3.53 1.688 5.49 2.099a18.64 18.64 0 0 0 1.988 4.718 9.765 9.765 0 0 1-7.478-6.816ZM13.878 2.43a9.755 9.755 0 0 1 6.116 3.986 11.267 11.267 0 0 1-3.746 2.504 18.63 18.63 0 0 0-2.37-6.49ZM12 2.276a17.152 17.152 0 0 1 2.805 7.121c-.897.23-1.837.353-2.805.353-.968 0-1.908-.122-2.805-.353A17.151 17.151 0 0 1 12 2.276ZM10.122 2.43a18.629 18.629 0 0 0-2.37 6.49 11.266 11.266 0 0 1-3.746-2.504 9.754 9.754 0 0 1 6.116-3.985Z"
+	/>
+</svg>

+ 19 - 0
src/lib/components/icons/Keyboard.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '2';
+</script>
+
+<svg
+	aria-hidden="true"
+	xmlns="http://www.w3.org/2000/svg"
+	fill="currentColor"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	class={className}
+>
+	<path
+		fill-rule="evenodd"
+		d="M2 7a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7Zm5.01 1H5v2.01h2.01V8Zm3 0H8v2.01h2.01V8Zm3 0H11v2.01h2.01V8Zm3 0H14v2.01h2.01V8Zm3 0H17v2.01h2.01V8Zm-12 3H5v2.01h2.01V11Zm3 0H8v2.01h2.01V11Zm3 0H11v2.01h2.01V11Zm3 0H14v2.01h2.01V11Zm3 0H17v2.01h2.01V11Zm-12 3H5v2.01h2.01V14ZM8 14l-.001 2 8.011.01V14H8Zm11.01 0H17v2.01h2.01V14Z"
+		clip-rule="evenodd"
+	/>
+</svg>

+ 19 - 0
src/lib/components/icons/Lifebuoy.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '2';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M16.712 4.33a9.027 9.027 0 0 1 1.652 1.306c.51.51.944 1.064 1.306 1.652M16.712 4.33l-3.448 4.138m3.448-4.138a9.014 9.014 0 0 0-9.424 0M19.67 7.288l-4.138 3.448m4.138-3.448a9.014 9.014 0 0 1 0 9.424m-4.138-5.976a3.736 3.736 0 0 0-.88-1.388 3.737 3.737 0 0 0-1.388-.88m2.268 2.268a3.765 3.765 0 0 1 0 2.528m-2.268-4.796a3.765 3.765 0 0 0-2.528 0m4.796 4.796c-.181.506-.475.982-.88 1.388a3.736 3.736 0 0 1-1.388.88m2.268-2.268 4.138 3.448m0 0a9.027 9.027 0 0 1-1.306 1.652c-.51.51-1.064.944-1.652 1.306m0 0-3.448-4.138m3.448 4.138a9.014 9.014 0 0 1-9.424 0m5.976-4.138a3.765 3.765 0 0 1-2.528 0m0 0a3.736 3.736 0 0 1-1.388-.88 3.737 3.737 0 0 1-.88-1.388m2.268 2.268L7.288 19.67m0 0a9.024 9.024 0 0 1-1.652-1.306 9.027 9.027 0 0 1-1.306-1.652m0 0 4.138-3.448M4.33 16.712a9.014 9.014 0 0 1 0-9.424m4.138 5.976a3.765 3.765 0 0 1 0-2.528m0 0c.181-.506.475-.982.88-1.388a3.736 3.736 0 0 1 1.388-.88m-2.268 2.268L4.33 7.288m6.406 1.18L7.288 4.33m0 0a9.024 9.024 0 0 0-1.652 1.306A9.025 9.025 0 0 0 4.33 7.288"
+	/>
+</svg>

+ 19 - 0
src/lib/components/icons/QuestionMarkCircle.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '2';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"
+	/>
+</svg>

+ 40 - 0
src/lib/components/layout/Help.svelte

@@ -0,0 +1,40 @@
+<script lang="ts">
+	import { onMount, tick, getContext } from 'svelte';
+
+	const i18n = getContext('i18n');
+
+	import ShortcutsModal from '../chat/ShortcutsModal.svelte';
+	import Tooltip from '../common/Tooltip.svelte';
+	import HelpMenu from './Help/HelpMenu.svelte';
+
+	let showShortcuts = false;
+</script>
+
+<div class=" hidden lg:flex fixed bottom-0 right-0 px-2 py-2 z-10">
+	<button
+		id="show-shortcuts-button"
+		class="hidden"
+		on:click={() => {
+			showShortcuts = !showShortcuts;
+		}}
+	/>
+
+	<HelpMenu
+		showDocsHandler={() => {
+			showShortcuts = !showShortcuts;
+		}}
+		showShortcutsHandler={() => {
+			showShortcuts = !showShortcuts;
+		}}
+	>
+		<Tooltip content={$i18n.t('Help')} placement="left">
+			<button
+				class="text-gray-600 dark:text-gray-300 bg-gray-300/20 size-5 flex items-center justify-center text-[0.7rem] rounded-full"
+			>
+				?
+			</button>
+		</Tooltip>
+	</HelpMenu>
+</div>
+
+<ShortcutsModal bind:show={showShortcuts} />

+ 60 - 0
src/lib/components/layout/Help/HelpMenu.svelte

@@ -0,0 +1,60 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { getContext } from 'svelte';
+
+	import { showSettings } from '$lib/stores';
+	import { flyAndScale } from '$lib/utils/transitions';
+
+	import Dropdown from '$lib/components/common/Dropdown.svelte';
+	import QuestionMarkCircle from '$lib/components/icons/QuestionMarkCircle.svelte';
+	import Lifebuoy from '$lib/components/icons/Lifebuoy.svelte';
+	import Keyboard from '$lib/components/icons/Keyboard.svelte';
+	const i18n = getContext('i18n');
+
+	export let showDocsHandler: Function;
+	export let showShortcutsHandler: Function;
+
+	export let onClose: Function = () => {};
+</script>
+
+<Dropdown
+	on:change={(e) => {
+		if (e.detail === false) {
+			onClose();
+		}
+	}}
+>
+	<slot />
+
+	<div slot="content">
+		<DropdownMenu.Content
+			class="w-full max-w-[200px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
+			sideOffset={4}
+			side="top"
+			align="end"
+			transition={flyAndScale}
+		>
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				id="chat-share-button"
+				on:click={() => {
+					window.open('https://docs.openwebui.com', '_blank');
+				}}
+			>
+				<QuestionMarkCircle className="size-5" />
+				<div class="flex items-center">{$i18n.t('Documentation')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				id="chat-share-button"
+				on:click={() => {
+					showShortcutsHandler();
+				}}
+			>
+				<Keyboard className="size-5" />
+				<div class="flex items-center">{$i18n.t('Keyboard shortcuts')}</div>
+			</DropdownMenu.Item>
+		</DropdownMenu.Content>
+	</div>
+</Dropdown>

+ 1 - 1
src/lib/components/layout/Navbar.svelte

@@ -67,7 +67,7 @@
 			<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
 				<!-- <div class="md:hidden flex self-center w-[1px] h-5 mx-2 bg-gray-300 dark:bg-stone-700" /> -->
 
-				{#if shareEnabled}
+				{#if shareEnabled && chat && chat.id}
 					<Menu
 						{chat}
 						{shareEnabled}

+ 15 - 1
src/lib/components/layout/Navbar/Menu.svelte

@@ -63,6 +63,13 @@
 		// Revoke the URL to release memory
 		window.URL.revokeObjectURL(url);
 	};
+
+	const downloadJSONExport = async () => {
+		let blob = new Blob([JSON.stringify([chat])], {
+			type: 'application/json'
+		});
+		saveAs(blob, `chat-export-${Date.now()}.json`);
+	};
 </script>
 
 <Dropdown
@@ -131,7 +138,6 @@
 				</svg>
 				<div class="flex items-center">{$i18n.t('Share')}</div>
 			</DropdownMenu.Item>
-
 			<!-- <DropdownMenu.Item
 					class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer"
 					on:click={() => {
@@ -164,6 +170,14 @@
 					transition={flyAndScale}
 					sideOffset={8}
 				>
+					<DropdownMenu.Item
+						class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+						on:click={() => {
+							downloadJSONExport();
+						}}
+					>
+						<div class="flex items-center line-clamp-1">{$i18n.t('Export chat (.json)')}</div>
+					</DropdownMenu.Item>
 					<DropdownMenu.Item
 						class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 						on:click={() => {

+ 59 - 0
src/lib/components/layout/Overlay/AccountPending.svelte

@@ -0,0 +1,59 @@
+<script lang="ts">
+	import { getAdminDetails } from '$lib/apis/auths';
+	import { onMount, tick, getContext } from 'svelte';
+
+	const i18n = getContext('i18n');
+
+	let adminDetails = null;
+
+	onMount(async () => {
+		adminDetails = await getAdminDetails(localStorage.token).catch((err) => {
+			console.error(err);
+			return null;
+		});
+	});
+</script>
+
+<div class="fixed w-full h-full flex z-[999]">
+	<div
+		class="absolute w-full h-full backdrop-blur-lg bg-white/10 dark:bg-gray-900/50 flex justify-center"
+	>
+		<div class="m-auto pb-10 flex flex-col justify-center">
+			<div class="max-w-md">
+				<div class="text-center dark:text-white text-2xl font-medium z-50">
+					Account Activation Pending<br /> Contact Admin for WebUI Access
+				</div>
+
+				<div class=" mt-4 text-center text-sm dark:text-gray-200 w-full">
+					Your account status is currently pending activation.<br /> To access the WebUI, please reach
+					out to the administrator. Admins can manage user statuses from the Admin Panel.
+				</div>
+
+				{#if adminDetails}
+					<div class="mt-4 text-sm font-medium text-center">
+						<div>Admin: {adminDetails.name} ({adminDetails.email})</div>
+					</div>
+				{/if}
+
+				<div class=" mt-6 mx-auto relative group w-fit">
+					<button
+						class="relative z-20 flex px-5 py-2 rounded-full bg-white border border-gray-100 dark:border-none hover:bg-gray-100 text-gray-700 transition font-medium text-sm"
+						on:click={async () => {
+							location.href = '/';
+						}}
+					>
+						{$i18n.t('Check Again')}
+					</button>
+
+					<button
+						class="text-xs text-center w-full mt-2 text-gray-400 underline"
+						on:click={async () => {
+							localStorage.removeItem('token');
+							location.href = '/auth';
+						}}>{$i18n.t('Sign Out')}</button
+					>
+				</div>
+			</div>
+		</div>
+	</div>
+</div>

+ 30 - 2
src/lib/components/layout/Sidebar.svelte

@@ -22,7 +22,8 @@
 		getChatListByTagName,
 		updateChatById,
 		getAllChatTags,
-		archiveChatById
+		archiveChatById,
+		cloneChatById
 	} from '$lib/apis/chats';
 	import { toast } from 'svelte-sonner';
 	import { fade, slide } from 'svelte/transition';
@@ -182,6 +183,18 @@
 		}
 	};
 
+	const cloneChatHandler = async (id) => {
+		const res = await cloneChatById(localStorage.token, id).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+
+		if (res) {
+			goto(`/c/${res.id}`);
+			await chats.set(await getChatList(localStorage.token));
+		}
+	};
+
 	const saveSettings = async (updated) => {
 		await settings.set({ ...$settings, ...updated });
 		await updateUserSettings(localStorage.token, { ui: $settings });
@@ -192,6 +205,10 @@
 		await archiveChatById(localStorage.token, id);
 		await chats.set(await getChatList(localStorage.token));
 	};
+
+	const focusEdit = async (node: HTMLInputElement) => {
+		node.focus();
+	};
 </script>
 
 <ShareChatModal bind:show={showShareChatModal} chatId={shareChatId} />
@@ -476,7 +493,11 @@
 									? 'bg-gray-100 dark:bg-gray-950'
 									: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'}  whitespace-nowrap text-ellipsis"
 							>
-								<input bind:value={chatTitle} class=" bg-transparent w-full outline-none mr-10" />
+								<input
+									use:focusEdit
+									bind:value={chatTitle}
+									class=" bg-transparent w-full outline-none mr-10"
+								/>
 							</div>
 						{:else}
 							<a
@@ -494,6 +515,10 @@
 										showSidebar.set(false);
 									}
 								}}
+								on:dblclick={() => {
+									chatTitle = chat.title;
+									chatTitleEditId = chat.id;
+								}}
 								draggable="false"
 							>
 								<div class=" flex self-center flex-1 w-full">
@@ -601,6 +626,9 @@
 								<div class="flex self-center space-x-1 z-10">
 									<ChatMenu
 										chatId={chat.id}
+										cloneChatHandler={() => {
+											cloneChatHandler(chat.id);
+										}}
 										shareHandler={() => {
 											shareChatId = selectedChatId;
 											showShareChatModal = true;

+ 7 - 1
src/lib/components/layout/Sidebar/ArchivedChatsModal.svelte

@@ -8,7 +8,12 @@
 	const dispatch = createEventDispatcher();
 
 	import Modal from '$lib/components/common/Modal.svelte';
-	import { archiveChatById, deleteChatById, getArchivedChatList } from '$lib/apis/chats';
+	import {
+		archiveChatById,
+		deleteChatById,
+		getAllArchivedChats,
+		getArchivedChatList
+	} from '$lib/apis/chats';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 
 	const i18n = getContext('i18n');
@@ -38,6 +43,7 @@
 	};
 
 	const exportChatsHandler = async () => {
+		const chats = await getAllArchivedChats(localStorage.token);
 		let blob = new Blob([JSON.stringify(chats)], {
 			type: 'application/json'
 		});

+ 20 - 8
src/lib/components/layout/Sidebar/ChatMenu.svelte

@@ -10,10 +10,12 @@
 	import Tags from '$lib/components/chat/Tags.svelte';
 	import Share from '$lib/components/icons/Share.svelte';
 	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
+	import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
 
 	const i18n = getContext('i18n');
 
 	export let shareHandler: Function;
+	export let cloneChatHandler: Function;
 	export let archiveChatHandler: Function;
 	export let renameHandler: Function;
 	export let deleteHandler: Function;
@@ -38,30 +40,30 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[160px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
+			class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
 			sideOffset={-2}
 			side="bottom"
 			align="start"
 			transition={flyAndScale}
 		>
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
-					shareHandler();
+					renameHandler();
 				}}
 			>
-				<Share />
-				<div class="flex items-center">{$i18n.t('Share')}</div>
+				<Pencil strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Rename')}</div>
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
 				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
-					renameHandler();
+					cloneChatHandler();
 				}}
 			>
-				<Pencil strokeWidth="2" />
-				<div class="flex items-center">{$i18n.t('Rename')}</div>
+				<DocumentDuplicate strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Clone')}</div>
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
@@ -74,6 +76,16 @@
 				<div class="flex items-center">{$i18n.t('Archive')}</div>
 			</DropdownMenu.Item>
 
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
+				on:click={() => {
+					shareHandler();
+				}}
+			>
+				<Share />
+				<div class="flex items-center">{$i18n.t('Share')}</div>
+			</DropdownMenu.Item>
+
 			<DropdownMenu.Item
 				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {

+ 34 - 3
src/lib/components/layout/Sidebar/UserMenu.svelte

@@ -1,12 +1,13 @@
 <script lang="ts">
 	import { DropdownMenu } from 'bits-ui';
-	import { createEventDispatcher, getContext } from 'svelte';
+	import { createEventDispatcher, getContext, onMount } from 'svelte';
 
 	import { flyAndScale } from '$lib/utils/transitions';
 	import { goto } from '$app/navigation';
 	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
-	import { showSettings } from '$lib/stores';
+	import { showSettings, activeUserCount, USAGE_POOL } from '$lib/stores';
 	import { fade, slide } from 'svelte/transition';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -107,7 +108,7 @@
 				</button>
 			{/if}
 
-			<hr class=" dark:border-gray-800 my-2 p-0" />
+			<hr class=" dark:border-gray-800 my-1.5 p-0" />
 
 			<button
 				class="flex rounded-md py-2 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition"
@@ -139,6 +140,36 @@
 				<div class=" self-center font-medium">{$i18n.t('Sign Out')}</div>
 			</button>
 
+			{#if $activeUserCount}
+				<hr class=" dark:border-gray-800 my-1.5 p-0" />
+
+				<Tooltip
+					content={$USAGE_POOL && $USAGE_POOL.length > 0
+						? `Running: ${$USAGE_POOL.join(', ')} ✨`
+						: ''}
+				>
+					<div class="flex rounded-md py-1.5 px-3 text-xs gap-2.5 items-center">
+						<div class=" flex items-center">
+							<span class="relative flex size-2">
+								<span
+									class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
+								/>
+								<span class="relative inline-flex rounded-full size-2 bg-green-500" />
+							</span>
+						</div>
+
+						<div class=" ">
+							<span class=" font-medium">
+								{$i18n.t('Active Users')}:
+							</span>
+							<span class=" font-semibold">
+								{$activeUserCount}
+							</span>
+						</div>
+					</div>
+				</Tooltip>
+			{/if}
+
 			<!-- <DropdownMenu.Item class="flex items-center px-3 py-2 text-sm  font-medium">
 				<div class="flex items-center">Profile</div>
 			</DropdownMenu.Item> -->

+ 148 - 77
src/lib/components/workspace/Models.svelte

@@ -1,18 +1,23 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
+	import Sortable from 'sortablejs';
+
 	import fileSaver from 'file-saver';
 	const { saveAs } = fileSaver;
 
-	import { onMount, getContext } from 'svelte';
+	import { onMount, getContext, tick } from 'svelte';
 
-	import { WEBUI_NAME, modelfiles, models, settings, user } from '$lib/stores';
-	import { addNewModel, deleteModelById, getModelInfos } from '$lib/apis/models';
+	import { WEBUI_NAME, mobile, models, settings, user } from '$lib/stores';
+	import { addNewModel, deleteModelById, getModelInfos, updateModelById } from '$lib/apis/models';
 
 	import { deleteModel } from '$lib/apis/ollama';
 	import { goto } from '$app/navigation';
 
 	import { getModels } from '$lib/apis';
 
+	import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
+	import ModelMenu from './Models/ModelMenu.svelte';
+
 	const i18n = getContext('i18n');
 
 	let localModelfiles = [];
@@ -20,6 +25,9 @@
 	let importFiles;
 	let modelsImportInputElement: HTMLInputElement;
 
+	let _models = [];
+
+	let sortable = null;
 	let searchValue = '';
 
 	const deleteModelHandler = async (model) => {
@@ -40,6 +48,7 @@
 		}
 
 		await models.set(await getModels(localStorage.token));
+		_models = $models;
 	};
 
 	const cloneModelHandler = async (model) => {
@@ -74,6 +83,42 @@
 		);
 	};
 
+	const hideModelHandler = async (model) => {
+		let info = model.info;
+
+		if (!info) {
+			info = {
+				id: model.id,
+				name: model.name,
+				meta: {
+					suggestion_prompts: null
+				},
+				params: {}
+			};
+		}
+
+		info.meta = {
+			...info.meta,
+			hidden: !(info?.meta?.hidden ?? false)
+		};
+
+		console.log(info);
+
+		const res = await updateModelById(localStorage.token, info.id, info);
+
+		if (res) {
+			toast.success(
+				$i18n.t(`Model {{name}} is now {{status}}`, {
+					name: info.id,
+					status: info.meta.hidden ? 'hidden' : 'visible'
+				})
+			);
+		}
+
+		await models.set(await getModels(localStorage.token));
+		_models = $models;
+	};
+
 	const downloadModels = async (models) => {
 		let blob = new Blob([JSON.stringify(models)], {
 			type: 'application/json'
@@ -81,13 +126,67 @@
 		saveAs(blob, `models-export-${Date.now()}.json`);
 	};
 
-	onMount(() => {
+	const exportModelHandler = async (model) => {
+		let blob = new Blob([JSON.stringify([model])], {
+			type: 'application/json'
+		});
+		saveAs(blob, `${model.id}-${Date.now()}.json`);
+	};
+
+	const positionChangeHanlder = async () => {
+		// Get the new order of the models
+		const modelIds = Array.from(document.getElementById('model-list').children).map((child) =>
+			child.id.replace('model-item-', '')
+		);
+
+		// Update the position of the models
+		for (const [index, id] of modelIds.entries()) {
+			const model = $models.find((m) => m.id === id);
+			if (model) {
+				let info = model.info;
+
+				if (!info) {
+					info = {
+						id: model.id,
+						name: model.name,
+						meta: {
+							position: index
+						},
+						params: {}
+					};
+				}
+
+				info.meta = {
+					...info.meta,
+					position: index
+				};
+				await updateModelById(localStorage.token, info.id, info);
+			}
+		}
+
+		await tick();
+		await models.set(await getModels(localStorage.token));
+	};
+
+	onMount(async () => {
 		// Legacy code to sync localModelfiles with models
+		_models = $models;
 		localModelfiles = JSON.parse(localStorage.getItem('modelfiles') ?? '[]');
 
 		if (localModelfiles) {
 			console.log(localModelfiles);
 		}
+
+		if (!$mobile) {
+			// SortableJS
+			sortable = new Sortable(document.getElementById('model-list'), {
+				animation: 150,
+				onUpdate: async (event) => {
+					console.log(event);
+					positionChangeHanlder();
+				}
+			});
+		}
 	});
 </script>
 
@@ -165,19 +264,24 @@
 
 <hr class=" dark:border-gray-850" />
 
-<div class=" my-2 mb-5">
-	{#each $models.filter((m) => searchValue === '' || m.name
+<div class=" my-2 mb-5" id="model-list">
+	{#each _models.filter((m) => searchValue === '' || m.name
 				.toLowerCase()
 				.includes(searchValue.toLowerCase())) as model}
 		<div
 			class=" flex space-x-4 cursor-pointer w-full px-3 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
+			id="model-item-{model.id}"
 		>
 			<a
-				class=" flex flex-1 space-x-4 cursor-pointer w-full"
+				class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
 				href={`/?models=${encodeURIComponent(model.id)}`}
 			>
-				<div class=" self-center w-10">
-					<div class=" rounded-full bg-stone-700">
+				<div class=" self-start w-8 pt-0.5">
+					<div
+						class=" rounded-full bg-stone-700 {model?.info?.meta?.hidden ?? false
+							? 'brightness-90 dark:brightness-50'
+							: ''} "
+					>
 						<img
 							src={model?.info?.meta?.profile_image_url ?? '/favicon.png'}
 							alt="modelfile profile"
@@ -186,14 +290,16 @@
 					</div>
 				</div>
 
-				<div class=" flex-1 self-center">
-					<div class=" font-bold line-clamp-1">{model.name}</div>
-					<div class=" text-sm overflow-hidden text-ellipsis line-clamp-1">
+				<div
+					class=" flex-1 self-center {model?.info?.meta?.hidden ?? false ? 'text-gray-500' : ''}"
+				>
+					<div class="  font-bold line-clamp-1">{model.name}</div>
+					<div class=" text-xs overflow-hidden text-ellipsis line-clamp-1">
 						{!!model?.info?.meta?.description ? model?.info?.meta?.description : model.id}
 					</div>
 				</div>
 			</a>
-			<div class="flex flex-row space-x-1 self-center">
+			<div class="flex flex-row gap-0.5 self-center">
 				<a
 					class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
 					type="button"
@@ -215,74 +321,32 @@
 					</svg>
 				</a>
 
-				<button
-					class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
-					type="button"
-					on:click={() => {
+				<ModelMenu
+					{model}
+					shareHandler={() => {
+						shareModelHandler(model);
+					}}
+					cloneHandler={() => {
 						cloneModelHandler(model);
 					}}
-				>
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						fill="none"
-						viewBox="0 0 24 24"
-						stroke-width="1.5"
-						stroke="currentColor"
-						class="w-4 h-4"
-					>
-						<path
-							stroke-linecap="round"
-							stroke-linejoin="round"
-							d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 0 1-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 0 1 1.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 0 0-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 0 1-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 0 0-3.375-3.375h-1.5a1.125 1.125 0 0 1-1.125-1.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H9.75"
-						/>
-					</svg>
-				</button>
-
-				<button
-					class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
-					type="button"
-					on:click={() => {
-						shareModelHandler(model);
+					exportHandler={() => {
+						exportModelHandler(model);
 					}}
-				>
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						fill="none"
-						viewBox="0 0 24 24"
-						stroke-width="1.5"
-						stroke="currentColor"
-						class="w-4 h-4"
-					>
-						<path
-							stroke-linecap="round"
-							stroke-linejoin="round"
-							d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z"
-						/>
-					</svg>
-				</button>
-
-				<button
-					class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
-					type="button"
-					on:click={() => {
+					hideHandler={() => {
+						hideModelHandler(model);
+					}}
+					deleteHandler={() => {
 						deleteModelHandler(model);
 					}}
+					onClose={() => {}}
 				>
-					<svg
-						xmlns="http://www.w3.org/2000/svg"
-						fill="none"
-						viewBox="0 0 24 24"
-						stroke-width="1.5"
-						stroke="currentColor"
-						class="w-4 h-4"
+					<button
+						class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+						type="button"
 					>
-						<path
-							stroke-linecap="round"
-							stroke-linejoin="round"
-							d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
-						/>
-					</svg>
-				</button>
+						<EllipsisHorizontal className="size-5" />
+					</button>
+				</ModelMenu>
 			</div>
 		</div>
 	{/each}
@@ -307,13 +371,20 @@
 
 					for (const model of savedModels) {
 						if (model?.info ?? false) {
-							await addNewModel(localStorage.token, model.info).catch((error) => {
-								return null;
-							});
+							if ($models.find((m) => m.id === model.id)) {
+								await updateModelById(localStorage.token, model.id, model.info).catch((error) => {
+									return null;
+								});
+							} else {
+								await addNewModel(localStorage.token, model.info).catch((error) => {
+									return null;
+								});
+							}
 						}
 					}
 
 					await models.set(await getModels(localStorage.token));
+					_models = $models;
 				};
 
 				reader.readAsText(importFiles[0]);

+ 144 - 0
src/lib/components/workspace/Models/ModelMenu.svelte

@@ -0,0 +1,144 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { flyAndScale } from '$lib/utils/transitions';
+	import { getContext } from 'svelte';
+
+	import Dropdown from '$lib/components/common/Dropdown.svelte';
+	import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
+	import Pencil from '$lib/components/icons/Pencil.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Tags from '$lib/components/chat/Tags.svelte';
+	import Share from '$lib/components/icons/Share.svelte';
+	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
+	import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
+	import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
+
+	const i18n = getContext('i18n');
+
+	export let model;
+
+	export let shareHandler: Function;
+	export let cloneHandler: Function;
+	export let exportHandler: Function;
+
+	export let hideHandler: Function;
+	export let deleteHandler: Function;
+	export let onClose: Function;
+
+	let show = false;
+</script>
+
+<Dropdown
+	bind:show
+	on:change={(e) => {
+		if (e.detail === false) {
+			onClose();
+		}
+	}}
+>
+	<Tooltip content={$i18n.t('More')}>
+		<slot />
+	</Tooltip>
+
+	<div slot="content">
+		<DropdownMenu.Content
+			class="w-full max-w-[160px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow"
+			sideOffset={-2}
+			side="bottom"
+			align="start"
+			transition={flyAndScale}
+		>
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
+				on:click={() => {
+					shareHandler();
+				}}
+			>
+				<Share />
+				<div class="flex items-center">{$i18n.t('Share')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					cloneHandler();
+				}}
+			>
+				<DocumentDuplicate />
+
+				<div class="flex items-center">{$i18n.t('Clone')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					exportHandler();
+				}}
+			>
+				<ArrowDownTray />
+
+				<div class="flex items-center">{$i18n.t('Export')}</div>
+			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					hideHandler();
+				}}
+			>
+				{#if model?.info?.meta?.hidden ?? false}
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						fill="none"
+						viewBox="0 0 24 24"
+						stroke-width="1.5"
+						stroke="currentColor"
+						class="size-4"
+					>
+						<path
+							stroke-linecap="round"
+							stroke-linejoin="round"
+							d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88"
+						/>
+					</svg>
+				{:else}
+					<svg
+						xmlns="http://www.w3.org/2000/svg"
+						fill="none"
+						viewBox="0 0 24 24"
+						stroke-width="1.5"
+						stroke="currentColor"
+						class="size-4"
+					>
+						<path
+							stroke-linecap="round"
+							stroke-linejoin="round"
+							d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
+						/>
+						<path
+							stroke-linecap="round"
+							stroke-linejoin="round"
+							d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
+						/>
+					</svg>
+				{/if}
+
+				<div class="flex items-center">
+					{$i18n.t(model?.info?.meta?.hidden ?? false ? 'Show Model' : 'Hide Model')}
+				</div>
+			</DropdownMenu.Item>
+
+			<hr class="border-gray-100 dark:border-gray-800 my-1" />
+
+			<DropdownMenu.Item
+				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					deleteHandler();
+				}}
+			>
+				<GarbageBin strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Delete')}</div>
+			</DropdownMenu.Item>
+		</DropdownMenu.Content>
+	</div>
+</Dropdown>

+ 1 - 13
src/lib/components/workspace/Playground.svelte

@@ -8,7 +8,7 @@
 	import { OLLAMA_API_BASE_URL, OPENAI_API_BASE_URL, WEBUI_API_BASE_URL } from '$lib/constants';
 	import { WEBUI_NAME, config, user, models, settings } from '$lib/stores';
 
-	import { cancelOllamaRequest, generateChatCompletion } from '$lib/apis/ollama';
+	import { generateChatCompletion } from '$lib/apis/ollama';
 	import { generateOpenAIChatCompletion } from '$lib/apis/openai';
 
 	import { splitStream } from '$lib/utils';
@@ -24,7 +24,6 @@
 	let selectedModelId = '';
 
 	let loading = false;
-	let currentRequestId = null;
 	let stopResponseFlag = false;
 
 	let messagesContainerElement: HTMLDivElement;
@@ -46,14 +45,6 @@
 		}
 	};
 
-	// const cancelHandler = async () => {
-	// 	if (currentRequestId) {
-	// 		const res = await cancelOllamaRequest(localStorage.token, currentRequestId);
-	// 		currentRequestId = null;
-	// 		loading = false;
-	// 	}
-	// };
-
 	const stopResponse = () => {
 		stopResponseFlag = true;
 		console.log('stopResponse');
@@ -171,8 +162,6 @@
 					if (stopResponseFlag) {
 						controller.abort('User: Stop Response');
 					}
-
-					currentRequestId = null;
 					break;
 				}
 
@@ -229,7 +218,6 @@
 
 			loading = false;
 			stopResponseFlag = false;
-			currentRequestId = null;
 		}
 	};
 

+ 2 - 1
src/lib/constants.ts

@@ -2,8 +2,9 @@ import { browser, dev } from '$app/environment';
 // import { version } from '../../package.json';
 
 export const APP_NAME = 'Open WebUI';
-export const WEBUI_BASE_URL = browser ? (dev ? `http://${location.hostname}:8080` : ``) : ``;
 
+export const WEBUI_HOSTNAME = browser ? (dev ? `${location.hostname}:8080` : ``) : '';
+export const WEBUI_BASE_URL = browser ? (dev ? `http://${WEBUI_HOSTNAME}` : ``) : ``;
 export const WEBUI_API_BASE_URL = `${WEBUI_BASE_URL}/api/v1`;
 
 export const OLLAMA_API_BASE_URL = `${WEBUI_BASE_URL}/ollama`;

+ 91 - 39
src/lib/i18n/locales/ar-BH/translation.json

@@ -3,18 +3,20 @@
 	"(Beta)": "(تجريبي)",
 	"(e.g. `sh webui.sh --api`)": "( `sh webui.sh --api`مثال)",
 	"(latest)": "(الأخير)",
-	"{{ models }}": "",
-	"{{ owner }}: You cannot delete a base model": "",
+	"{{ models }}": "{{ نماذج }}",
+	"{{ owner }}: You cannot delete a base model": "{{ المالك }}: لا يمكنك حذف نموذج أساسي",
 	"{{modelName}} is thinking...": "{{modelName}} ...يفكر",
 	"{{user}}'s Chats": "دردشات {{user}}",
 	"{{webUIName}} Backend Required": "{{webUIName}} مطلوب",
+	"A task model is used when performing tasks such as generating titles for chats and web search queries": "يتم استخدام نموذج المهمة عند تنفيذ مهام مثل إنشاء عناوين للدردشات واستعلامات بحث الويب",
 	"a user": "مستخدم",
 	"About": "عن",
 	"Account": "الحساب",
 	"Accurate information": "معلومات دقيقة",
+	"Active Users": "",
 	"Add": "أضف",
-	"Add a model id": "",
-	"Add a short description about what this model does": "",
+	"Add a model id": "إضافة معرف نموذج",
+	"Add a short description about what this model does": "أضف وصفا موجزا حول ما يفعله هذا النموذج",
 	"Add a short title for this prompt": "أضف عنوانًا قصيرًا لبداء المحادثة",
 	"Add a tag": "أضافة تاق",
 	"Add custom prompt": "أضافة مطالبة مخصصه",
@@ -30,12 +32,13 @@
 	"Admin Panel": "لوحة التحكم",
 	"Admin Settings": "اعدادات المشرف",
 	"Advanced Parameters": "التعليمات المتقدمة",
-	"Advanced Params": "",
+	"Advanced Params": "المعلمات المتقدمة",
 	"all": "الكل",
 	"All Documents": "جميع الملفات",
 	"All Users": "جميع المستخدمين",
 	"Allow": "يسمح",
 	"Allow Chat Deletion": "يستطيع حذف المحادثات",
+	"Allow non-local voices": "",
 	"alphanumeric characters and hyphens": "الأحرف الأبجدية الرقمية والواصلات",
 	"Already have an account?": "هل تملك حساب ؟",
 	"an assistant": "مساعد",
@@ -47,7 +50,7 @@
 	"API keys": "مفاتيح واجهة برمجة التطبيقات",
 	"April": "أبريل",
 	"Archive": "الأرشيف",
-	"Archive All Chats": "",
+	"Archive All Chats": "أرشفة جميع الدردشات",
 	"Archived Chats": "الأرشيف المحادثات",
 	"are allowed - Activate this command by typing": "مسموح - قم بتنشيط هذا الأمر عن طريق الكتابة",
 	"Are you sure?": "هل أنت متأكد ؟",
@@ -62,13 +65,14 @@
 	"available!": "متاح",
 	"Back": "خلف",
 	"Bad Response": "استجابة خطاء",
-	"Banners": "",
-	"Base Model (From)": "",
+	"Banners": "لافتات",
+	"Base Model (From)": "النموذج الأساسي (من)",
 	"before": "قبل",
 	"Being lazy": "كون كسول",
+	"Brave Search API Key": "مفتاح واجهة برمجة تطبيقات البحث الشجاع",
 	"Bypass SSL verification for Websites": "تجاوز التحقق من SSL للموقع",
 	"Cancel": "اللغاء",
-	"Capabilities": "",
+	"Capabilities": "قدرات",
 	"Change Password": "تغير الباسورد",
 	"Chat": "المحادثة",
 	"Chat Bubble UI": "UI الدردشة",
@@ -91,17 +95,20 @@
 	"Click here to select documents.": "انقر هنا لاختيار المستندات",
 	"click here.": "أضغط هنا",
 	"Click on the user role button to change a user's role.": "أضغط على أسم الصلاحيات لتغيرها للمستخدم",
+	"Clone": "استنساخ",
 	"Close": "أغلق",
 	"Collection": "مجموعة",
 	"ComfyUI": "ComfyUI",
 	"ComfyUI Base URL": "ComfyUI الرابط الافتراضي",
 	"ComfyUI Base URL is required.": "ComfyUI الرابط مطلوب",
 	"Command": "الأوامر",
+	"Concurrent Requests": "الطلبات المتزامنة",
 	"Confirm Password": "تأكيد كلمة المرور",
 	"Connections": "اتصالات",
 	"Content": "الاتصال",
 	"Context Length": "طول السياق",
 	"Continue Response": "متابعة الرد",
+	"Continue with {{provider}}": "",
 	"Conversation Mode": "وضع المحادثة",
 	"Copied shared chat URL to clipboard!": "تم نسخ عنوان URL للدردشة المشتركة إلى الحافظة",
 	"Copy": "نسخ",
@@ -110,7 +117,7 @@
 	"Copy Link": "أنسخ الرابط",
 	"Copying to clipboard was successful!": "تم النسخ إلى الحافظة بنجاح",
 	"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "قم بإنشاء عبارة موجزة مكونة من 3-5 كلمات كرأس للاستعلام التالي، مع الالتزام الصارم بالحد الأقصى لعدد الكلمات الذي يتراوح بين 3-5 كلمات وتجنب استخدام الكلمة 'عنوان':",
-	"Create a model": "",
+	"Create a model": "إنشاء نموذج",
 	"Create Account": "إنشاء حساب",
 	"Create new key": "عمل مفتاح جديد",
 	"Create new secret key": "عمل سر جديد",
@@ -119,7 +126,7 @@
 	"Current Model": "الموديل المختار",
 	"Current Password": "كلمة السر الحالية",
 	"Custom": "مخصص",
-	"Customize models for a specific purpose": "",
+	"Customize models for a specific purpose": "تخصيص النماذج لغرض معين",
 	"Dark": "مظلم",
 	"Database": "قاعدة البيانات",
 	"December": "ديسمبر",
@@ -127,29 +134,30 @@
 	"Default (Automatic1111)": "(Automatic1111) الإفتراضي",
 	"Default (SentenceTransformers)": "(SentenceTransformers) الإفتراضي",
 	"Default (Web API)": "(Web API) الإفتراضي",
+	"Default Model": "النموذج الافتراضي",
 	"Default model updated": "الإفتراضي تحديث الموديل",
 	"Default Prompt Suggestions": "الإفتراضي Prompt الاقتراحات",
 	"Default User Role": "الإفتراضي صلاحيات المستخدم",
 	"delete": "حذف",
 	"Delete": "حذف",
 	"Delete a model": "حذف الموديل",
-	"Delete All Chats": "",
+	"Delete All Chats": "حذف جميع الدردشات",
 	"Delete chat": "حذف المحادثه",
 	"Delete Chat": "حذف المحادثه.",
 	"delete this link": "أحذف هذا الرابط",
 	"Delete User": "حذف المستخدم",
 	"Deleted {{deleteModelTag}}": "{{deleteModelTag}} حذف",
-	"Deleted {{name}}": "",
+	"Deleted {{name}}": "حذف {{name}}",
 	"Description": "وصف",
 	"Didn't fully follow instructions": "لم أتبع التعليمات بشكل كامل",
-	"Disabled": "تعطيل",
-	"Discover a model": "",
+	"Discover a model": "اكتشف نموذجا",
 	"Discover a prompt": "اكتشاف موجه",
 	"Discover, download, and explore custom prompts": "اكتشاف وتنزيل واستكشاف المطالبات المخصصة",
 	"Discover, download, and explore model presets": "اكتشاف وتنزيل واستكشاف الإعدادات المسبقة للنموذج",
 	"Display the username instead of You in the Chat": "اعرض اسم المستخدم بدلاً منك في الدردشة",
 	"Document": "المستند",
 	"Document Settings": "أعدادات المستند",
+	"Documentation": "",
 	"Documents": "مستندات",
 	"does not make any external connections, and your data stays securely on your locally hosted server.": "لا يجري أي اتصالات خارجية، وتظل بياناتك آمنة على الخادم المستضاف محليًا.",
 	"Don't Allow": "لا تسمح بذلك",
@@ -164,22 +172,31 @@
 	"Edit Doc": "تعديل الملف",
 	"Edit User": "تعديل المستخدم",
 	"Email": "البريد",
+	"Embedding Batch Size": "",
 	"Embedding Model": "نموذج التضمين",
 	"Embedding Model Engine": "تضمين محرك النموذج",
 	"Embedding model set to \"{{embedding_model}}\"": "تم تعيين نموذج التضمين على \"{{embedding_model}}\"",
 	"Enable Chat History": "تمكين سجل الدردشة",
+	"Enable Community Sharing": "تمكين مشاركة المجتمع",
 	"Enable New Sign Ups": "تفعيل عمليات التسجيل الجديدة",
-	"Enabled": "تفعيل",
+	"Enable Web Search": "تمكين بحث الويب",
 	"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "تأكد من أن ملف CSV الخاص بك يتضمن 4 أعمدة بهذا الترتيب: Name, Email, Password, Role.",
 	"Enter {{role}} message here": "أدخل رسالة {{role}} هنا",
 	"Enter a detail about yourself for your LLMs to recall": "ادخل معلومات عنك تريد أن يتذكرها الموديل",
+	"Enter Brave Search API Key": "أدخل مفتاح واجهة برمجة تطبيقات البحث الشجاع",
 	"Enter Chunk Overlap": "أدخل الChunk Overlap",
 	"Enter Chunk Size": "أدخل Chunk الحجم",
+	"Enter Github Raw URL": "أدخل عنوان URL ل Github Raw",
+	"Enter Google PSE API Key": "أدخل مفتاح واجهة برمجة تطبيقات PSE من Google",
+	"Enter Google PSE Engine Id": "أدخل معرف محرك PSE من Google",
 	"Enter Image Size (e.g. 512x512)": "(e.g. 512x512) أدخل حجم الصورة ",
 	"Enter language codes": "أدخل كود اللغة",
 	"Enter model tag (e.g. {{modelTag}})": "(e.g. {{modelTag}}) أدخل الموديل تاق",
 	"Enter Number of Steps (e.g. 50)": "(e.g. 50) أدخل عدد الخطوات",
 	"Enter Score": "أدخل النتيجة",
+	"Enter Searxng Query URL": "أدخل عنوان URL لاستعلام Searxng",
+	"Enter Serper API Key": "أدخل مفتاح واجهة برمجة تطبيقات Serper",
+	"Enter Serpstack API Key": "أدخل مفتاح واجهة برمجة تطبيقات Serpstack",
 	"Enter stop sequence": "أدخل تسلسل التوقف",
 	"Enter Top K": "أدخل Top K",
 	"Enter URL (e.g. http://127.0.0.1:7860/)": "الرابط (e.g. http://127.0.0.1:7860/)",
@@ -188,15 +205,18 @@
 	"Enter Your Full Name": "أدخل الاسم كامل",
 	"Enter Your Password": "ادخل كلمة المرور",
 	"Enter Your Role": "أدخل الصلاحيات",
-	"Error": "",
+	"Error": "خطأ",
 	"Experimental": "تجريبي",
+	"Export": "تصدير",
 	"Export All Chats (All Users)": "تصدير جميع الدردشات (جميع المستخدمين)",
+	"Export chat (.json)": "",
 	"Export Chats": "تصدير جميع الدردشات",
 	"Export Documents Mapping": "تصدير وثائق الخرائط",
-	"Export Models": "",
+	"Export Models": "نماذج التصدير",
 	"Export Prompts": "مطالبات التصدير",
 	"Failed to create API Key.": "فشل في إنشاء مفتاح API.",
 	"Failed to read clipboard contents": "فشل في قراءة محتويات الحافظة",
+	"Failed to update settings": "",
 	"February": "فبراير",
 	"Feel free to add specific details": "لا تتردد في إضافة تفاصيل محددة",
 	"File Mode": "وضع الملف",
@@ -206,12 +226,14 @@
 	"Focus chat input": "التركيز على إدخال الدردشة",
 	"Followed instructions perfectly": "اتبعت التعليمات على أكمل وجه",
 	"Format your variables using square brackets like this:": "قم بتنسيق المتغيرات الخاصة بك باستخدام الأقواس المربعة مثل هذا:",
-	"Frequencey Penalty": "",
-	"Full Screen Mode": "وضع ملء الشاشة",
+	"Frequency Penalty": "عقوبة التردد",
 	"General": "عام",
 	"General Settings": "الاعدادات العامة",
+	"Generating search query": "إنشاء استعلام بحث",
 	"Generation Info": "معلومات الجيل",
 	"Good Response": "استجابة جيدة",
+	"Google PSE API Key": "مفتاح واجهة برمجة تطبيقات PSE من Google",
+	"Google PSE Engine Id": "معرف محرك PSE من Google",
 	"h:mm a": "الساعة:الدقائق صباحا/مساء",
 	"has no conversations.": "ليس لديه محادثات.",
 	"Hello, {{name}}": " {{name}} مرحبا",
@@ -225,17 +247,18 @@
 	"Images": "الصور",
 	"Import Chats": "استيراد الدردشات",
 	"Import Documents Mapping": "استيراد خرائط المستندات",
-	"Import Models": "",
+	"Import Models": "استيراد النماذج",
 	"Import Prompts": "مطالبات الاستيراد",
 	"Include `--api` flag when running stable-diffusion-webui": "قم بتضمين علامة `-api` عند تشغيل Stable-diffusion-webui",
-	"Info": "",
+	"Info": "معلومات",
 	"Input commands": "إدخال الأوامر",
+	"Install from Github URL": "التثبيت من عنوان URL لجيثب",
 	"Interface": "واجهه المستخدم",
 	"Invalid Tag": "تاق غير صالحة",
 	"January": "يناير",
 	"join our Discord for help.": "انضم إلى Discord للحصول على المساعدة.",
 	"JSON": "JSON",
-	"JSON Preview": "",
+	"JSON Preview": "معاينة JSON",
 	"July": "يوليو",
 	"June": "يونيو",
 	"JWT Expiration": "JWT تجريبي",
@@ -252,11 +275,12 @@
 	"Make sure to enclose them with": "تأكد من إرفاقها",
 	"Manage Models": "إدارة النماذج",
 	"Manage Ollama Models": "Ollama إدارة موديلات ",
+	"Manage Pipelines": "إدارة خطوط الأنابيب",
 	"March": "مارس",
-	"Max Tokens (num_predict)": "",
+	"Max Tokens (num_predict)": "ماكس توكنز (num_predict)",
 	"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "يمكن تنزيل 3 نماذج كحد أقصى في وقت واحد. الرجاء معاودة المحاولة في وقت لاحق.",
 	"May": "مايو",
-	"Memories accessible by LLMs will be shown here.": "",
+	"Memories accessible by LLMs will be shown here.": "سيتم عرض الذكريات التي يمكن الوصول إليها بواسطة LLMs هنا.",
 	"Memory": "الذاكرة",
 	"Messages you send after creating your link won't be shared. Users with the URL will be able to view the shared chat.": "لن تتم مشاركة الرسائل التي ترسلها بعد إنشاء الرابط الخاص بك. سيتمكن المستخدمون الذين لديهم عنوان URL من عرض الدردشة المشتركة",
 	"Minimum Score": "الحد الأدنى من النقاط",
@@ -268,11 +292,12 @@
 	"Model '{{modelName}}' has been successfully downloaded.": "تم تحميل النموذج '{{modelName}}' بنجاح",
 	"Model '{{modelTag}}' is already in queue for downloading.": "النموذج '{{modelTag}}' موجود بالفعل في قائمة الانتظار للتحميل",
 	"Model {{modelId}} not found": "لم يتم العثور على النموذج {{modelId}}.",
-	"Model {{modelName}} is not vision capable": "",
+	"Model {{modelName}} is not vision capable": "نموذج {{modelName}} غير قادر على الرؤية",
+	"Model {{name}} is now {{status}}": "نموذج {{name}} هو الآن {{status}}",
 	"Model filesystem path detected. Model shortname is required for update, cannot continue.": "تم اكتشاف مسار نظام الملفات النموذجي. الاسم المختصر للنموذج مطلوب للتحديث، ولا يمكن الاستمرار.",
-	"Model ID": "",
+	"Model ID": "رقم الموديل",
 	"Model not selected": "لم تختار موديل",
-	"Model Params": "",
+	"Model Params": "معلمات النموذج",
 	"Model Whitelisting": "القائمة البيضاء للموديل",
 	"Model(s) Whitelisted": "القائمة البيضاء الموديل",
 	"Modelfile Content": "محتوى الملف النموذجي",
@@ -280,21 +305,25 @@
 	"More": "المزيد",
 	"Name": "الأسم",
 	"Name Tag": "أسم التاق",
-	"Name your model": "",
+	"Name your model": "قم بتسمية النموذج الخاص بك",
 	"New Chat": "دردشة جديدة",
 	"New Password": "كلمة المرور الجديدة",
 	"No results found": "لا توجد نتايج",
+	"No search query generated": "لم يتم إنشاء استعلام بحث",
 	"No source available": "لا يوجد مصدر متاح",
+	"None": "اي",
 	"Not factually correct": "ليس صحيحا من حيث الواقع",
 	"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "ملاحظة: إذا قمت بتعيين الحد الأدنى من النقاط، فلن يؤدي البحث إلا إلى إرجاع المستندات التي لها نقاط أكبر من أو تساوي الحد الأدنى من النقاط.",
 	"Notifications": "إشعارات",
 	"November": "نوفمبر",
+	"num_thread (Ollama)": "num_thread (أولاما)",
 	"October": "اكتوبر",
 	"Off": "أغلاق",
 	"Okay, Let's Go!": "حسنا دعنا نذهب!",
 	"OLED Dark": "OLED داكن",
 	"Ollama": "Ollama",
-	"Ollama API": "",
+	"Ollama API": "أولاما API",
+	"Ollama API disabled": "أولاما API معطلة",
 	"Ollama Version": "Ollama الاصدار",
 	"On": "تشغيل",
 	"Only": "فقط",
@@ -319,6 +348,8 @@
 	"pending": "قيد الانتظار",
 	"Permission denied when accessing microphone: {{error}}": "{{error}} تم رفض الإذن عند الوصول إلى الميكروفون ",
 	"Personalization": "التخصيص",
+	"Pipelines": "خطوط الانابيب",
+	"Pipelines Valves": "صمامات خطوط الأنابيب",
 	"Plain text (.txt)": "نص عادي (.txt)",
 	"Playground": "مكان التجربة",
 	"Positive attitude": "موقف ايجابي",
@@ -348,6 +379,7 @@
 	"Reranking Model": "إعادة تقييم النموذج",
 	"Reranking model disabled": "تم تعطيل نموذج إعادة الترتيب",
 	"Reranking model set to \"{{reranking_model}}\"": "تم ضبط نموذج إعادة الترتيب على \"{{reranking_model}}\"",
+	"Reset Upload Directory": "",
 	"Reset Vector Storage": "إعادة تعيين تخزين المتجهات",
 	"Response AutoCopy to Clipboard": "النسخ التلقائي للاستجابة إلى الحافظة",
 	"Role": "منصب",
@@ -363,23 +395,36 @@
 	"Scan for documents from {{path}}": "{{path}} مسح على الملفات من",
 	"Search": "البحث",
 	"Search a model": "البحث عن موديل",
-	"Search Chats": "",
+	"Search Chats": "البحث في الدردشات",
 	"Search Documents": "البحث المستندات",
-	"Search Models": "",
+	"Search Models": "نماذج البحث",
 	"Search Prompts": "أبحث حث",
+	"Search Result Count": "عدد نتائج البحث",
+	"Searched {{count}} sites_zero": "تم البحث في {{count}} sites_zero",
+	"Searched {{count}} sites_one": "تم البحث في {{count}} sites_one",
+	"Searched {{count}} sites_two": "تم البحث في {{count}} sites_two",
+	"Searched {{count}} sites_few": "تم البحث في {{count}} sites_few",
+	"Searched {{count}} sites_many": "تم البحث في {{count}} sites_many",
+	"Searched {{count}} sites_other": "تم البحث في {{count}} sites_other",
+	"Searching the web for '{{searchQuery}}'": "البحث في الويب عن \"{{searchQuery}}\"",
+	"Searxng Query URL": "عنوان URL لاستعلام Searxng",
 	"See readme.md for instructions": "readme.md للحصول على التعليمات",
 	"See what's new": "ما الجديد",
 	"Seed": "Seed",
-	"Select a base model": "",
+	"Select a base model": "حدد نموذجا أساسيا",
 	"Select a mode": "أختار موديل",
 	"Select a model": "أختار الموديل",
+	"Select a pipeline": "حدد مسارا",
+	"Select a pipeline url": "حدد عنوان URL لخط الأنابيب",
 	"Select an Ollama instance": "أختار سيرفر ",
 	"Select model": " أختار موديل",
-	"Selected model(s) do not support image inputs": "",
+	"Selected model(s) do not support image inputs": "النموذج (النماذج) المحددة لا تدعم مدخلات الصور",
 	"Send": "تم",
 	"Send a Message": "يُرجى إدخال طلبك هنا",
 	"Send message": "يُرجى إدخال طلبك هنا.",
 	"September": "سبتمبر",
+	"Serper API Key": "مفتاح واجهة برمجة تطبيقات سيربر",
+	"Serpstack API Key": "مفتاح واجهة برمجة تطبيقات Serpstack",
 	"Server connection verified": "تم التحقق من اتصال الخادم",
 	"Set as default": "الافتراضي",
 	"Set Default Model": "تفعيد الموديل الافتراضي",
@@ -388,15 +433,17 @@
 	"Set Model": "ضبط النموذج",
 	"Set reranking model (e.g. {{model}})": "ضبط نموذج إعادة الترتيب (على سبيل المثال: {{model}})",
 	"Set Steps": "ضبط الخطوات",
-	"Set Title Auto-Generation Model": "قم بتعيين نموذج إنشاء العنوان تلقائيًا",
+	"Set Task Model": "تعيين نموذج المهمة",
 	"Set Voice": "ضبط الصوت",
 	"Settings": "الاعدادات",
 	"Settings saved successfully!": "تم حفظ الاعدادات بنجاح",
+	"Settings updated successfully": "",
 	"Share": "كشاركة",
 	"Share Chat": "مشاركة الدردشة",
 	"Share to OpenWebUI Community": "OpenWebUI شارك في مجتمع",
 	"short-summary": "ملخص قصير",
 	"Show": "عرض",
+	"Show Admin Details in Account Pending Overlay": "",
 	"Show shortcuts": "إظهار الاختصارات",
 	"Showcased creativity": "أظهر الإبداع",
 	"sidebar": "الشريط الجانبي",
@@ -447,19 +494,21 @@
 	"Top P": "Top P",
 	"Trouble accessing Ollama?": "هل تواجه مشكلة في الوصول",
 	"TTS Settings": "TTS اعدادات",
-	"Type": "",
+	"Type": "نوع",
 	"Type Hugging Face Resolve (Download) URL": "اكتب عنوان URL لحل مشكلة الوجه (تنزيل).",
 	"Uh-oh! There was an issue connecting to {{provider}}.": "{{provider}}خطاء أوه! حدثت مشكلة في الاتصال بـ ",
 	"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "نوع ملف غير معروف '{{file_type}}', ولكن القبول والتعامل كنص عادي ",
 	"Update and Copy Link": "تحديث ونسخ الرابط",
 	"Update password": "تحديث كلمة المرور",
 	"Upload a GGUF model": "GGUF رفع موديل نوع",
-	"Upload files": "رفع الملفات",
+	"Upload Files": "تحميل الملفات",
 	"Upload Progress": "جاري التحميل",
 	"URL Mode": "رابط الموديل",
 	"Use '#' in the prompt input to load and select your documents.": "أستخدم '#' في المحادثة لربطهامن المستندات",
 	"Use Gravatar": "Gravatar أستخدم",
 	"Use Initials": "Initials أستخدم",
+	"use_mlock (Ollama)": "use_mlock (أولاما)",
+	"use_mmap (Ollama)": "use_mmap (أولاما)",
 	"user": "مستخدم",
 	"User Permissions": "صلاحيات المستخدم",
 	"Users": "المستخدمين",
@@ -468,11 +517,13 @@
 	"variable": "المتغير",
 	"variable to have them replaced with clipboard content.": "متغير لاستبدالها بمحتوى الحافظة.",
 	"Version": "إصدار",
-	"Warning": "",
+	"Warning": "تحذير",
 	"Warning: If you update or change your embedding model, you will need to re-import all documents.": "تحذير: إذا قمت بتحديث أو تغيير نموذج التضمين الخاص بك، فستحتاج إلى إعادة استيراد كافة المستندات.",
 	"Web": "Web",
 	"Web Loader Settings": "Web تحميل اعدادات",
 	"Web Params": "Web تحميل اعدادات",
+	"Web Search": "بحث الويب",
+	"Web Search Engine": "محرك بحث الويب",
 	"Webhook URL": "Webhook الرابط",
 	"WebUI Add-ons": "WebUI الأضافات",
 	"WebUI Settings": "WebUI اعدادات",
@@ -480,12 +531,13 @@
 	"What’s New in": "ما هو الجديد",
 	"When history is turned off, new chats on this browser won't appear in your history on any of your devices.": "عند إيقاف تشغيل السجل، لن تظهر الدردشات الجديدة على هذا المتصفح في سجلك على أي من أجهزتك.",
 	"Whisper (Local)": "Whisper (Local)",
+	"Widescreen Mode": "",
 	"Workspace": "مساحة العمل",
 	"Write a prompt suggestion (e.g. Who are you?)": "اكتب اقتراحًا سريعًا (على سبيل المثال، من أنت؟)",
 	"Write a summary in 50 words that summarizes [topic or keyword].": "اكتب ملخصًا في 50 كلمة يلخص [الموضوع أو الكلمة الرئيسية]",
 	"Yesterday": "أمس",
 	"You": "انت",
-	"You cannot clone a base model": "",
+	"You cannot clone a base model": "لا يمكنك استنساخ نموذج أساسي",
 	"You have no archived conversations.": "لا تملك محادثات محفوظه",
 	"You have shared this chat": "تم مشاركة هذه المحادثة",
 	"You're a helpful assistant.": "مساعدك المفيد هنا",

+ 86 - 38
src/lib/i18n/locales/bg-BG/translation.json

@@ -3,18 +3,20 @@
 	"(Beta)": "(Бета)",
 	"(e.g. `sh webui.sh --api`)": "(например `sh webui.sh --api`)",
 	"(latest)": "(последна)",
-	"{{ models }}": "",
-	"{{ owner }}: You cannot delete a base model": "",
+	"{{ models }}": "{{ модели }}",
+	"{{ owner }}: You cannot delete a base model": "{{ owner }}: Не можете да изтриете базов модел",
 	"{{modelName}} is thinking...": "{{modelName}} мисли ...",
 	"{{user}}'s Chats": "{{user}}'s чатове",
 	"{{webUIName}} Backend Required": "{{webUIName}} Изисква се Бекенд",
+	"A task model is used when performing tasks such as generating titles for chats and web search queries": "Моделът на задачите се използва при изпълнение на задачи като генериране на заглавия за чатове и заявки за търсене в мрежата",
 	"a user": "потребител",
 	"About": "Относно",
 	"Account": "Акаунт",
 	"Accurate information": "Точни информация",
+	"Active Users": "",
 	"Add": "Добавяне",
-	"Add a model id": "",
-	"Add a short description about what this model does": "",
+	"Add a model id": "Добавяне на ИД на модел",
+	"Add a short description about what this model does": "Добавете кратко описание за това какво прави този модел",
 	"Add a short title for this prompt": "Добавяне на кратко заглавие за този промпт",
 	"Add a tag": "Добавяне на таг",
 	"Add custom prompt": "Добавяне на собствен промпт",
@@ -30,12 +32,13 @@
 	"Admin Panel": "Панел на Администратор",
 	"Admin Settings": "Настройки на Администратор",
 	"Advanced Parameters": "Разширени Параметри",
-	"Advanced Params": "",
+	"Advanced Params": "Разширени параметри",
 	"all": "всички",
 	"All Documents": "Всички Документи",
 	"All Users": "Всички Потребители",
 	"Allow": "Позволи",
 	"Allow Chat Deletion": "Позволи Изтриване на Чат",
+	"Allow non-local voices": "",
 	"alphanumeric characters and hyphens": "алфанумерични знаци и тире",
 	"Already have an account?": "Вече имате акаунт? ",
 	"an assistant": "асистент",
@@ -47,7 +50,7 @@
 	"API keys": "API Ключове",
 	"April": "Април",
 	"Archive": "Архивирани Чатове",
-	"Archive All Chats": "",
+	"Archive All Chats": "Архив Всички чатове",
 	"Archived Chats": "Архивирани Чатове",
 	"are allowed - Activate this command by typing": "са разрешени - Активирайте тази команда чрез въвеждане",
 	"Are you sure?": "Сигурни ли сте?",
@@ -62,13 +65,14 @@
 	"available!": "наличен!",
 	"Back": "Назад",
 	"Bad Response": "Невалиден отговор от API",
-	"Banners": "",
-	"Base Model (From)": "",
+	"Banners": "Банери",
+	"Base Model (From)": "Базов модел (от)",
 	"before": "преди",
 	"Being lazy": "Да бъдеш мързелив",
+	"Brave Search API Key": "Смел ключ за API за търсене",
 	"Bypass SSL verification for Websites": "Изключване на SSL проверката за сайтове",
 	"Cancel": "Отказ",
-	"Capabilities": "",
+	"Capabilities": "Възможности",
 	"Change Password": "Промяна на Парола",
 	"Chat": "Чат",
 	"Chat Bubble UI": "UI за чат бублон",
@@ -91,17 +95,20 @@
 	"Click here to select documents.": "Натиснете тук, за да изберете документи.",
 	"click here.": "натиснете тук.",
 	"Click on the user role button to change a user's role.": "Натиснете върху бутона за промяна на ролята на потребителя.",
+	"Clone": "Клонинг",
 	"Close": "Затвори",
 	"Collection": "Колекция",
 	"ComfyUI": "ComfyUI",
 	"ComfyUI Base URL": "ComfyUI Base URL",
 	"ComfyUI Base URL is required.": "ComfyUI Base URL е задължително.",
 	"Command": "Команда",
+	"Concurrent Requests": "Едновременни искания",
 	"Confirm Password": "Потвърди Парола",
 	"Connections": "Връзки",
 	"Content": "Съдържание",
 	"Context Length": "Дължина на Контекста",
 	"Continue Response": "Продължи отговора",
+	"Continue with {{provider}}": "",
 	"Conversation Mode": "Режим на Чат",
 	"Copied shared chat URL to clipboard!": "Копирана е връзката за чат!",
 	"Copy": "Копирай",
@@ -110,7 +117,7 @@
 	"Copy Link": "Копиране на връзка",
 	"Copying to clipboard was successful!": "Копирането в клипборда беше успешно!",
 	"Create a concise, 3-5 word phrase as a header for the following query, strictly adhering to the 3-5 word limit and avoiding the use of the word 'title':": "Създайте кратка фраза от 3-5 думи като заглавие за следващото запитване, като стриктно спазвате ограничението от 3-5 думи и избягвате използването на думата 'заглавие':",
-	"Create a model": "",
+	"Create a model": "Създаване на модел",
 	"Create Account": "Създаване на Акаунт",
 	"Create new key": "Създаване на нов ключ",
 	"Create new secret key": "Създаване на нов секретен ключ",
@@ -119,7 +126,7 @@
 	"Current Model": "Текущ модел",
 	"Current Password": "Текуща Парола",
 	"Custom": "Персонализиран",
-	"Customize models for a specific purpose": "",
+	"Customize models for a specific purpose": "Персонализиране на модели за конкретна цел",
 	"Dark": "Тъмен",
 	"Database": "База данни",
 	"December": "Декември",
@@ -127,29 +134,30 @@
 	"Default (Automatic1111)": "По подразбиране (Automatic1111)",
 	"Default (SentenceTransformers)": "По подразбиране (SentenceTransformers)",
 	"Default (Web API)": "По подразбиране (Web API)",
+	"Default Model": "Модел по подразбиране",
 	"Default model updated": "Моделът по подразбиране е обновен",
 	"Default Prompt Suggestions": "Промпт Предложения по подразбиране",
 	"Default User Role": "Роля на потребителя по подразбиране",
 	"delete": "изтриване",
 	"Delete": "Изтриване",
 	"Delete a model": "Изтриване на модел",
-	"Delete All Chats": "",
+	"Delete All Chats": "Изтриване на всички чатове",
 	"Delete chat": "Изтриване на чат",
 	"Delete Chat": "Изтриване на Чат",
 	"delete this link": "Изтриване на този линк",
 	"Delete User": "Изтриване на потребител",
 	"Deleted {{deleteModelTag}}": "Изтрито {{deleteModelTag}}",
-	"Deleted {{name}}": "",
+	"Deleted {{name}}": "Изтрито {{име}}",
 	"Description": "Описание",
 	"Didn't fully follow instructions": "Не следва инструкциите",
-	"Disabled": "Деактивиран",
-	"Discover a model": "",
+	"Discover a model": "Открийте модел",
 	"Discover a prompt": "Откриване на промпт",
 	"Discover, download, and explore custom prompts": "Откриване, сваляне и преглед на персонализирани промптове",
 	"Discover, download, and explore model presets": "Откриване, сваляне и преглед на пресетове на модели",
 	"Display the username instead of You in the Chat": "Показване на потребителското име вместо Вие в чата",
 	"Document": "Документ",
 	"Document Settings": "Документ Настройки",
+	"Documentation": "",
 	"Documents": "Документи",
 	"does not make any external connections, and your data stays securely on your locally hosted server.": "няма външни връзки, и вашите данни остават сигурни на локално назначен сървър.",
 	"Don't Allow": "Не Позволявай",
@@ -164,22 +172,31 @@
 	"Edit Doc": "Редактиране на документ",
 	"Edit User": "Редактиране на потребител",
 	"Email": "Имейл",
+	"Embedding Batch Size": "",
 	"Embedding Model": "Модел за вграждане",
 	"Embedding Model Engine": "Модел за вграждане",
 	"Embedding model set to \"{{embedding_model}}\"": "Модел за вграждане е настроен на \"{{embedding_model}}\"",
 	"Enable Chat History": "Вклюване на Чат История",
+	"Enable Community Sharing": "Разрешаване на споделяне в общност",
 	"Enable New Sign Ups": "Вклюване на Нови Потребители",
-	"Enabled": "Включено",
+	"Enable Web Search": "Разрешаване на търсене в уеб",
 	"Ensure your CSV file includes 4 columns in this order: Name, Email, Password, Role.": "Уверете се, че вашият CSV файл включва 4 колони в следния ред: Име, Имейл, Парола, Роля.",
 	"Enter {{role}} message here": "Въведете съобщение за {{role}} тук",
 	"Enter a detail about yourself for your LLMs to recall": "Въведете подробности за себе си, за да се herinnerат вашите LLMs",
+	"Enter Brave Search API Key": "Въведете Brave Search API ключ",
 	"Enter Chunk Overlap": "Въведете Chunk Overlap",
 	"Enter Chunk Size": "Въведете Chunk Size",
+	"Enter Github Raw URL": "Въведете URL адреса на Github Raw",
+	"Enter Google PSE API Key": "Въведете Google PSE API ключ",
+	"Enter Google PSE Engine Id": "Въведете идентификатор на двигателя на Google PSE",
 	"Enter Image Size (e.g. 512x512)": "Въведете размер на изображението (напр. 512x512)",
 	"Enter language codes": "Въведете кодове на езика",
 	"Enter model tag (e.g. {{modelTag}})": "Въведете таг на модел (напр. {{modelTag}})",
 	"Enter Number of Steps (e.g. 50)": "Въведете брой стъпки (напр. 50)",
 	"Enter Score": "Въведете оценка",
+	"Enter Searxng Query URL": "Въведете URL адреса на заявката на Searxng",
+	"Enter Serper API Key": "Въведете Serper API ключ",
+	"Enter Serpstack API Key": "Въведете Serpstack API ключ",
 	"Enter stop sequence": "Въведете стоп последователност",
 	"Enter Top K": "Въведете Top K",
 	"Enter URL (e.g. http://127.0.0.1:7860/)": "Въведете URL (напр. http://127.0.0.1:7860/)",
@@ -188,15 +205,18 @@
 	"Enter Your Full Name": "Въведете вашето пълно име",
 	"Enter Your Password": "Въведете вашата парола",
 	"Enter Your Role": "Въведете вашата роля",
-	"Error": "",
+	"Error": "Грешка",
 	"Experimental": "Експериментално",
+	"Export": "Износ",
 	"Export All Chats (All Users)": "Експортване на всички чатове (За всички потребители)",
+	"Export chat (.json)": "",
 	"Export Chats": "Експортване на чатове",
 	"Export Documents Mapping": "Експортване на документен мапинг",
-	"Export Models": "",
+	"Export Models": "Експортиране на модели",
 	"Export Prompts": "Експортване на промптове",
 	"Failed to create API Key.": "Неуспешно създаване на API ключ.",
 	"Failed to read clipboard contents": "Грешка при четене на съдържанието от клипборда",
+	"Failed to update settings": "",
 	"February": "Февруари",
 	"Feel free to add specific details": "Feel free to add specific details",
 	"File Mode": "Файл Мод",
@@ -206,12 +226,14 @@
 	"Focus chat input": "Фокусиране на чат вход",
 	"Followed instructions perfectly": "Следвайте инструкциите перфектно",
 	"Format your variables using square brackets like this:": "Форматирайте вашите променливи, като използвате квадратни скоби, както следва:",
-	"Frequencey Penalty": "",
-	"Full Screen Mode": "На Цял екран",
+	"Frequency Penalty": "Наказание за честота",
 	"General": "Основни",
 	"General Settings": "Основни Настройки",
+	"Generating search query": "Генериране на заявка за търсене",
 	"Generation Info": "Информация за Генерация",
 	"Good Response": "Добра отговор",
+	"Google PSE API Key": "Google PSE API ключ",
+	"Google PSE Engine Id": "Идентификатор на двигателя на Google PSE",
 	"h:mm a": "h:mm a",
 	"has no conversations.": "няма разговори.",
 	"Hello, {{name}}": "Здравей, {{name}}",
@@ -225,17 +247,18 @@
 	"Images": "Изображения",
 	"Import Chats": "Импортване на чатове",
 	"Import Documents Mapping": "Импортване на документен мапинг",
-	"Import Models": "",
+	"Import Models": "Импортиране на модели",
 	"Import Prompts": "Импортване на промптове",
 	"Include `--api` flag when running stable-diffusion-webui": "Включете флага `--api`, когато стартирате stable-diffusion-webui",
-	"Info": "",
+	"Info": "Информация",
 	"Input commands": "Въведете команди",
+	"Install from Github URL": "Инсталиране от URL адреса на Github",
 	"Interface": "Интерфейс",
 	"Invalid Tag": "Невалиден тег",
 	"January": "Януари",
 	"join our Discord for help.": "свържете се с нашия Discord за помощ.",
 	"JSON": "JSON",
-	"JSON Preview": "",
+	"JSON Preview": "JSON Преглед",
 	"July": "Июл",
 	"June": "Июн",
 	"JWT Expiration": "JWT Expiration",
@@ -252,8 +275,9 @@
 	"Make sure to enclose them with": "Уверете се, че са заключени с",
 	"Manage Models": "Управление на Моделите",
 	"Manage Ollama Models": "Управление на Ollama Моделите",
+	"Manage Pipelines": "Управление на тръбопроводи",
 	"March": "Март",
-	"Max Tokens (num_predict)": "",
+	"Max Tokens (num_predict)": "Макс токени (num_predict)",
 	"Maximum of 3 models can be downloaded simultaneously. Please try again later.": "Максимум 3 модели могат да бъдат сваляни едновременно. Моля, опитайте отново по-късно.",
 	"May": "Май",
 	"Memories accessible by LLMs will be shown here.": "Мемории достъпни от LLMs ще бъдат показани тук.",
@@ -268,11 +292,12 @@
 	"Model '{{modelName}}' has been successfully downloaded.": "Моделът '{{modelName}}' беше успешно свален.",
 	"Model '{{modelTag}}' is already in queue for downloading.": "Моделът '{{modelTag}}' е вече в очакване за сваляне.",
 	"Model {{modelId}} not found": "Моделът {{modelId}} не е намерен",
-	"Model {{modelName}} is not vision capable": "",
+	"Model {{modelName}} is not vision capable": "Моделът {{modelName}} не може да се вижда",
+	"Model {{name}} is now {{status}}": "Моделът {{name}} сега е {{status}}",
 	"Model filesystem path detected. Model shortname is required for update, cannot continue.": "Открит е път до файловата система на модела. За актуализацията се изисква съкратено име на модела, не може да продължи.",
-	"Model ID": "",
+	"Model ID": "ИД на модел",
 	"Model not selected": "Не е избран модел",
-	"Model Params": "",
+	"Model Params": "Модел Params",
 	"Model Whitelisting": "Модел Whitelisting",
 	"Model(s) Whitelisted": "Модели Whitelisted",
 	"Modelfile Content": "Съдържание на модфайл",
@@ -280,21 +305,25 @@
 	"More": "Повече",
 	"Name": "Име",
 	"Name Tag": "Име Таг",
-	"Name your model": "",
+	"Name your model": "Дайте име на вашия модел",
 	"New Chat": "Нов чат",
 	"New Password": "Нова парола",
 	"No results found": "Няма намерени резултати",
+	"No search query generated": "Не е генерирана заявка за търсене",
 	"No source available": "Няма наличен източник",
+	"None": "Никой",
 	"Not factually correct": "Не е фактологически правилно",
 	"Note: If you set a minimum score, the search will only return documents with a score greater than or equal to the minimum score.": "Забележка: Ако зададете минимален резултат, търсенето ще върне само документи с резултат, по-голям или равен на минималния резултат.",
 	"Notifications": "Десктоп Известия",
 	"November": "Ноември",
+	"num_thread (Ollama)": "num_thread (Ollama)",
 	"October": "Октомври",
 	"Off": "Изкл.",
 	"Okay, Let's Go!": "ОК, Нека започваме!",
 	"OLED Dark": "OLED тъмно",
 	"Ollama": "Ollama",
-	"Ollama API": "",
+	"Ollama API": "Ollama API",
+	"Ollama API disabled": "Ollama API деактивиран",
 	"Ollama Version": "Ollama Версия",
 	"On": "Вкл.",
 	"Only": "Само",
@@ -319,6 +348,8 @@
 	"pending": "в очакване",
 	"Permission denied when accessing microphone: {{error}}": "Permission denied when accessing microphone: {{error}}",
 	"Personalization": "Персонализация",
+	"Pipelines": "Тръбопроводи",
+	"Pipelines Valves": "Тръбопроводи Вентили",
 	"Plain text (.txt)": "Plain text (.txt)",
 	"Playground": "Плейграунд",
 	"Positive attitude": "Позитивна ативност",
@@ -348,6 +379,7 @@
 	"Reranking Model": "Reranking Model",
 	"Reranking model disabled": "Reranking model disabled",
 	"Reranking model set to \"{{reranking_model}}\"": "Reranking model set to \"{{reranking_model}}\"",
+	"Reset Upload Directory": "",
 	"Reset Vector Storage": "Ресет Vector Storage",
 	"Response AutoCopy to Clipboard": "Аувтоматично копиране на отговор в клипборда",
 	"Role": "Роля",
@@ -363,23 +395,32 @@
 	"Scan for documents from {{path}}": "Сканиране за документи в {{path}}",
 	"Search": "Търси",
 	"Search a model": "Търси модел",
-	"Search Chats": "",
+	"Search Chats": "Търсене на чатове",
 	"Search Documents": "Търси Документи",
-	"Search Models": "",
+	"Search Models": "Търсене на модели",
 	"Search Prompts": "Търси Промптове",
+	"Search Result Count": "Брой резултати от търсенето",
+	"Searched {{count}} sites_one": "Търси се в {{count}} sites_one",
+	"Searched {{count}} sites_other": "Търси се в {{count}} sites_other",
+	"Searching the web for '{{searchQuery}}'": "Търсене в уеб за '{{searchQuery}}'",
+	"Searxng Query URL": "URL адрес на заявка на Searxng",
 	"See readme.md for instructions": "Виж readme.md за инструкции",
 	"See what's new": "Виж какво е новото",
 	"Seed": "Seed",
-	"Select a base model": "",
+	"Select a base model": "Изберете базов модел",
 	"Select a mode": "Изберете режим",
 	"Select a model": "Изберете модел",
+	"Select a pipeline": "Изберете тръбопровод",
+	"Select a pipeline url": "Избор на URL адрес на канал",
 	"Select an Ollama instance": "Изберете Ollama инстанция",
 	"Select model": "Изберете модел",
-	"Selected model(s) do not support image inputs": "",
+	"Selected model(s) do not support image inputs": "Избраният(те) модел(и) не поддържа въвеждане на изображения",
 	"Send": "Изпрати",
 	"Send a Message": "Изпращане на Съобщение",
 	"Send message": "Изпращане на съобщение",
 	"September": "Септември",
+	"Serper API Key": "Serper API ключ",
+	"Serpstack API Key": "Serpstack API ключ",
 	"Server connection verified": "Server connection verified",
 	"Set as default": "Задай по подразбиране",
 	"Set Default Model": "Задай Модел По Подразбиране",
@@ -388,15 +429,17 @@
 	"Set Model": "Задай Модел",
 	"Set reranking model (e.g. {{model}})": "Задай reranking model (e.g. {{model}})",
 	"Set Steps": "Задай Стъпки",
-	"Set Title Auto-Generation Model": "Задай Модел за Автоматично Генериране на Заглавие",
+	"Set Task Model": "Задаване на модел на задача",
 	"Set Voice": "Задай Глас",
 	"Settings": "Настройки",
 	"Settings saved successfully!": "Настройките са запазени успешно!",
+	"Settings updated successfully": "",
 	"Share": "Подели",
 	"Share Chat": "Подели Чат",
 	"Share to OpenWebUI Community": "Споделите с OpenWebUI Общността",
 	"short-summary": "short-summary",
 	"Show": "Покажи",
+	"Show Admin Details in Account Pending Overlay": "",
 	"Show shortcuts": "Покажи",
 	"Showcased creativity": "Показана креативност",
 	"sidebar": "sidebar",
@@ -447,19 +490,21 @@
 	"Top P": "Top P",
 	"Trouble accessing Ollama?": "Проблеми с достъпът до Ollama?",
 	"TTS Settings": "TTS Настройки",
-	"Type": "",
+	"Type": "Вид",
 	"Type Hugging Face Resolve (Download) URL": "Въведете Hugging Face Resolve (Download) URL",
 	"Uh-oh! There was an issue connecting to {{provider}}.": "О, не! Възникна проблем при свързването с {{provider}}.",
 	"Unknown File Type '{{file_type}}', but accepting and treating as plain text": "Непознат файлов тип '{{file_type}}', но се приема и обработва като текст",
 	"Update and Copy Link": "Обнови и копирай връзка",
 	"Update password": "Обновяване на парола",
 	"Upload a GGUF model": "Качване на GGUF модел",
-	"Upload files": "Качване на файлове",
+	"Upload Files": "Качване на файлове",
 	"Upload Progress": "Прогрес на качването",
 	"URL Mode": "URL Mode",
 	"Use '#' in the prompt input to load and select your documents.": "Използвайте '#' във промпта за да заредите и изберете вашите документи.",
 	"Use Gravatar": "Използвайте Gravatar",
 	"Use Initials": "Използвайте Инициали",
+	"use_mlock (Ollama)": "use_mlock (Ollama)",
+	"use_mmap (Ollama)": "use_mmap (Ollama)",
 	"user": "потребител",
 	"User Permissions": "Права на потребителя",
 	"Users": "Потребители",
@@ -468,11 +513,13 @@
 	"variable": "променлива",
 	"variable to have them replaced with clipboard content.": "променливи да се заменят съдържанието от клипборд.",
 	"Version": "Версия",
-	"Warning": "",
+	"Warning": "Предупреждение",
 	"Warning: If you update or change your embedding model, you will need to re-import all documents.": "Предупреждение: Ако актуализирате или промените вашия модел за вграждане, трябва да повторите импортирането на всички документи.",
 	"Web": "Уеб",
 	"Web Loader Settings": "Настройки за зареждане на уеб",
 	"Web Params": "Параметри за уеб",
+	"Web Search": "Търсене в уеб",
+	"Web Search Engine": "Уеб търсачка",
 	"Webhook URL": "Уебхук URL",
 	"WebUI Add-ons": "WebUI Добавки",
 	"WebUI Settings": "WebUI Настройки",
@@ -480,12 +527,13 @@
 	"What’s New in": "Какво е новото в",
 	"When history is turned off, new chats on this browser won't appear in your history on any of your devices.": "Когато историята е изключена, нови чатове в този браузър ще не се показват в историята на никои от вашия профил.",
 	"Whisper (Local)": "Whisper (Локален)",
+	"Widescreen Mode": "",
 	"Workspace": "Работно пространство",
 	"Write a prompt suggestion (e.g. Who are you?)": "Напиши предложение за промпт (напр. Кой сте вие?)",
 	"Write a summary in 50 words that summarizes [topic or keyword].": "Напиши описание в 50 знака, което описва [тема или ключова дума].",
 	"Yesterday": "вчера",
 	"You": "вие",
-	"You cannot clone a base model": "",
+	"You cannot clone a base model": "Не можете да клонирате базов модел",
 	"You have no archived conversations.": "Нямате архивирани разговори.",
 	"You have shared this chat": "Вие сте споделели този чат",
 	"You're a helpful assistant.": "Вие сте полезен асистент.",

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است