Browse Source

Merge pull request #12932 from open-webui/dev

0.6.6
Tim Jaeryang Baek 1 month ago
parent
commit
23b9354cf6
100 changed files with 3849 additions and 1113 deletions
  1. 1 1
      .github/pull_request_template.md
  2. 47 0
      CHANGELOG.md
  3. 1 1
      Dockerfile
  4. 7 1
      LICENSE
  5. 20 4
      README.md
  6. 1 1
      backend/open_webui/__init__.py
  7. 137 6
      backend/open_webui/config.py
  8. 61 0
      backend/open_webui/env.py
  9. 55 3
      backend/open_webui/main.py
  10. 33 0
      backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py
  11. 1 1
      backend/open_webui/models/chats.py
  12. 135 0
      backend/open_webui/models/notes.py
  13. 66 5
      backend/open_webui/models/users.py
  14. 53 0
      backend/open_webui/retrieval/loaders/external.py
  15. 16 1
      backend/open_webui/retrieval/loaders/main.py
  16. 41 17
      backend/open_webui/retrieval/utils.py
  17. 4 0
      backend/open_webui/retrieval/vector/connector.py
  18. 7 2
      backend/open_webui/retrieval/vector/dbs/chroma.py
  19. 7 2
      backend/open_webui/retrieval/vector/dbs/elasticsearch.py
  20. 7 2
      backend/open_webui/retrieval/vector/dbs/milvus.py
  21. 7 2
      backend/open_webui/retrieval/vector/dbs/opensearch.py
  22. 9 5
      backend/open_webui/retrieval/vector/dbs/pgvector.py
  23. 412 0
      backend/open_webui/retrieval/vector/dbs/pinecone.py
  24. 41 9
      backend/open_webui/retrieval/vector/dbs/qdrant.py
  25. 68 1
      backend/open_webui/retrieval/vector/main.py
  26. 47 0
      backend/open_webui/retrieval/web/external.py
  27. 49 0
      backend/open_webui/retrieval/web/firecrawl.py
  28. 12 5
      backend/open_webui/retrieval/web/tavily.py
  29. 15 2
      backend/open_webui/retrieval/web/utils.py
  30. 87 0
      backend/open_webui/retrieval/web/yacy.py
  31. 27 3
      backend/open_webui/routers/audio.py
  32. 51 20
      backend/open_webui/routers/auths.py
  33. 10 1
      backend/open_webui/routers/chats.py
  34. 3 5
      backend/open_webui/routers/evaluations.py
  35. 42 11
      backend/open_webui/routers/files.py
  36. 5 1
      backend/open_webui/routers/images.py
  37. 33 19
      backend/open_webui/routers/knowledge.py
  38. 212 0
      backend/open_webui/routers/notes.py
  39. 20 4
      backend/open_webui/routers/ollama.py
  40. 19 12
      backend/open_webui/routers/openai.py
  41. 2 2
      backend/open_webui/routers/pipelines.py
  42. 118 17
      backend/open_webui/routers/retrieval.py
  43. 64 4
      backend/open_webui/routers/users.py
  44. 9 2
      backend/open_webui/socket/main.py
  45. 25 9
      backend/open_webui/storage/provider.py
  46. 44 10
      backend/open_webui/utils/audit.py
  47. 1 1
      backend/open_webui/utils/code_interpreter.py
  48. 53 29
      backend/open_webui/utils/middleware.py
  49. 1 0
      backend/open_webui/utils/models.py
  50. 65 4
      backend/open_webui/utils/oauth.py
  51. 31 1
      backend/open_webui/utils/plugin.py
  52. 78 51
      backend/open_webui/utils/tools.py
  53. 19 19
      backend/requirements.txt
  54. 1 1
      docs/apache.md
  55. 122 203
      package-lock.json
  56. 7 7
      package.json
  57. 7 6
      pyproject.toml
  58. 4 2
      src/lib/apis/auths/index.ts
  59. 0 1
      src/lib/apis/channels/index.ts
  60. 34 2
      src/lib/apis/index.ts
  61. 187 0
      src/lib/apis/notes/index.ts
  62. 54 3
      src/lib/apis/users/index.ts
  63. 1 1
      src/lib/components/AddFilesPlaceholder.svelte
  64. 1 1
      src/lib/components/admin/Functions.svelte
  65. 1 1
      src/lib/components/admin/Functions/FunctionEditor.svelte
  66. 33 1
      src/lib/components/admin/Settings/Audio.svelte
  67. 22 0
      src/lib/components/admin/Settings/Documents.svelte
  68. 9 1
      src/lib/components/admin/Settings/General.svelte
  69. 146 3
      src/lib/components/admin/Settings/WebSearch.svelte
  70. 3 16
      src/lib/components/admin/Users.svelte
  71. 26 6
      src/lib/components/admin/Users/Groups.svelte
  72. 28 1
      src/lib/components/admin/Users/Groups/Permissions.svelte
  73. 97 62
      src/lib/components/admin/Users/UserList.svelte
  74. 6 3
      src/lib/components/admin/Users/UserList/AddUserModal.svelte
  75. 52 50
      src/lib/components/admin/Users/UserList/EditUserModal.svelte
  76. 2 2
      src/lib/components/channel/Channel.svelte
  77. 3 3
      src/lib/components/channel/MessageInput.svelte
  78. 7 5
      src/lib/components/chat/Chat.svelte
  79. 1 1
      src/lib/components/chat/ChatControls.svelte
  80. 31 33
      src/lib/components/chat/MessageInput.svelte
  81. 1 1
      src/lib/components/chat/MessageInput/Commands/Knowledge.svelte
  82. 114 89
      src/lib/components/chat/MessageInput/InputMenu.svelte
  83. 154 96
      src/lib/components/chat/MessageInput/VoiceRecording.svelte
  84. 1 0
      src/lib/components/chat/Messages/Citations.svelte
  85. 7 4
      src/lib/components/chat/Messages/CitationsModal.svelte
  86. 4 4
      src/lib/components/chat/Messages/ContentRenderer.svelte
  87. 4 16
      src/lib/components/chat/Messages/Markdown.svelte
  88. 80 0
      src/lib/components/chat/Messages/Markdown/HTMLToken.svelte
  89. 2 10
      src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte
  90. 8 17
      src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte
  91. 9 8
      src/lib/components/chat/Messages/MultiResponseMessages.svelte
  92. 3 7
      src/lib/components/chat/Messages/ResponseMessage.svelte
  93. 5 3
      src/lib/components/chat/Messages/UserMessage.svelte
  94. 1 1
      src/lib/components/chat/ModelSelector/Selector.svelte
  95. 2 2
      src/lib/components/chat/Navbar.svelte
  96. 11 5
      src/lib/components/chat/Settings/About.svelte
  97. 119 112
      src/lib/components/chat/Settings/Account.svelte
  98. 1 1
      src/lib/components/chat/Settings/Advanced/AdvancedParams.svelte
  99. 25 22
      src/lib/components/chat/Settings/Chats.svelte
  100. 34 4
      src/lib/components/chat/Settings/Interface.svelte

+ 1 - 1
.github/pull_request_template.md

@@ -73,4 +73,4 @@
 
 ### Contributor License Agreement
 
-By submitting this pull request, I confirm that I have read and fully agree to the [CONTRIBUTOR_LICENSE_AGREEMENT](CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
+By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.

+ 47 - 0
CHANGELOG.md

@@ -5,6 +5,53 @@ 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.6.6] - 2025-05-05
+
+### Added
+
+- 📝 **AI-Enhanced Notes (With Audio Transcription)**: Effortlessly create notes, attach meeting or voice audio, and let the AI instantly enhance, summarize, or refine your notes using audio transcriptions—making your documentation smarter, cleaner, and more insightful with minimal effort.
+- 🔊 **Meeting Audio Recording & Import**: Seamlessly record audio from your meetings or capture screen audio and attach it to your notes—making it easier to revisit, annotate, and extract insights from important discussions.
+- 📁 **Import Markdown Notes Effortlessly**: Bring your existing knowledge library into Open WebUI by importing your Markdown notes, so you can leverage all advanced note management and AI features right away.
+- 👥 **Notes Permissions by User Group**: Fine-tune access and editing rights for notes based on user roles or groups, so you can delegate writing or restrict sensitive information as needed.
+- ☁️ **OneDrive & SharePoint Integration**: Keep your content in sync by connecting notes and files directly with OneDrive or SharePoint—unlocking fast enterprise import/export and seamless collaboration with your existing workflows.
+- 🗂️ **Paginated User List in Admin Panel**: Effortlessly manage and search through large teams via the new paginated user list—saving time and streamlining user administration in big organizations.
+- 🕹️ **Granular Chat Share & Export Permissions**: Enjoy enhanced control over who can share or export chats, enabling tighter governance and privacy in team and enterprise settings.
+- 🛑 **User Role Change Confirmation Dialog**: Reduce accidental privilege changes with a required confirmation step before updating user roles—improving security and preventing costly mistakes in team management.
+- 🚨 **Audit Log for Failed Login Attempts**: Quickly detect unauthorized access attempts or troubleshoot user login problems with detailed logs of failed authentication right in the audit trail.
+- 💡 **Dedicated 'Generate Title' Button for Chats**: Swiftly organize every conversation—tap the new button to let AI create relevant, clear titles for all your chats, saving time and reducing clutter.
+- 💬 **Notification Sound Always-On Option**: Take control of your notifications by setting sound alerts to always play—helping you stay on top of important updates in busy environments.
+- 🆔 **S3 File Tagging Support**: Uploaded files to S3 now include tags for better organization, searching, and integration with your file management policies.
+- 🛡️ **OAuth Blocked Groups Support**: Gain more control over group-based access by explicitly blocking specified OAuth groups—ideal for complex identity or security requirements.
+- 🚀 **Optimized Faster Web Search & Multi-Threaded Queries**: Enjoy dramatically faster web search and RAG (retrieval augmented generation) with revamped multi-threaded search—get richer, more accurate results in less time.
+- 🔍 **All-Knowledge Parallel Search**: Searches across your entire knowledge base now happen in parallel even in non-hybrid mode, speeding up responses and improving knowledge accuracy for every question.
+- 🌐 **New Firecrawl & Yacy Web Search Integrations**: Expand your world of information with two new advanced search engines—Firecrawl for deeper web insight and Yacy for decentralized, privacy-friendly search capabilities.
+- 🧠 **Configurable Docling OCR Engine & Language**: Use environment variables to fine-tune Docling OCR engine and supported languages for smarter, more tailored document extraction and RAG workflows.
+- 🗝️ **Enhanced Sentence Transformers Configuration**: Added new environment variables for easier set up and advanced customization of Sentence Transformers—ensuring best fit for your embedding needs.
+- 🌲 **Pinecone Vector Database Integration**: Index, search, and manage knowledge at enterprise scale with full native support for Pinecone as your vector database—effortlessly handle even the biggest document sets.
+- 🔄 **Automatic Requirements Installation for Tools & Functions**: Never worry about lost dependencies on restart—external function and tool requirements are now auto-installed at boot, ensuring tools always “just work.”
+- 🔒 **Automatic Sign-Out on Token Expiry**: Security is smarter—users are now automatically logged out if their authentication token expires, protecting sensitive content and ensuring compliance without disruption.
+- 🎬 **Automatic YouTube Embed Detection**: Paste YouTube links and see instant in-chat video embeds—no more manual embedding, making knowledge sharing and media consumption even easier for every team.
+- 🔄 **Expanded Language & Locale Support**: Translations for Danish, French, Russian, Traditional Chinese, Simplified Chinese, Thai, Catalan, German, and Korean have been upgraded, offering smoother, more natural user experiences across the platform.
+
+### Fixed
+
+- 🔒 **Tighter HTML Token Security**: HTML rendering is now restricted to admin-uploaded tokens only, reducing any risk of XSS and keeping your data safe.
+- 🔐 **Refined HTML Security and Token Handling**: Further hardened how HTML tokens and content are handled, guaranteeing even stronger resistance to security vulnerabilities and attacks.
+- 🔏 **Correct Model Usage with Ollama Proxy Prefixes**: Enhanced model reference handling so proxied models in Ollama always download and run correctly—even when using custom prefixes.
+- 📥 **Video File Upload Handling**: Prevented video files from being misclassified as text, fixing bugs with uploads and ensuring media files work as expected.
+- 🔄 **No More Dependent WebSocket Sequential Delays**: Streamlined WebSocket operation to prevent delays and maintain snappy real-time collaboration, especially in multi-user environments.
+- 🛠️ **More Robust Action Module Execution**: Multiple actions in a module now trigger as designed, increasing automation and scripting flexibility.
+- 📧 **Notification Webhooks**: Ensured that notification webhooks are always sent for user events, even when the user isn’t currently active.
+- 🗂️ **Smarter Knowledge Base Reindexing**: Knowledge reindexing continues even when corrupt or missing collections are encountered, keeping your search features running reliably.
+- 🏷️ **User Import with Profile Images**: When importing users, their profile images now come along—making onboarding and collaboration visually clearer from day one.
+- 💬 **OpenAI o-Series Universal Support**: All OpenAI o-series models are now seamlessly recognized and supported, unlocking more advanced capabilities and model choices for every workflow.
+
+### Changed
+
+- 📜 **Custom License Update & Contributor Agreement**: Open WebUI now operates under a custom license with Contributor License Agreement required by default—see https://docs.openwebui.com/license/ for details, ensuring sustainable open innovation for the community.
+- 🔨 **CUDA Docker Images Updated to 12.8**: Upgraded CUDA image support for faster, more compatible model inference and futureproof GPU performance in your AI infrastructure.
+- 🧱 **General Backend Refactoring for Reliability**: Continuous stability improvements streamline backend logic, reduce errors, and lay a stronger foundation for the next wave of feature releases—all under the hood for a more dependable WebUI.
+
 ## [0.6.5] - 2025-04-14
 
 ### Added

+ 1 - 1
Dockerfile

@@ -4,7 +4,7 @@
 ARG USE_CUDA=false
 ARG USE_OLLAMA=false
 # Tested with cu117 for CUDA 11 and cu121 for CUDA 12 (default)
-ARG USE_CUDA_VER=cu121
+ARG USE_CUDA_VER=cu128
 # any sentence transformer model; models to use can be found at https://huggingface.co/models?library=sentence-transformers
 # Leaderboard: https://huggingface.co/spaces/mteb/leaderboard 
 # for better performance and multilangauge support use "intfloat/multilingual-e5-large" (~2.5GB) or "intfloat/multilingual-e5-base" (~1.5GB)

+ 7 - 1
LICENSE

@@ -1,4 +1,4 @@
-Copyright (c) 2023-2025 Timothy Jaeryang Baek
+Copyright (c) 2023-2025 Timothy Jaeryang Baek (Open WebUI)
 All rights reserved.
 
 Redistribution and use in source and binary forms, with or without
@@ -15,6 +15,12 @@ modification, are permitted provided that the following conditions are met:
    contributors may be used to endorse or promote products derived from
    this software without specific prior written permission.
 
+4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
+
+5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
+
+6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
+
 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE

+ 20 - 4
README.md

@@ -7,7 +7,6 @@
 ![GitHub language count](https://img.shields.io/github/languages/count/open-webui/open-webui)
 ![GitHub top language](https://img.shields.io/github/languages/top/open-webui/open-webui)
 ![GitHub last commit](https://img.shields.io/github/last-commit/open-webui/open-webui?color=red)
-![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Follama-webui%2Follama-wbui&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)
 [![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)
 
@@ -62,9 +61,26 @@ For more information, be sure to check out our [Open WebUI Documentation](https:
 
 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!
+## Sponsors 🙌
 
-Don't forget to explore our sibling project, [Open WebUI Community](https://openwebui.com/), where you can discover, download, and explore customized Modelfiles. Open WebUI Community offers a wide range of exciting possibilities for enhancing your chat interactions with Open WebUI! 🚀
+#### Emerald
+
+<table>
+  <tr>
+    <td>
+      <a href="https://n8n.io/" target="_blank">
+        <img src="https://docs.openwebui.com/sponsors/logos/n8n.png" alt="n8n" style="width: 8rem; height: 8rem; border-radius: .75rem;" />
+      </a>
+    </td>
+    <td>
+      Does your interface have a backend yet?<br>Try <a href="https://n8n.io/">n8n</a>
+    </td>
+  </tr>
+</table>
+
+---
+
+We are incredibly grateful for the generous support of our sponsors. Their contributions help us to maintain and improve our project, ensuring we can continue to deliver quality work to our community. Thank you!
 
 ## How to Install 🚀
 
@@ -206,7 +222,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
 
 ## License 📜
 
-This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
+This project is licensed under the [Open WebUI License](LICENSE), a revised BSD-3-Clause license. You receive all the same rights as the classic BSD-3 license: you can use, modify, and distribute the software, including in proprietary and commercial products, with minimal restrictions. The only additional requirement is to preserve the "Open WebUI" branding, as detailed in the LICENSE file. For full terms, see the [LICENSE](LICENSE) document. 📄
 
 ## Support 💬
 

+ 1 - 1
backend/open_webui/__init__.py

@@ -76,7 +76,7 @@ def serve(
     from open_webui.env import UVICORN_WORKERS  # Import the workers setting
 
     uvicorn.run(
-        open_webui.main.app,
+        "open_webui.main:app",
         host=host,
         port=port,
         forwarded_allow_ips="*",

+ 137 - 6
backend/open_webui/config.py

@@ -509,6 +509,19 @@ ENABLE_OAUTH_GROUP_MANAGEMENT = PersistentConfig(
     os.environ.get("ENABLE_OAUTH_GROUP_MANAGEMENT", "False").lower() == "true",
 )
 
+ENABLE_OAUTH_GROUP_CREATION = PersistentConfig(
+    "ENABLE_OAUTH_GROUP_CREATION",
+    "oauth.enable_group_creation",
+    os.environ.get("ENABLE_OAUTH_GROUP_CREATION", "False").lower() == "true",
+)
+
+
+OAUTH_BLOCKED_GROUPS = PersistentConfig(
+    "OAUTH_BLOCKED_GROUPS",
+    "oauth.blocked_groups",
+    os.environ.get("OAUTH_BLOCKED_GROUPS", "[]"),
+)
+
 OAUTH_ROLES_CLAIM = PersistentConfig(
     "OAUTH_ROLES_CLAIM",
     "oauth.roles_claim",
@@ -952,10 +965,15 @@ DEFAULT_MODELS = PersistentConfig(
     "DEFAULT_MODELS", "ui.default_models", os.environ.get("DEFAULT_MODELS", None)
 )
 
-DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
-    "DEFAULT_PROMPT_SUGGESTIONS",
-    "ui.prompt_suggestions",
-    [
+try:
+    default_prompt_suggestions = json.loads(
+        os.environ.get("DEFAULT_PROMPT_SUGGESTIONS", "[]")
+    )
+except Exception as e:
+    log.exception(f"Error loading DEFAULT_PROMPT_SUGGESTIONS: {e}")
+    default_prompt_suggestions = []
+if default_prompt_suggestions == []:
+    default_prompt_suggestions = [
         {
             "title": ["Help me study", "vocabulary for a college entrance exam"],
             "content": "Help me study vocabulary: write a sentence for me to fill in the blank, and I'll try to pick the correct option.",
@@ -983,7 +1001,11 @@ DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
             "title": ["Overcome procrastination", "give me tips"],
             "content": "Could you start by asking me about instances when I procrastinate the most and then give me some suggestions to overcome it?",
         },
-    ],
+    ]
+DEFAULT_PROMPT_SUGGESTIONS = PersistentConfig(
+    "DEFAULT_PROMPT_SUGGESTIONS",
+    "ui.prompt_suggestions",
+    default_prompt_suggestions,
 )
 
 MODEL_ORDER_LIST = PersistentConfig(
@@ -1062,6 +1084,14 @@ USER_PERMISSIONS_CHAT_EDIT = (
     os.environ.get("USER_PERMISSIONS_CHAT_EDIT", "True").lower() == "true"
 )
 
+USER_PERMISSIONS_CHAT_SHARE = (
+    os.environ.get("USER_PERMISSIONS_CHAT_SHARE", "True").lower() == "true"
+)
+
+USER_PERMISSIONS_CHAT_EXPORT = (
+    os.environ.get("USER_PERMISSIONS_CHAT_EXPORT", "True").lower() == "true"
+)
+
 USER_PERMISSIONS_CHAT_STT = (
     os.environ.get("USER_PERMISSIONS_CHAT_STT", "True").lower() == "true"
 )
@@ -1107,6 +1137,10 @@ USER_PERMISSIONS_FEATURES_CODE_INTERPRETER = (
     == "true"
 )
 
+USER_PERMISSIONS_FEATURES_NOTES = (
+    os.environ.get("USER_PERMISSIONS_FEATURES_NOTES", "True").lower() == "true"
+)
+
 
 DEFAULT_USER_PERMISSIONS = {
     "workspace": {
@@ -1126,6 +1160,8 @@ DEFAULT_USER_PERMISSIONS = {
         "file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
         "delete": USER_PERMISSIONS_CHAT_DELETE,
         "edit": USER_PERMISSIONS_CHAT_EDIT,
+        "share": USER_PERMISSIONS_CHAT_SHARE,
+        "export": USER_PERMISSIONS_CHAT_EXPORT,
         "stt": USER_PERMISSIONS_CHAT_STT,
         "tts": USER_PERMISSIONS_CHAT_TTS,
         "call": USER_PERMISSIONS_CHAT_CALL,
@@ -1138,6 +1174,7 @@ DEFAULT_USER_PERMISSIONS = {
         "web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
         "image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
         "code_interpreter": USER_PERMISSIONS_FEATURES_CODE_INTERPRETER,
+        "notes": USER_PERMISSIONS_FEATURES_NOTES,
     },
 }
 
@@ -1153,6 +1190,11 @@ ENABLE_CHANNELS = PersistentConfig(
     os.environ.get("ENABLE_CHANNELS", "False").lower() == "true",
 )
 
+ENABLE_NOTES = PersistentConfig(
+    "ENABLE_NOTES",
+    "notes.enable",
+    os.environ.get("ENABLE_NOTES", "True").lower() == "true",
+)
 
 ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
     "ENABLE_EVALUATION_ARENA_MODELS",
@@ -1203,6 +1245,9 @@ ENABLE_USER_WEBHOOKS = PersistentConfig(
     os.environ.get("ENABLE_USER_WEBHOOKS", "True").lower() == "true",
 )
 
+# FastAPI / AnyIO settings
+THREAD_POOL_SIZE = int(os.getenv("THREAD_POOL_SIZE", "0"))
+
 
 def validate_cors_origins(origins):
     for origin in origins:
@@ -1229,7 +1274,9 @@ def validate_cors_origin(origin):
 # To test CORS_ALLOW_ORIGIN locally, you can set something like
 # CORS_ALLOW_ORIGIN=http://localhost:5173;http://localhost:8080
 # in your .env file depending on your frontend port, 5173 in this case.
-CORS_ALLOW_ORIGIN = os.environ.get("CORS_ALLOW_ORIGIN", "*").split(";")
+CORS_ALLOW_ORIGIN = os.environ.get(
+    "CORS_ALLOW_ORIGIN", "*;http://localhost:5173;http://localhost:8080"
+).split(";")
 
 if "*" in CORS_ALLOW_ORIGIN:
     log.warning(
@@ -1693,6 +1740,9 @@ MILVUS_TOKEN = os.environ.get("MILVUS_TOKEN", None)
 # Qdrant
 QDRANT_URI = os.environ.get("QDRANT_URI", None)
 QDRANT_API_KEY = os.environ.get("QDRANT_API_KEY", None)
+QDRANT_ON_DISK = os.environ.get("QDRANT_ON_DISK", "false").lower() == "true"
+QDRANT_PREFER_GRPC = os.environ.get("QDRANT_PREFER_GRPC", "False").lower() == "true"
+QDRANT_GRPC_PORT = int(os.environ.get("QDRANT_GRPC_PORT", "6334"))
 
 # OpenSearch
 OPENSEARCH_URI = os.environ.get("OPENSEARCH_URI", "https://localhost:9200")
@@ -1724,6 +1774,14 @@ PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
     os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
 )
 
+# Pinecone
+PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
+PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)
+PINECONE_INDEX_NAME = os.getenv("PINECONE_INDEX_NAME", "open-webui-index")
+PINECONE_DIMENSION = int(os.getenv("PINECONE_DIMENSION", 1536))  # or 3072, 1024, 768
+PINECONE_METRIC = os.getenv("PINECONE_METRIC", "cosine")
+PINECONE_CLOUD = os.getenv("PINECONE_CLOUD", "aws")  # or "gcp" or "azure"
+
 ####################################
 # Information Retrieval (RAG)
 ####################################
@@ -1760,6 +1818,13 @@ ONEDRIVE_CLIENT_ID = PersistentConfig(
     os.environ.get("ONEDRIVE_CLIENT_ID", ""),
 )
 
+ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
+    "ONEDRIVE_SHAREPOINT_URL",
+    "onedrive.sharepoint_url",
+    os.environ.get("ONEDRIVE_SHAREPOINT_URL", ""),
+)
+
+
 # RAG Content Extraction
 CONTENT_EXTRACTION_ENGINE = PersistentConfig(
     "CONTENT_EXTRACTION_ENGINE",
@@ -1779,6 +1844,18 @@ DOCLING_SERVER_URL = PersistentConfig(
     os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
 )
 
+DOCLING_OCR_ENGINE = PersistentConfig(
+    "DOCLING_OCR_ENGINE",
+    "rag.docling_ocr_engine",
+    os.getenv("DOCLING_OCR_ENGINE", "tesseract"),
+)
+
+DOCLING_OCR_LANG = PersistentConfig(
+    "DOCLING_OCR_LANG",
+    "rag.docling_ocr_lang",
+    os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
+)
+
 DOCUMENT_INTELLIGENCE_ENDPOINT = PersistentConfig(
     "DOCUMENT_INTELLIGENCE_ENDPOINT",
     "rag.document_intelligence_endpoint",
@@ -2087,6 +2164,24 @@ SEARXNG_QUERY_URL = PersistentConfig(
     os.getenv("SEARXNG_QUERY_URL", ""),
 )
 
+YACY_QUERY_URL = PersistentConfig(
+    "YACY_QUERY_URL",
+    "rag.web.search.yacy_query_url",
+    os.getenv("YACY_QUERY_URL", ""),
+)
+
+YACY_USERNAME = PersistentConfig(
+    "YACY_USERNAME",
+    "rag.web.search.yacy_username",
+    os.getenv("YACY_USERNAME", ""),
+)
+
+YACY_PASSWORD = PersistentConfig(
+    "YACY_PASSWORD",
+    "rag.web.search.yacy_password",
+    os.getenv("YACY_PASSWORD", ""),
+)
+
 GOOGLE_PSE_API_KEY = PersistentConfig(
     "GOOGLE_PSE_API_KEY",
     "rag.web.search.google_pse_api_key",
@@ -2251,6 +2346,29 @@ FIRECRAWL_API_BASE_URL = PersistentConfig(
     os.environ.get("FIRECRAWL_API_BASE_URL", "https://api.firecrawl.dev"),
 )
 
+EXTERNAL_WEB_SEARCH_URL = PersistentConfig(
+    "EXTERNAL_WEB_SEARCH_URL",
+    "rag.web.search.external_web_search_url",
+    os.environ.get("EXTERNAL_WEB_SEARCH_URL", ""),
+)
+
+EXTERNAL_WEB_SEARCH_API_KEY = PersistentConfig(
+    "EXTERNAL_WEB_SEARCH_API_KEY",
+    "rag.web.search.external_web_search_api_key",
+    os.environ.get("EXTERNAL_WEB_SEARCH_API_KEY", ""),
+)
+
+EXTERNAL_WEB_LOADER_URL = PersistentConfig(
+    "EXTERNAL_WEB_LOADER_URL",
+    "rag.web.loader.external_web_loader_url",
+    os.environ.get("EXTERNAL_WEB_LOADER_URL", ""),
+)
+
+EXTERNAL_WEB_LOADER_API_KEY = PersistentConfig(
+    "EXTERNAL_WEB_LOADER_API_KEY",
+    "rag.web.loader.external_web_loader_api_key",
+    os.environ.get("EXTERNAL_WEB_LOADER_API_KEY", ""),
+)
 
 ####################################
 # Images
@@ -2510,6 +2628,7 @@ WHISPER_VAD_FILTER = PersistentConfig(
     os.getenv("WHISPER_VAD_FILTER", "False").lower() == "true",
 )
 
+WHISPER_LANGUAGE = os.getenv("WHISPER_LANGUAGE", "").lower() or None
 
 # Add Deepgram configuration
 DEEPGRAM_API_KEY = PersistentConfig(
@@ -2561,6 +2680,18 @@ AUDIO_STT_AZURE_LOCALES = PersistentConfig(
     os.getenv("AUDIO_STT_AZURE_LOCALES", ""),
 )
 
+AUDIO_STT_AZURE_BASE_URL = PersistentConfig(
+    "AUDIO_STT_AZURE_BASE_URL",
+    "audio.stt.azure.base_url",
+    os.getenv("AUDIO_STT_AZURE_BASE_URL", ""),
+)
+
+AUDIO_STT_AZURE_MAX_SPEAKERS = PersistentConfig(
+    "AUDIO_STT_AZURE_MAX_SPEAKERS",
+    "audio.stt.azure.max_speakers",
+    os.getenv("AUDIO_STT_AZURE_MAX_SPEAKERS", "3"),
+)
+
 AUDIO_TTS_OPENAI_API_BASE_URL = PersistentConfig(
     "AUDIO_TTS_OPENAI_API_BASE_URL",
     "audio.tts.openai.api_base_url",

+ 61 - 0
backend/open_webui/env.py

@@ -354,6 +354,10 @@ BYPASS_MODEL_ACCESS_CONTROL = (
     os.environ.get("BYPASS_MODEL_ACCESS_CONTROL", "False").lower() == "true"
 )
 
+WEBUI_AUTH_SIGNOUT_REDIRECT_URL = os.environ.get(
+    "WEBUI_AUTH_SIGNOUT_REDIRECT_URL", None
+)
+
 ####################################
 # WEBUI_SECRET_KEY
 ####################################
@@ -409,6 +413,11 @@ else:
     except Exception:
         AIOHTTP_CLIENT_TIMEOUT = 300
 
+
+AIOHTTP_CLIENT_SESSION_SSL = (
+    os.environ.get("AIOHTTP_CLIENT_SESSION_SSL", "True").lower() == "true"
+)
+
 AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST = os.environ.get(
     "AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST",
     os.environ.get("AIOHTTP_CLIENT_TIMEOUT_OPENAI_MODEL_LIST", "10"),
@@ -437,6 +446,56 @@ else:
     except Exception:
         AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA = 10
 
+
+AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL = (
+    os.environ.get("AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL", "True").lower() == "true"
+)
+
+
+####################################
+# SENTENCE TRANSFORMERS
+####################################
+
+
+SENTENCE_TRANSFORMERS_BACKEND = os.environ.get("SENTENCE_TRANSFORMERS_BACKEND", "")
+if SENTENCE_TRANSFORMERS_BACKEND == "":
+    SENTENCE_TRANSFORMERS_BACKEND = "torch"
+
+
+SENTENCE_TRANSFORMERS_MODEL_KWARGS = os.environ.get(
+    "SENTENCE_TRANSFORMERS_MODEL_KWARGS", ""
+)
+if SENTENCE_TRANSFORMERS_MODEL_KWARGS == "":
+    SENTENCE_TRANSFORMERS_MODEL_KWARGS = None
+else:
+    try:
+        SENTENCE_TRANSFORMERS_MODEL_KWARGS = json.loads(
+            SENTENCE_TRANSFORMERS_MODEL_KWARGS
+        )
+    except Exception:
+        SENTENCE_TRANSFORMERS_MODEL_KWARGS = None
+
+
+SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = os.environ.get(
+    "SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND", ""
+)
+if SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND == "":
+    SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND = "torch"
+
+
+SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = os.environ.get(
+    "SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS", ""
+)
+if SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS == "":
+    SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
+else:
+    try:
+        SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = json.loads(
+            SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS
+        )
+    except Exception:
+        SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS = None
+
 ####################################
 # OFFLINE_MODE
 ####################################
@@ -446,6 +505,7 @@ OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
 if OFFLINE_MODE:
     os.environ["HF_HUB_OFFLINE"] = "1"
 
+
 ####################################
 # AUDIT LOGGING
 ####################################
@@ -467,6 +527,7 @@ AUDIT_EXCLUDED_PATHS = os.getenv("AUDIT_EXCLUDED_PATHS", "/chats,/chat,/folders"
 AUDIT_EXCLUDED_PATHS = [path.strip() for path in AUDIT_EXCLUDED_PATHS]
 AUDIT_EXCLUDED_PATHS = [path.lstrip("/") for path in AUDIT_EXCLUDED_PATHS]
 
+
 ####################################
 # OPENTELEMETRY
 ####################################

+ 55 - 3
backend/open_webui/main.py

@@ -17,6 +17,7 @@ from sqlalchemy import text
 from typing import Optional
 from aiocache import cached
 import aiohttp
+import anyio.to_thread
 import requests
 
 
@@ -63,6 +64,7 @@ from open_webui.routers import (
     auths,
     channels,
     chats,
+    notes,
     folders,
     configs,
     groups,
@@ -100,11 +102,14 @@ from open_webui.config import (
     # OpenAI
     ENABLE_OPENAI_API,
     ONEDRIVE_CLIENT_ID,
+    ONEDRIVE_SHAREPOINT_URL,
     OPENAI_API_BASE_URLS,
     OPENAI_API_KEYS,
     OPENAI_API_CONFIGS,
     # Direct Connections
     ENABLE_DIRECT_CONNECTIONS,
+    # Thread pool size for FastAPI/AnyIO
+    THREAD_POOL_SIZE,
     # Tool Server Configs
     TOOL_SERVER_CONNECTIONS,
     # Code Execution
@@ -151,6 +156,8 @@ from open_webui.config import (
     AUDIO_STT_AZURE_API_KEY,
     AUDIO_STT_AZURE_REGION,
     AUDIO_STT_AZURE_LOCALES,
+    AUDIO_STT_AZURE_BASE_URL,
+    AUDIO_STT_AZURE_MAX_SPEAKERS,
     AUDIO_TTS_API_KEY,
     AUDIO_TTS_ENGINE,
     AUDIO_TTS_MODEL,
@@ -167,6 +174,7 @@ from open_webui.config import (
     WEB_LOADER_ENGINE,
     WHISPER_MODEL,
     WHISPER_VAD_FILTER,
+    WHISPER_LANGUAGE,
     DEEPGRAM_API_KEY,
     WHISPER_MODEL_AUTO_UPDATE,
     WHISPER_MODEL_DIR,
@@ -195,6 +203,8 @@ from open_webui.config import (
     CONTENT_EXTRACTION_ENGINE,
     TIKA_SERVER_URL,
     DOCLING_SERVER_URL,
+    DOCLING_OCR_ENGINE,
+    DOCLING_OCR_LANG,
     DOCUMENT_INTELLIGENCE_ENDPOINT,
     DOCUMENT_INTELLIGENCE_KEY,
     MISTRAL_OCR_API_KEY,
@@ -219,6 +229,9 @@ from open_webui.config import (
     SERPAPI_API_KEY,
     SERPAPI_ENGINE,
     SEARXNG_QUERY_URL,
+    YACY_QUERY_URL,
+    YACY_USERNAME,
+    YACY_PASSWORD,
     SERPER_API_KEY,
     SERPLY_API_KEY,
     SERPSTACK_API_KEY,
@@ -240,12 +253,17 @@ from open_webui.config import (
     GOOGLE_DRIVE_CLIENT_ID,
     GOOGLE_DRIVE_API_KEY,
     ONEDRIVE_CLIENT_ID,
+    ONEDRIVE_SHAREPOINT_URL,
     ENABLE_RAG_HYBRID_SEARCH,
     ENABLE_RAG_LOCAL_WEB_FETCH,
     ENABLE_WEB_LOADER_SSL_VERIFICATION,
     ENABLE_GOOGLE_DRIVE_INTEGRATION,
     ENABLE_ONEDRIVE_INTEGRATION,
     UPLOAD_DIR,
+    EXTERNAL_WEB_SEARCH_URL,
+    EXTERNAL_WEB_SEARCH_API_KEY,
+    EXTERNAL_WEB_LOADER_URL,
+    EXTERNAL_WEB_LOADER_API_KEY,
     # WebUI
     WEBUI_AUTH,
     WEBUI_NAME,
@@ -260,6 +278,7 @@ from open_webui.config import (
     ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
     API_KEY_ALLOWED_ENDPOINTS,
     ENABLE_CHANNELS,
+    ENABLE_NOTES,
     ENABLE_COMMUNITY_SHARING,
     ENABLE_MESSAGE_RATING,
     ENABLE_USER_WEBHOOKS,
@@ -341,6 +360,7 @@ from open_webui.env import (
     WEBUI_SESSION_COOKIE_SECURE,
     WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
     WEBUI_AUTH_TRUSTED_NAME_HEADER,
+    WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
     ENABLE_WEBSOCKET_SUPPORT,
     BYPASS_MODEL_ACCESS_CONTROL,
     RESET_CONFIG_ON_START,
@@ -370,6 +390,7 @@ from open_webui.utils.auth import (
     get_admin_user,
     get_verified_user,
 )
+from open_webui.utils.plugin import install_tool_and_function_dependencies
 from open_webui.utils.oauth import OAuthManager
 from open_webui.utils.security_headers import SecurityHeadersMiddleware
 
@@ -432,7 +453,18 @@ async def lifespan(app: FastAPI):
     if LICENSE_KEY:
         get_license_data(app, LICENSE_KEY)
 
+    # This should be blocking (sync) so functions are not deactivated on first /get_models calls
+    # when the first user lands on the / route.
+    log.info("Installing external dependencies of functions and tools...")
+    install_tool_and_function_dependencies()
+
+    pool_size = THREAD_POOL_SIZE
+    if pool_size and pool_size > 0:
+        limiter = anyio.to_thread.current_default_thread_limiter()
+        limiter.total_tokens = pool_size
+
     asyncio.create_task(periodic_usage_pool_cleanup())
+
     yield
 
 
@@ -543,6 +575,7 @@ app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
 
 
 app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
+app.state.config.ENABLE_NOTES = ENABLE_NOTES
 app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
 app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
 app.state.config.ENABLE_USER_WEBHOOKS = ENABLE_USER_WEBHOOKS
@@ -576,6 +609,7 @@ app.state.config.LDAP_CIPHERS = LDAP_CIPHERS
 
 app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER
 app.state.AUTH_TRUSTED_NAME_HEADER = WEBUI_AUTH_TRUSTED_NAME_HEADER
+app.state.WEBUI_AUTH_SIGNOUT_REDIRECT_URL = WEBUI_AUTH_SIGNOUT_REDIRECT_URL
 app.state.EXTERNAL_PWA_MANIFEST_URL = EXTERNAL_PWA_MANIFEST_URL
 
 app.state.USER_COUNT = None
@@ -604,6 +638,8 @@ app.state.config.ENABLE_WEB_LOADER_SSL_VERIFICATION = ENABLE_WEB_LOADER_SSL_VERI
 app.state.config.CONTENT_EXTRACTION_ENGINE = CONTENT_EXTRACTION_ENGINE
 app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
 app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
+app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
+app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
 app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = DOCUMENT_INTELLIGENCE_ENDPOINT
 app.state.config.DOCUMENT_INTELLIGENCE_KEY = DOCUMENT_INTELLIGENCE_KEY
 app.state.config.MISTRAL_OCR_API_KEY = MISTRAL_OCR_API_KEY
@@ -646,6 +682,9 @@ app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL = (
 app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
 app.state.config.ENABLE_ONEDRIVE_INTEGRATION = ENABLE_ONEDRIVE_INTEGRATION
 app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
+app.state.config.YACY_QUERY_URL = YACY_QUERY_URL
+app.state.config.YACY_USERNAME = YACY_USERNAME
+app.state.config.YACY_PASSWORD = YACY_PASSWORD
 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
@@ -668,6 +707,10 @@ app.state.config.EXA_API_KEY = EXA_API_KEY
 app.state.config.PERPLEXITY_API_KEY = PERPLEXITY_API_KEY
 app.state.config.SOUGOU_API_SID = SOUGOU_API_SID
 app.state.config.SOUGOU_API_SK = SOUGOU_API_SK
+app.state.config.EXTERNAL_WEB_SEARCH_URL = EXTERNAL_WEB_SEARCH_URL
+app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = EXTERNAL_WEB_SEARCH_API_KEY
+app.state.config.EXTERNAL_WEB_LOADER_URL = EXTERNAL_WEB_LOADER_URL
+app.state.config.EXTERNAL_WEB_LOADER_API_KEY = EXTERNAL_WEB_LOADER_API_KEY
 
 
 app.state.config.PLAYWRIGHT_WS_URL = PLAYWRIGHT_WS_URL
@@ -796,6 +839,8 @@ app.state.config.DEEPGRAM_API_KEY = DEEPGRAM_API_KEY
 app.state.config.AUDIO_STT_AZURE_API_KEY = AUDIO_STT_AZURE_API_KEY
 app.state.config.AUDIO_STT_AZURE_REGION = AUDIO_STT_AZURE_REGION
 app.state.config.AUDIO_STT_AZURE_LOCALES = AUDIO_STT_AZURE_LOCALES
+app.state.config.AUDIO_STT_AZURE_BASE_URL = AUDIO_STT_AZURE_BASE_URL
+app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = AUDIO_STT_AZURE_MAX_SPEAKERS
 
 app.state.config.TTS_OPENAI_API_BASE_URL = AUDIO_TTS_OPENAI_API_BASE_URL
 app.state.config.TTS_OPENAI_API_KEY = AUDIO_TTS_OPENAI_API_KEY
@@ -869,7 +914,8 @@ class RedirectMiddleware(BaseHTTPMiddleware):
 
             # Check for the specific watch path and the presence of 'v' parameter
             if path.endswith("/watch") and "v" in query_params:
-                video_id = query_params["v"][0]  # Extract the first 'v' parameter
+                # Extract the first 'v' parameter
+                video_id = query_params["v"][0]
                 encoded_video_id = urlencode({"youtube": video_id})
                 redirect_url = f"/?{encoded_video_id}"
                 return RedirectResponse(url=redirect_url)
@@ -955,6 +1001,8 @@ app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
 
 app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
 app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
+app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"])
+
 
 app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
 app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"])
@@ -1283,6 +1331,7 @@ async def get_app_config(request: Request):
                 {
                     "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,
                     "enable_channels": app.state.config.ENABLE_CHANNELS,
+                    "enable_notes": app.state.config.ENABLE_NOTES,
                     "enable_web_search": app.state.config.ENABLE_WEB_SEARCH,
                     "enable_code_execution": app.state.config.ENABLE_CODE_EXECUTION,
                     "enable_code_interpreter": app.state.config.ENABLE_CODE_INTERPRETER,
@@ -1327,7 +1376,10 @@ async def get_app_config(request: Request):
                     "client_id": GOOGLE_DRIVE_CLIENT_ID.value,
                     "api_key": GOOGLE_DRIVE_API_KEY.value,
                 },
-                "onedrive": {"client_id": ONEDRIVE_CLIENT_ID.value},
+                "onedrive": {
+                    "client_id": ONEDRIVE_CLIENT_ID.value,
+                    "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
+                },
                 "license_metadata": app.state.LICENSE_METADATA,
                 **(
                     {
@@ -1439,7 +1491,7 @@ async def get_manifest_json():
             "start_url": "/",
             "display": "standalone",
             "background_color": "#343541",
-            "orientation": "natural",
+            "orientation": "any",
             "icons": [
                 {
                     "src": "/static/logo.png",

+ 33 - 0
backend/open_webui/migrations/versions/9f0c9cd09105_add_note_table.py

@@ -0,0 +1,33 @@
+"""Add note table
+
+Revision ID: 9f0c9cd09105
+Revises: 3781e22d8b01
+Create Date: 2025-05-03 03:00:00.000000
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "9f0c9cd09105"
+down_revision = "3781e22d8b01"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table(
+        "note",
+        sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
+        sa.Column("user_id", sa.Text(), nullable=True),
+        sa.Column("title", sa.Text(), nullable=True),
+        sa.Column("data", sa.JSON(), nullable=True),
+        sa.Column("meta", sa.JSON(), nullable=True),
+        sa.Column("access_control", sa.JSON(), nullable=True),
+        sa.Column("created_at", sa.BigInteger(), nullable=True),
+        sa.Column("updated_at", sa.BigInteger(), nullable=True),
+    )
+
+
+def downgrade():
+    op.drop_table("note")

+ 1 - 1
backend/open_webui/models/chats.py

@@ -436,7 +436,7 @@ class ChatTable:
 
             all_chats = query.all()
 
-            # result has to be destrctured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass.
+            # result has to be destructured from sqlalchemy `row` and mapped to a dict since the `ChatModel`is not the returned dataclass.
             return [
                 ChatTitleIdResponse.model_validate(
                     {

+ 135 - 0
backend/open_webui/models/notes.py

@@ -0,0 +1,135 @@
+import json
+import time
+import uuid
+from typing import Optional
+
+from open_webui.internal.db import Base, get_db
+from open_webui.utils.access_control import has_access
+from open_webui.models.users import Users, UserResponse
+
+
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
+from sqlalchemy import or_, func, select, and_, text
+from sqlalchemy.sql import exists
+
+####################
+# Note DB Schema
+####################
+
+
+class Note(Base):
+    __tablename__ = "note"
+
+    id = Column(Text, primary_key=True)
+    user_id = Column(Text)
+
+    title = Column(Text)
+    data = Column(JSON, nullable=True)
+    meta = Column(JSON, nullable=True)
+
+    access_control = Column(JSON, nullable=True)
+
+    created_at = Column(BigInteger)
+    updated_at = Column(BigInteger)
+
+
+class NoteModel(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+
+    id: str
+    user_id: str
+
+    title: str
+    data: Optional[dict] = None
+    meta: Optional[dict] = None
+
+    access_control: Optional[dict] = None
+
+    created_at: int  # timestamp in epoch
+    updated_at: int  # timestamp in epoch
+
+
+####################
+# Forms
+####################
+
+
+class NoteForm(BaseModel):
+    title: str
+    data: Optional[dict] = None
+    meta: Optional[dict] = None
+    access_control: Optional[dict] = None
+
+
+class NoteUserResponse(NoteModel):
+    user: Optional[UserResponse] = None
+
+
+class NoteTable:
+    def insert_new_note(
+        self,
+        form_data: NoteForm,
+        user_id: str,
+    ) -> Optional[NoteModel]:
+        with get_db() as db:
+            note = NoteModel(
+                **{
+                    "id": str(uuid.uuid4()),
+                    "user_id": user_id,
+                    **form_data.model_dump(),
+                    "created_at": int(time.time_ns()),
+                    "updated_at": int(time.time_ns()),
+                }
+            )
+
+            new_note = Note(**note.model_dump())
+
+            db.add(new_note)
+            db.commit()
+            return note
+
+    def get_notes(self) -> list[NoteModel]:
+        with get_db() as db:
+            notes = db.query(Note).order_by(Note.updated_at.desc()).all()
+            return [NoteModel.model_validate(note) for note in notes]
+
+    def get_notes_by_user_id(
+        self, user_id: str, permission: str = "write"
+    ) -> list[NoteModel]:
+        notes = self.get_notes()
+        return [
+            note
+            for note in notes
+            if note.user_id == user_id
+            or has_access(user_id, permission, note.access_control)
+        ]
+
+    def get_note_by_id(self, id: str) -> Optional[NoteModel]:
+        with get_db() as db:
+            note = db.query(Note).filter(Note.id == id).first()
+            return NoteModel.model_validate(note) if note else None
+
+    def update_note_by_id(self, id: str, form_data: NoteForm) -> Optional[NoteModel]:
+        with get_db() as db:
+            note = db.query(Note).filter(Note.id == id).first()
+            if not note:
+                return None
+
+            note.title = form_data.title
+            note.data = form_data.data
+            note.meta = form_data.meta
+            note.access_control = form_data.access_control
+            note.updated_at = int(time.time_ns())
+
+            db.commit()
+            return NoteModel.model_validate(note) if note else None
+
+    def delete_note_by_id(self, id: str):
+        with get_db() as db:
+            db.query(Note).filter(Note.id == id).delete()
+            db.commit()
+            return True
+
+
+Notes = NoteTable()

+ 66 - 5
backend/open_webui/models/users.py

@@ -10,6 +10,8 @@ from open_webui.models.groups import Groups
 
 from pydantic import BaseModel, ConfigDict
 from sqlalchemy import BigInteger, Column, String, Text
+from sqlalchemy import or_
+
 
 ####################
 # User DB Schema
@@ -67,6 +69,11 @@ class UserModel(BaseModel):
 ####################
 
 
+class UserListResponse(BaseModel):
+    users: list[UserModel]
+    total: int
+
+
 class UserResponse(BaseModel):
     id: str
     name: str
@@ -160,11 +167,63 @@ class UsersTable:
             return None
 
     def get_users(
-        self, skip: Optional[int] = None, limit: Optional[int] = None
-    ) -> list[UserModel]:
+        self,
+        filter: Optional[dict] = None,
+        skip: Optional[int] = None,
+        limit: Optional[int] = None,
+    ) -> UserListResponse:
         with get_db() as db:
+            query = db.query(User)
+
+            if filter:
+                query_key = filter.get("query")
+                if query_key:
+                    query = query.filter(
+                        or_(
+                            User.name.ilike(f"%{query_key}%"),
+                            User.email.ilike(f"%{query_key}%"),
+                        )
+                    )
 
-            query = db.query(User).order_by(User.created_at.desc())
+                order_by = filter.get("order_by")
+                direction = filter.get("direction")
+
+                if order_by == "name":
+                    if direction == "asc":
+                        query = query.order_by(User.name.asc())
+                    else:
+                        query = query.order_by(User.name.desc())
+                elif order_by == "email":
+                    if direction == "asc":
+                        query = query.order_by(User.email.asc())
+                    else:
+                        query = query.order_by(User.email.desc())
+
+                elif order_by == "created_at":
+                    if direction == "asc":
+                        query = query.order_by(User.created_at.asc())
+                    else:
+                        query = query.order_by(User.created_at.desc())
+
+                elif order_by == "last_active_at":
+                    if direction == "asc":
+                        query = query.order_by(User.last_active_at.asc())
+                    else:
+                        query = query.order_by(User.last_active_at.desc())
+
+                elif order_by == "updated_at":
+                    if direction == "asc":
+                        query = query.order_by(User.updated_at.asc())
+                    else:
+                        query = query.order_by(User.updated_at.desc())
+                elif order_by == "role":
+                    if direction == "asc":
+                        query = query.order_by(User.role.asc())
+                    else:
+                        query = query.order_by(User.role.desc())
+
+            else:
+                query = query.order_by(User.created_at.desc())
 
             if skip:
                 query = query.offset(skip)
@@ -172,8 +231,10 @@ class UsersTable:
                 query = query.limit(limit)
 
             users = query.all()
-
-            return [UserModel.model_validate(user) for user in users]
+            return {
+                "users": [UserModel.model_validate(user) for user in users],
+                "total": db.query(User).count(),
+            }
 
     def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
         with get_db() as db:

+ 53 - 0
backend/open_webui/retrieval/loaders/external.py

@@ -0,0 +1,53 @@
+import requests
+import logging
+from typing import Iterator, List, Union
+
+from langchain_core.document_loaders import BaseLoader
+from langchain_core.documents import Document
+from open_webui.env import SRC_LOG_LEVELS
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+class ExternalLoader(BaseLoader):
+    def __init__(
+        self,
+        web_paths: Union[str, List[str]],
+        external_url: str,
+        external_api_key: str,
+        continue_on_failure: bool = True,
+        **kwargs,
+    ) -> None:
+        self.external_url = external_url
+        self.external_api_key = external_api_key
+        self.urls = web_paths if isinstance(web_paths, list) else [web_paths]
+        self.continue_on_failure = continue_on_failure
+
+    def lazy_load(self) -> Iterator[Document]:
+        batch_size = 20
+        for i in range(0, len(self.urls), batch_size):
+            urls = self.urls[i : i + batch_size]
+            try:
+                response = requests.post(
+                    self.external_url,
+                    headers={
+                        "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
+                        "Authorization": f"Bearer {self.external_api_key}",
+                    },
+                    json={
+                        "urls": urls,
+                    },
+                )
+                response.raise_for_status()
+                results = response.json()
+                for result in results:
+                    yield Document(
+                        page_content=result.get("page_content", ""),
+                        metadata=result.get("metadata", {}),
+                    )
+            except Exception as e:
+                if self.continue_on_failure:
+                    log.error(f"Error extracting content from batch {urls}: {e}")
+                else:
+                    raise e

+ 16 - 1
backend/open_webui/retrieval/loaders/main.py

@@ -99,6 +99,9 @@ class TikaLoader:
         else:
             headers = {}
 
+        if self.kwargs.get("PDF_EXTRACT_IMAGES") == True:
+            headers["X-Tika-PDFextractInlineImages"] = "true"
+
         endpoint = self.url
         if not endpoint.endswith("/"):
             endpoint += "/"
@@ -121,10 +124,14 @@ class TikaLoader:
 
 
 class DoclingLoader:
-    def __init__(self, url, file_path=None, mime_type=None):
+    def __init__(
+        self, url, file_path=None, mime_type=None, ocr_engine=None, ocr_lang=None
+    ):
         self.url = url.rstrip("/")
         self.file_path = file_path
         self.mime_type = mime_type
+        self.ocr_engine = ocr_engine
+        self.ocr_lang = ocr_lang
 
     def load(self) -> list[Document]:
         with open(self.file_path, "rb") as f:
@@ -141,6 +148,12 @@ class DoclingLoader:
                 "table_mode": "accurate",
             }
 
+            if self.ocr_engine and self.ocr_lang:
+                params["ocr_engine"] = self.ocr_engine
+                params["ocr_lang"] = [
+                    lang.strip() for lang in self.ocr_lang.split(",") if lang.strip()
+                ]
+
             endpoint = f"{self.url}/v1alpha/convert/file"
             r = requests.post(endpoint, files=files, data=params)
 
@@ -209,6 +222,8 @@ class Loader:
                     url=self.kwargs.get("DOCLING_SERVER_URL"),
                     file_path=file_path,
                     mime_type=file_content_type,
+                    ocr_engine=self.kwargs.get("DOCLING_OCR_ENGINE"),
+                    ocr_lang=self.kwargs.get("DOCLING_OCR_LANG"),
                 )
         elif (
             self.engine == "document_intelligence"

+ 41 - 17
backend/open_webui/retrieval/utils.py

@@ -207,7 +207,7 @@ def merge_and_sort_query_results(query_results: list[dict], k: int) -> dict:
 
         for distance, document, metadata in zip(distances, documents, metadatas):
             if isinstance(document, str):
-                doc_hash = hashlib.md5(
+                doc_hash = hashlib.sha256(
                     document.encode()
                 ).hexdigest()  # Compute a hash for uniqueness
 
@@ -260,23 +260,47 @@ def query_collection(
     k: int,
 ) -> dict:
     results = []
-    for query in queries:
-        log.debug(f"query_collection:query {query}")
-        query_embedding = embedding_function(query, prefix=RAG_EMBEDDING_QUERY_PREFIX)
-        for collection_name in collection_names:
+    error = False
+
+    def process_query_collection(collection_name, query_embedding):
+        try:
             if collection_name:
-                try:
-                    result = query_doc(
-                        collection_name=collection_name,
-                        k=k,
-                        query_embedding=query_embedding,
-                    )
-                    if result is not None:
-                        results.append(result.model_dump())
-                except Exception as e:
-                    log.exception(f"Error when querying the collection: {e}")
-            else:
-                pass
+                result = query_doc(
+                    collection_name=collection_name,
+                    k=k,
+                    query_embedding=query_embedding,
+                )
+                if result is not None:
+                    return result.model_dump(), None
+            return None, None
+        except Exception as e:
+            log.exception(f"Error when querying the collection: {e}")
+            return None, e
+
+    # Generate all query embeddings (in one call)
+    query_embeddings = embedding_function(queries, prefix=RAG_EMBEDDING_QUERY_PREFIX)
+    log.debug(
+        f"query_collection: processing {len(queries)} queries across {len(collection_names)} collections"
+    )
+
+    with ThreadPoolExecutor() as executor:
+        future_results = []
+        for query_embedding in query_embeddings:
+            for collection_name in collection_names:
+                result = executor.submit(
+                    process_query_collection, collection_name, query_embedding
+                )
+                future_results.append(result)
+        task_results = [future.result() for future in future_results]
+
+    for result, err in task_results:
+        if err is not None:
+            error = True
+        elif result is not None:
+            results.append(result)
+
+    if error and not results:
+        log.warning("All collection queries failed. No results returned.")
 
     return merge_and_sort_query_results(results, k=k)
 

+ 4 - 0
backend/open_webui/retrieval/vector/connector.py

@@ -20,6 +20,10 @@ elif VECTOR_DB == "elasticsearch":
     from open_webui.retrieval.vector.dbs.elasticsearch import ElasticsearchClient
 
     VECTOR_DB_CLIENT = ElasticsearchClient()
+elif VECTOR_DB == "pinecone":
+    from open_webui.retrieval.vector.dbs.pinecone import PineconeClient
+
+    VECTOR_DB_CLIENT = PineconeClient()
 else:
     from open_webui.retrieval.vector.dbs.chroma import ChromaClient
 

+ 7 - 2
backend/open_webui/retrieval/vector/dbs/chroma.py

@@ -5,7 +5,12 @@ from chromadb.utils.batch_utils import create_batches
 
 from typing import Optional
 
-from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
+from open_webui.retrieval.vector.main import (
+    VectorDBBase,
+    VectorItem,
+    SearchResult,
+    GetResult,
+)
 from open_webui.config import (
     CHROMA_DATA_PATH,
     CHROMA_HTTP_HOST,
@@ -23,7 +28,7 @@ log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["RAG"])
 
 
-class ChromaClient:
+class ChromaClient(VectorDBBase):
     def __init__(self):
         settings_dict = {
             "allow_reset": True,

+ 7 - 2
backend/open_webui/retrieval/vector/dbs/elasticsearch.py

@@ -2,7 +2,12 @@ from elasticsearch import Elasticsearch, BadRequestError
 from typing import Optional
 import ssl
 from elasticsearch.helpers import bulk, scan
-from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
+from open_webui.retrieval.vector.main import (
+    VectorDBBase,
+    VectorItem,
+    SearchResult,
+    GetResult,
+)
 from open_webui.config import (
     ELASTICSEARCH_URL,
     ELASTICSEARCH_CA_CERTS,
@@ -15,7 +20,7 @@ from open_webui.config import (
 )
 
 
-class ElasticsearchClient:
+class ElasticsearchClient(VectorDBBase):
     """
     Important:
     in order to reduce the number of indexes and since the embedding vector length is fixed, we avoid creating

+ 7 - 2
backend/open_webui/retrieval/vector/dbs/milvus.py

@@ -4,7 +4,12 @@ import json
 import logging
 from typing import Optional
 
-from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
+from open_webui.retrieval.vector.main import (
+    VectorDBBase,
+    VectorItem,
+    SearchResult,
+    GetResult,
+)
 from open_webui.config import (
     MILVUS_URI,
     MILVUS_DB,
@@ -16,7 +21,7 @@ log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["RAG"])
 
 
-class MilvusClient:
+class MilvusClient(VectorDBBase):
     def __init__(self):
         self.collection_prefix = "open_webui"
         if MILVUS_TOKEN is None:

+ 7 - 2
backend/open_webui/retrieval/vector/dbs/opensearch.py

@@ -2,7 +2,12 @@ from opensearchpy import OpenSearch
 from opensearchpy.helpers import bulk
 from typing import Optional
 
-from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
+from open_webui.retrieval.vector.main import (
+    VectorDBBase,
+    VectorItem,
+    SearchResult,
+    GetResult,
+)
 from open_webui.config import (
     OPENSEARCH_URI,
     OPENSEARCH_SSL,
@@ -12,7 +17,7 @@ from open_webui.config import (
 )
 
 
-class OpenSearchClient:
+class OpenSearchClient(VectorDBBase):
     def __init__(self):
         self.index_prefix = "open_webui"
         self.client = OpenSearch(

+ 9 - 5
backend/open_webui/retrieval/vector/dbs/pgvector.py

@@ -22,7 +22,12 @@ from pgvector.sqlalchemy import Vector
 from sqlalchemy.ext.mutable import MutableDict
 from sqlalchemy.exc import NoSuchTableError
 
-from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
+from open_webui.retrieval.vector.main import (
+    VectorDBBase,
+    VectorItem,
+    SearchResult,
+    GetResult,
+)
 from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
 
 from open_webui.env import SRC_LOG_LEVELS
@@ -44,7 +49,7 @@ class DocumentChunk(Base):
     vmetadata = Column(MutableDict.as_mutable(JSONB), nullable=True)
 
 
-class PgvectorClient:
+class PgvectorClient(VectorDBBase):
     def __init__(self) -> None:
 
         # if no pgvector uri, use the existing database connection
@@ -136,9 +141,8 @@ class PgvectorClient:
             # Pad the vector with zeros
             vector += [0.0] * (VECTOR_LENGTH - current_length)
         elif current_length > VECTOR_LENGTH:
-            raise Exception(
-                f"Vector length {current_length} not supported. Max length must be <= {VECTOR_LENGTH}"
-            )
+            # Truncate the vector to VECTOR_LENGTH
+            vector = vector[:VECTOR_LENGTH]
         return vector
 
     def insert(self, collection_name: str, items: List[VectorItem]) -> None:

+ 412 - 0
backend/open_webui/retrieval/vector/dbs/pinecone.py

@@ -0,0 +1,412 @@
+from typing import Optional, List, Dict, Any, Union
+import logging
+from pinecone import Pinecone, ServerlessSpec
+
+from open_webui.retrieval.vector.main import (
+    VectorDBBase,
+    VectorItem,
+    SearchResult,
+    GetResult,
+)
+from open_webui.config import (
+    PINECONE_API_KEY,
+    PINECONE_ENVIRONMENT,
+    PINECONE_INDEX_NAME,
+    PINECONE_DIMENSION,
+    PINECONE_METRIC,
+    PINECONE_CLOUD,
+)
+from open_webui.env import SRC_LOG_LEVELS
+
+NO_LIMIT = 10000  # Reasonable limit to avoid overwhelming the system
+BATCH_SIZE = 100  # Recommended batch size for Pinecone operations
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+class PineconeClient(VectorDBBase):
+    def __init__(self):
+        self.collection_prefix = "open-webui"
+
+        # Validate required configuration
+        self._validate_config()
+
+        # Store configuration values
+        self.api_key = PINECONE_API_KEY
+        self.environment = PINECONE_ENVIRONMENT
+        self.index_name = PINECONE_INDEX_NAME
+        self.dimension = PINECONE_DIMENSION
+        self.metric = PINECONE_METRIC
+        self.cloud = PINECONE_CLOUD
+
+        # Initialize Pinecone client
+        self.client = Pinecone(api_key=self.api_key)
+
+        # Create index if it doesn't exist
+        self._initialize_index()
+
+    def _validate_config(self) -> None:
+        """Validate that all required configuration variables are set."""
+        missing_vars = []
+        if not PINECONE_API_KEY:
+            missing_vars.append("PINECONE_API_KEY")
+        if not PINECONE_ENVIRONMENT:
+            missing_vars.append("PINECONE_ENVIRONMENT")
+        if not PINECONE_INDEX_NAME:
+            missing_vars.append("PINECONE_INDEX_NAME")
+        if not PINECONE_DIMENSION:
+            missing_vars.append("PINECONE_DIMENSION")
+        if not PINECONE_CLOUD:
+            missing_vars.append("PINECONE_CLOUD")
+
+        if missing_vars:
+            raise ValueError(
+                f"Required configuration missing: {', '.join(missing_vars)}"
+            )
+
+    def _initialize_index(self) -> None:
+        """Initialize the Pinecone index."""
+        try:
+            # Check if index exists
+            if self.index_name not in self.client.list_indexes().names():
+                log.info(f"Creating Pinecone index '{self.index_name}'...")
+                self.client.create_index(
+                    name=self.index_name,
+                    dimension=self.dimension,
+                    metric=self.metric,
+                    spec=ServerlessSpec(cloud=self.cloud, region=self.environment),
+                )
+                log.info(f"Successfully created Pinecone index '{self.index_name}'")
+            else:
+                log.info(f"Using existing Pinecone index '{self.index_name}'")
+
+            # Connect to the index
+            self.index = self.client.Index(self.index_name)
+
+        except Exception as e:
+            log.error(f"Failed to initialize Pinecone index: {e}")
+            raise RuntimeError(f"Failed to initialize Pinecone index: {e}")
+
+    def _create_points(
+        self, items: List[VectorItem], collection_name_with_prefix: str
+    ) -> List[Dict[str, Any]]:
+        """Convert VectorItem objects to Pinecone point format."""
+        points = []
+        for item in items:
+            # Start with any existing metadata or an empty dict
+            metadata = item.get("metadata", {}).copy() if item.get("metadata") else {}
+
+            # Add text to metadata if available
+            if "text" in item:
+                metadata["text"] = item["text"]
+
+            # Always add collection_name to metadata for filtering
+            metadata["collection_name"] = collection_name_with_prefix
+
+            point = {
+                "id": item["id"],
+                "values": item["vector"],
+                "metadata": metadata,
+            }
+            points.append(point)
+        return points
+
+    def _get_collection_name_with_prefix(self, collection_name: str) -> str:
+        """Get the collection name with prefix."""
+        return f"{self.collection_prefix}_{collection_name}"
+
+    def _normalize_distance(self, score: float) -> float:
+        """Normalize distance score based on the metric used."""
+        if self.metric.lower() == "cosine":
+            # Cosine similarity ranges from -1 to 1, normalize to 0 to 1
+            return (score + 1.0) / 2.0
+        elif self.metric.lower() in ["euclidean", "dotproduct"]:
+            # These are already suitable for ranking (smaller is better for Euclidean)
+            return score
+        else:
+            # For other metrics, use as is
+            return score
+
+    def _result_to_get_result(self, matches: list) -> GetResult:
+        """Convert Pinecone matches to GetResult format."""
+        ids = []
+        documents = []
+        metadatas = []
+
+        for match in matches:
+            metadata = match.get("metadata", {})
+            ids.append(match["id"])
+            documents.append(metadata.get("text", ""))
+            metadatas.append(metadata)
+
+        return GetResult(
+            **{
+                "ids": [ids],
+                "documents": [documents],
+                "metadatas": [metadatas],
+            }
+        )
+
+    def has_collection(self, collection_name: str) -> bool:
+        """Check if a collection exists by searching for at least one item."""
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+
+        try:
+            # Search for at least 1 item with this collection name in metadata
+            response = self.index.query(
+                vector=[0.0] * self.dimension,  # dummy vector
+                top_k=1,
+                filter={"collection_name": collection_name_with_prefix},
+                include_metadata=False,
+            )
+            return len(response.matches) > 0
+        except Exception as e:
+            log.exception(
+                f"Error checking collection '{collection_name_with_prefix}': {e}"
+            )
+            return False
+
+    def delete_collection(self, collection_name: str) -> None:
+        """Delete a collection by removing all vectors with the collection name in metadata."""
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+        try:
+            self.index.delete(filter={"collection_name": collection_name_with_prefix})
+            log.info(
+                f"Collection '{collection_name_with_prefix}' deleted (all vectors removed)."
+            )
+        except Exception as e:
+            log.warning(
+                f"Failed to delete collection '{collection_name_with_prefix}': {e}"
+            )
+            raise
+
+    def insert(self, collection_name: str, items: List[VectorItem]) -> None:
+        """Insert vectors into a collection."""
+        if not items:
+            log.warning("No items to insert")
+            return
+
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+        points = self._create_points(items, collection_name_with_prefix)
+
+        # Insert in batches for better performance and reliability
+        for i in range(0, len(points), BATCH_SIZE):
+            batch = points[i : i + BATCH_SIZE]
+            try:
+                self.index.upsert(vectors=batch)
+                log.debug(
+                    f"Inserted batch of {len(batch)} vectors into '{collection_name_with_prefix}'"
+                )
+            except Exception as e:
+                log.error(
+                    f"Error inserting batch into '{collection_name_with_prefix}': {e}"
+                )
+                raise
+
+        log.info(
+            f"Successfully inserted {len(items)} vectors into '{collection_name_with_prefix}'"
+        )
+
+    def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
+        """Upsert (insert or update) vectors into a collection."""
+        if not items:
+            log.warning("No items to upsert")
+            return
+
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+        points = self._create_points(items, collection_name_with_prefix)
+
+        # Upsert in batches
+        for i in range(0, len(points), BATCH_SIZE):
+            batch = points[i : i + BATCH_SIZE]
+            try:
+                self.index.upsert(vectors=batch)
+                log.debug(
+                    f"Upserted batch of {len(batch)} vectors into '{collection_name_with_prefix}'"
+                )
+            except Exception as e:
+                log.error(
+                    f"Error upserting batch into '{collection_name_with_prefix}': {e}"
+                )
+                raise
+
+        log.info(
+            f"Successfully upserted {len(items)} vectors into '{collection_name_with_prefix}'"
+        )
+
+    def search(
+        self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
+    ) -> Optional[SearchResult]:
+        """Search for similar vectors in a collection."""
+        if not vectors or not vectors[0]:
+            log.warning("No vectors provided for search")
+            return None
+
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+
+        if limit is None or limit <= 0:
+            limit = NO_LIMIT
+
+        try:
+            # Search using the first vector (assuming this is the intended behavior)
+            query_vector = vectors[0]
+
+            # Perform the search
+            query_response = self.index.query(
+                vector=query_vector,
+                top_k=limit,
+                include_metadata=True,
+                filter={"collection_name": collection_name_with_prefix},
+            )
+
+            if not query_response.matches:
+                # Return empty result if no matches
+                return SearchResult(
+                    ids=[[]],
+                    documents=[[]],
+                    metadatas=[[]],
+                    distances=[[]],
+                )
+
+            # Convert to GetResult format
+            get_result = self._result_to_get_result(query_response.matches)
+
+            # Calculate normalized distances based on metric
+            distances = [
+                [
+                    self._normalize_distance(match.score)
+                    for match in query_response.matches
+                ]
+            ]
+
+            return SearchResult(
+                ids=get_result.ids,
+                documents=get_result.documents,
+                metadatas=get_result.metadatas,
+                distances=distances,
+            )
+        except Exception as e:
+            log.error(f"Error searching in '{collection_name_with_prefix}': {e}")
+            return None
+
+    def query(
+        self, collection_name: str, filter: Dict, limit: Optional[int] = None
+    ) -> Optional[GetResult]:
+        """Query vectors by metadata filter."""
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+
+        if limit is None or limit <= 0:
+            limit = NO_LIMIT
+
+        try:
+            # Create a zero vector for the dimension as Pinecone requires a vector
+            zero_vector = [0.0] * self.dimension
+
+            # Combine user filter with collection_name
+            pinecone_filter = {"collection_name": collection_name_with_prefix}
+            if filter:
+                pinecone_filter.update(filter)
+
+            # Perform metadata-only query
+            query_response = self.index.query(
+                vector=zero_vector,
+                filter=pinecone_filter,
+                top_k=limit,
+                include_metadata=True,
+            )
+
+            return self._result_to_get_result(query_response.matches)
+
+        except Exception as e:
+            log.error(f"Error querying collection '{collection_name}': {e}")
+            return None
+
+    def get(self, collection_name: str) -> Optional[GetResult]:
+        """Get all vectors in a collection."""
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+
+        try:
+            # Use a zero vector for fetching all entries
+            zero_vector = [0.0] * self.dimension
+
+            # Add filter to only get vectors for this collection
+            query_response = self.index.query(
+                vector=zero_vector,
+                top_k=NO_LIMIT,
+                include_metadata=True,
+                filter={"collection_name": collection_name_with_prefix},
+            )
+
+            return self._result_to_get_result(query_response.matches)
+
+        except Exception as e:
+            log.error(f"Error getting collection '{collection_name}': {e}")
+            return None
+
+    def delete(
+        self,
+        collection_name: str,
+        ids: Optional[List[str]] = None,
+        filter: Optional[Dict] = None,
+    ) -> None:
+        """Delete vectors by IDs or filter."""
+        collection_name_with_prefix = self._get_collection_name_with_prefix(
+            collection_name
+        )
+
+        try:
+            if ids:
+                # Delete by IDs (in batches for large deletions)
+                for i in range(0, len(ids), BATCH_SIZE):
+                    batch_ids = ids[i : i + BATCH_SIZE]
+                    # Note: When deleting by ID, we can't filter by collection_name
+                    # This is a limitation of Pinecone - be careful with ID uniqueness
+                    self.index.delete(ids=batch_ids)
+                    log.debug(
+                        f"Deleted batch of {len(batch_ids)} vectors by ID from '{collection_name_with_prefix}'"
+                    )
+                log.info(
+                    f"Successfully deleted {len(ids)} vectors by ID from '{collection_name_with_prefix}'"
+                )
+
+            elif filter:
+                # Combine user filter with collection_name
+                pinecone_filter = {"collection_name": collection_name_with_prefix}
+                if filter:
+                    pinecone_filter.update(filter)
+                # Delete by metadata filter
+                self.index.delete(filter=pinecone_filter)
+                log.info(
+                    f"Successfully deleted vectors by filter from '{collection_name_with_prefix}'"
+                )
+
+            else:
+                log.warning("No ids or filter provided for delete operation")
+
+        except Exception as e:
+            log.error(f"Error deleting from collection '{collection_name}': {e}")
+            raise
+
+    def reset(self) -> None:
+        """Reset the database by deleting all collections."""
+        try:
+            self.index.delete(delete_all=True)
+            log.info("All vectors successfully deleted from the index.")
+        except Exception as e:
+            log.error(f"Failed to reset Pinecone index: {e}")
+            raise

+ 41 - 9
backend/open_webui/retrieval/vector/dbs/qdrant.py

@@ -1,12 +1,24 @@
 from typing import Optional
 import logging
+from urllib.parse import urlparse
 
 from qdrant_client import QdrantClient as Qclient
 from qdrant_client.http.models import PointStruct
 from qdrant_client.models import models
 
-from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
-from open_webui.config import QDRANT_URI, QDRANT_API_KEY
+from open_webui.retrieval.vector.main import (
+    VectorDBBase,
+    VectorItem,
+    SearchResult,
+    GetResult,
+)
+from open_webui.config import (
+    QDRANT_URI,
+    QDRANT_API_KEY,
+    QDRANT_ON_DISK,
+    QDRANT_GRPC_PORT,
+    QDRANT_PREFER_GRPC,
+)
 from open_webui.env import SRC_LOG_LEVELS
 
 NO_LIMIT = 999999999
@@ -15,16 +27,34 @@ log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["RAG"])
 
 
-class QdrantClient:
+class QdrantClient(VectorDBBase):
     def __init__(self):
         self.collection_prefix = "open-webui"
         self.QDRANT_URI = QDRANT_URI
         self.QDRANT_API_KEY = QDRANT_API_KEY
-        self.client = (
-            Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
-            if self.QDRANT_URI
-            else None
-        )
+        self.QDRANT_ON_DISK = QDRANT_ON_DISK
+        self.PREFER_GRPC = QDRANT_PREFER_GRPC
+        self.GRPC_PORT = QDRANT_GRPC_PORT
+
+        if not self.QDRANT_URI:
+            self.client = None
+            return
+
+        # Unified handling for either scheme
+        parsed = urlparse(self.QDRANT_URI)
+        host = parsed.hostname or self.QDRANT_URI
+        http_port = parsed.port or 6333  # default REST port
+
+        if self.PREFER_GRPC:
+            self.client = Qclient(
+                host=host,
+                port=http_port,
+                grpc_port=self.GRPC_PORT,
+                prefer_grpc=self.PREFER_GRPC,
+                api_key=self.QDRANT_API_KEY,
+            )
+        else:
+            self.client = Qclient(url=self.QDRANT_URI, api_key=self.QDRANT_API_KEY)
 
     def _result_to_get_result(self, points) -> GetResult:
         ids = []
@@ -50,7 +80,9 @@ class QdrantClient:
         self.client.create_collection(
             collection_name=collection_name_with_prefix,
             vectors_config=models.VectorParams(
-                size=dimension, distance=models.Distance.COSINE
+                size=dimension,
+                distance=models.Distance.COSINE,
+                on_disk=self.QDRANT_ON_DISK,
             ),
         )
 

+ 68 - 1
backend/open_webui/retrieval/vector/main.py

@@ -1,5 +1,6 @@
 from pydantic import BaseModel
-from typing import Optional, List, Any
+from abc import ABC, abstractmethod
+from typing import Any, Dict, List, Optional, Union
 
 
 class VectorItem(BaseModel):
@@ -17,3 +18,69 @@ class GetResult(BaseModel):
 
 class SearchResult(GetResult):
     distances: Optional[List[List[float | int]]]
+
+
+class VectorDBBase(ABC):
+    """
+    Abstract base class for all vector database backends.
+
+    Implementations of this class provide methods for collection management,
+    vector insertion, deletion, similarity search, and metadata filtering.
+
+    Any custom vector database integration must inherit from this class and
+    implement all abstract methods.
+    """
+
+    @abstractmethod
+    def has_collection(self, collection_name: str) -> bool:
+        """Check if the collection exists in the vector DB."""
+        pass
+
+    @abstractmethod
+    def delete_collection(self, collection_name: str) -> None:
+        """Delete a collection from the vector DB."""
+        pass
+
+    @abstractmethod
+    def insert(self, collection_name: str, items: List[VectorItem]) -> None:
+        """Insert a list of vector items into a collection."""
+        pass
+
+    @abstractmethod
+    def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
+        """Insert or update vector items in a collection."""
+        pass
+
+    @abstractmethod
+    def search(
+        self, collection_name: str, vectors: List[List[Union[float, int]]], limit: int
+    ) -> Optional[SearchResult]:
+        """Search for similar vectors in a collection."""
+        pass
+
+    @abstractmethod
+    def query(
+        self, collection_name: str, filter: Dict, limit: Optional[int] = None
+    ) -> Optional[GetResult]:
+        """Query vectors from a collection using metadata filter."""
+        pass
+
+    @abstractmethod
+    def get(self, collection_name: str) -> Optional[GetResult]:
+        """Retrieve all vectors from a collection."""
+        pass
+
+    @abstractmethod
+    def delete(
+        self,
+        collection_name: str,
+        ids: Optional[List[str]] = None,
+        filter: Optional[Dict] = None,
+    ) -> None:
+        """Delete vectors by ID or filter from a collection."""
+        pass
+
+    @abstractmethod
+    def reset(self) -> None:
+        """Reset the vector database by removing all collections or those matching a condition."""
+        pass

+ 47 - 0
backend/open_webui/retrieval/web/external.py

@@ -0,0 +1,47 @@
+import logging
+from typing import Optional, List
+
+import requests
+from open_webui.retrieval.web.main import SearchResult, get_filtered_results
+from open_webui.env import SRC_LOG_LEVELS
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+def search_external(
+    external_url: str,
+    external_api_key: str,
+    query: str,
+    count: int,
+    filter_list: Optional[List[str]] = None,
+) -> List[SearchResult]:
+    try:
+        response = requests.post(
+            external_url,
+            headers={
+                "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
+                "Authorization": f"Bearer {external_api_key}",
+            },
+            json={
+                "query": query,
+                "count": count,
+            },
+        )
+        response.raise_for_status()
+        results = response.json()
+        if filter_list:
+            results = get_filtered_results(results, filter_list)
+        results = [
+            SearchResult(
+                link=result.get("link"),
+                title=result.get("title"),
+                snippet=result.get("snippet"),
+            )
+            for result in results[:count]
+        ]
+        log.info(f"External search results: {results}")
+        return results
+    except Exception as e:
+        log.error(f"Error in External search: {e}")
+        return []

+ 49 - 0
backend/open_webui/retrieval/web/firecrawl.py

@@ -0,0 +1,49 @@
+import logging
+from typing import Optional, List
+from urllib.parse import urljoin
+
+import requests
+from open_webui.retrieval.web.main import SearchResult, get_filtered_results
+from open_webui.env import SRC_LOG_LEVELS
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+def search_firecrawl(
+    firecrawl_url: str,
+    firecrawl_api_key: str,
+    query: str,
+    count: int,
+    filter_list: Optional[List[str]] = None,
+) -> List[SearchResult]:
+    try:
+        firecrawl_search_url = urljoin(firecrawl_url, "/v1/search")
+        response = requests.post(
+            firecrawl_search_url,
+            headers={
+                "User-Agent": "Open WebUI (https://github.com/open-webui/open-webui) RAG Bot",
+                "Authorization": f"Bearer {firecrawl_api_key}",
+            },
+            json={
+                "query": query,
+                "limit": count,
+            },
+        )
+        response.raise_for_status()
+        results = response.json().get("data", [])
+        if filter_list:
+            results = get_filtered_results(results, filter_list)
+        results = [
+            SearchResult(
+                link=result.get("url"),
+                title=result.get("title"),
+                snippet=result.get("description"),
+            )
+            for result in results[:count]
+        ]
+        log.info(f"External search results: {results}")
+        return results
+    except Exception as e:
+        log.error(f"Error in External search: {e}")
+        return []

+ 12 - 5
backend/open_webui/retrieval/web/tavily.py

@@ -2,7 +2,7 @@ import logging
 from typing import Optional
 
 import requests
-from open_webui.retrieval.web.main import SearchResult
+from open_webui.retrieval.web.main import SearchResult, get_filtered_results
 from open_webui.env import SRC_LOG_LEVELS
 
 log = logging.getLogger(__name__)
@@ -21,18 +21,25 @@ def search_tavily(
     Args:
         api_key (str): A Tavily Search API key
         query (str): The query to search for
+        count (int): The maximum number of results to return
 
     Returns:
         list[SearchResult]: A list of search results
     """
     url = "https://api.tavily.com/search"
-    data = {"query": query, "api_key": api_key}
-    response = requests.post(url, json=data)
+    headers = {
+        "Content-Type": "application/json",
+        "Authorization": f"Bearer {api_key}",
+    }
+    data = {"query": query, "max_results": count}
+    response = requests.post(url, headers=headers, json=data)
     response.raise_for_status()
 
     json_response = response.json()
 
-    raw_search_results = json_response.get("results", [])
+    results = json_response.get("results", [])
+    if filter_list:
+        results = get_filtered_results(results, filter_list)
 
     return [
         SearchResult(
@@ -40,5 +47,5 @@ def search_tavily(
             title=result.get("title", ""),
             snippet=result.get("content"),
         )
-        for result in raw_search_results[:count]
+        for result in results
     ]

+ 15 - 2
backend/open_webui/retrieval/web/utils.py

@@ -25,6 +25,7 @@ from langchain_community.document_loaders.firecrawl import FireCrawlLoader
 from langchain_community.document_loaders.base import BaseLoader
 from langchain_core.documents import Document
 from open_webui.retrieval.loaders.tavily import TavilyLoader
+from open_webui.retrieval.loaders.external import ExternalLoader
 from open_webui.constants import ERROR_MESSAGES
 from open_webui.config import (
     ENABLE_RAG_LOCAL_WEB_FETCH,
@@ -35,6 +36,8 @@ from open_webui.config import (
     FIRECRAWL_API_KEY,
     TAVILY_API_KEY,
     TAVILY_EXTRACT_DEPTH,
+    EXTERNAL_WEB_LOADER_URL,
+    EXTERNAL_WEB_LOADER_API_KEY,
 )
 from open_webui.env import SRC_LOG_LEVELS
 
@@ -167,7 +170,7 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
         continue_on_failure: bool = True,
         api_key: Optional[str] = None,
         api_url: Optional[str] = None,
-        mode: Literal["crawl", "scrape", "map"] = "crawl",
+        mode: Literal["crawl", "scrape", "map"] = "scrape",
         proxy: Optional[Dict[str, str]] = None,
         params: Optional[Dict] = None,
     ):
@@ -225,7 +228,10 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
                     mode=self.mode,
                     params=self.params,
                 )
-                yield from loader.lazy_load()
+                for document in loader.lazy_load():
+                    if not document.metadata.get("source"):
+                        document.metadata["source"] = document.metadata.get("sourceURL")
+                    yield document
             except Exception as e:
                 if self.continue_on_failure:
                     log.exception(f"Error loading {url}: {e}")
@@ -245,6 +251,8 @@ class SafeFireCrawlLoader(BaseLoader, RateLimitMixin, URLProcessingMixin):
                     params=self.params,
                 )
                 async for document in loader.alazy_load():
+                    if not document.metadata.get("source"):
+                        document.metadata["source"] = document.metadata.get("sourceURL")
                     yield document
             except Exception as e:
                 if self.continue_on_failure:
@@ -619,6 +627,11 @@ def get_web_loader(
         web_loader_args["api_key"] = TAVILY_API_KEY.value
         web_loader_args["extract_depth"] = TAVILY_EXTRACT_DEPTH.value
 
+    if WEB_LOADER_ENGINE.value == "external":
+        WebLoaderClass = ExternalLoader
+        web_loader_args["external_url"] = EXTERNAL_WEB_LOADER_URL.value
+        web_loader_args["external_api_key"] = EXTERNAL_WEB_LOADER_API_KEY.value
+
     if WebLoaderClass:
         web_loader = WebLoaderClass(**web_loader_args)
 

+ 87 - 0
backend/open_webui/retrieval/web/yacy.py

@@ -0,0 +1,87 @@
+import logging
+from typing import Optional
+
+import requests
+from requests.auth import HTTPDigestAuth
+from open_webui.retrieval.web.main import SearchResult, get_filtered_results
+from open_webui.env import SRC_LOG_LEVELS
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["RAG"])
+
+
+def search_yacy(
+    query_url: str,
+    username: Optional[str],
+    password: Optional[str],
+    query: str,
+    count: int,
+    filter_list: Optional[list[str]] = None,
+) -> list[SearchResult]:
+    """
+    Search a Yacy instance for a given query and return the results as a list of SearchResult objects.
+
+    The function accepts username and password for authenticating to Yacy.
+
+    Args:
+        query_url (str): The base URL of the Yacy server.
+        username (str): Optional YaCy username.
+        password (str): Optional YaCy password.
+        query (str): The search term or question to find in the Yacy database.
+        count (int): The maximum number of results to retrieve from the search.
+
+    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.
+    """
+
+    # Use authentication if either username or password is set
+    yacy_auth = None
+    if username or password:
+        yacy_auth = HTTPDigestAuth(username, password)
+
+    params = {
+        "query": query,
+        "contentdom": "text",
+        "resource": "global",
+        "maximumRecords": count,
+        "nav": "none",
+    }
+
+    # Check if provided a json API URL
+    if not query_url.endswith("yacysearch.json"):
+        # Strip all query parameters from the URL
+        query_url = query_url.rstrip("/") + "/yacysearch.json"
+
+    log.debug(f"searching {query_url}")
+
+    response = requests.get(
+        query_url,
+        auth=yacy_auth,
+        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("channels", [{}])[0].get("items", [])
+    sorted_results = sorted(results, key=lambda x: x.get("ranking", 0), reverse=True)
+    if filter_list:
+        sorted_results = get_filtered_results(sorted_results, filter_list)
+    return [
+        SearchResult(
+            link=result["link"],
+            title=result.get("title"),
+            snippet=result.get("description"),
+        )
+        for result in sorted_results[:count]
+    ]

+ 27 - 3
backend/open_webui/routers/audio.py

@@ -33,6 +33,7 @@ from open_webui.config import (
     WHISPER_MODEL_AUTO_UPDATE,
     WHISPER_MODEL_DIR,
     CACHE_DIR,
+    WHISPER_LANGUAGE,
 )
 
 from open_webui.constants import ERROR_MESSAGES
@@ -150,6 +151,8 @@ class STTConfigForm(BaseModel):
     AZURE_API_KEY: str
     AZURE_REGION: str
     AZURE_LOCALES: str
+    AZURE_BASE_URL: str
+    AZURE_MAX_SPEAKERS: str
 
 
 class AudioConfigUpdateForm(BaseModel):
@@ -181,6 +184,8 @@ async def get_audio_config(request: Request, user=Depends(get_admin_user)):
             "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
             "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION,
             "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
+            "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
+            "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
         },
     }
 
@@ -210,6 +215,10 @@ async def update_audio_config(
     request.app.state.config.AUDIO_STT_AZURE_API_KEY = form_data.stt.AZURE_API_KEY
     request.app.state.config.AUDIO_STT_AZURE_REGION = form_data.stt.AZURE_REGION
     request.app.state.config.AUDIO_STT_AZURE_LOCALES = form_data.stt.AZURE_LOCALES
+    request.app.state.config.AUDIO_STT_AZURE_BASE_URL = form_data.stt.AZURE_BASE_URL
+    request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS = (
+        form_data.stt.AZURE_MAX_SPEAKERS
+    )
 
     if request.app.state.config.STT_ENGINE == "":
         request.app.state.faster_whisper_model = set_faster_whisper_model(
@@ -238,6 +247,8 @@ async def update_audio_config(
             "AZURE_API_KEY": request.app.state.config.AUDIO_STT_AZURE_API_KEY,
             "AZURE_REGION": request.app.state.config.AUDIO_STT_AZURE_REGION,
             "AZURE_LOCALES": request.app.state.config.AUDIO_STT_AZURE_LOCALES,
+            "AZURE_BASE_URL": request.app.state.config.AUDIO_STT_AZURE_BASE_URL,
+            "AZURE_MAX_SPEAKERS": request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS,
         },
     }
 
@@ -501,6 +512,7 @@ def transcribe(request: Request, file_path):
             file_path,
             beam_size=5,
             vad_filter=request.app.state.config.WHISPER_VAD_FILTER,
+            language=WHISPER_LANGUAGE,
         )
         log.info(
             "Detected language '%s' with probability %f"
@@ -641,6 +653,8 @@ def transcribe(request: Request, file_path):
         api_key = request.app.state.config.AUDIO_STT_AZURE_API_KEY
         region = request.app.state.config.AUDIO_STT_AZURE_REGION
         locales = request.app.state.config.AUDIO_STT_AZURE_LOCALES
+        base_url = request.app.state.config.AUDIO_STT_AZURE_BASE_URL
+        max_speakers = request.app.state.config.AUDIO_STT_AZURE_MAX_SPEAKERS
 
         # IF NO LOCALES, USE DEFAULTS
         if len(locales) < 2:
@@ -664,7 +678,13 @@ def transcribe(request: Request, file_path):
         if not api_key or not region:
             raise HTTPException(
                 status_code=400,
-                detail="Azure API key and region are required for Azure STT",
+                detail="Azure API key is required for Azure STT",
+            )
+
+        if not base_url and not region:
+            raise HTTPException(
+                status_code=400,
+                detail="Azure region or base url is required for Azure STT",
             )
 
         r = None
@@ -674,13 +694,17 @@ def transcribe(request: Request, file_path):
                 "definition": json.dumps(
                     {
                         "locales": locales.split(","),
-                        "diarization": {"maxSpeakers": 3, "enabled": True},
+                        "diarization": {"maxSpeakers": max_speakers, "enabled": True},
                     }
                     if locales
                     else {}
                 )
             }
-            url = f"https://{region}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
+
+            url = (
+                base_url
+                or f"https://{region}.api.cognitive.microsoft.com/speechtotext/transcriptions:transcribe?api-version=2024-11-15"
+            )
 
             # Use context manager to ensure file is properly closed
             with open(file_path, "rb") as audio_file:

+ 51 - 20
backend/open_webui/routers/auths.py

@@ -27,20 +27,24 @@ from open_webui.env import (
     WEBUI_AUTH_TRUSTED_NAME_HEADER,
     WEBUI_AUTH_COOKIE_SAME_SITE,
     WEBUI_AUTH_COOKIE_SECURE,
+    WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
     SRC_LOG_LEVELS,
 )
 from fastapi import APIRouter, Depends, HTTPException, Request, status
 from fastapi.responses import RedirectResponse, Response
 from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_LDAP
 from pydantic import BaseModel
+
 from open_webui.utils.misc import parse_duration, validate_email_format
 from open_webui.utils.auth import (
+    decode_token,
     create_api_key,
     create_token,
     get_admin_user,
     get_verified_user,
     get_current_user,
     get_password_hash,
+    get_http_authorization_cred,
 )
 from open_webui.utils.webhook import post_webhook
 from open_webui.utils.access_control import get_permissions
@@ -72,27 +76,29 @@ class SessionUserResponse(Token, UserResponse):
 async def get_session_user(
     request: Request, response: Response, user=Depends(get_current_user)
 ):
-    expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
-    expires_at = None
-    if expires_delta:
-        expires_at = int(time.time()) + int(expires_delta.total_seconds())
-
-    token = create_token(
-        data={"id": user.id},
-        expires_delta=expires_delta,
-    )
 
-    datetime_expires_at = (
-        datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
-        if expires_at
-        else None
-    )
+    auth_header = request.headers.get("Authorization")
+    auth_token = get_http_authorization_cred(auth_header)
+    token = auth_token.credentials
+    data = decode_token(token)
+
+    expires_at = data.get("exp")
+
+    if (expires_at is not None) and int(time.time()) > expires_at:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.INVALID_TOKEN,
+        )
 
     # Set the cookie token
     response.set_cookie(
         key="token",
         value=token,
-        expires=datetime_expires_at,
+        expires=(
+            datetime.datetime.fromtimestamp(expires_at, datetime.timezone.utc)
+            if expires_at
+            else None
+        ),
         httponly=True,  # Ensures the cookie is not accessible via JavaScript
         samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
         secure=WEBUI_AUTH_COOKIE_SECURE,
@@ -230,7 +236,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
 
         entry = connection_app.entries[0]
         username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
-        email = entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"].value  # retrive the Attribute value
+        email = entry[
+            f"{LDAP_ATTRIBUTE_FOR_MAIL}"
+        ].value  # retrieve the Attribute value
         if not email:
             raise HTTPException(400, "User does not have a valid email address.")
         elif isinstance(email, str):
@@ -288,18 +296,30 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
             user = Auths.authenticate_user_by_trusted_header(email)
 
             if user:
+                expires_delta = parse_duration(request.app.state.config.JWT_EXPIRES_IN)
+                expires_at = None
+                if expires_delta:
+                    expires_at = int(time.time()) + int(expires_delta.total_seconds())
+
                 token = create_token(
                     data={"id": user.id},
-                    expires_delta=parse_duration(
-                        request.app.state.config.JWT_EXPIRES_IN
-                    ),
+                    expires_delta=expires_delta,
                 )
 
                 # Set the cookie token
                 response.set_cookie(
                     key="token",
                     value=token,
+                    expires=(
+                        datetime.datetime.fromtimestamp(
+                            expires_at, datetime.timezone.utc
+                        )
+                        if expires_at
+                        else None
+                    ),
                     httponly=True,  # Ensures the cookie is not accessible via JavaScript
+                    samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
+                    secure=WEBUI_AUTH_COOKIE_SECURE,
                 )
 
                 user_permissions = get_permissions(
@@ -309,6 +329,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
                 return {
                     "token": token,
                     "token_type": "Bearer",
+                    "expires_at": expires_at,
                     "id": user.id,
                     "email": user.email,
                     "name": user.name,
@@ -566,6 +587,12 @@ async def signout(request: Request, response: Response):
                     detail="Failed to sign out from the OpenID provider.",
                 )
 
+    if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:
+        return RedirectResponse(
+            headers=response.headers,
+            url=WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
+        )
+
     return {"status": True}
 
 
@@ -664,6 +691,7 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
         "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
         "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
         "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
+        "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
         "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
     }
 
@@ -680,6 +708,7 @@ class AdminConfig(BaseModel):
     ENABLE_COMMUNITY_SHARING: bool
     ENABLE_MESSAGE_RATING: bool
     ENABLE_CHANNELS: bool
+    ENABLE_NOTES: bool
     ENABLE_USER_WEBHOOKS: bool
 
 
@@ -700,6 +729,7 @@ async def update_admin_config(
     )
 
     request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
+    request.app.state.config.ENABLE_NOTES = form_data.ENABLE_NOTES
 
     if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
         request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
@@ -724,11 +754,12 @@ async def update_admin_config(
         "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
         "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
         "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
-        "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
         "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,
         "ENABLE_MESSAGE_RATING": request.app.state.config.ENABLE_MESSAGE_RATING,
+        "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
+        "ENABLE_NOTES": request.app.state.config.ENABLE_NOTES,
         "ENABLE_USER_WEBHOOKS": request.app.state.config.ENABLE_USER_WEBHOOKS,
     }
 

+ 10 - 1
backend/open_webui/routers/chats.py

@@ -638,8 +638,17 @@ async def archive_chat_by_id(id: str, user=Depends(get_verified_user)):
 
 
 @router.post("/{id}/share", response_model=Optional[ChatResponse])
-async def share_chat_by_id(id: str, user=Depends(get_verified_user)):
+async def share_chat_by_id(request: Request, id: str, user=Depends(get_verified_user)):
+    if not has_permission(
+        user.id, "chat.share", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+        )
+
     chat = Chats.get_chat_by_id_and_user_id(id, user.id)
+
     if chat:
         if chat.share_id:
             shared_chat = Chats.update_shared_chat_by_chat_id(chat.id)

+ 3 - 5
backend/open_webui/routers/evaluations.py

@@ -56,7 +56,7 @@ async def update_config(
     }
 
 
-class FeedbackUserReponse(BaseModel):
+class UserResponse(BaseModel):
     id: str
     name: str
     email: str
@@ -68,7 +68,7 @@ class FeedbackUserReponse(BaseModel):
 
 
 class FeedbackUserResponse(FeedbackResponse):
-    user: Optional[FeedbackUserReponse] = None
+    user: Optional[UserResponse] = None
 
 
 @router.get("/feedbacks/all", response_model=list[FeedbackUserResponse])
@@ -77,9 +77,7 @@ async def get_all_feedbacks(user=Depends(get_admin_user)):
     return [
         FeedbackUserResponse(
             **feedback.model_dump(),
-            user=FeedbackUserReponse(
-                **Users.get_user_by_id(feedback.user_id).model_dump()
-            ),
+            user=UserResponse(**Users.get_user_by_id(feedback.user_id).model_dump()),
         )
         for feedback in feedbacks
     ]

+ 42 - 11
backend/open_webui/routers/files.py

@@ -19,6 +19,8 @@ from fastapi import (
 from fastapi.responses import FileResponse, StreamingResponse
 from open_webui.constants import ERROR_MESSAGES
 from open_webui.env import SRC_LOG_LEVELS
+
+from open_webui.models.users import Users
 from open_webui.models.files import (
     FileForm,
     FileModel,
@@ -83,10 +85,12 @@ def upload_file(
     request: Request,
     file: UploadFile = File(...),
     user=Depends(get_verified_user),
-    file_metadata: dict = {},
+    file_metadata: dict = None,
     process: bool = Query(True),
 ):
     log.info(f"file.content_type: {file.content_type}")
+
+    file_metadata = file_metadata if file_metadata else {}
     try:
         unsanitized_filename = file.filename
         filename = os.path.basename(unsanitized_filename)
@@ -95,7 +99,13 @@ def upload_file(
         id = str(uuid.uuid4())
         name = filename
         filename = f"{id}_{filename}"
-        contents, file_path = Storage.upload_file(file.file, filename)
+        tags = {
+            "OpenWebUI-User-Email": user.email,
+            "OpenWebUI-User-Id": user.id,
+            "OpenWebUI-User-Name": user.name,
+            "OpenWebUI-File-Id": id,
+        }
+        contents, file_path = Storage.upload_file(file.file, filename, tags)
 
         file_item = Files.insert_new_file(
             user.id,
@@ -115,12 +125,16 @@ def upload_file(
         )
         if process:
             try:
-                if file.content_type in [
-                    "audio/mpeg",
-                    "audio/wav",
-                    "audio/ogg",
-                    "audio/x-m4a",
-                ]:
+
+                if file.content_type.startswith(
+                    (
+                        "audio/mpeg",
+                        "audio/wav",
+                        "audio/ogg",
+                        "audio/x-m4a",
+                        "audio/webm",
+                    )
+                ):
                     file_path = Storage.get_file(file_path)
                     result = transcribe(request, file_path)
 
@@ -129,7 +143,15 @@ def upload_file(
                         ProcessFileForm(file_id=id, content=result.get("text", "")),
                         user=user,
                     )
-                elif file.content_type not in ["image/png", "image/jpeg", "image/gif"]:
+                elif file.content_type not in [
+                    "image/png",
+                    "image/jpeg",
+                    "image/gif",
+                    "video/mp4",
+                    "video/ogg",
+                    "video/quicktime",
+                    "video/webm",
+                ]:
                     process_file(request, ProcessFileForm(file_id=id), user=user)
 
                 file_item = Files.get_file_by_id(id=id)
@@ -173,7 +195,8 @@ async def list_files(user=Depends(get_verified_user), content: bool = Query(True
 
     if not content:
         for file in files:
-            del file.data["content"]
+            if "content" in file.data:
+                del file.data["content"]
 
     return files
 
@@ -214,7 +237,8 @@ async def search_files(
 
     if not content:
         for file in matching_files:
-            del file.data["content"]
+            if "content" in file.data:
+                del file.data["content"]
 
     return matching_files
 
@@ -431,6 +455,13 @@ async def get_html_file_content_by_id(id: str, user=Depends(get_verified_user)):
             detail=ERROR_MESSAGES.NOT_FOUND,
         )
 
+    file_user = Users.get_user_by_id(file.user_id)
+    if not file_user.role == "admin":
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail=ERROR_MESSAGES.NOT_FOUND,
+        )
+
     if (
         file.user_id == user.id
         or user.role == "admin"

+ 5 - 1
backend/open_webui/routers/images.py

@@ -500,7 +500,11 @@ async def image_generations(
                     if form_data.size
                     else request.app.state.config.IMAGE_SIZE
                 ),
-                "response_format": "b64_json",
+                **(
+                    {"response_format": "b64_json"}
+                    if "gpt-image-1" in request.app.state.config.IMAGE_GENERATION_MODEL
+                    else {}
+                ),
             }
 
             # Use asyncio.to_thread for the requests.post call

+ 33 - 19
backend/open_webui/routers/knowledge.py

@@ -9,7 +9,7 @@ from open_webui.models.knowledge import (
     KnowledgeResponse,
     KnowledgeUserResponse,
 )
-from open_webui.models.files import Files, FileModel
+from open_webui.models.files import Files, FileModel, FileMetadataResponse
 from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
 from open_webui.routers.retrieval import (
     process_file,
@@ -178,10 +178,26 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
 
     log.info(f"Starting reindexing for {len(knowledge_bases)} knowledge bases")
 
+    deleted_knowledge_bases = []
+
     for knowledge_base in knowledge_bases:
-        try:
-            files = Files.get_files_by_ids(knowledge_base.data.get("file_ids", []))
+        # -- Robust error handling for missing or invalid data
+        if not knowledge_base.data or not isinstance(knowledge_base.data, dict):
+            log.warning(
+                f"Knowledge base {knowledge_base.id} has no data or invalid data ({knowledge_base.data!r}). Deleting."
+            )
+            try:
+                Knowledges.delete_knowledge_by_id(id=knowledge_base.id)
+                deleted_knowledge_bases.append(knowledge_base.id)
+            except Exception as e:
+                log.error(
+                    f"Failed to delete invalid knowledge base {knowledge_base.id}: {e}"
+                )
+            continue
 
+        try:
+            file_ids = knowledge_base.data.get("file_ids", [])
+            files = Files.get_files_by_ids(file_ids)
             try:
                 if VECTOR_DB_CLIENT.has_collection(collection_name=knowledge_base.id):
                     VECTOR_DB_CLIENT.delete_collection(
@@ -189,10 +205,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
                     )
             except Exception as e:
                 log.error(f"Error deleting collection {knowledge_base.id}: {str(e)}")
-                raise HTTPException(
-                    status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-                    detail=f"Error deleting vector DB collection",
-                )
+                continue  # Skip, don't raise
 
             failed_files = []
             for file in files:
@@ -213,10 +226,8 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
 
         except Exception as e:
             log.error(f"Error processing knowledge base {knowledge_base.id}: {str(e)}")
-            raise HTTPException(
-                status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
-                detail=f"Error processing knowledge base",
-            )
+            # Don't raise, just continue
+            continue
 
         if failed_files:
             log.warning(
@@ -225,7 +236,9 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
             for failed in failed_files:
                 log.warning(f"File ID: {failed['file_id']}, Error: {failed['error']}")
 
-    log.info("Reindexing completed successfully")
+    log.info(
+        f"Reindexing completed. Deleted {len(deleted_knowledge_bases)} invalid knowledge bases: {deleted_knowledge_bases}"
+    )
     return True
 
 
@@ -235,7 +248,7 @@ async def reindex_knowledge_files(request: Request, user=Depends(get_verified_us
 
 
 class KnowledgeFilesResponse(KnowledgeResponse):
-    files: list[FileModel]
+    files: list[FileMetadataResponse]
 
 
 @router.get("/{id}", response_model=Optional[KnowledgeFilesResponse])
@@ -251,7 +264,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
         ):
 
             file_ids = knowledge.data.get("file_ids", []) if knowledge.data else []
-            files = Files.get_files_by_ids(file_ids)
+            files = Files.get_file_metadatas_by_ids(file_ids)
 
             return KnowledgeFilesResponse(
                 **knowledge.model_dump(),
@@ -379,7 +392,7 @@ def add_file_to_knowledge_by_id(
             knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
 
             if knowledge:
-                files = Files.get_files_by_ids(file_ids)
+                files = Files.get_file_metadatas_by_ids(file_ids)
 
                 return KnowledgeFilesResponse(
                     **knowledge.model_dump(),
@@ -456,7 +469,7 @@ def update_file_from_knowledge_by_id(
         data = knowledge.data or {}
         file_ids = data.get("file_ids", [])
 
-        files = Files.get_files_by_ids(file_ids)
+        files = Files.get_file_metadatas_by_ids(file_ids)
 
         return KnowledgeFilesResponse(
             **knowledge.model_dump(),
@@ -538,7 +551,7 @@ def remove_file_from_knowledge_by_id(
             knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
 
             if knowledge:
-                files = Files.get_files_by_ids(file_ids)
+                files = Files.get_file_metadatas_by_ids(file_ids)
 
                 return KnowledgeFilesResponse(
                     **knowledge.model_dump(),
@@ -734,7 +747,7 @@ def add_files_to_knowledge_batch(
         error_details = [f"{err.file_id}: {err.error}" for err in result.errors]
         return KnowledgeFilesResponse(
             **knowledge.model_dump(),
-            files=Files.get_files_by_ids(existing_file_ids),
+            files=Files.get_file_metadatas_by_ids(existing_file_ids),
             warnings={
                 "message": "Some files failed to process",
                 "errors": error_details,
@@ -742,5 +755,6 @@ def add_files_to_knowledge_batch(
         )
 
     return KnowledgeFilesResponse(
-        **knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids)
+        **knowledge.model_dump(),
+        files=Files.get_file_metadatas_by_ids(existing_file_ids),
     )

+ 212 - 0
backend/open_webui/routers/notes.py

@@ -0,0 +1,212 @@
+import json
+import logging
+from typing import Optional
+
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
+from pydantic import BaseModel
+
+from open_webui.models.users import Users, UserResponse
+from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
+
+from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
+from open_webui.constants import ERROR_MESSAGES
+from open_webui.env import SRC_LOG_LEVELS
+
+
+from open_webui.utils.auth import get_admin_user, get_verified_user
+from open_webui.utils.access_control import has_permission
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MODELS"])
+
+router = APIRouter()
+
+############################
+# GetNotes
+############################
+
+
+@router.get("/", response_model=list[NoteUserResponse])
+async def get_notes(request: Request, user=Depends(get_verified_user)):
+
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
+    notes = [
+        NoteUserResponse(
+            **{
+                **note.model_dump(),
+                "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
+            }
+        )
+        for note in Notes.get_notes_by_user_id(user.id, "write")
+    ]
+
+    return notes
+
+
+@router.get("/list", response_model=list[NoteUserResponse])
+async def get_note_list(request: Request, user=Depends(get_verified_user)):
+
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
+    notes = [
+        NoteUserResponse(
+            **{
+                **note.model_dump(),
+                "user": UserResponse(**Users.get_user_by_id(note.user_id).model_dump()),
+            }
+        )
+        for note in Notes.get_notes_by_user_id(user.id, "read")
+    ]
+
+    return notes
+
+
+############################
+# CreateNewNote
+############################
+
+
+@router.post("/create", response_model=Optional[NoteModel])
+async def create_new_note(
+    request: Request, form_data: NoteForm, user=Depends(get_verified_user)
+):
+
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
+    try:
+        note = Notes.insert_new_note(form_data, user.id)
+        return note
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# GetNoteById
+############################
+
+
+@router.get("/{id}", response_model=Optional[NoteModel])
+async def get_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
+    note = Notes.get_note_by_id(id)
+    if not note:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=note.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    return note
+
+
+############################
+# UpdateNoteById
+############################
+
+
+@router.post("/{id}/update", response_model=Optional[NoteModel])
+async def update_note_by_id(
+    request: Request, id: str, form_data: NoteForm, user=Depends(get_verified_user)
+):
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
+    note = Notes.get_note_by_id(id)
+    if not note:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="write", access_control=note.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        note = Notes.update_note_by_id(id, form_data)
+        return note
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# DeleteNoteById
+############################
+
+
+@router.delete("/{id}/delete", response_model=bool)
+async def delete_note_by_id(request: Request, id: str, user=Depends(get_verified_user)):
+    if user.role != "admin" and not has_permission(
+        user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=ERROR_MESSAGES.UNAUTHORIZED,
+        )
+
+    note = Notes.get_note_by_id(id)
+    if not note:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="write", access_control=note.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        note = Notes.delete_note_by_id(id)
+        return True
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )

+ 20 - 4
backend/open_webui/routers/ollama.py

@@ -54,6 +54,7 @@ from open_webui.config import (
 from open_webui.env import (
     ENV,
     SRC_LOG_LEVELS,
+    AIOHTTP_CLIENT_SESSION_SSL,
     AIOHTTP_CLIENT_TIMEOUT,
     AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
     BYPASS_MODEL_ACCESS_CONTROL,
@@ -91,6 +92,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
                         else {}
                     ),
                 },
+                ssl=AIOHTTP_CLIENT_SESSION_SSL,
             ) as response:
                 return await response.json()
     except Exception as e:
@@ -141,6 +143,7 @@ async def send_post_request(
                     else {}
                 ),
             },
+            ssl=AIOHTTP_CLIENT_SESSION_SSL,
         )
         r.raise_for_status()
 
@@ -216,7 +219,8 @@ async def verify_connection(
     key = form_data.key
 
     async with aiohttp.ClientSession(
-        timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
+        trust_env=True,
+        timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
     ) as session:
         try:
             async with session.get(
@@ -234,6 +238,7 @@ async def verify_connection(
                         else {}
                     ),
                 },
+                ssl=AIOHTTP_CLIENT_SESSION_SSL,
             ) as r:
                 if r.status != 200:
                     detail = f"HTTP Error: {r.status}"
@@ -880,6 +885,10 @@ async def embed(
     url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
     key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
 
+    prefix_id = api_config.get("prefix_id", None)
+    if prefix_id:
+        form_data.model = form_data.model.replace(f"{prefix_id}.", "")
+
     try:
         r = requests.request(
             method="POST",
@@ -959,6 +968,10 @@ async def embeddings(
     url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
     key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
 
+    prefix_id = api_config.get("prefix_id", None)
+    if prefix_id:
+        form_data.model = form_data.model.replace(f"{prefix_id}.", "")
+
     try:
         r = requests.request(
             method="POST",
@@ -1006,7 +1019,7 @@ class GenerateCompletionForm(BaseModel):
     prompt: str
     suffix: Optional[str] = None
     images: Optional[list[str]] = None
-    format: Optional[str] = None
+    format: Optional[Union[dict, str]] = None
     options: Optional[dict] = None
     system: Optional[str] = None
     template: Optional[str] = None
@@ -1482,7 +1495,9 @@ async def download_file_stream(
     timeout = aiohttp.ClientTimeout(total=600)  # Set the timeout
 
     async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
-        async with session.get(file_url, headers=headers) as response:
+        async with session.get(
+            file_url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_SSL
+        ) as response:
             total_size = int(response.headers.get("content-length", 0)) + current_size
 
             with open(file_path, "ab+") as file:
@@ -1497,7 +1512,8 @@ async def download_file_stream(
 
                 if done:
                     file.seek(0)
-                    hashed = calculate_sha256(file)
+                    chunk_size = 1024 * 1024 * 2
+                    hashed = calculate_sha256(file, chunk_size)
                     file.seek(0)
 
                     url = f"{ollama_url}/api/blobs/sha256:{hashed}"

+ 19 - 12
backend/open_webui/routers/openai.py

@@ -21,6 +21,7 @@ from open_webui.config import (
     CACHE_DIR,
 )
 from open_webui.env import (
+    AIOHTTP_CLIENT_SESSION_SSL,
     AIOHTTP_CLIENT_TIMEOUT,
     AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST,
     ENABLE_FORWARD_USER_INFO_HEADERS,
@@ -74,6 +75,7 @@ async def send_get_request(url, key=None, user: UserModel = None):
                         else {}
                     ),
                 },
+                ssl=AIOHTTP_CLIENT_SESSION_SSL,
             ) as response:
                 return await response.json()
     except Exception as e:
@@ -92,20 +94,19 @@ async def cleanup_response(
         await session.close()
 
 
-def openai_o1_o3_handler(payload):
+def openai_o_series_handler(payload):
     """
-    Handle o1, o3 specific parameters
+    Handle "o" series specific parameters
     """
     if "max_tokens" in payload:
-        # Remove "max_tokens" from the payload
+        # Convert "max_tokens" to "max_completion_tokens" for all o-series models
         payload["max_completion_tokens"] = payload["max_tokens"]
         del payload["max_tokens"]
 
-    # Fix: o1 and o3 do not support the "system" role directly.
-    # For older models like "o1-mini" or "o1-preview", use role "user".
-    # For newer o1/o3 models, replace "system" with "developer".
+    # Handle system role conversion based on model type
     if payload["messages"][0]["role"] == "system":
         model_lower = payload["model"].lower()
+        # Legacy models use "user" role instead of "system"
         if model_lower.startswith("o1-mini") or model_lower.startswith("o1-preview"):
             payload["messages"][0]["role"] = "user"
         else:
@@ -462,7 +463,8 @@ async def get_models(
 
         r = None
         async with aiohttp.ClientSession(
-            timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
+            trust_env=True,
+            timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
         ) as session:
             try:
                 async with session.get(
@@ -481,6 +483,7 @@ async def get_models(
                             else {}
                         ),
                     },
+                    ssl=AIOHTTP_CLIENT_SESSION_SSL,
                 ) as r:
                     if r.status != 200:
                         # Extract response error details if available
@@ -542,7 +545,8 @@ async def verify_connection(
     key = form_data.key
 
     async with aiohttp.ClientSession(
-        timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST)
+        trust_env=True,
+        timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
     ) as session:
         try:
             async with session.get(
@@ -561,6 +565,7 @@ async def verify_connection(
                         else {}
                     ),
                 },
+                ssl=AIOHTTP_CLIENT_SESSION_SSL,
             ) as r:
                 if r.status != 200:
                     # Extract response error details if available
@@ -666,10 +671,10 @@ async def generate_chat_completion(
     url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
     key = request.app.state.config.OPENAI_API_KEYS[idx]
 
-    # Fix: o1,o3 does not support the "max_tokens" parameter, Modify "max_tokens" to "max_completion_tokens"
-    is_o1_o3 = payload["model"].lower().startswith(("o1", "o3-"))
-    if is_o1_o3:
-        payload = openai_o1_o3_handler(payload)
+    # Check if model is from "o" series
+    is_o_series = payload["model"].lower().startswith(("o1", "o3", "o4"))
+    if is_o_series:
+        payload = openai_o_series_handler(payload)
     elif "api.openai.com" not in url:
         # Remove "max_completion_tokens" from the payload for backward compatibility
         if "max_completion_tokens" in payload:
@@ -723,6 +728,7 @@ async def generate_chat_completion(
                     else {}
                 ),
             },
+            ssl=AIOHTTP_CLIENT_SESSION_SSL,
         )
 
         # Check if response is SSE
@@ -802,6 +808,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
                     else {}
                 ),
             },
+            ssl=AIOHTTP_CLIENT_SESSION_SSL,
         )
         r.raise_for_status()
 

+ 2 - 2
backend/open_webui/routers/pipelines.py

@@ -66,7 +66,7 @@ async def process_pipeline_inlet_filter(request, payload, user, models):
     if "pipeline" in model:
         sorted_filters.append(model)
 
-    async with aiohttp.ClientSession() as session:
+    async with aiohttp.ClientSession(trust_env=True) as session:
         for filter in sorted_filters:
             urlIdx = filter.get("urlIdx")
             if urlIdx is None:
@@ -115,7 +115,7 @@ async def process_pipeline_outlet_filter(request, payload, user, models):
     if "pipeline" in model:
         sorted_filters = [model] + sorted_filters
 
-    async with aiohttp.ClientSession() as session:
+    async with aiohttp.ClientSession(trust_env=True) as session:
         for filter in sorted_filters:
             urlIdx = filter.get("urlIdx")
             if urlIdx is None:

+ 118 - 17
backend/open_webui/routers/retrieval.py

@@ -53,6 +53,7 @@ from open_webui.retrieval.web.jina_search import search_jina
 from open_webui.retrieval.web.searchapi import search_searchapi
 from open_webui.retrieval.web.serpapi import search_serpapi
 from open_webui.retrieval.web.searxng import search_searxng
+from open_webui.retrieval.web.yacy import search_yacy
 from open_webui.retrieval.web.serper import search_serper
 from open_webui.retrieval.web.serply import search_serply
 from open_webui.retrieval.web.serpstack import search_serpstack
@@ -61,6 +62,8 @@ from open_webui.retrieval.web.bing import search_bing
 from open_webui.retrieval.web.exa import search_exa
 from open_webui.retrieval.web.perplexity import search_perplexity
 from open_webui.retrieval.web.sougou import search_sougou
+from open_webui.retrieval.web.firecrawl import search_firecrawl
+from open_webui.retrieval.web.external import search_external
 
 from open_webui.retrieval.utils import (
     get_embedding_function,
@@ -90,7 +93,12 @@ from open_webui.env import (
     SRC_LOG_LEVELS,
     DEVICE_TYPE,
     DOCKER,
+    SENTENCE_TRANSFORMERS_BACKEND,
+    SENTENCE_TRANSFORMERS_MODEL_KWARGS,
+    SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
+    SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
 )
+
 from open_webui.constants import ERROR_MESSAGES
 
 log = logging.getLogger(__name__)
@@ -117,6 +125,8 @@ def get_ef(
                 get_model_path(embedding_model, auto_update),
                 device=DEVICE_TYPE,
                 trust_remote_code=RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE,
+                backend=SENTENCE_TRANSFORMERS_BACKEND,
+                model_kwargs=SENTENCE_TRANSFORMERS_MODEL_KWARGS,
             )
         except Exception as e:
             log.debug(f"Error loading SentenceTransformer: {e}")
@@ -150,6 +160,8 @@ def get_rf(
                     get_model_path(reranking_model, auto_update),
                     device=DEVICE_TYPE,
                     trust_remote_code=RAG_RERANKING_MODEL_TRUST_REMOTE_CODE,
+                    backend=SENTENCE_TRANSFORMERS_CROSS_ENCODER_BACKEND,
+                    model_kwargs=SENTENCE_TRANSFORMERS_CROSS_ENCODER_MODEL_KWARGS,
                 )
             except Exception as e:
                 log.error(f"CrossEncoder: {e}")
@@ -366,6 +378,8 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
         "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
         "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
         "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
+        "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
+        "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
         "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
         "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
         "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
@@ -389,6 +403,9 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
             "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
             "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
             "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
+            "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
+            "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
+            "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD,
             "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY,
             "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID,
             "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY,
@@ -418,6 +435,10 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
             "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
             "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
             "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
+            "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
+            "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
+            "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL,
+            "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY,
             "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
             "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
             "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
@@ -434,6 +455,9 @@ class WebConfig(BaseModel):
     WEB_SEARCH_DOMAIN_FILTER_LIST: Optional[List[str]] = []
     BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL: Optional[bool] = None
     SEARXNG_QUERY_URL: Optional[str] = None
+    YACY_QUERY_URL: Optional[str] = None
+    YACY_USERNAME: Optional[str] = None
+    YACY_PASSWORD: Optional[str] = None
     GOOGLE_PSE_API_KEY: Optional[str] = None
     GOOGLE_PSE_ENGINE_ID: Optional[str] = None
     BRAVE_SEARCH_API_KEY: Optional[str] = None
@@ -463,6 +487,10 @@ class WebConfig(BaseModel):
     FIRECRAWL_API_KEY: Optional[str] = None
     FIRECRAWL_API_BASE_URL: Optional[str] = None
     TAVILY_EXTRACT_DEPTH: Optional[str] = None
+    EXTERNAL_WEB_SEARCH_URL: Optional[str] = None
+    EXTERNAL_WEB_SEARCH_API_KEY: Optional[str] = None
+    EXTERNAL_WEB_LOADER_URL: Optional[str] = None
+    EXTERNAL_WEB_LOADER_API_KEY: Optional[str] = None
     YOUTUBE_LOADER_LANGUAGE: Optional[List[str]] = None
     YOUTUBE_LOADER_PROXY_URL: Optional[str] = None
     YOUTUBE_LOADER_TRANSLATION: Optional[str] = None
@@ -485,6 +513,8 @@ class ConfigForm(BaseModel):
     PDF_EXTRACT_IMAGES: Optional[bool] = None
     TIKA_SERVER_URL: Optional[str] = None
     DOCLING_SERVER_URL: Optional[str] = None
+    DOCLING_OCR_ENGINE: Optional[str] = None
+    DOCLING_OCR_LANG: Optional[str] = None
     DOCUMENT_INTELLIGENCE_ENDPOINT: Optional[str] = None
     DOCUMENT_INTELLIGENCE_KEY: Optional[str] = None
     MISTRAL_OCR_API_KEY: Optional[str] = None
@@ -574,6 +604,16 @@ async def update_rag_config(
         if form_data.DOCLING_SERVER_URL is not None
         else request.app.state.config.DOCLING_SERVER_URL
     )
+    request.app.state.config.DOCLING_OCR_ENGINE = (
+        form_data.DOCLING_OCR_ENGINE
+        if form_data.DOCLING_OCR_ENGINE is not None
+        else request.app.state.config.DOCLING_OCR_ENGINE
+    )
+    request.app.state.config.DOCLING_OCR_LANG = (
+        form_data.DOCLING_OCR_LANG
+        if form_data.DOCLING_OCR_LANG is not None
+        else request.app.state.config.DOCLING_OCR_LANG
+    )
     request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT = (
         form_data.DOCUMENT_INTELLIGENCE_ENDPOINT
         if form_data.DOCUMENT_INTELLIGENCE_ENDPOINT is not None
@@ -651,6 +691,9 @@ async def update_rag_config(
             form_data.web.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL
         )
         request.app.state.config.SEARXNG_QUERY_URL = form_data.web.SEARXNG_QUERY_URL
+        request.app.state.config.YACY_QUERY_URL = form_data.web.YACY_QUERY_URL
+        request.app.state.config.YACY_USERNAME = form_data.web.YACY_USERNAME
+        request.app.state.config.YACY_PASSWORD = form_data.web.YACY_PASSWORD
         request.app.state.config.GOOGLE_PSE_API_KEY = form_data.web.GOOGLE_PSE_API_KEY
         request.app.state.config.GOOGLE_PSE_ENGINE_ID = (
             form_data.web.GOOGLE_PSE_ENGINE_ID
@@ -697,6 +740,18 @@ async def update_rag_config(
         request.app.state.config.FIRECRAWL_API_BASE_URL = (
             form_data.web.FIRECRAWL_API_BASE_URL
         )
+        request.app.state.config.EXTERNAL_WEB_SEARCH_URL = (
+            form_data.web.EXTERNAL_WEB_SEARCH_URL
+        )
+        request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY = (
+            form_data.web.EXTERNAL_WEB_SEARCH_API_KEY
+        )
+        request.app.state.config.EXTERNAL_WEB_LOADER_URL = (
+            form_data.web.EXTERNAL_WEB_LOADER_URL
+        )
+        request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY = (
+            form_data.web.EXTERNAL_WEB_LOADER_API_KEY
+        )
         request.app.state.config.TAVILY_EXTRACT_DEPTH = (
             form_data.web.TAVILY_EXTRACT_DEPTH
         )
@@ -726,6 +781,8 @@ async def update_rag_config(
         "PDF_EXTRACT_IMAGES": request.app.state.config.PDF_EXTRACT_IMAGES,
         "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
         "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
+        "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
+        "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
         "DOCUMENT_INTELLIGENCE_ENDPOINT": request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
         "DOCUMENT_INTELLIGENCE_KEY": request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
         "MISTRAL_OCR_API_KEY": request.app.state.config.MISTRAL_OCR_API_KEY,
@@ -749,6 +806,9 @@ async def update_rag_config(
             "WEB_SEARCH_DOMAIN_FILTER_LIST": request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
             "BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL": request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL,
             "SEARXNG_QUERY_URL": request.app.state.config.SEARXNG_QUERY_URL,
+            "YACY_QUERY_URL": request.app.state.config.YACY_QUERY_URL,
+            "YACY_USERNAME": request.app.state.config.YACY_USERNAME,
+            "YACY_PASSWORD": request.app.state.config.YACY_PASSWORD,
             "GOOGLE_PSE_API_KEY": request.app.state.config.GOOGLE_PSE_API_KEY,
             "GOOGLE_PSE_ENGINE_ID": request.app.state.config.GOOGLE_PSE_ENGINE_ID,
             "BRAVE_SEARCH_API_KEY": request.app.state.config.BRAVE_SEARCH_API_KEY,
@@ -778,6 +838,10 @@ async def update_rag_config(
             "FIRECRAWL_API_KEY": request.app.state.config.FIRECRAWL_API_KEY,
             "FIRECRAWL_API_BASE_URL": request.app.state.config.FIRECRAWL_API_BASE_URL,
             "TAVILY_EXTRACT_DEPTH": request.app.state.config.TAVILY_EXTRACT_DEPTH,
+            "EXTERNAL_WEB_SEARCH_URL": request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
+            "EXTERNAL_WEB_SEARCH_API_KEY": request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
+            "EXTERNAL_WEB_LOADER_URL": request.app.state.config.EXTERNAL_WEB_LOADER_URL,
+            "EXTERNAL_WEB_LOADER_API_KEY": request.app.state.config.EXTERNAL_WEB_LOADER_API_KEY,
             "YOUTUBE_LOADER_LANGUAGE": request.app.state.config.YOUTUBE_LOADER_LANGUAGE,
             "YOUTUBE_LOADER_PROXY_URL": request.app.state.config.YOUTUBE_LOADER_PROXY_URL,
             "YOUTUBE_LOADER_TRANSLATION": request.app.state.YOUTUBE_LOADER_TRANSLATION,
@@ -1032,6 +1096,8 @@ def process_file(
                     engine=request.app.state.config.CONTENT_EXTRACTION_ENGINE,
                     TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
                     DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
+                    DOCLING_OCR_ENGINE=request.app.state.config.DOCLING_OCR_ENGINE,
+                    DOCLING_OCR_LANG=request.app.state.config.DOCLING_OCR_LANG,
                     PDF_EXTRACT_IMAGES=request.app.state.config.PDF_EXTRACT_IMAGES,
                     DOCUMENT_INTELLIGENCE_ENDPOINT=request.app.state.config.DOCUMENT_INTELLIGENCE_ENDPOINT,
                     DOCUMENT_INTELLIGENCE_KEY=request.app.state.config.DOCUMENT_INTELLIGENCE_KEY,
@@ -1266,6 +1332,7 @@ def search_web(request: Request, 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
+    - YACY_QUERY_URL + YACY_USERNAME + YACY_PASSWORD
     - GOOGLE_PSE_API_KEY + GOOGLE_PSE_ENGINE_ID
     - BRAVE_SEARCH_API_KEY
     - KAGI_SEARCH_API_KEY
@@ -1295,6 +1362,18 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
             )
         else:
             raise Exception("No SEARXNG_QUERY_URL found in environment variables")
+    elif engine == "yacy":
+        if request.app.state.config.YACY_QUERY_URL:
+            return search_yacy(
+                request.app.state.config.YACY_QUERY_URL,
+                request.app.state.config.YACY_USERNAME,
+                request.app.state.config.YACY_PASSWORD,
+                query,
+                request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+                request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+            )
+        else:
+            raise Exception("No YACY_QUERY_URL found in environment variables")
     elif engine == "google_pse":
         if (
             request.app.state.config.GOOGLE_PSE_API_KEY
@@ -1465,6 +1544,22 @@ def search_web(request: Request, engine: str, query: str) -> list[SearchResult]:
             raise Exception(
                 "No SOUGOU_API_SID or SOUGOU_API_SK found in environment variables"
             )
+    elif engine == "firecrawl":
+        return search_firecrawl(
+            request.app.state.config.FIRECRAWL_API_BASE_URL,
+            request.app.state.config.FIRECRAWL_API_KEY,
+            query,
+            request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+            request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+        )
+    elif engine == "external":
+        return search_external(
+            request.app.state.config.EXTERNAL_WEB_SEARCH_URL,
+            request.app.state.config.EXTERNAL_WEB_SEARCH_API_KEY,
+            query,
+            request.app.state.config.WEB_SEARCH_RESULT_COUNT,
+            request.app.state.config.WEB_SEARCH_DOMAIN_FILTER_LIST,
+        )
     else:
         raise Exception("No search engine API key found in environment variables")
 
@@ -1477,8 +1572,11 @@ async def process_web_search(
         logging.info(
             f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.query}"
         )
-        web_results = search_web(
-            request, request.app.state.config.WEB_SEARCH_ENGINE, form_data.query
+        web_results = await run_in_threadpool(
+            search_web,
+            request,
+            request.app.state.config.WEB_SEARCH_ENGINE,
+            form_data.query,
         )
     except Exception as e:
         log.exception(e)
@@ -1500,8 +1598,8 @@ async def process_web_search(
         )
         docs = await loader.aload()
         urls = [
-            doc.metadata["source"] for doc in docs
-        ]  # only keep URLs which could be retrieved
+            doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
+        ]  # only keep URLs
 
         if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
             return {
@@ -1521,19 +1619,22 @@ async def process_web_search(
             collection_names = []
             for doc_idx, doc in enumerate(docs):
                 if doc and doc.page_content:
-                    collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[
-                        :63
-                    ]
-
-                    collection_names.append(collection_name)
-                    await run_in_threadpool(
-                        save_docs_to_vector_db,
-                        request,
-                        [doc],
-                        collection_name,
-                        overwrite=True,
-                        user=user,
-                    )
+                    try:
+                        collection_name = f"web-search-{calculate_sha256_string(form_data.query + '-' + urls[doc_idx])}"[
+                            :63
+                        ]
+
+                        collection_names.append(collection_name)
+                        await run_in_threadpool(
+                            save_docs_to_vector_db,
+                            request,
+                            [doc],
+                            collection_name,
+                            overwrite=True,
+                            user=user,
+                        )
+                    except Exception as e:
+                        log.debug(f"error saving doc {doc_idx}: {e}")
 
             return {
                 "status": True,

+ 64 - 4
backend/open_webui/routers/users.py

@@ -6,6 +6,7 @@ from open_webui.models.groups import Groups
 from open_webui.models.chats import Chats
 from open_webui.models.users import (
     UserModel,
+    UserListResponse,
     UserRoleUpdateForm,
     Users,
     UserSettings,
@@ -33,13 +34,38 @@ router = APIRouter()
 ############################
 
 
-@router.get("/", response_model=list[UserModel])
+PAGE_ITEM_COUNT = 10
+
+
+@router.get("/", response_model=UserListResponse)
 async def get_users(
-    skip: Optional[int] = None,
-    limit: Optional[int] = None,
+    query: Optional[str] = None,
+    order_by: Optional[str] = None,
+    direction: Optional[str] = None,
+    page: Optional[int] = 1,
     user=Depends(get_admin_user),
 ):
-    return Users.get_users(skip, limit)
+    limit = PAGE_ITEM_COUNT
+
+    page = max(1, page)
+    skip = (page - 1) * limit
+
+    filter = {}
+    if query:
+        filter["query"] = query
+    if order_by:
+        filter["order_by"] = order_by
+    if direction:
+        filter["direction"] = direction
+
+    return Users.get_users(filter=filter, skip=skip, limit=limit)
+
+
+@router.get("/all", response_model=UserListResponse)
+async def get_all_users(
+    user=Depends(get_admin_user),
+):
+    return Users.get_users()
 
 
 ############################
@@ -88,6 +114,8 @@ class ChatPermissions(BaseModel):
     file_upload: bool = True
     delete: bool = True
     edit: bool = True
+    share: bool = True
+    export: bool = True
     stt: bool = True
     tts: bool = True
     call: bool = True
@@ -101,6 +129,7 @@ class FeaturesPermissions(BaseModel):
     web_search: bool = True
     image_generation: bool = True
     code_interpreter: bool = True
+    notes: bool = True
 
 
 class UserPermissions(BaseModel):
@@ -288,6 +317,21 @@ async def update_user_by_id(
     form_data: UserUpdateForm,
     session_user=Depends(get_admin_user),
 ):
+    # Prevent modification of the primary admin user by other admins
+    try:
+        first_user = Users.get_first_user()
+        if first_user and user_id == first_user.id and session_user.id != user_id:
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=ERROR_MESSAGES.ACTION_PROHIBITED,
+            )
+    except Exception as e:
+        log.error(f"Error checking primary admin status: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail="Could not verify primary admin status.",
+        )
+
     user = Users.get_user_by_id(user_id)
 
     if user:
@@ -335,6 +379,21 @@ async def update_user_by_id(
 
 @router.delete("/{user_id}", response_model=bool)
 async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
+    # Prevent deletion of the primary admin user
+    try:
+        first_user = Users.get_first_user()
+        if first_user and user_id == first_user.id:
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail=ERROR_MESSAGES.ACTION_PROHIBITED,
+            )
+    except Exception as e:
+        log.error(f"Error checking primary admin status: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            detail="Could not verify primary admin status.",
+        )
+
     if user.id != user_id:
         result = Auths.delete_auth_by_id(user_id)
 
@@ -346,6 +405,7 @@ async def delete_user_by_id(user_id: str, user=Depends(get_admin_user)):
             detail=ERROR_MESSAGES.DELETE_USER_ERROR,
         )
 
+    # Prevent self-deletion
     raise HTTPException(
         status_code=status.HTTP_403_FORBIDDEN,
         detail=ERROR_MESSAGES.ACTION_PROHIBITED,

+ 9 - 2
backend/open_webui/socket/main.py

@@ -192,6 +192,9 @@ async def connect(sid, environ, auth):
             # print(f"user {user.name}({user.id}) connected with session ID {sid}")
             await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
             await sio.emit("usage", {"models": get_models_in_use()})
+            return True
+
+    return False
 
 
 @sio.on("user-join")
@@ -314,8 +317,8 @@ def get_event_emitter(request_info, update_db=True):
             )
         )
 
-        for session_id in session_ids:
-            await sio.emit(
+        emit_tasks = [
+            sio.emit(
                 "chat-events",
                 {
                     "chat_id": request_info.get("chat_id", None),
@@ -324,6 +327,10 @@ def get_event_emitter(request_info, update_db=True):
                 },
                 to=session_id,
             )
+            for session_id in session_ids
+        ]
+
+        await asyncio.gather(*emit_tasks)
 
         if update_db:
             if "type" in event_data and event_data["type"] == "status":

+ 25 - 9
backend/open_webui/storage/provider.py

@@ -3,7 +3,7 @@ import shutil
 import json
 import logging
 from abc import ABC, abstractmethod
-from typing import BinaryIO, Tuple
+from typing import BinaryIO, Tuple, Dict
 
 import boto3
 from botocore.config import Config
@@ -44,7 +44,9 @@ class StorageProvider(ABC):
         pass
 
     @abstractmethod
-    def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
+    def upload_file(
+        self, file: BinaryIO, filename: str, tags: Dict[str, str]
+    ) -> Tuple[bytes, str]:
         pass
 
     @abstractmethod
@@ -58,7 +60,9 @@ class StorageProvider(ABC):
 
 class LocalStorageProvider(StorageProvider):
     @staticmethod
-    def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]:
+    def upload_file(
+        file: BinaryIO, filename: str, tags: Dict[str, str]
+    ) -> Tuple[bytes, str]:
         contents = file.read()
         if not contents:
             raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
@@ -131,12 +135,20 @@ class S3StorageProvider(StorageProvider):
         self.bucket_name = S3_BUCKET_NAME
         self.key_prefix = S3_KEY_PREFIX if S3_KEY_PREFIX else ""
 
-    def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
+    def upload_file(
+        self, file: BinaryIO, filename: str, tags: Dict[str, str]
+    ) -> Tuple[bytes, str]:
         """Handles uploading of the file to S3 storage."""
-        _, file_path = LocalStorageProvider.upload_file(file, filename)
+        _, file_path = LocalStorageProvider.upload_file(file, filename, tags)
+        tagging = {"TagSet": [{"Key": k, "Value": v} for k, v in tags.items()]}
         try:
             s3_key = os.path.join(self.key_prefix, filename)
             self.s3_client.upload_file(file_path, self.bucket_name, s3_key)
+            self.s3_client.put_object_tagging(
+                Bucket=self.bucket_name,
+                Key=s3_key,
+                Tagging=tagging,
+            )
             return (
                 open(file_path, "rb").read(),
                 "s3://" + self.bucket_name + "/" + s3_key,
@@ -207,9 +219,11 @@ class GCSStorageProvider(StorageProvider):
             self.gcs_client = storage.Client()
         self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME)
 
-    def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
+    def upload_file(
+        self, file: BinaryIO, filename: str, tags: Dict[str, str]
+    ) -> Tuple[bytes, str]:
         """Handles uploading of the file to GCS storage."""
-        contents, file_path = LocalStorageProvider.upload_file(file, filename)
+        contents, file_path = LocalStorageProvider.upload_file(file, filename, tags)
         try:
             blob = self.bucket.blob(filename)
             blob.upload_from_filename(file_path)
@@ -277,9 +291,11 @@ class AzureStorageProvider(StorageProvider):
             self.container_name
         )
 
-    def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
+    def upload_file(
+        self, file: BinaryIO, filename: str, tags: Dict[str, str]
+    ) -> Tuple[bytes, str]:
         """Handles uploading of the file to Azure Blob Storage."""
-        contents, file_path = LocalStorageProvider.upload_file(file, filename)
+        contents, file_path = LocalStorageProvider.upload_file(file, filename, tags)
         try:
             blob_client = self.container_client.get_blob_client(filename)
             blob_client.upload_blob(contents, overwrite=True)

+ 44 - 10
backend/open_webui/utils/audit.py

@@ -37,7 +37,7 @@ if TYPE_CHECKING:
 class AuditLogEntry:
     # `Metadata` audit level properties
     id: str
-    user: dict[str, Any]
+    user: Optional[dict[str, Any]]
     audit_level: str
     verb: str
     request_uri: str
@@ -190,21 +190,40 @@ class AuditLoggingMiddleware:
         finally:
             await self._log_audit_entry(request, context)
 
-    async def _get_authenticated_user(self, request: Request) -> UserModel:
-
+    async def _get_authenticated_user(self, request: Request) -> Optional[UserModel]:
         auth_header = request.headers.get("Authorization")
-        assert auth_header
-        user = get_current_user(request, None, get_http_authorization_cred(auth_header))
 
-        return user
+        try:
+            user = get_current_user(
+                request, None, get_http_authorization_cred(auth_header)
+            )
+            return user
+        except Exception as e:
+            logger.debug(f"Failed to get authenticated user: {str(e)}")
+
+        return None
 
     def _should_skip_auditing(self, request: Request) -> bool:
         if (
             request.method not in {"POST", "PUT", "PATCH", "DELETE"}
             or AUDIT_LOG_LEVEL == "NONE"
-            or not request.headers.get("authorization")
         ):
             return True
+
+        ALWAYS_LOG_ENDPOINTS = {
+            "/api/v1/auths/signin",
+            "/api/v1/auths/signout",
+            "/api/v1/auths/signup",
+        }
+        path = request.url.path.lower()
+        for endpoint in ALWAYS_LOG_ENDPOINTS:
+            if path.startswith(endpoint):
+                return False  # Do NOT skip logging for auth endpoints
+
+        # Skip logging if the request is not authenticated
+        if not request.headers.get("authorization"):
+            return True
+
         # match either /api/<resource>/...(for the endpoint /api/chat case) or /api/v1/<resource>/...
         pattern = re.compile(
             r"^/api(?:/v1)?/(" + "|".join(self.excluded_paths) + r")\b"
@@ -231,17 +250,32 @@ class AuditLoggingMiddleware:
         try:
             user = await self._get_authenticated_user(request)
 
+            user = (
+                user.model_dump(include={"id", "name", "email", "role"}) if user else {}
+            )
+
+            request_body = context.request_body.decode("utf-8", errors="replace")
+            response_body = context.response_body.decode("utf-8", errors="replace")
+
+            # Redact sensitive information
+            if "password" in request_body:
+                request_body = re.sub(
+                    r'"password":\s*"(.*?)"',
+                    '"password": "********"',
+                    request_body,
+                )
+
             entry = AuditLogEntry(
                 id=str(uuid.uuid4()),
-                user=user.model_dump(include={"id", "name", "email", "role"}),
+                user=user,
                 audit_level=self.audit_level.value,
                 verb=request.method,
                 request_uri=str(request.url),
                 response_status_code=context.metadata.get("response_status_code", None),
                 source_ip=request.client.host if request.client else None,
                 user_agent=request.headers.get("user-agent"),
-                request_object=context.request_body.decode("utf-8", errors="replace"),
-                response_object=context.response_body.decode("utf-8", errors="replace"),
+                request_object=request_body,
+                response_object=response_body,
             )
 
             self.audit_logger.write(entry)

+ 1 - 1
backend/open_webui/utils/code_interpreter.py

@@ -50,7 +50,7 @@ class JupyterCodeExecuter:
         self.password = password
         self.timeout = timeout
         self.kernel_id = ""
-        self.session = aiohttp.ClientSession(base_url=self.base_url)
+        self.session = aiohttp.ClientSession(trust_env=True, base_url=self.base_url)
         self.params = {}
         self.result = ResultModel()
 

+ 53 - 29
backend/open_webui/utils/middleware.py

@@ -355,29 +355,33 @@ async def chat_web_search_handler(
 
     all_results = []
 
-    for searchQuery in queries:
-        await event_emitter(
-            {
-                "type": "status",
-                "data": {
-                    "action": "web_search",
-                    "description": 'Searching "{{searchQuery}}"',
-                    "query": searchQuery,
-                    "done": False,
-                },
-            }
-        )
+    await event_emitter(
+        {
+            "type": "status",
+            "data": {
+                "action": "web_search",
+                "description": "Searching the web",
+                "done": False,
+            },
+        }
+    )
 
-        try:
-            results = await process_web_search(
+    gathered_results = await asyncio.gather(
+        *(
+            process_web_search(
                 request,
-                SearchForm(
-                    **{
-                        "query": searchQuery,
-                    }
-                ),
+                SearchForm(**{"query": searchQuery}),
                 user=user,
             )
+            for searchQuery in queries
+        ),
+        return_exceptions=True,
+    )
+
+    for searchQuery, results in zip(queries, gathered_results):
+        try:
+            if isinstance(results, Exception):
+                raise Exception(f"Error searching {searchQuery}: {str(results)}")
 
             if results:
                 all_results.append(results)
@@ -888,16 +892,20 @@ async def process_chat_payload(request, form_data, user, metadata, model):
     # If context is not empty, insert it into the messages
     if len(sources) > 0:
         context_string = ""
-        citated_file_idx = {}
-        for _, source in enumerate(sources, 1):
+        citation_idx = {}
+        for source in sources:
             if "document" in source:
                 for doc_context, doc_meta in zip(
                     source["document"], source["metadata"]
                 ):
-                    file_id = doc_meta.get("file_id")
-                    if file_id not in citated_file_idx:
-                        citated_file_idx[file_id] = len(citated_file_idx) + 1
-                    context_string += f'<source id="{citated_file_idx[file_id]}">{doc_context}</source>\n'
+                    citation_id = (
+                        doc_meta.get("source", None)
+                        or source.get("source", {}).get("id", None)
+                        or "N/A"
+                    )
+                    if citation_id not in citation_idx:
+                        citation_idx[citation_id] = len(citation_idx) + 1
+                    context_string += f'<source id="{citation_idx[citation_id]}">{doc_context}</source>\n'
 
         context_string = context_string.strip()
         prompt = get_last_user_message(form_data["messages"])
@@ -930,7 +938,12 @@ async def process_chat_payload(request, form_data, user, metadata, model):
             )
 
     # If there are citations, add them to the data_items
-    sources = [source for source in sources if source.get("source", {}).get("name", "")]
+    sources = [
+        source
+        for source in sources
+        if source.get("source", {}).get("name", "")
+        or source.get("source", {}).get("id", "")
+    ]
 
     if len(sources) > 0:
         events.append({"sources": sources})
@@ -1129,7 +1142,7 @@ async def process_chat_response(
                     )
 
                     # Send a webhook notification if the user is not active
-                    if get_active_status_by_user_id(user.id) is None:
+                    if not get_active_status_by_user_id(user.id):
                         webhook_url = Users.get_user_webhook_url_by_id(user.id)
                         if webhook_url:
                             post_webhook(
@@ -1418,8 +1431,10 @@ async def process_chat_response(
                             if after_tag:
                                 content_blocks[-1]["content"] = after_tag
 
+                            content = after_tag
                             break
-                elif content_blocks[-1]["type"] == content_type:
+
+                if content and content_blocks[-1]["type"] == content_type:
                     start_tag = content_blocks[-1]["start_tag"]
                     end_tag = content_blocks[-1]["end_tag"]
                     # Match end tag e.g., </tag>
@@ -1667,6 +1682,15 @@ async def process_chat_response(
 
                                                 if current_response_tool_call is None:
                                                     # Add the new tool call
+                                                    delta_tool_call.setdefault(
+                                                        "function", {}
+                                                    )
+                                                    delta_tool_call[
+                                                        "function"
+                                                    ].setdefault("name", "")
+                                                    delta_tool_call[
+                                                        "function"
+                                                    ].setdefault("arguments", "")
                                                     response_tool_calls.append(
                                                         delta_tool_call
                                                     )
@@ -2211,7 +2235,7 @@ async def process_chat_response(
                     )
 
                 # Send a webhook notification if the user is not active
-                if get_active_status_by_user_id(user.id) is None:
+                if not get_active_status_by_user_id(user.id):
                     webhook_url = Users.get_user_webhook_url_by_id(user.id)
                     if webhook_url:
                         post_webhook(

+ 1 - 0
backend/open_webui/utils/models.py

@@ -203,6 +203,7 @@ async def get_all_models(request, user: UserModel = None):
         else:
             function_module, _, _ = load_function_module_by_id(function_id)
             request.app.state.FUNCTIONS[function_id] = function_module
+        return function_module
 
     for model in models:
         action_ids = [

+ 65 - 4
backend/open_webui/utils/oauth.py

@@ -3,6 +3,7 @@ import logging
 import mimetypes
 import sys
 import uuid
+import json
 
 import aiohttp
 from authlib.integrations.starlette_client import OAuth
@@ -15,7 +16,7 @@ from starlette.responses import RedirectResponse
 
 from open_webui.models.auths import Auths
 from open_webui.models.users import Users
-from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm
+from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
 from open_webui.config import (
     DEFAULT_USER_ROLE,
     ENABLE_OAUTH_SIGNUP,
@@ -23,6 +24,8 @@ from open_webui.config import (
     OAUTH_PROVIDERS,
     ENABLE_OAUTH_ROLE_MANAGEMENT,
     ENABLE_OAUTH_GROUP_MANAGEMENT,
+    ENABLE_OAUTH_GROUP_CREATION,
+    OAUTH_BLOCKED_GROUPS,
     OAUTH_ROLES_CLAIM,
     OAUTH_GROUPS_CLAIM,
     OAUTH_EMAIL_CLAIM,
@@ -57,6 +60,8 @@ auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
 auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
 auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
 auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
+auth_manager_config.ENABLE_OAUTH_GROUP_CREATION = ENABLE_OAUTH_GROUP_CREATION
+auth_manager_config.OAUTH_BLOCKED_GROUPS = OAUTH_BLOCKED_GROUPS
 auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
 auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
 auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
@@ -140,6 +145,12 @@ class OAuthManager:
         log.debug("Running OAUTH Group management")
         oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
 
+        try:
+            blocked_groups = json.loads(auth_manager_config.OAUTH_BLOCKED_GROUPS)
+        except Exception as e:
+            log.exception(f"Error loading OAUTH_BLOCKED_GROUPS: {e}")
+            blocked_groups = []
+
         user_oauth_groups = []
         # Nested claim search for groups claim
         if oauth_claim:
@@ -152,6 +163,51 @@ class OAuthManager:
         user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
         all_available_groups: list[GroupModel] = Groups.get_groups()
 
+        # Create groups if they don't exist and creation is enabled
+        if auth_manager_config.ENABLE_OAUTH_GROUP_CREATION:
+            log.debug("Checking for missing groups to create...")
+            all_group_names = {g.name for g in all_available_groups}
+            groups_created = False
+            # Determine creator ID: Prefer admin, fallback to current user if no admin exists
+            admin_user = Users.get_admin_user()
+            creator_id = admin_user.id if admin_user else user.id
+            log.debug(f"Using creator ID {creator_id} for potential group creation.")
+
+            for group_name in user_oauth_groups:
+                if group_name not in all_group_names:
+                    log.info(
+                        f"Group '{group_name}' not found via OAuth claim. Creating group..."
+                    )
+                    try:
+                        new_group_form = GroupForm(
+                            name=group_name,
+                            description=f"Group '{group_name}' created automatically via OAuth.",
+                            permissions=default_permissions,  # Use default permissions from function args
+                            user_ids=[],  # Start with no users, user will be added later by subsequent logic
+                        )
+                        # Use determined creator ID (admin or fallback to current user)
+                        created_group = Groups.insert_new_group(
+                            creator_id, new_group_form
+                        )
+                        if created_group:
+                            log.info(
+                                f"Successfully created group '{group_name}' with ID {created_group.id} using creator ID {creator_id}"
+                            )
+                            groups_created = True
+                            # Add to local set to prevent duplicate creation attempts in this run
+                            all_group_names.add(group_name)
+                        else:
+                            log.error(
+                                f"Failed to create group '{group_name}' via OAuth."
+                            )
+                    except Exception as e:
+                        log.error(f"Error creating group '{group_name}' via OAuth: {e}")
+
+            # Refresh the list of all available groups if any were created
+            if groups_created:
+                all_available_groups = Groups.get_groups()
+                log.debug("Refreshed list of all available groups after creation.")
+
         log.debug(f"Oauth Groups claim: {oauth_claim}")
         log.debug(f"User oauth groups: {user_oauth_groups}")
         log.debug(f"User's current groups: {[g.name for g in user_current_groups]}")
@@ -161,7 +217,11 @@ class OAuthManager:
 
         # Remove groups that user is no longer a part of
         for group_model in user_current_groups:
-            if user_oauth_groups and group_model.name not in user_oauth_groups:
+            if (
+                user_oauth_groups
+                and group_model.name not in user_oauth_groups
+                and group_model.name not in blocked_groups
+            ):
                 # Remove group from user
                 log.debug(
                     f"Removing user from group {group_model.name} as it is no longer in their oauth groups"
@@ -191,6 +251,7 @@ class OAuthManager:
                 user_oauth_groups
                 and group_model.name in user_oauth_groups
                 and not any(gm.name == group_model.name for gm in user_current_groups)
+                and group_model.name not in blocked_groups
             ):
                 # Add user to group
                 log.debug(
@@ -257,7 +318,7 @@ class OAuthManager:
                 try:
                     access_token = token.get("access_token")
                     headers = {"Authorization": f"Bearer {access_token}"}
-                    async with aiohttp.ClientSession() as session:
+                    async with aiohttp.ClientSession(trust_env=True) as session:
                         async with session.get(
                             "https://api.github.com/user/emails", headers=headers
                         ) as resp:
@@ -339,7 +400,7 @@ class OAuthManager:
                                 get_kwargs["headers"] = {
                                     "Authorization": f"Bearer {access_token}",
                                 }
-                            async with aiohttp.ClientSession() as session:
+                            async with aiohttp.ClientSession(trust_env=True) as session:
                                 async with session.get(
                                     picture_url, **get_kwargs
                                 ) as resp:

+ 31 - 1
backend/open_webui/utils/plugin.py

@@ -157,7 +157,8 @@ def load_function_module_by_id(function_id, content=None):
             raise Exception("No Function class found in the module")
     except Exception as e:
         log.error(f"Error loading module: {function_id}: {e}")
-        del sys.modules[module_name]  # Cleanup by removing the module in case of error
+        # Cleanup by removing the module in case of error
+        del sys.modules[module_name]
 
         Functions.update_function_by_id(function_id, {"is_active": False})
         raise e
@@ -182,3 +183,32 @@ def install_frontmatter_requirements(requirements: str):
 
     else:
         log.info("No requirements found in frontmatter.")
+
+
+def install_tool_and_function_dependencies():
+    """
+    Install all dependencies for all admin tools and active functions.
+
+    By first collecting all dependencies from the frontmatter of each tool and function,
+    and then installing them using pip. Duplicates or similar version specifications are
+    handled by pip as much as possible.
+    """
+    function_list = Functions.get_functions(active_only=True)
+    tool_list = Tools.get_tools()
+
+    all_dependencies = ""
+    try:
+        for function in function_list:
+            frontmatter = extract_frontmatter(replace_imports(function.content))
+            if dependencies := frontmatter.get("requirements"):
+                all_dependencies += f"{dependencies}, "
+        for tool in tool_list:
+            # Only install requirements for admin tools
+            if tool.user.role == "admin":
+                frontmatter = extract_frontmatter(replace_imports(tool.content))
+                if dependencies := frontmatter.get("requirements"):
+                    all_dependencies += f"{dependencies}, "
+
+        install_frontmatter_requirements(all_dependencies.strip(", "))
+    except Exception as e:
+        log.error(f"Error installing requirements: {e}")

+ 78 - 51
backend/open_webui/utils/tools.py

@@ -36,7 +36,10 @@ from langchain_core.utils.function_calling import (
 from open_webui.models.tools import Tools
 from open_webui.models.users import UserModel
 from open_webui.utils.plugin import load_tool_module_by_id
-from open_webui.env import AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA
+from open_webui.env import (
+    AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA,
+    AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
+)
 
 import copy
 
@@ -276,8 +279,8 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
 
     docstring = func.__doc__
 
-    description = parse_description(docstring)
-    function_descriptions = parse_docstring(docstring)
+    function_description = parse_description(docstring)
+    function_param_descriptions = parse_docstring(docstring)
 
     field_defs = {}
     for name, param in parameters.items():
@@ -285,15 +288,17 @@ def convert_function_to_pydantic_model(func: Callable) -> type[BaseModel]:
         type_hint = type_hints.get(name, Any)
         default_value = param.default if param.default is not param.empty else ...
 
-        description = function_descriptions.get(name, None)
+        param_description = function_param_descriptions.get(name, None)
 
-        if description:
-            field_defs[name] = type_hint, Field(default_value, description=description)
+        if param_description:
+            field_defs[name] = type_hint, Field(
+                default_value, description=param_description
+            )
         else:
             field_defs[name] = type_hint, default_value
 
     model = create_model(func.__name__, **field_defs)
-    model.__doc__ = description
+    model.__doc__ = function_description
 
     return model
 
@@ -371,51 +376,64 @@ def convert_openapi_to_tool_payload(openapi_spec):
 
     for path, methods in openapi_spec.get("paths", {}).items():
         for method, operation in methods.items():
-            tool = {
-                "type": "function",
-                "name": operation.get("operationId"),
-                "description": operation.get(
-                    "description", operation.get("summary", "No description available.")
-                ),
-                "parameters": {"type": "object", "properties": {}, "required": []},
-            }
-
-            # Extract path and query parameters
-            for param in operation.get("parameters", []):
-                param_name = param["name"]
-                param_schema = param.get("schema", {})
-                tool["parameters"]["properties"][param_name] = {
-                    "type": param_schema.get("type"),
-                    "description": param_schema.get("description", ""),
+            if operation.get("operationId"):
+                tool = {
+                    "type": "function",
+                    "name": operation.get("operationId"),
+                    "description": operation.get(
+                        "description",
+                        operation.get("summary", "No description available."),
+                    ),
+                    "parameters": {"type": "object", "properties": {}, "required": []},
                 }
-                if param.get("required"):
-                    tool["parameters"]["required"].append(param_name)
-
-            # Extract and resolve requestBody if available
-            request_body = operation.get("requestBody")
-            if request_body:
-                content = request_body.get("content", {})
-                json_schema = content.get("application/json", {}).get("schema")
-                if json_schema:
-                    resolved_schema = resolve_schema(
-                        json_schema, openapi_spec.get("components", {})
-                    )
 
-                    if resolved_schema.get("properties"):
-                        tool["parameters"]["properties"].update(
-                            resolved_schema["properties"]
+                # Extract path and query parameters
+                for param in operation.get("parameters", []):
+                    param_name = param["name"]
+                    param_schema = param.get("schema", {})
+                    description = param_schema.get("description", "")
+                    if not description:
+                        description = param.get("description") or ""
+                    if param_schema.get("enum") and isinstance(
+                        param_schema.get("enum"), list
+                    ):
+                        description += (
+                            f". Possible values: {', '.join(param_schema.get('enum'))}"
                         )
-                        if "required" in resolved_schema:
-                            tool["parameters"]["required"] = list(
-                                set(
-                                    tool["parameters"]["required"]
-                                    + resolved_schema["required"]
+                    tool["parameters"]["properties"][param_name] = {
+                        "type": param_schema.get("type"),
+                        "description": description,
+                    }
+                    if param.get("required"):
+                        tool["parameters"]["required"].append(param_name)
+
+                # Extract and resolve requestBody if available
+                request_body = operation.get("requestBody")
+                if request_body:
+                    content = request_body.get("content", {})
+                    json_schema = content.get("application/json", {}).get("schema")
+                    if json_schema:
+                        resolved_schema = resolve_schema(
+                            json_schema, openapi_spec.get("components", {})
+                        )
+
+                        if resolved_schema.get("properties"):
+                            tool["parameters"]["properties"].update(
+                                resolved_schema["properties"]
+                            )
+                            if "required" in resolved_schema:
+                                tool["parameters"]["required"] = list(
+                                    set(
+                                        tool["parameters"]["required"]
+                                        + resolved_schema["required"]
+                                    )
                                 )
+                        elif resolved_schema.get("type") == "array":
+                            tool["parameters"] = (
+                                resolved_schema  # special case for array
                             )
-                    elif resolved_schema.get("type") == "array":
-                        tool["parameters"] = resolved_schema  # special case for array
 
-            tool_payload.append(tool)
+                tool_payload.append(tool)
 
     return tool_payload
 
@@ -431,8 +449,10 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
     error = None
     try:
         timeout = aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_TOOL_SERVER_DATA)
-        async with aiohttp.ClientSession(timeout=timeout) as session:
-            async with session.get(url, headers=headers) as response:
+        async with aiohttp.ClientSession(timeout=timeout, trust_env=True) as session:
+            async with session.get(
+                url, headers=headers, ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL
+            ) as response:
                 if response.status != 200:
                     error_body = await response.json()
                     raise Exception(error_body)
@@ -573,19 +593,26 @@ async def execute_tool_server(
         if token:
             headers["Authorization"] = f"Bearer {token}"
 
-        async with aiohttp.ClientSession() as session:
+        async with aiohttp.ClientSession(trust_env=True) as session:
             request_method = getattr(session, http_method.lower())
 
             if http_method in ["post", "put", "patch"]:
                 async with request_method(
-                    final_url, json=body_params, headers=headers
+                    final_url,
+                    json=body_params,
+                    headers=headers,
+                    ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
                 ) as response:
                     if response.status >= 400:
                         text = await response.text()
                         raise Exception(f"HTTP error {response.status}: {text}")
                     return await response.json()
             else:
-                async with request_method(final_url, headers=headers) as response:
+                async with request_method(
+                    final_url,
+                    headers=headers,
+                    ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
+                ) as response:
                     if response.status >= 400:
                         text = await response.text()
                         raise Exception(f"HTTP error {response.status}: {text}")

+ 19 - 19
backend/requirements.txt

@@ -31,7 +31,7 @@ APScheduler==3.10.4
 
 RestrictedPython==8.0
 
-loguru==0.7.2
+loguru==0.7.3
 asgiref==3.8.1
 
 # AI libraries
@@ -40,8 +40,8 @@ anthropic
 google-generativeai==0.8.4
 tiktoken
 
-langchain==0.3.19
-langchain-community==0.3.18
+langchain==0.3.24
+langchain-community==0.3.23
 
 fake-useragent==2.1.0
 chromadb==0.6.3
@@ -49,11 +49,11 @@ pymilvus==2.5.0
 qdrant-client~=1.12.0
 opensearch-py==2.8.0
 playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
-elasticsearch==8.17.1
-
+elasticsearch==9.0.1
+pinecone==6.0.2
 
 transformers
-sentence-transformers==3.3.1
+sentence-transformers==4.1.0
 accelerate
 colbert-ai==0.2.21
 einops==0.8.1
@@ -81,7 +81,7 @@ azure-ai-documentintelligence==1.0.0
 
 pillow==11.1.0
 opencv-python-headless==4.11.0.86
-rapidocr-onnxruntime==1.3.24
+rapidocr-onnxruntime==1.4.4
 rank-bm25==0.2.2
 
 onnxruntime==1.20.1
@@ -107,7 +107,7 @@ google-auth-oauthlib
 
 ## Tests
 docker~=7.1.0
-pytest~=8.3.2
+pytest~=8.3.5
 pytest-docker~=3.1.1
 
 googleapis-common-protos==1.63.2
@@ -127,14 +127,14 @@ firecrawl-py==1.12.0
 tencentcloud-sdk-python==3.0.1336
 
 ## Trace
-opentelemetry-api==1.31.1
-opentelemetry-sdk==1.31.1
-opentelemetry-exporter-otlp==1.31.1
-opentelemetry-instrumentation==0.52b1
-opentelemetry-instrumentation-fastapi==0.52b1
-opentelemetry-instrumentation-sqlalchemy==0.52b1
-opentelemetry-instrumentation-redis==0.52b1
-opentelemetry-instrumentation-requests==0.52b1
-opentelemetry-instrumentation-logging==0.52b1
-opentelemetry-instrumentation-httpx==0.52b1
-opentelemetry-instrumentation-aiohttp-client==0.52b1
+opentelemetry-api==1.32.1
+opentelemetry-sdk==1.32.1
+opentelemetry-exporter-otlp==1.32.1
+opentelemetry-instrumentation==0.53b1
+opentelemetry-instrumentation-fastapi==0.53b1
+opentelemetry-instrumentation-sqlalchemy==0.53b1
+opentelemetry-instrumentation-redis==0.53b1
+opentelemetry-instrumentation-requests==0.53b1
+opentelemetry-instrumentation-logging==0.53b1
+opentelemetry-instrumentation-httpx==0.53b1
+opentelemetry-instrumentation-aiohttp-client==0.53b1

+ 1 - 1
docs/apache.md

@@ -1,6 +1,6 @@
 # Hosting UI and Models separately
 
-Sometimes, its beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users:
+Sometimes, it's beneficial to host Ollama, separate from the UI, but retain the RAG and RBAC support features shared across users:
 
 # Open WebUI Configuration
 

+ 122 - 203
package-lock.json

@@ -1,12 +1,12 @@
 {
 	"name": "open-webui",
-	"version": "0.6.5",
+	"version": "0.6.6",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "open-webui",
-			"version": "0.6.5",
+			"version": "0.6.6",
 			"dependencies": {
 				"@azure/msal-browser": "^4.5.0",
 				"@codemirror/lang-javascript": "^6.2.2",
@@ -18,8 +18,8 @@
 				"@pyscript/core": "^0.4.32",
 				"@sveltejs/adapter-node": "^2.0.0",
 				"@sveltejs/svelte-virtual-list": "^3.0.1",
-				"@tiptap/core": "^2.10.0",
-				"@tiptap/extension-code-block-lowlight": "^2.10.0",
+				"@tiptap/core": "^2.11.9",
+				"@tiptap/extension-code-block-lowlight": "^2.11.9",
 				"@tiptap/extension-highlight": "^2.10.0",
 				"@tiptap/extension-placeholder": "^2.10.0",
 				"@tiptap/extension-typography": "^2.10.0",
@@ -30,7 +30,7 @@
 				"bits-ui": "^0.19.7",
 				"codemirror": "^6.0.1",
 				"codemirror-lang-elixir": "^4.0.0",
-				"codemirror-lang-hcl": "^0.0.0-beta.2",
+				"codemirror-lang-hcl": "^0.1.0",
 				"crc-32": "^1.2.2",
 				"dayjs": "^1.11.10",
 				"dompurify": "^3.2.5",
@@ -59,7 +59,7 @@
 				"prosemirror-markdown": "^1.13.1",
 				"prosemirror-model": "^1.23.0",
 				"prosemirror-schema-basic": "^1.2.3",
-				"prosemirror-schema-list": "^1.4.1",
+				"prosemirror-schema-list": "^1.5.1",
 				"prosemirror-state": "^1.4.3",
 				"prosemirror-view": "^1.34.3",
 				"pyodide": "^0.27.3",
@@ -81,8 +81,8 @@
 				"@tailwindcss/container-queries": "^0.1.1",
 				"@tailwindcss/postcss": "^4.0.0",
 				"@tailwindcss/typography": "^0.5.13",
-				"@typescript-eslint/eslint-plugin": "^6.17.0",
-				"@typescript-eslint/parser": "^6.17.0",
+				"@typescript-eslint/eslint-plugin": "^8.31.1",
+				"@typescript-eslint/parser": "^8.31.1",
 				"cypress": "^13.15.0",
 				"eslint": "^8.56.0",
 				"eslint-config-prettier": "^9.1.0",
@@ -2890,9 +2890,9 @@
 			}
 		},
 		"node_modules/@tiptap/core": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.10.0.tgz",
-			"integrity": "sha512-58nAjPxLRFcXepdDqQRC1mhrw6E8Sanqr6bbO4Tz0+FWgDJMZvHG+dOK5wHaDVNSgK2iJDz08ETvQayfOOgDvg==",
+			"version": "2.11.9",
+			"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.9.tgz",
+			"integrity": "sha512-UZSxQLLyJst47xep3jlyKM6y1ebZnmvbGsB7njBVjfxf5H+4yFpRJwwNqrBHM/vyU55LCtPChojqaYC1wXLf6g==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
@@ -2969,9 +2969,9 @@
 			}
 		},
 		"node_modules/@tiptap/extension-code-block-lowlight": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.10.0.tgz",
-			"integrity": "sha512-dAv03XIHT5h+sdFmJzvx2FfpfFOOK9SBKHflRUdqTa8eA+0VZNAcPRjvJWVEWqts1fKZDJj774mO28NlhFzk9Q==",
+			"version": "2.11.9",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.11.9.tgz",
+			"integrity": "sha512-bB8N59A2aU18/ieyKRZAI0J0xyimmUckYePqBkUX8HFnq8yf9HsM0NPFpqZdK0eqjnZYCXcNwAI3YluLsHuutw==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
@@ -3547,12 +3547,6 @@
 				"@types/unist": "*"
 			}
 		},
-		"node_modules/@types/json-schema": {
-			"version": "7.0.15",
-			"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
-			"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
-			"dev": true
-		},
 		"node_modules/@types/linkify-it": {
 			"version": "5.0.0",
 			"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -3604,12 +3598,6 @@
 			"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
 			"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="
 		},
-		"node_modules/@types/semver": {
-			"version": "7.5.8",
-			"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
-			"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
-			"dev": true
-		},
 		"node_modules/@types/sinonjs__fake-timers": {
 			"version": "8.1.1",
 			"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz",
@@ -3652,79 +3640,72 @@
 			}
 		},
 		"node_modules/@typescript-eslint/eslint-plugin": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
-			"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.31.1.tgz",
+			"integrity": "sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@eslint-community/regexpp": "^4.5.1",
-				"@typescript-eslint/scope-manager": "6.21.0",
-				"@typescript-eslint/type-utils": "6.21.0",
-				"@typescript-eslint/utils": "6.21.0",
-				"@typescript-eslint/visitor-keys": "6.21.0",
-				"debug": "^4.3.4",
+				"@eslint-community/regexpp": "^4.10.0",
+				"@typescript-eslint/scope-manager": "8.31.1",
+				"@typescript-eslint/type-utils": "8.31.1",
+				"@typescript-eslint/utils": "8.31.1",
+				"@typescript-eslint/visitor-keys": "8.31.1",
 				"graphemer": "^1.4.0",
-				"ignore": "^5.2.4",
+				"ignore": "^5.3.1",
 				"natural-compare": "^1.4.0",
-				"semver": "^7.5.4",
-				"ts-api-utils": "^1.0.1"
+				"ts-api-utils": "^2.0.1"
 			},
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
-				"eslint": "^7.0.0 || ^8.0.0"
-			},
-			"peerDependenciesMeta": {
-				"typescript": {
-					"optional": true
-				}
+				"@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
+				"eslint": "^8.57.0 || ^9.0.0",
+				"typescript": ">=4.8.4 <5.9.0"
 			}
 		},
 		"node_modules/@typescript-eslint/parser": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz",
-			"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.31.1.tgz",
+			"integrity": "sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/scope-manager": "6.21.0",
-				"@typescript-eslint/types": "6.21.0",
-				"@typescript-eslint/typescript-estree": "6.21.0",
-				"@typescript-eslint/visitor-keys": "6.21.0",
+				"@typescript-eslint/scope-manager": "8.31.1",
+				"@typescript-eslint/types": "8.31.1",
+				"@typescript-eslint/typescript-estree": "8.31.1",
+				"@typescript-eslint/visitor-keys": "8.31.1",
 				"debug": "^4.3.4"
 			},
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"eslint": "^7.0.0 || ^8.0.0"
-			},
-			"peerDependenciesMeta": {
-				"typescript": {
-					"optional": true
-				}
+				"eslint": "^8.57.0 || ^9.0.0",
+				"typescript": ">=4.8.4 <5.9.0"
 			}
 		},
 		"node_modules/@typescript-eslint/scope-manager": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
-			"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.31.1.tgz",
+			"integrity": "sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/types": "6.21.0",
-				"@typescript-eslint/visitor-keys": "6.21.0"
+				"@typescript-eslint/types": "8.31.1",
+				"@typescript-eslint/visitor-keys": "8.31.1"
 			},
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
@@ -3732,39 +3713,37 @@
 			}
 		},
 		"node_modules/@typescript-eslint/type-utils": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
-			"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.31.1.tgz",
+			"integrity": "sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/typescript-estree": "6.21.0",
-				"@typescript-eslint/utils": "6.21.0",
+				"@typescript-eslint/typescript-estree": "8.31.1",
+				"@typescript-eslint/utils": "8.31.1",
 				"debug": "^4.3.4",
-				"ts-api-utils": "^1.0.1"
+				"ts-api-utils": "^2.0.1"
 			},
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"eslint": "^7.0.0 || ^8.0.0"
-			},
-			"peerDependenciesMeta": {
-				"typescript": {
-					"optional": true
-				}
+				"eslint": "^8.57.0 || ^9.0.0",
+				"typescript": ">=4.8.4 <5.9.0"
 			}
 		},
 		"node_modules/@typescript-eslint/types": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
-			"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.31.1.tgz",
+			"integrity": "sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
@@ -3772,75 +3751,87 @@
 			}
 		},
 		"node_modules/@typescript-eslint/typescript-estree": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
-			"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.31.1.tgz",
+			"integrity": "sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/types": "6.21.0",
-				"@typescript-eslint/visitor-keys": "6.21.0",
+				"@typescript-eslint/types": "8.31.1",
+				"@typescript-eslint/visitor-keys": "8.31.1",
 				"debug": "^4.3.4",
-				"globby": "^11.1.0",
+				"fast-glob": "^3.3.2",
 				"is-glob": "^4.0.3",
-				"minimatch": "9.0.3",
-				"semver": "^7.5.4",
-				"ts-api-utils": "^1.0.1"
+				"minimatch": "^9.0.4",
+				"semver": "^7.6.0",
+				"ts-api-utils": "^2.0.1"
 			},
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/typescript-eslint"
 			},
-			"peerDependenciesMeta": {
-				"typescript": {
-					"optional": true
-				}
+			"peerDependencies": {
+				"typescript": ">=4.8.4 <5.9.0"
 			}
 		},
 		"node_modules/@typescript-eslint/utils": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
-			"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.31.1.tgz",
+			"integrity": "sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
 				"@eslint-community/eslint-utils": "^4.4.0",
-				"@types/json-schema": "^7.0.12",
-				"@types/semver": "^7.5.0",
-				"@typescript-eslint/scope-manager": "6.21.0",
-				"@typescript-eslint/types": "6.21.0",
-				"@typescript-eslint/typescript-estree": "6.21.0",
-				"semver": "^7.5.4"
+				"@typescript-eslint/scope-manager": "8.31.1",
+				"@typescript-eslint/types": "8.31.1",
+				"@typescript-eslint/typescript-estree": "8.31.1"
 			},
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/typescript-eslint"
 			},
 			"peerDependencies": {
-				"eslint": "^7.0.0 || ^8.0.0"
+				"eslint": "^8.57.0 || ^9.0.0",
+				"typescript": ">=4.8.4 <5.9.0"
 			}
 		},
 		"node_modules/@typescript-eslint/visitor-keys": {
-			"version": "6.21.0",
-			"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
-			"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
+			"version": "8.31.1",
+			"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.31.1.tgz",
+			"integrity": "sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==",
 			"dev": true,
+			"license": "MIT",
 			"dependencies": {
-				"@typescript-eslint/types": "6.21.0",
-				"eslint-visitor-keys": "^3.4.1"
+				"@typescript-eslint/types": "8.31.1",
+				"eslint-visitor-keys": "^4.2.0"
 			},
 			"engines": {
-				"node": "^16.0.0 || >=18.0.0"
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 			},
 			"funding": {
 				"type": "opencollective",
 				"url": "https://opencollective.com/typescript-eslint"
 			}
 		},
+		"node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+			"version": "4.2.0",
+			"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
+			"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
+			"dev": true,
+			"license": "Apache-2.0",
+			"engines": {
+				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+			},
+			"funding": {
+				"url": "https://opencollective.com/eslint"
+			}
+		},
 		"node_modules/@ungap/structured-clone": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
@@ -4166,15 +4157,6 @@
 				"dequal": "^2.0.3"
 			}
 		},
-		"node_modules/array-union": {
-			"version": "2.1.0",
-			"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
-			"integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
-			"dev": true,
-			"engines": {
-				"node": ">=8"
-			}
-		},
 		"node_modules/asn1": {
 			"version": "0.2.6",
 			"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
@@ -4995,9 +4977,9 @@
 			}
 		},
 		"node_modules/codemirror-lang-hcl": {
-			"version": "0.0.0-beta.2",
-			"resolved": "https://registry.npmjs.org/codemirror-lang-hcl/-/codemirror-lang-hcl-0.0.0-beta.2.tgz",
-			"integrity": "sha512-R3ew7Z2EYTdHTMXsWKBW9zxnLoLPYO+CrAa3dPZjXLrIR96Q3GR4cwJKF7zkSsujsnWgwRQZonyWpXYXfhQYuQ==",
+			"version": "0.1.0",
+			"resolved": "https://registry.npmjs.org/codemirror-lang-hcl/-/codemirror-lang-hcl-0.1.0.tgz",
+			"integrity": "sha512-duwKEaQDhkJWad4YQ9pv4282BS6hCdR+gS/qTAj3f9bypXNNZ42bIN43h9WK3DjyZRENtVlUQdrQM1sA44wHmA==",
 			"license": "MIT",
 			"dependencies": {
 				"@codemirror/language": "^6.0.0",
@@ -6022,18 +6004,6 @@
 				"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
 			}
 		},
-		"node_modules/dir-glob": {
-			"version": "3.0.1",
-			"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
-			"integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
-			"dev": true,
-			"dependencies": {
-				"path-type": "^4.0.0"
-			},
-			"engines": {
-				"node": ">=8"
-			}
-		},
 		"node_modules/doctrine": {
 			"version": "3.0.0",
 			"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -7154,26 +7124,6 @@
 				"url": "https://github.com/sponsors/sindresorhus"
 			}
 		},
-		"node_modules/globby": {
-			"version": "11.1.0",
-			"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
-			"integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
-			"dev": true,
-			"dependencies": {
-				"array-union": "^2.1.0",
-				"dir-glob": "^3.0.1",
-				"fast-glob": "^3.2.9",
-				"ignore": "^5.2.0",
-				"merge2": "^1.4.1",
-				"slash": "^3.0.0"
-			},
-			"engines": {
-				"node": ">=10"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/sindresorhus"
-			}
-		},
 		"node_modules/gopd": {
 			"version": "1.0.1",
 			"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -8762,10 +8712,10 @@
 			}
 		},
 		"node_modules/minimatch": {
-			"version": "9.0.3",
-			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
-			"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
-			"dev": true,
+			"version": "9.0.5",
+			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+			"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+			"license": "ISC",
 			"dependencies": {
 				"brace-expansion": "^2.0.1"
 			},
@@ -8841,21 +8791,6 @@
 				"@pkgjs/parseargs": "^0.11.0"
 			}
 		},
-		"node_modules/minizlib/node_modules/minimatch": {
-			"version": "9.0.5",
-			"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
-			"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
-			"license": "ISC",
-			"dependencies": {
-				"brace-expansion": "^2.0.1"
-			},
-			"engines": {
-				"node": ">=16 || 14 >=14.17"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/isaacs"
-			}
-		},
 		"node_modules/minizlib/node_modules/rimraf": {
 			"version": "5.0.10",
 			"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz",
@@ -9330,15 +9265,6 @@
 				"node": "14 || >=16.14"
 			}
 		},
-		"node_modules/path-type": {
-			"version": "4.0.0",
-			"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
-			"integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
-			"dev": true,
-			"engines": {
-				"node": ">=8"
-			}
-		},
 		"node_modules/pathe": {
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -9860,9 +9786,10 @@
 			}
 		},
 		"node_modules/prosemirror-schema-list": {
-			"version": "1.4.1",
-			"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz",
-			"integrity": "sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==",
+			"version": "1.5.1",
+			"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+			"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+			"license": "MIT",
 			"dependencies": {
 				"prosemirror-model": "^1.0.0",
 				"prosemirror-state": "^1.0.0",
@@ -11069,15 +10996,6 @@
 				"node": ">=18"
 			}
 		},
-		"node_modules/slash": {
-			"version": "3.0.0",
-			"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
-			"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
-			"dev": true,
-			"engines": {
-				"node": ">=8"
-			}
-		},
 		"node_modules/slice-ansi": {
 			"version": "3.0.0",
 			"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
@@ -11842,15 +11760,16 @@
 			}
 		},
 		"node_modules/ts-api-utils": {
-			"version": "1.3.0",
-			"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
-			"integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
+			"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
 			"dev": true,
+			"license": "MIT",
 			"engines": {
-				"node": ">=16"
+				"node": ">=18.12"
 			},
 			"peerDependencies": {
-				"typescript": ">=4.2.0"
+				"typescript": ">=4.8.4"
 			}
 		},
 		"node_modules/ts-dedent": {

+ 7 - 7
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "open-webui",
-	"version": "0.6.5",
+	"version": "0.6.6",
 	"private": true,
 	"scripts": {
 		"dev": "npm run pyodide:fetch && vite dev --host",
@@ -28,8 +28,8 @@
 		"@tailwindcss/container-queries": "^0.1.1",
 		"@tailwindcss/postcss": "^4.0.0",
 		"@tailwindcss/typography": "^0.5.13",
-		"@typescript-eslint/eslint-plugin": "^6.17.0",
-		"@typescript-eslint/parser": "^6.17.0",
+		"@typescript-eslint/eslint-plugin": "^8.31.1",
+		"@typescript-eslint/parser": "^8.31.1",
 		"cypress": "^13.15.0",
 		"eslint": "^8.56.0",
 		"eslint-config-prettier": "^9.1.0",
@@ -61,8 +61,8 @@
 		"@pyscript/core": "^0.4.32",
 		"@sveltejs/adapter-node": "^2.0.0",
 		"@sveltejs/svelte-virtual-list": "^3.0.1",
-		"@tiptap/core": "^2.10.0",
-		"@tiptap/extension-code-block-lowlight": "^2.10.0",
+		"@tiptap/core": "^2.11.9",
+		"@tiptap/extension-code-block-lowlight": "^2.11.9",
 		"@tiptap/extension-highlight": "^2.10.0",
 		"@tiptap/extension-placeholder": "^2.10.0",
 		"@tiptap/extension-typography": "^2.10.0",
@@ -73,7 +73,7 @@
 		"bits-ui": "^0.19.7",
 		"codemirror": "^6.0.1",
 		"codemirror-lang-elixir": "^4.0.0",
-		"codemirror-lang-hcl": "^0.0.0-beta.2",
+		"codemirror-lang-hcl": "^0.1.0",
 		"crc-32": "^1.2.2",
 		"dayjs": "^1.11.10",
 		"dompurify": "^3.2.5",
@@ -102,7 +102,7 @@
 		"prosemirror-markdown": "^1.13.1",
 		"prosemirror-model": "^1.23.0",
 		"prosemirror-schema-basic": "^1.2.3",
-		"prosemirror-schema-list": "^1.4.1",
+		"prosemirror-schema-list": "^1.5.1",
 		"prosemirror-state": "^1.4.3",
 		"prosemirror-view": "^1.34.3",
 		"pyodide": "^0.27.3",

+ 7 - 6
pyproject.toml

@@ -40,7 +40,7 @@ dependencies = [
 
     "RestrictedPython==8.0",
 
-    "loguru==0.7.2",
+    "loguru==0.7.3",
     "asgiref==3.8.1",
 
     "openai",
@@ -48,8 +48,8 @@ dependencies = [
     "google-generativeai==0.8.4",
     "tiktoken",
 
-    "langchain==0.3.19",
-    "langchain-community==0.3.18",
+    "langchain==0.3.24",
+    "langchain-community==0.3.23",
 
     "fake-useragent==2.1.0",
     "chromadb==0.6.3",
@@ -57,10 +57,11 @@ dependencies = [
     "qdrant-client~=1.12.0",
     "opensearch-py==2.8.0",
     "playwright==1.49.1",
-    "elasticsearch==8.17.1",
+    "elasticsearch==9.0.1",
+    "pinecone==6.0.2",
 
     "transformers",
-    "sentence-transformers==3.3.1",
+    "sentence-transformers==4.1.0",
     "accelerate",
     "colbert-ai==0.2.21",
     "einops==0.8.1",
@@ -87,7 +88,7 @@ dependencies = [
 
     "pillow==11.1.0",
     "opencv-python-headless==4.11.0.86",
-    "rapidocr-onnxruntime==1.3.24",
+    "rapidocr-onnxruntime==1.4.4",
     "rank-bm25==0.2.2",
 
     "onnxruntime==1.20.1",

+ 4 - 2
src/lib/apis/auths/index.ts

@@ -354,7 +354,8 @@ export const addUser = async (
 	name: string,
 	email: string,
 	password: string,
-	role: string = 'pending'
+	role: string = 'pending',
+	profile_image_url: null | string = null
 ) => {
 	let error = null;
 
@@ -368,7 +369,8 @@ export const addUser = async (
 			name: name,
 			email: email,
 			password: password,
-			role: role
+			role: role,
+			...(profile_image_url && { profile_image_url: profile_image_url })
 		})
 	})
 		.then(async (res) => {

+ 0 - 1
src/lib/apis/channels/index.ts

@@ -1,5 +1,4 @@
 import { WEBUI_API_BASE_URL } from '$lib/constants';
-import { t } from 'i18next';
 
 type ChannelForm = {
 	name: string;

+ 34 - 2
src/lib/apis/index.ts

@@ -539,7 +539,7 @@ export const updateTaskConfig = async (token: string, config: object) => {
 export const generateTitle = async (
 	token: string = '',
 	model: string,
-	messages: string[],
+	messages: object[],
 	chat_id?: string
 ) => {
 	let error = null;
@@ -573,7 +573,39 @@ export const generateTitle = async (
 		throw error;
 	}
 
-	return res?.choices[0]?.message?.content.replace(/["']/g, '') ?? 'New Chat';
+	try {
+		// Step 1: Safely extract the response string
+		const response = res?.choices[0]?.message?.content ?? '';
+
+		// Step 2: Attempt to fix common JSON format issues like single quotes
+		const sanitizedResponse = response.replace(/['‘’`]/g, '"'); // Convert single quotes to double quotes for valid JSON
+
+		// Step 3: Find the relevant JSON block within the response
+		const jsonStartIndex = sanitizedResponse.indexOf('{');
+		const jsonEndIndex = sanitizedResponse.lastIndexOf('}');
+
+		// Step 4: Check if we found a valid JSON block (with both `{` and `}`)
+		if (jsonStartIndex !== -1 && jsonEndIndex !== -1) {
+			const jsonResponse = sanitizedResponse.substring(jsonStartIndex, jsonEndIndex + 1);
+
+			// Step 5: Parse the JSON block
+			const parsed = JSON.parse(jsonResponse);
+
+			// Step 6: If there's a "tags" key, return the tags array; otherwise, return an empty array
+			if (parsed && parsed.title) {
+				return parsed.title;
+			} else {
+				return null;
+			}
+		}
+
+		// If no valid JSON block found, return an empty array
+		return null;
+	} catch (e) {
+		// Catch and safely return empty array on any parsing errors
+		console.error('Failed to parse response: ', e);
+		return null;
+	}
 };
 
 export const generateTags = async (

+ 187 - 0
src/lib/apis/notes/index.ts

@@ -0,0 +1,187 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+import { getTimeRange } from '$lib/utils';
+
+type NoteItem = {
+	title: string;
+	data: object;
+	meta?: null | object;
+	access_control?: null | object;
+};
+
+export const createNewNote = async (token: string, note: NoteItem) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/create`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			...note
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getNotes = async (token: string = '') => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	if (!Array.isArray(res)) {
+		return {}; // or throw new Error("Notes response is not an array")
+	}
+
+	// Build the grouped object
+	const grouped: Record<string, any[]> = {};
+	for (const note of res) {
+		const timeRange = getTimeRange(note.updated_at / 1000000000);
+		if (!grouped[timeRange]) {
+			grouped[timeRange] = [];
+		}
+		grouped[timeRange].push({
+			...note,
+			timeRange
+		});
+	}
+
+	return grouped;
+};
+
+export const getNoteById = async (token: string, id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const updateNoteById = async (token: string, id: string, note: NoteItem) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/update`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({
+			...note
+		})
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const deleteNoteById = async (token: string, id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/${id}/delete`, {
+		method: 'DELETE',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 54 - 3
src/lib/apis/users/index.ts

@@ -116,10 +116,61 @@ export const updateUserRole = async (token: string, id: string, role: string) =>
 	return res;
 };
 
-export const getUsers = async (token: string) => {
+export const getUsers = async (
+	token: string,
+	query?: string,
+	orderBy?: string,
+	direction?: string,
+	page = 1
+) => {
 	let error = null;
+	let res = null;
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/users/`, {
+	let searchParams = new URLSearchParams();
+
+	searchParams.set('page', `${page}`);
+
+	if (query) {
+		searchParams.set('query', query);
+	}
+
+	if (orderBy) {
+		searchParams.set('order_by', orderBy);
+	}
+
+	if (direction) {
+		searchParams.set('direction', direction);
+	}
+
+	res = await fetch(`${WEBUI_API_BASE_URL}/users/?${searchParams.toString()}`, {
+		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 getAllUsers = async (token: string) => {
+	let error = null;
+	let res = null;
+
+	res = await fetch(`${WEBUI_API_BASE_URL}/users/all`, {
 		method: 'GET',
 		headers: {
 			'Content-Type': 'application/json',
@@ -140,7 +191,7 @@ export const getUsers = async (token: string) => {
 		throw error;
 	}
 
-	return res ? res : [];
+	return res;
 };
 
 export const getUserSettings = async (token: string) => {

+ 1 - 1
src/lib/components/AddFilesPlaceholder.svelte

@@ -21,7 +21,7 @@
 			{#if content}
 				{content}
 			{:else}
-				{$i18n.t('Drop any files here to add to the conversation')}
+				{$i18n.t('Drop any files here to upload')}
 			{/if}
 		</div>
 	</slot>

+ 1 - 1
src/lib/components/admin/Functions.svelte

@@ -192,7 +192,7 @@
 
 <svelte:head>
 	<title>
-		{$i18n.t('Functions')} | {$WEBUI_NAME}
+		{$i18n.t('Functions')}  {$WEBUI_NAME}
 	</title>
 </svelte:head>
 

+ 1 - 1
src/lib/components/admin/Functions/FunctionEditor.svelte

@@ -387,7 +387,7 @@ class Pipe:
 					<div class="flex-1 pr-3">
 						<div class="text-xs text-gray-500 line-clamp-2">
 							<span class=" font-semibold dark:text-gray-200">{$i18n.t('Warning:')}</span>
-							{$i18n.t('Functions allow arbitrary code execution')} <br />—
+							{$i18n.t('Functions allow arbitrary code execution.')} <br />—
 							<span class=" font-medium dark:text-gray-400"
 								>{$i18n.t(`don't install random functions from sources you don't trust.`)}</span
 							>

+ 33 - 1
src/lib/components/admin/Settings/Audio.svelte

@@ -42,6 +42,8 @@
 	let STT_AZURE_API_KEY = '';
 	let STT_AZURE_REGION = '';
 	let STT_AZURE_LOCALES = '';
+	let STT_AZURE_BASE_URL = '';
+	let STT_AZURE_MAX_SPEAKERS = '';
 	let STT_DEEPGRAM_API_KEY = '';
 
 	let STT_WHISPER_MODEL_LOADING = false;
@@ -114,7 +116,9 @@
 				DEEPGRAM_API_KEY: STT_DEEPGRAM_API_KEY,
 				AZURE_API_KEY: STT_AZURE_API_KEY,
 				AZURE_REGION: STT_AZURE_REGION,
-				AZURE_LOCALES: STT_AZURE_LOCALES
+				AZURE_LOCALES: STT_AZURE_LOCALES,
+				AZURE_BASE_URL: STT_AZURE_BASE_URL,
+				AZURE_MAX_SPEAKERS: STT_AZURE_MAX_SPEAKERS
 			}
 		});
 
@@ -157,6 +161,8 @@
 			STT_AZURE_API_KEY = res.stt.AZURE_API_KEY;
 			STT_AZURE_REGION = res.stt.AZURE_REGION;
 			STT_AZURE_LOCALES = res.stt.AZURE_LOCALES;
+			STT_AZURE_BASE_URL = res.stt.AZURE_BASE_URL;
+			STT_AZURE_MAX_SPEAKERS = res.stt.AZURE_MAX_SPEAKERS;
 			STT_DEEPGRAM_API_KEY = res.stt.DEEPGRAM_API_KEY;
 		}
 
@@ -288,6 +294,32 @@
 								</div>
 							</div>
 						</div>
+
+						<div>
+							<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Base URL')}</div>
+							<div class="flex w-full">
+								<div class="flex-1">
+									<input
+										class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+										bind:value={STT_AZURE_BASE_URL}
+										placeholder={$i18n.t('(leave blank for Azure Commercial URL auto-generation)')}
+									/>
+								</div>
+							</div>
+						</div>
+
+						<div>
+							<div class=" mb-1.5 text-sm font-medium">{$i18n.t('Max Speakers')}</div>
+							<div class="flex w-full">
+								<div class="flex-1">
+									<input
+										class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+										bind:value={STT_AZURE_MAX_SPEAKERS}
+										placeholder={$i18n.t('e.g., 3, 4, 5 (leave blank for default)')}
+									/>
+								</div>
+							</div>
+						</div>
 					</div>
 				{:else if STT_ENGINE === ''}
 					<div>

+ 22 - 0
src/lib/components/admin/Settings/Documents.svelte

@@ -161,6 +161,16 @@
 			toast.error($i18n.t('Docling Server URL required.'));
 			return;
 		}
+		if (
+			RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
+			((RAGConfig.DOCLING_OCR_ENGINE === '' && RAGConfig.DOCLING_OCR_LANG !== '') ||
+				(RAGConfig.DOCLING_OCR_ENGINE !== '' && RAGConfig.DOCLING_OCR_LANG === ''))
+		) {
+			toast.error(
+				$i18n.t('Both Docling OCR Engine and Language(s) must be provided or both left empty.')
+			);
+			return;
+		}
 
 		if (
 			RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence' &&
@@ -326,6 +336,18 @@
 									bind:value={RAGConfig.DOCLING_SERVER_URL}
 								/>
 							</div>
+							<div class="flex w-full mt-2">
+								<input
+									class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
+									placeholder={$i18n.t('Enter Docling OCR Engine')}
+									bind:value={RAGConfig.DOCLING_OCR_ENGINE}
+								/>
+								<input
+									class="flex-1 w-full rounded-lg text-sm bg-transparent outline-hidden"
+									placeholder={$i18n.t('Enter Docling OCR Language(s)')}
+									bind:value={RAGConfig.DOCLING_OCR_LANG}
+								/>
+							</div>
 						{:else if RAGConfig.CONTENT_EXTRACTION_ENGINE === 'document_intelligence'}
 							<div class="my-0.5 flex gap-2 pr-2">
 								<input

+ 9 - 1
src/lib/components/admin/Settings/General.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 	import DOMPurify from 'dompurify';
 
-	import { getBackendConfig, getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
+	import { getVersionUpdates, getWebhookUrl, updateWebhookUrl } from '$lib/apis';
 	import {
 		getAdminConfig,
 		getLdapConfig,
@@ -601,6 +601,14 @@
 						<Switch bind:state={adminConfig.ENABLE_MESSAGE_RATING} />
 					</div>
 
+					<div class="mb-2.5 flex w-full items-center justify-between pr-2">
+						<div class=" self-center text-xs font-medium">
+							{$i18n.t('Notes')} ({$i18n.t('Beta')})
+						</div>
+
+						<Switch bind:state={adminConfig.ENABLE_NOTES} />
+					</div>
+
 					<div class="mb-2.5 flex w-full items-center justify-between pr-2">
 						<div class=" self-center text-xs font-medium">
 							{$i18n.t('Channels')} ({$i18n.t('Beta')})

+ 146 - 3
src/lib/components/admin/Settings/WebSearch.svelte

@@ -14,6 +14,7 @@
 
 	let webSearchEngines = [
 		'searxng',
+		'yacy',
 		'google_pse',
 		'brave',
 		'kagi',
@@ -30,9 +31,11 @@
 		'bing',
 		'exa',
 		'perplexity',
-		'sougou'
+		'sougou',
+		'firecrawl',
+		'external'
 	];
-	let webLoaderEngines = ['playwright', 'firecrawl', 'tavily'];
+	let webLoaderEngines = ['playwright', 'firecrawl', 'tavily', 'external'];
 
 	let webConfig = null;
 
@@ -143,6 +146,53 @@
 									</div>
 								</div>
 							</div>
+						{:else if webConfig.WEB_SEARCH_ENGINE === 'yacy'}
+							<div class="mb-2.5 flex w-full flex-col">
+								<div>
+									<div class=" self-center text-xs font-medium mb-1">
+										{$i18n.t('Yacy Instance URL')}
+									</div>
+
+									<div class="flex w-full">
+										<div class="flex-1">
+											<input
+												class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+												type="text"
+												placeholder={$i18n.t('Enter Yacy URL (e.g. http://yacy.example.com:8090)')}
+												bind:value={webConfig.YACY_QUERY_URL}
+												autocomplete="off"
+											/>
+										</div>
+									</div>
+								</div>
+							</div>
+							<div class="mb-2.5 flex w-full flex-col">
+								<div class="flex gap-2">
+									<div class="w-full">
+										<div class=" self-center text-xs font-medium mb-1">
+											{$i18n.t('Yacy Username')}
+										</div>
+
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+											placeholder={$i18n.t('Enter Yacy Username')}
+											bind:value={webConfig.YACY_USERNAME}
+											required
+										/>
+									</div>
+
+									<div class="w-full">
+										<div class=" self-center text-xs font-medium mb-1">
+											{$i18n.t('Yacy Password')}
+										</div>
+
+										<SensitiveInput
+											placeholder={$i18n.t('Enter Yacy Password')}
+											bind:value={webConfig.YACY_PASSWORD}
+										/>
+									</div>
+								</div>
+							</div>
 						{:else if webConfig.WEB_SEARCH_ENGINE === 'google_pse'}
 							<div class="mb-2.5 flex w-full flex-col">
 								<div>
@@ -431,6 +481,68 @@
 									/>
 								</div>
 							</div>
+						{:else if webConfig.WEB_SEARCH_ENGINE === 'firecrawl'}
+							<div class="mb-2.5 flex w-full flex-col">
+								<div>
+									<div class=" self-center text-xs font-medium mb-1">
+										{$i18n.t('Firecrawl API Base URL')}
+									</div>
+
+									<div class="flex w-full">
+										<div class="flex-1">
+											<input
+												class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+												type="text"
+												placeholder={$i18n.t('Enter Firecrawl API Base URL')}
+												bind:value={webConfig.FIRECRAWL_API_BASE_URL}
+												autocomplete="off"
+											/>
+										</div>
+									</div>
+								</div>
+
+								<div class="mt-2">
+									<div class=" self-center text-xs font-medium mb-1">
+										{$i18n.t('Firecrawl API Key')}
+									</div>
+
+									<SensitiveInput
+										placeholder={$i18n.t('Enter Firecrawl API Key')}
+										bind:value={webConfig.FIRECRAWL_API_KEY}
+									/>
+								</div>
+							</div>
+						{:else if webConfig.WEB_SEARCH_ENGINE === 'external'}
+							<div class="mb-2.5 flex w-full flex-col">
+								<div>
+									<div class=" self-center text-xs font-medium mb-1">
+										{$i18n.t('External Web Search URL')}
+									</div>
+
+									<div class="flex w-full">
+										<div class="flex-1">
+											<input
+												class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+												type="text"
+												placeholder={$i18n.t('Enter External Web Search URL')}
+												bind:value={webConfig.EXTERNAL_WEB_SEARCH_URL}
+												autocomplete="off"
+											/>
+										</div>
+									</div>
+								</div>
+
+								<div class="mt-2">
+									<div class=" self-center text-xs font-medium mb-1">
+										{$i18n.t('External Web Search API Key')}
+									</div>
+
+									<SensitiveInput
+										placeholder={$i18n.t('Enter External Web Search API Key')}
+										bind:value={webConfig.EXTERNAL_WEB_SEARCH_API_KEY}
+									/>
+								</div>
+							</div>
 						{/if}
 					{/if}
 
@@ -588,7 +700,7 @@
 								</div>
 							</div>
 						</div>
-					{:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl'}
+					{:else if webConfig.WEB_LOADER_ENGINE === 'firecrawl' && webConfig.WEB_SEARCH_ENGINE !== 'firecrawl'}
 						<div class="mb-2.5 flex w-full flex-col">
 							<div>
 								<div class=" self-center text-xs font-medium mb-1">
@@ -652,6 +764,37 @@
 								</div>
 							{/if}
 						</div>
+					{:else if webConfig.WEB_LOADER_ENGINE === 'external'}
+						<div class="mb-2.5 flex w-full flex-col">
+							<div>
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('External Web Loader URL')}
+								</div>
+
+								<div class="flex w-full">
+									<div class="flex-1">
+										<input
+											class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+											type="text"
+											placeholder={$i18n.t('Enter External Web Loader URL')}
+											bind:value={webConfig.EXTERNAL_WEB_LOADER_URL}
+											autocomplete="off"
+										/>
+									</div>
+								</div>
+							</div>
+
+							<div class="mt-2">
+								<div class=" self-center text-xs font-medium mb-1">
+									{$i18n.t('External Web Loader API Key')}
+								</div>
+
+								<SensitiveInput
+									placeholder={$i18n.t('Enter External Web Loader API Key')}
+									bind:value={webConfig.EXTERNAL_WEB_LOADER_API_KEY}
+								/>
+							</div>
+						</div>
 					{/if}
 
 					<div class="  mb-2.5 flex w-full justify-between">

+ 3 - 16
src/lib/components/admin/Users.svelte

@@ -5,32 +5,19 @@
 	import { goto } from '$app/navigation';
 	import { user } from '$lib/stores';
 
-	import { getUsers } from '$lib/apis/users';
-
 	import UserList from './Users/UserList.svelte';
 	import Groups from './Users/Groups.svelte';
 
 	const i18n = getContext('i18n');
 
-	let users = [];
-
 	let selectedTab = 'overview';
 	let loaded = false;
 
-	$: if (selectedTab) {
-		getUsersHandler();
-	}
-
-	const getUsersHandler = async () => {
-		users = await getUsers(localStorage.token);
-	};
-
 	onMount(async () => {
 		if ($user?.role !== 'admin') {
 			await goto('/');
-		} else {
-			users = await getUsers(localStorage.token);
 		}
+
 		loaded = true;
 
 		const containerElement = document.getElementById('users-tabs-container');
@@ -102,9 +89,9 @@
 
 	<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
 		{#if selectedTab === 'overview'}
-			<UserList {users} />
+			<UserList />
 		{:else if selectedTab === 'groups'}
-			<Groups {users} />
+			<Groups />
 		{/if}
 	</div>
 </div>

+ 26 - 6
src/lib/components/admin/Users/Groups.svelte

@@ -23,13 +23,18 @@
 	import GroupItem from './Groups/GroupItem.svelte';
 	import AddGroupModal from './Groups/AddGroupModal.svelte';
 	import { createNewGroup, getGroups } from '$lib/apis/groups';
-	import { getUserDefaultPermissions, updateUserDefaultPermissions } from '$lib/apis/users';
+	import {
+		getUserDefaultPermissions,
+		getAllUsers,
+		updateUserDefaultPermissions
+	} from '$lib/apis/users';
 
 	const i18n = getContext('i18n');
 
 	let loaded = false;
 
-	export let users = [];
+	let users = [];
+	let total = 0;
 
 	let groups = [];
 	let filteredGroups;
@@ -63,6 +68,8 @@
 			file_upload: true,
 			delete: true,
 			edit: true,
+			share: true,
+			export: true,
 			stt: true,
 			tts: true,
 			call: true,
@@ -74,7 +81,8 @@
 			direct_tool_servers: false,
 			web_search: true,
 			image_generation: true,
-			code_interpreter: true
+			code_interpreter: true,
+			notes: true
 		}
 	};
 
@@ -116,10 +124,22 @@
 	onMount(async () => {
 		if ($user?.role !== 'admin') {
 			await goto('/');
-		} else {
-			await setGroups();
-			defaultPermissions = await getUserDefaultPermissions(localStorage.token);
+			return;
 		}
+
+		const res = await getAllUsers(localStorage.token).catch((error) => {
+			toast.error(`${error}`);
+			return null;
+		});
+
+		if (res) {
+			users = res.users;
+			total = res.total;
+		}
+
+		await setGroups();
+		defaultPermissions = await getUserDefaultPermissions(localStorage.token);
+
 		loaded = true;
 	});
 </script>

+ 28 - 1
src/lib/components/admin/Users/Groups/Permissions.svelte

@@ -24,6 +24,8 @@
 			file_upload: true,
 			delete: true,
 			edit: true,
+			share: true,
+			export: true,
 			stt: true,
 			tts: true,
 			call: true,
@@ -35,7 +37,8 @@
 			direct_tool_servers: false,
 			web_search: true,
 			image_generation: true,
-			code_interpreter: true
+			code_interpreter: true,
+			notes: true
 		}
 	};
 
@@ -276,6 +279,22 @@
 			<Switch bind:state={permissions.chat.edit} />
 		</div>
 
+		<div class="  flex w-full justify-between my-2 pr-2">
+			<div class=" self-center text-xs font-medium">
+				{$i18n.t('Allow Chat Share')}
+			</div>
+
+			<Switch bind:state={permissions.chat.share} />
+		</div>
+
+		<div class="  flex w-full justify-between my-2 pr-2">
+			<div class=" self-center text-xs font-medium">
+				{$i18n.t('Allow Chat Export')}
+			</div>
+
+			<Switch bind:state={permissions.chat.export} />
+		</div>
+
 		<div class="  flex w-full justify-between my-2 pr-2">
 			<div class=" self-center text-xs font-medium">
 				{$i18n.t('Allow Speech to Text')}
@@ -362,5 +381,13 @@
 
 			<Switch bind:state={permissions.features.code_interpreter} />
 		</div>
+
+		<div class="  flex w-full justify-between my-2 pr-2">
+			<div class=" self-center text-xs font-medium">
+				{$i18n.t('Notes')}
+			</div>
+
+			<Switch bind:state={permissions.features.notes} />
+		</div>
 	</div>
 </div>

+ 97 - 62
src/lib/components/admin/Users/UserList.svelte

@@ -23,6 +23,8 @@
 	import AddUserModal from '$lib/components/admin/Users/UserList/AddUserModal.svelte';
 
 	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import RoleUpdateConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+
 	import Badge from '$lib/components/common/Badge.svelte';
 	import Plus from '$lib/components/icons/Plus.svelte';
 	import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
@@ -33,19 +35,33 @@
 
 	const i18n = getContext('i18n');
 
-	export let users = [];
+	let page = 1;
 
-	let search = '';
-	let selectedUser = null;
+	let users = [];
+	let total = 0;
 
-	let page = 1;
+	let query = '';
+	let orderBy = 'created_at'; // default sort key
+	let direction = 'asc'; // default sort order
+
+	let selectedUser = null;
 
 	let showDeleteConfirmDialog = false;
 	let showAddUserModal = false;
 
 	let showUserChatsModal = false;
 	let showEditUserModal = false;
+	let showUpdateRoleModal = false;
 
+	const onUpdateRole = (user) => {
+		if (user.role === 'user') {
+			updateRoleHandler(user.id, 'admin');
+		} else if (user.role === 'pending') {
+			updateRoleHandler(user.id, 'user');
+		} else {
+			updateRoleHandler(user.id, 'pending');
+		}
+	};
 	const updateRoleHandler = async (id, role) => {
 		const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
 			toast.error(`${error}`);
@@ -53,7 +69,7 @@
 		});
 
 		if (res) {
-			users = await getUsers(localStorage.token);
+			getUserList();
 		}
 	};
 
@@ -62,42 +78,51 @@
 			toast.error(`${error}`);
 			return null;
 		});
+
+		// if the user is deleted and the current page has only one user, go back to the previous page
+		if (users.length === 1 && page > 1) {
+			page -= 1;
+		}
+
 		if (res) {
-			users = await getUsers(localStorage.token);
+			getUserList();
 		}
 	};
 
-	let sortKey = 'created_at'; // default sort key
-	let sortOrder = 'asc'; // default sort order
-
-	function setSortKey(key) {
-		if (sortKey === key) {
-			sortOrder = sortOrder === 'asc' ? 'desc' : 'asc';
+	const setSortKey = (key) => {
+		if (orderBy === key) {
+			direction = direction === 'asc' ? 'desc' : 'asc';
 		} else {
-			sortKey = key;
-			sortOrder = 'asc';
+			orderBy = key;
+			direction = 'asc';
 		}
-	}
+	};
 
-	let filteredUsers;
-
-	$: filteredUsers = users
-		.filter((user) => {
-			if (search === '') {
-				return true;
-			} else {
-				let name = user.name.toLowerCase();
-				let email = user.email.toLowerCase();
-				const query = search.toLowerCase();
-				return name.includes(query) || email.includes(query);
+	const getUserList = async () => {
+		try {
+			const res = await getUsers(localStorage.token, query, orderBy, direction, page).catch(
+				(error) => {
+					toast.error(`${error}`);
+					return null;
+				}
+			);
+
+			if (res) {
+				users = res.users;
+				total = res.total;
 			}
-		})
-		.sort((a, b) => {
-			if (a[sortKey] < b[sortKey]) return sortOrder === 'asc' ? -1 : 1;
-			if (a[sortKey] > b[sortKey]) return sortOrder === 'asc' ? 1 : -1;
-			return 0;
-		})
-		.slice((page - 1) * 20, page * 20);
+		} catch (err) {
+			console.error(err);
+		}
+	};
+
+	$: if (page) {
+		getUserList();
+	}
+
+	$: if (query !== null && orderBy && direction) {
+		getUserList();
+	}
 </script>
 
 <ConfirmDialog
@@ -107,13 +132,28 @@
 	}}
 />
 
+<RoleUpdateConfirmDialog
+	bind:show={showUpdateRoleModal}
+	on:confirm={() => {
+		onUpdateRole(selectedUser);
+	}}
+	message={$i18n.t(`Are you sure you want to update this user\'s role to **{{ROLE}}**?`, {
+		ROLE:
+			selectedUser?.role === 'user'
+				? 'admin'
+				: selectedUser?.role === 'pending'
+					? 'user'
+					: 'pending'
+	})}
+/>
+
 {#key selectedUser}
 	<EditUserModal
 		bind:show={showEditUserModal}
 		{selectedUser}
 		sessionUser={$user}
 		on:save={async () => {
-			users = await getUsers(localStorage.token);
+			getUserList();
 		}}
 	/>
 {/key}
@@ -121,7 +161,7 @@
 <AddUserModal
 	bind:show={showAddUserModal}
 	on:save={async () => {
-		users = await getUsers(localStorage.token);
+		getUserList();
 	}}
 />
 <UserChatsModal bind:show={showUserChatsModal} user={selectedUser} />
@@ -149,19 +189,19 @@
 		<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50 dark:bg-gray-850" />
 
 		{#if ($config?.license_metadata?.seats ?? null) !== null}
-			{#if users.length > $config?.license_metadata?.seats}
+			{#if total > $config?.license_metadata?.seats}
 				<span class="text-lg font-medium text-red-500"
-					>{users.length} of {$config?.license_metadata?.seats}
+					>{total} of {$config?.license_metadata?.seats}
 					<span class="text-sm font-normal">available users</span></span
 				>
 			{:else}
 				<span class="text-lg font-medium text-gray-500 dark:text-gray-300"
-					>{users.length} of {$config?.license_metadata?.seats}
+					>{total} of {$config?.license_metadata?.seats}
 					<span class="text-sm font-normal">available users</span></span
 				>
 			{/if}
 		{:else}
-			<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{users.length}</span>
+			<span class="text-lg font-medium text-gray-500 dark:text-gray-300">{total}</span>
 		{/if}
 	</div>
 
@@ -184,7 +224,7 @@
 				</div>
 				<input
 					class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
-					bind:value={search}
+					bind:value={query}
 					placeholder={$i18n.t('Search')}
 				/>
 			</div>
@@ -223,9 +263,9 @@
 					<div class="flex gap-1.5 items-center">
 						{$i18n.t('Role')}
 
-						{#if sortKey === 'role'}
+						{#if orderBy === 'role'}
 							<span class="font-normal"
-								>{#if sortOrder === 'asc'}
+								>{#if direction === 'asc'}
 									<ChevronUp className="size-2" />
 								{:else}
 									<ChevronDown className="size-2" />
@@ -246,9 +286,9 @@
 					<div class="flex gap-1.5 items-center">
 						{$i18n.t('Name')}
 
-						{#if sortKey === 'name'}
+						{#if orderBy === 'name'}
 							<span class="font-normal"
-								>{#if sortOrder === 'asc'}
+								>{#if direction === 'asc'}
 									<ChevronUp className="size-2" />
 								{:else}
 									<ChevronDown className="size-2" />
@@ -269,9 +309,9 @@
 					<div class="flex gap-1.5 items-center">
 						{$i18n.t('Email')}
 
-						{#if sortKey === 'email'}
+						{#if orderBy === 'email'}
 							<span class="font-normal"
-								>{#if sortOrder === 'asc'}
+								>{#if direction === 'asc'}
 									<ChevronUp className="size-2" />
 								{:else}
 									<ChevronDown className="size-2" />
@@ -293,9 +333,9 @@
 					<div class="flex gap-1.5 items-center">
 						{$i18n.t('Last Active')}
 
-						{#if sortKey === 'last_active_at'}
+						{#if orderBy === 'last_active_at'}
 							<span class="font-normal"
-								>{#if sortOrder === 'asc'}
+								>{#if direction === 'asc'}
 									<ChevronUp className="size-2" />
 								{:else}
 									<ChevronDown className="size-2" />
@@ -315,9 +355,9 @@
 				>
 					<div class="flex gap-1.5 items-center">
 						{$i18n.t('Created at')}
-						{#if sortKey === 'created_at'}
+						{#if orderBy === 'created_at'}
 							<span class="font-normal"
-								>{#if sortOrder === 'asc'}
+								>{#if direction === 'asc'}
 									<ChevronUp className="size-2" />
 								{:else}
 									<ChevronDown className="size-2" />
@@ -339,9 +379,9 @@
 					<div class="flex gap-1.5 items-center">
 						{$i18n.t('OAuth ID')}
 
-						{#if sortKey === 'oauth_sub'}
+						{#if orderBy === 'oauth_sub'}
 							<span class="font-normal"
-								>{#if sortOrder === 'asc'}
+								>{#if direction === 'asc'}
 									<ChevronUp className="size-2" />
 								{:else}
 									<ChevronDown className="size-2" />
@@ -359,19 +399,14 @@
 			</tr>
 		</thead>
 		<tbody class="">
-			{#each filteredUsers as user, userIdx}
+			{#each users as user, userIdx}
 				<tr class="bg-white dark:bg-gray-900 dark:border-gray-850 text-xs">
 					<td class="px-3 py-1 min-w-[7rem] w-28">
 						<button
 							class=" translate-y-0.5"
 							on:click={() => {
-								if (user.role === 'user') {
-									updateRoleHandler(user.id, 'admin');
-								} else if (user.role === 'pending') {
-									updateRoleHandler(user.id, 'user');
-								} else {
-									updateRoleHandler(user.id, 'pending');
-								}
+								selectedUser = user;
+								showUpdateRoleModal = true;
 							}}
 						>
 							<Badge
@@ -486,10 +521,10 @@
 	ⓘ {$i18n.t("Click on the user role button to change a user's role.")}
 </div>
 
-<Pagination bind:page count={users.length} />
+<Pagination bind:page count={total} perPage={10} />
 
 {#if !$config?.license_metadata}
-	{#if users.length > 50}
+	{#if total > 50}
 		<div class="text-sm">
 			<Markdown
 				content={`

+ 6 - 3
src/lib/components/admin/Users/UserList/AddUserModal.svelte

@@ -7,6 +7,7 @@
 	import { WEBUI_BASE_URL } from '$lib/constants';
 
 	import Modal from '$lib/components/common/Modal.svelte';
+	import { generateInitialsImage } from '$lib/utils';
 
 	const i18n = getContext('i18n');
 	const dispatch = createEventDispatcher();
@@ -47,7 +48,8 @@
 				_user.name,
 				_user.email,
 				_user.password,
-				_user.role
+				_user.role,
+				generateInitialsImage(_user.name)
 			).catch((error) => {
 				toast.error(`${error}`);
 			});
@@ -83,7 +85,8 @@
 									columns[0],
 									columns[1],
 									columns[2],
-									columns[3].toLowerCase()
+									columns[3].toLowerCase(),
+									generateInitialsImage(columns[0])
 								).catch((error) => {
 									toast.error(`Row ${idx + 1}: ${error}`);
 									return null;
@@ -109,7 +112,7 @@
 					stopLoading();
 				};
 
-				reader.readAsText(file);
+				reader.readAsText(file, 'utf-8');
 			} else {
 				toast.error($i18n.t('File not found.'));
 			}

+ 52 - 50
src/lib/components/admin/Users/UserList/EditUserModal.svelte

@@ -45,7 +45,7 @@
 
 <Modal size="sm" bind:show>
 	<div>
-		<div class=" flex justify-between dark:text-gray-300 px-5 py-4">
+		<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
 			<div class=" text-lg font-medium self-center">{$i18n.t('Edit User')}</div>
 			<button
 				class="self-center"
@@ -65,9 +65,8 @@
 				</svg>
 			</button>
 		</div>
-		<hr class="border-gray-100 dark:border-gray-850" />
 
-		<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
+		<div class="flex flex-col md:flex-row w-full md:space-x-4 dark:text-gray-200">
 			<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
 				<form
 					class="flex flex-col w-full"
@@ -75,7 +74,7 @@
 						submitHandler();
 					}}
 				>
-					<div class=" flex items-center rounded-md py-2 px-4 w-full">
+					<div class=" flex items-center rounded-md px-5 py-2 w-full">
 						<div class=" self-center mr-5">
 							<img
 								src={selectedUser.profile_image_url}
@@ -94,59 +93,62 @@
 						</div>
 					</div>
 
-					<hr class="border-gray-100 dark:border-gray-850 my-3 w-full" />
-
-					<div class=" flex flex-col space-y-1.5">
-						<div class="flex flex-col w-full">
-							<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
-
-							<div class="flex-1">
-								<input
-									class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
-									type="email"
-									bind:value={_user.email}
-									autocomplete="off"
-									required
-									disabled={_user.id == sessionUser.id}
-								/>
+					<div class=" px-5 pt-3 pb-5">
+						<div class=" flex flex-col space-y-1.5">
+							<div class="flex flex-col w-full">
+								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Email')}</div>
+
+								<div class="flex-1">
+									<input
+										class="w-full rounded-sm text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-hidden"
+										type="email"
+										bind:value={_user.email}
+										placeholder={$i18n.t('Enter Your Email')}
+										autocomplete="off"
+										required
+										disabled={_user.id == sessionUser.id}
+									/>
+								</div>
 							</div>
-						</div>
 
-						<div class="flex flex-col w-full">
-							<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
-
-							<div class="flex-1">
-								<input
-									class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
-									type="text"
-									bind:value={_user.name}
-									autocomplete="off"
-									required
-								/>
+							<div class="flex flex-col w-full">
+								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Name')}</div>
+
+								<div class="flex-1">
+									<input
+										class="w-full rounded-sm text-sm bg-transparent outline-hidden"
+										type="text"
+										bind:value={_user.name}
+										placeholder={$i18n.t('Enter Your Name')}
+										autocomplete="off"
+										required
+									/>
+								</div>
 							</div>
-						</div>
-
-						<div class="flex flex-col w-full">
-							<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
 
-							<div class="flex-1">
-								<input
-									class="w-full rounded-sm py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-hidden"
-									type="password"
-									bind:value={_user.password}
-									autocomplete="new-password"
-								/>
+							<div class="flex flex-col w-full">
+								<div class=" mb-1 text-xs text-gray-500">{$i18n.t('New Password')}</div>
+
+								<div class="flex-1">
+									<input
+										class="w-full rounded-sm text-sm bg-transparent outline-hidden"
+										type="password"
+										placeholder={$i18n.t('Enter New Password')}
+										bind:value={_user.password}
+										autocomplete="new-password"
+									/>
+								</div>
 							</div>
 						</div>
-					</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"
-						>
-							{$i18n.t('Save')}
-						</button>
+						<div class="flex justify-end pt-3 text-sm font-medium">
+							<button
+								class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center"
+								type="submit"
+							>
+								{$i18n.t('Save')}
+							</button>
+						</div>
 					</div>
 				</form>
 			</div>

+ 2 - 2
src/lib/components/channel/Channel.svelte

@@ -195,7 +195,7 @@
 </script>
 
 <svelte:head>
-	<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
+	<title>#{channel?.name ?? 'Channel'}  Open WebUI</title>
 </svelte:head>
 
 <div
@@ -262,7 +262,7 @@
 			{#if threadId !== null}
 				<Drawer
 					show={threadId !== null}
-					on:close={() => {
+					onClose={() => {
 						threadId = null;
 					}}
 				>

+ 3 - 3
src/lib/components/channel/MessageInput.svelte

@@ -357,14 +357,14 @@
 			{#if recording}
 				<VoiceRecording
 					bind:recording
-					on:cancel={async () => {
+					onCancel={async () => {
 						recording = false;
 
 						await tick();
 						document.getElementById(`chat-input-${id}`)?.focus();
 					}}
-					on:confirm={async (e) => {
-						const { text, filename } = e.detail;
+					onConfirm={async (data) => {
+						const { text, filename } = data;
 						content = `${content}${text} `;
 						recording = false;
 

+ 7 - 5
src/lib/components/chat/Chat.svelte

@@ -236,9 +236,11 @@
 		await tick();
 		await tick();
 
-		const messageElement = document.getElementById(`message-${message.id}`);
-		if (messageElement) {
-			messageElement.scrollIntoView({ behavior: 'smooth' });
+		if ($settings?.scrollOnBranchChange ?? true) {
+			const messageElement = document.getElementById(`message-${message.id}`);
+			if (messageElement) {
+				messageElement.scrollIntoView({ behavior: 'smooth' });
+			}
 		}
 
 		await tick();
@@ -1919,7 +1921,7 @@
 <svelte:head>
 	<title>
 		{$chatTitle
-			? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle} | ${$WEBUI_NAME}`
+			? `${$chatTitle.length > 30 ? `${$chatTitle.slice(0, 30)}...` : $chatTitle}  ${$WEBUI_NAME}`
 			: `${$WEBUI_NAME}`}
 	</title>
 </svelte:head>
@@ -2038,7 +2040,7 @@
 								{stopResponse}
 								{createMessagePair}
 								onChange={(input) => {
-									if (input.prompt) {
+									if (input.prompt !== null) {
 										localStorage.setItem(`chat-input-${$chatId}`, JSON.stringify(input));
 									} else {
 										localStorage.removeItem(`chat-input-${$chatId}`);

+ 1 - 1
src/lib/components/chat/ChatControls.svelte

@@ -140,7 +140,7 @@
 		{#if $showControls}
 			<Drawer
 				show={$showControls}
-				on:close={() => {
+				onClose={() => {
 					showControls.set(false);
 				}}
 			>

+ 31 - 33
src/lib/components/chat/MessageInput.svelte

@@ -395,39 +395,37 @@
 				</div>
 
 				<div class="w-full relative">
-					{#if atSelectedModel !== undefined || selectedToolIds.length > 0 || webSearchEnabled || ($settings?.webSearch ?? false) === 'always' || imageGenerationEnabled || codeInterpreterEnabled}
+					{#if atSelectedModel !== undefined}
 						<div
 							class="px-3 pb-0.5 pt-1.5 text-left w-full flex flex-col absolute bottom-0 left-0 right-0 bg-linear-to-t from-white dark:from-gray-900 z-10"
 						>
-							{#if atSelectedModel !== undefined}
-								<div class="flex items-center justify-between w-full">
-									<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
-										<img
-											crossorigin="anonymous"
-											alt="model profile"
-											class="size-3.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 class="translate-y-[0.5px]">
-											Talking to <span class=" font-medium">{atSelectedModel.name}</span>
-										</div>
-									</div>
-									<div>
-										<button
-											class="flex items-center dark:text-gray-500"
-											on:click={() => {
-												atSelectedModel = undefined;
-											}}
-										>
-											<XMark />
-										</button>
+							<div class="flex items-center justify-between w-full">
+								<div class="pl-[1px] flex items-center gap-2 text-sm dark:text-gray-500">
+									<img
+										crossorigin="anonymous"
+										alt="model profile"
+										class="size-3.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 class="translate-y-[0.5px]">
+										Talking to <span class=" font-medium">{atSelectedModel.name}</span>
 									</div>
 								</div>
-							{/if}
+								<div>
+									<button
+										class="flex items-center dark:text-gray-500"
+										on:click={() => {
+											atSelectedModel = undefined;
+										}}
+									>
+										<XMark />
+									</button>
+								</div>
+							</div>
 						</div>
 					{/if}
 
@@ -481,14 +479,14 @@
 					{#if recording}
 						<VoiceRecording
 							bind:recording
-							on:cancel={async () => {
+							onCancel={async () => {
 								recording = false;
 
 								await tick();
 								document.getElementById('chat-input')?.focus();
 							}}
-							on:confirm={async (e) => {
-								const { text, filename } = e.detail;
+							onConfirm={async (data) => {
+								const { text, filename } = data;
 								prompt = `${prompt}${text} `;
 
 								recording = false;
@@ -1063,9 +1061,9 @@
 													);
 												}
 											}}
-											uploadOneDriveHandler={async () => {
+											uploadOneDriveHandler={async (authorityType) => {
 												try {
-													const fileData = await pickAndDownloadFile();
+													const fileData = await pickAndDownloadFile(authorityType);
 													if (fileData) {
 														const file = new File([fileData.blob], fileData.name, {
 															type: fileData.blob.type || 'application/octet-stream'

+ 1 - 1
src/lib/components/chat/MessageInput/Commands/Knowledge.svelte

@@ -265,7 +265,7 @@
 									{/each}
 								{:else}
 									<div class=" text-gray-500 text-xs mt-1 mb-2">
-										{$i18n.t('No files found.')}
+										{$i18n.t('File not found.')}
 									</div>
 								{/if}
 							</div> -->

+ 114 - 89
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -146,7 +146,7 @@
 			{/if}
 
 			<Tooltip
-				content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
+				content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files.') : ''}
 				className="w-full"
 			>
 				<DropdownMenu.Item
@@ -173,7 +173,7 @@
 			</Tooltip>
 
 			<Tooltip
-				content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files') : ''}
+				content={!fileUploadEnabled ? $i18n.t('You do not have permission to upload files.') : ''}
 				className="w-full"
 			>
 				<DropdownMenu.Item
@@ -229,94 +229,119 @@
 			{/if}
 
 			{#if $config?.features?.enable_onedrive_integration}
-				<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={() => {
-						uploadOneDriveHandler();
-					}}
-				>
-					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
-						<mask
-							id="mask0_87_7796"
-							style="mask-type:alpha"
-							maskUnits="userSpaceOnUse"
-							x="0"
-							y="6"
-							width="32"
-							height="20"
-						>
-							<path
-								d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
-								fill="#C4C4C4"
-							/>
-						</mask>
-						<g mask="url(#mask0_87_7796)">
-							<path
-								d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
-								fill="url(#paint0_linear_87_7796)"
-							/>
-							<path
-								d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
-								fill="url(#paint1_linear_87_7796)"
-							/>
-							<path
-								d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
-								fill="url(#paint2_linear_87_7796)"
-							/>
-							<path
-								d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
-								fill="url(#paint3_linear_87_7796)"
-							/>
-						</g>
-						<defs>
-							<linearGradient
-								id="paint0_linear_87_7796"
-								x1="4.42591"
-								y1="24.6668"
-								x2="27.2309"
-								y2="23.2764"
-								gradientUnits="userSpaceOnUse"
-							>
-								<stop stop-color="#2086B8" />
-								<stop offset="1" stop-color="#46D3F6" />
-							</linearGradient>
-							<linearGradient
-								id="paint1_linear_87_7796"
-								x1="23.8302"
-								y1="19.6668"
-								x2="30.2108"
-								y2="15.2082"
-								gradientUnits="userSpaceOnUse"
+				<DropdownMenu.Sub>
+					<DropdownMenu.SubTrigger
+						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 w-full"
+					>
+						<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" class="w-5 h-5" fill="none">
+							<mask
+								id="mask0_87_7796"
+								style="mask-type:alpha"
+								maskUnits="userSpaceOnUse"
+								x="0"
+								y="6"
+								width="32"
+								height="20"
 							>
-								<stop stop-color="#1694DB" />
-								<stop offset="1" stop-color="#62C3FE" />
-							</linearGradient>
-							<linearGradient
-								id="paint2_linear_87_7796"
-								x1="8.51037"
-								y1="7.33333"
-								x2="23.3335"
-								y2="15.9348"
-								gradientUnits="userSpaceOnUse"
-							>
-								<stop stop-color="#0D3D78" />
-								<stop offset="1" stop-color="#063B83" />
-							</linearGradient>
-							<linearGradient
-								id="paint3_linear_87_7796"
-								x1="-0.340429"
-								y1="19.9998"
-								x2="14.5634"
-								y2="14.4649"
-								gradientUnits="userSpaceOnUse"
-							>
-								<stop stop-color="#16589B" />
-								<stop offset="1" stop-color="#1464B7" />
-							</linearGradient>
-						</defs>
-					</svg>
-					<div class="line-clamp-1">{$i18n.t('OneDrive')}</div>
-				</DropdownMenu.Item>
+								<path
+									d="M7.82979 26C3.50549 26 0 22.5675 0 18.3333C0 14.1921 3.35322 10.8179 7.54613 10.6716C9.27535 7.87166 12.4144 6 16 6C20.6308 6 24.5169 9.12183 25.5829 13.3335C29.1316 13.3603 32 16.1855 32 19.6667C32 23.0527 29 26 25.8723 25.9914L7.82979 26Z"
+									fill="#C4C4C4"
+								/>
+							</mask>
+							<g mask="url(#mask0_87_7796)">
+								<path
+									d="M7.83017 26.0001C5.37824 26.0001 3.18957 24.8966 1.75391 23.1691L18.0429 16.3335L30.7089 23.4647C29.5926 24.9211 27.9066 26.0001 26.0004 25.9915C23.1254 26.0001 12.0629 26.0001 7.83017 26.0001Z"
+									fill="url(#paint0_linear_87_7796)"
+								/>
+								<path
+									d="M25.5785 13.3149L18.043 16.3334L30.709 23.4647C31.5199 22.4065 32.0004 21.0916 32.0004 19.6669C32.0004 16.1857 29.1321 13.3605 25.5833 13.3337C25.5817 13.3274 25.5801 13.3212 25.5785 13.3149Z"
+									fill="url(#paint1_linear_87_7796)"
+								/>
+								<path
+									d="M7.06445 10.7028L18.0423 16.3333L25.5779 13.3148C24.5051 9.11261 20.6237 6 15.9997 6C12.4141 6 9.27508 7.87166 7.54586 10.6716C7.3841 10.6773 7.22358 10.6877 7.06445 10.7028Z"
+									fill="url(#paint2_linear_87_7796)"
+								/>
+								<path
+									d="M1.7535 23.1687L18.0425 16.3331L7.06471 10.7026C3.09947 11.0792 0 14.3517 0 18.3331C0 20.1665 0.657197 21.8495 1.7535 23.1687Z"
+									fill="url(#paint3_linear_87_7796)"
+								/>
+							</g>
+							<defs>
+								<linearGradient
+									id="paint0_linear_87_7796"
+									x1="4.42591"
+									y1="24.6668"
+									x2="27.2309"
+									y2="23.2764"
+									gradientUnits="userSpaceOnUse"
+								>
+									<stop stop-color="#2086B8" />
+									<stop offset="1" stop-color="#46D3F6" />
+								</linearGradient>
+								<linearGradient
+									id="paint1_linear_87_7796"
+									x1="23.8302"
+									y1="19.6668"
+									x2="30.2108"
+									y2="15.2082"
+									gradientUnits="userSpaceOnUse"
+								>
+									<stop stop-color="#1694DB" />
+									<stop offset="1" stop-color="#62C3FE" />
+								</linearGradient>
+								<linearGradient
+									id="paint2_linear_87_7796"
+									x1="8.51037"
+									y1="7.33333"
+									x2="23.3335"
+									y2="15.9348"
+									gradientUnits="userSpaceOnUse"
+								>
+									<stop stop-color="#0D3D78" />
+									<stop offset="1" stop-color="#063B83" />
+								</linearGradient>
+								<linearGradient
+									id="paint3_linear_87_7796"
+									x1="-0.340429"
+									y1="19.9998"
+									x2="14.5634"
+									y2="14.4649"
+									gradientUnits="userSpaceOnUse"
+								>
+									<stop stop-color="#16589B" />
+									<stop offset="1" stop-color="#1464B7" />
+								</linearGradient>
+							</defs>
+						</svg>
+						<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive')}</div>
+					</DropdownMenu.SubTrigger>
+					<DropdownMenu.SubContent
+						class="w-[calc(100vw-2rem)] max-w-[280px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
+						side={$mobile ? 'bottom' : 'right'}
+						sideOffset={$mobile ? 5 : 0}
+						alignOffset={$mobile ? 0 : -8}
+					>
+						<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={() => {
+								uploadOneDriveHandler('personal');
+							}}
+						>
+							<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (personal)')}</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-xl"
+							on:click={() => {
+								uploadOneDriveHandler('organizations');
+							}}
+						>
+							<div class="flex flex-col">
+								<div class="line-clamp-1">{$i18n.t('Microsoft OneDrive (work/school)')}</div>
+								<div class="text-xs text-gray-500">Includes SharePoint</div>
+							</div>
+						</DropdownMenu.Item>
+					</DropdownMenu.SubContent>
+				</DropdownMenu.Sub>
 			{/if}
 		</DropdownMenu.Content>
 	</div>

+ 154 - 96
src/lib/components/chat/MessageInput/VoiceRecording.svelte

@@ -1,18 +1,26 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
-	import { createEventDispatcher, tick, getContext, onMount, onDestroy } from 'svelte';
+	import { tick, getContext, onMount, onDestroy } from 'svelte';
 	import { config, settings } from '$lib/stores';
 	import { blobToFile, calculateSHA256, extractCurlyBraceWords } from '$lib/utils';
 
 	import { transcribeAudio } from '$lib/apis/audio';
 
-	const i18n = getContext('i18n');
+	import dayjs from 'dayjs';
+	import LocalizedFormat from 'dayjs/plugin/localizedFormat';
+	dayjs.extend(LocalizedFormat);
 
-	const dispatch = createEventDispatcher();
+	const i18n = getContext('i18n');
 
 	export let recording = false;
+	export let transcribe = true;
+	export let displayMedia = false;
+
 	export let className = ' p-2.5 w-full max-w-full';
 
+	export let onCancel = () => {};
+	export let onConfirm = (data) => {};
+
 	let loading = false;
 	let confirmed = false;
 
@@ -130,45 +138,73 @@
 		detectSound();
 	};
 
-	const transcribeHandler = async (audioBlob) => {
+	const onStopHandler = async (audioBlob, ext: string = 'wav') => {
 		// Create a blob from the audio chunks
 
 		await tick();
-		const file = blobToFile(audioBlob, 'recording.wav');
+		const file = blobToFile(audioBlob, `Recording-${dayjs().format('L LT')}.${ext}`);
 
-		const res = await transcribeAudio(localStorage.token, file).catch((error) => {
-			toast.error(`${error}`);
-			return null;
-		});
+		if (transcribe) {
+			if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
+				// with web stt, we don't need to send the file to the server
+				return;
+			}
 
-		if (res) {
-			console.log(res);
-			dispatch('confirm', res);
-		}
-	};
+			const res = await transcribeAudio(localStorage.token, file).catch((error) => {
+				toast.error(`${error}`);
+				return null;
+			});
 
-	const saveRecording = (blob) => {
-		const url = URL.createObjectURL(blob);
-		const a = document.createElement('a');
-		document.body.appendChild(a);
-		a.style = 'display: none';
-		a.href = url;
-		a.download = 'recording.wav';
-		a.click();
-		window.URL.revokeObjectURL(url);
+			if (res) {
+				console.log(res);
+				onConfirm(res);
+			}
+		} else {
+			onConfirm({
+				file: file,
+				blob: audioBlob
+			});
+		}
 	};
 
 	const startRecording = async () => {
 		loading = true;
 
-		stream = await navigator.mediaDevices.getUserMedia({
-			audio: {
-				echoCancellation: true,
-				noiseSuppression: true,
-				autoGainControl: true
+		try {
+			if (displayMedia) {
+				const mediaStream = await navigator.mediaDevices.getDisplayMedia({
+					audio: true
+				});
+
+				stream = new MediaStream();
+				for (const track of mediaStream.getAudioTracks()) {
+					stream.addTrack(track);
+				}
+
+				for (const track of mediaStream.getVideoTracks()) {
+					track.stop();
+				}
+			} else {
+				stream = await navigator.mediaDevices.getUserMedia({
+					audio: {
+						echoCancellation: true,
+						noiseSuppression: true,
+						autoGainControl: true
+					}
+				});
 			}
+		} catch (err) {
+			console.error('Error accessing media devices.', err);
+			toast.error($i18n.t('Error accessing media devices.'));
+			loading = false;
+			recording = false;
+			return;
+		}
+
+		mediaRecorder = new MediaRecorder(stream, {
+			mimeType: 'audio/webm; codecs=opus'
 		});
-		mediaRecorder = new MediaRecorder(stream);
+
 		mediaRecorder.onstart = () => {
 			console.log('Recording started');
 			loading = false;
@@ -180,77 +216,99 @@
 		mediaRecorder.ondataavailable = (event) => audioChunks.push(event.data);
 		mediaRecorder.onstop = async () => {
 			console.log('Recording stopped');
-			if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
-				audioChunks = [];
-			} else {
-				if (confirmed) {
-					const audioBlob = new Blob(audioChunks, { type: 'audio/wav' });
 
-					await transcribeHandler(audioBlob);
+			if (confirmed) {
+				// Use the actual type provided by MediaRecorder
+				let type = audioChunks[0]?.type || mediaRecorder.mimeType || 'audio/webm';
+
+				// split `/` and `;` to get the extension
+				let ext = type.split('/')[1].split(';')[0] || 'webm';
 
-					confirmed = false;
-					loading = false;
+				// If not audio, default to audio/webm
+				if (!type.startsWith('audio/')) {
+					ext = 'webm';
 				}
-				audioChunks = [];
-				recording = false;
+
+				const audioBlob = new Blob(audioChunks, { type: type });
+				await onStopHandler(audioBlob, ext);
+
+				confirmed = false;
+				loading = false;
 			}
+
+			audioChunks = [];
+			recording = false;
 		};
-		mediaRecorder.start();
-		if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
-			if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
-				// Create a SpeechRecognition object
-				speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
-
-				// Set continuous to true for continuous recognition
-				speechRecognition.continuous = true;
-
-				// Set the timeout for turning off the recognition after inactivity (in milliseconds)
-				const inactivityTimeout = 2000; // 3 seconds
-
-				let timeoutId;
-				// Start recognition
-				speechRecognition.start();
-
-				// Event triggered when speech is recognized
-				speechRecognition.onresult = async (event) => {
-					// Clear the inactivity timeout
-					clearTimeout(timeoutId);
-
-					// Handle recognized speech
-					console.log(event);
-					const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
-
-					transcription = `${transcription}${transcript}`;
-
-					await tick();
-					document.getElementById('chat-input')?.focus();
-
-					// Restart the inactivity timeout
-					timeoutId = setTimeout(() => {
-						console.log('Speech recognition turned off due to inactivity.');
-						speechRecognition.stop();
-					}, inactivityTimeout);
-				};
-
-				// Event triggered when recognition is ended
-				speechRecognition.onend = function () {
-					// Restart recognition after it ends
-					console.log('recognition ended');
-
-					confirmRecording();
-					dispatch('confirm', { text: transcription });
-					confirmed = false;
-					loading = false;
-				};
-
-				// Event triggered when an error occurs
-				speechRecognition.onerror = function (event) {
-					console.log(event);
-					toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
-					dispatch('cancel');
-
-					stopRecording();
-				};
+
+		try {
+			mediaRecorder.start();
+		} catch (error) {
+			console.error('Error starting recording:', error);
+			toast.error($i18n.t('Error starting recording.'));
+			loading = false;
+			recording = false;
+			return;
+		}
+
+		if (transcribe) {
+			if ($config.audio.stt.engine === 'web' || ($settings?.audio?.stt?.engine ?? '') === 'web') {
+				if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
+					// Create a SpeechRecognition object
+					speechRecognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
+
+					// Set continuous to true for continuous recognition
+					speechRecognition.continuous = true;
+
+					// Set the timeout for turning off the recognition after inactivity (in milliseconds)
+					const inactivityTimeout = 2000; // 3 seconds
+
+					let timeoutId;
+					// Start recognition
+					speechRecognition.start();
+
+					// Event triggered when speech is recognized
+					speechRecognition.onresult = async (event) => {
+						// Clear the inactivity timeout
+						clearTimeout(timeoutId);
+
+						// Handle recognized speech
+						console.log(event);
+						const transcript = event.results[Object.keys(event.results).length - 1][0].transcript;
+
+						transcription = `${transcription}${transcript}`;
+
+						await tick();
+						document.getElementById('chat-input')?.focus();
+
+						// Restart the inactivity timeout
+						timeoutId = setTimeout(() => {
+							console.log('Speech recognition turned off due to inactivity.');
+							speechRecognition.stop();
+						}, inactivityTimeout);
+					};
+
+					// Event triggered when recognition is ended
+					speechRecognition.onend = function () {
+						// Restart recognition after it ends
+						console.log('recognition ended');
+
+						confirmRecording();
+						onConfirm({
+							text: transcription
+						});
+						confirmed = false;
+						loading = false;
+					};
+
+					// Event triggered when an error occurs
+					speechRecognition.onerror = function (event) {
+						console.log(event);
+						toast.error($i18n.t(`Speech recognition error: {{error}}`, { error: event.error }));
+						onCancel();
+
+						stopRecording();
+					};
+				}
 			}
 		}
 	};
@@ -339,7 +397,7 @@
              rounded-full"
 			on:click={async () => {
 				stopRecording();
-				dispatch('cancel');
+				onCancel();
 			}}
 		>
 			<svg

+ 1 - 0
src/lib/components/chat/Messages/Citations.svelte

@@ -83,6 +83,7 @@
 			});
 			return acc;
 		}, []);
+		console.log('citations', citations);
 
 		showRelevance = calculateShowRelevance(citations);
 		showPercentage = shouldShowPercentage(citations);

+ 7 - 4
src/lib/components/chat/Messages/CitationsModal.svelte

@@ -139,13 +139,16 @@
 														{percentage.toFixed(2)}%
 													</span>
 												{/if}
+
+												{#if typeof document?.distance === 'number'}
+													<span class="text-gray-500 dark:text-gray-500">
+														({(document?.distance ?? 0).toFixed(4)})
+													</span>
+												{/if}
+											{:else if typeof document?.distance === 'number'}
 												<span class="text-gray-500 dark:text-gray-500">
 													({(document?.distance ?? 0).toFixed(4)})
 												</span>
-											{:else}
-												<span class="text-gray-500 dark:text-gray-500">
-													{(document?.distance ?? 0).toFixed(4)}
-												</span>
 											{/if}
 										</div>
 									</Tooltip>

+ 4 - 4
src/lib/components/chat/Messages/ContentRenderer.svelte

@@ -154,11 +154,11 @@
 		}, [])}
 		{onSourceClick}
 		{onTaskClick}
-		on:update={(e) => {
-			dispatch('update', e.detail);
+		onUpdate={(value) => {
+			dispatch('update', value);
 		}}
-		on:code={(e) => {
-			const { lang, code } = e.detail;
+		onCode={(value) => {
+			const { lang, code } = value;
 
 			if (
 				($settings?.detectArtifacts ?? true) &&

+ 4 - 16
src/lib/components/chat/Messages/Markdown.svelte

@@ -7,9 +7,6 @@
 	import markedKatexExtension from '$lib/utils/marked/katex-extension';
 
 	import MarkdownTokens from './Markdown/MarkdownTokens.svelte';
-	import { createEventDispatcher } from 'svelte';
-
-	const dispatch = createEventDispatcher();
 
 	export let id = '';
 	export let content;
@@ -18,6 +15,9 @@
 
 	export let sourceIds = [];
 
+	export let onUpdate = () => {};
+	export let onCode = () => {};
+
 	export let onSourceClick = () => {};
 	export let onTaskClick = () => {};
 
@@ -40,17 +40,5 @@
 </script>
 
 {#key id}
-	<MarkdownTokens
-		{tokens}
-		{id}
-		{save}
-		{onTaskClick}
-		{onSourceClick}
-		on:update={(e) => {
-			dispatch('update', e.detail);
-		}}
-		on:code={(e) => {
-			dispatch('code', e.detail);
-		}}
-	/>
+	<MarkdownTokens {tokens} {id} {save} {onTaskClick} {onSourceClick} {onUpdate} {onCode} />
 {/key}

+ 80 - 0
src/lib/components/chat/Messages/Markdown/HTMLToken.svelte

@@ -0,0 +1,80 @@
+<script lang="ts">
+	import DOMPurify from 'dompurify';
+	import type { Token } from 'marked';
+
+	import { WEBUI_BASE_URL } from '$lib/constants';
+	import Source from './Source.svelte';
+	import { settings } from '$lib/stores';
+
+	export let id: string;
+	export let token: Token;
+
+	export let onSourceClick: Function = () => {};
+
+	let html: string | null = null;
+
+	$: if (token.type === 'html' && token?.text) {
+		html = DOMPurify.sanitize(token.text);
+	} else {
+		html = null;
+	}
+</script>
+
+{#if token.type === 'html'}
+	{#if html && html.includes('<video')}
+		{@const video = html.match(/<video[^>]*>([\s\S]*?)<\/video>/)}
+		{@const videoSrc = video && video[1]}
+		{#if videoSrc}
+			<!-- svelte-ignore a11y-media-has-caption -->
+			<video
+				class="w-full my-2"
+				src={videoSrc}
+				title="Video player"
+				frameborder="0"
+				referrerpolicy="strict-origin-when-cross-origin"
+				allowfullscreen
+			></video>
+		{:else}
+			{token.text}
+		{/if}
+	{:else if token.text && token.text.match(/<iframe\s+[^>]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/)}
+		{@const match = token.text.match(
+			/<iframe\s+[^>]*src="https:\/\/www\.youtube\.com\/embed\/([a-zA-Z0-9_-]{11})(?:\?[^"]*)?"[^>]*><\/iframe>/
+		)}
+		{@const ytId = match && match[1]}
+		{#if ytId}
+			<iframe
+				class="w-full aspect-video my-2"
+				src={`https://www.youtube.com/embed/${ytId}`}
+				title="YouTube video player"
+				frameborder="0"
+				allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
+				referrerpolicy="strict-origin-when-cross-origin"
+				allowfullscreen
+			>
+			</iframe>
+		{/if}
+	{:else if token.text.includes(`<file type="html"`)}
+		{@const match = token.text.match(/<file type="html" id="([^"]+)"/)}
+		{@const fileId = match && match[1]}
+		{#if fileId}
+			<iframe
+				class="w-full my-2"
+				src={`${WEBUI_BASE_URL}/api/v1/files/${fileId}/content/html`}
+				title="Content"
+				frameborder="0"
+				sandbox="allow-scripts{($settings?.iframeSandboxAllowForms ?? false)
+					? ' allow-forms'
+					: ''}{($settings?.iframeSandboxAllowSameOrigin ?? false) ? ' allow-same-origin' : ''}"
+				referrerpolicy="strict-origin-when-cross-origin"
+				allowfullscreen
+				width="100%"
+				onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
+			></iframe>
+		{/if}
+	{:else if token.text.includes(`<source_id`)}
+		<Source {id} {token} onClick={onSourceClick} />
+	{:else}
+		{token.text}
+	{/if}
+{/if}

+ 2 - 10
src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte

@@ -13,6 +13,7 @@
 	import Image from '$lib/components/common/Image.svelte';
 	import KatexRenderer from './KatexRenderer.svelte';
 	import Source from './Source.svelte';
+	import HtmlToken from './HTMLToken.svelte';
 
 	export let id: string;
 	export let tokens: Token[];
@@ -23,16 +24,7 @@
 	{#if token.type === 'escape'}
 		{unescapeHtml(token.text)}
 	{:else if token.type === 'html'}
-		{@const html = DOMPurify.sanitize(token.text)}
-		{#if html && html.includes('<video')}
-			{@html html}
-		{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
-			{@html `${token.text}`}
-		{:else if token.text.includes(`<source_id`)}
-			<Source {id} {token} onClick={onSourceClick} />
-		{:else}
-			{@html html}
-		{/if}
+		<HtmlToken {id} {token} {onSourceClick} />
 	{:else if token.type === 'link'}
 		{#if token.tokens}
 			<a href={token.href} target="_blank" rel="nofollow" title={token.title}>

+ 8 - 17
src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte

@@ -1,6 +1,6 @@
 <script lang="ts">
 	import DOMPurify from 'dompurify';
-	import { createEventDispatcher, onMount, getContext } from 'svelte';
+	import { onMount, getContext } from 'svelte';
 	const i18n = getContext('i18n');
 
 	import fileSaver from 'file-saver';
@@ -21,8 +21,7 @@
 
 	import Source from './Source.svelte';
 	import { settings } from '$lib/stores';
-
-	const dispatch = createEventDispatcher();
+	import HtmlToken from './HTMLToken.svelte';
 
 	export let id: string;
 	export let tokens: Token[];
@@ -31,6 +30,9 @@
 
 	export let save = false;
 
+	export let onUpdate: Function = () => {};
+	export let onCode: Function = () => {};
+
 	export let onTaskClick: Function = () => {};
 	export let onSourceClick: Function = () => {};
 
@@ -93,11 +95,9 @@
 				code={token?.text ?? ''}
 				{attributes}
 				{save}
-				onCode={(value) => {
-					dispatch('code', value);
-				}}
+				{onCode}
 				onSave={(value) => {
-					dispatch('update', {
+					onUpdate({
 						raw: token.raw,
 						oldContent: token.text,
 						newContent: value
@@ -267,16 +267,7 @@
 			</div>
 		</Collapsible>
 	{:else if token.type === 'html'}
-		{@const html = DOMPurify.sanitize(token.text)}
-		{#if html && html.includes('<video')}
-			{@html html}
-		{:else if token.text.includes(`<iframe src="${WEBUI_BASE_URL}/api/v1/files/`)}
-			{@html `${token.text}`}
-		{:else if token.text.includes(`<source_id`)}
-			<Source {id} {token} onClick={onSourceClick} />
-		{:else}
-			{token.text}
-		{/if}
+		<HtmlToken {id} {token} {onSourceClick} />
 	{:else if token.type === 'iframe'}
 		<iframe
 			src="{WEBUI_BASE_URL}/api/v1/files/{token.fileId}/content"

+ 9 - 8
src/lib/components/chat/Messages/MultiResponseMessages.svelte

@@ -200,9 +200,11 @@
 		await initHandler();
 		await tick();
 
-		const messageElement = document.getElementById(`message-${messageId}`);
-		if (messageElement) {
-			messageElement.scrollIntoView({ block: 'start' });
+		if ($settings?.scrollOnBranchChange ?? true) {
+			const messageElement = document.getElementById(`message-${messageId}`);
+			if (messageElement) {
+				messageElement.scrollIntoView({ block: 'start' });
+			}
 		}
 	});
 </script>
@@ -238,10 +240,9 @@
 									messageChildrenIds = history.messages[currentMessageId].childrenIds;
 								}
 								history.currentId = currentMessageId;
-
-								await tick();
-								await updateChat();
-								triggerScroll();
+								// await tick();
+								// await updateChat();
+								// triggerScroll();
 							}
 						}}
 					>
@@ -293,7 +294,7 @@
 
 							<div class="w-full rounded-xl pl-5 pr-2 py-2">
 								<Name>
-									Merged Response
+									{$i18n.t('Merged Response')}
 
 									{#if message.timestamp}
 										<span

+ 3 - 7
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -623,12 +623,6 @@
 							).at(-1)}
 							{#if !status?.hidden}
 								<div class="status-description flex items-center gap-2 py-0.5">
-									{#if status?.done === false}
-										<div class="">
-											<Spinner className="size-4" />
-										</div>
-									{/if}
-
 									{#if status?.action === 'web_search' && status?.urls}
 										<WebSearchResults {status}>
 											<div class="flex flex-col justify-center -space-y-0.5">
@@ -683,6 +677,8 @@
 													{$i18n.t('No search query generated')}
 												{:else if status?.description === 'Generating search query'}
 													{$i18n.t('Generating search query')}
+												{:else if status?.description === 'Searching the web'}
+													{$i18n.t('Searching the web...')}
 												{:else}
 													{status?.description}
 												{/if}
@@ -777,7 +773,7 @@
 							</div>
 						{:else}
 							<div class="w-full flex flex-col relative" id="response-content-container">
-								{#if message.content === '' && !message.error}
+								{#if message.content === '' && !message.error && (message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]).length === 0}
 									<Skeleton />
 								{:else if message.content && message.error !== true}
 									<!-- always show message contents even if there's an error -->

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

@@ -65,10 +65,12 @@
 
 		await tick();
 
-		messageEditTextAreaElement.style.height = '';
-		messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
+		if (messageEditTextAreaElement) {
+			messageEditTextAreaElement.style.height = '';
+			messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`;
 
-		messageEditTextAreaElement?.focus();
+			messageEditTextAreaElement?.focus();
+		}
 	};
 
 	const editMessageConfirmHandler = async (submit = true) => {

+ 1 - 1
src/lib/components/chat/ModelSelector/Selector.svelte

@@ -778,7 +778,7 @@
 						</div>
 					</button>
 				</div>
-			{:else if filteredItems.length === 0}
+			{:else}
 				<div class="mb-3"></div>
 			{/if}
 

+ 2 - 2
src/lib/components/chat/Navbar.svelte

@@ -47,8 +47,8 @@
 
 <ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
 
-<nav class="sticky top-0 z-30 w-full py-1.5 -mb-8 flex flex-col items-center drag-region">
-	<div class="flex items-center w-full px-1.5">
+<nav class="sticky top-0 z-30 w-full py-1 -mb-8 flex flex-col items-center drag-region">
+	<div class="flex items-center w-full pl-1.5 pr-1">
 		<div
 			class=" bg-linear-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1]"
 		></div>

+ 11 - 5
src/lib/components/chat/Settings/About.svelte

@@ -159,15 +159,21 @@ Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are met:
 
 1. Redistributions of source code must retain the above copyright notice, this
-   list of conditions and the following disclaimer.
+	list of conditions and the following disclaimer.
 
 2. Redistributions in binary form must reproduce the above copyright notice,
-   this list of conditions and the following disclaimer in the documentation
-   and/or other materials provided with the distribution.
+	this list of conditions and the following disclaimer in the documentation
+	and/or other materials provided with the distribution.
 
 3. Neither the name of the copyright holder nor the names of its
-   contributors may be used to endorse or promote products derived from
-   this software without specific prior written permission.
+	contributors may be used to endorse or promote products derived from
+	this software without specific prior written permission.
+
+4. Notwithstanding any other provision of this License, and as a material condition of the rights granted herein, licensees are strictly prohibited from altering, removing, obscuring, or replacing any "Open WebUI" branding, including but not limited to the name, logo, or any visual, textual, or symbolic identifiers that distinguish the software and its interfaces, in any deployment or distribution, regardless of the number of users, except as explicitly set forth in Clauses 5 and 6 below.
+
+5. The branding restriction enumerated in Clause 4 shall not apply in the following limited circumstances: (i) deployments or distributions where the total number of end users (defined as individual natural persons with direct access to the application) does not exceed fifty (50) within any rolling thirty (30) day period; (ii) cases in which the licensee is an official contributor to the codebase—with a substantive code change successfully merged into the main branch of the official codebase maintained by the copyright holder—who has obtained specific prior written permission for branding adjustment from the copyright holder; or (iii) where the licensee has obtained a duly executed enterprise license expressly permitting such modification. For all other cases, any removal or alteration of the "Open WebUI" branding shall constitute a material breach of license.
+
+6. All code, modifications, or derivative works incorporated into this project prior to the incorporation of this branding clause remain licensed under the BSD 3-Clause License, and prior contributors retain all BSD-3 rights therein; if any such contributor requests the removal of their BSD-3-licensed code, the copyright holder will do so, and any replacement code will be licensed under the project's primary license then in effect. By contributing after this clause's adoption, you agree to the project's Contributor License Agreement (CLA) and to these updated terms for all new contributions.
 
 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE

+ 119 - 112
src/lib/components/chat/Settings/Account.svelte

@@ -87,7 +87,7 @@
 </script>
 
 <div class="flex flex-col h-full justify-between text-sm">
-	<div class=" space-y-3 overflow-y-scroll max-h-[28rem] lg:max-h-full">
+	<div class=" overflow-y-scroll max-h-[28rem] lg:max-h-full">
 		<input
 			id="profile-image-input"
 			bind:this={profileImageInputElement}
@@ -236,7 +236,7 @@
 
 					<div class="flex-1">
 						<input
-							class="w-full text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
+							class="w-full text-sm dark:text-gray-300 bg-transparent outline-hidden"
 							type="text"
 							bind:value={name}
 							required
@@ -265,98 +265,46 @@
 			{/if}
 		</div>
 
-		<div class="py-0.5">
-			<UpdatePassword />
-		</div>
-
 		<hr class="border-gray-50 dark:border-gray-850 my-2" />
 
-		<div class="flex justify-between items-center text-sm">
-			<div class="  font-medium">{$i18n.t('API keys')}</div>
-			<button
-				class=" text-xs font-medium text-gray-500"
-				type="button"
-				on:click={() => {
-					showAPIKeys = !showAPIKeys;
-				}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
-			>
+		<div class="my-2">
+			<UpdatePassword />
 		</div>
 
-		{#if showAPIKeys}
-			<div class="flex flex-col gap-4">
-				<div class="justify-between w-full">
-					<div class="flex justify-between w-full">
-						<div class="self-center text-xs font-medium">{$i18n.t('JWT Token')}</div>
-					</div>
+		{#if ($config?.features?.enable_api_key ?? true) || $user?.role === 'admin'}
+			<div class="flex justify-between items-center text-sm mb-2">
+				<div class="  font-medium">{$i18n.t('API keys')}</div>
+				<button
+					class=" text-xs font-medium text-gray-500"
+					type="button"
+					on:click={() => {
+						showAPIKeys = !showAPIKeys;
+					}}>{showAPIKeys ? $i18n.t('Hide') : $i18n.t('Show')}</button
+				>
+			</div>
 
-					<div class="flex mt-2">
-						<SensitiveInput value={localStorage.token} readOnly={true} />
+			{#if showAPIKeys}
+				<div class="flex flex-col gap-4">
+					{#if $user?.role === 'admin'}
+						<div class="justify-between w-full">
+							<div class="flex justify-between w-full">
+								<div class="self-center text-xs font-medium mb-1">{$i18n.t('JWT Token')}</div>
+							</div>
 
-						<button
-							class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
-							on:click={() => {
-								copyToClipboard(localStorage.token);
-								JWTTokenCopied = true;
-								setTimeout(() => {
-									JWTTokenCopied = false;
-								}, 2000);
-							}}
-						>
-							{#if JWTTokenCopied}
-								<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>
-							{: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="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
-										clip-rule="evenodd"
-									/>
-									<path
-										fill-rule="evenodd"
-										d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
-										clip-rule="evenodd"
-									/>
-								</svg>
-							{/if}
-						</button>
-					</div>
-				</div>
-				{#if $config?.features?.enable_api_key ?? true}
-					<div class="justify-between w-full">
-						<div class="flex justify-between w-full">
-							<div class="self-center text-xs font-medium">{$i18n.t('API Key')}</div>
-						</div>
-						<div class="flex mt-2">
-							{#if APIKey}
-								<SensitiveInput value={APIKey} readOnly={true} />
+							<div class="flex">
+								<SensitiveInput value={localStorage.token} readOnly={true} />
 
 								<button
 									class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
 									on:click={() => {
-										copyToClipboard(APIKey);
-										APIKeyCopied = true;
+										copyToClipboard(localStorage.token);
+										JWTTokenCopied = true;
 										setTimeout(() => {
-											APIKeyCopied = false;
+											JWTTokenCopied = false;
 										}, 2000);
 									}}
 								>
-									{#if APIKeyCopied}
+									{#if JWTTokenCopied}
 										<svg
 											xmlns="http://www.w3.org/2000/svg"
 											viewBox="0 0 20 20"
@@ -389,46 +337,105 @@
 										</svg>
 									{/if}
 								</button>
+							</div>
+						</div>
+					{/if}
+
+					{#if $config?.features?.enable_api_key ?? true}
+						<div class="justify-between w-full">
+							{#if $user?.role === 'admin'}
+								<div class="flex justify-between w-full">
+									<div class="self-center text-xs font-medium mb-1">{$i18n.t('API Key')}</div>
+								</div>
+							{/if}
+							<div class="flex">
+								{#if APIKey}
+									<SensitiveInput value={APIKey} readOnly={true} />
 
-								<Tooltip content={$i18n.t('Create new key')}>
 									<button
-										class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
+										class="ml-1.5 px-1.5 py-1 dark:hover:bg-gray-850 transition rounded-lg"
 										on:click={() => {
-											createAPIKeyHandler();
+											copyToClipboard(APIKey);
+											APIKeyCopied = true;
+											setTimeout(() => {
+												APIKeyCopied = false;
+											}, 2000);
 										}}
 									>
-										<svg
-											xmlns="http://www.w3.org/2000/svg"
-											fill="none"
-											viewBox="0 0 24 24"
-											stroke-width="2"
-											stroke="currentColor"
-											class="size-4"
-										>
-											<path
-												stroke-linecap="round"
-												stroke-linejoin="round"
-												d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
-											/>
-										</svg>
+										{#if APIKeyCopied}
+											<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>
+										{: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="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z"
+													clip-rule="evenodd"
+												/>
+												<path
+													fill-rule="evenodd"
+													d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z"
+													clip-rule="evenodd"
+												/>
+											</svg>
+										{/if}
 									</button>
-								</Tooltip>
-							{:else}
-								<button
-									class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
-									on:click={() => {
-										createAPIKeyHandler();
-									}}
-								>
-									<Plus strokeWidth="2" className=" size-3.5" />
 
-									{$i18n.t('Create new secret key')}</button
-								>
-							{/if}
+									<Tooltip content={$i18n.t('Create new key')}>
+										<button
+											class=" px-1.5 py-1 dark:hover:bg-gray-850transition rounded-lg"
+											on:click={() => {
+												createAPIKeyHandler();
+											}}
+										>
+											<svg
+												xmlns="http://www.w3.org/2000/svg"
+												fill="none"
+												viewBox="0 0 24 24"
+												stroke-width="2"
+												stroke="currentColor"
+												class="size-4"
+											>
+												<path
+													stroke-linecap="round"
+													stroke-linejoin="round"
+													d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
+												/>
+											</svg>
+										</button>
+									</Tooltip>
+								{:else}
+									<button
+										class="flex gap-1.5 items-center font-medium px-3.5 py-1.5 rounded-lg bg-gray-100/70 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-850 transition"
+										on:click={() => {
+											createAPIKeyHandler();
+										}}
+									>
+										<Plus strokeWidth="2" className=" size-3.5" />
+
+										{$i18n.t('Create new secret key')}</button
+									>
+								{/if}
+							</div>
 						</div>
-					</div>
-				{/if}
-			</div>
+					{/if}
+				</div>
+			{/if}
 		{/if}
 	</div>
 

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

@@ -334,7 +334,7 @@
 						class="w-full rounded-lg pl-2 py-2 px-1 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden"
 						type="text"
 						placeholder={$i18n.t(
-							'Enter comma-seperated "token:bias_value" pairs (example: 5432:100, 413:-100)'
+							'Enter comma-separated "token:bias_value" pairs (example: 5432:100, 413:-100)'
 						)}
 						bind:value={params.logit_bias}
 						autocomplete="off"

+ 25 - 22
src/lib/components/chat/Settings/Chats.svelte

@@ -140,28 +140,31 @@
 				</div>
 				<div class=" self-center text-sm font-medium">{$i18n.t('Import Chats')}</div>
 			</button>
-			<button
-				class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
-				on:click={() => {
-					exportChats();
-				}}
-			>
-				<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="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
-							clip-rule="evenodd"
-						/>
-					</svg>
-				</div>
-				<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
-			</button>
+
+			{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
+				<button
+					class=" flex rounded-md py-2 px-3.5 w-full hover:bg-gray-200 dark:hover:bg-gray-800 transition"
+					on:click={() => {
+						exportChats();
+					}}
+				>
+					<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="M4 2a1.5 1.5 0 0 0-1.5 1.5v9A1.5 1.5 0 0 0 4 14h8a1.5 1.5 0 0 0 1.5-1.5V6.621a1.5 1.5 0 0 0-.44-1.06L9.94 2.439A1.5 1.5 0 0 0 8.878 2H4Zm4 3.5a.75.75 0 0 1 .75.75v2.69l.72-.72a.75.75 0 1 1 1.06 1.06l-2 2a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 0 1 1.06-1.06l.72.72V6.25A.75.75 0 0 1 8 5.5Z"
+								clip-rule="evenodd"
+							/>
+						</svg>
+					</div>
+					<div class=" self-center text-sm font-medium">{$i18n.t('Export Chats')}</div>
+				</button>
+			{/if}
 		</div>
 
 		<hr class=" border-gray-100 dark:border-gray-850" />

+ 34 - 4
src/lib/components/chat/Settings/Interface.svelte

@@ -1,6 +1,4 @@
 <script lang="ts">
-	import { getBackendConfig } from '$lib/apis';
-	import { setDefaultPromptSuggestions } from '$lib/apis/configs';
 	import { config, models, settings, user } from '$lib/stores';
 	import { createEventDispatcher, onMount, getContext } from 'svelte';
 	import { toast } from 'svelte-sonner';
@@ -30,7 +28,9 @@
 	// Interface
 	let defaultModelId = '';
 	let showUsername = false;
+
 	let notificationSound = true;
+	let notificationSoundAlways = false;
 
 	let detectArtifacts = true;
 
@@ -117,6 +117,11 @@
 		saveSettings({ notificationSound: notificationSound });
 	};
 
+	const toggleNotificationSoundAlways = async () => {
+		notificationSoundAlways = !notificationSoundAlways;
+		saveSettings({ notificationSoundAlways: notificationSoundAlways });
+	};
+
 	const toggleShowChangelog = async () => {
 		showChangelog = !showChangelog;
 		saveSettings({ showChangelog: showChangelog });
@@ -294,7 +299,8 @@
 		chatDirection = $settings.chatDirection ?? 'auto';
 		userLocation = $settings.userLocation ?? false;
 
-		notificationSound = $settings.notificationSound ?? true;
+		notificationSound = $settings?.notificationSound ?? true;
+		notificationSoundAlways = $settings?.notificationSoundAlways ?? false;
 
 		hapticFeedback = $settings.hapticFeedback ?? false;
 		ctrlEnterToSend = $settings.ctrlEnterToSend ?? false;
@@ -477,6 +483,30 @@
 				</div>
 			</div>
 
+			{#if notificationSound}
+				<div>
+					<div class=" py-0.5 flex w-full justify-between">
+						<div class=" self-center text-xs">
+							{$i18n.t('Always Play Notification Sound')}
+						</div>
+
+						<button
+							class="p-1 px-3 text-xs flex rounded-sm transition"
+							on:click={() => {
+								toggleNotificationSoundAlways();
+							}}
+							type="button"
+						>
+							{#if notificationSoundAlways === 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>
+			{/if}
+
 			{#if $user?.role === 'admin'}
 				<div>
 					<div class=" py-0.5 flex w-full justify-between">
@@ -855,7 +885,7 @@
 			<div>
 				<div class=" py-0.5 flex w-full justify-between">
 					<div class=" self-center text-xs">
-						{$i18n.t('Scroll to bottom when switching between branches')}
+						{$i18n.t('Scroll On Branch Change')}
 					</div>
 
 					<button

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