1
0
Эх сурвалжийг харах

Merge branch 'open-webui:dev' into dev

Aleix Dorca 3 долоо хоног өмнө
parent
commit
cf49b823b0
100 өөрчлөгдсөн 4417 нэмэгдсэн , 1663 устгасан
  1. 8 5
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/pull_request_template.md
  3. 2 2
      .github/workflows/build-release.yml
  4. 1 1
      .github/workflows/format-backend.yaml
  5. 2 2
      .github/workflows/format-build-frontend.yaml
  6. 2 2
      .github/workflows/release-pypi.yml
  7. 101 0
      CHANGELOG.md
  8. 11 0
      LICENSE_NOTICE
  9. 1 1
      README.md
  10. 1 1
      backend/dev.sh
  11. 102 7
      backend/open_webui/config.py
  12. 16 3
      backend/open_webui/env.py
  13. 74 46
      backend/open_webui/functions.py
  14. 36 39
      backend/open_webui/main.py
  15. 52 0
      backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py
  16. 1 1
      backend/open_webui/models/chats.py
  17. 9 0
      backend/open_webui/models/files.py
  18. 54 7
      backend/open_webui/models/functions.py
  19. 15 2
      backend/open_webui/models/knowledge.py
  20. 8 2
      backend/open_webui/models/messages.py
  21. 15 4
      backend/open_webui/models/notes.py
  22. 246 0
      backend/open_webui/models/oauth_sessions.py
  23. 2 0
      backend/open_webui/models/tools.py
  24. 11 1
      backend/open_webui/models/users.py
  25. 19 2
      backend/open_webui/retrieval/loaders/main.py
  26. 2 3
      backend/open_webui/retrieval/loaders/youtube.py
  27. 73 26
      backend/open_webui/retrieval/utils.py
  28. 1 0
      backend/open_webui/retrieval/web/utils.py
  29. 4 3
      backend/open_webui/routers/audio.py
  30. 22 12
      backend/open_webui/routers/auths.py
  31. 164 15
      backend/open_webui/routers/channels.py
  32. 1 1
      backend/open_webui/routers/chats.py
  33. 71 10
      backend/open_webui/routers/configs.py
  34. 20 22
      backend/open_webui/routers/files.py
  35. 28 0
      backend/open_webui/routers/folders.py
  36. 24 5
      backend/open_webui/routers/functions.py
  37. 26 1
      backend/open_webui/routers/knowledge.py
  38. 38 2
      backend/open_webui/routers/models.py
  39. 10 3
      backend/open_webui/routers/notes.py
  40. 25 20
      backend/open_webui/routers/ollama.py
  41. 153 113
      backend/open_webui/routers/openai.py
  42. 75 12
      backend/open_webui/routers/retrieval.py
  43. 24 0
      backend/open_webui/routers/tools.py
  44. 32 0
      backend/open_webui/routers/users.py
  45. 3 2
      backend/open_webui/utils/access_control.py
  46. 55 43
      backend/open_webui/utils/auth.py
  47. 31 0
      backend/open_webui/utils/channels.py
  48. 97 0
      backend/open_webui/utils/files.py
  49. 5 3
      backend/open_webui/utils/filter.py
  50. 114 0
      backend/open_webui/utils/mcp/client.py
  51. 335 59
      backend/open_webui/utils/middleware.py
  52. 4 4
      backend/open_webui/utils/misc.py
  53. 39 1
      backend/open_webui/utils/models.py
  54. 412 173
      backend/open_webui/utils/oauth.py
  55. 23 16
      backend/open_webui/utils/telemetry/metrics.py
  56. 130 72
      backend/open_webui/utils/tools.py
  57. 11 10
      backend/requirements.txt
  58. 51 26
      package-lock.json
  59. 4 2
      package.json
  60. 21 17
      pyproject.toml
  61. 136 11
      src/app.css
  62. 30 17
      src/app.html
  63. 12 1
      src/lib/apis/index.ts
  64. 7 2
      src/lib/apis/notes/index.ts
  65. 28 0
      src/lib/apis/users/index.ts
  66. 98 47
      src/lib/components/AddConnectionModal.svelte
  67. 2 3
      src/lib/components/AddFilesPlaceholder.svelte
  68. 103 28
      src/lib/components/AddToolServerModal.svelte
  69. 25 23
      src/lib/components/ChangelogModal.svelte
  70. 54 8
      src/lib/components/NotificationToast.svelte
  71. 2 2
      src/lib/components/admin/Evaluations.svelte
  72. 2 2
      src/lib/components/admin/Evaluations/FeedbackMenu.svelte
  73. 12 18
      src/lib/components/admin/Evaluations/Feedbacks.svelte
  74. 15 19
      src/lib/components/admin/Evaluations/Leaderboard.svelte
  75. 7 7
      src/lib/components/admin/Functions.svelte
  76. 2 2
      src/lib/components/admin/Functions/AddFunctionMenu.svelte
  77. 16 15
      src/lib/components/admin/Functions/FunctionEditor.svelte
  78. 11 11
      src/lib/components/admin/Functions/FunctionMenu.svelte
  79. 4 2
      src/lib/components/admin/Settings.svelte
  80. 3 3
      src/lib/components/admin/Settings/Connections.svelte
  81. 3 2
      src/lib/components/admin/Settings/Connections/OllamaConnection.svelte
  82. 1 7
      src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte
  83. 1 11
      src/lib/components/admin/Settings/Database.svelte
  84. 93 12
      src/lib/components/admin/Settings/Documents.svelte
  85. 1 1
      src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte
  86. 2 2
      src/lib/components/admin/Settings/Models.svelte
  87. 6 6
      src/lib/components/admin/Settings/Models/ModelMenu.svelte
  88. 2 2
      src/lib/components/admin/Settings/Tools.svelte
  89. 2 2
      src/lib/components/admin/Users.svelte
  90. 1 1
      src/lib/components/admin/Users/Groups.svelte
  91. 2 2
      src/lib/components/admin/Users/Groups/GroupItem.svelte
  92. 15 21
      src/lib/components/admin/Users/UserList.svelte
  93. 4 0
      src/lib/components/admin/Users/UserList/UserChatsModal.svelte
  94. 7 4
      src/lib/components/channel/Channel.svelte
  95. 514 423
      src/lib/components/channel/MessageInput.svelte
  96. 18 18
      src/lib/components/channel/MessageInput/InputMenu.svelte
  97. 205 0
      src/lib/components/channel/MessageInput/MentionList.svelte
  98. 3 6
      src/lib/components/channel/Messages.svelte
  99. 42 26
      src/lib/components/channel/Messages/Message.svelte
  100. 8 91
      src/lib/components/channel/Messages/Message/ProfilePreview.svelte

+ 8 - 5
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -11,7 +11,7 @@ body:
 
         ## Important Notes
 
-        - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) or [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project.
+        - **Before submitting a bug report**: Please check the [Issues](https://github.com/open-webui/open-webui/issues) and [Discussions](https://github.com/open-webui/open-webui/discussions) sections to see if a similar issue has already been reported. If unsure, start a discussion first, as this helps us efficiently focus on improving the project. Duplicates may be closed without notice. **Please search for existing issues and discussions.**
 
         - **Respectful collaboration**: Open WebUI is a volunteer-driven project with a single maintainer and contributors who also have full-time jobs. Please be constructive and respectful in your communication.
 
@@ -25,7 +25,9 @@ body:
       label: Check Existing Issues
       description: Confirm that you’ve checked for existing reports before submitting a new one.
       options:
-        - label: I have searched the existing issues and discussions.
+        - label: I have searched for any existing and/or related issues.
+          required: true
+        - label: I have searched for any existing and/or related discussions.
           required: true
         - label: I am using the latest version of Open WebUI.
           required: true
@@ -47,7 +49,7 @@ body:
     id: open-webui-version
     attributes:
       label: Open WebUI Version
-      description: Specify the version (e.g., v0.3.11)
+      description: Specify the version (e.g., v0.6.26)
     validations:
       required: true
 
@@ -63,7 +65,7 @@ body:
     id: operating-system
     attributes:
       label: Operating System
-      description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04)
+      description: Specify the OS (e.g., Windows 10, macOS Sonoma, Ubuntu 22.04, Debian 12)
     validations:
       required: true
 
@@ -126,6 +128,7 @@ body:
       description: |
         Please provide a **very detailed, step-by-step guide** to reproduce the issue. Your instructions should be so clear and precise that anyone can follow them without guesswork. Include every relevant detail—settings, configuration options, exact commands used, values entered, and any prerequisites or environment variables.  
         **If full reproduction steps and all relevant settings are not provided, your issue may not be addressed.**
+        **If your steps to reproduction are incomplete, lacking detail or not reproducible, your issue can not be addressed.**
 
       placeholder: |
         Example (include every detail):
@@ -163,5 +166,5 @@ body:
     attributes:
       value: |
         ## Note
-        If the bug report is incomplete or does not follow instructions, it may not be addressed. Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.  
+        **If the bug report is incomplete, does not follow instructions or is lacking details it may not be addressed.** Ensure that you've followed all the **README.md** and **troubleshooting.md** guidelines, and provide all necessary information for us to reproduce the issue.  
         Thank you for contributing to Open WebUI!

+ 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 (CLA)](/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)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.

+ 2 - 2
.github/workflows/build-release.yml

@@ -36,7 +36,7 @@ jobs:
           echo "::set-output name=content::$CHANGELOG_ESCAPED"
 
       - name: Create GitHub release
-        uses: actions/github-script@v7
+        uses: actions/github-script@v8
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           script: |
@@ -61,7 +61,7 @@ jobs:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
       - name: Trigger Docker build workflow
-        uses: actions/github-script@v7
+        uses: actions/github-script@v8
         with:
           script: |
             github.rest.actions.createWorkflowDispatch({

+ 1 - 1
.github/workflows/format-backend.yaml

@@ -33,7 +33,7 @@ jobs:
       - uses: actions/checkout@v5
 
       - name: Set up Python
-        uses: actions/setup-python@v5
+        uses: actions/setup-python@v6
         with:
           python-version: '${{ matrix.python-version }}'
 

+ 2 - 2
.github/workflows/format-build-frontend.yaml

@@ -27,7 +27,7 @@ jobs:
         uses: actions/checkout@v5
 
       - name: Setup Node.js
-        uses: actions/setup-node@v4
+        uses: actions/setup-node@v5
         with:
           node-version: '22'
 
@@ -54,7 +54,7 @@ jobs:
         uses: actions/checkout@v5
 
       - name: Setup Node.js
-        uses: actions/setup-node@v4
+        uses: actions/setup-node@v5
         with:
           node-version: '22'
 

+ 2 - 2
.github/workflows/release-pypi.yml

@@ -21,10 +21,10 @@ jobs:
           fetch-depth: 0
       - name: Install Git
         run: sudo apt-get update && sudo apt-get install -y git
-      - uses: actions/setup-node@v4
+      - uses: actions/setup-node@v5
         with:
           node-version: 22
-      - uses: actions/setup-python@v5
+      - uses: actions/setup-python@v6
         with:
           python-version: 3.11
       - name: Build

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 101 - 0
CHANGELOG.md


+ 11 - 0
LICENSE_NOTICE

@@ -0,0 +1,11 @@
+# Open WebUI Multi-License Notice
+
+This repository contains code governed by multiple licenses based on the date and origin of contribution:
+
+1. All code committed prior to commit a76068d69cd59568b920dfab85dc573dbbb8f131 is licensed under the MIT License (see LICENSE_HISTORY).
+
+2. All code committed from commit a76068d69cd59568b920dfab85dc573dbbb8f131 up to and including commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the BSD 3-Clause License (see LICENSE_HISTORY).
+
+3. All code contributed or modified after commit 60d84a3aae9802339705826e9095e272e3c83623 is licensed under the Open WebUI License (see LICENSE).
+
+For details on which commits are covered by which license, refer to LICENSE_HISTORY.

+ 1 - 1
README.md

@@ -248,7 +248,7 @@ Discover upcoming features on our roadmap in the [Open WebUI Documentation](http
 
 ## License 📜
 
-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. 📄
+This project contains code under multiple licenses. The current codebase includes components licensed under the Open WebUI License with an additional requirement to preserve the "Open WebUI" branding, as well as prior contributions under their respective original licenses. For a detailed record of license changes and the applicable terms for each section of the code, please refer to [LICENSE_HISTORY](./LICENSE_HISTORY). For complete and updated licensing details, please see the [LICENSE](./LICENSE) and [LICENSE_HISTORY](./LICENSE_HISTORY) files.
 
 ## Support 💬
 

+ 1 - 1
backend/dev.sh

@@ -1,3 +1,3 @@
-export CORS_ALLOW_ORIGIN="http://localhost:5173"
+export CORS_ALLOW_ORIGIN="http://localhost:5173;http://localhost:8080"
 PORT="${PORT:-8080}"
 uvicorn open_webui.main:app --port $PORT --host 0.0.0.0 --forwarded-allow-ips '*' --reload

+ 102 - 7
backend/open_webui/config.py

@@ -222,10 +222,11 @@ class PersistentConfig(Generic[T]):
 
 
 class AppConfig:
-    _state: dict[str, PersistentConfig]
     _redis: Union[redis.Redis, redis.cluster.RedisCluster] = None
     _redis_key_prefix: str
 
+    _state: dict[str, PersistentConfig]
+
     def __init__(
         self,
         redis_url: Optional[str] = None,
@@ -233,9 +234,8 @@ class AppConfig:
         redis_cluster: Optional[bool] = False,
         redis_key_prefix: str = "open-webui",
     ):
-        super().__setattr__("_state", {})
-        super().__setattr__("_redis_key_prefix", redis_key_prefix)
         if redis_url:
+            super().__setattr__("_redis_key_prefix", redis_key_prefix)
             super().__setattr__(
                 "_redis",
                 get_redis_connection(
@@ -246,6 +246,8 @@ class AppConfig:
                 ),
             )
 
+        super().__setattr__("_state", {})
+
     def __setattr__(self, key, value):
         if isinstance(value, PersistentConfig):
             self._state[key] = value
@@ -513,6 +515,30 @@ OAUTH_GROUPS_CLAIM = PersistentConfig(
     os.environ.get("OAUTH_GROUPS_CLAIM", os.environ.get("OAUTH_GROUP_CLAIM", "groups")),
 )
 
+FEISHU_CLIENT_ID = PersistentConfig(
+    "FEISHU_CLIENT_ID",
+    "oauth.feishu.client_id",
+    os.environ.get("FEISHU_CLIENT_ID", ""),
+)
+
+FEISHU_CLIENT_SECRET = PersistentConfig(
+    "FEISHU_CLIENT_SECRET",
+    "oauth.feishu.client_secret",
+    os.environ.get("FEISHU_CLIENT_SECRET", ""),
+)
+
+FEISHU_OAUTH_SCOPE = PersistentConfig(
+    "FEISHU_OAUTH_SCOPE",
+    "oauth.feishu.scope",
+    os.environ.get("FEISHU_OAUTH_SCOPE", "contact:user.base:readonly"),
+)
+
+FEISHU_REDIRECT_URI = PersistentConfig(
+    "FEISHU_REDIRECT_URI",
+    "oauth.feishu.redirect_uri",
+    os.environ.get("FEISHU_REDIRECT_URI", ""),
+)
+
 ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
     "ENABLE_OAUTH_ROLE_MANAGEMENT",
     "oauth.enable_role_mapping",
@@ -705,6 +731,33 @@ def load_oauth_providers():
             "register": oidc_oauth_register,
         }
 
+    if FEISHU_CLIENT_ID.value and FEISHU_CLIENT_SECRET.value:
+
+        def feishu_oauth_register(client: OAuth):
+            client.register(
+                name="feishu",
+                client_id=FEISHU_CLIENT_ID.value,
+                client_secret=FEISHU_CLIENT_SECRET.value,
+                access_token_url="https://open.feishu.cn/open-apis/authen/v2/oauth/token",
+                authorize_url="https://accounts.feishu.cn/open-apis/authen/v1/authorize",
+                api_base_url="https://open.feishu.cn/open-apis",
+                userinfo_endpoint="https://open.feishu.cn/open-apis/authen/v1/user_info",
+                client_kwargs={
+                    "scope": FEISHU_OAUTH_SCOPE.value,
+                    **(
+                        {"timeout": int(OAUTH_TIMEOUT.value)}
+                        if OAUTH_TIMEOUT.value
+                        else {}
+                    ),
+                },
+                redirect_uri=FEISHU_REDIRECT_URI.value,
+            )
+
+        OAUTH_PROVIDERS["feishu"] = {
+            "register": feishu_oauth_register,
+            "sub_claim": "user_id",
+        }
+
     configured_providers = []
     if GOOGLE_CLIENT_ID.value:
         configured_providers.append("Google")
@@ -712,6 +765,8 @@ def load_oauth_providers():
         configured_providers.append("Microsoft")
     if GITHUB_CLIENT_ID.value:
         configured_providers.append("GitHub")
+    if FEISHU_CLIENT_ID.value:
+        configured_providers.append("Feishu")
 
     if configured_providers and not OPENID_PROVIDER_URL.value:
         provider_list = ", ".join(configured_providers)
@@ -2116,10 +2171,20 @@ ENABLE_ONEDRIVE_INTEGRATION = PersistentConfig(
     os.getenv("ENABLE_ONEDRIVE_INTEGRATION", "False").lower() == "true",
 )
 
-ONEDRIVE_CLIENT_ID = PersistentConfig(
-    "ONEDRIVE_CLIENT_ID",
-    "onedrive.client_id",
-    os.environ.get("ONEDRIVE_CLIENT_ID", ""),
+
+ENABLE_ONEDRIVE_PERSONAL = (
+    os.environ.get("ENABLE_ONEDRIVE_PERSONAL", "True").lower() == "true"
+)
+ENABLE_ONEDRIVE_BUSINESS = (
+    os.environ.get("ENABLE_ONEDRIVE_BUSINESS", "True").lower() == "true"
+)
+
+ONEDRIVE_CLIENT_ID = os.environ.get("ONEDRIVE_CLIENT_ID", "")
+ONEDRIVE_CLIENT_ID_PERSONAL = os.environ.get(
+    "ONEDRIVE_CLIENT_ID_PERSONAL", ONEDRIVE_CLIENT_ID
+)
+ONEDRIVE_CLIENT_ID_BUSINESS = os.environ.get(
+    "ONEDRIVE_CLIENT_ID_BUSINESS", ONEDRIVE_CLIENT_ID
 )
 
 ONEDRIVE_SHAREPOINT_URL = PersistentConfig(
@@ -2232,6 +2297,18 @@ DOCLING_SERVER_URL = PersistentConfig(
     os.getenv("DOCLING_SERVER_URL", "http://docling:5001"),
 )
 
+DOCLING_DO_OCR = PersistentConfig(
+    "DOCLING_DO_OCR",
+    "rag.docling_do_ocr",
+    os.getenv("DOCLING_DO_OCR", "True").lower() == "true",
+)
+
+DOCLING_FORCE_OCR = PersistentConfig(
+    "DOCLING_FORCE_OCR",
+    "rag.docling_force_ocr",
+    os.getenv("DOCLING_FORCE_OCR", "False").lower() == "true",
+)
+
 DOCLING_OCR_ENGINE = PersistentConfig(
     "DOCLING_OCR_ENGINE",
     "rag.docling_ocr_engine",
@@ -2244,6 +2321,24 @@ DOCLING_OCR_LANG = PersistentConfig(
     os.getenv("DOCLING_OCR_LANG", "eng,fra,deu,spa"),
 )
 
+DOCLING_PDF_BACKEND = PersistentConfig(
+    "DOCLING_PDF_BACKEND",
+    "rag.docling_pdf_backend",
+    os.getenv("DOCLING_PDF_BACKEND", "dlparse_v4"),
+)
+
+DOCLING_TABLE_MODE = PersistentConfig(
+    "DOCLING_TABLE_MODE",
+    "rag.docling_table_mode",
+    os.getenv("DOCLING_TABLE_MODE", "accurate"),
+)
+
+DOCLING_PIPELINE = PersistentConfig(
+    "DOCLING_PIPELINE",
+    "rag.docling_pipeline",
+    os.getenv("DOCLING_PIPELINE", "standard"),
+)
+
 DOCLING_DO_PICTURE_DESCRIPTION = PersistentConfig(
     "DOCLING_DO_PICTURE_DESCRIPTION",
     "rag.docling_do_picture_description",

+ 16 - 3
backend/open_webui/env.py

@@ -465,6 +465,19 @@ ENABLE_COMPRESSION_MIDDLEWARE = (
     os.environ.get("ENABLE_COMPRESSION_MIDDLEWARE", "True").lower() == "true"
 )
 
+####################################
+# OAUTH Configuration
+####################################
+
+
+ENABLE_OAUTH_ID_TOKEN_COOKIE = (
+    os.environ.get("ENABLE_OAUTH_ID_TOKEN_COOKIE", "True").lower() == "true"
+)
+
+OAUTH_SESSION_TOKEN_ENCRYPTION_KEY = os.environ.get(
+    "OAUTH_SESSION_TOKEN_ENCRYPTION_KEY", WEBUI_SECRET_KEY
+)
+
 
 ####################################
 # SCIM Configuration
@@ -534,16 +547,16 @@ else:
 
 
 CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = os.environ.get(
-    "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "10"
+    "CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES", "30"
 )
 
 if CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES == "":
-    CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
+    CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
 else:
     try:
         CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = int(CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES)
     except Exception:
-        CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 10
+        CHAT_RESPONSE_MAX_TOOL_CALL_RETRIES = 30
 
 
 ####################################

+ 74 - 46
backend/open_webui/functions.py

@@ -19,6 +19,7 @@ from fastapi import (
 from starlette.responses import Response, StreamingResponse
 
 
+from open_webui.constants import ERROR_MESSAGES
 from open_webui.socket.main import (
     get_event_call,
     get_event_emitter,
@@ -60,8 +61,20 @@ def get_function_module_by_id(request: Request, pipe_id: str):
     function_module, _, _ = get_function_module_from_cache(request, pipe_id)
 
     if hasattr(function_module, "valves") and hasattr(function_module, "Valves"):
+        Valves = function_module.Valves
         valves = Functions.get_function_valves_by_id(pipe_id)
-        function_module.valves = function_module.Valves(**(valves if valves else {}))
+
+        if valves:
+            try:
+                function_module.valves = Valves(
+                    **{k: v for k, v in valves.items() if v is not None}
+                )
+            except Exception as e:
+                log.exception(f"Error loading valves for function {pipe_id}: {e}")
+                raise e
+        else:
+            function_module.valves = Valves()
+
     return function_module
 
 
@@ -70,65 +83,69 @@ async def get_function_models(request):
     pipe_models = []
 
     for pipe in pipes:
-        function_module = get_function_module_by_id(request, pipe.id)
-
-        # Check if function is a manifold
-        if hasattr(function_module, "pipes"):
-            sub_pipes = []
+        try:
+            function_module = get_function_module_by_id(request, pipe.id)
 
-            # Handle pipes being a list, sync function, or async function
-            try:
-                if callable(function_module.pipes):
-                    if asyncio.iscoroutinefunction(function_module.pipes):
-                        sub_pipes = await function_module.pipes()
-                    else:
-                        sub_pipes = function_module.pipes()
-                else:
-                    sub_pipes = function_module.pipes
-            except Exception as e:
-                log.exception(e)
+            # Check if function is a manifold
+            if hasattr(function_module, "pipes"):
                 sub_pipes = []
 
-            log.debug(
-                f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
-            )
-
-            for p in sub_pipes:
-                sub_pipe_id = f'{pipe.id}.{p["id"]}'
-                sub_pipe_name = p["name"]
+                # Handle pipes being a list, sync function, or async function
+                try:
+                    if callable(function_module.pipes):
+                        if asyncio.iscoroutinefunction(function_module.pipes):
+                            sub_pipes = await function_module.pipes()
+                        else:
+                            sub_pipes = function_module.pipes()
+                    else:
+                        sub_pipes = function_module.pipes
+                except Exception as e:
+                    log.exception(e)
+                    sub_pipes = []
 
-                if hasattr(function_module, "name"):
-                    sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
+                log.debug(
+                    f"get_function_models: function '{pipe.id}' is a manifold of {sub_pipes}"
+                )
 
-                pipe_flag = {"type": pipe.type}
+                for p in sub_pipes:
+                    sub_pipe_id = f'{pipe.id}.{p["id"]}'
+                    sub_pipe_name = p["name"]
+
+                    if hasattr(function_module, "name"):
+                        sub_pipe_name = f"{function_module.name}{sub_pipe_name}"
+
+                    pipe_flag = {"type": pipe.type}
+
+                    pipe_models.append(
+                        {
+                            "id": sub_pipe_id,
+                            "name": sub_pipe_name,
+                            "object": "model",
+                            "created": pipe.created_at,
+                            "owned_by": "openai",
+                            "pipe": pipe_flag,
+                        }
+                    )
+            else:
+                pipe_flag = {"type": "pipe"}
+
+                log.debug(
+                    f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
+                )
 
                 pipe_models.append(
                     {
-                        "id": sub_pipe_id,
-                        "name": sub_pipe_name,
+                        "id": pipe.id,
+                        "name": pipe.name,
                         "object": "model",
                         "created": pipe.created_at,
                         "owned_by": "openai",
                         "pipe": pipe_flag,
                     }
                 )
-        else:
-            pipe_flag = {"type": "pipe"}
-
-            log.debug(
-                f"get_function_models: function '{pipe.id}' is a single pipe {{ 'id': {pipe.id}, 'name': {pipe.name} }}"
-            )
-
-            pipe_models.append(
-                {
-                    "id": pipe.id,
-                    "name": pipe.name,
-                    "object": "model",
-                    "created": pipe.created_at,
-                    "owned_by": "openai",
-                    "pipe": pipe_flag,
-                }
-            )
+        except Exception as e:
+            log.exception(e)
+            continue
 
     return pipe_models
 
@@ -219,6 +236,16 @@ async def generate_function_chat_completion(
         __task__ = metadata.get("task", None)
         __task_body__ = metadata.get("task_body", None)
 
+    oauth_token = None
+    try:
+        if request.cookies.get("oauth_session_id", None):
+            oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+                user.id,
+                request.cookies.get("oauth_session_id", None),
+            )
+    except Exception as e:
+        log.error(f"Error getting OAuth token: {e}")
+
     extra_params = {
         "__event_emitter__": __event_emitter__,
         "__event_call__": __event_call__,
@@ -230,6 +257,7 @@ async def generate_function_chat_completion(
         "__files__": files,
         "__user__": user.model_dump() if isinstance(user, UserModel) else {},
         "__metadata__": metadata,
+        "__oauth_token__": oauth_token,
         "__request__": request,
     }
     extra_params["__tools__"] = await get_tools(

+ 36 - 39
backend/open_webui/main.py

@@ -110,9 +110,6 @@ from open_webui.config import (
     OLLAMA_API_CONFIGS,
     # OpenAI
     ENABLE_OPENAI_API,
-    ONEDRIVE_CLIENT_ID,
-    ONEDRIVE_SHAREPOINT_URL,
-    ONEDRIVE_SHAREPOINT_TENANT_ID,
     OPENAI_API_BASE_URLS,
     OPENAI_API_KEYS,
     OPENAI_API_CONFIGS,
@@ -244,8 +241,13 @@ from open_webui.config import (
     EXTERNAL_DOCUMENT_LOADER_API_KEY,
     TIKA_SERVER_URL,
     DOCLING_SERVER_URL,
+    DOCLING_DO_OCR,
+    DOCLING_FORCE_OCR,
     DOCLING_OCR_ENGINE,
     DOCLING_OCR_LANG,
+    DOCLING_PDF_BACKEND,
+    DOCLING_TABLE_MODE,
+    DOCLING_PIPELINE,
     DOCLING_DO_PICTURE_DESCRIPTION,
     DOCLING_PICTURE_DESCRIPTION_MODE,
     DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -298,14 +300,17 @@ from open_webui.config import (
     GOOGLE_PSE_ENGINE_ID,
     GOOGLE_DRIVE_CLIENT_ID,
     GOOGLE_DRIVE_API_KEY,
-    ONEDRIVE_CLIENT_ID,
+    ENABLE_ONEDRIVE_INTEGRATION,
+    ONEDRIVE_CLIENT_ID_PERSONAL,
+    ONEDRIVE_CLIENT_ID_BUSINESS,
     ONEDRIVE_SHAREPOINT_URL,
     ONEDRIVE_SHAREPOINT_TENANT_ID,
+    ENABLE_ONEDRIVE_PERSONAL,
+    ENABLE_ONEDRIVE_BUSINESS,
     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,
@@ -443,6 +448,7 @@ from open_webui.utils.models import (
     get_all_models,
     get_all_base_models,
     check_model_access,
+    get_filtered_models,
 )
 from open_webui.utils.chat import (
     generate_chat_completion as chat_completion_handler,
@@ -592,6 +598,7 @@ app = FastAPI(
 )
 
 oauth_manager = OAuthManager(app)
+app.state.oauth_manager = oauth_manager
 
 app.state.instance_id = None
 app.state.config = AppConfig(
@@ -811,8 +818,13 @@ app.state.config.EXTERNAL_DOCUMENT_LOADER_URL = EXTERNAL_DOCUMENT_LOADER_URL
 app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY = EXTERNAL_DOCUMENT_LOADER_API_KEY
 app.state.config.TIKA_SERVER_URL = TIKA_SERVER_URL
 app.state.config.DOCLING_SERVER_URL = DOCLING_SERVER_URL
+app.state.config.DOCLING_DO_OCR = DOCLING_DO_OCR
+app.state.config.DOCLING_FORCE_OCR = DOCLING_FORCE_OCR
 app.state.config.DOCLING_OCR_ENGINE = DOCLING_OCR_ENGINE
 app.state.config.DOCLING_OCR_LANG = DOCLING_OCR_LANG
+app.state.config.DOCLING_PDF_BACKEND = DOCLING_PDF_BACKEND
+app.state.config.DOCLING_TABLE_MODE = DOCLING_TABLE_MODE
+app.state.config.DOCLING_PIPELINE = DOCLING_PIPELINE
 app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = DOCLING_DO_PICTURE_DESCRIPTION
 app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE = DOCLING_PICTURE_DESCRIPTION_MODE
 app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL = DOCLING_PICTURE_DESCRIPTION_LOCAL
@@ -1280,33 +1292,6 @@ if audit_level != AuditLevel.NONE:
 async def get_models(
     request: Request, refresh: bool = False, user=Depends(get_verified_user)
 ):
-    def get_filtered_models(models, user):
-        filtered_models = []
-        for model in models:
-            if model.get("arena"):
-                if has_access(
-                    user.id,
-                    type="read",
-                    access_control=model.get("info", {})
-                    .get("meta", {})
-                    .get("access_control", {}),
-                ):
-                    filtered_models.append(model)
-                continue
-
-            model_info = Models.get_model_by_id(model["id"])
-            if model_info:
-                if (
-                    (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
-                    or user.id == model_info.user_id
-                    or has_access(
-                        user.id, type="read", access_control=model_info.access_control
-                    )
-                ):
-                    filtered_models.append(model)
-
-        return filtered_models
-
     all_models = await get_all_models(request, refresh=refresh, user=user)
 
     models = []
@@ -1342,12 +1327,7 @@ async def get_models(
             )
         )
 
-    # Filter out models that the user does not have access to
-    if (
-        user.role == "user"
-        or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
-    ) and not BYPASS_MODEL_ACCESS_CONTROL:
-        models = get_filtered_models(models, user)
+    models = get_filtered_models(models, user)
 
     log.debug(
         f"/api/models returned filtered models accessible to the user: {json.dumps([model.get('id') for model in models])}"
@@ -1551,6 +1531,14 @@ async def chat_completion(
 
                 except:
                     pass
+        finally:
+            try:
+                if mcp_clients := metadata.get("mcp_clients"):
+                    for client in mcp_clients:
+                        await client.disconnect()
+            except Exception as e:
+                log.debug(f"Error cleaning up: {e}")
+                pass
 
     if (
         metadata.get("session_id")
@@ -1719,6 +1707,14 @@ async def get_app_config(request: Request):
                     "enable_admin_chat_access": ENABLE_ADMIN_CHAT_ACCESS,
                     "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
                     "enable_onedrive_integration": app.state.config.ENABLE_ONEDRIVE_INTEGRATION,
+                    **(
+                        {
+                            "enable_onedrive_personal": ENABLE_ONEDRIVE_PERSONAL,
+                            "enable_onedrive_business": ENABLE_ONEDRIVE_BUSINESS,
+                        }
+                        if app.state.config.ENABLE_ONEDRIVE_INTEGRATION
+                        else {}
+                    ),
                 }
                 if user is not None
                 else {}
@@ -1756,7 +1752,8 @@ async def get_app_config(request: Request):
                     "api_key": GOOGLE_DRIVE_API_KEY.value,
                 },
                 "onedrive": {
-                    "client_id": ONEDRIVE_CLIENT_ID.value,
+                    "client_id_personal": ONEDRIVE_CLIENT_ID_PERSONAL,
+                    "client_id_business": ONEDRIVE_CLIENT_ID_BUSINESS,
                     "sharepoint_url": ONEDRIVE_SHAREPOINT_URL.value,
                     "sharepoint_tenant_id": ONEDRIVE_SHAREPOINT_TENANT_ID.value,
                 },

+ 52 - 0
backend/open_webui/migrations/versions/38d63c18f30f_add_oauth_session_table.py

@@ -0,0 +1,52 @@
+"""Add oauth_session table
+
+Revision ID: 38d63c18f30f
+Revises: 3af16a1c9fb6
+Create Date: 2025-09-08 14:19:59.583921
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = "38d63c18f30f"
+down_revision: Union[str, None] = "3af16a1c9fb6"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+    # Create oauth_session table
+    op.create_table(
+        "oauth_session",
+        sa.Column("id", sa.Text(), nullable=False),
+        sa.Column("user_id", sa.Text(), nullable=False),
+        sa.Column("provider", sa.Text(), nullable=False),
+        sa.Column("token", sa.Text(), nullable=False),
+        sa.Column("expires_at", sa.BigInteger(), nullable=False),
+        sa.Column("created_at", sa.BigInteger(), nullable=False),
+        sa.Column("updated_at", sa.BigInteger(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"),
+    )
+
+    # Create indexes for better performance
+    op.create_index("idx_oauth_session_user_id", "oauth_session", ["user_id"])
+    op.create_index("idx_oauth_session_expires_at", "oauth_session", ["expires_at"])
+    op.create_index(
+        "idx_oauth_session_user_provider", "oauth_session", ["user_id", "provider"]
+    )
+
+
+def downgrade() -> None:
+    # Drop indexes first
+    op.drop_index("idx_oauth_session_user_provider", table_name="oauth_session")
+    op.drop_index("idx_oauth_session_expires_at", table_name="oauth_session")
+    op.drop_index("idx_oauth_session_user_id", table_name="oauth_session")
+
+    # Drop the table
+    op.drop_table("oauth_session")

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

@@ -236,7 +236,7 @@ class ChatTable:
 
         return chat.chat.get("title", "New Chat")
 
-    def get_messages_by_chat_id(self, id: str) -> Optional[dict]:
+    def get_messages_map_by_chat_id(self, id: str) -> Optional[dict]:
         chat = self.get_chat_by_id(id)
         if chat is None:
             return None

+ 9 - 0
backend/open_webui/models/files.py

@@ -147,6 +147,15 @@ class FilesTable:
         with get_db() as db:
             return [FileModel.model_validate(file) for file in db.query(File).all()]
 
+    def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
+        file = self.get_file_by_id(id)
+        if not file:
+            return False
+        if file.user_id == user_id:
+            return True
+        # Implement additional access control logic here as needed
+        return False
+
     def get_files_by_ids(self, ids: list[str]) -> list[FileModel]:
         with get_db() as db:
             return [

+ 54 - 7
backend/open_webui/models/functions.py

@@ -37,6 +37,7 @@ class Function(Base):
 class FunctionMeta(BaseModel):
     description: Optional[str] = None
     manifest: Optional[dict] = {}
+    model_config = ConfigDict(extra="allow")
 
 
 class FunctionModel(BaseModel):
@@ -54,6 +55,22 @@ class FunctionModel(BaseModel):
     model_config = ConfigDict(from_attributes=True)
 
 
+class FunctionWithValvesModel(BaseModel):
+    id: str
+    user_id: str
+    name: str
+    type: str
+    content: str
+    meta: FunctionMeta
+    valves: Optional[dict] = None
+    is_active: bool = False
+    is_global: bool = False
+    updated_at: int  # timestamp in epoch
+    created_at: int  # timestamp in epoch
+
+    model_config = ConfigDict(from_attributes=True)
+
+
 ####################
 # Forms
 ####################
@@ -111,8 +128,8 @@ class FunctionsTable:
             return None
 
     def sync_functions(
-        self, user_id: str, functions: list[FunctionModel]
-    ) -> list[FunctionModel]:
+        self, user_id: str, functions: list[FunctionWithValvesModel]
+    ) -> list[FunctionWithValvesModel]:
         # Synchronize functions for a user by updating existing ones, inserting new ones, and removing those that are no longer present.
         try:
             with get_db() as db:
@@ -166,17 +183,24 @@ class FunctionsTable:
         except Exception:
             return None
 
-    def get_functions(self, active_only=False) -> list[FunctionModel]:
+    def get_functions(
+        self, active_only=False, include_valves=False
+    ) -> list[FunctionModel | FunctionWithValvesModel]:
         with get_db() as db:
             if active_only:
+                functions = db.query(Function).filter_by(is_active=True).all()
+
+            else:
+                functions = db.query(Function).all()
+
+            if include_valves:
                 return [
-                    FunctionModel.model_validate(function)
-                    for function in db.query(Function).filter_by(is_active=True).all()
+                    FunctionWithValvesModel.model_validate(function)
+                    for function in functions
                 ]
             else:
                 return [
-                    FunctionModel.model_validate(function)
-                    for function in db.query(Function).all()
+                    FunctionModel.model_validate(function) for function in functions
                 ]
 
     def get_functions_by_type(
@@ -237,6 +261,29 @@ class FunctionsTable:
             except Exception:
                 return None
 
+    def update_function_metadata_by_id(
+        self, id: str, metadata: dict
+    ) -> Optional[FunctionModel]:
+        with get_db() as db:
+            try:
+                function = db.get(Function, id)
+
+                if function:
+                    if function.meta:
+                        function.meta = {**function.meta, **metadata}
+                    else:
+                        function.meta = metadata
+
+                    function.updated_at = int(time.time())
+                    db.commit()
+                    db.refresh(function)
+                    return self.get_function_by_id(id)
+                else:
+                    return None
+            except Exception as e:
+                log.exception(f"Error updating function metadata by id {id}: {e}")
+                return None
+
     def get_user_valves_by_id_and_user_id(
         self, id: str, user_id: str
     ) -> Optional[dict]:

+ 15 - 2
backend/open_webui/models/knowledge.py

@@ -129,7 +129,9 @@ class KnowledgeTable:
 
     def get_knowledge_bases(self) -> list[KnowledgeUserModel]:
         with get_db() as db:
-            all_knowledge = db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
+            all_knowledge = (
+                db.query(Knowledge).order_by(Knowledge.updated_at.desc()).all()
+            )
 
             user_ids = list(set(knowledge.user_id for knowledge in all_knowledge))
 
@@ -149,6 +151,15 @@ class KnowledgeTable:
                 )
             return knowledge_bases
 
+    def check_access_by_user_id(self, id, user_id, permission="write") -> bool:
+        knowledge = self.get_knowledge_by_id(id)
+        if not knowledge:
+            return False
+        if knowledge.user_id == user_id:
+            return True
+        user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
+        return has_access(user_id, permission, knowledge.access_control, user_group_ids)
+
     def get_knowledge_bases_by_user_id(
         self, user_id: str, permission: str = "write"
     ) -> list[KnowledgeUserModel]:
@@ -158,7 +169,9 @@ class KnowledgeTable:
             knowledge_base
             for knowledge_base in knowledge_bases
             if knowledge_base.user_id == user_id
-            or has_access(user_id, permission, knowledge_base.access_control, user_group_ids)
+            or has_access(
+                user_id, permission, knowledge_base.access_control, user_group_ids
+            )
         ]
 
     def get_knowledge_by_id(self, id: str) -> Optional[KnowledgeModel]:

+ 8 - 2
backend/open_webui/models/messages.py

@@ -201,8 +201,14 @@ class MessageTable:
         with get_db() as db:
             message = db.get(Message, id)
             message.content = form_data.content
-            message.data = form_data.data
-            message.meta = form_data.meta
+            message.data = {
+                **(message.data if message.data else {}),
+                **(form_data.data if form_data.data else {}),
+            }
+            message.meta = {
+                **(message.meta if message.meta else {}),
+                **(form_data.meta if form_data.meta else {}),
+            }
             message.updated_at = int(time.time_ns())
             db.commit()
             db.refresh(message)

+ 15 - 4
backend/open_webui/models/notes.py

@@ -97,15 +97,26 @@ class NoteTable:
             db.commit()
             return note
 
-    def get_notes(self) -> list[NoteModel]:
+    def get_notes(
+        self, skip: Optional[int] = None, limit: Optional[int] = None
+    ) -> list[NoteModel]:
         with get_db() as db:
-            notes = db.query(Note).order_by(Note.updated_at.desc()).all()
+            query = db.query(Note).order_by(Note.updated_at.desc())
+            if skip is not None:
+                query = query.offset(skip)
+            if limit is not None:
+                query = query.limit(limit)
+            notes = query.all()
             return [NoteModel.model_validate(note) for note in notes]
 
     def get_notes_by_user_id(
-        self, user_id: str, permission: str = "write"
+        self,
+        user_id: str,
+        permission: str = "write",
+        skip: Optional[int] = None,
+        limit: Optional[int] = None,
     ) -> list[NoteModel]:
-        notes = self.get_notes()
+        notes = self.get_notes(skip=skip, limit=limit)
         user_group_ids = {group.id for group in Groups.get_groups_by_member_id(user_id)}
         return [
             note

+ 246 - 0
backend/open_webui/models/oauth_sessions.py

@@ -0,0 +1,246 @@
+import time
+import logging
+import uuid
+from typing import Optional, List
+import base64
+import hashlib
+import json
+
+from cryptography.fernet import Fernet
+
+from open_webui.internal.db import Base, get_db
+from open_webui.env import SRC_LOG_LEVELS, OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
+
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import BigInteger, Column, String, Text, Index
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MODELS"])
+
+####################
+# DB MODEL
+####################
+
+
+class OAuthSession(Base):
+    __tablename__ = "oauth_session"
+
+    id = Column(Text, primary_key=True)
+    user_id = Column(Text, nullable=False)
+    provider = Column(Text, nullable=False)
+    token = Column(
+        Text, nullable=False
+    )  # JSON with access_token, id_token, refresh_token
+    expires_at = Column(BigInteger, nullable=False)
+    created_at = Column(BigInteger, nullable=False)
+    updated_at = Column(BigInteger, nullable=False)
+
+    # Add indexes for better performance
+    __table_args__ = (
+        Index("idx_oauth_session_user_id", "user_id"),
+        Index("idx_oauth_session_expires_at", "expires_at"),
+        Index("idx_oauth_session_user_provider", "user_id", "provider"),
+    )
+
+
+class OAuthSessionModel(BaseModel):
+    id: str
+    user_id: str
+    provider: str
+    token: dict
+    expires_at: int  # timestamp in epoch
+    created_at: int  # timestamp in epoch
+    updated_at: int  # timestamp in epoch
+
+    model_config = ConfigDict(from_attributes=True)
+
+
+####################
+# Forms
+####################
+
+
+class OAuthSessionResponse(BaseModel):
+    id: str
+    user_id: str
+    provider: str
+    expires_at: int
+
+
+class OAuthSessionTable:
+    def __init__(self):
+        self.encryption_key = OAUTH_SESSION_TOKEN_ENCRYPTION_KEY
+        if not self.encryption_key:
+            raise Exception("OAUTH_SESSION_TOKEN_ENCRYPTION_KEY is not set")
+
+        # check if encryption key is in the right format for Fernet (32 url-safe base64-encoded bytes)
+        if len(self.encryption_key) != 44:
+            key_bytes = hashlib.sha256(self.encryption_key.encode()).digest()
+            self.encryption_key = base64.urlsafe_b64encode(key_bytes)
+        else:
+            self.encryption_key = self.encryption_key.encode()
+
+        try:
+            self.fernet = Fernet(self.encryption_key)
+        except Exception as e:
+            log.error(f"Error initializing Fernet with provided key: {e}")
+            raise
+
+    def _encrypt_token(self, token) -> str:
+        """Encrypt OAuth tokens for storage"""
+        try:
+            token_json = json.dumps(token)
+            encrypted = self.fernet.encrypt(token_json.encode()).decode()
+            return encrypted
+        except Exception as e:
+            log.error(f"Error encrypting tokens: {e}")
+            raise
+
+    def _decrypt_token(self, token: str):
+        """Decrypt OAuth tokens from storage"""
+        try:
+            decrypted = self.fernet.decrypt(token.encode()).decode()
+            return json.loads(decrypted)
+        except Exception as e:
+            log.error(f"Error decrypting tokens: {e}")
+            raise
+
+    def create_session(
+        self,
+        user_id: str,
+        provider: str,
+        token: dict,
+    ) -> Optional[OAuthSessionModel]:
+        """Create a new OAuth session"""
+        try:
+            with get_db() as db:
+                current_time = int(time.time())
+                id = str(uuid.uuid4())
+
+                result = OAuthSession(
+                    **{
+                        "id": id,
+                        "user_id": user_id,
+                        "provider": provider,
+                        "token": self._encrypt_token(token),
+                        "expires_at": token.get("expires_at"),
+                        "created_at": current_time,
+                        "updated_at": current_time,
+                    }
+                )
+
+                db.add(result)
+                db.commit()
+                db.refresh(result)
+
+                if result:
+                    result.token = token  # Return decrypted token
+                    return OAuthSessionModel.model_validate(result)
+                else:
+                    return None
+        except Exception as e:
+            log.error(f"Error creating OAuth session: {e}")
+            return None
+
+    def get_session_by_id(self, session_id: str) -> Optional[OAuthSessionModel]:
+        """Get OAuth session by ID"""
+        try:
+            with get_db() as db:
+                session = db.query(OAuthSession).filter_by(id=session_id).first()
+                if session:
+                    session.token = self._decrypt_token(session.token)
+                    return OAuthSessionModel.model_validate(session)
+
+                return None
+        except Exception as e:
+            log.error(f"Error getting OAuth session by ID: {e}")
+            return None
+
+    def get_session_by_id_and_user_id(
+        self, session_id: str, user_id: str
+    ) -> Optional[OAuthSessionModel]:
+        """Get OAuth session by ID and user ID"""
+        try:
+            with get_db() as db:
+                session = (
+                    db.query(OAuthSession)
+                    .filter_by(id=session_id, user_id=user_id)
+                    .first()
+                )
+                if session:
+                    session.token = self._decrypt_token(session.token)
+                    return OAuthSessionModel.model_validate(session)
+
+                return None
+        except Exception as e:
+            log.error(f"Error getting OAuth session by ID: {e}")
+            return None
+
+    def get_sessions_by_user_id(self, user_id: str) -> List[OAuthSessionModel]:
+        """Get all OAuth sessions for a user"""
+        try:
+            with get_db() as db:
+                sessions = db.query(OAuthSession).filter_by(user_id=user_id).all()
+
+                results = []
+                for session in sessions:
+                    session.token = self._decrypt_token(session.token)
+                    results.append(OAuthSessionModel.model_validate(session))
+
+                return results
+
+        except Exception as e:
+            log.error(f"Error getting OAuth sessions by user ID: {e}")
+            return []
+
+    def update_session_by_id(
+        self, session_id: str, token: dict
+    ) -> Optional[OAuthSessionModel]:
+        """Update OAuth session tokens"""
+        try:
+            with get_db() as db:
+                current_time = int(time.time())
+
+                db.query(OAuthSession).filter_by(id=session_id).update(
+                    {
+                        "token": self._encrypt_token(token),
+                        "expires_at": token.get("expires_at"),
+                        "updated_at": current_time,
+                    }
+                )
+                db.commit()
+                session = db.query(OAuthSession).filter_by(id=session_id).first()
+
+                if session:
+                    session.token = self._decrypt_token(session.token)
+                    return OAuthSessionModel.model_validate(session)
+
+                return None
+        except Exception as e:
+            log.error(f"Error updating OAuth session tokens: {e}")
+            return None
+
+    def delete_session_by_id(self, session_id: str) -> bool:
+        """Delete an OAuth session"""
+        try:
+            with get_db() as db:
+                result = db.query(OAuthSession).filter_by(id=session_id).delete()
+                db.commit()
+                return result > 0
+        except Exception as e:
+            log.error(f"Error deleting OAuth session: {e}")
+            return False
+
+    def delete_sessions_by_user_id(self, user_id: str) -> bool:
+        """Delete all OAuth sessions for a user"""
+        try:
+            with get_db() as db:
+                result = db.query(OAuthSession).filter_by(user_id=user_id).delete()
+                db.commit()
+                return True
+        except Exception as e:
+            log.error(f"Error deleting OAuth sessions by user ID: {e}")
+            return False
+
+
+OAuthSessions = OAuthSessionTable()

+ 2 - 0
backend/open_webui/models/tools.py

@@ -4,6 +4,8 @@ from typing import Optional
 
 from open_webui.internal.db import Base, JSONField, get_db
 from open_webui.models.users import Users, UserResponse
+from open_webui.models.groups import Groups
+
 from open_webui.env import SRC_LOG_LEVELS
 from pydantic import BaseModel, ConfigDict
 from sqlalchemy import BigInteger, Column, String, Text, JSON

+ 11 - 1
backend/open_webui/models/users.py

@@ -107,11 +107,21 @@ class UserInfoResponse(BaseModel):
     role: str
 
 
+class UserIdNameResponse(BaseModel):
+    id: str
+    name: str
+
+
 class UserInfoListResponse(BaseModel):
     users: list[UserInfoResponse]
     total: int
 
 
+class UserIdNameListResponse(BaseModel):
+    users: list[UserIdNameResponse]
+    total: int
+
+
 class UserResponse(BaseModel):
     id: str
     name: str
@@ -210,7 +220,7 @@ class UsersTable:
         filter: Optional[dict] = None,
         skip: Optional[int] = None,
         limit: Optional[int] = None,
-    ) -> UserListResponse:
+    ) -> dict:
         with get_db() as db:
             query = db.query(User)
 

+ 19 - 2
backend/open_webui/retrieval/loaders/main.py

@@ -148,7 +148,7 @@ class DoclingLoader:
                 )
             }
 
-            params = {"image_export_mode": "placeholder", "table_mode": "accurate"}
+            params = {"image_export_mode": "placeholder"}
 
             if self.params:
                 if self.params.get("do_picture_description"):
@@ -174,7 +174,15 @@ class DoclingLoader:
                             self.params.get("picture_description_api", {})
                         )
 
-                if self.params.get("ocr_engine") and self.params.get("ocr_lang"):
+                params["do_ocr"] = self.params.get("do_ocr")
+
+                params["force_ocr"] = self.params.get("force_ocr")
+
+                if (
+                    self.params.get("do_ocr")
+                    and self.params.get("ocr_engine")
+                    and self.params.get("ocr_lang")
+                ):
                     params["ocr_engine"] = self.params.get("ocr_engine")
                     params["ocr_lang"] = [
                         lang.strip()
@@ -182,6 +190,15 @@ class DoclingLoader:
                         if lang.strip()
                     ]
 
+                if self.params.get("pdf_backend"):
+                    params["pdf_backend"] = self.params.get("pdf_backend")
+
+                if self.params.get("table_mode"):
+                    params["table_mode"] = self.params.get("table_mode")
+
+                if self.params.get("pipeline"):
+                    params["pipeline"] = self.params.get("pipeline")
+
             endpoint = f"{self.url}/v1/convert/file"
             r = requests.post(endpoint, files=files, data=params)
 

+ 2 - 3
backend/open_webui/retrieval/loaders/youtube.py

@@ -98,10 +98,9 @@ class YoutubeLoader:
         else:
             youtube_proxies = None
 
+        transcript_api = YouTubeTranscriptApi(proxy_config=youtube_proxies)
         try:
-            transcript_list = YouTubeTranscriptApi.list_transcripts(
-                self.video_id, proxies=youtube_proxies
-            )
+            transcript_list = transcript_api.list(self.video_id)
         except Exception as e:
             log.exception("Loading YouTube transcript failed")
             return []

+ 73 - 26
backend/open_webui/retrieval/utils.py

@@ -19,10 +19,13 @@ from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
 from open_webui.models.users import UserModel
 from open_webui.models.files import Files
 from open_webui.models.knowledge import Knowledges
+
+from open_webui.models.chats import Chats
 from open_webui.models.notes import Notes
 
 from open_webui.retrieval.vector.main import GetResult
 from open_webui.utils.access_control import has_access
+from open_webui.utils.misc import get_message_list
 
 
 from open_webui.env import (
@@ -124,7 +127,13 @@ def query_doc_with_hybrid_search(
     hybrid_bm25_weight: float,
 ) -> dict:
     try:
-        if not collection_result.documents[0]:
+        if (
+            not collection_result
+            or not hasattr(collection_result, "documents")
+            or not collection_result.documents
+            or len(collection_result.documents) == 0
+            or not collection_result.documents[0]
+        ):
             log.warning(f"query_doc_with_hybrid_search:no_docs {collection_name}")
             return {"documents": [], "metadatas": [], "distances": []}
 
@@ -432,13 +441,14 @@ def get_embedding_function(
             if isinstance(query, list):
                 embeddings = []
                 for i in range(0, len(query), embedding_batch_size):
-                    embeddings.extend(
-                        func(
-                            query[i : i + embedding_batch_size],
-                            prefix=prefix,
-                            user=user,
-                        )
+                    batch_embeddings = func(
+                        query[i : i + embedding_batch_size],
+                        prefix=prefix,
+                        user=user,
                     )
+
+                    if isinstance(batch_embeddings, list):
+                        embeddings.extend(batch_embeddings)
                 return embeddings
             else:
                 return func(query, prefix, user)
@@ -490,25 +500,37 @@ def get_sources_from_items(
             # Raw Text
             # Used during temporary chat file uploads or web page & youtube attachements
 
-            if item.get("collection_name"):
-                # If item has a collection name, use it
-                collection_names.append(item.get("collection_name"))
-            elif item.get("file"):
-                # if item has file data, use it
-                query_result = {
-                    "documents": [
-                        [item.get("file", {}).get("data", {}).get("content")]
-                    ],
-                    "metadatas": [[item.get("file", {}).get("meta", {})]],
-                }
-            else:
-                # Fallback to item content
-                query_result = {
-                    "documents": [[item.get("content")]],
-                    "metadatas": [
-                        [{"file_id": item.get("id"), "name": item.get("name")}]
-                    ],
-                }
+            if item.get("context") == "full":
+                if item.get("file"):
+                    # if item has file data, use it
+                    query_result = {
+                        "documents": [
+                            [item.get("file", {}).get("data", {}).get("content")]
+                        ],
+                        "metadatas": [[item.get("file", {}).get("meta", {})]],
+                    }
+
+            if query_result is None:
+                # Fallback
+                if item.get("collection_name"):
+                    # If item has a collection name, use it
+                    collection_names.append(item.get("collection_name"))
+                elif item.get("file"):
+                    # If item has file data, use it
+                    query_result = {
+                        "documents": [
+                            [item.get("file", {}).get("data", {}).get("content")]
+                        ],
+                        "metadatas": [[item.get("file", {}).get("meta", {})]],
+                    }
+                else:
+                    # Fallback to item content
+                    query_result = {
+                        "documents": [[item.get("content")]],
+                        "metadatas": [
+                            [{"file_id": item.get("id"), "name": item.get("name")}]
+                        ],
+                    }
 
         elif item.get("type") == "note":
             # Note Attached
@@ -525,6 +547,30 @@ def get_sources_from_items(
                     "metadatas": [[{"file_id": note.id, "name": note.title}]],
                 }
 
+        elif item.get("type") == "chat":
+            # Chat Attached
+            chat = Chats.get_chat_by_id(item.get("id"))
+
+            if chat and (user.role == "admin" or chat.user_id == user.id):
+                messages_map = chat.chat.get("history", {}).get("messages", {})
+                message_id = chat.chat.get("history", {}).get("currentId")
+
+                if messages_map and message_id:
+                    # Reconstruct the message list in order
+                    message_list = get_message_list(messages_map, message_id)
+                    message_history = "\n".join(
+                        [
+                            f"#### {m.get('role', 'user').capitalize()}\n{m.get('content')}\n"
+                            for m in message_list
+                        ]
+                    )
+
+                    # User has access to the chat
+                    query_result = {
+                        "documents": [[message_history]],
+                        "metadatas": [[{"file_id": chat.id, "name": chat.title}]],
+                    }
+
         elif item.get("type") == "file":
             if (
                 item.get("context") == "full"
@@ -581,6 +627,7 @@ def get_sources_from_items(
 
                 if knowledge_base and (
                     user.role == "admin"
+                    or knowledge_base.user_id == user.id
                     or has_access(user.id, "read", knowledge_base.access_control)
                 ):
 

+ 1 - 0
backend/open_webui/retrieval/web/utils.py

@@ -517,6 +517,7 @@ class SafeWebBaseLoader(WebBaseLoader):
                     async with session.get(
                         url,
                         **(self.requests_kwargs | kwargs),
+                        allow_redirects=False,
                     ) as response:
                         if self.raise_for_status:
                             response.raise_for_status()

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

@@ -337,7 +337,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
                 timeout=timeout, trust_env=True
             ) as session:
                 r = await session.post(
-                    url=urljoin(request.app.state.config.TTS_OPENAI_API_BASE_URL, "/audio/speech"),
+                    url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
                     json=payload,
                     headers={
                         "Content-Type": "application/json",
@@ -465,7 +465,8 @@ async def speech(request: Request, user=Depends(get_verified_user)):
                 timeout=timeout, trust_env=True
             ) as session:
                 async with session.post(
-                    urljoin(base_url or f"https://{region}.tts.speech.microsoft.com", "/cognitiveservices/v1"),
+                    (base_url or f"https://{region}.tts.speech.microsoft.com")
+                    + "/cognitiveservices/v1",
                     headers={
                         "Ocp-Apim-Subscription-Key": request.app.state.config.TTS_API_KEY,
                         "Content-Type": "application/ssml+xml",
@@ -549,7 +550,7 @@ def transcription_handler(request, file_path, metadata):
     metadata = metadata or {}
 
     languages = [
-        metadata.get("language", None) if WHISPER_LANGUAGE == "" else WHISPER_LANGUAGE,
+        metadata.get("language", None) if not WHISPER_LANGUAGE else WHISPER_LANGUAGE,
         None,  # Always fallback to None in case transcription fails
     ]
 

+ 22 - 12
backend/open_webui/routers/auths.py

@@ -19,6 +19,7 @@ from open_webui.models.auths import (
 )
 from open_webui.models.users import Users, UpdateProfileForm
 from open_webui.models.groups import Groups
+from open_webui.models.oauth_sessions import OAuthSessions
 
 from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
 from open_webui.env import (
@@ -676,19 +677,29 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
 async def signout(request: Request, response: Response):
     response.delete_cookie("token")
     response.delete_cookie("oui-session")
+    response.delete_cookie("oauth_id_token")
 
-    if ENABLE_OAUTH_SIGNUP.value:
-        oauth_id_token = request.cookies.get("oauth_id_token")
-        if oauth_id_token and OPENID_PROVIDER_URL.value:
+    oauth_session_id = request.cookies.get("oauth_session_id")
+    if oauth_session_id:
+        response.delete_cookie("oauth_session_id")
+
+        session = OAuthSessions.get_session_by_id(oauth_session_id)
+        oauth_server_metadata_url = (
+            request.app.state.oauth_manager.get_server_metadata_url(session.provider)
+            if session
+            else None
+        ) or OPENID_PROVIDER_URL.value
+
+        if session and oauth_server_metadata_url:
+            oauth_id_token = session.token.get("id_token")
             try:
                 async with ClientSession(trust_env=True) as session:
-                    async with session.get(OPENID_PROVIDER_URL.value) as resp:
-                        if resp.status == 200:
-                            openid_data = await resp.json()
+                    async with session.get(oauth_server_metadata_url) as r:
+                        if r.status == 200:
+                            openid_data = await r.json()
                             logout_url = openid_data.get("end_session_endpoint")
-                            if logout_url:
-                                response.delete_cookie("oauth_id_token")
 
+                            if logout_url:
                                 return JSONResponse(
                                     status_code=200,
                                     content={
@@ -703,15 +714,14 @@ async def signout(request: Request, response: Response):
                                     headers=response.headers,
                                 )
                         else:
-                            raise HTTPException(
-                                status_code=resp.status,
-                                detail="Failed to fetch OpenID configuration",
-                            )
+                            raise Exception("Failed to fetch OpenID configuration")
+
             except Exception as e:
                 log.error(f"OpenID signout error: {str(e)}")
                 raise HTTPException(
                     status_code=500,
                     detail="Failed to sign out from the OpenID provider.",
+                    headers=response.headers,
                 )
 
     if WEBUI_AUTH_SIGNOUT_REDIRECT_URL:

+ 164 - 15
backend/open_webui/routers/channels.py

@@ -24,9 +24,17 @@ from open_webui.constants import ERROR_MESSAGES
 from open_webui.env import SRC_LOG_LEVELS
 
 
+from open_webui.utils.models import (
+    get_all_models,
+    get_filtered_models,
+)
+from open_webui.utils.chat import generate_chat_completion
+
+
 from open_webui.utils.auth import get_admin_user, get_verified_user
 from open_webui.utils.access_control import has_access, get_users_with_access
 from open_webui.utils.webhook import post_webhook
+from open_webui.utils.channels import extract_mentions, replace_mentions
 
 log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["MODELS"])
@@ -200,14 +208,11 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
     users = get_users_with_access("read", channel.access_control)
 
     for user in users:
-        if user.id in active_user_ids:
-            continue
-        else:
+        if user.id not in active_user_ids:
             if user.settings:
                 webhook_url = user.settings.ui.get("notifications", {}).get(
                     "webhook_url", None
                 )
-
                 if webhook_url:
                     await post_webhook(
                         name,
@@ -221,14 +226,134 @@ async def send_notification(name, webui_url, channel, message, active_user_ids):
                         },
                     )
 
+    return True
 
-@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
-async def post_new_message(
-    request: Request,
-    id: str,
-    form_data: MessageForm,
-    background_tasks: BackgroundTasks,
-    user=Depends(get_verified_user),
+
+async def model_response_handler(request, channel, message, user):
+    MODELS = {
+        model["id"]: model
+        for model in get_filtered_models(await get_all_models(request, user=user), user)
+    }
+
+    mentions = extract_mentions(message.content)
+    message_content = replace_mentions(message.content)
+
+    # check if any of the mentions are models
+    model_mentions = [mention for mention in mentions if mention["id_type"] == "M"]
+    if not model_mentions:
+        return False
+
+    for mention in model_mentions:
+        model_id = mention["id"]
+        model = MODELS.get(model_id, None)
+
+        if model:
+            try:
+                # reverse to get in chronological order
+                thread_messages = Messages.get_messages_by_parent_id(
+                    channel.id,
+                    message.parent_id if message.parent_id else message.id,
+                )[::-1]
+
+                response_message, channel = await new_message_handler(
+                    request,
+                    channel.id,
+                    MessageForm(
+                        **{
+                            "parent_id": (
+                                message.parent_id if message.parent_id else message.id
+                            ),
+                            "content": f"",
+                            "data": {},
+                            "meta": {
+                                "model_id": model_id,
+                                "model_name": model.get("name", model_id),
+                            },
+                        }
+                    ),
+                    user,
+                )
+
+                thread_history = []
+                message_users = {}
+
+                for thread_message in thread_messages:
+                    message_user = None
+                    if thread_message.user_id not in message_users:
+                        message_user = Users.get_user_by_id(thread_message.user_id)
+                        message_users[thread_message.user_id] = message_user
+                    else:
+                        message_user = message_users[thread_message.user_id]
+
+                    if thread_message.meta and thread_message.meta.get(
+                        "model_id", None
+                    ):
+                        # If the message was sent by a model, use the model name
+                        message_model_id = thread_message.meta.get("model_id", None)
+                        message_model = MODELS.get(message_model_id, None)
+                        username = (
+                            message_model.get("name", message_model_id)
+                            if message_model
+                            else message_model_id
+                        )
+                    else:
+                        username = message_user.name if message_user else "Unknown"
+
+                    thread_history.append(
+                        f"{username}: {replace_mentions(thread_message.content)}"
+                    )
+
+                system_message = {
+                    "role": "system",
+                    "content": f"You are {model.get('name', model_id)}, an AI assistant participating in a threaded conversation. Be helpful, concise, and conversational."
+                    + (
+                        f"Here's the thread history:\n\n{''.join([f'{msg}' for msg in thread_history])}\n\nContinue the conversation naturally, addressing the most recent message while being aware of the full context."
+                        if thread_history
+                        else ""
+                    ),
+                }
+
+                form_data = {
+                    "model": model_id,
+                    "messages": [
+                        system_message,
+                        {
+                            "role": "user",
+                            "content": f"{user.name if user else 'User'}: {message_content}",
+                        },
+                    ],
+                    "stream": False,
+                }
+
+                res = await generate_chat_completion(
+                    request,
+                    form_data=form_data,
+                    user=user,
+                )
+
+                if res:
+                    await update_message_by_id(
+                        channel.id,
+                        response_message.id,
+                        MessageForm(
+                            **{
+                                "content": res["choices"][0]["message"]["content"],
+                                "meta": {
+                                    "done": True,
+                                },
+                            }
+                        ),
+                        user,
+                    )
+            except Exception as e:
+                log.info(e)
+                pass
+
+    return True
+
+
+async def new_message_handler(
+    request: Request, id: str, form_data: MessageForm, user=Depends(get_verified_user)
 ):
     channel = Channels.get_channel_by_id(id)
     if not channel:
@@ -302,11 +427,30 @@ async def post_new_message(
                         },
                         to=f"channel:{channel.id}",
                     )
+        return MessageModel(**message.model_dump()), channel
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
 
-            active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
 
-            background_tasks.add_task(
-                send_notification,
+@router.post("/{id}/messages/post", response_model=Optional[MessageModel])
+async def post_new_message(
+    request: Request,
+    id: str,
+    form_data: MessageForm,
+    background_tasks: BackgroundTasks,
+    user=Depends(get_verified_user),
+):
+
+    try:
+        message, channel = await new_message_handler(request, id, form_data, user)
+        active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
+
+        async def background_handler():
+            await model_response_handler(request, channel, message, user)
+            await send_notification(
                 request.app.state.WEBUI_NAME,
                 request.app.state.config.WEBUI_URL,
                 channel,
@@ -314,7 +458,12 @@ async def post_new_message(
                 active_user_ids,
             )
 
-        return MessageModel(**message.model_dump())
+        background_tasks.add_task(background_handler)
+
+        return message
+
+    except HTTPException as e:
+        raise e
     except Exception as e:
         log.exception(e)
         raise HTTPException(

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

@@ -166,7 +166,7 @@ async def import_chat(form_data: ChatImportForm, user=Depends(get_verified_user)
 
 
 @router.get("/search", response_model=list[ChatTitleIdResponse])
-async def search_user_chats(
+def search_user_chats(
     text: str, page: Optional[int] = None, user=Depends(get_verified_user)
 ):
     if page is None:

+ 71 - 10
backend/open_webui/routers/configs.py

@@ -1,3 +1,4 @@
+import logging
 from fastapi import APIRouter, Depends, Request, HTTPException
 from pydantic import BaseModel, ConfigDict
 
@@ -12,10 +13,16 @@ from open_webui.utils.tools import (
     get_tool_server_url,
     set_tool_servers,
 )
+from open_webui.utils.mcp.client import MCPClient
+
+from open_webui.env import SRC_LOG_LEVELS
 
 
 router = APIRouter()
 
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MAIN"])
+
 
 ############################
 # ImportConfig
@@ -87,6 +94,7 @@ async def set_connections_config(
 class ToolServerConnection(BaseModel):
     url: str
     path: str
+    type: Optional[str] = "openapi"  # openapi, mcp
     auth_type: Optional[str]
     key: Optional[str]
     config: Optional[dict]
@@ -129,19 +137,72 @@ async def verify_tool_servers_config(
     Verify the connection to the tool server.
     """
     try:
-
-        token = None
-        if form_data.auth_type == "bearer":
-            token = form_data.key
-        elif form_data.auth_type == "session":
-            token = request.state.token.credentials
-
-        url = get_tool_server_url(form_data.url, form_data.path)
-        return await get_tool_server_data(token, url)
+        if form_data.type == "mcp":
+            try:
+                client = MCPClient()
+                auth = None
+                headers = None
+
+                token = None
+                if form_data.auth_type == "bearer":
+                    token = form_data.key
+                elif form_data.auth_type == "session":
+                    token = request.state.token.credentials
+                elif form_data.auth_type == "system_oauth":
+                    try:
+                        if request.cookies.get("oauth_session_id", None):
+                            token = (
+                                await request.app.state.oauth_manager.get_oauth_token(
+                                    user.id,
+                                    request.cookies.get("oauth_session_id", None),
+                                )
+                            )
+                    except Exception as e:
+                        pass
+
+                if token:
+                    headers = {"Authorization": f"Bearer {token}"}
+
+                await client.connect(form_data.url, auth=auth, headers=headers)
+                specs = await client.list_tool_specs()
+                return {
+                    "status": True,
+                    "specs": specs,
+                }
+            except Exception as e:
+                log.debug(f"Failed to create MCP client: {e}")
+                raise HTTPException(
+                    status_code=400,
+                    detail=f"Failed to create MCP client",
+                )
+            finally:
+                if client:
+                    await client.disconnect()
+        else:  # openapi
+            token = None
+            if form_data.auth_type == "bearer":
+                token = form_data.key
+            elif form_data.auth_type == "session":
+                token = request.state.token.credentials
+            elif form_data.auth_type == "system_oauth":
+                try:
+                    if request.cookies.get("oauth_session_id", None):
+                        token = await request.app.state.oauth_manager.get_oauth_token(
+                            user.id,
+                            request.cookies.get("oauth_session_id", None),
+                        )
+                except Exception as e:
+                    pass
+
+            url = get_tool_server_url(form_data.url, form_data.path)
+            return await get_tool_server_data(token, url)
+    except HTTPException as e:
+        raise e
     except Exception as e:
+        log.debug(f"Failed to connect to the tool server: {e}")
         raise HTTPException(
             status_code=400,
-            detail=f"Failed to connect to the tool server: {str(e)}",
+            detail=f"Failed to connect to the tool server",
         )
 
 

+ 20 - 22
backend/open_webui/routers/files.py

@@ -120,11 +120,6 @@ def process_uploaded_file(request, file, file_path, file_item, file_metadata, us
                 f"File type {file.content_type} is not provided, but trying to process anyway"
             )
             process_file(request, ProcessFileForm(file_id=file_item.id), user=user)
-
-        Files.update_file_data_by_id(
-            file_item.id,
-            {"status": "completed"},
-        )
     except Exception as e:
         log.error(f"Error processing file: {file_item.id}")
         Files.update_file_data_by_id(
@@ -411,25 +406,28 @@ async def get_file_process_status(
             MAX_FILE_PROCESSING_DURATION = 3600 * 2
 
             async def event_stream(file_item):
-                for _ in range(MAX_FILE_PROCESSING_DURATION):
-                    file_item = Files.get_file_by_id(file_item.id)
-                    if file_item:
-                        data = file_item.model_dump().get("data", {})
-                        status = data.get("status")
-
-                        if status:
-                            event = {"status": status}
-                            if status == "failed":
-                                event["error"] = data.get("error")
-
-                            yield f"data: {json.dumps(event)}\n\n"
-                            if status in ("completed", "failed"):
+                if file_item:
+                    for _ in range(MAX_FILE_PROCESSING_DURATION):
+                        file_item = Files.get_file_by_id(file_item.id)
+                        if file_item:
+                            data = file_item.model_dump().get("data", {})
+                            status = data.get("status")
+
+                            if status:
+                                event = {"status": status}
+                                if status == "failed":
+                                    event["error"] = data.get("error")
+
+                                yield f"data: {json.dumps(event)}\n\n"
+                                if status in ("completed", "failed"):
+                                    break
+                            else:
+                                # Legacy
                                 break
-                        else:
-                            # Legacy
-                            break
 
-                    await asyncio.sleep(0.5)
+                        await asyncio.sleep(0.5)
+                else:
+                    yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
 
             return StreamingResponse(
                 event_stream(file),

+ 28 - 0
backend/open_webui/routers/folders.py

@@ -15,6 +15,9 @@ from open_webui.models.folders import (
     Folders,
 )
 from open_webui.models.chats import Chats
+from open_webui.models.files import Files
+from open_webui.models.knowledge import Knowledges
+
 
 from open_webui.config import UPLOAD_DIR
 from open_webui.env import SRC_LOG_LEVELS
@@ -45,6 +48,31 @@ router = APIRouter()
 async def get_folders(user=Depends(get_verified_user)):
     folders = Folders.get_folders_by_user_id(user.id)
 
+    # Verify folder data integrity
+    for folder in folders:
+        if folder.data:
+            if "files" in folder.data:
+                valid_files = []
+                for file in folder.data["files"]:
+
+                    if file.get("type") == "file":
+                        if Files.check_access_by_user_id(
+                            file.get("id"), user.id, "read"
+                        ):
+                            valid_files.append(file)
+                    elif file.get("type") == "collection":
+                        if Knowledges.check_access_by_user_id(
+                            file.get("id"), user.id, "read"
+                        ):
+                            valid_files.append(file)
+                    else:
+                        valid_files.append(file)
+
+                folder.data["files"] = valid_files
+                Folders.update_folder_by_id_and_user_id(
+                    folder.id, user.id, FolderUpdateForm(data=folder.data)
+                )
+
     return [
         {
             **folder.model_dump(),

+ 24 - 5
backend/open_webui/routers/functions.py

@@ -10,6 +10,7 @@ from open_webui.models.functions import (
     FunctionForm,
     FunctionModel,
     FunctionResponse,
+    FunctionWithValvesModel,
     Functions,
 )
 from open_webui.utils.plugin import (
@@ -46,9 +47,9 @@ async def get_functions(user=Depends(get_verified_user)):
 ############################
 
 
-@router.get("/export", response_model=list[FunctionModel])
-async def get_functions(user=Depends(get_admin_user)):
-    return Functions.get_functions()
+@router.get("/export", response_model=list[FunctionModel | FunctionWithValvesModel])
+async def get_functions(include_valves: bool = False, user=Depends(get_admin_user)):
+    return Functions.get_functions(include_valves=include_valves)
 
 
 ############################
@@ -132,10 +133,10 @@ async def load_function_from_url(
 
 
 class SyncFunctionsForm(BaseModel):
-    functions: list[FunctionModel] = []
+    functions: list[FunctionWithValvesModel] = []
 
 
-@router.post("/sync", response_model=list[FunctionModel])
+@router.post("/sync", response_model=list[FunctionWithValvesModel])
 async def sync_functions(
     request: Request, form_data: SyncFunctionsForm, user=Depends(get_admin_user)
 ):
@@ -147,6 +148,18 @@ async def sync_functions(
                 content=function.content,
             )
 
+            if hasattr(function_module, "Valves") and function.valves:
+                Valves = function_module.Valves
+                try:
+                    Valves(
+                        **{k: v for k, v in function.valves.items() if v is not None}
+                    )
+                except Exception as e:
+                    log.exception(
+                        f"Error validating valves for function {function.id}: {e}"
+                    )
+                    raise e
+
         return Functions.sync_functions(user.id, form_data.functions)
     except Exception as e:
         log.exception(f"Failed to load a function: {e}")
@@ -191,6 +204,9 @@ async def create_new_function(
             function_cache_dir = CACHE_DIR / "functions" / form_data.id
             function_cache_dir.mkdir(parents=True, exist_ok=True)
 
+            if function_type == "filter" and getattr(function_module, "toggle", None):
+                Functions.update_function_metadata_by_id(id, {"toggle": True})
+
             if function:
                 return function
             else:
@@ -307,6 +323,9 @@ async def update_function_by_id(
 
         function = Functions.update_function_by_id(id, updated)
 
+        if function_type == "filter" and getattr(function_module, "toggle", None):
+            Functions.update_function_metadata_by_id(id, {"toggle": True})
+
         if function:
             return function
         else:

+ 26 - 1
backend/open_webui/routers/knowledge.py

@@ -151,6 +151,18 @@ async def create_new_knowledge(
             detail=ERROR_MESSAGES.UNAUTHORIZED,
         )
 
+    # Check if user can share publicly
+    if (
+        user.role != "admin"
+        and form_data.access_control == None
+        and not has_permission(
+            user.id,
+            "sharing.public_knowledge",
+            request.app.state.config.USER_PERMISSIONS,
+        )
+    ):
+        form_data.access_control = {}
+
     knowledge = Knowledges.insert_new_knowledge(user.id, form_data)
 
     if knowledge:
@@ -285,6 +297,7 @@ async def get_knowledge_by_id(id: str, user=Depends(get_verified_user)):
 
 @router.post("/{id}/update", response_model=Optional[KnowledgeFilesResponse])
 async def update_knowledge_by_id(
+    request: Request,
     id: str,
     form_data: KnowledgeForm,
     user=Depends(get_verified_user),
@@ -306,10 +319,22 @@ async def update_knowledge_by_id(
             detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
         )
 
+    # Check if user can share publicly
+    if (
+        user.role != "admin"
+        and form_data.access_control == None
+        and not has_permission(
+            user.id,
+            "sharing.public_knowledge",
+            request.app.state.config.USER_PERMISSIONS,
+        )
+    ):
+        form_data.access_control = {}
+
     knowledge = Knowledges.update_knowledge_by_id(id=id, form_data=form_data)
     if knowledge:
         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(),

+ 38 - 2
backend/open_webui/routers/models.py

@@ -1,4 +1,6 @@
 from typing import Optional
+import io
+import base64
 
 from open_webui.models.models import (
     ModelForm,
@@ -10,12 +12,13 @@ from open_webui.models.models import (
 
 from pydantic import BaseModel
 from open_webui.constants import ERROR_MESSAGES
-from fastapi import APIRouter, Depends, HTTPException, Request, status
+from fastapi import APIRouter, Depends, HTTPException, Request, status, Response
+from fastapi.responses import FileResponse, StreamingResponse
 
 
 from open_webui.utils.auth import get_admin_user, get_verified_user
 from open_webui.utils.access_control import has_access, has_permission
-from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL
+from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL, STATIC_DIR
 
 router = APIRouter()
 
@@ -129,6 +132,39 @@ async def get_model_by_id(id: str, user=Depends(get_verified_user)):
         )
 
 
+###########################
+# GetModelById
+###########################
+
+
+@router.get("/model/profile/image")
+async def get_model_profile_image(id: str, user=Depends(get_verified_user)):
+    model = Models.get_model_by_id(id)
+    if model:
+        if model.meta.profile_image_url:
+            if model.meta.profile_image_url.startswith("http"):
+                return Response(
+                    status_code=status.HTTP_302_FOUND,
+                    headers={"Location": model.meta.profile_image_url},
+                )
+            elif model.meta.profile_image_url.startswith("data:image"):
+                try:
+                    header, base64_data = model.meta.profile_image_url.split(",", 1)
+                    image_data = base64.b64decode(base64_data)
+                    image_buffer = io.BytesIO(image_data)
+
+                    return StreamingResponse(
+                        image_buffer,
+                        media_type="image/png",
+                        headers={"Content-Disposition": "inline; filename=image.png"},
+                    )
+                except Exception as e:
+                    pass
+        return FileResponse(f"{STATIC_DIR}/favicon.png")
+    else:
+        return FileResponse(f"{STATIC_DIR}/favicon.png")
+
+
 ############################
 # ToggleModelById
 ############################

+ 10 - 3
backend/open_webui/routers/notes.py

@@ -62,8 +62,9 @@ class NoteTitleIdResponse(BaseModel):
 
 
 @router.get("/list", response_model=list[NoteTitleIdResponse])
-async def get_note_list(request: Request, user=Depends(get_verified_user)):
-
+async def get_note_list(
+    request: Request, page: Optional[int] = None, user=Depends(get_verified_user)
+):
     if user.role != "admin" and not has_permission(
         user.id, "features.notes", request.app.state.config.USER_PERMISSIONS
     ):
@@ -72,9 +73,15 @@ async def get_note_list(request: Request, user=Depends(get_verified_user)):
             detail=ERROR_MESSAGES.UNAUTHORIZED,
         )
 
+    limit = None
+    skip = None
+    if page is not None:
+        limit = 60
+        skip = (page - 1) * limit
+
     notes = [
         NoteTitleIdResponse(**note.model_dump())
-        for note in Notes.get_notes_by_user_id(user.id, "write")
+        for note in Notes.get_notes_by_user_id(user.id, "write", skip=skip, limit=limit)
     ]
 
     return notes

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

@@ -340,7 +340,10 @@ def merge_ollama_models_lists(model_lists):
     return list(merged_models.values())
 
 
-@cached(ttl=MODELS_CACHE_TTL, key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models")
+@cached(
+    ttl=MODELS_CACHE_TTL,
+    key=lambda _, user: f"ollama_all_models_{user.id}" if user else "ollama_all_models",
+)
 async def get_all_models(request: Request, user: UserModel = None):
     log.info("get_all_models()")
     if request.app.state.config.ENABLE_OLLAMA_API:
@@ -1691,25 +1694,27 @@ async def download_file_stream(
                     yield f'data: {{"progress": {progress}, "completed": {current_size}, "total": {total_size}}}\n\n'
 
                 if done:
-                    file.seek(0)
-                    chunk_size = 1024 * 1024 * 2
-                    hashed = calculate_sha256(file, chunk_size)
-                    file.seek(0)
-
-                    url = f"{ollama_url}/api/blobs/sha256:{hashed}"
-                    response = requests.post(url, data=file)
-
-                    if response.ok:
-                        res = {
-                            "done": done,
-                            "blob": f"sha256:{hashed}",
-                            "name": file_name,
-                        }
-                        os.remove(file_path)
-
-                        yield f"data: {json.dumps(res)}\n\n"
-                    else:
-                        raise "Ollama: Could not create blob, Please try again."
+                    file.close()
+
+                    with open(file_path, "rb") as file:
+                        chunk_size = 1024 * 1024 * 2
+                        hashed = calculate_sha256(file, chunk_size)
+
+                        url = f"{ollama_url}/api/blobs/sha256:{hashed}"
+                        with requests.Session() as session:
+                            response = session.post(url, data=file, timeout=30)
+
+                            if response.ok:
+                                res = {
+                                    "done": done,
+                                    "blob": f"sha256:{hashed}",
+                                    "name": file_name,
+                                }
+                                os.remove(file_path)
+
+                                yield f"data: {json.dumps(res)}\n\n"
+                            else:
+                                raise "Ollama: Could not create blob, Please try again."
 
 
 # url = "https://huggingface.co/TheBloke/stablelm-zephyr-3b-GGUF/resolve/main/stablelm-zephyr-3b.Q2_K.gguf"

+ 153 - 113
backend/open_webui/routers/openai.py

@@ -9,6 +9,8 @@ from aiocache import cached
 import requests
 from urllib.parse import quote
 
+from azure.identity import DefaultAzureCredential, get_bearer_token_provider
+
 from fastapi import Depends, HTTPException, Request, APIRouter
 from fastapi.responses import (
     FileResponse,
@@ -119,6 +121,93 @@ def openai_reasoning_model_handler(payload):
     return payload
 
 
+async def get_headers_and_cookies(
+    request: Request,
+    url,
+    key=None,
+    config=None,
+    metadata: Optional[dict] = None,
+    user: UserModel = None,
+):
+    cookies = {}
+    headers = {
+        "Content-Type": "application/json",
+        **(
+            {
+                "HTTP-Referer": "https://openwebui.com/",
+                "X-Title": "Open WebUI",
+            }
+            if "openrouter.ai" in url
+            else {}
+        ),
+        **(
+            {
+                "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
+                "X-OpenWebUI-User-Id": user.id,
+                "X-OpenWebUI-User-Email": user.email,
+                "X-OpenWebUI-User-Role": user.role,
+                **(
+                    {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
+                    if metadata and metadata.get("chat_id")
+                    else {}
+                ),
+            }
+            if ENABLE_FORWARD_USER_INFO_HEADERS
+            else {}
+        ),
+    }
+
+    token = None
+    auth_type = config.get("auth_type")
+
+    if auth_type == "bearer" or auth_type is None:
+        # Default to bearer if not specified
+        token = f"{key}"
+    elif auth_type == "none":
+        token = None
+    elif auth_type == "session":
+        cookies = request.cookies
+        token = request.state.token.credentials
+    elif auth_type == "system_oauth":
+        cookies = request.cookies
+
+        oauth_token = None
+        try:
+            if request.cookies.get("oauth_session_id", None):
+                oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+                    user.id,
+                    request.cookies.get("oauth_session_id", None),
+                )
+        except Exception as e:
+            log.error(f"Error getting OAuth token: {e}")
+
+        if oauth_token:
+            token = f"{oauth_token.get('access_token', '')}"
+
+    elif auth_type in ("azure_ad", "microsoft_entra_id"):
+        token = get_microsoft_entra_id_access_token()
+
+    if token:
+        headers["Authorization"] = f"Bearer {token}"
+
+    return headers, cookies
+
+
+def get_microsoft_entra_id_access_token():
+    """
+    Get Microsoft Entra ID access token using DefaultAzureCredential for Azure OpenAI.
+    Returns the token string or None if authentication fails.
+    """
+    try:
+        token_provider = get_bearer_token_provider(
+            DefaultAzureCredential(), "https://cognitiveservices.azure.com/.default"
+        )
+        return token_provider()
+    except Exception as e:
+        log.error(f"Error getting Microsoft Entra ID access token: {e}")
+        return None
+
+
 ##########################################
 #
 # API routes
@@ -210,34 +299,23 @@ async def speech(request: Request, user=Depends(get_verified_user)):
             return FileResponse(file_path)
 
         url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
+        key = request.app.state.config.OPENAI_API_KEYS[idx]
+        api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
+            str(idx),
+            request.app.state.config.OPENAI_API_CONFIGS.get(url, {}),  # Legacy support
+        )
+
+        headers, cookies = await get_headers_and_cookies(
+            request, url, key, api_config, user=user
+        )
 
         r = None
         try:
             r = requests.post(
                 url=f"{url}/audio/speech",
                 data=body,
-                headers={
-                    "Content-Type": "application/json",
-                    "Authorization": f"Bearer {request.app.state.config.OPENAI_API_KEYS[idx]}",
-                    **(
-                        {
-                            "HTTP-Referer": "https://openwebui.com/",
-                            "X-Title": "Open WebUI",
-                        }
-                        if "openrouter.ai" in url
-                        else {}
-                    ),
-                    **(
-                        {
-                            "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
-                        }
-                        if ENABLE_FORWARD_USER_INFO_HEADERS
-                        else {}
-                    ),
-                },
+                headers=headers,
+                cookies=cookies,
                 stream=True,
             )
 
@@ -401,7 +479,10 @@ async def get_filtered_models(models, user):
     return filtered_models
 
 
-@cached(ttl=MODELS_CACHE_TTL, key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models")
+@cached(
+    ttl=MODELS_CACHE_TTL,
+    key=lambda _, user: f"openai_all_models_{user.id}" if user else "openai_all_models",
+)
 async def get_all_models(request: Request, user: UserModel) -> dict[str, list]:
     log.info("get_all_models()")
 
@@ -489,19 +570,9 @@ async def get_models(
             timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
         ) as session:
             try:
-                headers = {
-                    "Content-Type": "application/json",
-                    **(
-                        {
-                            "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
-                            "X-OpenWebUI-User-Id": user.id,
-                            "X-OpenWebUI-User-Email": user.email,
-                            "X-OpenWebUI-User-Role": user.role,
-                        }
-                        if ENABLE_FORWARD_USER_INFO_HEADERS
-                        else {}
-                    ),
-                }
+                headers, cookies = await get_headers_and_cookies(
+                    request, url, key, api_config, user=user
+                )
 
                 if api_config.get("azure", False):
                     models = {
@@ -509,11 +580,10 @@ async def get_models(
                         "object": "list",
                     }
                 else:
-                    headers["Authorization"] = f"Bearer {key}"
-
                     async with session.get(
                         f"{url}/models",
                         headers=headers,
+                        cookies=cookies,
                         ssl=AIOHTTP_CLIENT_SESSION_SSL,
                     ) as r:
                         if r.status != 200:
@@ -572,7 +642,9 @@ class ConnectionVerificationForm(BaseModel):
 
 @router.post("/verify")
 async def verify_connection(
-    form_data: ConnectionVerificationForm, user=Depends(get_admin_user)
+    request: Request,
+    form_data: ConnectionVerificationForm,
+    user=Depends(get_admin_user),
 ):
     url = form_data.url
     key = form_data.key
@@ -584,27 +656,21 @@ async def verify_connection(
         timeout=aiohttp.ClientTimeout(total=AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST),
     ) as session:
         try:
-            headers = {
-                "Content-Type": "application/json",
-                **(
-                    {
-                        "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
-                    }
-                    if ENABLE_FORWARD_USER_INFO_HEADERS
-                    else {}
-                ),
-            }
+            headers, cookies = await get_headers_and_cookies(
+                request, url, key, api_config, user=user
+            )
 
             if api_config.get("azure", False):
-                headers["api-key"] = key
-                api_version = api_config.get("api_version", "") or "2023-03-15-preview"
+                # Only set api-key header if not using Azure Entra ID authentication
+                auth_type = api_config.get("auth_type", "bearer")
+                if auth_type not in ("azure_ad", "microsoft_entra_id"):
+                    headers["api-key"] = key
 
+                api_version = api_config.get("api_version", "") or "2023-03-15-preview"
                 async with session.get(
                     url=f"{url}/openai/models?api-version={api_version}",
                     headers=headers,
+                    cookies=cookies,
                     ssl=AIOHTTP_CLIENT_SESSION_SSL,
                 ) as r:
                     try:
@@ -624,11 +690,10 @@ async def verify_connection(
 
                     return response_data
             else:
-                headers["Authorization"] = f"Bearer {key}"
-
                 async with session.get(
                     f"{url}/models",
                     headers=headers,
+                    cookies=cookies,
                     ssl=AIOHTTP_CLIENT_SESSION_SSL,
                 ) as r:
                     try:
@@ -836,42 +901,23 @@ async def generate_chat_completion(
             convert_logit_bias_input_to_json(payload["logit_bias"])
         )
 
-    headers = {
-        "Content-Type": "application/json",
-        **(
-            {
-                "HTTP-Referer": "https://openwebui.com/",
-                "X-Title": "Open WebUI",
-            }
-            if "openrouter.ai" in url
-            else {}
-        ),
-        **(
-            {
-                "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
-                "X-OpenWebUI-User-Id": user.id,
-                "X-OpenWebUI-User-Email": user.email,
-                "X-OpenWebUI-User-Role": user.role,
-                **(
-                    {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
-                    if metadata and metadata.get("chat_id")
-                    else {}
-                ),
-            }
-            if ENABLE_FORWARD_USER_INFO_HEADERS
-            else {}
-        ),
-    }
+    headers, cookies = await get_headers_and_cookies(
+        request, url, key, api_config, metadata, user=user
+    )
 
     if api_config.get("azure", False):
         api_version = api_config.get("api_version", "2023-03-15-preview")
         request_url, payload = convert_to_azure_payload(url, payload, api_version)
-        headers["api-key"] = key
+
+        # Only set api-key header if not using Azure Entra ID authentication
+        auth_type = api_config.get("auth_type", "bearer")
+        if auth_type not in ("azure_ad", "microsoft_entra_id"):
+            headers["api-key"] = key
+
         headers["api-version"] = api_version
         request_url = f"{request_url}/chat/completions?api-version={api_version}"
     else:
         request_url = f"{url}/chat/completions"
-        headers["Authorization"] = f"Bearer {key}"
 
     payload = json.dumps(payload)
 
@@ -890,6 +936,7 @@ async def generate_chat_completion(
             url=request_url,
             data=payload,
             headers=headers,
+            cookies=cookies,
             ssl=AIOHTTP_CLIENT_SESSION_SSL,
         )
 
@@ -951,31 +998,29 @@ async def embeddings(request: Request, form_data: dict, user):
     models = request.app.state.OPENAI_MODELS
     if model_id in models:
         idx = models[model_id]["urlIdx"]
+
     url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
     key = request.app.state.config.OPENAI_API_KEYS[idx]
+    api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
+        str(idx),
+        request.app.state.config.OPENAI_API_CONFIGS.get(url, {}),  # Legacy support
+    )
+
     r = None
     session = None
     streaming = False
+
+    headers, cookies = await get_headers_and_cookies(
+        request, url, key, api_config, user=user
+    )
     try:
         session = aiohttp.ClientSession(trust_env=True)
         r = await session.request(
             method="POST",
             url=f"{url}/embeddings",
             data=body,
-            headers={
-                "Authorization": f"Bearer {key}",
-                "Content-Type": "application/json",
-                **(
-                    {
-                        "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
-                        "X-OpenWebUI-User-Id": user.id,
-                        "X-OpenWebUI-User-Email": user.email,
-                        "X-OpenWebUI-User-Role": user.role,
-                    }
-                    if ENABLE_FORWARD_USER_INFO_HEADERS and user
-                    else {}
-                ),
-            },
+            headers=headers,
+            cookies=cookies,
         )
 
         if "text/event-stream" in r.headers.get("Content-Type", ""):
@@ -1037,23 +1082,18 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
     streaming = False
 
     try:
-        headers = {
-            "Content-Type": "application/json",
-            **(
-                {
-                    "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
-                    "X-OpenWebUI-User-Id": user.id,
-                    "X-OpenWebUI-User-Email": user.email,
-                    "X-OpenWebUI-User-Role": user.role,
-                }
-                if ENABLE_FORWARD_USER_INFO_HEADERS
-                else {}
-            ),
-        }
+        headers, cookies = await get_headers_and_cookies(
+            request, url, key, api_config, user=user
+        )
 
         if api_config.get("azure", False):
             api_version = api_config.get("api_version", "2023-03-15-preview")
-            headers["api-key"] = key
+
+            # Only set api-key header if not using Azure Entra ID authentication
+            auth_type = api_config.get("auth_type", "bearer")
+            if auth_type not in ("azure_ad", "microsoft_entra_id"):
+                headers["api-key"] = key
+
             headers["api-version"] = api_version
 
             payload = json.loads(body)
@@ -1062,7 +1102,6 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
 
             request_url = f"{url}/{path}?api-version={api_version}"
         else:
-            headers["Authorization"] = f"Bearer {key}"
             request_url = f"{url}/{path}"
 
         session = aiohttp.ClientSession(trust_env=True)
@@ -1071,6 +1110,7 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
             url=request_url,
             data=body,
             headers=headers,
+            cookies=cookies,
             ssl=AIOHTTP_CLIENT_SESSION_SSL,
         )
 

+ 75 - 12
backend/open_webui/routers/retrieval.py

@@ -426,8 +426,13 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
         "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
         "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
         "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
+        "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
+        "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
         "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
         "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
+        "DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
+        "DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
+        "DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
         "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
         "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
         "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -596,8 +601,13 @@ class ConfigForm(BaseModel):
 
     TIKA_SERVER_URL: Optional[str] = None
     DOCLING_SERVER_URL: Optional[str] = None
+    DOCLING_DO_OCR: Optional[bool] = None
+    DOCLING_FORCE_OCR: Optional[bool] = None
     DOCLING_OCR_ENGINE: Optional[str] = None
     DOCLING_OCR_LANG: Optional[str] = None
+    DOCLING_PDF_BACKEND: Optional[str] = None
+    DOCLING_TABLE_MODE: Optional[str] = None
+    DOCLING_PIPELINE: Optional[str] = None
     DOCLING_DO_PICTURE_DESCRIPTION: Optional[bool] = None
     DOCLING_PICTURE_DESCRIPTION_MODE: Optional[str] = None
     DOCLING_PICTURE_DESCRIPTION_LOCAL: Optional[dict] = None
@@ -767,6 +777,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_DO_OCR = (
+        form_data.DOCLING_DO_OCR
+        if form_data.DOCLING_DO_OCR is not None
+        else request.app.state.config.DOCLING_DO_OCR
+    )
+    request.app.state.config.DOCLING_FORCE_OCR = (
+        form_data.DOCLING_FORCE_OCR
+        if form_data.DOCLING_FORCE_OCR is not None
+        else request.app.state.config.DOCLING_FORCE_OCR
+    )
     request.app.state.config.DOCLING_OCR_ENGINE = (
         form_data.DOCLING_OCR_ENGINE
         if form_data.DOCLING_OCR_ENGINE is not None
@@ -777,7 +797,21 @@ async def update_rag_config(
         if form_data.DOCLING_OCR_LANG is not None
         else request.app.state.config.DOCLING_OCR_LANG
     )
-
+    request.app.state.config.DOCLING_PDF_BACKEND = (
+        form_data.DOCLING_PDF_BACKEND
+        if form_data.DOCLING_PDF_BACKEND is not None
+        else request.app.state.config.DOCLING_PDF_BACKEND
+    )
+    request.app.state.config.DOCLING_TABLE_MODE = (
+        form_data.DOCLING_TABLE_MODE
+        if form_data.DOCLING_TABLE_MODE is not None
+        else request.app.state.config.DOCLING_TABLE_MODE
+    )
+    request.app.state.config.DOCLING_PIPELINE = (
+        form_data.DOCLING_PIPELINE
+        if form_data.DOCLING_PIPELINE is not None
+        else request.app.state.config.DOCLING_PIPELINE
+    )
     request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION = (
         form_data.DOCLING_DO_PICTURE_DESCRIPTION
         if form_data.DOCLING_DO_PICTURE_DESCRIPTION is not None
@@ -1062,8 +1096,13 @@ async def update_rag_config(
         "EXTERNAL_DOCUMENT_LOADER_API_KEY": request.app.state.config.EXTERNAL_DOCUMENT_LOADER_API_KEY,
         "TIKA_SERVER_URL": request.app.state.config.TIKA_SERVER_URL,
         "DOCLING_SERVER_URL": request.app.state.config.DOCLING_SERVER_URL,
+        "DOCLING_DO_OCR": request.app.state.config.DOCLING_DO_OCR,
+        "DOCLING_FORCE_OCR": request.app.state.config.DOCLING_FORCE_OCR,
         "DOCLING_OCR_ENGINE": request.app.state.config.DOCLING_OCR_ENGINE,
         "DOCLING_OCR_LANG": request.app.state.config.DOCLING_OCR_LANG,
+        "DOCLING_PDF_BACKEND": request.app.state.config.DOCLING_PDF_BACKEND,
+        "DOCLING_TABLE_MODE": request.app.state.config.DOCLING_TABLE_MODE,
+        "DOCLING_PIPELINE": request.app.state.config.DOCLING_PIPELINE,
         "DOCLING_DO_PICTURE_DESCRIPTION": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
         "DOCLING_PICTURE_DESCRIPTION_MODE": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
         "DOCLING_PICTURE_DESCRIPTION_LOCAL": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -1295,7 +1334,7 @@ def save_docs_to_vector_db(
                 )
                 return True
 
-        log.info(f"adding to collection {collection_name}")
+        log.info(f"generating embeddings for {collection_name}")
         embedding_function = get_embedding_function(
             request.app.state.config.RAG_EMBEDDING_ENGINE,
             request.app.state.config.RAG_EMBEDDING_MODEL,
@@ -1331,6 +1370,7 @@ def save_docs_to_vector_db(
             prefix=RAG_EMBEDDING_CONTENT_PREFIX,
             user=user,
         )
+        log.info(f"embeddings generated {len(embeddings)} for {len(texts)} items")
 
         items = [
             {
@@ -1342,11 +1382,13 @@ def save_docs_to_vector_db(
             for idx, text in enumerate(texts)
         ]
 
+        log.info(f"adding to collection {collection_name}")
         VECTOR_DB_CLIENT.insert(
             collection_name=collection_name,
             items=items,
         )
 
+        log.info(f"added {len(items)} items to collection {collection_name}")
         return True
     except Exception as e:
         log.exception(e)
@@ -1453,8 +1495,13 @@ def process_file(
                     TIKA_SERVER_URL=request.app.state.config.TIKA_SERVER_URL,
                     DOCLING_SERVER_URL=request.app.state.config.DOCLING_SERVER_URL,
                     DOCLING_PARAMS={
+                        "do_ocr": request.app.state.config.DOCLING_DO_OCR,
+                        "force_ocr": request.app.state.config.DOCLING_FORCE_OCR,
                         "ocr_engine": request.app.state.config.DOCLING_OCR_ENGINE,
                         "ocr_lang": request.app.state.config.DOCLING_OCR_LANG,
+                        "pdf_backend": request.app.state.config.DOCLING_PDF_BACKEND,
+                        "table_mode": request.app.state.config.DOCLING_TABLE_MODE,
+                        "pipeline": request.app.state.config.DOCLING_PIPELINE,
                         "do_picture_description": request.app.state.config.DOCLING_DO_PICTURE_DESCRIPTION,
                         "picture_description_mode": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_MODE,
                         "picture_description_local": request.app.state.config.DOCLING_PICTURE_DESCRIPTION_LOCAL,
@@ -1500,13 +1547,20 @@ def process_file(
         log.debug(f"text_content: {text_content}")
         Files.update_file_data_by_id(
             file.id,
-            {"status": "completed", "content": text_content},
+            {"content": text_content},
         )
-
         hash = calculate_sha256_string(text_content)
         Files.update_file_hash_by_id(file.id, hash)
 
-        if not request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
+        if request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL:
+            Files.update_file_data_by_id(file.id, {"status": "completed"})
+            return {
+                "status": True,
+                "collection_name": None,
+                "filename": file.filename,
+                "content": text_content,
+            }
+        else:
             try:
                 result = save_docs_to_vector_db(
                     request,
@@ -1520,6 +1574,7 @@ def process_file(
                     add=(True if form_data.collection_name else False),
                     user=user,
                 )
+                log.info(f"added {len(docs)} items to collection {collection_name}")
 
                 if result:
                     Files.update_file_metadata_by_id(
@@ -1529,21 +1584,21 @@ def process_file(
                         },
                     )
 
+                    Files.update_file_data_by_id(
+                        file.id,
+                        {"status": "completed"},
+                    )
+
                     return {
                         "status": True,
                         "collection_name": collection_name,
                         "filename": file.filename,
                         "content": text_content,
                     }
+                else:
+                    raise Exception("Error saving document to vector database")
             except Exception as e:
                 raise e
-        else:
-            return {
-                "status": True,
-                "collection_name": None,
-                "filename": file.filename,
-                "content": text_content,
-            }
 
     except Exception as e:
         log.exception(e)
@@ -1945,6 +2000,8 @@ async def process_web_search(
 ):
 
     urls = []
+    result_items = []
+
     try:
         logging.info(
             f"trying to web search with {request.app.state.config.WEB_SEARCH_ENGINE, form_data.queries}"
@@ -1966,6 +2023,7 @@ async def process_web_search(
             if result:
                 for item in result:
                     if item and item.link:
+                        result_items.append(item)
                         urls.append(item.link)
 
         urls = list(dict.fromkeys(urls))
@@ -2010,12 +2068,16 @@ async def process_web_search(
         urls = [
             doc.metadata.get("source") for doc in docs if doc.metadata.get("source")
         ]  # only keep the urls returned by the loader
+        result_items = [
+            dict(item) for item in result_items if item.link in urls
+        ]  # only keep the search results that have been loaded
 
         if request.app.state.config.BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL:
             return {
                 "status": True,
                 "collection_name": None,
                 "filenames": urls,
+                "items": result_items,
                 "docs": [
                     {
                         "content": doc.page_content,
@@ -2048,6 +2110,7 @@ async def process_web_search(
             return {
                 "status": True,
                 "collection_names": [collection_name],
+                "items": result_items,
                 "filenames": urls,
                 "loaded_count": len(docs),
             }

+ 24 - 0
backend/open_webui/routers/tools.py

@@ -43,6 +43,7 @@ router = APIRouter()
 async def get_tools(request: Request, user=Depends(get_verified_user)):
     tools = Tools.get_tools()
 
+    # OpenAPI Tool Servers
     for server in await get_tool_servers(request):
         tools.append(
             ToolUserResponse(
@@ -68,6 +69,29 @@ async def get_tools(request: Request, user=Depends(get_verified_user)):
             )
         )
 
+    # MCP Tool Servers
+    for server in request.app.state.config.TOOL_SERVER_CONNECTIONS:
+        if server.get("type", "openapi") == "mcp":
+            tools.append(
+                ToolUserResponse(
+                    **{
+                        "id": f"server:mcp:{server.get('info', {}).get('id')}",
+                        "user_id": f"server:mcp:{server.get('info', {}).get('id')}",
+                        "name": server.get("info", {}).get("name", "MCP Tool Server"),
+                        "meta": {
+                            "description": server.get("info", {}).get(
+                                "description", ""
+                            ),
+                        },
+                        "access_control": server.get("config", {}).get(
+                            "access_control", None
+                        ),
+                        "updated_at": int(time.time()),
+                        "created_at": int(time.time()),
+                    }
+                )
+            )
+
     if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL:
         # Admin can see all tools
         return tools

+ 32 - 0
backend/open_webui/routers/users.py

@@ -10,12 +10,15 @@ from pydantic import BaseModel
 
 
 from open_webui.models.auths import Auths
+from open_webui.models.oauth_sessions import OAuthSessions
+
 from open_webui.models.groups import Groups
 from open_webui.models.chats import Chats
 from open_webui.models.users import (
     UserModel,
     UserListResponse,
     UserInfoListResponse,
+    UserIdNameListResponse,
     UserRoleUpdateForm,
     Users,
     UserSettings,
@@ -98,6 +101,23 @@ async def get_all_users(
     return Users.get_users()
 
 
+@router.get("/search", response_model=UserIdNameListResponse)
+async def search_users(
+    query: Optional[str] = None,
+    user=Depends(get_verified_user),
+):
+    limit = PAGE_ITEM_COUNT
+
+    page = 1  # Always return the first page for search
+    skip = (page - 1) * limit
+
+    filter = {}
+    if query:
+        filter["query"] = query
+
+    return Users.get_users(filter=filter, skip=skip, limit=limit)
+
+
 ############################
 # User Groups
 ############################
@@ -340,6 +360,18 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
         )
 
 
+@router.get("/{user_id}/oauth/sessions", response_model=Optional[dict])
+async def get_user_oauth_sessions_by_id(user_id: str, user=Depends(get_admin_user)):
+    sessions = OAuthSessions.get_sessions_by_user_id(user_id)
+    if sessions and len(sessions) > 0:
+        return sessions
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.USER_NOT_FOUND,
+        )
+
+
 ############################
 # GetUserProfileImageById
 ############################

+ 3 - 2
backend/open_webui/utils/access_control.py

@@ -130,9 +130,10 @@ def has_access(
 # Get all users with access to a resource
 def get_users_with_access(
     type: str = "write", access_control: Optional[dict] = None
-) -> List[UserModel]:
+) -> list[UserModel]:
     if access_control is None:
-        return Users.get_users()
+        result = Users.get_users()
+        return result.get("users", [])
 
     permission_access = access_control.get(type, {})
     permitted_group_ids = permission_access.get("group_ids", [])

+ 55 - 43
backend/open_webui/utils/auth.py

@@ -261,55 +261,67 @@ def get_current_user(
         return user
 
     # auth by jwt token
-    try:
-        data = decode_token(token)
-    except Exception as e:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail="Invalid token",
-        )
 
-    if data is not None and "id" in data:
-        user = Users.get_user_by_id(data["id"])
-        if user is None:
+    try:
+        try:
+            data = decode_token(token)
+        except Exception as e:
             raise HTTPException(
                 status_code=status.HTTP_401_UNAUTHORIZED,
-                detail=ERROR_MESSAGES.INVALID_TOKEN,
+                detail="Invalid token",
             )
-        else:
-            if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
-                trusted_email = request.headers.get(
-                    WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
-                ).lower()
-                if trusted_email and user.email != trusted_email:
-                    # Delete the token cookie
-                    response.delete_cookie("token")
-                    # Delete OAuth token if present
-                    if request.cookies.get("oauth_id_token"):
-                        response.delete_cookie("oauth_id_token")
-                    raise HTTPException(
-                        status_code=status.HTTP_401_UNAUTHORIZED,
-                        detail="User mismatch. Please sign in again.",
+
+        if data is not None and "id" in data:
+            user = Users.get_user_by_id(data["id"])
+            if user is None:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail=ERROR_MESSAGES.INVALID_TOKEN,
+                )
+            else:
+                if WEBUI_AUTH_TRUSTED_EMAIL_HEADER:
+                    trusted_email = request.headers.get(
+                        WEBUI_AUTH_TRUSTED_EMAIL_HEADER, ""
+                    ).lower()
+                    if trusted_email and user.email != trusted_email:
+                        raise HTTPException(
+                            status_code=status.HTTP_401_UNAUTHORIZED,
+                            detail="User mismatch. Please sign in again.",
+                        )
+
+                # Add user info to current span
+                current_span = trace.get_current_span()
+                if current_span:
+                    current_span.set_attribute("client.user.id", user.id)
+                    current_span.set_attribute("client.user.email", user.email)
+                    current_span.set_attribute("client.user.role", user.role)
+                    current_span.set_attribute("client.auth.type", "jwt")
+
+                # Refresh the user's last active timestamp asynchronously
+                # to prevent blocking the request
+                if background_tasks:
+                    background_tasks.add_task(
+                        Users.update_user_last_active_by_id, user.id
                     )
+            return user
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_401_UNAUTHORIZED,
+                detail=ERROR_MESSAGES.UNAUTHORIZED,
+            )
+    except Exception as e:
+        # Delete the token cookie
+        if request.cookies.get("token"):
+            response.delete_cookie("token")
 
-            # Add user info to current span
-            current_span = trace.get_current_span()
-            if current_span:
-                current_span.set_attribute("client.user.id", user.id)
-                current_span.set_attribute("client.user.email", user.email)
-                current_span.set_attribute("client.user.role", user.role)
-                current_span.set_attribute("client.auth.type", "jwt")
-
-            # Refresh the user's last active timestamp asynchronously
-            # to prevent blocking the request
-            if background_tasks:
-                background_tasks.add_task(Users.update_user_last_active_by_id, user.id)
-        return user
-    else:
-        raise HTTPException(
-            status_code=status.HTTP_401_UNAUTHORIZED,
-            detail=ERROR_MESSAGES.UNAUTHORIZED,
-        )
+        if request.cookies.get("oauth_id_token"):
+            response.delete_cookie("oauth_id_token")
+
+        # Delete OAuth session if present
+        if request.cookies.get("oauth_session_id"):
+            response.delete_cookie("oauth_session_id")
+
+        raise e
 
 
 def get_current_user_by_api_key(api_key: str):

+ 31 - 0
backend/open_webui/utils/channels.py

@@ -0,0 +1,31 @@
+import re
+
+
+def extract_mentions(message: str, triggerChar: str = "@"):
+    # Escape triggerChar in case it's a regex special character
+    triggerChar = re.escape(triggerChar)
+    pattern = rf"<{triggerChar}([A-Z]):([^|>]+)"
+
+    matches = re.findall(pattern, message)
+    return [{"id_type": id_type, "id": id_value} for id_type, id_value in matches]
+
+
+def replace_mentions(message: str, triggerChar: str = "@", use_label: bool = True):
+    """
+    Replace mentions in the message with either their label (after the pipe `|`)
+    or their id if no label exists.
+
+    Example:
+      "<@M:gpt-4.1|GPT-4>" -> "GPT-4"   (if use_label=True)
+      "<@M:gpt-4.1|GPT-4>" -> "gpt-4.1" (if use_label=False)
+    """
+    # Escape triggerChar
+    triggerChar = re.escape(triggerChar)
+
+    def replacer(match):
+        id_type, id_value, label = match.groups()
+        return label if use_label and label else id_value
+
+    # Regex captures: idType, id, optional label
+    pattern = rf"<{triggerChar}([A-Z]):([^|>]+)(?:\|([^>]+))?>"
+    return re.sub(pattern, replacer, message)

+ 97 - 0
backend/open_webui/utils/files.py

@@ -0,0 +1,97 @@
+from open_webui.routers.images import (
+    load_b64_image_data,
+    upload_image,
+)
+
+from fastapi import (
+    APIRouter,
+    Depends,
+    HTTPException,
+    Request,
+    UploadFile,
+)
+
+from open_webui.routers.files import upload_file_handler
+
+import mimetypes
+import base64
+import io
+
+
+def get_image_url_from_base64(request, base64_image_string, metadata, user):
+    if "data:image/png;base64" in base64_image_string:
+        image_url = ""
+        # Extract base64 image data from the line
+        image_data, content_type = load_b64_image_data(base64_image_string)
+        if image_data is not None:
+            image_url = upload_image(
+                request,
+                image_data,
+                content_type,
+                metadata,
+                user,
+            )
+        return image_url
+    return None
+
+
+def load_b64_audio_data(b64_str):
+    try:
+        if "," in b64_str:
+            header, b64_data = b64_str.split(",", 1)
+        else:
+            b64_data = b64_str
+            header = "data:audio/wav;base64"
+        audio_data = base64.b64decode(b64_data)
+        content_type = (
+            header.split(";")[0].split(":")[1] if ";" in header else "audio/wav"
+        )
+        return audio_data, content_type
+    except Exception as e:
+        print(f"Error decoding base64 audio data: {e}")
+        return None, None
+
+
+def upload_audio(request, audio_data, content_type, metadata, user):
+    audio_format = mimetypes.guess_extension(content_type)
+    file = UploadFile(
+        file=io.BytesIO(audio_data),
+        filename=f"generated-{audio_format}",  # will be converted to a unique ID on upload_file
+        headers={
+            "content-type": content_type,
+        },
+    )
+    file_item = upload_file_handler(
+        request,
+        file=file,
+        metadata=metadata,
+        process=False,
+        user=user,
+    )
+    url = request.app.url_path_for("get_file_content_by_id", id=file_item.id)
+    return url
+
+
+def get_audio_url_from_base64(request, base64_audio_string, metadata, user):
+    if "data:audio/wav;base64" in base64_audio_string:
+        audio_url = ""
+        # Extract base64 audio data from the line
+        audio_data, content_type = load_b64_audio_data(base64_audio_string)
+        if audio_data is not None:
+            audio_url = upload_audio(
+                request,
+                audio_data,
+                content_type,
+                metadata,
+                user,
+            )
+        return audio_url
+    return None
+
+
+def get_file_url_from_base64(request, base64_file_string, metadata, user):
+    if "data:image/png;base64" in base64_file_string:
+        return get_image_url_from_base64(request, base64_file_string, metadata, user)
+    elif "data:audio/wav;base64" in base64_file_string:
+        return get_audio_url_from_base64(request, base64_file_string, metadata, user)
+    return None

+ 5 - 3
backend/open_webui/utils/filter.py

@@ -127,8 +127,10 @@ async def process_filter_functions(
             raise e
 
     # Handle file cleanup for inlet
-    if skip_files and "files" in form_data.get("metadata", {}):
-        del form_data["files"]
-        del form_data["metadata"]["files"]
+    if skip_files:
+        if "files" in form_data.get("metadata", {}):
+            del form_data["metadata"]["files"]
+        if "files" in form_data:
+            del form_data["files"]
 
     return form_data, {}

+ 114 - 0
backend/open_webui/utils/mcp/client.py

@@ -0,0 +1,114 @@
+import asyncio
+from typing import Optional
+from contextlib import AsyncExitStack
+
+from mcp import ClientSession
+from mcp.client.auth import OAuthClientProvider, TokenStorage
+from mcp.client.streamable_http import streamablehttp_client
+from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
+
+
+class MCPClient:
+    def __init__(self):
+        self.session: Optional[ClientSession] = None
+        self.exit_stack = AsyncExitStack()
+
+    async def connect(
+        self, url: str, headers: Optional[dict] = None, auth: Optional[any] = None
+    ):
+        try:
+            self._streams_context = streamablehttp_client(
+                url, headers=headers, auth=auth
+            )
+
+            transport = await self.exit_stack.enter_async_context(self._streams_context)
+            read_stream, write_stream, _ = transport
+
+            self._session_context = ClientSession(
+                read_stream, write_stream
+            )  # pylint: disable=W0201
+
+            self.session = await self.exit_stack.enter_async_context(
+                self._session_context
+            )
+            await self.session.initialize()
+        except Exception as e:
+            await self.disconnect()
+            raise e
+
+    async def list_tool_specs(self) -> Optional[dict]:
+        if not self.session:
+            raise RuntimeError("MCP client is not connected.")
+
+        result = await self.session.list_tools()
+        tools = result.tools
+
+        tool_specs = []
+        for tool in tools:
+            name = tool.name
+            description = tool.description
+
+            inputSchema = tool.inputSchema
+
+            # TODO: handle outputSchema if needed
+            outputSchema = getattr(tool, "outputSchema", None)
+
+            tool_specs.append(
+                {"name": name, "description": description, "parameters": inputSchema}
+            )
+
+        return tool_specs
+
+    async def call_tool(
+        self, function_name: str, function_args: dict
+    ) -> Optional[dict]:
+        if not self.session:
+            raise RuntimeError("MCP client is not connected.")
+
+        result = await self.session.call_tool(function_name, function_args)
+        if not result:
+            raise Exception("No result returned from MCP tool call.")
+
+        result_dict = result.model_dump(mode="json")
+        result_content = result_dict.get("content", {})
+
+        if result.isError:
+            raise Exception(result_content)
+        else:
+            return result_content
+
+    async def list_resources(self, cursor: Optional[str] = None) -> Optional[dict]:
+        if not self.session:
+            raise RuntimeError("MCP client is not connected.")
+
+        result = await self.session.list_resources(cursor=cursor)
+        if not result:
+            raise Exception("No result returned from MCP list_resources call.")
+
+        result_dict = result.model_dump()
+        resources = result_dict.get("resources", [])
+
+        return resources
+
+    async def read_resource(self, uri: str) -> Optional[dict]:
+        if not self.session:
+            raise RuntimeError("MCP client is not connected.")
+
+        result = await self.session.read_resource(uri)
+        if not result:
+            raise Exception("No result returned from MCP read_resource call.")
+        result_dict = result.model_dump()
+
+        return result_dict
+
+    async def disconnect(self):
+        # Clean up and close the session
+        await self.exit_stack.aclose()
+
+    async def __aenter__(self):
+        await self.exit_stack.__aenter__()
+        return self
+
+    async def __aexit__(self, exc_type, exc_value, traceback):
+        await self.exit_stack.__aexit__(exc_type, exc_value, traceback)
+        await self.disconnect()

+ 335 - 59
backend/open_webui/utils/middleware.py

@@ -20,6 +20,7 @@ from concurrent.futures import ThreadPoolExecutor
 
 
 from fastapi import Request, HTTPException
+from fastapi.responses import HTMLResponse
 from starlette.responses import Response, StreamingResponse, JSONResponse
 
 
@@ -52,6 +53,11 @@ from open_webui.routers.pipelines import (
 from open_webui.routers.memories import query_memory, QueryMemoryForm
 
 from open_webui.utils.webhook import post_webhook
+from open_webui.utils.files import (
+    get_audio_url_from_base64,
+    get_file_url_from_base64,
+    get_image_url_from_base64,
+)
 
 
 from open_webui.models.users import UserModel
@@ -86,6 +92,7 @@ from open_webui.utils.filter import (
 )
 from open_webui.utils.code_interpreter import execute_code_jupyter
 from open_webui.utils.payload import apply_system_prompt_to_body
+from open_webui.utils.mcp.client import MCPClient
 
 
 from open_webui.config import (
@@ -144,12 +151,14 @@ async def chat_completion_tools_handler(
 
     def get_tools_function_calling_payload(messages, task_model_id, content):
         user_message = get_last_user_message(messages)
-        history = "\n".join(
+
+        recent_messages = messages[-4:] if len(messages) > 4 else messages
+        chat_history = "\n".join(
             f"{message['role'].upper()}: \"\"\"{message['content']}\"\"\""
-            for message in messages[::-1][:4]
+            for message in recent_messages
         )
 
-        prompt = f"History:\n{history}\nQuery: {user_message}"
+        prompt = f"History:\n{chat_history}\nQuery: {user_message}"
 
         return {
             "model": task_model_id,
@@ -369,7 +378,7 @@ async def chat_web_search_handler(
             "type": "status",
             "data": {
                 "action": "web_search",
-                "description": "Generating search query",
+                "description": "Searching the web",
                 "done": False,
             },
         }
@@ -435,8 +444,8 @@ async def chat_web_search_handler(
         {
             "type": "status",
             "data": {
-                "action": "web_search",
-                "description": "Searching the web",
+                "action": "web_search_queries_generated",
+                "queries": queries,
                 "done": False,
             },
         }
@@ -487,6 +496,7 @@ async def chat_web_search_handler(
                         "action": "web_search",
                         "description": "Searched {{count}} sites",
                         "urls": results["filenames"],
+                        "items": results.get("items", []),
                         "done": True,
                     },
                 }
@@ -529,7 +539,7 @@ async def chat_image_generation_handler(
     await __event_emitter__(
         {
             "type": "status",
-            "data": {"description": "Generating an image", "done": False},
+            "data": {"description": "Creating image", "done": False},
         }
     )
 
@@ -581,7 +591,7 @@ async def chat_image_generation_handler(
         await __event_emitter__(
             {
                 "type": "status",
-                "data": {"description": "Generated an image", "done": True},
+                "data": {"description": "Image created", "done": True},
             }
         )
 
@@ -624,8 +634,9 @@ async def chat_image_generation_handler(
 
 
 async def chat_completion_files_handler(
-    request: Request, body: dict, user: UserModel
+    request: Request, body: dict, extra_params: dict, user: UserModel
 ) -> tuple[dict, dict[str, list]]:
+    __event_emitter__ = extra_params["__event_emitter__"]
     sources = []
 
     if files := body.get("metadata", {}).get("files", None):
@@ -661,6 +672,17 @@ async def chat_completion_files_handler(
         if len(queries) == 0:
             queries = [get_last_user_message(body["messages"])]
 
+        await __event_emitter__(
+            {
+                "type": "status",
+                "data": {
+                    "action": "queries_generated",
+                    "queries": queries,
+                    "done": False,
+                },
+            }
+        )
+
         try:
             # Offload get_sources_from_items to a separate thread
             loop = asyncio.get_running_loop()
@@ -697,6 +719,38 @@ async def chat_completion_files_handler(
 
         log.debug(f"rag_contexts:sources: {sources}")
 
+        unique_ids = set()
+
+        for source in sources or []:
+            if not source or len(source.keys()) == 0:
+                continue
+
+            documents = source.get("document") or []
+            metadatas = source.get("metadata") or []
+            src_info = source.get("source") or {}
+
+            for index, _ in enumerate(documents):
+                metadata = metadatas[index] if index < len(metadatas) else None
+                _id = (
+                    (metadata or {}).get("source")
+                    or (src_info or {}).get("id")
+                    or "N/A"
+                )
+                unique_ids.add(_id)
+
+        sources_count = len(unique_ids)
+
+        await __event_emitter__(
+            {
+                "type": "status",
+                "data": {
+                    "action": "sources_retrieved",
+                    "count": sources_count,
+                    "done": True,
+                },
+            }
+        )
+
     return body, {"sources": sources}
 
 
@@ -770,6 +824,16 @@ async def process_chat_payload(request, form_data, user, metadata, model):
     event_emitter = get_event_emitter(metadata)
     event_call = get_event_call(metadata)
 
+    oauth_token = None
+    try:
+        if request.cookies.get("oauth_session_id", None):
+            oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+                user.id,
+                request.cookies.get("oauth_session_id", None),
+            )
+    except Exception as e:
+        log.error(f"Error getting OAuth token: {e}")
+
     extra_params = {
         "__event_emitter__": event_emitter,
         "__event_call__": event_call,
@@ -777,6 +841,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
         "__metadata__": metadata,
         "__request__": request,
         "__model__": model,
+        "__oauth_token__": oauth_token,
     }
 
     # Initialize events to store additional event to be sent to the client
@@ -931,14 +996,91 @@ async def process_chat_payload(request, form_data, user, metadata, model):
     # Server side tools
     tool_ids = metadata.get("tool_ids", None)
     # Client side tools
-    tool_servers = metadata.get("tool_servers", None)
+    direct_tool_servers = metadata.get("tool_servers", None)
 
     log.debug(f"{tool_ids=}")
-    log.debug(f"{tool_servers=}")
+    log.debug(f"{direct_tool_servers=}")
 
     tools_dict = {}
 
+    mcp_clients = []
+    mcp_tools_dict = {}
+
     if tool_ids:
+        for tool_id in tool_ids:
+            if tool_id.startswith("server:mcp:"):
+                try:
+                    server_id = tool_id[len("server:mcp:") :]
+
+                    mcp_server_connection = None
+                    for (
+                        server_connection
+                    ) in request.app.state.config.TOOL_SERVER_CONNECTIONS:
+                        if (
+                            server_connection.get("type", "") == "mcp"
+                            and server_connection.get("info", {}).get("id") == server_id
+                        ):
+                            mcp_server_connection = server_connection
+                            break
+
+                    if not mcp_server_connection:
+                        log.error(f"MCP server with id {server_id} not found")
+                        continue
+
+                    auth_type = mcp_server_connection.get("auth_type", "")
+
+                    headers = {}
+                    if auth_type == "bearer":
+                        headers["Authorization"] = (
+                            f"Bearer {mcp_server_connection.get('key', '')}"
+                        )
+                    elif auth_type == "none":
+                        # No authentication
+                        pass
+                    elif auth_type == "session":
+                        headers["Authorization"] = (
+                            f"Bearer {request.state.token.credentials}"
+                        )
+                    elif auth_type == "system_oauth":
+                        oauth_token = extra_params.get("__oauth_token__", None)
+                        if oauth_token:
+                            headers["Authorization"] = (
+                                f"Bearer {oauth_token.get('access_token', '')}"
+                            )
+
+                    mcp_client = MCPClient()
+                    await mcp_client.connect(
+                        url=mcp_server_connection.get("url", ""),
+                        headers=headers if headers else None,
+                    )
+
+                    tool_specs = await mcp_client.list_tool_specs()
+                    for tool_spec in tool_specs:
+
+                        def make_tool_function(function_name):
+                            async def tool_function(**kwargs):
+                                return await mcp_client.call_tool(
+                                    function_name,
+                                    function_args=kwargs,
+                                )
+
+                            return tool_function
+
+                        tool_function = make_tool_function(tool_spec["name"])
+
+                        mcp_tools_dict[tool_spec["name"]] = {
+                            "spec": tool_spec,
+                            "callable": tool_function,
+                            "type": "mcp",
+                            "client": mcp_client,
+                            "direct": False,
+                        }
+
+                    mcp_clients.append(mcp_client)
+                except Exception as e:
+                    log.debug(e)
+                    continue
+
         tools_dict = await get_tools(
             request,
             tool_ids,
@@ -950,9 +1092,11 @@ async def process_chat_payload(request, form_data, user, metadata, model):
                 "__files__": metadata.get("files", []),
             },
         )
+        if mcp_tools_dict:
+            tools_dict = {**tools_dict, **mcp_tools_dict}
 
-    if tool_servers:
-        for tool_server in tool_servers:
+    if direct_tool_servers:
+        for tool_server in direct_tool_servers:
             tool_specs = tool_server.pop("specs", [])
 
             for tool in tool_specs:
@@ -962,6 +1106,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
                     "server": tool_server,
                 }
 
+    if mcp_clients:
+        metadata["mcp_clients"] = mcp_clients
+
     if tools_dict:
         if metadata.get("params", {}).get("function_calling") == "native":
             # If the function calling is native, then call the tools function calling handler
@@ -970,6 +1117,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
                 {"type": "function", "function": tool.get("spec", {})}
                 for tool in tools_dict.values()
             ]
+
         else:
             # If the function calling is not native, then call the tools function calling handler
             try:
@@ -981,7 +1129,9 @@ async def process_chat_payload(request, form_data, user, metadata, model):
                 log.exception(e)
 
     try:
-        form_data, flags = await chat_completion_files_handler(request, form_data, user)
+        form_data, flags = await chat_completion_files_handler(
+            request, form_data, extra_params, user
+        )
         sources.extend(flags.get("sources", []))
     except Exception as e:
         log.exception(e)
@@ -1073,11 +1223,11 @@ async def process_chat_response(
     request, response, form_data, user, metadata, model, events, tasks
 ):
     async def background_tasks_handler():
-        message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
-        message = message_map.get(metadata["message_id"]) if message_map else None
+        messages_map = Chats.get_messages_map_by_chat_id(metadata["chat_id"])
+        message = messages_map.get(metadata["message_id"]) if messages_map else None
 
         if message:
-            message_list = get_message_list(message_map, metadata["message_id"])
+            message_list = get_message_list(messages_map, metadata["message_id"])
 
             # Remove details tags and files from the messages.
             # as get_message_list creates a new list, it does not affect
@@ -1437,11 +1587,22 @@ async def process_chat_response(
     ):
         return response
 
+    oauth_token = None
+    try:
+        if request.cookies.get("oauth_session_id", None):
+            oauth_token = await request.app.state.oauth_manager.get_oauth_token(
+                user.id,
+                request.cookies.get("oauth_session_id", None),
+            )
+    except Exception as e:
+        log.error(f"Error getting OAuth token: {e}")
+
     extra_params = {
         "__event_emitter__": event_emitter,
         "__event_call__": event_caller,
         "__user__": user.model_dump() if isinstance(user, UserModel) else {},
         "__metadata__": metadata,
+        "__oauth_token__": oauth_token,
         "__request__": request,
         "__model__": model,
     }
@@ -1512,7 +1673,8 @@ async def process_chat_response(
                                         break
 
                                 if tool_result is not None:
-                                    tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}">\n<summary>Tool Executed</summary>\n</details>\n'
+                                    tool_result_embeds = result.get("embeds", "")
+                                    tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="true" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}" result="{html.escape(json.dumps(tool_result, ensure_ascii=False))}" files="{html.escape(json.dumps(tool_result_files)) if tool_result_files else ""}" embeds="{html.escape(json.dumps(tool_result_embeds))}">\n<summary>Tool Executed</summary>\n</details>\n'
                                 else:
                                     tool_calls_display_content = f'{tool_calls_display_content}<details type="tool_calls" done="false" id="{tool_call_id}" name="{tool_name}" arguments="{html.escape(json.dumps(tool_arguments))}">\n<summary>Executing...</summary>\n</details>\n'
 
@@ -1962,6 +2124,20 @@ async def process_chat_response(
                                     )
                                 else:
                                     choices = data.get("choices", [])
+
+                                    # 17421
+                                    usage = data.get("usage", {}) or {}
+                                    usage.update(data.get("timings", {}))  # llama.cpp
+                                    if usage:
+                                        await event_emitter(
+                                            {
+                                                "type": "chat:completion",
+                                                "data": {
+                                                    "usage": usage,
+                                                },
+                                            }
+                                        )
+
                                     if not choices:
                                         error = data.get("error", {})
                                         if error:
@@ -1973,20 +2149,6 @@ async def process_chat_response(
                                                     },
                                                 }
                                             )
-                                        usage = data.get("usage", {})
-                                        usage.update(
-                                            data.get("timing", {})
-                                        )  # llama.cpp
-
-                                        if usage:
-                                            await event_emitter(
-                                                {
-                                                    "type": "chat:completion",
-                                                    "data": {
-                                                        "usage": usage,
-                                                    },
-                                                }
-                                            )
                                         continue
 
                                     delta = choices[0].get("delta", {})
@@ -2259,6 +2421,8 @@ async def process_chat_response(
                     results = []
 
                     for tool_call in response_tool_calls:
+
+                        print("tool_call", tool_call)
                         tool_call_id = tool_call.get("id", "")
                         tool_name = tool_call.get("function", {}).get("name", "")
                         tool_args = tool_call.get("function", {}).get("arguments", "{}")
@@ -2333,14 +2497,133 @@ async def process_chat_response(
                             except Exception as e:
                                 tool_result = str(e)
 
+                        tool_result_embeds = []
+                        if isinstance(tool_result, HTMLResponse):
+                            content_disposition = tool_result.headers.get(
+                                "Content-Disposition", ""
+                            )
+                            if "inline" in content_disposition:
+                                content = tool_result.body.decode("utf-8")
+                                tool_result_embeds.append(content)
+
+                                if 200 <= tool_result.status_code < 300:
+                                    tool_result = {
+                                        "status": "success",
+                                        "code": "ui_component",
+                                        "message": "Embedded UI result is active and visible to the user.",
+                                    }
+                                elif 400 <= tool_result.status_code < 500:
+                                    tool_result = {
+                                        "status": "error",
+                                        "code": "ui_component",
+                                        "message": f"Client error {tool_result.status_code} from embedded UI result.",
+                                    }
+                                elif 500 <= tool_result.status_code < 600:
+                                    tool_result = {
+                                        "status": "error",
+                                        "code": "ui_component",
+                                        "message": f"Server error {tool_result.status_code} from embedded UI result.",
+                                    }
+                                else:
+                                    tool_result = {
+                                        "status": "error",
+                                        "code": "ui_component",
+                                        "message": f"Unexpected status code {tool_result.status_code} from embedded UI result.",
+                                    }
+                            else:
+                                tool_result = tool_result.body.decode("utf-8")
+
+                        elif tool.get("type") == "external" and isinstance(
+                            tool_result, tuple
+                        ):
+                            tool_result, tool_response_headers = tool_result
+
+                            if tool_response_headers:
+                                content_disposition = tool_response_headers.get(
+                                    "Content-Disposition", ""
+                                )
+
+                                if "inline" in content_disposition:
+                                    content_type = tool_response_headers.get(
+                                        "Content-Type", ""
+                                    )
+                                    location = tool_response_headers.get("Location", "")
+
+                                    if "text/html" in content_type:
+                                        # Display as iframe embed
+                                        tool_result_embeds.append(tool_result)
+                                        tool_result = {
+                                            "status": "success",
+                                            "code": "ui_component",
+                                            "message": "Embedded UI result is active and visible to the user.",
+                                        }
+                                    elif location:
+                                        tool_result_embeds.append(location)
+                                        tool_result = {
+                                            "status": "success",
+                                            "code": "ui_component",
+                                            "message": "Embedded UI result is active and visible to the user.",
+                                        }
+
                         tool_result_files = []
                         if isinstance(tool_result, list):
                             for item in tool_result:
                                 # check if string
                                 if isinstance(item, str) and item.startswith("data:"):
-                                    tool_result_files.append(item)
+                                    tool_result_files.append(
+                                        {
+                                            "type": "data",
+                                            "content": item,
+                                        }
+                                    )
                                     tool_result.remove(item)
 
+                                if tool.get("type") == "mcp":
+                                    if isinstance(item, dict):
+                                        if (
+                                            item.get("type") == "image"
+                                            or item.get("type") == "audio"
+                                        ):
+                                            file_url = get_file_url_from_base64(
+                                                request,
+                                                f"data:{item.get('mimeType')};base64,{item.get('data', item.get('blob', ''))}",
+                                                {
+                                                    "chat_id": metadata.get(
+                                                        "chat_id", None
+                                                    ),
+                                                    "message_id": metadata.get(
+                                                        "message_id", None
+                                                    ),
+                                                    "session_id": metadata.get(
+                                                        "session_id", None
+                                                    ),
+                                                    "result": item,
+                                                },
+                                                user,
+                                            )
+
+                                            tool_result_files.append(
+                                                {
+                                                    "type": item.get("type", "data"),
+                                                    "url": file_url,
+                                                }
+                                            )
+                                            tool_result.remove(item)
+
+                        if tool_result_files:
+                            if not isinstance(tool_result, list):
+                                tool_result = [
+                                    tool_result,
+                                ]
+
+                            for file in tool_result_files:
+                                tool_result.append(
+                                    {
+                                        "type": file.get("type", "data"),
+                                        "content": "Result is being displayed as a file.",
+                                    }
+                                )
+
                         if isinstance(tool_result, dict) or isinstance(
                             tool_result, list
                         ):
@@ -2357,6 +2640,11 @@ async def process_chat_response(
                                     if tool_result_files
                                     else {}
                                 ),
+                                **(
+                                    {"embeds": tool_result_embeds}
+                                    if tool_result_embeds
+                                    else {}
+                                ),
                             }
                         )
 
@@ -2502,23 +2790,18 @@ async def process_chat_response(
                                     if isinstance(stdout, str):
                                         stdoutLines = stdout.split("\n")
                                         for idx, line in enumerate(stdoutLines):
+
                                             if "data:image/png;base64" in line:
-                                                image_url = ""
-                                                # Extract base64 image data from the line
-                                                image_data, content_type = (
-                                                    load_b64_image_data(line)
+                                                image_url = get_image_url_from_base64(
+                                                    request,
+                                                    line,
+                                                    metadata,
+                                                    user,
                                                 )
-                                                if image_data is not None:
-                                                    image_url = upload_image(
-                                                        request,
-                                                        image_data,
-                                                        content_type,
-                                                        metadata,
-                                                        user,
+                                                if image_url:
+                                                    stdoutLines[idx] = (
+                                                        f"![Output Image]({image_url})"
                                                     )
-                                                stdoutLines[idx] = (
-                                                    f"![Output Image]({image_url})"
-                                                )
 
                                         output["stdout"] = "\n".join(stdoutLines)
 
@@ -2528,19 +2811,12 @@ async def process_chat_response(
                                         resultLines = result.split("\n")
                                         for idx, line in enumerate(resultLines):
                                             if "data:image/png;base64" in line:
-                                                image_url = ""
-                                                # Extract base64 image data from the line
-                                                image_data, content_type = (
-                                                    load_b64_image_data(line)
+                                                image_url = get_image_url_from_base64(
+                                                    request,
+                                                    line,
+                                                    metadata,
+                                                    user,
                                                 )
-                                                if image_data is not None:
-                                                    image_url = upload_image(
-                                                        request,
-                                                        image_data,
-                                                        content_type,
-                                                        metadata,
-                                                        user,
-                                                    )
                                                 resultLines[idx] = (
                                                     f"![Output Image]({image_url})"
                                                 )

+ 4 - 4
backend/open_webui/utils/misc.py

@@ -26,7 +26,7 @@ def deep_update(d, u):
     return d
 
 
-def get_message_list(messages, message_id):
+def get_message_list(messages_map, message_id):
     """
     Reconstructs a list of messages in order up to the specified message_id.
 
@@ -36,11 +36,11 @@ def get_message_list(messages, message_id):
     """
 
     # Handle case where messages is None
-    if not messages:
+    if not messages_map:
         return []  # Return empty list instead of None to prevent iteration errors
 
     # Find the message by its id
-    current_message = messages.get(message_id)
+    current_message = messages_map.get(message_id)
 
     if not current_message:
         return []  # Return empty list instead of None to prevent iteration errors
@@ -53,7 +53,7 @@ def get_message_list(messages, message_id):
             0, current_message
         )  # Insert the message at the beginning of the list
         parent_id = current_message.get("parentId")  # Use .get() for safety
-        current_message = messages.get(parent_id) if parent_id else None
+        current_message = messages_map.get(parent_id) if parent_id else None
 
     return message_list
 

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

@@ -22,10 +22,11 @@ from open_webui.utils.access_control import has_access
 
 
 from open_webui.config import (
+    BYPASS_ADMIN_ACCESS_CONTROL,
     DEFAULT_ARENA_MODEL,
 )
 
-from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
+from open_webui.env import BYPASS_MODEL_ACCESS_CONTROL, SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL
 from open_webui.models.users import UserModel
 
 
@@ -332,3 +333,40 @@ def check_model_access(user, model):
             )
         ):
             raise Exception("Model not found")
+
+
+def get_filtered_models(models, user):
+    # Filter out models that the user does not have access to
+    if (
+        user.role == "user"
+        or (user.role == "admin" and not BYPASS_ADMIN_ACCESS_CONTROL)
+    ) and not BYPASS_MODEL_ACCESS_CONTROL:
+        filtered_models = []
+        for model in models:
+            if model.get("arena"):
+                if has_access(
+                    user.id,
+                    type="read",
+                    access_control=model.get("info", {})
+                    .get("meta", {})
+                    .get("access_control", {}),
+                ):
+                    filtered_models.append(model)
+                continue
+
+            model_info = Models.get_model_by_id(model["id"])
+            if model_info:
+                if (
+                    (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
+                    or user.id == model_info.user_id
+                    or has_access(
+                        user.id,
+                        type="read",
+                        access_control=model_info.access_control,
+                    )
+                ):
+                    filtered_models.append(model)
+
+        return filtered_models
+    else:
+        return models

+ 412 - 173
backend/open_webui/utils/oauth.py

@@ -4,9 +4,11 @@ import mimetypes
 import sys
 import uuid
 import json
+from datetime import datetime, timedelta
 
 import re
 import fnmatch
+import time
 
 import aiohttp
 from authlib.integrations.starlette_client import OAuth
@@ -17,8 +19,12 @@ from fastapi import (
 )
 from starlette.responses import RedirectResponse
 
+
 from open_webui.models.auths import Auths
+from open_webui.models.oauth_sessions import OAuthSessions
 from open_webui.models.users import Users
+
+
 from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm, GroupForm
 from open_webui.config import (
     DEFAULT_USER_ROLE,
@@ -49,6 +55,7 @@ from open_webui.env import (
     WEBUI_NAME,
     WEBUI_AUTH_COOKIE_SAME_SITE,
     WEBUI_AUTH_COOKIE_SECURE,
+    ENABLE_OAUTH_ID_TOKEN_COOKIE,
 )
 from open_webui.utils.misc import parse_duration
 from open_webui.utils.auth import get_password_hash, create_token
@@ -130,11 +137,187 @@ class OAuthManager:
     def __init__(self, app):
         self.oauth = OAuth()
         self.app = app
+
+        self._clients = {}
         for _, provider_config in OAUTH_PROVIDERS.items():
             provider_config["register"](self.oauth)
 
     def get_client(self, provider_name):
-        return self.oauth.create_client(provider_name)
+        if provider_name not in self._clients:
+            self._clients[provider_name] = self.oauth.create_client(provider_name)
+        return self._clients[provider_name]
+
+    def get_server_metadata_url(self, provider_name):
+        if provider_name in self._clients:
+            client = self._clients[provider_name]
+            return (
+                client.server_metadata_url
+                if hasattr(client, "server_metadata_url")
+                else None
+            )
+        return None
+
+    async def get_oauth_token(
+        self, user_id: str, session_id: str, force_refresh: bool = False
+    ):
+        """
+        Get a valid OAuth token for the user, automatically refreshing if needed.
+
+        Args:
+            user_id: The user ID
+            provider: Optional provider name. If None, gets the most recent session.
+            force_refresh: Force token refresh even if current token appears valid
+
+        Returns:
+            dict: OAuth token data with access_token, or None if no valid token available
+        """
+        try:
+            # Get the OAuth session
+            session = OAuthSessions.get_session_by_id_and_user_id(session_id, user_id)
+            if not session:
+                log.warning(
+                    f"No OAuth session found for user {user_id}, session {session_id}"
+                )
+                return None
+
+            if force_refresh or datetime.now() + timedelta(
+                minutes=5
+            ) >= datetime.fromtimestamp(session.expires_at):
+                log.debug(
+                    f"Token refresh needed for user {user_id}, provider {session.provider}"
+                )
+                refreshed_token = await self._refresh_token(session)
+                if refreshed_token:
+                    return refreshed_token
+                else:
+                    log.warning(
+                        f"Token refresh failed for user {user_id}, provider {session.provider}"
+                    )
+                    return None
+            return session.token
+
+        except Exception as e:
+            log.error(f"Error getting OAuth token for user {user_id}: {e}")
+            return None
+
+    async def _refresh_token(self, session) -> dict:
+        """
+        Refresh an OAuth token if needed, with concurrency protection.
+
+        Args:
+            session: The OAuth session object
+
+        Returns:
+            dict: Refreshed token data, or None if refresh failed
+        """
+        try:
+            # Perform the actual refresh
+            refreshed_token = await self._perform_token_refresh(session)
+
+            if refreshed_token:
+                # Update the session with new token data
+                session = OAuthSessions.update_session_by_id(
+                    session.id, refreshed_token
+                )
+                log.info(f"Successfully refreshed token for session {session.id}")
+                return session.token
+            else:
+                log.error(f"Failed to refresh token for session {session.id}")
+                return None
+
+        except Exception as e:
+            log.error(f"Error refreshing token for session {session.id}: {e}")
+            return None
+
+    async def _perform_token_refresh(self, session) -> dict:
+        """
+        Perform the actual OAuth token refresh.
+
+        Args:
+            session: The OAuth session object
+
+        Returns:
+            dict: New token data, or None if refresh failed
+        """
+        provider = session.provider
+        token_data = session.token
+
+        if not token_data.get("refresh_token"):
+            log.warning(f"No refresh token available for session {session.id}")
+            return None
+
+        try:
+            client = self.get_client(provider)
+            if not client:
+                log.error(f"No OAuth client found for provider {provider}")
+                return None
+
+            token_endpoint = None
+            async with aiohttp.ClientSession(trust_env=True) as session_http:
+                async with session_http.get(client.gserver_metadata_url) as r:
+                    if r.status == 200:
+                        openid_data = await r.json()
+                        token_endpoint = openid_data.get("token_endpoint")
+                    else:
+                        log.error(
+                            f"Failed to fetch OpenID configuration for provider {provider}"
+                        )
+            if not token_endpoint:
+                log.error(f"No token endpoint found for provider {provider}")
+                return None
+
+            # Prepare refresh request
+            refresh_data = {
+                "grant_type": "refresh_token",
+                "refresh_token": token_data["refresh_token"],
+                "client_id": client.client_id,
+            }
+            # Add client_secret if available (some providers require it)
+            if hasattr(client, "client_secret") and client.client_secret:
+                refresh_data["client_secret"] = client.client_secret
+
+            # Make refresh request
+            async with aiohttp.ClientSession(trust_env=True) as session_http:
+                async with session_http.post(
+                    token_endpoint,
+                    data=refresh_data,
+                    headers={"Content-Type": "application/x-www-form-urlencoded"},
+                    ssl=AIOHTTP_CLIENT_SESSION_SSL,
+                ) as r:
+                    if r.status == 200:
+                        new_token_data = await r.json()
+
+                        # Merge with existing token data (preserve refresh_token if not provided)
+                        if "refresh_token" not in new_token_data:
+                            new_token_data["refresh_token"] = token_data[
+                                "refresh_token"
+                            ]
+
+                        # Add timestamp for tracking
+                        new_token_data["issued_at"] = datetime.now().timestamp()
+
+                        # Calculate expires_at if we have expires_in
+                        if (
+                            "expires_in" in new_token_data
+                            and "expires_at" not in new_token_data
+                        ):
+                            new_token_data["expires_at"] = (
+                                datetime.now().timestamp()
+                                + new_token_data["expires_in"]
+                            )
+
+                        log.debug(f"Token refresh successful for provider {provider}")
+                        return new_token_data
+                    else:
+                        error_text = await r.text()
+                        log.error(
+                            f"Token refresh failed for provider {provider}: {r.status} - {error_text}"
+                        )
+                        return None
+
+        except Exception as e:
+            log.error(f"Exception during token refresh for provider {provider}: {e}")
+            return None
 
     def get_user_role(self, user, user_data):
         user_count = Users.get_num_users()
@@ -401,185 +584,211 @@ class OAuthManager:
     async def handle_callback(self, request, provider, response):
         if provider not in OAUTH_PROVIDERS:
             raise HTTPException(404)
-        client = self.get_client(provider)
+
+        error_message = None
         try:
-            token = await client.authorize_access_token(request)
-        except Exception as e:
-            log.warning(f"OAuth callback error: {e}")
-            raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
-        user_data: UserInfo = token.get("userinfo")
-        if (
-            (not user_data)
-            or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
-            or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
-        ):
-            user_data: UserInfo = await client.userinfo(token=token)
-        if not user_data:
-            log.warning(f"OAuth callback failed, user data is missing: {token}")
-            raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+            client = self.get_client(provider)
+            try:
+                token = await client.authorize_access_token(request)
+            except Exception as e:
+                log.warning(f"OAuth callback error: {e}")
+                raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
 
-        if auth_manager_config.OAUTH_SUB_CLAIM:
-            sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
-        else:
-            # Fallback to the default sub claim if not configured
-            sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
-
-        if not sub:
-            log.warning(f"OAuth callback failed, sub is missing: {user_data}")
-            raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
-
-        provider_sub = f"{provider}@{sub}"
-
-        email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
-        email = user_data.get(email_claim, "")
-        # We currently mandate that email addresses are provided
-        if not email:
-            # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
-            if provider == "github":
-                try:
-                    access_token = token.get("access_token")
-                    headers = {"Authorization": f"Bearer {access_token}"}
-                    async with aiohttp.ClientSession(trust_env=True) as session:
-                        async with session.get(
-                            "https://api.github.com/user/emails",
-                            headers=headers,
-                            ssl=AIOHTTP_CLIENT_SESSION_SSL,
-                        ) as resp:
-                            if resp.ok:
-                                emails = await resp.json()
-                                # use the primary email as the user's email
-                                primary_email = next(
-                                    (e["email"] for e in emails if e.get("primary")),
-                                    None,
-                                )
-                                if primary_email:
-                                    email = primary_email
-                                else:
-                                    log.warning(
-                                        "No primary email found in GitHub response"
+            # Try to get userinfo from the token first, some providers include it there
+            user_data: UserInfo = token.get("userinfo")
+            if (
+                (not user_data)
+                or (auth_manager_config.OAUTH_EMAIL_CLAIM not in user_data)
+                or (auth_manager_config.OAUTH_USERNAME_CLAIM not in user_data)
+            ):
+                user_data: UserInfo = await client.userinfo(token=token)
+            if (
+                provider == "feishu"
+                and isinstance(user_data, dict)
+                and "data" in user_data
+            ):
+                user_data = user_data["data"]
+            if not user_data:
+                log.warning(f"OAuth callback failed, user data is missing: {token}")
+                raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
+            # Extract the "sub" claim, using custom claim if configured
+            if auth_manager_config.OAUTH_SUB_CLAIM:
+                sub = user_data.get(auth_manager_config.OAUTH_SUB_CLAIM)
+            else:
+                # Fallback to the default sub claim if not configured
+                sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
+            if not sub:
+                log.warning(f"OAuth callback failed, sub is missing: {user_data}")
+                raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+
+            provider_sub = f"{provider}@{sub}"
+
+            # Email extraction
+            email_claim = auth_manager_config.OAUTH_EMAIL_CLAIM
+            email = user_data.get(email_claim, "")
+            # We currently mandate that email addresses are provided
+            if not email:
+                # If the provider is GitHub,and public email is not provided, we can use the access token to fetch the user's email
+                if provider == "github":
+                    try:
+                        access_token = token.get("access_token")
+                        headers = {"Authorization": f"Bearer {access_token}"}
+                        async with aiohttp.ClientSession(trust_env=True) as session:
+                            async with session.get(
+                                "https://api.github.com/user/emails",
+                                headers=headers,
+                                ssl=AIOHTTP_CLIENT_SESSION_SSL,
+                            ) as resp:
+                                if resp.ok:
+                                    emails = await resp.json()
+                                    # use the primary email as the user's email
+                                    primary_email = next(
+                                        (
+                                            e["email"]
+                                            for e in emails
+                                            if e.get("primary")
+                                        ),
+                                        None,
                                     )
+                                    if primary_email:
+                                        email = primary_email
+                                    else:
+                                        log.warning(
+                                            "No primary email found in GitHub response"
+                                        )
+                                        raise HTTPException(
+                                            400, detail=ERROR_MESSAGES.INVALID_CRED
+                                        )
+                                else:
+                                    log.warning("Failed to fetch GitHub email")
                                     raise HTTPException(
                                         400, detail=ERROR_MESSAGES.INVALID_CRED
                                     )
-                            else:
-                                log.warning("Failed to fetch GitHub email")
-                                raise HTTPException(
-                                    400, detail=ERROR_MESSAGES.INVALID_CRED
-                                )
-                except Exception as e:
-                    log.warning(f"Error fetching GitHub email: {e}")
+                    except Exception as e:
+                        log.warning(f"Error fetching GitHub email: {e}")
+                        raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
+                else:
+                    log.warning(f"OAuth callback failed, email is missing: {user_data}")
                     raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
-            else:
-                log.warning(f"OAuth callback failed, email is missing: {user_data}")
+            email = email.lower()
+
+            # If allowed domains are configured, check if the email domain is in the list
+            if (
+                "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
+                and email.split("@")[-1]
+                not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
+            ):
+                log.warning(
+                    f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
+                )
                 raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
-        email = email.lower()
-        if (
-            "*" not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
-            and email.split("@")[-1] not in auth_manager_config.OAUTH_ALLOWED_DOMAINS
-        ):
-            log.warning(
-                f"OAuth callback failed, e-mail domain is not in the list of allowed domains: {user_data}"
-            )
-            raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
-
-        # Check if the user exists
-        user = Users.get_user_by_oauth_sub(provider_sub)
-
-        if not user:
-            # If the user does not exist, check if merging is enabled
-            if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
-                # Check if the user exists by email
-                user = Users.get_user_by_email(email)
-                if user:
-                    # Update the user with the new oauth sub
-                    Users.update_user_oauth_sub_by_id(user.id, provider_sub)
-
-        if user:
-            determined_role = self.get_user_role(user, user_data)
-            if user.role != determined_role:
-                Users.update_user_role_by_id(user.id, determined_role)
-
-            # Update profile picture if enabled and different from current
-            if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
-                picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
-                if picture_claim:
-                    new_picture_url = user_data.get(
-                        picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
-                    )
-                    processed_picture_url = await self._process_picture_url(
-                        new_picture_url, token.get("access_token")
-                    )
-                    if processed_picture_url != user.profile_image_url:
-                        Users.update_user_profile_image_url_by_id(
-                            user.id, processed_picture_url
+
+            # Check if the user exists
+            user = Users.get_user_by_oauth_sub(provider_sub)
+            if not user:
+                # If the user does not exist, check if merging is enabled
+                if auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL:
+                    # Check if the user exists by email
+                    user = Users.get_user_by_email(email)
+                    if user:
+                        # Update the user with the new oauth sub
+                        Users.update_user_oauth_sub_by_id(user.id, provider_sub)
+
+            if user:
+                determined_role = self.get_user_role(user, user_data)
+                if user.role != determined_role:
+                    Users.update_user_role_by_id(user.id, determined_role)
+                # Update profile picture if enabled and different from current
+                if auth_manager_config.OAUTH_UPDATE_PICTURE_ON_LOGIN:
+                    picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
+                    if picture_claim:
+                        new_picture_url = user_data.get(
+                            picture_claim,
+                            OAUTH_PROVIDERS[provider].get("picture_url", ""),
                         )
-                        log.debug(f"Updated profile picture for user {user.email}")
-
-        if not user:
-            # If the user does not exist, check if signups are enabled
-            if auth_manager_config.ENABLE_OAUTH_SIGNUP:
-                # Check if an existing user with the same email already exists
-                existing_user = Users.get_user_by_email(email)
-                if existing_user:
-                    raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
-
-                picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
-                if picture_claim:
-                    picture_url = user_data.get(
-                        picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
-                    )
-                    picture_url = await self._process_picture_url(
-                        picture_url, token.get("access_token")
+                        processed_picture_url = await self._process_picture_url(
+                            new_picture_url, token.get("access_token")
+                        )
+                        if processed_picture_url != user.profile_image_url:
+                            Users.update_user_profile_image_url_by_id(
+                                user.id, processed_picture_url
+                            )
+                            log.debug(f"Updated profile picture for user {user.email}")
+            else:
+                # If the user does not exist, check if signups are enabled
+                if auth_manager_config.ENABLE_OAUTH_SIGNUP:
+                    # Check if an existing user with the same email already exists
+                    existing_user = Users.get_user_by_email(email)
+                    if existing_user:
+                        raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
+
+                    picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
+                    if picture_claim:
+                        picture_url = user_data.get(
+                            picture_claim,
+                            OAUTH_PROVIDERS[provider].get("picture_url", ""),
+                        )
+                        picture_url = await self._process_picture_url(
+                            picture_url, token.get("access_token")
+                        )
+                    else:
+                        picture_url = "/user.png"
+                    username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
+
+                    name = user_data.get(username_claim)
+                    if not name:
+                        log.warning("Username claim is missing, using email as name")
+                        name = email
+
+                    user = Auths.insert_new_auth(
+                        email=email,
+                        password=get_password_hash(
+                            str(uuid.uuid4())
+                        ),  # Random password, not used
+                        name=name,
+                        profile_image_url=picture_url,
+                        role=self.get_user_role(None, user_data),
+                        oauth_sub=provider_sub,
                     )
-                else:
-                    picture_url = "/user.png"
-
-                username_claim = auth_manager_config.OAUTH_USERNAME_CLAIM
-
-                name = user_data.get(username_claim)
-                if not name:
-                    log.warning("Username claim is missing, using email as name")
-                    name = email
-
-                role = self.get_user_role(None, user_data)
-
-                user = Auths.insert_new_auth(
-                    email=email,
-                    password=get_password_hash(
-                        str(uuid.uuid4())
-                    ),  # Random password, not used
-                    name=name,
-                    profile_image_url=picture_url,
-                    role=role,
-                    oauth_sub=provider_sub,
-                )
 
-                if auth_manager_config.WEBHOOK_URL:
-                    await post_webhook(
-                        WEBUI_NAME,
-                        auth_manager_config.WEBHOOK_URL,
-                        WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
-                        {
-                            "action": "signup",
-                            "message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
-                            "user": user.model_dump_json(exclude_none=True),
-                        },
+                    if auth_manager_config.WEBHOOK_URL:
+                        await post_webhook(
+                            WEBUI_NAME,
+                            auth_manager_config.WEBHOOK_URL,
+                            WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
+                            {
+                                "action": "signup",
+                                "message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
+                                "user": user.model_dump_json(exclude_none=True),
+                            },
+                        )
+                else:
+                    raise HTTPException(
+                        status.HTTP_403_FORBIDDEN,
+                        detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
                     )
-            else:
-                raise HTTPException(
-                    status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
-                )
 
-        jwt_token = create_token(
-            data={"id": user.id},
-            expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
-        )
+            jwt_token = create_token(
+                data={"id": user.id},
+                expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
+            )
+            if (
+                auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT
+                and user.role != "admin"
+            ):
+                self.update_user_groups(
+                    user=user,
+                    user_data=user_data,
+                    default_permissions=request.app.state.config.USER_PERMISSIONS,
+                )
 
-        if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
-            self.update_user_groups(
-                user=user,
-                user_data=user_data,
-                default_permissions=request.app.state.config.USER_PERMISSIONS,
+        except Exception as e:
+            log.error(f"Error during OAuth process: {e}")
+            error_message = (
+                e.detail
+                if isinstance(e, HTTPException) and e.detail
+                else ERROR_MESSAGES.DEFAULT("Error during OAuth process")
             )
 
         redirect_base_url = str(request.app.state.config.WEBUI_URL or request.base_url)
@@ -587,6 +796,10 @@ class OAuthManager:
             redirect_base_url = redirect_base_url[:-1]
         redirect_url = f"{redirect_base_url}/auth"
 
+        if error_message:
+            redirect_url = f"{redirect_url}?error={error_message}"
+            return RedirectResponse(url=redirect_url, headers=response.headers)
+
         response = RedirectResponse(url=redirect_url, headers=response.headers)
 
         # Set the cookie token
@@ -599,22 +812,48 @@ class OAuthManager:
             secure=WEBUI_AUTH_COOKIE_SECURE,
         )
 
-        if ENABLE_OAUTH_SIGNUP.value:
-            oauth_access_token = token.get("access_token")
+        # Legacy cookies for compatibility with older frontend versions
+        if ENABLE_OAUTH_ID_TOKEN_COOKIE:
             response.set_cookie(
-                key="oauth_access_token",
-                value=oauth_access_token,
+                key="oauth_id_token",
+                value=token.get("id_token"),
                 httponly=True,
                 samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
                 secure=WEBUI_AUTH_COOKIE_SECURE,
             )
 
-            oauth_id_token = token.get("id_token")
+        try:
+            # Add timestamp for tracking
+            token["issued_at"] = datetime.now().timestamp()
+
+            # Calculate expires_at if we have expires_in
+            if "expires_in" in token and "expires_at" not in token:
+                token["expires_at"] = datetime.now().timestamp() + token["expires_in"]
+
+            # Clean up any existing sessions for this user/provider first
+            sessions = OAuthSessions.get_sessions_by_user_id(user.id)
+            for session in sessions:
+                if session.provider == provider:
+                    OAuthSessions.delete_session_by_id(session.id)
+
+            session = OAuthSessions.create_session(
+                user_id=user.id,
+                provider=provider,
+                token=token,
+            )
+
             response.set_cookie(
-                key="oauth_id_token",
-                value=oauth_id_token,
+                key="oauth_session_id",
+                value=session.id,
                 httponly=True,
                 samesite=WEBUI_AUTH_COOKIE_SAME_SITE,
                 secure=WEBUI_AUTH_COOKIE_SECURE,
             )
+
+            log.info(
+                f"Stored OAuth session server-side for user {user.id}, provider {provider}"
+            )
+        except Exception as e:
+            log.error(f"Failed to store OAuth session server-side: {e}")
+
         return response

+ 23 - 16
backend/open_webui/utils/telemetry/metrics.py

@@ -163,20 +163,27 @@ def setup_metrics(app: FastAPI, resource: Resource) -> None:
     @app.middleware("http")
     async def _metrics_middleware(request: Request, call_next):
         start_time = time.perf_counter()
-        response = await call_next(request)
-        elapsed_ms = (time.perf_counter() - start_time) * 1000.0
 
-        # Route template e.g. "/items/{item_id}" instead of real path.
-        route = request.scope.get("route")
-        route_path = getattr(route, "path", request.url.path)
-
-        attrs: Dict[str, str | int] = {
-            "http.method": request.method,
-            "http.route": route_path,
-            "http.status_code": response.status_code,
-        }
-
-        request_counter.add(1, attrs)
-        duration_histogram.record(elapsed_ms, attrs)
-
-        return response
+        status_code = None
+        try:
+            response = await call_next(request)
+            status_code = getattr(response, "status_code", 500)
+            return response
+        except Exception:
+            status_code = 500
+            raise
+        finally:
+            elapsed_ms = (time.perf_counter() - start_time) * 1000.0
+
+            # Route template e.g. "/items/{item_id}" instead of real path.
+            route = request.scope.get("route")
+            route_path = getattr(route, "path", request.url.path)
+
+            attrs: Dict[str, str | int] = {
+                "http.method": request.method,
+                "http.route": route_path,
+                "http.status_code": status_code,
+            }
+
+            request_counter.add(1, attrs)
+            duration_histogram.record(elapsed_ms, attrs)

+ 130 - 72
backend/open_webui/utils/tools.py

@@ -96,80 +96,118 @@ async def get_tools(
     for tool_id in tool_ids:
         tool = Tools.get_tool_by_id(tool_id)
         if tool is None:
+
             if tool_id.startswith("server:"):
-                server_id = tool_id.split(":")[1]
+                splits = tool_id.split(":")
+
+                if len(splits) == 2:
+                    type = "openapi"
+                    server_id = splits[1]
+                elif len(splits) == 3:
+                    type = splits[1]
+                    server_id = splits[2]
+
+                server_id_splits = server_id.split("|")
+                if len(server_id_splits) == 2:
+                    server_id = server_id_splits[0]
+                    function_names = server_id_splits[1].split(",")
+
+                if type == "openapi":
+
+                    tool_server_data = None
+                    for server in await get_tool_servers(request):
+                        if server["id"] == server_id:
+                            tool_server_data = server
+                            break
+
+                    if tool_server_data is None:
+                        log.warning(f"Tool server data not found for {server_id}")
+                        continue
+
+                    tool_server_idx = tool_server_data.get("idx", 0)
+                    tool_server_connection = (
+                        request.app.state.config.TOOL_SERVER_CONNECTIONS[
+                            tool_server_idx
+                        ]
+                    )
 
-                tool_server_data = None
-                for server in await get_tool_servers(request):
-                    if server["id"] == server_id:
-                        tool_server_data = server
-                        break
+                    specs = tool_server_data.get("specs", [])
+                    for spec in specs:
+                        function_name = spec["name"]
 
-                if tool_server_data is None:
-                    log.warning(f"Tool server data not found for {server_id}")
-                    continue
+                        auth_type = tool_server_connection.get("auth_type", "bearer")
 
-                tool_server_idx = tool_server_data.get("idx", 0)
-                tool_server_connection = (
-                    request.app.state.config.TOOL_SERVER_CONNECTIONS[tool_server_idx]
-                )
+                        cookies = {}
+                        headers = {}
 
-                specs = tool_server_data.get("specs", [])
-                for spec in specs:
-                    function_name = spec["name"]
+                        if auth_type == "bearer":
+                            headers["Authorization"] = (
+                                f"Bearer {tool_server_connection.get('key', '')}"
+                            )
+                        elif auth_type == "none":
+                            # No authentication
+                            pass
+                        elif auth_type == "session":
+                            cookies = request.cookies
+                            headers["Authorization"] = (
+                                f"Bearer {request.state.token.credentials}"
+                            )
+                        elif auth_type == "system_oauth":
+                            cookies = request.cookies
+                            oauth_token = extra_params.get("__oauth_token__", None)
+                            if oauth_token:
+                                headers["Authorization"] = (
+                                    f"Bearer {oauth_token.get('access_token', '')}"
+                                )
 
-                    auth_type = tool_server_connection.get("auth_type", "bearer")
-                    headers = {}
+                        headers["Content-Type"] = "application/json"
+
+                        def make_tool_function(
+                            function_name, tool_server_data, headers
+                        ):
+                            async def tool_function(**kwargs):
+                                return await execute_tool_server(
+                                    url=tool_server_data["url"],
+                                    headers=headers,
+                                    cookies=cookies,
+                                    name=function_name,
+                                    params=kwargs,
+                                    server_data=tool_server_data,
+                                )
 
-                    if auth_type == "bearer":
-                        headers["Authorization"] = (
-                            f"Bearer {tool_server_connection.get('key', '')}"
-                        )
-                    elif auth_type == "session":
-                        headers["Authorization"] = (
-                            f"Bearer {request.state.token.credentials}"
-                        )
-                    elif auth_type == "request_headers":
-                        headers.update(dict(request.headers))
-
-                    headers["Content-Type"] = "application/json"
-
-                    def make_tool_function(function_name, tool_server_data, headers):
-                        async def tool_function(**kwargs):
-                            return await execute_tool_server(
-                                url=tool_server_data["url"],
-                                headers=headers,
-                                name=function_name,
-                                params=kwargs,
-                                server_data=tool_server_data,
-                            )
+                            return tool_function
 
-                        return tool_function
+                        tool_function = make_tool_function(
+                            function_name, tool_server_data, headers
+                        )
 
-                    tool_function = make_tool_function(
-                        function_name, tool_server_data, headers
-                    )
+                        callable = get_async_tool_function_and_apply_extra_params(
+                            tool_function,
+                            {},
+                        )
 
-                    callable = get_async_tool_function_and_apply_extra_params(
-                        tool_function,
-                        {},
-                    )
+                        tool_dict = {
+                            "tool_id": tool_id,
+                            "callable": callable,
+                            "spec": spec,
+                            # Misc info
+                            "type": "external",
+                        }
+
+                        # Handle function name collisions
+                        while function_name in tools_dict:
+                            log.warning(
+                                f"Tool {function_name} already exists in another tools!"
+                            )
+                            # Prepend server ID to function name
+                            function_name = f"{server_id}_{function_name}"
 
-                    tool_dict = {
-                        "tool_id": tool_id,
-                        "callable": callable,
-                        "spec": spec,
-                    }
+                        tools_dict[function_name] = tool_dict
 
-                    # Handle function name collisions
-                    while function_name in tools_dict:
-                        log.warning(
-                            f"Tool {function_name} already exists in another tools!"
-                        )
-                        # Prepend server ID to function name
-                        function_name = f"{server_id}_{function_name}"
+                else:
+                    log.warning(f"Unsupported tool server type: {type}")
+                    continue
 
-                    tools_dict[function_name] = tool_dict
             else:
                 continue
         else:
@@ -526,12 +564,23 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
                     error_body = await response.json()
                     raise Exception(error_body)
 
+                text_content = None
+
                 # Check if URL ends with .yaml or .yml to determine format
                 if url.lower().endswith((".yaml", ".yml")):
                     text_content = await response.text()
                     res = yaml.safe_load(text_content)
                 else:
-                    res = await response.json()
+                    text_content = await response.text()
+
+                try:
+                    res = json.loads(text_content)
+                except json.JSONDecodeError:
+                    try:
+                        res = yaml.safe_load(text_content)
+                    except Exception as e:
+                        raise e
+
     except Exception as err:
         log.exception(f"Could not fetch tool server spec from {url}")
         if isinstance(err, dict) and "detail" in err:
@@ -550,13 +599,14 @@ async def get_tool_server_data(token: str, url: str) -> Dict[str, Any]:
     return data
 
 
-async def get_tool_servers_data(
-    servers: List[Dict[str, Any]], session_token: Optional[str] = None
-) -> List[Dict[str, Any]]:
+async def get_tool_servers_data(servers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
     # Prepare list of enabled servers along with their original index
     server_entries = []
     for idx, server in enumerate(servers):
-        if server.get("config", {}).get("enable"):
+        if (
+            server.get("config", {}).get("enable")
+            and server.get("type", "openapi") == "openapi"
+        ):
             # Path (to OpenAPI spec URL) can be either a full URL or a path to append to the base URL
             openapi_path = server.get("path", "openapi.json")
             full_url = get_tool_server_url(server.get("url"), openapi_path)
@@ -568,8 +618,9 @@ async def get_tool_servers_data(
 
             if auth_type == "bearer":
                 token = server.get("key", "")
-            elif auth_type == "session":
-                token = session_token
+            elif auth_type == "none":
+                # No authentication
+                pass
 
             id = info.get("id")
             if not id:
@@ -620,10 +671,11 @@ async def get_tool_servers_data(
 async def execute_tool_server(
     url: str,
     headers: Dict[str, str],
+    cookies: Dict[str, str],
     name: str,
     params: Dict[str, Any],
     server_data: Dict[str, Any],
-) -> Any:
+) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]]]:
     error = None
     try:
         openapi = server_data.get("openapi", {})
@@ -693,7 +745,9 @@ async def execute_tool_server(
                     final_url,
                     json=body_params,
                     headers=headers,
+                    cookies=cookies,
                     ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
+                    allow_redirects=False,
                 ) as response:
                     if response.status >= 400:
                         text = await response.text()
@@ -704,12 +758,15 @@ async def execute_tool_server(
                     except Exception:
                         response_data = await response.text()
 
-                    return response_data
+                    response_headers = response.headers
+                    return (response_data, response_headers)
             else:
                 async with request_method(
                     final_url,
                     headers=headers,
+                    cookies=cookies,
                     ssl=AIOHTTP_CLIENT_SESSION_TOOL_SERVER_SSL,
+                    allow_redirects=False,
                 ) as response:
                     if response.status >= 400:
                         text = await response.text()
@@ -720,12 +777,13 @@ async def execute_tool_server(
                     except Exception:
                         response_data = await response.text()
 
-                    return response_data
+                    response_headers = response.headers
+                    return (response_data, response_headers)
 
     except Exception as err:
         error = str(err)
         log.exception(f"API Request Error: {error}")
-        return {"error": error}
+        return ({"error": error}, None)
 
 
 def get_tool_server_url(url: Optional[str], path: str) -> str:

+ 11 - 10
backend/requirements.txt

@@ -2,6 +2,7 @@ fastapi==0.115.7
 uvicorn[standard]==0.35.0
 pydantic==2.11.7
 python-multipart==0.0.20
+itsdangerous==2.2.0
 
 python-socketio==5.13.0
 python-jose==3.4.0
@@ -20,8 +21,8 @@ sqlalchemy==2.0.38
 alembic==1.14.0
 peewee==3.18.1
 peewee-migrate==1.12.2
-psycopg2-binary==2.9.9
-pgvector==0.4.0
+psycopg2-binary==2.9.10
+pgvector==0.4.1
 PyMySQL==1.1.1
 bcrypt==4.3.0
 
@@ -45,18 +46,18 @@ anthropic
 google-genai==1.32.0
 google-generativeai==0.8.5
 tiktoken
+mcp==1.14.1
 
 langchain==0.3.26
-langchain-community==0.3.26
+langchain-community==0.3.27
 
 fake-useragent==2.2.0
-chromadb==0.6.3
-posthog==5.4.0
+chromadb==1.0.20
 pymilvus==2.5.0
 qdrant-client==1.14.3
 opensearch-py==2.8.0
 playwright==1.49.1 # Caution: version must match docker-compose.playwright.yaml
-elasticsearch==9.0.1
+elasticsearch==9.1.0
 pinecone==6.0.2
 oracledb==3.2.0
 
@@ -70,7 +71,7 @@ einops==0.8.1
 
 
 ftfy==6.2.3
-pypdf==4.3.1
+pypdf==6.0.0
 fpdf2==2.8.2
 pymdown-extensions==10.14.2
 docx2txt==0.8
@@ -99,7 +100,7 @@ onnxruntime==1.20.1
 faster-whisper==1.1.1
 
 PyJWT[crypto]==2.10.1
-authlib==1.6.1
+authlib==1.6.3
 
 black==25.1.0
 youtube-transcript-api==1.2.2
@@ -118,10 +119,10 @@ docker~=7.1.0
 pytest~=8.4.1
 pytest-docker~=3.1.1
 
-googleapis-common-protos==1.63.2
+googleapis-common-protos==1.70.0
 google-cloud-storage==2.19.0
 
-azure-identity==1.23.0
+azure-identity==1.25.0
 azure-storage-blob==12.24.1
 
 

+ 51 - 26
package-lock.json

@@ -1,12 +1,12 @@
 {
 	"name": "open-webui",
-	"version": "0.6.26",
+	"version": "0.6.30",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "open-webui",
-			"version": "0.6.26",
+			"version": "0.6.30",
 			"dependencies": {
 				"@azure/msal-browser": "^4.5.0",
 				"@codemirror/lang-javascript": "^6.2.2",
@@ -23,7 +23,7 @@
 				"@tiptap/core": "^3.0.7",
 				"@tiptap/extension-bubble-menu": "^2.26.1",
 				"@tiptap/extension-code-block-lowlight": "^3.0.7",
-				"@tiptap/extension-drag-handle": "^3.0.7",
+				"@tiptap/extension-drag-handle": "^3.4.5",
 				"@tiptap/extension-file-handler": "^3.0.7",
 				"@tiptap/extension-floating-menu": "^2.26.1",
 				"@tiptap/extension-highlight": "^3.3.0",
@@ -37,7 +37,9 @@
 				"@tiptap/extensions": "^3.0.7",
 				"@tiptap/pm": "^3.0.7",
 				"@tiptap/starter-kit": "^3.0.7",
+				"@tiptap/suggestion": "^3.4.2",
 				"@xyflow/svelte": "^0.1.19",
+				"alpinejs": "^3.15.0",
 				"async": "^3.2.5",
 				"bits-ui": "^0.21.15",
 				"chart.js": "^4.5.0",
@@ -3382,9 +3384,9 @@
 			}
 		},
 		"node_modules/@tiptap/extension-collaboration": {
-			"version": "3.0.7",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz",
-			"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==",
+			"version": "3.4.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.4.5.tgz",
+			"integrity": "sha512-JyPXTYkYi2XzUWsmObv2cogMrs7huAvfq6l7d5hAwsU2FnA1vMycaa48N4uekogySP6VBkiQNDf9B4T09AwwqA==",
 			"license": "MIT",
 			"peer": true,
 			"funding": {
@@ -3392,8 +3394,8 @@
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.0.7",
-				"@tiptap/pm": "^3.0.7",
+				"@tiptap/core": "^3.4.5",
+				"@tiptap/pm": "^3.4.5",
 				"@tiptap/y-tiptap": "^3.0.0-beta.3",
 				"yjs": "^13"
 			}
@@ -3412,9 +3414,9 @@
 			}
 		},
 		"node_modules/@tiptap/extension-drag-handle": {
-			"version": "3.0.7",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz",
-			"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==",
+			"version": "3.4.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.4.5.tgz",
+			"integrity": "sha512-177hQ9lMQYJz+SuCg8eA47MB2tn3G3MGBJ5+3PNl5Bs4WQukR9uHpxdR+bH00/LedwxrlNlglMa5Hirrx9odMQ==",
 			"license": "MIT",
 			"dependencies": {
 				"@floating-ui/dom": "^1.6.13"
@@ -3424,10 +3426,10 @@
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.0.7",
-				"@tiptap/extension-collaboration": "^3.0.7",
-				"@tiptap/extension-node-range": "^3.0.7",
-				"@tiptap/pm": "^3.0.7",
+				"@tiptap/core": "^3.4.5",
+				"@tiptap/extension-collaboration": "^3.4.5",
+				"@tiptap/extension-node-range": "^3.4.5",
+				"@tiptap/pm": "^3.4.5",
 				"@tiptap/y-tiptap": "^3.0.0-beta.3"
 			}
 		},
@@ -3641,9 +3643,9 @@
 			}
 		},
 		"node_modules/@tiptap/extension-node-range": {
-			"version": "3.0.7",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz",
-			"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==",
+			"version": "3.4.5",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.4.5.tgz",
+			"integrity": "sha512-mHCjdJZX8DZCpnw9wBqioanANy6tRoy20/OcJxMW1T7naeRCuCU4sFjwO37yb/tmYk1BQA2/L1/H2r0fVoZwtA==",
 			"license": "MIT",
 			"peer": true,
 			"funding": {
@@ -3651,8 +3653,8 @@
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.0.7",
-				"@tiptap/pm": "^3.0.7"
+				"@tiptap/core": "^3.4.5",
+				"@tiptap/pm": "^3.4.5"
 			}
 		},
 		"node_modules/@tiptap/extension-ordered-list": {
@@ -3855,18 +3857,17 @@
 			}
 		},
 		"node_modules/@tiptap/suggestion": {
-			"version": "3.0.9",
-			"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.0.9.tgz",
-			"integrity": "sha512-irthqfUybezo3IwR6AXvyyTOtkzwfvvst58VXZtTnR1nN6NEcrs3TQoY3bGKGbN83bdiquKh6aU2nLnZfAhoXg==",
+			"version": "3.4.2",
+			"resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.4.2.tgz",
+			"integrity": "sha512-sljtfiDtdAsbPOwrXrFGf64D6sXUjeU3Iz5v3TvN7TVJKozkZ/gaMkPRl+WC1CGwC6BnzQVDBEEa1e+aApV0mA==",
 			"license": "MIT",
-			"peer": true,
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^3.0.9",
-				"@tiptap/pm": "^3.0.9"
+				"@tiptap/core": "^3.4.2",
+				"@tiptap/pm": "^3.4.2"
 			}
 		},
 		"node_modules/@tiptap/y-tiptap": {
@@ -4569,6 +4570,21 @@
 				"@types/estree": "^1.0.0"
 			}
 		},
+		"node_modules/@vue/reactivity": {
+			"version": "3.1.5",
+			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+			"integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+			"license": "MIT",
+			"dependencies": {
+				"@vue/shared": "3.1.5"
+			}
+		},
+		"node_modules/@vue/shared": {
+			"version": "3.1.5",
+			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+			"integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
+			"license": "MIT"
+		},
 		"node_modules/@webreflection/fetch": {
 			"version": "0.1.5",
 			"resolved": "https://registry.npmjs.org/@webreflection/fetch/-/fetch-0.1.5.tgz",
@@ -4672,6 +4688,15 @@
 				"url": "https://github.com/sponsors/epoberezkin"
 			}
 		},
+		"node_modules/alpinejs": {
+			"version": "3.15.0",
+			"resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.0.tgz",
+			"integrity": "sha512-lpokA5okCF1BKh10LG8YjqhfpxyHBk4gE7boIgVHltJzYoM7O9nK3M7VlntLEJGsVmu7U/RzUWajmHREGT38Eg==",
+			"license": "MIT",
+			"dependencies": {
+				"@vue/reactivity": "~3.1.1"
+			}
+		},
 		"node_modules/amator": {
 			"version": "1.1.0",
 			"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",

+ 4 - 2
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "open-webui",
-	"version": "0.6.26",
+	"version": "0.6.30",
 	"private": true,
 	"scripts": {
 		"dev": "npm run pyodide:fetch && vite dev --host",
@@ -67,7 +67,7 @@
 		"@tiptap/core": "^3.0.7",
 		"@tiptap/extension-bubble-menu": "^2.26.1",
 		"@tiptap/extension-code-block-lowlight": "^3.0.7",
-		"@tiptap/extension-drag-handle": "^3.0.7",
+		"@tiptap/extension-drag-handle": "^3.4.5",
 		"@tiptap/extension-file-handler": "^3.0.7",
 		"@tiptap/extension-floating-menu": "^2.26.1",
 		"@tiptap/extension-highlight": "^3.3.0",
@@ -81,7 +81,9 @@
 		"@tiptap/extensions": "^3.0.7",
 		"@tiptap/pm": "^3.0.7",
 		"@tiptap/starter-kit": "^3.0.7",
+		"@tiptap/suggestion": "^3.4.2",
 		"@xyflow/svelte": "^0.1.19",
+		"alpinejs": "^3.15.0",
 		"async": "^3.2.5",
 		"bits-ui": "^0.21.15",
 		"chart.js": "^4.5.0",

+ 21 - 17
pyproject.toml

@@ -10,6 +10,7 @@ dependencies = [
     "uvicorn[standard]==0.35.0",
     "pydantic==2.11.7",
     "python-multipart==0.0.20",
+    "itsdangerous==2.2.0",
 
     "python-socketio==5.13.0",
     "python-jose==3.4.0",
@@ -18,7 +19,7 @@ dependencies = [
     "bcrypt==4.3.0",
     "argon2-cffi==23.1.0",
     "PyJWT[crypto]==2.10.1",
-    "authlib==1.6.1",
+    "authlib==1.6.3",
 
     "requests==2.32.4",
     "aiohttp==3.12.15",
@@ -46,33 +47,28 @@ dependencies = [
     "asgiref==3.8.1",
 
     "tiktoken",
+    "mcp==1.14.1",
+
     "openai",
     "anthropic",
     "google-genai==1.32.0",
     "google-generativeai==0.8.5",
 
     "langchain==0.3.26",
-    "langchain-community==0.3.26",
+    "langchain-community==0.3.27",
 
     "fake-useragent==2.2.0",
-    "chromadb==0.6.3",
-    "pymilvus==2.5.0",
-    "qdrant-client==1.14.3",
+    "chromadb==1.0.20",
     "opensearch-py==2.8.0",
-    "playwright==1.49.1",
-    "elasticsearch==9.0.1",
-    "pinecone==6.0.2",
-    "oracledb==3.2.0",
-
+    
     "transformers",
     "sentence-transformers==4.1.0",
     "accelerate",
-    "colbert-ai==0.2.21",
     "pyarrow==20.0.0",
     "einops==0.8.1",
 
     "ftfy==6.2.3",
-    "pypdf==4.3.1",
+    "pypdf==6.0.0",
     "fpdf2==2.8.2",
     "pymdown-extensions==10.14.2",
     "docx2txt==0.8",
@@ -112,10 +108,10 @@ dependencies = [
 
     
 
-    "googleapis-common-protos==1.63.2",
+    "googleapis-common-protos==1.70.0",
     "google-cloud-storage==2.19.0",
 
-    "azure-identity==1.20.0",
+    "azure-identity==1.25.0",
     "azure-storage-blob==12.24.1",
 
     "ldap3==2.9.1",
@@ -124,7 +120,6 @@ dependencies = [
     "tencentcloud-sdk-python==3.0.1336",
     
     "oracledb>=3.2.0",
-    "posthog==5.4.0",
 
 ]
 readme = "README.md"
@@ -142,8 +137,8 @@ classifiers = [
 
 [project.optional-dependencies]
 postgres = [
-    "psycopg2-binary==2.9.9",
-    "pgvector==0.4.0",
+    "psycopg2-binary==2.9.10",
+    "pgvector==0.4.1",
 ]
 
 all = [
@@ -155,6 +150,15 @@ all = [
     "docker~=7.1.0",
     "pytest~=8.3.2",
     "pytest-docker~=3.1.1",
+    "playwright==1.49.1",
+    "elasticsearch==9.1.0",
+
+    "qdrant-client==1.14.3",
+    "pymilvus==2.5.0",
+    "pinecone==6.0.2",
+    "oracledb==3.2.0",
+
+    "colbert-ai==0.2.21",
 ]
 
 [project.scripts]

+ 136 - 11
src/app.css

@@ -70,23 +70,23 @@ textarea::placeholder {
 }
 
 .input-prose {
-	@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
+	@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 whitespace-pre-line;
 }
 
 .input-prose-sm {
-	@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
+	@apply prose dark:prose-invert prose-headings:font-medium prose-h1:text-2xl prose-h2:text-xl prose-h3:text-lg prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-1 prose-img:my-1 prose-headings:my-2 prose-pre:my-0 prose-table:my-1 prose-blockquote:my-0 prose-ul:my-1 prose-ol:my-1 prose-li:my-1 whitespace-pre-line text-sm;
 }
 
 .markdown-prose {
-	@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal  prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
+	@apply prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal  prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
 }
 
 .markdown-prose-sm {
-	@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal  prose-headings:font-semibold prose-hr:my-2  prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
+	@apply text-sm prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal  prose-headings:font-semibold prose-hr:my-2  prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
 }
 
 .markdown-prose-xs {
-	@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal  prose-headings:font-semibold prose-hr:my-0.5  prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
+	@apply text-xs prose dark:prose-invert prose-blockquote:border-s-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-s-2 prose-blockquote:not-italic prose-blockquote:font-normal  prose-headings:font-semibold prose-hr:my-0.5  prose-hr:border-gray-50 prose-hr:dark:border-gray-850 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
 }
 
 .markdown a {
@@ -116,7 +116,7 @@ li p {
 
 ::-webkit-scrollbar-thumb {
 	--tw-border-opacity: 1;
-	background-color: rgba(215, 215, 215, 0.8);
+	background-color: rgba(215, 215, 215, 0.6);
 	border-color: rgba(255, 255, 255, var(--tw-border-opacity));
 	border-radius: 9999px;
 	border-width: 1px;
@@ -124,12 +124,12 @@ li p {
 
 /* Dark theme scrollbar styles */
 .dark ::-webkit-scrollbar-thumb {
-	background-color: rgba(67, 67, 67, 0.8); /* Darker color for dark theme */
+	background-color: rgba(67, 67, 67, 0.6); /* Darker color for dark theme */
 	border-color: rgba(0, 0, 0, var(--tw-border-opacity));
 }
 
 ::-webkit-scrollbar {
-	height: 0.6rem;
+	height: 0.4rem;
 	width: 0.4rem;
 }
 
@@ -409,17 +409,33 @@ input[type='number'] {
 	}
 }
 
-.tiptap .mention {
+.mention {
+	border-radius: 0.4rem;
+	box-decoration-break: clone;
+	padding: 0.1rem 0.3rem;
+	@apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
+}
+
+.mention::after {
+	content: '\200B';
+}
+
+.tiptap .suggestion {
 	border-radius: 0.4rem;
 	box-decoration-break: clone;
 	padding: 0.1rem 0.3rem;
-	@apply text-blue-900 dark:text-blue-100 bg-blue-300/20 dark:bg-blue-500/20;
+	@apply text-sky-800 dark:text-sky-200 bg-sky-300/15 dark:bg-sky-500/15;
 }
 
-.tiptap .mention::after {
+.tiptap .suggestion::after {
 	content: '\200B';
 }
 
+.tiptap .suggestion.is-empty::after {
+	content: '\00A0';
+	border-bottom: 1px dotted rgba(31, 41, 55, 0.12);
+}
+
 .input-prose .tiptap ul[data-type='taskList'] {
 	list-style: none;
 	margin-left: 0;
@@ -645,3 +661,112 @@ body {
 	background: #171717;
 	color: #eee;
 }
+
+/* Position the handle relative to each LI */
+.pm-li--with-handle {
+	position: relative;
+	margin-left: 12px; /* make space for the handle */
+}
+
+.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
+	margin-left: 0px;
+}
+
+/* The drag handle itself */
+.pm-list-drag-handle {
+	position: absolute;
+	left: -36px; /* pull into the left gutter */
+	top: 1px;
+	width: 18px;
+	height: 18px;
+	display: inline-flex;
+	align-items: center;
+	justify-content: center;
+	font-size: 12px;
+	line-height: 1;
+	border-radius: 4px;
+	cursor: grab;
+	user-select: none;
+	opacity: 0.35;
+	transition:
+		opacity 120ms ease,
+		background 120ms ease;
+}
+
+.tiptap ul[data-type='taskList'] .pm-list-drag-handle {
+	left: -16px; /* pull into the left gutter more to avoid the checkbox */
+}
+
+.pm-list-drag-handle:active {
+	cursor: grabbing;
+}
+.pm-li--with-handle:hover > .pm-list-drag-handle {
+	opacity: 1;
+}
+.pm-list-drag-handle:hover {
+	background: rgba(0, 0, 0, 0.06);
+}
+
+:root {
+	--pm-accent: color-mix(in oklab, Highlight 70%, transparent);
+	--pm-fill-target: color-mix(in oklab, Highlight 26%, transparent);
+	--pm-fill-ancestor: color-mix(in oklab, Highlight 16%, transparent);
+}
+
+.pm-li-drop-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+	position: relative;
+}
+
+/* BEFORE/AFTER lines */
+.pm-li-drop-before::before,
+.pm-li-drop-after::after {
+	content: '';
+	position: absolute;
+	left: 0;
+	right: 0;
+	height: 3px;
+	background: var(--pm-accent);
+	pointer-events: none;
+}
+.pm-li-drop-before::before {
+	top: -2px;
+}
+.pm-li-drop-after::after {
+	bottom: -2px;
+}
+
+.pm-li-drop-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+	background: var(--pm-fill-target);
+	border-radius: 6px;
+}
+
+.pm-li-drop-outdent::before {
+	content: '';
+	position: absolute;
+	inset-block: 0;
+	inset-inline-start: 0;
+	width: 3px;
+	background: color-mix(in oklab, Highlight 35%, transparent);
+}
+
+.pm-li--with-handle:has(.pm-li-drop-before),
+.pm-li--with-handle:has(.pm-li-drop-after),
+.pm-li--with-handle:has(.pm-li-drop-into),
+.pm-li--with-handle:has(.pm-li-drop-outdent) {
+	background: var(--pm-fill-ancestor);
+	border-radius: 6px;
+}
+
+.pm-li-drop-before,
+.pm-li-drop-after,
+.pm-li-drop-into,
+.pm-li-drop-outdent {
+	position: relative;
+	z-index: 0;
+}

+ 30 - 17
src/app.html

@@ -2,29 +2,42 @@
 <html lang="en">
 	<head>
 		<meta charset="utf-8" />
-		<link rel="icon" type="image/png" href="/static/favicon.png" />
-		<link rel="icon" type="image/png" href="/static/favicon-96x96.png" sizes="96x96" />
-		<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
-		<link rel="shortcut icon" href="/static/favicon.ico" />
-		<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png" />
-		<meta name="apple-mobile-web-app-title" content="Open WebUI" />
-
-		<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
+		<link rel="icon" type="image/png" href="/static/favicon.png" crossorigin="use-credentials" />
+		<link
+			rel="icon"
+			type="image/png"
+			href="/static/favicon-96x96.png"
+			sizes="96x96"
+			crossorigin="use-credentials"
+		/>
+		<link
+			rel="icon"
+			type="image/svg+xml"
+			href="/static/favicon.svg"
+			crossorigin="use-credentials"
+		/>
+		<link rel="shortcut icon" href="/static/favicon.ico" crossorigin="use-credentials" />
+		<link
+			rel="apple-touch-icon"
+			sizes="180x180"
+			href="/static/apple-touch-icon.png"
+			crossorigin="use-credentials"
+		/>
+		<link
+			rel="manifest"
+			href="/manifest.json"
+			crossorigin="use-credentials"
+			crossorigin="use-credentials"
+		/>
 		<meta
 			name="viewport"
 			content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover"
 		/>
 		<meta name="theme-color" content="#171717" />
 		<meta name="robots" content="noindex,nofollow" />
-		<meta name="description" content="Open WebUI" />
-		<link
-			rel="search"
-			type="application/opensearchdescription+xml"
-			title="Open WebUI"
-			href="/opensearch.xml"
-		/>
-		<script src="/static/loader.js" defer></script>
-		<link rel="stylesheet" href="/static/custom.css" />
+
+		<script src="/static/loader.js" defer crossorigin="use-credentials"></script>
+		<link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" />
 
 		<script>
 			function resizeIframe(obj) {

+ 12 - 1
src/lib/apis/index.ts

@@ -354,8 +354,19 @@ export const getToolServersData = async (servers: object[]) => {
 				.filter((server) => server?.config?.enable)
 				.map(async (server) => {
 					let error = null;
+
+					let toolServerToken = null;
+					const auth_type = server?.auth_type ?? 'bearer';
+					if (auth_type === 'bearer') {
+						toolServerToken = server?.key;
+					} else if (auth_type === 'none') {
+						// No authentication
+					} else if (auth_type === 'session') {
+						toolServerToken = localStorage.token;
+					}
+
 					const data = await getToolServerData(
-						(server?.auth_type ?? 'bearer') === 'bearer' ? server?.key : localStorage.token,
+						toolServerToken,
 						(server?.path ?? '').includes('://')
 							? server?.path
 							: `${server?.url}${(server?.path ?? '').startsWith('/') ? '' : '/'}${server?.path}`

+ 7 - 2
src/lib/apis/notes/index.ts

@@ -91,10 +91,15 @@ export const getNotes = async (token: string = '', raw: boolean = false) => {
 	return grouped;
 };
 
-export const getNoteList = async (token: string = '') => {
+export const getNoteList = async (token: string = '', page: number | null = null) => {
 	let error = null;
+	const searchParams = new URLSearchParams();
 
-	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list`, {
+	if (page !== null) {
+		searchParams.append('page', `${page}`);
+	}
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/notes/list?${searchParams.toString()}`, {
 		method: 'GET',
 		headers: {
 			Accept: 'application/json',

+ 28 - 0
src/lib/apis/users/index.ts

@@ -194,6 +194,34 @@ export const getAllUsers = async (token: string) => {
 	return res;
 };
 
+export const searchUsers = async (token: string, query: string) => {
+	let error = null;
+	let res = null;
+
+	res = await fetch(`${WEBUI_API_BASE_URL}/users/search?query=${encodeURIComponent(query)}`, {
+		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.error(err);
+			error = err.detail;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
 export const getUserSettings = async (token: string) => {
 	let error = null;
 	const res = await fetch(`${WEBUI_API_BASE_URL}/users/user/settings`, {

+ 98 - 47
src/lib/components/AddConnectionModal.svelte

@@ -31,6 +31,7 @@
 
 	let url = '';
 	let key = '';
+	let auth_type = 'bearer';
 
 	let connectionType = 'external';
 	let azure = false;
@@ -74,6 +75,7 @@
 				url,
 				key,
 				config: {
+					auth_type,
 					azure: azure,
 					api_version: apiVersion
 				}
@@ -120,7 +122,7 @@
 				return;
 			}
 
-			if (!key) {
+			if (!key && !['azure_ad', 'microsoft_entra_id'].includes(auth_type)) {
 				loading = false;
 
 				toast.error($i18n.t('Key is required'));
@@ -146,6 +148,7 @@
 				prefix_id: prefixId,
 				model_ids: modelIds,
 				connection_type: connectionType,
+				auth_type,
 				...(!ollama && azure ? { azure: true, api_version: apiVersion } : {})
 			}
 		};
@@ -157,6 +160,7 @@
 
 		url = '';
 		key = '';
+		auth_type = 'bearer';
 		prefixId = '';
 		tags = [];
 		modelIds = [];
@@ -167,6 +171,8 @@
 			url = connection.url;
 			key = connection.key;
 
+			auth_type = connection.config.auth_type ?? 'bearer';
+
 			enable = connection.config?.enable ?? true;
 			tags = connection.config?.tags ?? [];
 			prefixId = connection.config?.prefix_id ?? '';
@@ -305,23 +311,72 @@
 
 						<div class="flex gap-2 mt-2">
 							<div class="flex flex-col w-full">
-								<div
-									class={`mb-0.5 text-xs text-gray-500
-								${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
+								<label
+									for="select-bearer-or-session"
+									class={`text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+									>{$i18n.t('Auth')}</label
 								>
-									{$i18n.t('Key')}
-								</div>
 
-								<div class="flex-1">
-									<SensitiveInput
-										inputClassName={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
-										bind:value={key}
-										placeholder={$i18n.t('API Key')}
-										required={false}
-									/>
+								<div class="flex gap-2">
+									<div class="flex-shrink-0 self-start">
+										<select
+											id="select-bearer-or-session"
+											class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
+											bind:value={auth_type}
+										>
+											<option value="none">{$i18n.t('None')}</option>
+											<option value="bearer">{$i18n.t('Bearer')}</option>
+
+											{#if !ollama}
+												<option value="session">{$i18n.t('Session')}</option>
+												{#if !direct}
+													<option value="system_oauth">{$i18n.t('OAuth')}</option>
+													{#if azure}
+														<option value="microsoft_entra_id">{$i18n.t('Entra ID')}</option>
+													{/if}
+												{/if}
+											{/if}
+										</select>
+									</div>
+
+									<div class="flex flex-1 items-center">
+										{#if auth_type === 'bearer'}
+											<SensitiveInput
+												bind:value={key}
+												placeholder={$i18n.t('API Key')}
+												required={false}
+											/>
+										{:else if auth_type === 'none'}
+											<div
+												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+											>
+												{$i18n.t('No authentication')}
+											</div>
+										{:else if auth_type === 'session'}
+											<div
+												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+											>
+												{$i18n.t('Forwards system user session credentials to authenticate')}
+											</div>
+										{:else if auth_type === 'system_oauth'}
+											<div
+												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+											>
+												{$i18n.t('Forwards system user OAuth access token to authenticate')}
+											</div>
+										{:else if ['azure_ad', 'microsoft_entra_id'].includes(auth_type)}
+											<div
+												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+											>
+												{$i18n.t('Uses DefaultAzureCredential to authenticate')}
+											</div>
+										{/if}
+									</div>
 								</div>
 							</div>
+						</div>
 
+						<div class="flex gap-2 mt-2">
 							<div class="flex flex-col w-full">
 								<label
 									for="prefix-id-input"
@@ -355,7 +410,7 @@
 									for="prefix-id-input"
 									class={`mb-0.5 text-xs text-gray-500
 								${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
-									>{$i18n.t('Provider')}</label
+									>{$i18n.t('Provider Type')}</label
 								>
 
 								<div>
@@ -397,37 +452,7 @@
 							</div>
 						{/if}
 
-						<div class="flex gap-2 mt-2">
-							<div class="flex flex-col w-full">
-								<div
-									class={`mb-0.5 text-xs text-gray-500
-								${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
-								>
-									{$i18n.t('Tags')}
-								</div>
-
-								<div class="flex-1">
-									<Tags
-										bind:tags
-										on:add={(e) => {
-											tags = [
-												...tags,
-												{
-													name: e.detail
-												}
-											];
-										}}
-										on:delete={(e) => {
-											tags = tags.filter((tag) => tag.name !== e.detail);
-										}}
-									/>
-								</div>
-							</div>
-						</div>
-
-						<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
-
-						<div class="flex flex-col w-full">
+						<div class="flex flex-col w-full mt-2">
 							<div class="mb-1 flex justify-between">
 								<div
 									class={`mb-0.5 text-xs text-gray-500
@@ -483,8 +508,6 @@
 							{/if}
 						</div>
 
-						<hr class=" border-gray-100 dark:border-gray-700/10 my-1.5 w-full" />
-
 						<div class="flex items-center">
 							<label class="sr-only" for="add-model-id-input">{$i18n.t('Add a model ID')}</label>
 							<input
@@ -512,6 +535,34 @@
 						</div>
 					</div>
 
+					<div class="flex gap-2 mt-2">
+						<div class="flex flex-col w-full">
+							<div
+								class={`mb-0.5 text-xs text-gray-500
+								${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : ''}`}
+							>
+								{$i18n.t('Tags')}
+							</div>
+
+							<div class="flex-1 mt-0.5">
+								<Tags
+									bind:tags
+									on:add={(e) => {
+										tags = [
+											...tags,
+											{
+												name: e.detail
+											}
+										];
+									}}
+									on:delete={(e) => {
+										tags = tags.filter((tag) => tag.name !== e.detail);
+									}}
+								/>
+							</div>
+						</div>
+					</div>
+
 					<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
 						{#if edit}
 							<button

+ 2 - 3
src/lib/components/AddFilesPlaceholder.svelte

@@ -7,8 +7,7 @@
 </script>
 
 <div class="px-3">
-	<div class="text-center text-6xl mb-3">📄</div>
-	<div class="text-center dark:text-white text-xl font-semibold z-50">
+	<div class="text-center dark:text-white text-2xl font-medium z-50">
 		{#if title}
 			{title}
 		{:else}
@@ -17,7 +16,7 @@
 	</div>
 
 	<slot
-		><div class="px-2 mt-2 text-center text-sm dark:text-gray-200 w-full">
+		><div class="px-2 mt-2 text-center text-gray-700 dark:text-gray-200 w-full">
 			{#if content}
 				{content}
 			{:else}

+ 103 - 28
src/lib/components/AddServerModal.svelte → src/lib/components/AddToolServerModal.svelte

@@ -30,6 +30,8 @@
 	let url = '';
 	let path = 'openapi.json';
 
+	let type = 'openapi'; // 'openapi', 'mcp'
+
 	let auth_type = 'bearer';
 	let key = '';
 
@@ -70,6 +72,7 @@
 			const res = await verifyToolServerConnection(localStorage.token, {
 				url,
 				path,
+				type,
 				auth_type,
 				key,
 				config: {
@@ -97,10 +100,16 @@
 
 		// remove trailing slash from url
 		url = url.replace(/\/$/, '');
+		if (id.includes(':') || id.includes('|')) {
+			toast.error($i18n.t('ID cannot contain ":" or "|" characters'));
+			loading = false;
+			return;
+		}
 
 		const connection = {
 			url,
 			path,
+			type,
 			auth_type,
 			key,
 			config: {
@@ -119,8 +128,11 @@
 		loading = false;
 		show = false;
 
+		// reset form
 		url = '';
 		path = 'openapi.json';
+		type = 'openapi';
+
 		key = '';
 		auth_type = 'bearer';
 
@@ -137,6 +149,7 @@
 			url = connection.url;
 			path = connection?.path ?? 'openapi.json';
 
+			type = connection?.type ?? 'openapi';
 			auth_type = connection?.auth_type ?? 'bearer';
 			key = connection?.key ?? '';
 
@@ -189,6 +202,50 @@
 					}}
 				>
 					<div class="px-1">
+						{#if !direct}
+							<div class="flex gap-2 mb-1.5">
+								<div class="flex w-full justify-between items-center">
+									<div class=" text-xs text-gray-500">{$i18n.t('Type')}</div>
+
+									<div class="">
+										<button
+											on:click={() => {
+												type = ['', 'openapi'].includes(type) ? 'mcp' : 'openapi';
+											}}
+											type="button"
+											class=" text-xs text-gray-700 dark:text-gray-300"
+										>
+											{#if ['', 'openapi'].includes(type)}
+												{$i18n.t('OpenAPI')}
+											{:else if type === 'mcp'}
+												{$i18n.t('MCP')}
+												<span class="text-gray-500">{$i18n.t('Streamable HTTP')}</span>
+											{/if}
+										</button>
+									</div>
+								</div>
+							</div>
+						{/if}
+
+						{#if type === 'mcp'}
+							<div
+								class=" bg-yellow-500/20 text-yellow-700 dark:text-yellow-200 rounded-2xl text-xs px-4 py-3 mb-2"
+							>
+								<span class="font-medium">
+									{$i18n.t('Warning')}:
+								</span>
+								{$i18n.t(
+									'MCP support is experimental and its specification changes often, which can lead to incompatibilities. OpenAPI specification support is directly maintained by the Open WebUI team, making it the more reliable option for compatibility.'
+								)}
+
+								<a
+									class="font-medium underline"
+									href="https://docs.openwebui.com/features/mcp"
+									target="_blank">{$i18n.t('Read more →')}</a
+								>
+							</div>
+						{/if}
+
 						<div class="flex gap-2">
 							<div class="flex flex-col w-full">
 								<div class="flex justify-between mb-0.5">
@@ -243,30 +300,36 @@
 									</Tooltip>
 								</div>
 
-								<div class="flex-1 flex items-center">
-									<label for="url-or-path" class="sr-only"
-										>{$i18n.t('openapi.json URL or Path')}</label
-									>
-									<input
-										class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
-										type="text"
-										id="url-or-path"
-										bind:value={path}
-										placeholder={$i18n.t('openapi.json URL or Path')}
-										autocomplete="off"
-										required
-									/>
-								</div>
+								{#if ['', 'openapi'].includes(type)}
+									<div class="flex-1 flex items-center">
+										<label for="url-or-path" class="sr-only"
+											>{$i18n.t('openapi.json URL or Path')}</label
+										>
+										<input
+											class={`w-full text-sm bg-transparent ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
+											type="text"
+											id="url-or-path"
+											bind:value={path}
+											placeholder={$i18n.t('openapi.json URL or Path')}
+											autocomplete="off"
+											required
+										/>
+									</div>
+								{/if}
 							</div>
 						</div>
 
-						<div
-							class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
-						>
-							{$i18n.t(`WebUI will make requests to "{{url}}"`, {
-								url: path.includes('://') ? path : `${url}${path.startsWith('/') ? '' : '/'}${path}`
-							})}
-						</div>
+						{#if ['', 'openapi'].includes(type)}
+							<div
+								class={`text-xs mt-1 ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+							>
+								{$i18n.t(`WebUI will make requests to "{{url}}"`, {
+									url: path.includes('://')
+										? path
+										: `${url}${path.startsWith('/') ? '' : '/'}${path}`
+								})}
+							</div>
+						{/if}
 
 						<div class="flex gap-2 mt-2">
 							<div class="flex flex-col w-full">
@@ -283,11 +346,13 @@
 											class={`w-full text-sm bg-transparent pr-5 ${($settings?.highContrastMode ?? false) ? 'placeholder:text-gray-700 dark:placeholder:text-gray-100' : 'outline-hidden placeholder:text-gray-300 dark:placeholder:text-gray-700'}`}
 											bind:value={auth_type}
 										>
+											<option value="none">{$i18n.t('None')}</option>
+
 											<option value="bearer">{$i18n.t('Bearer')}</option>
 											<option value="session">{$i18n.t('Session')}</option>
 
 											{#if !direct}
-												<option value="request_headers">{$i18n.t('Request Headers')}</option>
+												<option value="system_oauth">{$i18n.t('OAuth')}</option>
 											{/if}
 										</select>
 									</div>
@@ -299,17 +364,23 @@
 												placeholder={$i18n.t('API Key')}
 												required={false}
 											/>
+										{:else if auth_type === 'none'}
+											<div
+												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
+											>
+												{$i18n.t('No authentication')}
+											</div>
 										{:else if auth_type === 'session'}
 											<div
 												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
 											>
 												{$i18n.t('Forwards system user session credentials to authenticate')}
 											</div>
-										{:else if auth_type === 'request_headers'}
+										{:else if auth_type === 'system_oauth'}
 											<div
 												class={`text-xs self-center translate-y-[1px] ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
 											>
-												{$i18n.t('Forwards system user headers to authenticate')}
+												{$i18n.t('Forwards system user OAuth access token to authenticate')}
 											</div>
 										{/if}
 									</div>
@@ -326,9 +397,12 @@
 										for="enter-id"
 										class={`mb-0.5 text-xs ${($settings?.highContrastMode ?? false) ? 'text-gray-800 dark:text-gray-100' : 'text-gray-500'}`}
 										>{$i18n.t('ID')}
-										<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
-											>{$i18n.t('Optional')}</span
-										>
+
+										{#if type !== 'mcp'}
+											<span class="text-xs text-gray-200 dark:text-gray-800 ml-0.5"
+												>{$i18n.t('Optional')}</span
+											>
+										{/if}
 									</label>
 
 									<div class="flex-1">
@@ -339,6 +413,7 @@
 											bind:value={id}
 											placeholder={$i18n.t('Enter ID')}
 											autocomplete="off"
+											required={type === 'mcp'}
 										/>
 									</div>
 								</div>
@@ -388,7 +463,7 @@
 							<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
 
 							<div class="my-2 -mx-2">
-								<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
+								<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
 									<AccessControl bind:accessControl />
 								</div>
 							</div>

+ 25 - 23
src/lib/components/ChangelogModal.svelte

@@ -1,4 +1,6 @@
 <script lang="ts">
+	import DOMPurify from 'dompurify';
+
 	import { onMount, getContext } from 'svelte';
 	import { Confetti } from 'svelte-confetti';
 
@@ -17,16 +19,19 @@
 
 	let changelog = null;
 
-	onMount(async () => {
-		const res = await getChangelog();
-		changelog = res;
-	});
+	const init = async () => {
+		changelog = await getChangelog();
+	};
+
+	$: if (show) {
+		init();
+	}
 </script>
 
-<Modal bind:show size="lg">
-	<div class="px-5 pt-4 dark:text-gray-300 text-gray-700">
+<Modal bind:show size="xl">
+	<div class="px-6 pt-5 dark:text-white text-black">
 		<div class="flex justify-between items-start">
-			<div class="text-xl font-semibold">
+			<div class="text-xl font-medium">
 				{$i18n.t("What's New in")}
 				{$WEBUI_NAME}
 				<Confetti x={[-1, -0.25]} y={[0, 0.5]} />
@@ -46,7 +51,7 @@
 		</div>
 		<div class="flex items-center mt-1">
 			<div class="text-sm dark:text-gray-200">{$i18n.t('Release Notes')}</div>
-			<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-200 dark:bg-gray-700" />
+			<div class="flex self-center w-[1px] h-6 mx-2.5 bg-gray-50/50 dark:bg-gray-850/50" />
 			<div class="text-sm dark:text-gray-200">
 				v{WEBUI_VERSION}
 			</div>
@@ -54,7 +59,7 @@
 	</div>
 
 	<div class=" w-full p-4 px-5 text-gray-700 dark:text-gray-100">
-		<div class=" overflow-y-scroll max-h-96 scrollbar-hidden">
+		<div class=" overflow-y-scroll max-h-[30rem] scrollbar-hidden">
 			<div class="mb-3">
 				{#if changelog}
 					{#each Object.keys(changelog) as version}
@@ -63,31 +68,28 @@
 								v{version} - {changelog[version].date}
 							</div>
 
-							<hr class="border-gray-100 dark:border-gray-850 my-2" />
+							<hr class="border-gray-50/50 dark:border-gray-850/50 my-2" />
 
 							{#each Object.keys(changelog[version]).filter((section) => section !== 'date') as section}
-								<div class="">
+								<div class="w-full">
 									<div
 										class="font-semibold uppercase text-xs {section === 'added'
-											? 'text-white bg-blue-600'
+											? 'bg-blue-500/20 text-blue-700 dark:text-blue-200'
 											: section === 'fixed'
-												? 'text-white bg-green-600'
+												? 'bg-green-500/20 text-green-700 dark:text-green-200'
 												: section === 'changed'
-													? 'text-white bg-yellow-600'
+													? 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200'
 													: section === 'removed'
-														? 'text-white bg-red-600'
-														: ''}  w-fit px-3 rounded-full my-2.5"
+														? 'bg-red-500/20 text-red-700 dark:text-red-200'
+														: ''}  w-fit rounded-xl px-2 my-2.5"
 									>
 										{section}
 									</div>
 
-									<div class="my-2.5 px-1.5">
-										{#each Object.keys(changelog[version][section]) as item}
-											<div class="text-sm mb-2">
-												<div class="font-semibold uppercase">
-													{changelog[version][section][item].title}
-												</div>
-												<div class="mb-2 mt-1">{changelog[version][section][item].content}</div>
+									<div class="my-2.5 px-1.5 markdown-prose-sm !list-none !w-full !max-w-none">
+										{#each changelog[version][section] as entry}
+											<div class="my-2">
+												{@html DOMPurify.sanitize(entry?.raw)}
 											</div>
 										{/each}
 									</div>

+ 54 - 8
src/lib/components/NotificationToast.svelte

@@ -12,6 +12,43 @@
 	export let title: string = 'HI';
 	export let content: string;
 
+	let startX = 0,
+		startY = 0;
+	let moved = false;
+	const DRAG_THRESHOLD_PX = 6;
+
+	const clickHandler = () => {
+		onClick();
+		dispatch('closeToast');
+	};
+
+	function onPointerDown(e: PointerEvent) {
+		startX = e.clientX;
+		startY = e.clientY;
+		moved = false;
+		// Ensure we continue to get events even if the toast moves under the pointer.
+		(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
+	}
+
+	function onPointerMove(e: PointerEvent) {
+		if (moved) return;
+		const dx = e.clientX - startX;
+		const dy = e.clientY - startY;
+		if (dx * dx + dy * dy > DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX) {
+			moved = true;
+		}
+	}
+
+	function onPointerUp(e: PointerEvent) {
+		// Release capture if taken
+		(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId);
+
+		// Only treat as a click if there wasn't a drag
+		if (!moved) {
+			clickHandler();
+		}
+	}
+
 	onMount(() => {
 		if (!navigator.userActivation.hasBeenActive) {
 			return;
@@ -31,24 +68,33 @@
 	});
 </script>
 
-<button
-	class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-850 rounded-xl px-3.5 py-3.5"
-	on:click={() => {
-		onClick();
-		dispatch('closeToast');
+<!-- svelte-ignore a11y-click-events-have-key-events -->
+<!-- svelte-ignore a11y-no-static-element-interactions -->
+<div
+	class="flex gap-2.5 text-left min-w-[var(--width)] w-full dark:bg-gray-850 dark:text-white bg-white text-black border border-gray-100 dark:border-gray-800 rounded-3xl px-4 py-3.5 cursor-pointer select-none"
+	on:dragstart|preventDefault
+	on:pointerdown={onPointerDown}
+	on:pointermove={onPointerMove}
+	on:pointerup={onPointerUp}
+	on:pointercancel={() => (moved = true)}
+	on:keydown={(e) => {
+		if (e.key === 'Enter' || e.key === ' ') {
+			e.preventDefault();
+			clickHandler();
+		}
 	}}
 >
 	<div class="shrink-0 self-top -translate-y-0.5">
-		<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-7 rounded-full" />
+		<img src="{WEBUI_BASE_URL}/static/favicon.png" alt="favicon" class="size-6 rounded-full" />
 	</div>
 
 	<div>
 		{#if title}
-			<div class=" text-[13px] font-medium mb-0.5 line-clamp-1 capitalize">{title}</div>
+			<div class=" text-[13px] font-medium mb-0.5 line-clamp-1">{title}</div>
 		{/if}
 
 		<div class=" line-clamp-2 text-xs self-center dark:text-gray-300 font-normal">
 			{@html DOMPurify.sanitize(marked(content))}
 		</div>
 	</div>
-</button>
+</div>

+ 2 - 2
src/lib/components/admin/Evaluations.svelte

@@ -56,7 +56,7 @@
 	<div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
 		<div
 			id="users-tabs-container"
-			class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
+			class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
 		>
 			<button
 				id="leaderboard"
@@ -113,7 +113,7 @@
 			</button>
 		</div>
 
-		<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
+		<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
 			{#if selectedTab === 'leaderboard'}
 				<Leaderboard {feedbacks} />
 			{:else if selectedTab === 'feedbacks'}

+ 2 - 2
src/lib/components/admin/Evaluations/FeedbackMenu.svelte

@@ -13,7 +13,7 @@
 	import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
 	import Pencil from '$lib/components/icons/Pencil.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
-	import Download from '$lib/components/icons/ArrowDownTray.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
 
 	let show = false;
 </script>
@@ -25,7 +25,7 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[150px] rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
+			class="w-full max-w-[150px] rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
 			sideOffset={-2}
 			side="bottom"
 			align="start"

+ 12 - 18
src/lib/components/admin/Evaluations/Feedbacks.svelte

@@ -13,7 +13,7 @@
 	import { deleteFeedbackById, exportAllFeedbacks, getAllFeedbacks } from '$lib/apis/evaluations';
 
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
-	import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
 	import Badge from '$lib/components/common/Badge.svelte';
 	import CloudArrowUp from '$lib/components/icons/CloudArrowUp.svelte';
 	import Pagination from '$lib/components/common/Pagination.svelte';
@@ -169,7 +169,7 @@
 
 <FeedbackModal bind:show={showFeedbackModal} {selectedFeedback} onClose={closeFeedbackModal} />
 
-<div class="mt-0.5 mb-2 gap-1 flex flex-row justify-between">
+<div class="mt-0.5 mb-1 gap-1 flex flex-row justify-between">
 	<div class="flex md:self-center text-lg font-medium px-0.5">
 		{$i18n.t('Feedback History')}
 
@@ -187,31 +187,25 @@
 						exportHandler();
 					}}
 				>
-					<ArrowDownTray className="size-3" />
+					<Download className="size-3" />
 				</button>
 			</Tooltip>
 		</div>
 	{/if}
 </div>
 
-<div
-	class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
->
+<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
 	{#if (feedbacks ?? []).length === 0}
 		<div class="text-center text-xs text-gray-500 dark:text-gray-400 py-1">
 			{$i18n.t('No feedbacks found')}
 		</div>
 	{:else}
-		<table
-			class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
-		>
-			<thead
-				class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
-			>
-				<tr class="">
+		<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
+			<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
+				<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none w-3"
+						class="px-2.5 py-2 cursor-pointer select-none w-3"
 						on:click={() => setSortKey('user')}
 					>
 						<div class="flex gap-1.5 items-center justify-end">
@@ -234,7 +228,7 @@
 
 					<th
 						scope="col"
-						class="px-3 pr-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('model_id')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -257,7 +251,7 @@
 
 					<th
 						scope="col"
-						class="px-3 py-1.5 text-right cursor-pointer select-none w-fit"
+						class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
 						on:click={() => setSortKey('rating')}
 					>
 						<div class="flex gap-1.5 items-center justify-end">
@@ -280,7 +274,7 @@
 
 					<th
 						scope="col"
-						class="px-3 py-1.5 text-right cursor-pointer select-none w-0"
+						class="px-2.5 py-2 text-right cursor-pointer select-none w-0"
 						on:click={() => setSortKey('updated_at')}
 					>
 						<div class="flex gap-1.5 items-center justify-end">
@@ -301,7 +295,7 @@
 						</div>
 					</th>
 
-					<th scope="col" class="px-3 py-1.5 text-right cursor-pointer select-none w-0"> </th>
+					<th scope="col" class="px-2.5 py-2 text-right cursor-pointer select-none w-0"> </th>
 				</tr>
 			</thead>
 			<tbody class="">

+ 15 - 19
src/lib/components/admin/Evaluations/Leaderboard.svelte

@@ -1,9 +1,4 @@
 <script lang="ts">
-	import * as ort from 'onnxruntime-web';
-	import { env, AutoModel, AutoTokenizer } from '@huggingface/transformers';
-
-	env.backends.onnx.wasm.wasmPaths = '/wasm/';
-
 	import { onMount, getContext } from 'svelte';
 	import { models } from '$lib/stores';
 
@@ -237,6 +232,11 @@
 	//////////////////////
 
 	const loadEmbeddingModel = async () => {
+		const { env, AutoModel, AutoTokenizer } = await import('@huggingface/transformers');
+		if (env.backends.onnx.wasm) {
+			env.backends.onnx.wasm.wasmPaths = '/wasm/';
+		}
+
 		// Check if the tokenizer and model are already loaded and stored in the window object
 		if (!window.tokenizer) {
 			window.tokenizer = await AutoTokenizer.from_pretrained(EMBEDDING_MODEL);
@@ -337,7 +337,7 @@
 />
 
 <div
-	class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
+	class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
 >
 	<div class="flex md:self-center text-lg font-medium px-0.5 shrink-0 items-center">
 		<div class=" gap-1">
@@ -370,9 +370,7 @@
 	</div>
 </div>
 
-<div
-	class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
->
+<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm">
 	{#if loadingLeaderboard}
 		<div class=" absolute top-0 bottom-0 left-0 right-0 flex">
 			<div class="m-auto">
@@ -386,17 +384,15 @@
 		</div>
 	{:else}
 		<table
-			class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded {loadingLeaderboard
+			class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full {loadingLeaderboard
 				? 'opacity-20'
 				: ''}"
 		>
-			<thead
-				class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
-			>
-				<tr class="">
+			<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
+				<tr class=" border-b-2 border-gray-100 dark:border-gray-800">
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none w-3"
+						class="px-2.5 py-2 cursor-pointer select-none w-3"
 						on:click={() => setSortKey('rating')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -418,7 +414,7 @@
 					</th>
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('name')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -440,7 +436,7 @@
 					</th>
 					<th
 						scope="col"
-						class="px-3 py-1.5 text-right cursor-pointer select-none w-fit"
+						class="px-2.5 py-2 text-right cursor-pointer select-none w-fit"
 						on:click={() => setSortKey('rating')}
 					>
 						<div class="flex gap-1.5 items-center justify-end">
@@ -462,7 +458,7 @@
 					</th>
 					<th
 						scope="col"
-						class="px-3 py-1.5 text-right cursor-pointer select-none w-5"
+						class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
 						on:click={() => setSortKey('won')}
 					>
 						<div class="flex gap-1.5 items-center justify-end">
@@ -484,7 +480,7 @@
 					</th>
 					<th
 						scope="col"
-						class="px-3 py-1.5 text-right cursor-pointer select-none w-5"
+						class="px-2.5 py-2 text-right cursor-pointer select-none w-5"
 						on:click={() => setSortKey('lost')}
 					>
 						<div class="flex gap-1.5 items-center justify-end">

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

@@ -18,7 +18,7 @@
 		toggleGlobalById
 	} from '$lib/apis/functions';
 
-	import ArrowDownTray from '../icons/ArrowDownTray.svelte';
+	import Download from '../icons/Download.svelte';
 	import Tooltip from '../common/Tooltip.svelte';
 	import ConfirmDialog from '../common/ConfirmDialog.svelte';
 	import { getModels } from '$lib/apis';
@@ -222,7 +222,7 @@
 	}}
 />
 
-<div class="flex flex-col mt-1.5 mb-0.5">
+<div class="flex flex-col mt-1.5 mb-0.5 px-[16px]">
 	<div class="flex justify-between items-center mb-1">
 		<div class="flex md:self-center text-xl items-center font-medium px-0.5">
 			{$i18n.t('Functions')}
@@ -317,7 +317,7 @@
 	</div>
 </div>
 
-<div class="mb-5">
+<div class="mb-5 px-[16px]">
 	{#each filteredItems as func (func.id)}
 		<div
 			class=" flex space-x-4 cursor-pointer w-full px-2 py-2 dark:hover:bg-white/5 hover:bg-black/5 rounded-xl"
@@ -330,14 +330,14 @@
 					<div class=" flex-1 self-center pl-1">
 						<div class=" font-semibold flex items-center gap-1.5">
 							<div
-								class=" text-xs font-bold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
+								class=" text-xs font-semibold px-1 rounded-sm uppercase line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
 							>
 								{func.type}
 							</div>
 
 							{#if func?.meta?.manifest?.version}
 								<div
-									class="text-xs font-bold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
+									class="text-xs font-semibold px-1 rounded-sm line-clamp-1 bg-gray-500/20 text-gray-700 dark:text-gray-200"
 								>
 									v{func?.meta?.manifest?.version ?? ''}
 								</div>
@@ -482,7 +482,7 @@
 	)}
 </div> -->
 
-<div class=" flex justify-end w-full mb-2">
+<div class=" flex justify-end w-full mb-2 px-[16px]">
 	<div class="flex space-x-2">
 		<input
 			id="documents-import-input"
@@ -562,7 +562,7 @@
 </div>
 
 {#if $config?.features.enable_community_sharing}
-	<div class=" my-16">
+	<div class=" my-16 px-[16px]">
 		<div class=" text-xl font-medium mb-1 line-clamp-1">
 			{$i18n.t('Made by Open WebUI Community')}
 		</div>

+ 2 - 2
src/lib/components/admin/Functions/AddFunctionMenu.svelte

@@ -8,7 +8,7 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import Share from '$lib/components/icons/Share.svelte';
 	import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
-	import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
 	import Switch from '$lib/components/common/Switch.svelte';
 	import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
 	import Github from '$lib/components/icons/Github.svelte';
@@ -41,7 +41,7 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[190px] text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
+			class="w-full max-w-[190px] text-sm rounded-xl p-1 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
 			sideOffset={-2}
 			side="bottom"
 			align="start"

+ 16 - 15
src/lib/components/admin/Functions/FunctionEditor.svelte

@@ -4,7 +4,6 @@
 
 	const i18n = getContext('i18n');
 
-	import CodeEditor from '$lib/components/common/CodeEditor.svelte';
 	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
 	import Badge from '$lib/components/common/Badge.svelte';
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
@@ -367,20 +366,22 @@ class Pipe:
 				</div>
 
 				<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
-					<CodeEditor
-						bind:this={codeEditor}
-						value={content}
-						lang="python"
-						{boilerplate}
-						onChange={(e) => {
-							_content = e;
-						}}
-						onSave={async () => {
-							if (formElement) {
-								formElement.requestSubmit();
-							}
-						}}
-					/>
+					{#await import('$lib/components/common/CodeEditor.svelte') then { default: CodeEditor }}
+						<CodeEditor
+							bind:this={codeEditor}
+							value={content}
+							lang="python"
+							{boilerplate}
+							onChange={(e) => {
+								_content = e;
+							}}
+							onSave={async () => {
+								if (formElement) {
+									formElement.requestSubmit();
+								}
+							}}
+						/>
+					{/await}
 				</div>
 
 				<div class="pb-3 flex justify-between">

+ 11 - 11
src/lib/components/admin/Functions/FunctionMenu.svelte

@@ -8,7 +8,7 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import Share from '$lib/components/icons/Share.svelte';
 	import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
-	import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
 	import Switch from '$lib/components/common/Switch.svelte';
 	import GlobeAlt from '$lib/components/icons/GlobeAlt.svelte';
 
@@ -42,7 +42,7 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[180px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
+			class="w-full max-w-[180px] rounded-xl p-1 border border-gray-100  dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
 			sideOffset={-2}
 			side="bottom"
 			align="start"
@@ -50,7 +50,7 @@
 		>
 			{#if ['filter', 'action'].includes(func.type)}
 				<div
-					class="flex gap-2 justify-between items-center px-3 py-2 text-sm font-medium cursor-pointerrounded-md"
+					class="flex gap-2 justify-between items-center px-3 py-1.5 text-sm font-medium cursor-pointerrounded-md"
 				>
 					<div class="flex gap-2 items-center">
 						<GlobeAlt />
@@ -63,11 +63,11 @@
 					</div>
 				</div>
 
-				<hr class="border-gray-100 dark:border-gray-850 my-1" />
+				<hr class="border-gray-50 dark:border-gray-850 my-1" />
 			{/if}
 
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
+				class="flex gap-2 items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
 				on:click={() => {
 					editHandler();
 				}}
@@ -91,7 +91,7 @@
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
+				class="flex gap-2 items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800  rounded-md"
 				on:click={() => {
 					shareHandler();
 				}}
@@ -101,7 +101,7 @@
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				class="flex gap-2 items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
 					cloneHandler();
 				}}
@@ -112,20 +112,20 @@
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				class="flex gap-2 items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
 					exportHandler();
 				}}
 			>
-				<ArrowDownTray />
+				<Download />
 
 				<div class="flex items-center">{$i18n.t('Export')}</div>
 			</DropdownMenu.Item>
 
-			<hr class="border-gray-100 dark:border-gray-850 my-1" />
+			<hr class="border-gray-50 dark:border-gray-850 my-1" />
 
 			<DropdownMenu.Item
-				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				class="flex  gap-2  items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
 					deleteHandler();
 				}}

+ 4 - 2
src/lib/components/admin/Settings.svelte

@@ -83,7 +83,7 @@
 <div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
 	<div
 		id="admin-settings-tabs-container"
-		class="tabs flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
+		class="tabs mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
 	>
 		<button
 			id="general"
@@ -433,7 +433,9 @@
 		</button>
 	</div>
 
-	<div class="flex-1 mt-3 lg:mt-0 overflow-y-scroll pr-1 scrollbar-hidden">
+	<div
+		class="flex-1 mt-3 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll scrollbar-hidden"
+	>
 		{#if selectedTab === 'general'}
 			<General
 				saveHandler={async () => {

+ 3 - 3
src/lib/components/admin/Settings/Connections.svelte

@@ -261,10 +261,10 @@
 								<div class="flex flex-col gap-1.5 mt-1.5">
 									{#each OPENAI_API_BASE_URLS as url, idx}
 										<OpenAIConnection
-											pipeline={pipelineUrls[url] ? true : false}
-											bind:url
+											bind:url={OPENAI_API_BASE_URLS[idx]}
 											bind:key={OPENAI_API_KEYS[idx]}
 											bind:config={OPENAI_API_CONFIGS[idx]}
+											pipeline={pipelineUrls[url] ? true : false}
 											onSubmit={() => {
 												updateOpenAIHandler();
 											}}
@@ -326,7 +326,7 @@
 								<div class="flex-1 flex flex-col gap-1.5 mt-1.5">
 									{#each OLLAMA_BASE_URLS as url, idx}
 										<OllamaConnection
-											bind:url
+											bind:url={OLLAMA_BASE_URLS[idx]}
 											bind:config={OLLAMA_API_CONFIGS[idx]}
 											{idx}
 											onSubmit={() => {

+ 3 - 2
src/lib/components/admin/Settings/Connections/OllamaConnection.svelte

@@ -10,7 +10,7 @@
 	import Cog6 from '$lib/components/icons/Cog6.svelte';
 	import Wrench from '$lib/components/icons/Wrench.svelte';
 	import ManageOllamaModal from './ManageOllamaModal.svelte';
-	import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
 
 	export let onDelete = () => {};
 	export let onSubmit = () => {};
@@ -71,6 +71,7 @@
 			class="w-full text-sm bg-transparent outline-hidden"
 			placeholder={$i18n.t('Enter URL (e.g. http://localhost:11434)')}
 			bind:value={url}
+			readonly={true}
 		/>
 	</Tooltip>
 
@@ -83,7 +84,7 @@
 				}}
 				type="button"
 			>
-				<ArrowDownTray />
+				<Download />
 			</button>
 		</Tooltip>
 

+ 1 - 7
src/lib/components/admin/Settings/Connections/OpenAIConnection.svelte

@@ -69,6 +69,7 @@
 					placeholder={$i18n.t('API Base URL')}
 					bind:value={url}
 					autocomplete="off"
+					readonly={true}
 				/>
 
 				{#if pipeline}
@@ -94,13 +95,6 @@
 					</div>
 				{/if}
 			</div>
-
-			<SensitiveInput
-				inputClassName=" outline-hidden bg-transparent w-full"
-				placeholder={$i18n.t('API Key')}
-				required={false}
-				bind:value={key}
-			/>
 		</div>
 	</Tooltip>
 

+ 1 - 11
src/lib/components/admin/Settings/Database.svelte

@@ -143,7 +143,7 @@
 				</div>
 			</button>
 
-			<hr class="border-gray-100 dark:border-gray-850 my-1" />
+			<hr class="border-gray-50 dark:border-gray-850 my-1" />
 
 			{#if $config?.features.enable_admin_export ?? true}
 				<div class="  flex w-full justify-between">
@@ -233,14 +233,4 @@
 			{/if}
 		</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> -->
 </form>

+ 93 - 12
src/lib/components/admin/Settings/Documents.svelte

@@ -153,6 +153,7 @@
 		}
 		if (
 			RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
+			RAGConfig.DOCLING_DO_OCR &&
 			((RAGConfig.DOCLING_OCR_ENGINE === '' && RAGConfig.DOCLING_OCR_LANG !== '') ||
 				(RAGConfig.DOCLING_OCR_ENGINE !== '' && RAGConfig.DOCLING_OCR_LANG === ''))
 		) {
@@ -161,6 +162,14 @@
 			);
 			return;
 		}
+		if (
+			RAGConfig.CONTENT_EXTRACTION_ENGINE === 'docling' &&
+			RAGConfig.DOCLING_DO_OCR === false &&
+			RAGConfig.DOCLING_FORCE_OCR === true
+		) {
+			toast.error($i18n.t('In order to force OCR, performing OCR must be enabled.'));
+			return;
+		}
 
 		if (
 			RAGConfig.CONTENT_EXTRACTION_ENGINE === 'datalab_marker' &&
@@ -545,19 +554,91 @@
 									bind:value={RAGConfig.DOCLING_SERVER_URL}
 								/>
 							</div>
+
 							<div class="flex w-full mt-2">
-								<input
-									class="flex-1 w-full 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 text-sm bg-transparent outline-hidden"
-									placeholder={$i18n.t('Enter Docling OCR Language(s)')}
-									bind:value={RAGConfig.DOCLING_OCR_LANG}
-								/>
+								<div class="flex-1 flex justify-between">
+									<div class=" self-center text-xs font-medium">
+										{$i18n.t('Perform OCR')}
+									</div>
+									<div class="flex items-center relative">
+										<Switch bind:state={RAGConfig.DOCLING_DO_OCR} />
+									</div>
+								</div>
+							</div>
+							{#if RAGConfig.DOCLING_DO_OCR}
+								<div class="flex w-full mt-2">
+									<input
+										class="flex-1 w-full 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 text-sm bg-transparent outline-hidden"
+										placeholder={$i18n.t('Enter Docling OCR Language(s)')}
+										bind:value={RAGConfig.DOCLING_OCR_LANG}
+									/>
+								</div>
+							{/if}
+							<div class="flex w-full mt-2">
+								<div class="flex-1 flex justify-between">
+									<div class=" self-center text-xs font-medium">
+										{$i18n.t('Force OCR')}
+									</div>
+									<div class="flex items-center relative">
+										<Switch bind:state={RAGConfig.DOCLING_FORCE_OCR} />
+									</div>
+								</div>
+							</div>
+							<div class="flex justify-between w-full mt-2">
+								<div class="self-center text-xs font-medium">
+									<Tooltip content={''} placement="top-start">
+										{$i18n.t('PDF Backend')}
+									</Tooltip>
+								</div>
+								<div class="">
+									<select
+										class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
+										bind:value={RAGConfig.DOCLING_PDF_BACKEND}
+									>
+										<option value="pypdfium2">{$i18n.t('pypdfium2')}</option>
+										<option value="dlparse_v1">{$i18n.t('dlparse_v1')}</option>
+										<option value="dlparse_v2">{$i18n.t('dlparse_v2')}</option>
+										<option value="dlparse_v4">{$i18n.t('dlparse_v4')}</option>
+									</select>
+								</div>
+							</div>
+							<div class="flex justify-between w-full mt-2">
+								<div class="self-center text-xs font-medium">
+									<Tooltip content={''} placement="top-start">
+										{$i18n.t('Table Mode')}
+									</Tooltip>
+								</div>
+								<div class="">
+									<select
+										class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
+										bind:value={RAGConfig.DOCLING_TABLE_MODE}
+									>
+										<option value="fast">{$i18n.t('fast')}</option>
+										<option value="accurate">{$i18n.t('accurate')}</option>
+									</select>
+								</div>
+							</div>
+							<div class="flex justify-between w-full mt-2">
+								<div class="self-center text-xs font-medium">
+									<Tooltip content={''} placement="top-start">
+										{$i18n.t('Pipeline')}
+									</Tooltip>
+								</div>
+								<div class="">
+									<select
+										class="dark:bg-gray-900 w-fit pr-8 rounded-sm px-2 text-xs bg-transparent outline-hidden text-right"
+										bind:value={RAGConfig.DOCLING_PIPELINE}
+									>
+										<option value="standard">{$i18n.t('standard')}</option>
+										<option value="vlm">{$i18n.t('vlm')}</option>
+									</select>
+								</div>
 							</div>
-
 							<div class="flex w-full mt-2">
 								<div class="flex-1 flex justify-between">
 									<div class=" self-center text-xs font-medium">
@@ -1062,7 +1143,7 @@
 								<div class=" mb-2.5 py-0.5 w-full justify-between">
 									<Tooltip
 										content={$i18n.t(
-											'The Weight of BM25 Hybrid Search. 0 more lexical, 1 more semantic. Default 0.5'
+											'The Weight of BM25 Hybrid Search. 0 more semantic, 1 more lexical. Default 0.5'
 										)}
 										placement="top-start"
 										className="inline-tooltip"

+ 1 - 1
src/lib/components/admin/Settings/Evaluations/ArenaModelModal.svelte

@@ -293,7 +293,7 @@
 						<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
 
 						<div class="my-2 -mx-2">
-							<div class="px-3 py-2 bg-gray-50 dark:bg-gray-950 rounded-lg">
+							<div class="px-4 py-3 bg-gray-50 dark:bg-gray-950 rounded-3xl">
 								<AccessControl bind:accessControl />
 							</div>
 						</div>

+ 2 - 2
src/lib/components/admin/Settings/Models.svelte

@@ -30,7 +30,7 @@
 	import Cog6 from '$lib/components/icons/Cog6.svelte';
 	import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
 	import Wrench from '$lib/components/icons/Wrench.svelte';
-	import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
 	import ManageModelsModal from './Models/ManageModelsModal.svelte';
 	import ModelMenu from '$lib/components/admin/Settings/Models/ModelMenu.svelte';
 	import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
@@ -265,7 +265,7 @@
 								showManageModal = true;
 							}}
 						>
-							<ArrowDownTray />
+							<Download />
 						</button>
 					</Tooltip>
 

+ 6 - 6
src/lib/components/admin/Settings/Models/ModelMenu.svelte

@@ -11,7 +11,7 @@
 	import Share from '$lib/components/icons/Share.svelte';
 	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
 	import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
-	import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
 	import ArrowUpCircle from '$lib/components/icons/ArrowUpCircle.svelte';
 
 	import { config } from '$lib/stores';
@@ -45,14 +45,14 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[170px] rounded-xl px-1 py-1.5 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
+			class="w-full max-w-[170px] rounded-xl p-1 border border-gray-100  dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
 			sideOffset={-2}
 			side="bottom"
 			align="start"
 			transition={flyAndScale}
 		>
 			<DropdownMenu.Item
-				class="flex  gap-2  items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				class="flex  gap-2  items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
 					hideHandler();
 				}}
@@ -104,7 +104,7 @@
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				class="flex gap-2 items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
 					copyLinkHandler();
 				}}
@@ -115,12 +115,12 @@
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				class="flex gap-2 items-center px-3 py-1.5 text-sm  font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
 					exportHandler();
 				}}
 			>
-				<ArrowDownTray />
+				<Download />
 
 				<div class="flex items-center">{$i18n.t('Export')}</div>
 			</DropdownMenu.Item>

+ 2 - 2
src/lib/components/admin/Settings/Tools.svelte

@@ -14,7 +14,7 @@
 	import Plus from '$lib/components/icons/Plus.svelte';
 	import Connection from '$lib/components/chat/Settings/Tools/Connection.svelte';
 
-	import AddServerModal from '$lib/components/AddServerModal.svelte';
+	import AddToolServerModal from '$lib/components/AddToolServerModal.svelte';
 	import { getToolServerConnections, setToolServerConnections } from '$lib/apis/configs';
 
 	export let saveSettings: Function;
@@ -47,7 +47,7 @@
 	});
 </script>
 
-<AddServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
+<AddToolServerModal bind:show={showConnectionModal} onSubmit={addConnectionHandler} />
 
 <form
 	class="flex flex-col h-full justify-between text-sm"

+ 2 - 2
src/lib/components/admin/Users.svelte

@@ -58,7 +58,7 @@
 <div class="flex flex-col lg:flex-row w-full h-full pb-2 lg:space-x-4">
 	<div
 		id="users-tabs-container"
-		class=" flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-40 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
+		class="mx-[16px] lg:mx-0 lg:px-[16px] flex flex-row overflow-x-auto gap-2.5 max-w-full lg:gap-1 lg:flex-col lg:flex-none lg:w-50 dark:text-gray-200 text-sm font-medium text-left scrollbar-none"
 	>
 		<button
 			id="overview"
@@ -111,7 +111,7 @@
 		</button>
 	</div>
 
-	<div class="flex-1 mt-1 lg:mt-0 overflow-y-scroll">
+	<div class="flex-1 mt-1 lg:mt-0 px-[16px] lg:pr-[16px] lg:pl-0 overflow-y-scroll">
 		{#if selectedTab === 'overview'}
 			<UserList />
 		{:else if selectedTab === 'groups'}

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

@@ -216,7 +216,7 @@
 			</div>
 		{:else}
 			<div>
-				<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
+				<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-semibold">
 					<div class="w-full basis-3/5">{$i18n.t('Group')}</div>
 
 					<div class="w-full basis-2/5 text-right">{$i18n.t('Users')}</div>

+ 2 - 2
src/lib/components/admin/Users/Groups/GroupItem.svelte

@@ -1,6 +1,7 @@
 <script>
 	import { toast } from 'svelte-sonner';
 	import { onMount, getContext } from 'svelte';
+	import { page } from '$app/stores';
 
 	const i18n = getContext('i18n');
 
@@ -10,7 +11,6 @@
 	import User from '$lib/components/icons/User.svelte';
 	import UserCircleSolid from '$lib/components/icons/UserCircleSolid.svelte';
 	import GroupModal from './EditGroupModal.svelte';
-	import { querystringValue } from '$lib/utils';
 
 	export let users = [];
 	export let group = {
@@ -47,7 +47,7 @@
 	};
 
 	onMount(() => {
-		const groupId = querystringValue('id');
+		const groupId = $page.url.searchParams.get('id');
 		if (groupId && groupId === group.id) {
 			showEdit = true;
 		}

+ 15 - 21
src/lib/components/admin/Users/UserList.svelte

@@ -154,7 +154,7 @@
 	</div>
 {:else}
 	<div
-		class="pt-0.5 pb-2 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
+		class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
 	>
 		<div class="flex md:self-center text-lg font-medium px-0.5">
 			<div class="flex-shrink-0">
@@ -219,19 +219,13 @@
 		</div>
 	</div>
 
-	<div
-		class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm pt-0.5"
-	>
-		<table
-			class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full rounded-sm"
-		>
-			<thead
-				class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-850 dark:text-gray-400 -translate-y-0.5"
-			>
-				<tr class="">
+	<div class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full">
+		<table class="w-full text-sm text-left text-gray-500 dark:text-gray-400 table-auto max-w-full">
+			<thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
+				<tr class=" border-b-[1.5px] border-gray-50 dark:border-gray-850">
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('role')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -254,7 +248,7 @@
 					</th>
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('name')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -277,7 +271,7 @@
 					</th>
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('email')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -301,7 +295,7 @@
 
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('last_active_at')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -324,7 +318,7 @@
 					</th>
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('created_at')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -347,7 +341,7 @@
 
 					<th
 						scope="col"
-						class="px-3 py-1.5 cursor-pointer select-none"
+						class="px-2.5 py-2 cursor-pointer select-none"
 						on:click={() => setSortKey('oauth_sub')}
 					>
 						<div class="flex gap-1.5 items-center">
@@ -369,7 +363,7 @@
 						</div>
 					</th>
 
-					<th scope="col" class="px-3 py-2 text-right" />
+					<th scope="col" class="px-2.5 py-2 text-right" />
 				</tr>
 			</thead>
 			<tbody class="">
@@ -508,11 +502,11 @@
 > [!NOTE]
 > # **Hey there! 👋**
 >
-> It looks like you have over 50 users that usually falls under organizational usage.
+> It looks like you have over 50 users, that usually falls under organizational usage.
 > 
-> Open WebUI is proudly open source and completely free, with no hidden limits — and we'd love to keep it that way. 🌱  
+> Open WebUI is completely free to use as-is, with no restrictions or hidden limits, and we'd love to keep it that way. 🌱  
 >
-> By supporting the project through sponsorship or an enterprise license, you’re not only helping us stay independent, you’re also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more all at a fraction of what it would cost to build and maintain internally.  
+> By supporting the project through sponsorship or an enterprise license, you’re not only helping us stay independent, you’re also helping us ship new features faster, improve stability, and grow the project for the long haul. With an *enterprise license*, you also get additional perks like dedicated support, customization options, and more, all at a fraction of what it would cost to build and maintain internally.  
 > 
 > Your support helps us stay independent and continue building great tools for everyone. 💛
 > 

+ 4 - 0
src/lib/components/admin/Users/UserList/UserChatsModal.svelte

@@ -43,6 +43,10 @@
 	let searchDebounceTimeout;
 
 	const searchHandler = async () => {
+		if (!show) {
+			return;
+		}
+
 		if (searchDebounceTimeout) {
 			clearTimeout(searchDebounceTimeout);
 		}

+ 7 - 4
src/lib/components/channel/Channel.svelte

@@ -250,6 +250,8 @@
 				<MessageInput
 					id="root"
 					{typingUsers}
+					userSuggestions={true}
+					channelSuggestions={true}
 					{onChange}
 					onSubmit={submitHandler}
 					{scrollToBottom}
@@ -279,11 +281,12 @@
 			{/if}
 		{:else if threadId !== null}
 			<PaneResizer
-				class="relative flex w-[3px] items-center justify-center bg-background group bg-gray-50 dark:bg-gray-850"
+				class="relative flex items-center justify-center group border-l border-gray-50 dark:border-gray-850 hover:border-gray-200 dark:hover:border-gray-800  transition z-20"
+				id="controls-resizer"
 			>
-				<div class="z-10 flex h-7 w-5 items-center justify-center rounded-xs">
-					<EllipsisVertical className="size-4 invisible group-hover:visible" />
-				</div>
+				<div
+					class=" absolute -left-1.5 -right-1.5 -top-0 -bottom-0 z-20 cursor-col-resize bg-transparent"
+				/>
 			</PaneResizer>
 
 			<Pane defaultSize={50} minSize={30} class="h-full w-full">

+ 514 - 423
src/lib/components/channel/MessageInput.svelte

@@ -1,7 +1,6 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
 	import { v4 as uuidv4 } from 'uuid';
-	import heic2any from 'heic2any';
 
 	import { tick, getContext, onMount, onDestroy } from 'svelte';
 
@@ -9,7 +8,7 @@
 
 	import { config, mobile, settings, socket, user } from '$lib/stores';
 	import {
-		blobToFile,
+		convertHeicToJpeg,
 		compressImage,
 		extractInputVariables,
 		getAge,
@@ -18,9 +17,12 @@
 		getFormattedTime,
 		getUserPosition,
 		getUserTimezone,
-		getWeekday
+		getWeekday,
+		extractCurlyBraceWords
 	} from '$lib/utils';
 
+	import { getSessionUser } from '$lib/apis/auths';
+
 	import Tooltip from '../common/Tooltip.svelte';
 	import RichTextInput from '../common/RichTextInput.svelte';
 	import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
@@ -30,26 +32,17 @@
 	import FileItem from '../common/FileItem.svelte';
 	import Image from '../common/Image.svelte';
 	import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
-	import Commands from '../chat/MessageInput/Commands.svelte';
 	import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
-	import { getSessionUser } from '$lib/apis/auths';
+	import { getSuggestionRenderer } from '../common/RichTextInput/suggestions';
+	import CommandSuggestionList from '../chat/MessageInput/CommandSuggestionList.svelte';
+	import MentionList from './MessageInput/MentionList.svelte';
+	import Skeleton from '../chat/Messages/Skeleton.svelte';
 
 	export let placeholder = $i18n.t('Send a Message');
 
 	export let id = null;
-
-	let draggedOver = false;
-
-	let recording = false;
-	let content = '';
-	let files = [];
-
 	export let chatInputElement;
 
-	let commandsElement;
-	let filesInputElement;
-	let inputFiles;
-
 	export let typingUsers = [];
 	export let inputLoading = false;
 
@@ -63,15 +56,44 @@
 	export let acceptFiles = true;
 	export let showFormattingToolbar = true;
 
+	export let userSuggestions = false;
+	export let channelSuggestions = false;
+
+	export let typingUsersClassName = 'from-white dark:from-gray-900';
+
+	let loaded = false;
+	let draggedOver = false;
+
+	let recording = false;
+	let content = '';
+	let files = [];
+
+	let filesInputElement;
+	let inputFiles;
+
 	let showInputVariablesModal = false;
+	let inputVariablesModalCallback: (variableValues: Record<string, any>) => void;
 	let inputVariables: Record<string, any> = {};
 	let inputVariableValues = {};
 
-	const inputVariableHandler = async (text: string) => {
+	const inputVariableHandler = async (text: string): Promise<string> => {
 		inputVariables = extractInputVariables(text);
-		if (Object.keys(inputVariables).length > 0) {
-			showInputVariablesModal = true;
+
+		// No variables? return the original text immediately.
+		if (Object.keys(inputVariables).length === 0) {
+			return text;
 		}
+
+		// Show modal and wait for the user's input.
+		showInputVariablesModal = true;
+		return await new Promise<string>((resolve) => {
+			inputVariablesModalCallback = (variableValues) => {
+				inputVariableValues = { ...inputVariableValues, ...variableValues };
+				replaceVariables(inputVariableValues);
+				showInputVariablesModal = false;
+				resolve(text);
+			};
+		});
 	};
 
 	const textVariableHandler = async (text: string) => {
@@ -189,68 +211,92 @@
 			text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
 		}
 
-		inputVariableHandler(text);
 		return text;
 	};
 
 	const replaceVariables = (variables: Record<string, any>) => {
-		if (!chatInputElement) return;
 		console.log('Replacing variables:', variables);
 
-		chatInputElement.replaceVariables(variables);
-		chatInputElement.focus();
+		const chatInput = document.getElementById('chat-input');
+
+		if (chatInput) {
+			chatInputElement.replaceVariables(variables);
+			chatInputElement.focus();
+		}
 	};
 
-	export const setText = async (text?: string) => {
-		if (!chatInputElement) return;
+	export const setText = async (text?: string, cb?: (text: string) => void) => {
+		const chatInput = document.getElementById('chat-input');
 
-		text = await textVariableHandler(text || '');
+		if (chatInput) {
+			if (text !== '') {
+				text = await textVariableHandler(text || '');
+			}
+
+			chatInputElement?.setText(text);
+			chatInputElement?.focus();
 
-		chatInputElement?.setText(text);
-		chatInputElement?.focus();
+			if (text !== '') {
+				text = await inputVariableHandler(text);
+			}
+
+			await tick();
+			if (cb) await cb(text);
+		}
 	};
 
 	const getCommand = () => {
-		if (!chatInputElement) return;
-
+		const chatInput = document.getElementById('chat-input');
 		let word = '';
-		word = chatInputElement?.getWordAtDocPos();
+
+		if (chatInput) {
+			word = chatInputElement?.getWordAtDocPos();
+		}
 
 		return word;
 	};
 
 	const replaceCommandWithText = (text) => {
-		if (!chatInputElement) return;
+		const chatInput = document.getElementById('chat-input');
+		if (!chatInput) return;
 
 		chatInputElement?.replaceCommandWithText(text);
 	};
 
 	const insertTextAtCursor = async (text: string) => {
+		const chatInput = document.getElementById('chat-input');
+		if (!chatInput) return;
+
 		text = await textVariableHandler(text);
 
 		if (command) {
 			replaceCommandWithText(text);
 		} else {
-			const selection = window.getSelection();
-			if (selection && selection.rangeCount > 0) {
-				const range = selection.getRangeAt(0);
-				range.deleteContents();
-				range.insertNode(document.createTextNode(text));
-				range.collapse(false);
-				selection.removeAllRanges();
-				selection.addRange(range);
-			}
+			chatInputElement?.insertContent(text);
 		}
 
 		await tick();
+		text = await inputVariableHandler(text);
+		await tick();
+
 		const chatInputContainer = document.getElementById('chat-input-container');
 		if (chatInputContainer) {
 			chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
 		}
 
 		await tick();
-		if (chatInputElement) {
-			chatInputElement.focus();
+		if (chatInput) {
+			chatInput.focus();
+			chatInput.dispatchEvent(new Event('input'));
+
+			const words = extractCurlyBraceWords(prompt);
+
+			if (words.length > 0) {
+				const word = words.at(0);
+				await tick();
+			} else {
+				chatInput.scrollTop = chatInput.scrollHeight;
+			}
 		}
 	};
 
@@ -258,6 +304,7 @@
 
 	export let showCommands = false;
 	$: showCommands = ['/'].includes(command?.charAt(0));
+	let suggestions = null;
 
 	const screenCaptureHandler = async () => {
 		try {
@@ -377,11 +424,7 @@
 					];
 				};
 
-				reader.readAsDataURL(
-					file['type'] === 'image/heic'
-						? await heic2any({ blob: file, toType: 'image/jpeg' })
-						: file
-				);
+				reader.readAsDataURL(file['type'] === 'image/heic' ? await convertHeicToJpeg(file) : file);
 			} else {
 				uploadFileHandler(file);
 			}
@@ -519,6 +562,64 @@
 	}
 
 	onMount(async () => {
+		suggestions = [
+			{
+				char: '@',
+				render: getSuggestionRenderer(MentionList, {
+					i18n,
+					triggerChar: '@',
+					modelSuggestions: true,
+					userSuggestions
+				})
+			},
+			...(channelSuggestions
+				? [
+						{
+							char: '#',
+							render: getSuggestionRenderer(MentionList, {
+								i18n,
+								triggerChar: '#',
+								channelSuggestions
+							})
+						}
+					]
+				: []),
+			{
+				char: '/',
+				render: getSuggestionRenderer(CommandSuggestionList, {
+					i18n,
+					onSelect: (e) => {
+						const { type, data } = e;
+
+						if (type === 'model') {
+							console.log('Selected model:', data);
+						}
+
+						document.getElementById('chat-input')?.focus();
+					},
+
+					insertTextHandler: insertTextAtCursor,
+					onUpload: (e) => {
+						const { type, data } = e;
+
+						if (type === 'file') {
+							if (files.find((f) => f.id === data.id)) {
+								return;
+							}
+							files = [
+								...files,
+								{
+									...data,
+									status: 'processed'
+								}
+							];
+						}
+					}
+				})
+			}
+		];
+		loaded = true;
+
 		window.setTimeout(() => {
 			if (chatInputElement) {
 				chatInputElement.focus();
@@ -548,432 +649,422 @@
 	});
 </script>
 
-<FilesOverlay show={draggedOver} />
+{#if loaded}
+	<FilesOverlay show={draggedOver} />
+
+	{#if acceptFiles}
+		<input
+			bind:this={filesInputElement}
+			bind:files={inputFiles}
+			type="file"
+			hidden
+			multiple
+			on:change={async () => {
+				if (inputFiles && inputFiles.length > 0) {
+					inputFilesHandler(Array.from(inputFiles));
+				} else {
+					toast.error($i18n.t(`File not found.`));
+				}
 
-{#if acceptFiles}
-	<input
-		bind:this={filesInputElement}
-		bind:files={inputFiles}
-		type="file"
-		hidden
-		multiple
-		on:change={async () => {
-			if (inputFiles && inputFiles.length > 0) {
-				inputFilesHandler(Array.from(inputFiles));
-			} else {
-				toast.error($i18n.t(`File not found.`));
-			}
+				filesInputElement.value = '';
+			}}
+		/>
+	{/if}
 
-			filesInputElement.value = '';
-		}}
+	<InputVariablesModal
+		bind:show={showInputVariablesModal}
+		variables={inputVariables}
+		onSave={inputVariablesModalCallback}
 	/>
-{/if}
 
-<InputVariablesModal
-	bind:show={showInputVariablesModal}
-	variables={inputVariables}
-	onSave={(variableValues) => {
-		inputVariableValues = { ...inputVariableValues, ...variableValues };
-		replaceVariables(inputVariableValues);
-	}}
-/>
-
-<div class="bg-transparent">
-	<div
-		class="{($settings?.widescreenMode ?? null)
-			? 'max-w-full'
-			: 'max-w-6xl'}  mx-auto inset-x-0 relative"
-	>
-		<div class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center">
-			<div class="flex flex-col px-3 w-full">
-				<div class="relative">
-					{#if scrollEnd === false}
-						<div
-							class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
-						>
-							<button
-								class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
-								on:click={() => {
-									scrollEnd = true;
-									scrollToBottom();
-								}}
+	<div class="bg-transparent">
+		<div class="max-w-full mx-auto inset-x-0 relative">
+			<div
+				class="absolute top-0 left-0 right-0 mx-auto inset-x-0 bg-transparent flex justify-center"
+			>
+				<div class="flex flex-col px-3 w-full">
+					<div class="relative">
+						{#if scrollEnd === false}
+							<div
+								class=" absolute -top-12 left-0 right-0 flex justify-center z-30 pointer-events-none"
 							>
-								<svg
-									xmlns="http://www.w3.org/2000/svg"
-									viewBox="0 0 20 20"
-									fill="currentColor"
-									class="w-5 h-5"
+								<button
+									class=" bg-white border border-gray-100 dark:border-none dark:bg-white/20 p-1.5 rounded-full pointer-events-auto"
+									on:click={() => {
+										scrollEnd = true;
+										scrollToBottom();
+									}}
 								>
-									<path
-										fill-rule="evenodd"
-										d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
-										clip-rule="evenodd"
-									/>
-								</svg>
-							</button>
-						</div>
-					{/if}
-				</div>
-
-				<div class="relative">
-					<div class=" -mt-5">
-						{#if typingUsers.length > 0}
-							<div class=" text-xs px-4 mb-1">
-								<span class=" font-normal text-black dark:text-white">
-									{typingUsers.map((user) => user.name).join(', ')}
-								</span>
-								{$i18n.t('is typing...')}
+									<svg
+										xmlns="http://www.w3.org/2000/svg"
+										viewBox="0 0 20 20"
+										fill="currentColor"
+										class="w-5 h-5"
+									>
+										<path
+											fill-rule="evenodd"
+											d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
+											clip-rule="evenodd"
+										/>
+									</svg>
+								</button>
 							</div>
 						{/if}
 					</div>
 
-					<Commands
-						bind:this={commandsElement}
-						show={showCommands}
-						{command}
-						insertTextHandler={insertTextAtCursor}
-					/>
+					{#if typingUsers.length > 0}
+						<div
+							class=" -mt-7 pb-2.5 bg-gradient-to-t to-transparent {typingUsersClassName} pointer-events-none select-none"
+						>
+							<div class=" text-xs px-1 mt-1.5 flex items-center gap-1.5">
+								<Skeleton size="xs" />
+
+								<div>
+									<span class=" font-normal text-black dark:text-white">
+										{typingUsers.map((user) => user.name).join(', ')}
+									</span>
+									{$i18n.t('is typing...')}
+								</div>
+							</div>
+						</div>
+					{/if}
 				</div>
 			</div>
-		</div>
 
-		<div class="">
-			{#if recording}
-				<VoiceRecording
-					bind:recording
-					onCancel={async () => {
-						recording = false;
+			<div class="">
+				{#if recording}
+					<VoiceRecording
+						bind:recording
+						onCancel={async () => {
+							recording = false;
 
-						await tick();
+							await tick();
 
-						if (chatInputElement) {
-							chatInputElement.focus();
-						}
-					}}
-					onConfirm={async (data) => {
-						const { text, filename } = data;
-						recording = false;
+							if (chatInputElement) {
+								chatInputElement.focus();
+							}
+						}}
+						onConfirm={async (data) => {
+							const { text, filename } = data;
+							recording = false;
 
-						await tick();
-						insertTextAtCursor(text);
+							await tick();
+							insertTextAtCursor(text);
 
-						await tick();
+							await tick();
 
-						if (chatInputElement) {
-							chatInputElement.focus();
-						}
-					}}
-				/>
-			{:else}
-				<form
-					class="w-full flex gap-1.5"
-					on:submit|preventDefault={() => {
-						submitHandler();
-					}}
-				>
-					<div
-						class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-600/5 dark:bg-gray-400/5 dark:text-gray-100"
-						dir={$settings?.chatDirection ?? 'auto'}
+							if (chatInputElement) {
+								chatInputElement.focus();
+							}
+						}}
+					/>
+				{:else}
+					<form
+						class="w-full flex gap-1.5"
+						on:submit|preventDefault={() => {
+							submitHandler();
+						}}
 					>
-						{#if files.length > 0}
-							<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
-								{#each files as file, fileIdx}
-									{#if file.type === 'image'}
-										<div class=" relative group">
-											<div class="relative">
-												<Image
-													src={file.url}
-													alt="input"
-													imageClassName=" h-16 w-16 rounded-xl object-cover"
-												/>
+						<div
+							class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border border-gray-50 dark:border-gray-850 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition px-1 bg-white/90 dark:bg-gray-400/5 dark:text-gray-100"
+							dir={$settings?.chatDirection ?? 'auto'}
+						>
+							{#if files.length > 0}
+								<div class="mx-2 mt-2.5 -mb-1 flex flex-wrap gap-2">
+									{#each files as file, fileIdx}
+										{#if file.type === 'image'}
+											<div class=" relative group">
+												<div class="relative">
+													<Image
+														src={file.url}
+														alt=""
+														imageClassName=" size-10 rounded-xl object-cover"
+													/>
+												</div>
+												<div class=" absolute -top-1 -right-1">
+													<button
+														class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
+														type="button"
+														on:click={() => {
+															files.splice(fileIdx, 1);
+															files = files;
+														}}
+													>
+														<svg
+															xmlns="http://www.w3.org/2000/svg"
+															viewBox="0 0 20 20"
+															fill="currentColor"
+															class="w-4 h-4"
+														>
+															<path
+																d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+															/>
+														</svg>
+													</button>
+												</div>
 											</div>
-											<div class=" absolute -top-1 -right-1">
+										{:else}
+											<FileItem
+												item={file}
+												name={file.name}
+												type={file.type}
+												size={file?.size}
+												small={true}
+												loading={file.status === 'uploading'}
+												dismissible={true}
+												edit={true}
+												on:dismiss={() => {
+													files.splice(fileIdx, 1);
+													files = files;
+												}}
+												on:click={() => {
+													console.log(file);
+												}}
+											/>
+										{/if}
+									{/each}
+								</div>
+							{/if}
+
+							<div class="px-2.5">
+								<div
+									class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-2.5 pb-[5px] px-1 resize-none h-fit max-h-96 overflow-auto"
+								>
+									{#key $settings?.richTextInput}
+										<RichTextInput
+											id="chat-input"
+											bind:this={chatInputElement}
+											json={true}
+											messageInput={true}
+											richText={$settings?.richTextInput ?? true}
+											showFormattingToolbar={$settings?.showFormattingToolbar ?? false}
+											shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
+												(!$mobile ||
+													!(
+														'ontouchstart' in window ||
+														navigator.maxTouchPoints > 0 ||
+														navigator.msMaxTouchPoints > 0
+													))}
+											largeTextAsFile={$settings?.largeTextAsFile ?? false}
+											floatingMenuPlacement={'top-start'}
+											{suggestions}
+											onChange={(e) => {
+												const { md } = e;
+												content = md;
+												command = getCommand();
+											}}
+											on:keydown={async (e) => {
+												e = e.detail.event;
+												const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
+
+												const suggestionsContainerElement =
+													document.getElementById('suggestions-container');
+
+												if (!suggestionsContainerElement) {
+													if (
+														!$mobile ||
+														!(
+															'ontouchstart' in window ||
+															navigator.maxTouchPoints > 0 ||
+															navigator.msMaxTouchPoints > 0
+														)
+													) {
+														// Prevent Enter key from creating a new line
+														// Uses keyCode '13' for Enter key for chinese/japanese keyboards
+														if (e.keyCode === 13 && !e.shiftKey) {
+															e.preventDefault();
+														}
+
+														// Submit the content when Enter key is pressed
+														if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
+															submitHandler();
+														}
+													}
+												}
+
+												if (e.key === 'Escape') {
+													console.info('Escape');
+												}
+											}}
+											on:paste={async (e) => {
+												e = e.detail.event;
+												console.log(e);
+
+												const clipboardData = e.clipboardData || window.clipboardData;
+
+												if (clipboardData && clipboardData.items) {
+													for (const item of clipboardData.items) {
+														if (item.type.indexOf('image') !== -1) {
+															const blob = item.getAsFile();
+															const reader = new FileReader();
+
+															reader.onload = function (e) {
+																files = [
+																	...files,
+																	{
+																		type: 'image',
+																		url: `${e.target.result}`
+																	}
+																];
+															};
+
+															reader.readAsDataURL(blob);
+														} else if (item?.kind === 'file') {
+															const file = item.getAsFile();
+															if (file) {
+																const _files = [file];
+																await inputFilesHandler(_files);
+																e.preventDefault();
+															}
+														}
+													}
+												}
+											}}
+										/>
+									{/key}
+								</div>
+							</div>
+
+							<div class=" flex justify-between mb-2.5 mx-0.5">
+								<div class="ml-1 self-end flex space-x-1 flex-1">
+									<slot name="menu">
+										{#if acceptFiles}
+											<InputMenu
+												{screenCaptureHandler}
+												uploadFilesHandler={() => {
+													filesInputElement.click();
+												}}
+											>
 												<button
-													class=" bg-white text-black border border-white rounded-full group-hover:visible invisible transition"
+													class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
 													type="button"
-													on:click={() => {
-														files.splice(fileIdx, 1);
-														files = files;
-													}}
+													aria-label="More"
 												>
 													<svg
 														xmlns="http://www.w3.org/2000/svg"
 														viewBox="0 0 20 20"
 														fill="currentColor"
-														class="w-4 h-4"
+														class="size-5"
 													>
 														<path
-															d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+															d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
 														/>
 													</svg>
 												</button>
-											</div>
-										</div>
-									{:else}
-										<FileItem
-											item={file}
-											name={file.name}
-											type={file.type}
-											size={file?.size}
-											loading={file.status === 'uploading'}
-											dismissible={true}
-											edit={true}
-											on:dismiss={() => {
-												files.splice(fileIdx, 1);
-												files = files;
-											}}
-											on:click={() => {
-												console.log(file);
-											}}
-										/>
-									{/if}
-								{/each}
-							</div>
-						{/if}
-
-						<div class="px-2.5">
-							<div
-								class="scrollbar-hidden font-primary text-left bg-transparent dark:text-gray-100 outline-hidden w-full pt-3 px-1 resize-none h-fit max-h-80 overflow-auto"
-							>
-								<RichTextInput
-									bind:this={chatInputElement}
-									json={true}
-									messageInput={true}
-									{showFormattingToolbar}
-									shiftEnter={!($settings?.ctrlEnterToSend ?? false) &&
-										(!$mobile ||
-											!(
-												'ontouchstart' in window ||
-												navigator.maxTouchPoints > 0 ||
-												navigator.msMaxTouchPoints > 0
-											))}
-									largeTextAsFile={$settings?.largeTextAsFile ?? false}
-									floatingMenuPlacement={'top-start'}
-									onChange={(e) => {
-										const { md } = e;
-										content = md;
-										command = getCommand();
-									}}
-									on:keydown={async (e) => {
-										e = e.detail.event;
-										const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
-
-										const commandsContainerElement = document.getElementById('commands-container');
-
-										if (commandsContainerElement) {
-											if (commandsContainerElement && e.key === 'ArrowUp') {
-												e.preventDefault();
-												commandsElement.selectUp();
-
-												const commandOptionButton = [
-													...document.getElementsByClassName('selected-command-option-button')
-												]?.at(-1);
-												commandOptionButton.scrollIntoView({ block: 'center' });
-											}
-
-											if (commandsContainerElement && e.key === 'ArrowDown') {
-												e.preventDefault();
-												commandsElement.selectDown();
-
-												const commandOptionButton = [
-													...document.getElementsByClassName('selected-command-option-button')
-												]?.at(-1);
-												commandOptionButton.scrollIntoView({ block: 'center' });
-											}
-
-											if (commandsContainerElement && e.key === 'Tab') {
-												e.preventDefault();
-
-												const commandOptionButton = [
-													...document.getElementsByClassName('selected-command-option-button')
-												]?.at(-1);
-
-												commandOptionButton?.click();
-											}
-
-											if (commandsContainerElement && e.key === 'Enter') {
-												e.preventDefault();
-
-												const commandOptionButton = [
-													...document.getElementsByClassName('selected-command-option-button')
-												]?.at(-1);
-
-												if (commandOptionButton) {
-													commandOptionButton?.click();
-												} else {
-													document.getElementById('send-message-button')?.click();
-												}
-											}
-										} else {
-											if (
-												!$mobile ||
-												!(
-													'ontouchstart' in window ||
-													navigator.maxTouchPoints > 0 ||
-													navigator.msMaxTouchPoints > 0
-												)
-											) {
-												// Prevent Enter key from creating a new line
-												// Uses keyCode '13' for Enter key for chinese/japanese keyboards
-												if (e.keyCode === 13 && !e.shiftKey) {
-													e.preventDefault();
-												}
-
-												// Submit the content when Enter key is pressed
-												if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
-													submitHandler();
-												}
-											}
-										}
-
-										if (e.key === 'Escape') {
-											console.info('Escape');
-										}
-									}}
-									on:paste={async (e) => {
-										e = e.detail.event;
-										console.info(e);
-									}}
-								/>
-							</div>
-						</div>
+											</InputMenu>
+										{/if}
+									</slot>
+								</div>
 
-						<div class=" flex justify-between mb-2.5 mt-1.5 mx-0.5">
-							<div class="ml-1 self-end flex space-x-1 flex-1">
-								<slot name="menu">
-									{#if acceptFiles}
-										<InputMenu
-											{screenCaptureHandler}
-											uploadFilesHandler={() => {
-												filesInputElement.click();
-											}}
-										>
+								<div class="self-end flex space-x-1 mr-1">
+									{#if content === ''}
+										<Tooltip content={$i18n.t('Record voice')}>
 											<button
-												class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5 outline-hidden focus:outline-hidden"
+												id="voice-input-button"
+												class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
 												type="button"
-												aria-label="More"
+												on:click={async () => {
+													try {
+														let stream = await navigator.mediaDevices
+															.getUserMedia({ audio: true })
+															.catch(function (err) {
+																toast.error(
+																	$i18n.t(
+																		`Permission denied when accessing microphone: {{error}}`,
+																		{
+																			error: err
+																		}
+																	)
+																);
+																return null;
+															});
+
+														if (stream) {
+															recording = true;
+															const tracks = stream.getTracks();
+															tracks.forEach((track) => track.stop());
+														}
+														stream = null;
+													} catch {
+														toast.error($i18n.t('Permission denied when accessing microphone'));
+													}
+												}}
+												aria-label="Voice Input"
 											>
 												<svg
 													xmlns="http://www.w3.org/2000/svg"
 													viewBox="0 0 20 20"
 													fill="currentColor"
-													class="size-5"
+													class="w-5 h-5 translate-y-[0.5px]"
 												>
+													<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
 													<path
-														d="M10.75 4.75a.75.75 0 0 0-1.5 0v4.5h-4.5a.75.75 0 0 0 0 1.5h4.5v4.5a.75.75 0 0 0 1.5 0v-4.5h4.5a.75.75 0 0 0 0-1.5h-4.5v-4.5Z"
+														d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
 													/>
 												</svg>
 											</button>
-										</InputMenu>
+										</Tooltip>
 									{/if}
-								</slot>
-							</div>
 
-							<div class="self-end flex space-x-1 mr-1">
-								{#if content === ''}
-									<Tooltip content={$i18n.t('Record voice')}>
-										<button
-											id="voice-input-button"
-											class=" text-gray-600 dark:text-gray-300 hover:text-gray-700 dark:hover:text-gray-200 transition rounded-full p-1.5 mr-0.5 self-center"
-											type="button"
-											on:click={async () => {
-												try {
-													let stream = await navigator.mediaDevices
-														.getUserMedia({ audio: true })
-														.catch(function (err) {
-															toast.error(
-																$i18n.t(`Permission denied when accessing microphone: {{error}}`, {
-																	error: err
-																})
-															);
-															return null;
-														});
-
-													if (stream) {
-														recording = true;
-														const tracks = stream.getTracks();
-														tracks.forEach((track) => track.stop());
-													}
-													stream = null;
-												} catch {
-													toast.error($i18n.t('Permission denied when accessing microphone'));
-												}
-											}}
-											aria-label="Voice Input"
-										>
-											<svg
-												xmlns="http://www.w3.org/2000/svg"
-												viewBox="0 0 20 20"
-												fill="currentColor"
-												class="w-5 h-5 translate-y-[0.5px]"
-											>
-												<path d="M7 4a3 3 0 016 0v6a3 3 0 11-6 0V4z" />
-												<path
-													d="M5.5 9.643a.75.75 0 00-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 000 1.5h4.5a.75.75 0 000-1.5h-1.5v-1.546A6.001 6.001 0 0016 10v-.357a.75.75 0 00-1.5 0V10a4.5 4.5 0 01-9 0v-.357z"
-												/>
-											</svg>
-										</button>
-									</Tooltip>
-								{/if}
-
-								<div class=" flex items-center">
-									{#if inputLoading && onStop}
-										<div class=" flex items-center">
-											<Tooltip content={$i18n.t('Stop')}>
-												<button
-													class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
-													on:click={() => {
-														onStop();
-													}}
-												>
-													<svg
-														xmlns="http://www.w3.org/2000/svg"
-														viewBox="0 0 24 24"
-														fill="currentColor"
-														class="size-5"
+									<div class=" flex items-center">
+										{#if inputLoading && onStop}
+											<div class=" flex items-center">
+												<Tooltip content={$i18n.t('Stop')}>
+													<button
+														class="bg-white hover:bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-1.5"
+														on:click={() => {
+															onStop();
+														}}
 													>
-														<path
-															fill-rule="evenodd"
-															d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
-															clip-rule="evenodd"
-														/>
-													</svg>
-												</button>
-											</Tooltip>
-										</div>
-									{:else}
-										<div class=" flex items-center">
-											<Tooltip content={$i18n.t('Send message')}>
-												<button
-													id="send-message-button"
-													class="{content !== '' || files.length !== 0
-														? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
-														: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
-													type="submit"
-													disabled={content === '' && files.length === 0}
-												>
-													<svg
-														xmlns="http://www.w3.org/2000/svg"
-														viewBox="0 0 16 16"
-														fill="currentColor"
-														class="size-5"
+														<svg
+															xmlns="http://www.w3.org/2000/svg"
+															viewBox="0 0 24 24"
+															fill="currentColor"
+															class="size-5"
+														>
+															<path
+																fill-rule="evenodd"
+																d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm6-2.438c0-.724.588-1.312 1.313-1.312h4.874c.725 0 1.313.588 1.313 1.313v4.874c0 .725-.588 1.313-1.313 1.313H9.564a1.312 1.312 0 01-1.313-1.313V9.564z"
+																clip-rule="evenodd"
+															/>
+														</svg>
+													</button>
+												</Tooltip>
+											</div>
+										{:else}
+											<div class=" flex items-center">
+												<Tooltip content={$i18n.t('Send message')}>
+													<button
+														id="send-message-button"
+														class="{content !== '' || files.length !== 0
+															? 'bg-black text-white hover:bg-gray-900 dark:bg-white dark:text-black dark:hover:bg-gray-100 '
+															: 'text-white bg-gray-200 dark:text-gray-900 dark:bg-gray-700 disabled'} transition rounded-full p-1.5 self-center"
+														type="submit"
+														disabled={content === '' && files.length === 0}
 													>
-														<path
-															fill-rule="evenodd"
-															d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
-															clip-rule="evenodd"
-														/>
-													</svg>
-												</button>
-											</Tooltip>
-										</div>
-									{/if}
+														<svg
+															xmlns="http://www.w3.org/2000/svg"
+															viewBox="0 0 16 16"
+															fill="currentColor"
+															class="size-5"
+														>
+															<path
+																fill-rule="evenodd"
+																d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
+																clip-rule="evenodd"
+															/>
+														</svg>
+													</button>
+												</Tooltip>
+											</div>
+										{/if}
+									</div>
 								</div>
 							</div>
 						</div>
-					</div>
-				</form>
-			{/if}
+					</form>
+				{/if}
+			</div>
 		</div>
 	</div>
-</div>
+{/if}

+ 18 - 18
src/lib/components/channel/MessageInput/InputMenu.svelte

@@ -13,6 +13,8 @@
 	import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
 	import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
 	import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
+	import Camera from '$lib/components/icons/Camera.svelte';
+	import Clip from '$lib/components/icons/Clip.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -44,34 +46,32 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[200px] rounded-xl px-1 py-1  border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
-			sideOffset={15}
-			alignOffset={-8}
-			side="top"
+			class="w-full max-w-[200px] rounded-2xl px-1 py-1  border border-gray-100  dark:border-gray-800 z-999 bg-white dark:bg-gray-850 dark:text-white shadow-lg transition"
+			sideOffset={4}
+			alignOffset={-6}
+			side="bottom"
 			align="start"
 			transition={flyAndScale}
 		>
-			{#if !$mobile}
-				<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={() => {
-						screenCaptureHandler();
-					}}
-				>
-					<CameraSolid />
-					<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
-				</DropdownMenu.Item>
-			{/if}
-
 			<DropdownMenu.Item
-				class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
+				class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-xl"
 				on:click={() => {
 					uploadFilesHandler();
 				}}
 			>
-				<DocumentArrowUpSolid />
+				<Clip />
 				<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
 			</DropdownMenu.Item>
+
+			<DropdownMenu.Item
+				class="flex gap-2 items-center px-3 py-1.5 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50  rounded-xl"
+				on:click={() => {
+					screenCaptureHandler();
+				}}
+			>
+				<Camera />
+				<div class=" line-clamp-1">{$i18n.t('Capture')}</div>
+			</DropdownMenu.Item>
 		</DropdownMenu.Content>
 	</div>
 </Dropdown>

+ 205 - 0
src/lib/components/channel/MessageInput/MentionList.svelte

@@ -0,0 +1,205 @@
+<script lang="ts">
+	import { getContext, onDestroy, onMount } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import { channels, models, user } from '$lib/stores';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Hashtag from '$lib/components/icons/Hashtag.svelte';
+	import Lock from '$lib/components/icons/Lock.svelte';
+	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
+	import { searchUsers } from '$lib/apis/users';
+
+	export let query = '';
+
+	export let command: (payload: { id: string; label: string }) => void;
+	export let selectedIndex = 0;
+
+	export let label = '';
+	export let triggerChar = '@';
+
+	export let modelSuggestions = false;
+	export let userSuggestions = false;
+	export let channelSuggestions = false;
+
+	let _models = [];
+	let _users = [];
+	let _channels = [];
+
+	$: filteredItems = [..._users, ..._models, ..._channels].filter(
+		(u) =>
+			u.label.toLowerCase().includes(query.toLowerCase()) ||
+			u.id.toLowerCase().includes(query.toLowerCase())
+	);
+
+	const getUserList = async () => {
+		const res = await searchUsers(localStorage.token, query).catch((error) => {
+			console.error('Error searching users:', error);
+			return null;
+		});
+
+		if (res) {
+			_users = [...res.users.map((u) => ({ type: 'user', id: u.id, label: u.name }))].sort((a, b) =>
+				a.label.localeCompare(b.label)
+			);
+		}
+	};
+
+	$: if (query !== null && userSuggestions) {
+		getUserList();
+	}
+
+	const select = (index: number) => {
+		const item = filteredItems[index];
+		if (!item) return;
+
+		// Add the "U:", "M:" or "C:" prefix to the id
+		// and also append the label after a pipe |
+		// so that the mention renderer can show the label
+		if (item)
+			command({
+				id: `${item.type === 'user' ? 'U' : item.type === 'model' ? 'M' : 'C'}:${item.id}|${item.label}`,
+				label: item.label
+			});
+	};
+
+	const onKeyDown = (event: KeyboardEvent) => {
+		if (!['ArrowUp', 'ArrowDown', 'Enter', 'Tab', 'Escape'].includes(event.key)) return false;
+
+		if (event.key === 'ArrowUp') {
+			selectedIndex = Math.max(0, selectedIndex - 1);
+			const item = document.querySelector(`[data-selected="true"]`);
+			item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
+			return true;
+		}
+		if (event.key === 'ArrowDown') {
+			selectedIndex = Math.min(selectedIndex + 1, filteredItems.length - 1);
+			const item = document.querySelector(`[data-selected="true"]`);
+			item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
+			return true;
+		}
+		if (event.key === 'Enter' || event.key === 'Tab') {
+			select(selectedIndex);
+
+			if (event.key === 'Enter') {
+				event.preventDefault();
+			}
+			return true;
+		}
+		if (event.key === 'Escape') {
+			// tell tiptap we handled it (it will close)
+			return true;
+		}
+		return false;
+	};
+
+	// This method will be called from the suggestion renderer
+	// @ts-ignore
+	export function _onKeyDown(event: KeyboardEvent) {
+		return onKeyDown(event);
+	}
+
+	const keydownListener = (e) => {
+		// required to prevent the default enter behavior
+		if (e.key === 'Enter') {
+			e.preventDefault();
+			select(selectedIndex);
+		}
+	};
+
+	onMount(async () => {
+		window.addEventListener('keydown', keydownListener);
+		if (channelSuggestions) {
+			// Add a dummy channel item
+			_channels = [
+				...$channels.map((c) => ({ type: 'channel', id: c.id, label: c.name, data: c }))
+			];
+		} else {
+			if (userSuggestions) {
+				await getUserList();
+			}
+
+			if (modelSuggestions) {
+				_models = [...$models.map((m) => ({ type: 'model', id: m.id, label: m.name, data: m }))];
+			}
+		}
+	});
+
+	onDestroy(() => {
+		window.removeEventListener('keydown', keydownListener);
+	});
+</script>
+
+{#if filteredItems.length}
+	<div
+		class="mention-list text-black dark:text-white rounded-2xl shadow-lg border border-gray-200 dark:border-gray-800 flex flex-col bg-white dark:bg-gray-850 w-72 p-1"
+		id="suggestions-container"
+	>
+		<div class="overflow-y-auto scrollbar-thin max-h-60">
+			{#each filteredItems as item, i}
+				{#if i === 0 || item?.type !== filteredItems[i - 1]?.type}
+					<div class="px-2 text-xs text-gray-500 py-1">
+						{#if item?.type === 'user'}
+							{$i18n.t('Users')}
+						{:else if item?.type === 'model'}
+							{$i18n.t('Models')}
+						{:else if item?.type === 'channel'}
+							{$i18n.t('Channels')}
+						{/if}
+					</div>
+				{/if}
+
+				<Tooltip content={item?.id} placement="top-start">
+					<button
+						type="button"
+						on:click={() => select(i)}
+						on:mousemove={() => {
+							selectedIndex = i;
+						}}
+						class="flex items-center justify-between px-2.5 py-1.5 rounded-xl w-full text-left {i ===
+						selectedIndex
+							? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
+							: ''}"
+						data-selected={i === selectedIndex}
+					>
+						{#if item.type === 'channel'}
+							<div class=" size-4 justify-center flex items-center mr-0.5">
+								{#if item?.data?.access_control === null}
+									<Hashtag className="size-3" strokeWidth="2.5" />
+								{:else}
+									<Lock className="size-[15px]" strokeWidth="2" />
+								{/if}
+							</div>
+						{:else if item.type === 'model'}
+							<img
+								src={item?.data?.info?.meta?.profile_image_url ??
+									`${WEBUI_BASE_URL}/static/favicon.png`}
+								alt={item?.data?.name ?? item.id}
+								class="rounded-full size-5 items-center mr-2"
+							/>
+						{:else if item.type === 'user'}
+							<img
+								src={`${WEBUI_API_BASE_URL}/users/${item.id}/profile/image`}
+								alt={item?.label ?? item.id}
+								class="rounded-full size-5 items-center mr-2"
+							/>
+						{/if}
+
+						<div class="truncate flex-1 pr-2">
+							{item.label}
+						</div>
+
+						<div class="shrink-0 text-xs text-gray-500">
+							{#if item.type === 'user'}
+								{$i18n.t('User')}
+							{:else if item.type === 'model'}
+								{$i18n.t('Model')}
+							{:else if item.type === 'channel'}
+								{$i18n.t('Channel')}
+							{/if}
+						</div>
+					</button>
+				</Tooltip>
+			{/each}
+		</div>
+	</div>
+{/if}

+ 3 - 6
src/lib/components/channel/Messages.svelte

@@ -63,11 +63,7 @@
 				</div>
 			</Loader>
 		{:else if !thread}
-			<div
-				class="px-5
-			
-			{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} mx-auto"
-			>
+			<div class="px-5 max-w-full mx-auto">
 				{#if channel}
 					<div class="flex flex-col gap-1.5 pb-5 pt-10">
 						<div class="text-2xl font-medium capitalize">{channel.name}</div>
@@ -99,7 +95,8 @@
 				{message}
 				{thread}
 				showUserProfile={messageIdx === 0 ||
-					messageList.at(messageIdx - 1)?.user_id !== message.user_id}
+					messageList.at(messageIdx - 1)?.user_id !== message.user_id ||
+					messageList.at(messageIdx - 1)?.meta?.model_id !== message?.meta?.model_id}
 				onDelete={() => {
 					messages = messages.filter((m) => m.id !== message.id);
 

+ 42 - 26
src/lib/components/channel/Messages/Message.svelte

@@ -15,7 +15,7 @@
 
 	import { settings, user, shortCodesToEmojis } from '$lib/stores';
 
-	import { WEBUI_BASE_URL } from '$lib/constants';
+	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
 
 	import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
 	import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
@@ -34,6 +34,8 @@
 	import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
 	import { formatDate } from '$lib/utils';
 	import Emoji from '$lib/components/common/Emoji.svelte';
+	import { t } from 'i18next';
+	import Skeleton from '$lib/components/chat/Messages/Skeleton.svelte';
 
 	export let message;
 	export let showUserProfile = true;
@@ -64,9 +66,7 @@
 	<div
 		class="flex flex-col justify-between px-5 {showUserProfile
 			? 'pt-1.5 pb-0.5'
-			: ''} w-full {($settings?.widescreenMode ?? null)
-			? 'max-w-full'
-			: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
+			: ''} w-full max-w-full mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
 	>
 		{#if !edit}
 			<div
@@ -138,19 +138,22 @@
 			id="message-{message.id}"
 			dir={$settings.chatDirection}
 		>
-			<div
-				class={`shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
-			>
+			<div class={`shrink-0 mr-3 w-9`}>
 				{#if showUserProfile}
-					<ProfilePreview user={message.user}>
-						<ProfileImage
-							src={message.user?.profile_image_url ??
-								($i18n.language === 'dg-DG'
-									? `${WEBUI_BASE_URL}/doge.png`
-									: `${WEBUI_BASE_URL}/static/favicon.png`)}
-							className={'size-8 translate-y-1 ml-0.5'}
+					{#if message?.meta?.model_id}
+						<img
+							src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
+							alt={message.meta.model_name ?? message.meta.model_id}
+							class="size-8 translate-y-1 ml-0.5 object-cover rounded-full"
 						/>
-					</ProfilePreview>
+					{:else}
+						<ProfilePreview user={message.user}>
+							<ProfileImage
+								src={message.user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
+								className={'size-8 translate-y-1 ml-0.5'}
+							/>
+						</ProfilePreview>
+					{/if}
 				{:else}
 					<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
 
@@ -170,7 +173,11 @@
 				{#if showUserProfile}
 					<Name>
 						<div class=" self-end text-base shrink-0 font-medium truncate">
-							{message?.user?.name}
+							{#if message?.meta?.model_id}
+								{message?.meta?.model_name ?? message?.meta?.model_id}
+							{:else}
+								{message?.user?.name}
+							{/if}
 						</div>
 
 						{#if message.created_at}
@@ -178,7 +185,12 @@
 								class=" self-center text-xs invisible group-hover:visible text-gray-400 font-medium first-letter:capitalize ml-0.5 translate-y-[1px]"
 							>
 								<Tooltip content={dayjs(message.created_at / 1000000).format('LLLL')}>
-									<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
+									<span class="line-clamp-1">
+										{$i18n.t(formatDate(message.created_at / 1000000), {
+											LOCALIZED_TIME: dayjs(message.created_at / 1000000).format('LT'),
+											LOCALIZED_DATE: dayjs(message.created_at / 1000000).format('L')
+										})}
+									</span>
 								</Tooltip>
 							</div>
 						{/if}
@@ -198,7 +210,7 @@
 										name={file.name}
 										type={file.type}
 										size={file?.size}
-										colorClassName="bg-white dark:bg-gray-850 "
+										small={true}
 									/>
 								{/if}
 							</div>
@@ -228,7 +240,7 @@
 							<div class="flex space-x-1.5">
 								<button
 									id="close-edit-message-button"
-									class="px-4 py-2 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
+									class="px-3.5 py-1.5 bg-white dark:bg-gray-900 hover:bg-gray-100 text-gray-800 dark:text-gray-100 transition rounded-3xl"
 									on:click={() => {
 										edit = false;
 										editedContent = null;
@@ -239,7 +251,7 @@
 
 								<button
 									id="confirm-edit-message-button"
-									class=" px-4 py-2 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
+									class="px-3.5 py-1.5 bg-gray-900 dark:bg-white hover:bg-gray-850 text-gray-100 dark:text-gray-800 transition rounded-3xl"
 									on:click={async () => {
 										onEdit(editedContent);
 										edit = false;
@@ -253,12 +265,16 @@
 					</div>
 				{:else}
 					<div class=" min-w-full markdown-prose">
-						<Markdown
-							id={message.id}
-							content={message.content}
-						/>{#if message.created_at !== message.updated_at}<span class="text-gray-500 text-[10px]"
-								>(edited)</span
-							>{/if}
+						{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id}
+							<Skeleton />
+						{:else}
+							<Markdown
+								id={message.id}
+								content={message.content}
+							/>{#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}<span
+									class="text-gray-500 text-[10px]">({$i18n.t('edited')})</span
+								>{/if}
+						{/if}
 					</div>
 
 					{#if (message?.reactions ?? []).length > 0}

+ 8 - 91
src/lib/components/channel/Messages/Message/ProfilePreview.svelte

@@ -1,101 +1,18 @@
 <script lang="ts">
-	import { DropdownMenu } from 'bits-ui';
+	import { LinkPreview } from 'bits-ui';
 	import { getContext } from 'svelte';
 
 	const i18n = getContext('i18n');
-
-	import { flyAndScale } from '$lib/utils/transitions';
-	import { WEBUI_BASE_URL } from '$lib/constants';
-	import { getUserActiveStatusById } from '$lib/apis/users';
-
-	export let side = 'right';
-	export let align = 'top';
+	import UserStatus from './UserStatus.svelte';
+	import UserStatusLinkPreview from './UserStatusLinkPreview.svelte';
 
 	export let user = null;
-	let show = false;
-
-	let active = false;
-
-	const getActiveStatus = async () => {
-		const res = await getUserActiveStatusById(localStorage.token, user.id).catch((error) => {
-			console.error('Error fetching user active status:', error);
-		});
-
-		if (res) {
-			active = res.active;
-		} else {
-			active = false;
-		}
-	};
-
-	$: if (show) {
-		getActiveStatus();
-	}
 </script>
 
-<DropdownMenu.Root
-	bind:open={show}
-	closeFocus={false}
-	onOpenChange={(state) => {}}
-	typeahead={false}
->
-	<DropdownMenu.Trigger>
+<LinkPreview.Root openDelay={0} closeDelay={0}>
+	<LinkPreview.Trigger class=" cursor-pointer no-underline! font-normal! ">
 		<slot />
-	</DropdownMenu.Trigger>
-
-	<slot name="content">
-		<DropdownMenu.Content
-			class="max-w-full w-[240px] rounded-lg z-9999 bg-white dark:bg-black dark:text-white shadow-lg"
-			sideOffset={8}
-			{side}
-			{align}
-			transition={flyAndScale}
-		>
-			{#if user}
-				<div class=" flex flex-col gap-2 w-full rounded-lg">
-					<div class="py-8 relative bg-gray-900 rounded-t-lg">
-						<img
-							crossorigin="anonymous"
-							src={user?.profile_image_url ?? `${WEBUI_BASE_URL}/static/favicon.png`}
-							class=" absolute -bottom-5 left-3 size-12 ml-0.5 object-cover rounded-full -translate-y-[1px]"
-							alt="profile"
-						/>
-					</div>
-
-					<div class=" flex flex-col pt-4 pb-2.5 px-4">
-						<div class=" -mb-1">
-							<span class="font-medium text-sm line-clamp-1"> {user.name} </span>
-						</div>
-
-						<div class=" flex items-center gap-2">
-							{#if active}
-								<div>
-									<span class="relative flex size-2">
-										<span
-											class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"
-										/>
-										<span class="relative inline-flex rounded-full size-2 bg-green-500" />
-									</span>
-								</div>
-
-								<div class=" -translate-y-[1px]">
-									<span class="text-xs"> {$i18n.t('Active')} </span>
-								</div>
-							{:else}
-								<div>
-									<span class="relative flex size-2">
-										<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
-									</span>
-								</div>
+	</LinkPreview.Trigger>
 
-								<div class=" -translate-y-[1px]">
-									<span class="text-xs"> {$i18n.t('Away')} </span>
-								</div>
-							{/if}
-						</div>
-					</div>
-				</div>
-			{/if}
-		</DropdownMenu.Content>
-	</slot>
-</DropdownMenu.Root>
+	<UserStatusLinkPreview id={user?.id} side="right" align="center" sideOffset={8} />
+</LinkPreview.Root>

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно