浏览代码

Merge branch 'open-webui:main' into main

MadsLang 3 月之前
父节点
当前提交
d4a26f8031
共有 100 个文件被更改,包括 82228 次插入1266 次删除
  1. 25 0
      .github/workflows/codespell.disabled
  2. 105 1
      CHANGELOG.md
  3. 1 1
      CODE_OF_CONDUCT.md
  4. 23 17
      LICENSE
  5. 12 2
      README.md
  6. 74 4
      backend/open_webui/config.py
  7. 13 2
      backend/open_webui/env.py
  8. 1 1
      backend/open_webui/internal/db.py
  9. 102 25
      backend/open_webui/main.py
  10. 70 0
      backend/open_webui/migrations/versions/3781e22d8b01_update_message_table.py
  11. 48 0
      backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py
  12. 26 0
      backend/open_webui/migrations/versions/7826ab40b532_update_file_table.py
  13. 136 0
      backend/open_webui/models/channels.py
  14. 96 0
      backend/open_webui/models/chats.py
  15. 5 0
      backend/open_webui/models/files.py
  16. 7 0
      backend/open_webui/models/groups.py
  17. 279 0
      backend/open_webui/models/messages.py
  18. 41 6
      backend/open_webui/models/users.py
  19. 7 2
      backend/open_webui/retrieval/utils.py
  20. 38 2
      backend/open_webui/retrieval/vector/dbs/pgvector.py
  21. 1 1
      backend/open_webui/retrieval/web/testdata/brave.json
  22. 3 3
      backend/open_webui/retrieval/web/utils.py
  23. 19 9
      backend/open_webui/routers/audio.py
  24. 22 1
      backend/open_webui/routers/auths.py
  25. 710 0
      backend/open_webui/routers/channels.py
  26. 24 0
      backend/open_webui/routers/chats.py
  27. 11 4
      backend/open_webui/routers/files.py
  28. 13 5
      backend/open_webui/routers/images.py
  29. 91 11
      backend/open_webui/routers/knowledge.py
  30. 11 9
      backend/open_webui/routers/ollama.py
  31. 11 8
      backend/open_webui/routers/openai.py
  32. 3 7
      backend/open_webui/routers/pipelines.py
  33. 107 6
      backend/open_webui/routers/retrieval.py
  34. 9 6
      backend/open_webui/routers/tasks.py
  35. 16 2
      backend/open_webui/routers/users.py
  36. 201 58
      backend/open_webui/socket/main.py
  37. 28 0
      backend/open_webui/socket/utils.py
  38. 2 2
      backend/open_webui/static/assets/pdf-style.css
  39. 二进制
      backend/open_webui/static/swagger-ui/favicon.png
  40. 65166 0
      backend/open_webui/static/swagger-ui/swagger-ui-bundle.js
  41. 9312 0
      backend/open_webui/static/swagger-ui/swagger-ui.css
  42. 3 1
      backend/open_webui/storage/provider.py
  43. 61 0
      backend/open_webui/tasks.py
  44. 22 0
      backend/open_webui/utils/access_control.py
  45. 14 0
      backend/open_webui/utils/auth.py
  46. 24 8
      backend/open_webui/utils/chat.py
  47. 19 12
      backend/open_webui/utils/images/comfyui.py
  48. 618 31
      backend/open_webui/utils/middleware.py
  49. 35 0
      backend/open_webui/utils/misc.py
  50. 0 1
      backend/open_webui/utils/models.py
  51. 67 0
      backend/open_webui/utils/oauth.py
  52. 7 0
      backend/open_webui/utils/payload.py
  53. 6 19
      backend/open_webui/utils/response.py
  54. 6 1
      backend/open_webui/utils/webhook.py
  55. 10 5
      backend/requirements.txt
  56. 20 11
      package-lock.json
  57. 3 2
      package.json
  58. 12 5
      pyproject.toml
  59. 5 1
      src/app.css
  60. 442 0
      src/lib/apis/channels/index.ts
  61. 38 0
      src/lib/apis/chats/index.ts
  62. 33 1
      src/lib/apis/index.ts
  63. 34 3
      src/lib/apis/openai/index.ts
  64. 1 1
      src/lib/apis/prompts/index.ts
  65. 1 0
      src/lib/apis/retrieval/index.ts
  66. 1 3
      src/lib/apis/streaming/index.ts
  67. 53 0
      src/lib/components/NotificationToast.svelte
  68. 21 1
      src/lib/components/admin/Settings/Documents.svelte
  69. 71 2
      src/lib/components/admin/Settings/General.svelte
  70. 13 0
      src/lib/components/admin/Settings/Images.svelte
  71. 1 1
      src/lib/components/admin/Settings/Models.svelte
  72. 302 0
      src/lib/components/channel/Channel.svelte
  73. 613 0
      src/lib/components/channel/MessageInput.svelte
  74. 77 0
      src/lib/components/channel/MessageInput/InputMenu.svelte
  75. 194 0
      src/lib/components/channel/Messages.svelte
  76. 357 0
      src/lib/components/channel/Messages/Message.svelte
  77. 85 0
      src/lib/components/channel/Messages/Message/ProfilePreview.svelte
  78. 166 0
      src/lib/components/channel/Messages/Message/ReactionPicker.svelte
  79. 86 0
      src/lib/components/channel/Navbar.svelte
  80. 204 0
      src/lib/components/channel/Thread.svelte
  81. 565 775
      src/lib/components/chat/Chat.svelte
  82. 339 0
      src/lib/components/chat/ContentRenderer/FloatingButtons.svelte
  83. 130 20
      src/lib/components/chat/MessageInput.svelte
  84. 7 1
      src/lib/components/chat/MessageInput/CallOverlay.svelte
  85. 57 3
      src/lib/components/chat/MessageInput/InputMenu.svelte
  86. 7 1
      src/lib/components/chat/MessageInput/VoiceRecording.svelte
  87. 5 1
      src/lib/components/chat/Messages.svelte
  88. 26 113
      src/lib/components/chat/Messages/ContentRenderer.svelte
  89. 15 4
      src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte
  90. 3 0
      src/lib/components/chat/Messages/Message.svelte
  91. 3 0
      src/lib/components/chat/Messages/MultiResponseMessages.svelte
  92. 1 1
      src/lib/components/chat/Messages/Name.svelte
  93. 12 3
      src/lib/components/chat/Messages/RateComment.svelte
  94. 73 24
      src/lib/components/chat/Messages/ResponseMessage.svelte
  95. 24 8
      src/lib/components/chat/Messages/Skeleton.svelte
  96. 194 0
      src/lib/components/chat/Navbar.svelte
  97. 42 0
      src/lib/components/chat/Settings/About.svelte
  98. 29 1
      src/lib/components/chat/Settings/Account.svelte
  99. 7 4
      src/lib/components/chat/Settings/Account/UpdatePassword.svelte
  100. 95 3
      src/lib/components/chat/Settings/Interface.svelte

+ 25 - 0
.github/workflows/codespell.disabled

@@ -0,0 +1,25 @@
+# Codespell configuration is within pyproject.toml
+---
+name: Codespell
+
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches: [main]
+
+permissions:
+  contents: read
+
+jobs:
+  codespell:
+    name: Check for spelling errors
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+      - name: Annotate locations with typos
+        uses: codespell-project/codespell-problem-matcher@v1
+      - name: Codespell
+        uses: codespell-project/actions-codespell@v2

+ 105 - 1
CHANGELOG.md

@@ -5,8 +5,112 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.5.4] - 2024-01-05
+
+### Added
+
+- **🔄 Clone Shared Chats**: Effortlessly clone shared chats to save time and streamline collaboration, perfect for reusing insightful discussions or custom setups.
+- **📣 Native Notifications for Channel Messages**: Stay informed with integrated desktop notifications for channel messages, ensuring you never miss important updates while multitasking.
+- **🔥 Torch MPS Support**: MPS support for Mac users when Open WebUI is installed directly, offering better performance and compatibility for AI workloads.
+- **🌍 Enhanced Translations**: Small improvements to various translations, ensuring a smoother global user experience.
+
+### Fixed
+
+- **🖼️ Image-Only Messages in Channels**: You can now send images without accompanying text or content in channels.
+- **❌ Proper Exception Handling**: Enhanced error feedback by ensuring exceptions are raised clearly, reducing confusion and promoting smoother debugging.
+- **🔍 RAG Query Generation Restored**: Fixed query generation issues for Retrieval-Augmented Generation, improving retrieval accuracy and ensuring seamless functionality.
+- **📩 MOA Response Functionality Fixed**: Addressed an error with the MOA response generation feature.
+- **💬 Channel Thread Loading with 50+ Messages**: Resolved an issue where channel threads stalled when exceeding 50 messages, ensuring smooth navigation in active discussions.
+- **🔑 API Endpoint Restrictions Resolution**: Fixed a critical bug where the 'API_KEY_ALLOWED_ENDPOINTS' setting was not functioning as intended, ensuring API access is limited to specified endpoints for enhanced security.
+- **🛠️ Action Functions Restored**: Corrected an issue preventing action functions from working, restoring their utility for customized automations and workflows.
+- **📂 Temporary Chat JSON Export Fix**: Resolved a bug blocking temporary chats from being exported in JSON format, ensuring seamless data portability.
+
+### Changed
+
+- **🎛️ Sidebar UI Tweaks**: Chat folders, including pinned folders, now display below the Chats section for better organization; the "New Folder" button has been relocated to the Chats section for a more intuitive workflow.
+- **🏗️ Real-Time Save Disabled by Default**: The 'ENABLE_REALTIME_CHAT_SAVE' setting is now off by default, boosting response speed for users who prioritize performance in high-paced workflows or less critical scenarios.
+- **🎤 Audio Input Echo Cancellation**: Audio input now features echo cancellation enabled by default, reducing audio feedback for improved clarity during conversations or voice-based interactions.
+- **🔧 General Reliability Improvements**: Numerous under-the-hood enhancements have been made to improve platform stability, boost overall performance, and ensure a more seamless, dependable experience across workflows.
+
+## [0.5.3] - 2024-12-31
+
+### Added
+
+- **💬 Channel Reactions with Built-In Emoji Picker**: Easily express yourself in channel threads and messages with reactions, featuring an intuitive built-in emoji picker for seamless selection.
+- **🧵 Threads for Channels**: Organize discussions within channels by creating threads, improving clarity and fostering focused conversations.
+- **🔄 Reset Button for SVG Pan/Zoom**: Added a handy reset button to SVG Pan/Zoom, allowing users to quickly return diagrams or visuals to their default state without hassle.
+- **⚡ Realtime Chat Save Environment Variable**: Introduced the ENABLE_REALTIME_CHAT_SAVE environment variable. Choose between faster responses by disabling realtime chat saving or ensuring chunk-by-chunk data persistency for critical operations.
+- **🌍 Translation Enhancements**: Updated and refined translations across multiple languages, providing a smoother experience for international users.
+- **📚 Improved Documentation**: Expanded documentation on functions, including clearer guidance on function plugins and detailed instructions for migrating to v0.5. This ensures users can adapt and harness new updates more effectively. (https://docs.openwebui.com/features/plugin/)
+
+### Fixed
+
+- **🛠️ Ollama Parameters Respected**: Resolved an issue where input parameters for Ollama were being ignored, ensuring precise and consistent model behavior.
+- **🔧 Function Plugin Outlet Hook Reliability**: Fixed a bug causing issues with 'event_emitter' and outlet hooks in filter function plugins, guaranteeing smoother operation within custom extensions.
+- **🖋️ Weird Custom Status Descriptions**: Adjusted the formatting and functionality for custom user statuses, ensuring they display correctly and intuitively.
+- **🔗 Restored API Functionality**: Fixed a critical issue where APIs were not operational for certain configurations, ensuring uninterrupted access.
+- **⏳ Custom Pipe Function Completion**: Resolved an issue where chats using specific custom pipe function plugins weren’t finishing properly, restoring consistent chat workflows.
+- **✅ General Stability Enhancements**: Implemented various under-the-hood improvements to boost overall reliability, ensuring smoother and more consistent performance across the WebUI.
+
+## [0.5.2] - 2024-12-26
+
+### Added
+
+- **🖊️ Typing Indicators in Channels**: Know exactly who’s typing in real-time within your channels, enhancing collaboration and keeping everyone engaged.
+- **👤 User Status Indicators**: Quickly view a user’s status by clicking their profile image in channels for better coordination and availability insights.
+- **🔒 Configurable API Key Authentication Restrictions**: Flexibly configure endpoint restrictions for API key authentication, now off by default for a smoother setup in trusted environments.
+
+### Fixed
+
+- **🔧 Playground Functionality Restored**: Resolved a critical issue where the playground wasn’t working, ensuring seamless experimentation and troubleshooting workflows.
+- **📊 Corrected Ollama Usage Statistics**: Fixed a calculation error in Ollama’s usage statistics, providing more accurate tracking and insights for better resource management.
+- **🔗 Pipelines Outlet Hook Registration**: Addressed an issue where outlet hooks for pipelines weren’t registered, restoring functionality and consistency in pipeline workflows.
+- **🎨 Image Generation Error**: Resolved a persistent issue causing errors with 'get_automatic1111_api_auth()' to ensure smooth image generation workflows.
+- **🎙️ Text-to-Speech Error**: Fixed the missing argument in Eleven Labs’ 'get_available_voices()', restoring full text-to-speech capabilities for uninterrupted voice interactions.
+- **🖋️ Title Generation Issue**: Fixed a bug where title generation was not working in certain cases, ensuring consistent and reliable chat organization.
+
+## [0.5.1] - 2024-12-25
+
+### Added
+
+- **🔕 Notification Sound Toggle**: Added a new setting under Settings > Interface to disable notification sounds, giving you greater control over your workspace environment and focus.
+
+### Fixed
+
+- **🔄 Non-Streaming Response Visibility**: Resolved an issue where non-streaming responses were not displayed, ensuring all responses are now reliably shown in your conversations.
+- **🖋️ Title Generation with OpenAI APIs**: Fixed a bug preventing title generation when using OpenAI APIs, restoring the ability to automatically generate chat titles for smoother organization.
+- **👥 Admin Panel User List**: Addressed the issue where only 50 users were visible in the admin panel. You can now manage and view all users without restrictions.
+- **🖼️ Image Generation Error**: Fixed the issue causing 'get_automatic1111_api_auth()' errors in image generation, ensuring seamless creative workflows.
+- **⚙️ Pipeline Settings Loading Issue**: Resolved a problem where pipeline settings were stuck at the loading screen, restoring full configurability in the admin panel.
+
+## [0.5.0] - 2024-12-25
+
 ### Added
-- **🌐 Enhanced Translations**: Added Slovak language, improved Czech language.
+
+- **💬 True Asynchronous Chat Support**: Create chats, navigate away, and return anytime with responses ready. Ideal for reasoning models and multi-agent workflows, enhancing multitasking like never before.
+- **🔔 Chat Completion Notifications**: Never miss a completed response. Receive instant in-UI notifications when a chat finishes in a non-active tab, keeping you updated while you work elsewhere.
+- **🌐 Notification Webhook Integration**: Get alerts via webhooks even when your tab is closed! Configure your webhook URL in Settings > Account and receive timely updates for long-running chats or external integration needs.
+- **📚 Channels (Beta)**: Explore Discord/Slack-style chat rooms designed for real-time collaboration between users and AIs. Build bots for channels and unlock asynchronous communication for proactive multi-agent workflows. Opt-in via Admin Settings > General. A Comprehensive Bot SDK tutorial (https://github.com/open-webui/bot) is incoming, so stay tuned!
+- **🖼️ Client-Side Image Compression**: Now compress images before upload (Settings > Interface), saving bandwidth and improving performance seamlessly.
+- **🛠️ OAuth Management for User Groups**: Enable group-level management via OAuth integration for enhanced control and scalability in collaborative environments.
+- **✅ Structured Output for Ollama**: Pass structured data output directly to Ollama, unlocking new possibilities for streamlined automation and precise data handling.
+- **📜 Offline Swagger Documentation**: Developer-friendly Swagger API docs are now available offline, ensuring full accessibility wherever you are.
+- **📸 Quick Screen Capture Button**: Effortlessly capture your screen with a single click from the message input menu.
+- **🌍 i18n Updates**: Improved and refined translations across several languages, including Ukrainian, German, Brazilian Portuguese, Catalan, and more, ensuring a seamless global user experience.
+
+### Fixed
+
+- **📋 Table Export to CSV**: Resolved issues with CSV export where headers were missing or errors occurred due to values with commas, ensuring smooth and reliable data handling.
+- **🔓 BYPASS_MODEL_ACCESS_CONTROL**: Fixed an issue where users could see models but couldn’t use them with 'BYPASS_MODEL_ACCESS_CONTROL=True', restoring proper functionality for environments leveraging this setting.
+
+### Changed
+
+- **💡 API Key Authentication Restriction**: Narrowed API key auth permissions to '/api/models' and '/api/chat/completions' for enhanced security and better API governance.
+- **⚙️ Backend Overhaul for Performance**: Major backend restructuring; a heads-up that some "Functions" using internal variables may face compatibility issues. Moving forward, websocket support is mandatory to ensure Open WebUI operates seamlessly.
+
+### Removed
+
+- **⚠️ Legacy Functionality Clean-Up**: Deprecated outdated backend systems that were non-essential or overlapped with newer implementations, allowing for a leaner, more efficient platform.
 
 ## [0.4.8] - 2024-12-07
 

+ 1 - 1
CODE_OF_CONDUCT.md

@@ -2,7 +2,7 @@
 
 ## Our Pledge
 
-As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
+As members, contributors, and leaders of this community, we pledge to make participation in our open-source project a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socioeconomic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
 
 We are committed to creating and maintaining an open, respectful, and professional environment where positive contributions and meaningful discussions can flourish. By participating in this project, you agree to uphold these values and align your behavior to the standards outlined in this Code of Conduct.
 

+ 23 - 17
LICENSE

@@ -1,21 +1,27 @@
-MIT License
+Copyright (c) 2023-2025 Timothy Jaeryang Baek
+All rights reserved.
 
-Copyright (c) 2023 Timothy Jaeryang Baek
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
 
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
 
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
 
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 12 - 2
README.md

@@ -11,7 +11,9 @@
 [![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
 [![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tjbck)
 
-Open WebUI is an [extensible](https://github.com/open-webui/pipelines), feature-rich, and user-friendly self-hosted WebUI designed to operate entirely offline. It supports various LLM runners, including Ollama and OpenAI-compatible APIs. For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
+**Open WebUI is an [extensible](https://docs.openwebui.com/features/plugin/), feature-rich, and user-friendly self-hosted AI platform designed to operate entirely offline.** It supports various LLM runners like **Ollama** and **OpenAI-compatible APIs**, with **built-in inference engine** for RAG, making it a **powerful AI deployment solution**.
+
+For more information, be sure to check out our [Open WebUI Documentation](https://docs.openwebui.com/).
 
 ![Open WebUI Demo](./demo.gif)
 
@@ -185,13 +187,21 @@ If you want to try out the latest bleeding-edge features and are okay with occas
 docker run -d -p 3000:8080 -v open-webui:/app/backend/data --name open-webui --add-host=host.docker.internal:host-gateway --restart always ghcr.io/open-webui/open-webui:dev
 ```
 
+### Offline Mode
+
+If you are running Open WebUI in an offline environment, you can set the `HF_HUB_OFFLINE` environment variable to `1` to prevent attempts to download models from the internet.
+
+```bash
+export HF_HUB_OFFLINE=1
+```
+
 ## What's Next? 🌟
 
 Discover upcoming features on our roadmap in the [Open WebUI Documentation](https://docs.openwebui.com/roadmap/).
 
 ## License 📜
 
-This project is licensed under the [MIT License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
+This project is licensed under the [BSD-3-Clause License](LICENSE) - see the [LICENSE](LICENSE) file for details. 📄
 
 ## Support 💬
 

+ 74 - 4
backend/open_webui/config.py

@@ -21,7 +21,7 @@ from open_webui.env import (
     WEBUI_NAME,
     log,
     DATABASE_URL,
-    OFFLINE_MODE
+    OFFLINE_MODE,
 )
 from pydantic import BaseModel
 from sqlalchemy import JSON, Column, DateTime, Integer, func
@@ -272,6 +272,18 @@ ENABLE_API_KEY = PersistentConfig(
     os.environ.get("ENABLE_API_KEY", "True").lower() == "true",
 )
 
+ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = PersistentConfig(
+    "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS",
+    "auth.api_key.endpoint_restrictions",
+    os.environ.get("ENABLE_API_KEY_ENDPOINT_RESTRICTIONS", "False").lower() == "true",
+)
+
+API_KEY_ALLOWED_ENDPOINTS = PersistentConfig(
+    "API_KEY_ALLOWED_ENDPOINTS",
+    "auth.api_key.allowed_endpoints",
+    os.environ.get("API_KEY_ALLOWED_ENDPOINTS", ""),
+)
+
 
 JWT_EXPIRES_IN = PersistentConfig(
     "JWT_EXPIRES_IN", "auth.jwt_expiry", os.environ.get("JWT_EXPIRES_IN", "-1")
@@ -307,6 +319,7 @@ GOOGLE_CLIENT_SECRET = PersistentConfig(
     os.environ.get("GOOGLE_CLIENT_SECRET", ""),
 )
 
+
 GOOGLE_OAUTH_SCOPE = PersistentConfig(
     "GOOGLE_OAUTH_SCOPE",
     "oauth.google.scope",
@@ -403,12 +416,24 @@ OAUTH_EMAIL_CLAIM = PersistentConfig(
     os.environ.get("OAUTH_EMAIL_CLAIM", "email"),
 )
 
+OAUTH_GROUPS_CLAIM = PersistentConfig(
+    "OAUTH_GROUPS_CLAIM",
+    "oauth.oidc.group_claim",
+    os.environ.get("OAUTH_GROUP_CLAIM", "groups"),
+)
+
 ENABLE_OAUTH_ROLE_MANAGEMENT = PersistentConfig(
     "ENABLE_OAUTH_ROLE_MANAGEMENT",
     "oauth.enable_role_mapping",
     os.environ.get("ENABLE_OAUTH_ROLE_MANAGEMENT", "False").lower() == "true",
 )
 
+ENABLE_OAUTH_GROUP_MANAGEMENT = PersistentConfig(
+    "ENABLE_OAUTH_GROUP_MANAGEMENT",
+    "oauth.enable_group_mapping",
+    os.environ.get("ENABLE_OAUTH_GROUP_MANAGEMENT", "False").lower() == "true",
+)
+
 OAUTH_ROLES_CLAIM = PersistentConfig(
     "OAUTH_ROLES_CLAIM",
     "oauth.roles_claim",
@@ -696,6 +721,12 @@ OPENAI_API_BASE_URL = "https://api.openai.com/v1"
 # WEBUI
 ####################################
 
+
+WEBUI_URL = PersistentConfig(
+    "WEBUI_URL", "webui.url", os.environ.get("WEBUI_URL", "http://localhost:3000")
+)
+
+
 ENABLE_SIGNUP = PersistentConfig(
     "ENABLE_SIGNUP",
     "ui.enable_signup",
@@ -823,6 +854,12 @@ USER_PERMISSIONS = PersistentConfig(
     },
 )
 
+ENABLE_CHANNELS = PersistentConfig(
+    "ENABLE_CHANNELS",
+    "channels.enable",
+    os.environ.get("ENABLE_CHANNELS", "False").lower() == "true",
+)
+
 
 ENABLE_EVALUATION_ARENA_MODELS = PersistentConfig(
     "ENABLE_EVALUATION_ARENA_MODELS",
@@ -1174,11 +1211,34 @@ if VECTOR_DB == "pgvector" and not PGVECTOR_DB_URL.startswith("postgres"):
     raise ValueError(
         "Pgvector requires setting PGVECTOR_DB_URL or using Postgres with vector extension as the primary database."
     )
+PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH = int(
+    os.environ.get("PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH", "1536")
+)
 
 ####################################
 # Information Retrieval (RAG)
 ####################################
 
+
+# If configured, Google Drive will be available as an upload option.
+ENABLE_GOOGLE_DRIVE_INTEGRATION = PersistentConfig(
+    "ENABLE_GOOGLE_DRIVE_INTEGRATION",
+    "google_drive.enable",
+    os.getenv("ENABLE_GOOGLE_DRIVE_INTEGRATION", "False").lower() == "true",
+)
+
+GOOGLE_DRIVE_CLIENT_ID = PersistentConfig(
+    "GOOGLE_DRIVE_CLIENT_ID",
+    "google_drive.client_id",
+    os.environ.get("GOOGLE_DRIVE_CLIENT_ID", ""),
+)
+
+GOOGLE_DRIVE_API_KEY = PersistentConfig(
+    "GOOGLE_DRIVE_API_KEY",
+    "google_drive.api_key",
+    os.environ.get("GOOGLE_DRIVE_API_KEY", ""),
+)
+
 # RAG Content Extraction
 CONTENT_EXTRACTION_ENGINE = PersistentConfig(
     "CONTENT_EXTRACTION_ENGINE",
@@ -1253,7 +1313,8 @@ RAG_EMBEDDING_MODEL = PersistentConfig(
 log.info(f"Embedding model set: {RAG_EMBEDDING_MODEL.value}")
 
 RAG_EMBEDDING_MODEL_AUTO_UPDATE = (
-    not OFFLINE_MODE and os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "True").lower() == "true"
+    not OFFLINE_MODE
+    and os.environ.get("RAG_EMBEDDING_MODEL_AUTO_UPDATE", "True").lower() == "true"
 )
 
 RAG_EMBEDDING_MODEL_TRUST_REMOTE_CODE = (
@@ -1278,7 +1339,8 @@ if RAG_RERANKING_MODEL.value != "":
     log.info(f"Reranking model set: {RAG_RERANKING_MODEL.value}")
 
 RAG_RERANKING_MODEL_AUTO_UPDATE = (
-    not OFFLINE_MODE and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true"
+    not OFFLINE_MODE
+    and os.environ.get("RAG_RERANKING_MODEL_AUTO_UPDATE", "True").lower() == "true"
 )
 
 RAG_RERANKING_MODEL_TRUST_REMOTE_CODE = (
@@ -1412,6 +1474,7 @@ RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = PersistentConfig(
     ],
 )
 
+
 SEARXNG_QUERY_URL = PersistentConfig(
     "SEARXNG_QUERY_URL",
     "rag.web.search.searxng_query_url",
@@ -1587,6 +1650,12 @@ COMFYUI_BASE_URL = PersistentConfig(
     os.getenv("COMFYUI_BASE_URL", ""),
 )
 
+COMFYUI_API_KEY = PersistentConfig(
+    "COMFYUI_API_KEY",
+    "image_generation.comfyui.api_key",
+    os.getenv("COMFYUI_API_KEY", ""),
+)
+
 COMFYUI_DEFAULT_WORKFLOW = """
 {
   "3": {
@@ -1748,7 +1817,8 @@ WHISPER_MODEL = PersistentConfig(
 
 WHISPER_MODEL_DIR = os.getenv("WHISPER_MODEL_DIR", f"{CACHE_DIR}/whisper/models")
 WHISPER_MODEL_AUTO_UPDATE = (
-    not OFFLINE_MODE and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
+    not OFFLINE_MODE
+    and os.environ.get("WHISPER_MODEL_AUTO_UPDATE", "").lower() == "true"
 )
 
 

+ 13 - 2
backend/open_webui/env.py

@@ -53,6 +53,11 @@ if USE_CUDA.lower() == "true":
 else:
     DEVICE_TYPE = "cpu"
 
+try:
+    if torch.backends.mps.is_available() and torch.backends.mps.is_built():
+        DEVICE_TYPE = "mps"
+except Exception:
+    pass
 
 ####################################
 # LOGGING
@@ -103,8 +108,6 @@ WEBUI_NAME = os.environ.get("WEBUI_NAME", "Open WebUI")
 if WEBUI_NAME != "Open WebUI":
     WEBUI_NAME += " (Open WebUI)"
 
-WEBUI_URL = os.environ.get("WEBUI_URL", "http://localhost:3000")
-
 WEBUI_FAVICON_URL = "https://openwebui.com/favicon.png"
 
 
@@ -315,6 +318,11 @@ RESET_CONFIG_ON_START = (
     os.environ.get("RESET_CONFIG_ON_START", "False").lower() == "true"
 )
 
+
+ENABLE_REALTIME_CHAT_SAVE = (
+    os.environ.get("ENABLE_REALTIME_CHAT_SAVE", "False").lower() == "true"
+)
+
 ####################################
 # REDIS
 ####################################
@@ -396,3 +404,6 @@ else:
 ####################################
 
 OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
+
+if OFFLINE_MODE:
+    os.environ["HF_HUB_OFFLINE"] = "1"

+ 1 - 1
backend/open_webui/internal/db.py

@@ -55,7 +55,7 @@ def handle_peewee_migration(DATABASE_URL):
     try:
         # Replace the postgresql:// with postgres:// to handle the peewee migration
         db = register_connection(DATABASE_URL.replace("postgresql://", "postgres://"))
-        migrate_dir = OPEN_WEBUI_DIR / "apps" / "webui" / "internal" / "migrations"
+        migrate_dir = OPEN_WEBUI_DIR / "internal" / "migrations"
         router = Router(db, logger=log, migrate_dir=migrate_dir)
         router.run()
         db.close()

+ 102 - 25
backend/open_webui/main.py

@@ -18,6 +18,8 @@ from typing import Optional
 from aiocache import cached
 import aiohttp
 import requests
+
+
 from fastapi import (
     Depends,
     FastAPI,
@@ -27,7 +29,12 @@ from fastapi import (
     Request,
     UploadFile,
     status,
+    applications,
+    BackgroundTasks,
 )
+
+from fastapi.openapi.docs import get_swagger_ui_html
+
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse, RedirectResponse
 from fastapi.staticfiles import StaticFiles
@@ -51,6 +58,7 @@ from open_webui.routers import (
     pipelines,
     tasks,
     auths,
+    channels,
     chats,
     folders,
     configs,
@@ -96,6 +104,7 @@ from open_webui.config import (
     AUTOMATIC1111_SAMPLER,
     AUTOMATIC1111_SCHEDULER,
     COMFYUI_BASE_URL,
+    COMFYUI_API_KEY,
     COMFYUI_WORKFLOW,
     COMFYUI_WORKFLOW_NODES,
     ENABLE_IMAGE_GENERATION,
@@ -171,10 +180,13 @@ from open_webui.config import (
     MOJEEK_SEARCH_API_KEY,
     GOOGLE_PSE_API_KEY,
     GOOGLE_PSE_ENGINE_ID,
+    GOOGLE_DRIVE_CLIENT_ID,
+    GOOGLE_DRIVE_API_KEY,
     ENABLE_RAG_HYBRID_SEARCH,
     ENABLE_RAG_LOCAL_WEB_FETCH,
     ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
     ENABLE_RAG_WEB_SEARCH,
+    ENABLE_GOOGLE_DRIVE_INTEGRATION,
     UPLOAD_DIR,
     # WebUI
     WEBUI_AUTH,
@@ -187,6 +199,9 @@ from open_webui.config import (
     ENABLE_SIGNUP,
     ENABLE_LOGIN_FORM,
     ENABLE_API_KEY,
+    ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
+    API_KEY_ALLOWED_ENDPOINTS,
+    ENABLE_CHANNELS,
     ENABLE_COMMUNITY_SHARING,
     ENABLE_MESSAGE_RATING,
     ENABLE_EVALUATION_ARENA_MODELS,
@@ -226,6 +241,7 @@ from open_webui.config import (
     CORS_ALLOW_ORIGIN,
     DEFAULT_LOCALE,
     OAUTH_PROVIDERS,
+    WEBUI_URL,
     # Admin
     ENABLE_ADMIN_CHAT_ACCESS,
     ENABLE_ADMIN_EXPORT,
@@ -251,13 +267,13 @@ from open_webui.env import (
     SAFE_MODE,
     SRC_LOG_LEVELS,
     VERSION,
-    WEBUI_URL,
     WEBUI_BUILD_HASH,
     WEBUI_SECRET_KEY,
     WEBUI_SESSION_COOKIE_SAME_SITE,
     WEBUI_SESSION_COOKIE_SECURE,
     WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
     WEBUI_AUTH_TRUSTED_NAME_HEADER,
+    ENABLE_WEBSOCKET_SUPPORT,
     BYPASS_MODEL_ACCESS_CONTROL,
     RESET_CONFIG_ON_START,
     OFFLINE_MODE,
@@ -285,6 +301,7 @@ from open_webui.utils.auth import (
 from open_webui.utils.oauth import oauth_manager
 from open_webui.utils.security_headers import SecurityHeadersMiddleware
 
+from open_webui.tasks import stop_task, list_tasks  # Import from tasks.py
 
 if SAFE_MODE:
     print("SAFE MODE ENABLED")
@@ -374,9 +391,15 @@ app.state.OPENAI_MODELS = {}
 #
 ########################################
 
+app.state.config.WEBUI_URL = WEBUI_URL
 app.state.config.ENABLE_SIGNUP = ENABLE_SIGNUP
 app.state.config.ENABLE_LOGIN_FORM = ENABLE_LOGIN_FORM
+
 app.state.config.ENABLE_API_KEY = ENABLE_API_KEY
+app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
+    ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
+)
+app.state.config.API_KEY_ALLOWED_ENDPOINTS = API_KEY_ALLOWED_ENDPOINTS
 
 app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
 
@@ -393,6 +416,8 @@ app.state.config.WEBHOOK_URL = WEBHOOK_URL
 app.state.config.BANNERS = WEBUI_BANNERS
 app.state.config.MODEL_ORDER_LIST = MODEL_ORDER_LIST
 
+
+app.state.config.ENABLE_CHANNELS = ENABLE_CHANNELS
 app.state.config.ENABLE_COMMUNITY_SHARING = ENABLE_COMMUNITY_SHARING
 app.state.config.ENABLE_MESSAGE_RATING = ENABLE_MESSAGE_RATING
 
@@ -477,6 +502,7 @@ app.state.config.ENABLE_RAG_WEB_SEARCH = ENABLE_RAG_WEB_SEARCH
 app.state.config.RAG_WEB_SEARCH_ENGINE = RAG_WEB_SEARCH_ENGINE
 app.state.config.RAG_WEB_SEARCH_DOMAIN_FILTER_LIST = RAG_WEB_SEARCH_DOMAIN_FILTER_LIST
 
+app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = ENABLE_GOOGLE_DRIVE_INTEGRATION
 app.state.config.SEARXNG_QUERY_URL = SEARXNG_QUERY_URL
 app.state.config.GOOGLE_PSE_API_KEY = GOOGLE_PSE_API_KEY
 app.state.config.GOOGLE_PSE_ENGINE_ID = GOOGLE_PSE_ENGINE_ID
@@ -504,6 +530,22 @@ app.state.rf = None
 app.state.YOUTUBE_LOADER_TRANSLATION = None
 
 
+try:
+    app.state.ef = get_ef(
+        app.state.config.RAG_EMBEDDING_ENGINE,
+        app.state.config.RAG_EMBEDDING_MODEL,
+        RAG_EMBEDDING_MODEL_AUTO_UPDATE,
+    )
+
+    app.state.rf = get_rf(
+        app.state.config.RAG_RERANKING_MODEL,
+        RAG_RERANKING_MODEL_AUTO_UPDATE,
+    )
+except Exception as e:
+    log.error(f"Error updating models: {e}")
+    pass
+
+
 app.state.EMBEDDING_FUNCTION = get_embedding_function(
     app.state.config.RAG_EMBEDDING_ENGINE,
     app.state.config.RAG_EMBEDDING_MODEL,
@@ -521,21 +563,6 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
     app.state.config.RAG_EMBEDDING_BATCH_SIZE,
 )
 
-try:
-    app.state.ef = get_ef(
-        app.state.config.RAG_EMBEDDING_ENGINE,
-        app.state.config.RAG_EMBEDDING_MODEL,
-        RAG_EMBEDDING_MODEL_AUTO_UPDATE,
-    )
-
-    app.state.rf = get_rf(
-        app.state.config.RAG_RERANKING_MODEL,
-        RAG_RERANKING_MODEL_AUTO_UPDATE,
-    )
-except Exception as e:
-    log.error(f"Error updating models: {e}")
-    pass
-
 
 ########################################
 #
@@ -557,6 +584,7 @@ app.state.config.AUTOMATIC1111_CFG_SCALE = AUTOMATIC1111_CFG_SCALE
 app.state.config.AUTOMATIC1111_SAMPLER = AUTOMATIC1111_SAMPLER
 app.state.config.AUTOMATIC1111_SCHEDULER = AUTOMATIC1111_SCHEDULER
 app.state.config.COMFYUI_BASE_URL = COMFYUI_BASE_URL
+app.state.config.COMFYUI_API_KEY = COMFYUI_API_KEY
 app.state.config.COMFYUI_WORKFLOW = COMFYUI_WORKFLOW
 app.state.config.COMFYUI_WORKFLOW_NODES = COMFYUI_WORKFLOW_NODES
 
@@ -722,6 +750,8 @@ app.include_router(configs.router, prefix="/api/v1/configs", tags=["configs"])
 app.include_router(auths.router, prefix="/api/v1/auths", tags=["auths"])
 app.include_router(users.router, prefix="/api/v1/users", tags=["users"])
 
+
+app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"])
 app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"])
 
 app.include_router(models.router, prefix="/api/v1/models", tags=["models"])
@@ -810,11 +840,11 @@ async def chat_completion(
     request: Request,
     form_data: dict,
     user=Depends(get_verified_user),
-    bypass_filter: bool = False,
 ):
     if not request.app.state.MODELS:
         await get_all_models(request)
 
+    tasks = form_data.pop("background_tasks", None)
     try:
         model_id = form_data.get("model", None)
         if model_id not in request.app.state.MODELS:
@@ -822,13 +852,26 @@ async def chat_completion(
         model = request.app.state.MODELS[model_id]
 
         # Check if user has access to the model
-        if not bypass_filter and user.role == "user":
+        if not BYPASS_MODEL_ACCESS_CONTROL and user.role == "user":
             try:
                 check_model_access(user, model)
             except Exception as e:
                 raise e
 
-        form_data, events = await process_chat_payload(request, form_data, user, model)
+        metadata = {
+            "user_id": user.id,
+            "chat_id": form_data.pop("chat_id", None),
+            "message_id": form_data.pop("id", None),
+            "session_id": form_data.pop("session_id", None),
+            "tool_ids": form_data.get("tool_ids", None),
+            "files": form_data.get("files", None),
+            "features": form_data.get("features", None),
+        }
+        form_data["metadata"] = metadata
+
+        form_data, events = await process_chat_payload(
+            request, form_data, metadata, user, model
+        )
     except Exception as e:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
@@ -836,10 +879,10 @@ async def chat_completion(
         )
 
     try:
-        response = await chat_completion_handler(
-            request, form_data, user, bypass_filter
+        response = await chat_completion_handler(request, form_data, user)
+        return await process_chat_response(
+            request, response, form_data, user, events, metadata, tasks
         )
-        return await process_chat_response(response, events)
     except Exception as e:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,
@@ -878,6 +921,20 @@ async def chat_action(
         )
 
 
+@app.post("/api/tasks/stop/{task_id}")
+async def stop_task_endpoint(task_id: str, user=Depends(get_verified_user)):
+    try:
+        result = await stop_task(task_id)  # Use the function from tasks.py
+        return result
+    except ValueError as e:
+        raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
+
+
+@app.get("/api/tasks")
+async def list_tasks_endpoint(user=Depends(get_verified_user)):
+    return {"tasks": list_tasks()}  # Use the function from tasks.py
+
+
 ##################################
 #
 # Config Endpoints
@@ -925,9 +982,12 @@ async def get_app_config(request: Request):
             "enable_api_key": app.state.config.ENABLE_API_KEY,
             "enable_signup": app.state.config.ENABLE_SIGNUP,
             "enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
+            "enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
             **(
                 {
+                    "enable_channels": app.state.config.ENABLE_CHANNELS,
                     "enable_web_search": app.state.config.ENABLE_RAG_WEB_SEARCH,
+                    "enable_google_drive_integration": app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
                     "enable_image_generation": app.state.config.ENABLE_IMAGE_GENERATION,
                     "enable_community_sharing": app.state.config.ENABLE_COMMUNITY_SHARING,
                     "enable_message_rating": app.state.config.ENABLE_MESSAGE_RATING,
@@ -938,6 +998,10 @@ async def get_app_config(request: Request):
                 else {}
             ),
         },
+        "google_drive": {
+            "client_id": GOOGLE_DRIVE_CLIENT_ID.value,
+            "api_key": GOOGLE_DRIVE_API_KEY.value,
+        },
         **(
             {
                 "default_models": app.state.config.DEFAULT_MODELS,
@@ -1082,9 +1146,9 @@ async def get_opensearch_xml():
     <ShortName>{WEBUI_NAME}</ShortName>
     <Description>Search {WEBUI_NAME}</Description>
     <InputEncoding>UTF-8</InputEncoding>
-    <Image width="16" height="16" type="image/x-icon">{WEBUI_URL}/static/favicon.png</Image>
-    <Url type="text/html" method="get" template="{WEBUI_URL}/?q={"{searchTerms}"}"/>
-    <moz:SearchForm>{WEBUI_URL}</moz:SearchForm>
+    <Image width="16" height="16" type="image/x-icon">{app.state.config.WEBUI_URL}/static/favicon.png</Image>
+    <Url type="text/html" method="get" template="{app.state.config.WEBUI_URL}/?q={"{searchTerms}"}"/>
+    <moz:SearchForm>{app.state.config.WEBUI_URL}</moz:SearchForm>
     </OpenSearchDescription>
     """
     return Response(content=xml_content, media_type="application/xml")
@@ -1104,6 +1168,19 @@ async def healthcheck_with_db():
 app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
 app.mount("/cache", StaticFiles(directory=CACHE_DIR), name="cache")
 
+
+def swagger_ui_html(*args, **kwargs):
+    return get_swagger_ui_html(
+        *args,
+        **kwargs,
+        swagger_js_url="/static/swagger-ui/swagger-ui-bundle.js",
+        swagger_css_url="/static/swagger-ui/swagger-ui.css",
+        swagger_favicon_url="/static/swagger-ui/favicon.png",
+    )
+
+
+applications.get_swagger_ui_html = swagger_ui_html
+
 if os.path.exists(FRONTEND_BUILD_DIR):
     mimetypes.add_type("text/javascript", ".js")
     app.mount(

+ 70 - 0
backend/open_webui/migrations/versions/3781e22d8b01_update_message_table.py

@@ -0,0 +1,70 @@
+"""Update message & channel tables
+
+Revision ID: 3781e22d8b01
+Revises: 7826ab40b532
+Create Date: 2024-12-30 03:00:00.000000
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "3781e22d8b01"
+down_revision = "7826ab40b532"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    # Add 'type' column to the 'channel' table
+    op.add_column(
+        "channel",
+        sa.Column(
+            "type",
+            sa.Text(),
+            nullable=True,
+        ),
+    )
+
+    # Add 'parent_id' column to the 'message' table for threads
+    op.add_column(
+        "message",
+        sa.Column("parent_id", sa.Text(), nullable=True),
+    )
+
+    op.create_table(
+        "message_reaction",
+        sa.Column(
+            "id", sa.Text(), nullable=False, primary_key=True, unique=True
+        ),  # Unique reaction ID
+        sa.Column("user_id", sa.Text(), nullable=False),  # User who reacted
+        sa.Column(
+            "message_id", sa.Text(), nullable=False
+        ),  # Message that was reacted to
+        sa.Column(
+            "name", sa.Text(), nullable=False
+        ),  # Reaction name (e.g. "thumbs_up")
+        sa.Column(
+            "created_at", sa.BigInteger(), nullable=True
+        ),  # Timestamp of when the reaction was added
+    )
+
+    op.create_table(
+        "channel_member",
+        sa.Column(
+            "id", sa.Text(), nullable=False, primary_key=True, unique=True
+        ),  # Record ID for the membership row
+        sa.Column("channel_id", sa.Text(), nullable=False),  # Associated channel
+        sa.Column("user_id", sa.Text(), nullable=False),  # Associated user
+        sa.Column(
+            "created_at", sa.BigInteger(), nullable=True
+        ),  # Timestamp of when the user joined the channel
+    )
+
+
+def downgrade():
+    # Revert 'type' column addition to the 'channel' table
+    op.drop_column("channel", "type")
+    op.drop_column("message", "parent_id")
+    op.drop_table("message_reaction")
+    op.drop_table("channel_member")

+ 48 - 0
backend/open_webui/migrations/versions/57c599a3cb57_add_channel_table.py

@@ -0,0 +1,48 @@
+"""Add channel table
+
+Revision ID: 57c599a3cb57
+Revises: 922e7a387820
+Create Date: 2024-12-22 03:00:00.000000
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "57c599a3cb57"
+down_revision = "922e7a387820"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.create_table(
+        "channel",
+        sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
+        sa.Column("user_id", sa.Text()),
+        sa.Column("name", sa.Text()),
+        sa.Column("description", sa.Text(), nullable=True),
+        sa.Column("data", sa.JSON(), nullable=True),
+        sa.Column("meta", sa.JSON(), nullable=True),
+        sa.Column("access_control", sa.JSON(), nullable=True),
+        sa.Column("created_at", sa.BigInteger(), nullable=True),
+        sa.Column("updated_at", sa.BigInteger(), nullable=True),
+    )
+
+    op.create_table(
+        "message",
+        sa.Column("id", sa.Text(), nullable=False, primary_key=True, unique=True),
+        sa.Column("user_id", sa.Text()),
+        sa.Column("channel_id", sa.Text(), nullable=True),
+        sa.Column("content", sa.Text()),
+        sa.Column("data", sa.JSON(), nullable=True),
+        sa.Column("meta", sa.JSON(), nullable=True),
+        sa.Column("created_at", sa.BigInteger(), nullable=True),
+        sa.Column("updated_at", sa.BigInteger(), nullable=True),
+    )
+
+
+def downgrade():
+    op.drop_table("channel")
+
+    op.drop_table("message")

+ 26 - 0
backend/open_webui/migrations/versions/7826ab40b532_update_file_table.py

@@ -0,0 +1,26 @@
+"""Update file table
+
+Revision ID: 7826ab40b532
+Revises: 57c599a3cb57
+Create Date: 2024-12-23 03:00:00.000000
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+revision = "7826ab40b532"
+down_revision = "57c599a3cb57"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.add_column(
+        "file",
+        sa.Column("access_control", sa.JSON(), nullable=True),
+    )
+
+
+def downgrade():
+    op.drop_column("file", "access_control")

+ 136 - 0
backend/open_webui/models/channels.py

@@ -0,0 +1,136 @@
+import json
+import time
+import uuid
+from typing import Optional
+
+from open_webui.internal.db import Base, get_db
+from open_webui.utils.access_control import has_access
+
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
+from sqlalchemy import or_, func, select, and_, text
+from sqlalchemy.sql import exists
+
+####################
+# Channel DB Schema
+####################
+
+
+class Channel(Base):
+    __tablename__ = "channel"
+
+    id = Column(Text, primary_key=True)
+    user_id = Column(Text)
+    type = Column(Text, nullable=True)
+
+    name = Column(Text)
+    description = Column(Text, nullable=True)
+
+    data = Column(JSON, nullable=True)
+    meta = Column(JSON, nullable=True)
+    access_control = Column(JSON, nullable=True)
+
+    created_at = Column(BigInteger)
+    updated_at = Column(BigInteger)
+
+
+class ChannelModel(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+
+    id: str
+    user_id: str
+    type: Optional[str] = None
+
+    name: str
+    description: Optional[str] = None
+
+    data: Optional[dict] = None
+    meta: Optional[dict] = None
+    access_control: Optional[dict] = None
+
+    created_at: int  # timestamp in epoch
+    updated_at: int  # timestamp in epoch
+
+
+####################
+# Forms
+####################
+
+
+class ChannelForm(BaseModel):
+    name: str
+    description: Optional[str] = None
+    data: Optional[dict] = None
+    meta: Optional[dict] = None
+    access_control: Optional[dict] = None
+
+
+class ChannelTable:
+    def insert_new_channel(
+        self, type: Optional[str], form_data: ChannelForm, user_id: str
+    ) -> Optional[ChannelModel]:
+        with get_db() as db:
+            channel = ChannelModel(
+                **{
+                    **form_data.model_dump(),
+                    "type": type,
+                    "name": form_data.name.lower(),
+                    "id": str(uuid.uuid4()),
+                    "user_id": user_id,
+                    "created_at": int(time.time_ns()),
+                    "updated_at": int(time.time_ns()),
+                }
+            )
+
+            new_channel = Channel(**channel.model_dump())
+
+            db.add(new_channel)
+            db.commit()
+            return channel
+
+    def get_channels(self) -> list[ChannelModel]:
+        with get_db() as db:
+            channels = db.query(Channel).all()
+            return [ChannelModel.model_validate(channel) for channel in channels]
+
+    def get_channels_by_user_id(
+        self, user_id: str, permission: str = "read"
+    ) -> list[ChannelModel]:
+        channels = self.get_channels()
+        return [
+            channel
+            for channel in channels
+            if channel.user_id == user_id
+            or has_access(user_id, permission, channel.access_control)
+        ]
+
+    def get_channel_by_id(self, id: str) -> Optional[ChannelModel]:
+        with get_db() as db:
+            channel = db.query(Channel).filter(Channel.id == id).first()
+            return ChannelModel.model_validate(channel) if channel else None
+
+    def update_channel_by_id(
+        self, id: str, form_data: ChannelForm
+    ) -> Optional[ChannelModel]:
+        with get_db() as db:
+            channel = db.query(Channel).filter(Channel.id == id).first()
+            if not channel:
+                return None
+
+            channel.name = form_data.name
+            channel.data = form_data.data
+            channel.meta = form_data.meta
+            channel.access_control = form_data.access_control
+            channel.updated_at = int(time.time_ns())
+
+            db.commit()
+            return ChannelModel.model_validate(channel) if channel else None
+
+    def delete_channel_by_id(self, id: str):
+        with get_db() as db:
+            db.query(Channel).filter(Channel.id == id).delete()
+            db.commit()
+            return True
+
+
+Channels = ChannelTable()

+ 96 - 0
backend/open_webui/models/chats.py

@@ -168,6 +168,100 @@ class ChatTable:
         except Exception:
             return None
 
+    def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]:
+        chat = self.get_chat_by_id(id)
+        if chat is None:
+            return None
+
+        chat = chat.chat
+        chat["title"] = title
+
+        return self.update_chat_by_id(id, chat)
+
+    def update_chat_tags_by_id(
+        self, id: str, tags: list[str], user
+    ) -> Optional[ChatModel]:
+        chat = self.get_chat_by_id(id)
+        if chat is None:
+            return None
+
+        self.delete_all_tags_by_id_and_user_id(id, user.id)
+
+        for tag in chat.meta.get("tags", []):
+            if self.count_chats_by_tag_name_and_user_id(tag, user.id) == 0:
+                Tags.delete_tag_by_name_and_user_id(tag, user.id)
+
+        for tag_name in tags:
+            if tag_name.lower() == "none":
+                continue
+
+            self.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, tag_name)
+        return self.get_chat_by_id(id)
+
+    def get_chat_title_by_id(self, id: str) -> Optional[str]:
+        chat = self.get_chat_by_id(id)
+        if chat is None:
+            return None
+
+        return chat.chat.get("title", "New Chat")
+
+    def get_messages_by_chat_id(self, id: str) -> Optional[dict]:
+        chat = self.get_chat_by_id(id)
+        if chat is None:
+            return None
+
+        return chat.chat.get("history", {}).get("messages", {}) or {}
+
+    def get_message_by_id_and_message_id(
+        self, id: str, message_id: str
+    ) -> Optional[dict]:
+        chat = self.get_chat_by_id(id)
+        if chat is None:
+            return None
+
+        return chat.chat.get("history", {}).get("messages", {}).get(message_id, {})
+
+    def upsert_message_to_chat_by_id_and_message_id(
+        self, id: str, message_id: str, message: dict
+    ) -> Optional[ChatModel]:
+        chat = self.get_chat_by_id(id)
+        if chat is None:
+            return None
+
+        chat = chat.chat
+        history = chat.get("history", {})
+
+        if message_id in history.get("messages", {}):
+            history["messages"][message_id] = {
+                **history["messages"][message_id],
+                **message,
+            }
+        else:
+            history["messages"][message_id] = message
+
+        history["currentId"] = message_id
+
+        chat["history"] = history
+        return self.update_chat_by_id(id, chat)
+
+    def add_message_status_to_chat_by_id_and_message_id(
+        self, id: str, message_id: str, status: dict
+    ) -> Optional[ChatModel]:
+        chat = self.get_chat_by_id(id)
+        if chat is None:
+            return None
+
+        chat = chat.chat
+        history = chat.get("history", {})
+
+        if message_id in history.get("messages", {}):
+            status_history = history["messages"][message_id].get("statusHistory", [])
+            status_history.append(status)
+            history["messages"][message_id]["statusHistory"] = status_history
+
+        chat["history"] = history
+        return self.update_chat_by_id(id, chat)
+
     def insert_shared_chat_by_chat_id(self, chat_id: str) -> Optional[ChatModel]:
         with get_db() as db:
             # Get the existing chat to share
@@ -375,6 +469,8 @@ class ChatTable:
     def get_chat_by_share_id(self, id: str) -> Optional[ChatModel]:
         try:
             with get_db() as db:
+                # it is possible that the shared link was deleted. hence,
+                # we check if the chat is still shared by checkng if a chat with the share_id exists
                 chat = db.query(Chat).filter_by(share_id=id).first()
 
                 if chat:

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

@@ -27,6 +27,8 @@ class File(Base):
     data = Column(JSON, nullable=True)
     meta = Column(JSON, nullable=True)
 
+    access_control = Column(JSON, nullable=True)
+
     created_at = Column(BigInteger)
     updated_at = Column(BigInteger)
 
@@ -44,6 +46,8 @@ class FileModel(BaseModel):
     data: Optional[dict] = None
     meta: Optional[dict] = None
 
+    access_control: Optional[dict] = None
+
     created_at: Optional[int]  # timestamp in epoch
     updated_at: Optional[int]  # timestamp in epoch
 
@@ -90,6 +94,7 @@ class FileForm(BaseModel):
     path: str
     data: dict = {}
     meta: dict = {}
+    access_control: Optional[dict] = None
 
 
 class FilesTable:

+ 7 - 0
backend/open_webui/models/groups.py

@@ -146,6 +146,13 @@ class GroupTable:
         except Exception:
             return None
 
+    def get_group_user_ids_by_id(self, id: str) -> Optional[str]:
+        group = self.get_group_by_id(id)
+        if group:
+            return group.user_ids
+        else:
+            return None
+
     def update_group_by_id(
         self, id: str, form_data: GroupUpdateForm, overwrite: bool = False
     ) -> Optional[GroupModel]:

+ 279 - 0
backend/open_webui/models/messages.py

@@ -0,0 +1,279 @@
+import json
+import time
+import uuid
+from typing import Optional
+
+from open_webui.internal.db import Base, get_db
+from open_webui.models.tags import TagModel, Tag, Tags
+
+
+from pydantic import BaseModel, ConfigDict
+from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
+from sqlalchemy import or_, func, select, and_, text
+from sqlalchemy.sql import exists
+
+####################
+# Message DB Schema
+####################
+
+
+class MessageReaction(Base):
+    __tablename__ = "message_reaction"
+    id = Column(Text, primary_key=True)
+    user_id = Column(Text)
+    message_id = Column(Text)
+    name = Column(Text)
+    created_at = Column(BigInteger)
+
+
+class MessageReactionModel(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+
+    id: str
+    user_id: str
+    message_id: str
+    name: str
+    created_at: int  # timestamp in epoch
+
+
+class Message(Base):
+    __tablename__ = "message"
+    id = Column(Text, primary_key=True)
+
+    user_id = Column(Text)
+    channel_id = Column(Text, nullable=True)
+
+    parent_id = Column(Text, nullable=True)
+
+    content = Column(Text)
+    data = Column(JSON, nullable=True)
+    meta = Column(JSON, nullable=True)
+
+    created_at = Column(BigInteger)  # time_ns
+    updated_at = Column(BigInteger)  # time_ns
+
+
+class MessageModel(BaseModel):
+    model_config = ConfigDict(from_attributes=True)
+
+    id: str
+    user_id: str
+    channel_id: Optional[str] = None
+
+    parent_id: Optional[str] = None
+
+    content: str
+    data: Optional[dict] = None
+    meta: Optional[dict] = None
+
+    created_at: int  # timestamp in epoch
+    updated_at: int  # timestamp in epoch
+
+
+####################
+# Forms
+####################
+
+
+class MessageForm(BaseModel):
+    content: str
+    parent_id: Optional[str] = None
+    data: Optional[dict] = None
+    meta: Optional[dict] = None
+
+
+class Reactions(BaseModel):
+    name: str
+    user_ids: list[str]
+    count: int
+
+
+class MessageResponse(MessageModel):
+    latest_reply_at: Optional[int]
+    reply_count: int
+    reactions: list[Reactions]
+
+
+class MessageTable:
+    def insert_new_message(
+        self, form_data: MessageForm, channel_id: str, user_id: str
+    ) -> Optional[MessageModel]:
+        with get_db() as db:
+            id = str(uuid.uuid4())
+
+            ts = int(time.time_ns())
+            message = MessageModel(
+                **{
+                    "id": id,
+                    "user_id": user_id,
+                    "channel_id": channel_id,
+                    "parent_id": form_data.parent_id,
+                    "content": form_data.content,
+                    "data": form_data.data,
+                    "meta": form_data.meta,
+                    "created_at": ts,
+                    "updated_at": ts,
+                }
+            )
+
+            result = Message(**message.model_dump())
+            db.add(result)
+            db.commit()
+            db.refresh(result)
+            return MessageModel.model_validate(result) if result else None
+
+    def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
+        with get_db() as db:
+            message = db.get(Message, id)
+            if not message:
+                return None
+
+            reactions = self.get_reactions_by_message_id(id)
+            replies = self.get_replies_by_message_id(id)
+
+            return MessageResponse(
+                **{
+                    **MessageModel.model_validate(message).model_dump(),
+                    "latest_reply_at": replies[0].created_at if replies else None,
+                    "reply_count": len(replies),
+                    "reactions": reactions,
+                }
+            )
+
+    def get_replies_by_message_id(self, id: str) -> list[MessageModel]:
+        with get_db() as db:
+            all_messages = (
+                db.query(Message)
+                .filter_by(parent_id=id)
+                .order_by(Message.created_at.desc())
+                .all()
+            )
+            return [MessageModel.model_validate(message) for message in all_messages]
+
+    def get_reply_user_ids_by_message_id(self, id: str) -> list[str]:
+        with get_db() as db:
+            return [
+                message.user_id
+                for message in db.query(Message).filter_by(parent_id=id).all()
+            ]
+
+    def get_messages_by_channel_id(
+        self, channel_id: str, skip: int = 0, limit: int = 50
+    ) -> list[MessageModel]:
+        with get_db() as db:
+            all_messages = (
+                db.query(Message)
+                .filter_by(channel_id=channel_id, parent_id=None)
+                .order_by(Message.created_at.desc())
+                .offset(skip)
+                .limit(limit)
+                .all()
+            )
+            return [MessageModel.model_validate(message) for message in all_messages]
+
+    def get_messages_by_parent_id(
+        self, channel_id: str, parent_id: str, skip: int = 0, limit: int = 50
+    ) -> list[MessageModel]:
+        with get_db() as db:
+            message = db.get(Message, parent_id)
+
+            if not message:
+                return []
+
+            all_messages = (
+                db.query(Message)
+                .filter_by(channel_id=channel_id, parent_id=parent_id)
+                .order_by(Message.created_at.desc())
+                .offset(skip)
+                .limit(limit)
+                .all()
+            )
+
+            # If length of all_messages is less than limit, then add the parent message
+            if len(all_messages) < limit:
+                all_messages.append(message)
+
+            return [MessageModel.model_validate(message) for message in all_messages]
+
+    def update_message_by_id(
+        self, id: str, form_data: MessageForm
+    ) -> Optional[MessageModel]:
+        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.updated_at = int(time.time_ns())
+            db.commit()
+            db.refresh(message)
+            return MessageModel.model_validate(message) if message else None
+
+    def add_reaction_to_message(
+        self, id: str, user_id: str, name: str
+    ) -> Optional[MessageReactionModel]:
+        with get_db() as db:
+            reaction_id = str(uuid.uuid4())
+            reaction = MessageReactionModel(
+                id=reaction_id,
+                user_id=user_id,
+                message_id=id,
+                name=name,
+                created_at=int(time.time_ns()),
+            )
+            result = MessageReaction(**reaction.model_dump())
+            db.add(result)
+            db.commit()
+            db.refresh(result)
+            return MessageReactionModel.model_validate(result) if result else None
+
+    def get_reactions_by_message_id(self, id: str) -> list[Reactions]:
+        with get_db() as db:
+            all_reactions = db.query(MessageReaction).filter_by(message_id=id).all()
+
+            reactions = {}
+            for reaction in all_reactions:
+                if reaction.name not in reactions:
+                    reactions[reaction.name] = {
+                        "name": reaction.name,
+                        "user_ids": [],
+                        "count": 0,
+                    }
+                reactions[reaction.name]["user_ids"].append(reaction.user_id)
+                reactions[reaction.name]["count"] += 1
+
+            return [Reactions(**reaction) for reaction in reactions.values()]
+
+    def remove_reaction_by_id_and_user_id_and_name(
+        self, id: str, user_id: str, name: str
+    ) -> bool:
+        with get_db() as db:
+            db.query(MessageReaction).filter_by(
+                message_id=id, user_id=user_id, name=name
+            ).delete()
+            db.commit()
+            return True
+
+    def delete_reactions_by_id(self, id: str) -> bool:
+        with get_db() as db:
+            db.query(MessageReaction).filter_by(message_id=id).delete()
+            db.commit()
+            return True
+
+    def delete_replies_by_id(self, id: str) -> bool:
+        with get_db() as db:
+            db.query(Message).filter_by(parent_id=id).delete()
+            db.commit()
+            return True
+
+    def delete_message_by_id(self, id: str) -> bool:
+        with get_db() as db:
+            db.query(Message).filter_by(id=id).delete()
+
+            # Delete all reactions to this message
+            db.query(MessageReaction).filter_by(message_id=id).delete()
+
+            db.commit()
+            return True
+
+
+Messages = MessageTable()

+ 41 - 6
backend/open_webui/models/users.py

@@ -70,6 +70,13 @@ class UserResponse(BaseModel):
     profile_image_url: str
 
 
+class UserNameResponse(BaseModel):
+    id: str
+    name: str
+    role: str
+    profile_image_url: str
+
+
 class UserRoleUpdateForm(BaseModel):
     id: str
     role: str
@@ -147,13 +154,25 @@ class UsersTable:
         except Exception:
             return None
 
-    def get_users(self, skip: int = 0, limit: int = 50) -> list[UserModel]:
+    def get_users(
+        self, skip: Optional[int] = None, limit: Optional[int] = None
+    ) -> list[UserModel]:
         with get_db() as db:
-            users = (
-                db.query(User)
-                # .offset(skip).limit(limit)
-                .all()
-            )
+
+            query = db.query(User).order_by(User.created_at.desc())
+
+            if skip:
+                query = query.offset(skip)
+            if limit:
+                query = query.limit(limit)
+
+            users = query.all()
+
+            return [UserModel.model_validate(user) for user in users]
+
+    def get_users_by_user_ids(self, user_ids: list[str]) -> list[UserModel]:
+        with get_db() as db:
+            users = db.query(User).filter(User.id.in_(user_ids)).all()
             return [UserModel.model_validate(user) for user in users]
 
     def get_num_users(self) -> Optional[int]:
@@ -168,6 +187,22 @@ class UsersTable:
         except Exception:
             return None
 
+    def get_user_webhook_url_by_id(self, id: str) -> Optional[str]:
+        try:
+            with get_db() as db:
+                user = db.query(User).filter_by(id=id).first()
+
+                if user.settings is None:
+                    return None
+                else:
+                    return (
+                        user.settings.get("ui", {})
+                        .get("notifications", {})
+                        .get("webhook_url", None)
+                    )
+        except Exception:
+            return None
+
     def update_user_role_by_id(self, id: str, role: str) -> Optional[UserModel]:
         try:
             with get_db() as db:

+ 7 - 2
backend/open_webui/retrieval/utils.py

@@ -14,7 +14,7 @@ from langchain_core.documents import Document
 from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
 from open_webui.utils.misc import get_last_user_message
 
-from open_webui.env import SRC_LOG_LEVELS
+from open_webui.env import SRC_LOG_LEVELS, OFFLINE_MODE
 
 log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["RAG"])
@@ -70,7 +70,9 @@ def query_doc(
             limit=k,
         )
 
-        log.info(f"query_doc:result {result.ids} {result.metadatas}")
+        if result:
+            log.info(f"query_doc:result {result.ids} {result.metadatas}")
+
         return result
     except Exception as e:
         print(e)
@@ -373,6 +375,9 @@ def get_model_path(model: str, update_model: bool = False):
 
     local_files_only = not update_model
 
+    if OFFLINE_MODE:
+        local_files_only = True
+
     snapshot_kwargs = {
         "cache_dir": cache_dir,
         "local_files_only": local_files_only,

+ 38 - 2
backend/open_webui/retrieval/vector/dbs/pgvector.py

@@ -5,6 +5,7 @@ from sqlalchemy import (
     create_engine,
     Column,
     Integer,
+    MetaData,
     select,
     text,
     Text,
@@ -19,9 +20,9 @@ from pgvector.sqlalchemy import Vector
 from sqlalchemy.ext.mutable import MutableDict
 
 from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
-from open_webui.config import PGVECTOR_DB_URL
+from open_webui.config import PGVECTOR_DB_URL, PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
 
-VECTOR_LENGTH = 1536
+VECTOR_LENGTH = PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH
 Base = declarative_base()
 
 
@@ -56,6 +57,9 @@ class PgvectorClient:
             # Ensure the pgvector extension is available
             self.session.execute(text("CREATE EXTENSION IF NOT EXISTS vector;"))
 
+            # Check vector length consistency
+            self.check_vector_length()
+
             # Create the tables if they do not exist
             # Base.metadata.create_all requires a bind (engine or connection)
             # Get the connection from the session
@@ -82,6 +86,38 @@ class PgvectorClient:
             print(f"Error during initialization: {e}")
             raise
 
+    def check_vector_length(self) -> None:
+        """
+        Check if the VECTOR_LENGTH matches the existing vector column dimension in the database.
+        Raises an exception if there is a mismatch.
+        """
+        metadata = MetaData()
+        metadata.reflect(bind=self.session.bind, only=["document_chunk"])
+
+        if "document_chunk" in metadata.tables:
+            document_chunk_table = metadata.tables["document_chunk"]
+            if "vector" in document_chunk_table.columns:
+                vector_column = document_chunk_table.columns["vector"]
+                vector_type = vector_column.type
+                if isinstance(vector_type, Vector):
+                    db_vector_length = vector_type.dim
+                    if db_vector_length != VECTOR_LENGTH:
+                        raise Exception(
+                            f"VECTOR_LENGTH {VECTOR_LENGTH} does not match existing vector column dimension {db_vector_length}. "
+                            "Cannot change vector size after initialization without migrating the data."
+                        )
+                else:
+                    raise Exception(
+                        "The 'vector' column exists but is not of type 'Vector'."
+                    )
+            else:
+                raise Exception(
+                    "The 'vector' column does not exist in the 'document_chunk' table."
+                )
+        else:
+            # Table does not exist yet; no action needed
+            pass
+
     def adjust_vector_length(self, vector: List[float]) -> List[float]:
         # Adjust vector to have length VECTOR_LENGTH
         current_length = len(vector)

+ 1 - 1
backend/open_webui/retrieval/web/testdata/brave.json

@@ -683,7 +683,7 @@
 				"age": "October 29, 2022",
 				"extra_snippets": [
 					"You can pass many options to the configure script; run ./configure --help to find out more. On macOS case-insensitive file systems and on Cygwin, the executable is called python.exe; elsewhere it's just python.",
-					"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or useable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
+					"Building a complete Python installation requires the use of various additional third-party libraries, depending on your build platform and configure options. Not all standard library modules are buildable or usable on all platforms. Refer to the Install dependencies section of the Developer Guide for current detailed information on dependencies for various Linux distributions and macOS.",
 					"To get an optimized build of Python, configure --enable-optimizations before you run make. This sets the default make targets up to enable Profile Guided Optimization (PGO) and may be used to auto-enable Link Time Optimization (LTO) on some platforms. For more details, see the sections below.",
 					"Copyright © 2001-2024 Python Software Foundation. All rights reserved."
 				]

+ 3 - 3
backend/open_webui/retrieval/web/utils.py

@@ -82,15 +82,15 @@ class SafeWebBaseLoader(WebBaseLoader):
 
 
 def get_web_loader(
-    url: Union[str, Sequence[str]],
+    urls: Union[str, Sequence[str]],
     verify_ssl: bool = True,
     requests_per_second: int = 2,
 ):
     # Check if the URL is valid
-    if not validate_url(url):
+    if not validate_url(urls):
         raise ValueError(ERROR_MESSAGES.INVALID_URL)
     return SafeWebBaseLoader(
-        url,
+        urls,
         verify_ssl=verify_ssl,
         requests_per_second=requests_per_second,
         continue_on_failure=True,

+ 19 - 9
backend/open_webui/routers/audio.py

@@ -218,7 +218,7 @@ async def update_audio_config(
     }
 
 
-def load_speech_pipeline():
+def load_speech_pipeline(request):
     from transformers import pipeline
     from datasets import load_dataset
 
@@ -236,7 +236,11 @@ def load_speech_pipeline():
 @router.post("/speech")
 async def speech(request: Request, user=Depends(get_verified_user)):
     body = await request.body()
-    name = hashlib.sha256(body).hexdigest()
+    name = hashlib.sha256(
+        body
+        + str(request.app.state.config.TTS_ENGINE).encode("utf-8")
+        + str(request.app.state.config.TTS_MODEL).encode("utf-8")
+    ).hexdigest()
 
     file_path = SPEECH_CACHE_DIR.joinpath(f"{name}.mp3")
     file_body_path = SPEECH_CACHE_DIR.joinpath(f"{name}.json")
@@ -256,10 +260,11 @@ async def speech(request: Request, user=Depends(get_verified_user)):
         payload["model"] = request.app.state.config.TTS_MODEL
 
         try:
+            # print(payload)
             async with aiohttp.ClientSession() as session:
                 async with session.post(
                     url=f"{request.app.state.config.TTS_OPENAI_API_BASE_URL}/audio/speech",
-                    data=payload,
+                    json=payload,
                     headers={
                         "Content-Type": "application/json",
                         "Authorization": f"Bearer {request.app.state.config.TTS_OPENAI_API_KEY}",
@@ -281,7 +286,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
                         await f.write(await r.read())
 
                     async with aiofiles.open(file_body_path, "w") as f:
-                        await f.write(json.dumps(json.loads(body.decode("utf-8"))))
+                        await f.write(json.dumps(payload))
 
             return FileResponse(file_path)
 
@@ -292,6 +297,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
             try:
                 if r.status != 200:
                     res = await r.json()
+
                     if "error" in res:
                         detail = f"External: {res['error'].get('message', '')}"
             except Exception:
@@ -305,7 +311,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
     elif request.app.state.config.TTS_ENGINE == "elevenlabs":
         voice_id = payload.get("voice", "")
 
-        if voice_id not in get_available_voices():
+        if voice_id not in get_available_voices(request):
             raise HTTPException(
                 status_code=400,
                 detail="Invalid voice id",
@@ -332,7 +338,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
                         await f.write(await r.read())
 
                     async with aiofiles.open(file_body_path, "w") as f:
-                        await f.write(json.dumps(json.loads(body.decode("utf-8"))))
+                        await f.write(json.dumps(payload))
 
             return FileResponse(file_path)
 
@@ -384,6 +390,9 @@ async def speech(request: Request, user=Depends(get_verified_user)):
                     async with aiofiles.open(file_path, "wb") as f:
                         await f.write(await r.read())
 
+                    async with aiofiles.open(file_body_path, "w") as f:
+                        await f.write(json.dumps(payload))
+
                     return FileResponse(file_path)
 
         except Exception as e:
@@ -414,7 +423,7 @@ async def speech(request: Request, user=Depends(get_verified_user)):
         import torch
         import soundfile as sf
 
-        load_speech_pipeline()
+        load_speech_pipeline(request)
 
         embeddings_dataset = request.app.state.speech_speaker_embeddings_dataset
 
@@ -436,8 +445,9 @@ async def speech(request: Request, user=Depends(get_verified_user)):
         )
 
         sf.write(file_path, speech["audio"], samplerate=speech["sampling_rate"])
-        with open(file_body_path, "w") as f:
-            json.dump(json.loads(body.decode("utf-8")), f)
+
+        async with aiofiles.open(file_body_path, "w") as f:
+            await f.write(json.dumps(payload))
 
         return FileResponse(file_path)
 

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

@@ -547,7 +547,6 @@ async def add_user(form_data: AddUserForm, user=Depends(get_admin_user)):
         raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
 
     try:
-        print(form_data)
         hashed = get_password_hash(form_data.password)
         user = Auths.insert_new_auth(
             form_data.email.lower(),
@@ -614,8 +613,12 @@ async def get_admin_details(request: Request, user=Depends(get_current_user)):
 async def get_admin_config(request: Request, user=Depends(get_admin_user)):
     return {
         "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
+        "WEBUI_URL": request.app.state.config.WEBUI_URL,
         "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
         "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
+        "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
+        "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
+        "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
         "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
         "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
         "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,
@@ -625,8 +628,12 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
 
 class AdminConfig(BaseModel):
     SHOW_ADMIN_DETAILS: bool
+    WEBUI_URL: str
     ENABLE_SIGNUP: bool
     ENABLE_API_KEY: bool
+    ENABLE_API_KEY_ENDPOINT_RESTRICTIONS: bool
+    API_KEY_ALLOWED_ENDPOINTS: str
+    ENABLE_CHANNELS: bool
     DEFAULT_USER_ROLE: str
     JWT_EXPIRES_IN: str
     ENABLE_COMMUNITY_SHARING: bool
@@ -638,8 +645,18 @@ async def update_admin_config(
     request: Request, form_data: AdminConfig, user=Depends(get_admin_user)
 ):
     request.app.state.config.SHOW_ADMIN_DETAILS = form_data.SHOW_ADMIN_DETAILS
+    request.app.state.config.WEBUI_URL = form_data.WEBUI_URL
     request.app.state.config.ENABLE_SIGNUP = form_data.ENABLE_SIGNUP
+
     request.app.state.config.ENABLE_API_KEY = form_data.ENABLE_API_KEY
+    request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS = (
+        form_data.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS
+    )
+    request.app.state.config.API_KEY_ALLOWED_ENDPOINTS = (
+        form_data.API_KEY_ALLOWED_ENDPOINTS
+    )
+
+    request.app.state.config.ENABLE_CHANNELS = form_data.ENABLE_CHANNELS
 
     if form_data.DEFAULT_USER_ROLE in ["pending", "user", "admin"]:
         request.app.state.config.DEFAULT_USER_ROLE = form_data.DEFAULT_USER_ROLE
@@ -657,8 +674,12 @@ async def update_admin_config(
 
     return {
         "SHOW_ADMIN_DETAILS": request.app.state.config.SHOW_ADMIN_DETAILS,
+        "WEBUI_URL": request.app.state.config.WEBUI_URL,
         "ENABLE_SIGNUP": request.app.state.config.ENABLE_SIGNUP,
         "ENABLE_API_KEY": request.app.state.config.ENABLE_API_KEY,
+        "ENABLE_API_KEY_ENDPOINT_RESTRICTIONS": request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS,
+        "API_KEY_ALLOWED_ENDPOINTS": request.app.state.config.API_KEY_ALLOWED_ENDPOINTS,
+        "ENABLE_CHANNELS": request.app.state.config.ENABLE_CHANNELS,
         "DEFAULT_USER_ROLE": request.app.state.config.DEFAULT_USER_ROLE,
         "JWT_EXPIRES_IN": request.app.state.config.JWT_EXPIRES_IN,
         "ENABLE_COMMUNITY_SHARING": request.app.state.config.ENABLE_COMMUNITY_SHARING,

+ 710 - 0
backend/open_webui/routers/channels.py

@@ -0,0 +1,710 @@
+import json
+import logging
+from typing import Optional
+
+
+from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
+from pydantic import BaseModel
+
+
+from open_webui.socket.main import sio, get_user_ids_from_room
+from open_webui.models.users import Users, UserNameResponse
+
+from open_webui.models.channels import Channels, ChannelModel, ChannelForm
+from open_webui.models.messages import (
+    Messages,
+    MessageModel,
+    MessageResponse,
+    MessageForm,
+)
+
+
+from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
+from open_webui.constants import ERROR_MESSAGES
+from open_webui.env import SRC_LOG_LEVELS
+
+
+from open_webui.utils.auth import get_admin_user, get_verified_user
+from open_webui.utils.access_control import has_access, get_users_with_access
+from open_webui.utils.webhook import post_webhook
+
+log = logging.getLogger(__name__)
+log.setLevel(SRC_LOG_LEVELS["MODELS"])
+
+router = APIRouter()
+
+############################
+# GetChatList
+############################
+
+
+@router.get("/", response_model=list[ChannelModel])
+async def get_channels(user=Depends(get_verified_user)):
+    if user.role == "admin":
+        return Channels.get_channels()
+    else:
+        return Channels.get_channels_by_user_id(user.id)
+
+
+############################
+# CreateNewChannel
+############################
+
+
+@router.post("/create", response_model=Optional[ChannelModel])
+async def create_new_channel(form_data: ChannelForm, user=Depends(get_admin_user)):
+    try:
+        channel = Channels.insert_new_channel(None, form_data, user.id)
+        return ChannelModel(**channel.model_dump())
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# GetChannelById
+############################
+
+
+@router.get("/{id}", response_model=Optional[ChannelModel])
+async def get_channel_by_id(id: str, user=Depends(get_verified_user)):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    return ChannelModel(**channel.model_dump())
+
+
+############################
+# UpdateChannelById
+############################
+
+
+@router.post("/{id}/update", response_model=Optional[ChannelModel])
+async def update_channel_by_id(
+    id: str, form_data: ChannelForm, user=Depends(get_admin_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    try:
+        channel = Channels.update_channel_by_id(id, form_data)
+        return ChannelModel(**channel.model_dump())
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# DeleteChannelById
+############################
+
+
+@router.delete("/{id}/delete", response_model=bool)
+async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    try:
+        Channels.delete_channel_by_id(id)
+        return True
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# GetChannelMessages
+############################
+
+
+class MessageUserResponse(MessageResponse):
+    user: UserNameResponse
+
+
+@router.get("/{id}/messages", response_model=list[MessageUserResponse])
+async def get_channel_messages(
+    id: str, skip: int = 0, limit: int = 50, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message_list = Messages.get_messages_by_channel_id(id, skip, limit)
+    users = {}
+
+    messages = []
+    for message in message_list:
+        if message.user_id not in users:
+            user = Users.get_user_by_id(message.user_id)
+            users[message.user_id] = user
+
+        replies = Messages.get_replies_by_message_id(message.id)
+        latest_reply_at = replies[0].created_at if replies else None
+
+        messages.append(
+            MessageUserResponse(
+                **{
+                    **message.model_dump(),
+                    "reply_count": len(replies),
+                    "latest_reply_at": latest_reply_at,
+                    "reactions": Messages.get_reactions_by_message_id(message.id),
+                    "user": UserNameResponse(**users[message.user_id].model_dump()),
+                }
+            )
+        )
+
+    return messages
+
+
+############################
+# PostNewMessage
+############################
+
+
+async def send_notification(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.settings:
+                webhook_url = user.settings.ui.get("notifications", {}).get(
+                    "webhook_url", None
+                )
+
+                if webhook_url:
+                    post_webhook(
+                        webhook_url,
+                        f"#{channel.name} - {webui_url}/channels/{channel.id}\n\n{message.content}",
+                        {
+                            "action": "channel",
+                            "message": message.content,
+                            "title": channel.name,
+                            "url": f"{webui_url}/channels/{channel.id}",
+                        },
+                    )
+
+
+@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),
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        message = Messages.insert_new_message(form_data, channel.id, user.id)
+
+        if message:
+            event_data = {
+                "channel_id": channel.id,
+                "message_id": message.id,
+                "data": {
+                    "type": "message",
+                    "data": MessageUserResponse(
+                        **{
+                            **message.model_dump(),
+                            "reply_count": 0,
+                            "latest_reply_at": None,
+                            "reactions": Messages.get_reactions_by_message_id(
+                                message.id
+                            ),
+                            "user": UserNameResponse(**user.model_dump()),
+                        }
+                    ).model_dump(),
+                },
+                "user": UserNameResponse(**user.model_dump()).model_dump(),
+                "channel": channel.model_dump(),
+            }
+
+            await sio.emit(
+                "channel-events",
+                event_data,
+                to=f"channel:{channel.id}",
+            )
+
+            if message.parent_id:
+                # If this message is a reply, emit to the parent message as well
+                parent_message = Messages.get_message_by_id(message.parent_id)
+
+                if parent_message:
+                    await sio.emit(
+                        "channel-events",
+                        {
+                            "channel_id": channel.id,
+                            "message_id": parent_message.id,
+                            "data": {
+                                "type": "message:reply",
+                                "data": MessageUserResponse(
+                                    **{
+                                        **parent_message.model_dump(),
+                                        "user": UserNameResponse(
+                                            **Users.get_user_by_id(
+                                                parent_message.user_id
+                                            ).model_dump()
+                                        ),
+                                    }
+                                ).model_dump(),
+                            },
+                            "user": UserNameResponse(**user.model_dump()).model_dump(),
+                            "channel": channel.model_dump(),
+                        },
+                        to=f"channel:{channel.id}",
+                    )
+
+            active_user_ids = get_user_ids_from_room(f"channel:{channel.id}")
+
+            background_tasks.add_task(
+                send_notification,
+                request.app.state.config.WEBUI_URL,
+                channel,
+                message,
+                active_user_ids,
+            )
+
+        return MessageModel(**message.model_dump())
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# GetChannelMessage
+############################
+
+
+@router.get("/{id}/messages/{message_id}", response_model=Optional[MessageUserResponse])
+async def get_channel_message(
+    id: str, message_id: str, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message = Messages.get_message_by_id(message_id)
+    if not message:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if message.channel_id != id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    return MessageUserResponse(
+        **{
+            **message.model_dump(),
+            "user": UserNameResponse(
+                **Users.get_user_by_id(message.user_id).model_dump()
+            ),
+        }
+    )
+
+
+############################
+# GetChannelThreadMessages
+############################
+
+
+@router.get(
+    "/{id}/messages/{message_id}/thread", response_model=list[MessageUserResponse]
+)
+async def get_channel_thread_messages(
+    id: str,
+    message_id: str,
+    skip: int = 0,
+    limit: int = 50,
+    user=Depends(get_verified_user),
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message_list = Messages.get_messages_by_parent_id(id, message_id, skip, limit)
+    users = {}
+
+    messages = []
+    for message in message_list:
+        if message.user_id not in users:
+            user = Users.get_user_by_id(message.user_id)
+            users[message.user_id] = user
+
+        messages.append(
+            MessageUserResponse(
+                **{
+                    **message.model_dump(),
+                    "reply_count": 0,
+                    "latest_reply_at": None,
+                    "reactions": Messages.get_reactions_by_message_id(message.id),
+                    "user": UserNameResponse(**users[message.user_id].model_dump()),
+                }
+            )
+        )
+
+    return messages
+
+
+############################
+# UpdateMessageById
+############################
+
+
+@router.post(
+    "/{id}/messages/{message_id}/update", response_model=Optional[MessageModel]
+)
+async def update_message_by_id(
+    id: str, message_id: str, form_data: MessageForm, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message = Messages.get_message_by_id(message_id)
+    if not message:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if message.channel_id != id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        message = Messages.update_message_by_id(message_id, form_data)
+        message = Messages.get_message_by_id(message_id)
+
+        if message:
+            await sio.emit(
+                "channel-events",
+                {
+                    "channel_id": channel.id,
+                    "message_id": message.id,
+                    "data": {
+                        "type": "message:update",
+                        "data": MessageUserResponse(
+                            **{
+                                **message.model_dump(),
+                                "user": UserNameResponse(
+                                    **user.model_dump()
+                                ).model_dump(),
+                            }
+                        ).model_dump(),
+                    },
+                    "user": UserNameResponse(**user.model_dump()).model_dump(),
+                    "channel": channel.model_dump(),
+                },
+                to=f"channel:{channel.id}",
+            )
+
+        return MessageModel(**message.model_dump())
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# AddReactionToMessage
+############################
+
+
+class ReactionForm(BaseModel):
+    name: str
+
+
+@router.post("/{id}/messages/{message_id}/reactions/add", response_model=bool)
+async def add_reaction_to_message(
+    id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message = Messages.get_message_by_id(message_id)
+    if not message:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if message.channel_id != id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        Messages.add_reaction_to_message(message_id, user.id, form_data.name)
+        message = Messages.get_message_by_id(message_id)
+
+        await sio.emit(
+            "channel-events",
+            {
+                "channel_id": channel.id,
+                "message_id": message.id,
+                "data": {
+                    "type": "message:reaction:add",
+                    "data": {
+                        **message.model_dump(),
+                        "user": UserNameResponse(
+                            **Users.get_user_by_id(message.user_id).model_dump()
+                        ).model_dump(),
+                        "name": form_data.name,
+                    },
+                },
+                "user": UserNameResponse(**user.model_dump()).model_dump(),
+                "channel": channel.model_dump(),
+            },
+            to=f"channel:{channel.id}",
+        )
+
+        return True
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# RemoveReactionById
+############################
+
+
+@router.post("/{id}/messages/{message_id}/reactions/remove", response_model=bool)
+async def remove_reaction_by_id_and_user_id_and_name(
+    id: str, message_id: str, form_data: ReactionForm, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message = Messages.get_message_by_id(message_id)
+    if not message:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if message.channel_id != id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        Messages.remove_reaction_by_id_and_user_id_and_name(
+            message_id, user.id, form_data.name
+        )
+
+        message = Messages.get_message_by_id(message_id)
+
+        await sio.emit(
+            "channel-events",
+            {
+                "channel_id": channel.id,
+                "message_id": message.id,
+                "data": {
+                    "type": "message:reaction:remove",
+                    "data": {
+                        **message.model_dump(),
+                        "user": UserNameResponse(
+                            **Users.get_user_by_id(message.user_id).model_dump()
+                        ).model_dump(),
+                        "name": form_data.name,
+                    },
+                },
+                "user": UserNameResponse(**user.model_dump()).model_dump(),
+                "channel": channel.model_dump(),
+            },
+            to=f"channel:{channel.id}",
+        )
+
+        return True
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
+############################
+# DeleteMessageById
+############################
+
+
+@router.delete("/{id}/messages/{message_id}/delete", response_model=bool)
+async def delete_message_by_id(
+    id: str, message_id: str, user=Depends(get_verified_user)
+):
+    channel = Channels.get_channel_by_id(id)
+    if not channel:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if user.role != "admin" and not has_access(
+        user.id, type="read", access_control=channel.access_control
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    message = Messages.get_message_by_id(message_id)
+    if not message:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND
+        )
+
+    if message.channel_id != id:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+    try:
+        Messages.delete_message_by_id(message_id)
+        await sio.emit(
+            "channel-events",
+            {
+                "channel_id": channel.id,
+                "message_id": message.id,
+                "data": {
+                    "type": "message:delete",
+                    "data": {
+                        **message.model_dump(),
+                        "user": UserNameResponse(**user.model_dump()).model_dump(),
+                    },
+                },
+                "user": UserNameResponse(**user.model_dump()).model_dump(),
+                "channel": channel.model_dump(),
+            },
+            to=f"channel:{channel.id}",
+        )
+
+        if message.parent_id:
+            # If this message is a reply, emit to the parent message as well
+            parent_message = Messages.get_message_by_id(message.parent_id)
+
+            if parent_message:
+                await sio.emit(
+                    "channel-events",
+                    {
+                        "channel_id": channel.id,
+                        "message_id": parent_message.id,
+                        "data": {
+                            "type": "message:reply",
+                            "data": MessageUserResponse(
+                                **{
+                                    **parent_message.model_dump(),
+                                    "user": UserNameResponse(
+                                        **Users.get_user_by_id(
+                                            parent_message.user_id
+                                        ).model_dump()
+                                    ),
+                                }
+                            ).model_dump(),
+                        },
+                        "user": UserNameResponse(**user.model_dump()).model_dump(),
+                        "channel": channel.model_dump(),
+                    },
+                    to=f"channel:{channel.id}",
+                )
+
+        return True
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )

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

@@ -463,6 +463,30 @@ async def clone_chat_by_id(id: str, user=Depends(get_verified_user)):
         )
 
 
+############################
+# CloneSharedChatById
+############################
+
+
+@router.post("/{id}/clone/shared", response_model=Optional[ChatResponse])
+async def clone_shared_chat_by_id(id: str, user=Depends(get_verified_user)):
+    chat = Chats.get_chat_by_share_id(id)
+    if chat:
+        updated_chat = {
+            **chat.chat,
+            "originalChatId": chat.id,
+            "branchPointMessageId": chat.chat["history"]["currentId"],
+            "title": f"Clone of {chat.title}",
+        }
+
+        chat = Chats.insert_new_chat(user.id, ChatForm(**{"chat": updated_chat}))
+        return ChatResponse(**chat.model_dump())
+    else:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
+
 ############################
 # ArchiveChat
 ############################

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

@@ -226,9 +226,16 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
                 # Handle Unicode filenames
                 filename = file.meta.get("name", file.filename)
                 encoded_filename = quote(filename)  # RFC5987 encoding
-                headers = {
-                    "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"
-                }
+
+                headers = {}
+                if file.meta.get("content_type") not in [
+                    "application/pdf",
+                    "text/plain",
+                ]:
+                    headers = {
+                        **headers,
+                        "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
+                    }
 
                 return FileResponse(file_path, headers=headers)
 
@@ -341,7 +348,7 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
         result = Files.delete_file_by_id(id)
         if result:
             try:
-                Storage.delete_file(file.filename)
+                Storage.delete_file(file.path)
             except Exception as e:
                 log.exception(e)
                 log.error(f"Error deleting files")

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

@@ -56,6 +56,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
         },
         "comfyui": {
             "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
+            "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
             "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
             "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
         },
@@ -77,6 +78,7 @@ class Automatic1111ConfigForm(BaseModel):
 
 class ComfyUIConfigForm(BaseModel):
     COMFYUI_BASE_URL: str
+    COMFYUI_API_KEY: str
     COMFYUI_WORKFLOW: str
     COMFYUI_WORKFLOW_NODES: list[dict]
 
@@ -148,6 +150,7 @@ async def update_config(
         },
         "comfyui": {
             "COMFYUI_BASE_URL": request.app.state.config.COMFYUI_BASE_URL,
+            "COMFYUI_API_KEY": request.app.state.config.COMFYUI_API_KEY,
             "COMFYUI_WORKFLOW": request.app.state.config.COMFYUI_WORKFLOW,
             "COMFYUI_WORKFLOW_NODES": request.app.state.config.COMFYUI_WORKFLOW_NODES,
         },
@@ -197,7 +200,7 @@ def set_image_model(request: Request, model: str):
     log.info(f"Setting image model to {model}")
     request.app.state.config.IMAGE_GENERATION_MODEL = model
     if request.app.state.config.IMAGE_GENERATION_ENGINE in ["", "automatic1111"]:
-        api_auth = get_automatic1111_api_auth()
+        api_auth = get_automatic1111_api_auth(request)
         r = requests.get(
             url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
             headers={"authorization": api_auth},
@@ -233,7 +236,7 @@ def get_image_model(request):
         try:
             r = requests.get(
                 url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/options",
-                headers={"authorization": get_automatic1111_api_auth()},
+                headers={"authorization": get_automatic1111_api_auth(request)},
             )
             options = r.json()
             return options["sd_model_checkpoint"]
@@ -298,8 +301,12 @@ def get_models(request: Request, user=Depends(get_verified_user)):
             ]
         elif request.app.state.config.IMAGE_GENERATION_ENGINE == "comfyui":
             # TODO - get models from comfyui
+            headers = {
+                "Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
+            }
             r = requests.get(
-                url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info"
+                url=f"{request.app.state.config.COMFYUI_BASE_URL}/object_info",
+                headers=headers,
             )
             info = r.json()
 
@@ -347,7 +354,7 @@ def get_models(request: Request, user=Depends(get_verified_user)):
         ):
             r = requests.get(
                 url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/sd-models",
-                headers={"authorization": get_automatic1111_api_auth()},
+                headers={"authorization": get_automatic1111_api_auth(request)},
             )
             models = r.json()
             return list(
@@ -521,6 +528,7 @@ async def image_generations(
                 form_data,
                 user.id,
                 request.app.state.config.COMFYUI_BASE_URL,
+                request.app.state.config.COMFYUI_API_KEY,
             )
             log.debug(f"res: {res}")
 
@@ -570,7 +578,7 @@ async def image_generations(
                 requests.post,
                 url=f"{request.app.state.config.AUTOMATIC1111_BASE_URL}/sdapi/v1/txt2img",
                 json=data,
-                headers={"authorization": get_automatic1111_api_auth()},
+                headers={"authorization": get_automatic1111_api_auth(request)},
             )
 
             res = r.json()

+ 91 - 11
backend/open_webui/routers/knowledge.py

@@ -1,5 +1,4 @@
-import json
-from typing import Optional, Union
+from typing import List, Optional
 from pydantic import BaseModel
 from fastapi import APIRouter, Depends, HTTPException, status, Request
 import logging
@@ -12,11 +11,16 @@ from open_webui.models.knowledge import (
 )
 from open_webui.models.files import Files, FileModel
 from open_webui.retrieval.vector.connector import VECTOR_DB_CLIENT
-from open_webui.routers.retrieval import process_file, ProcessFileForm
+from open_webui.routers.retrieval import (
+    process_file,
+    ProcessFileForm,
+    process_files_batch,
+    BatchProcessFilesForm,
+)
 
 
 from open_webui.constants import ERROR_MESSAGES
-from open_webui.utils.auth import get_admin_user, get_verified_user
+from open_webui.utils.auth import get_verified_user
 from open_webui.utils.access_control import has_access, has_permission
 
 
@@ -415,13 +419,6 @@ def remove_file_from_knowledge_by_id(
         collection_name=knowledge.id, filter={"file_id": form_data.file_id}
     )
 
-    result = VECTOR_DB_CLIENT.query(
-        collection_name=knowledge.id,
-        filter={"file_id": form_data.file_id},
-    )
-
-    Files.delete_file_by_id(form_data.file_id)
-
     if knowledge:
         data = knowledge.data or {}
         file_ids = data.get("file_ids", [])
@@ -514,3 +511,86 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
     knowledge = Knowledges.update_knowledge_data_by_id(id=id, data={"file_ids": []})
 
     return knowledge
+
+
+############################
+# AddFilesToKnowledge
+############################
+
+
+@router.post("/{id}/files/batch/add", response_model=Optional[KnowledgeFilesResponse])
+def add_files_to_knowledge_batch(
+    request: Request,
+    id: str,
+    form_data: list[KnowledgeFileIdForm],
+    user=Depends(get_verified_user),
+):
+    """
+    Add multiple files to a knowledge base
+    """
+    knowledge = Knowledges.get_knowledge_by_id(id=id)
+    if not knowledge:
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.NOT_FOUND,
+        )
+
+    if knowledge.user_id != user.id and user.role != "admin":
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
+        )
+
+    # Get files content
+    print(f"files/batch/add - {len(form_data)} files")
+    files: List[FileModel] = []
+    for form in form_data:
+        file = Files.get_file_by_id(form.file_id)
+        if not file:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=f"File {form.file_id} not found",
+            )
+        files.append(file)
+
+    # Process files
+    try:
+        result = process_files_batch(
+            request=request,
+            form_data=BatchProcessFilesForm(files=files, collection_name=id),
+            user=user,
+        )
+    except Exception as e:
+        log.error(
+            f"add_files_to_knowledge_batch: Exception occurred: {e}", exc_info=True
+        )
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
+
+    # Add successful files to knowledge base
+    data = knowledge.data or {}
+    existing_file_ids = data.get("file_ids", [])
+
+    # Only add files that were successfully processed
+    successful_file_ids = [r.file_id for r in result.results if r.status == "completed"]
+    for file_id in successful_file_ids:
+        if file_id not in existing_file_ids:
+            existing_file_ids.append(file_id)
+
+    data["file_ids"] = existing_file_ids
+    knowledge = Knowledges.update_knowledge_data_by_id(id=id, data=data)
+
+    # If there were any errors, include them in the response
+    if result.errors:
+        error_details = [f"{err.file_id}: {err.error}" for err in result.errors]
+        return KnowledgeFilesResponse(
+            **knowledge.model_dump(),
+            files=Files.get_files_by_ids(existing_file_ids),
+            warnings={
+                "message": "Some files failed to process",
+                "errors": error_details,
+            },
+        )
+
+    return KnowledgeFilesResponse(
+        **knowledge.model_dump(), files=Files.get_files_by_ids(existing_file_ids)
+    )

+ 11 - 9
backend/open_webui/routers/ollama.py

@@ -82,6 +82,16 @@ async def send_get_request(url, key=None):
         return None
 
 
+async def cleanup_response(
+    response: Optional[aiohttp.ClientResponse],
+    session: Optional[aiohttp.ClientSession],
+):
+    if response:
+        response.close()
+    if session:
+        await session.close()
+
+
 async def send_post_request(
     url: str,
     payload: Union[str, bytes],
@@ -89,14 +99,6 @@ async def send_post_request(
     key: Optional[str] = None,
     content_type: Optional[str] = None,
 ):
-    async def cleanup_response(
-        response: Optional[aiohttp.ClientResponse],
-        session: Optional[aiohttp.ClientSession],
-    ):
-        if response:
-            response.close()
-        if session:
-            await session.close()
 
     r = None
     try:
@@ -917,7 +919,7 @@ class ChatMessage(BaseModel):
 class GenerateChatCompletionForm(BaseModel):
     model: str
     messages: list[ChatMessage]
-    format: Optional[str] = None
+    format: Optional[dict] = None
     options: Optional[dict] = None
     template: Optional[str] = None
     stream: Optional[bool] = True

+ 11 - 8
backend/open_webui/routers/openai.py

@@ -533,6 +533,9 @@ async def generate_chat_completion(
     user=Depends(get_verified_user),
     bypass_filter: Optional[bool] = False,
 ):
+    if BYPASS_MODEL_ACCESS_CONTROL:
+        bypass_filter = True
+
     idx = 0
     payload = {**form_data}
     if "metadata" in payload:
@@ -545,6 +548,7 @@ async def generate_chat_completion(
     if model_info:
         if model_info.base_model_id:
             payload["model"] = model_info.base_model_id
+            model_id = model_info.base_model_id
 
         params = model_info.params.model_dump()
         payload = apply_model_params_to_body_openai(params, payload)
@@ -604,14 +608,13 @@ async def generate_chat_completion(
     if is_o1:
         payload = openai_o1_handler(payload)
     elif "api.openai.com" not in url:
-        # Remove "max_tokens" from the payload for backward compatibility
-        if "max_tokens" in payload:
-            payload["max_completion_tokens"] = payload["max_tokens"]
-            del payload["max_tokens"]
-
-    # TODO: check if below is needed
-    # if "max_tokens" in payload and "max_completion_tokens" in payload:
-    #     del payload["max_tokens"]
+        # Remove "max_completion_tokens" from the payload for backward compatibility
+        if "max_completion_tokens" in payload:
+            payload["max_tokens"] = payload["max_completion_tokens"]
+            del payload["max_completion_tokens"]
+
+    if "max_tokens" in payload and "max_completion_tokens" in payload:
+        del payload["max_tokens"]
 
     # Convert the modified body back to JSON
     payload = json.dumps(payload)

+ 3 - 7
backend/open_webui/routers/pipelines.py

@@ -124,18 +124,14 @@ def process_pipeline_outlet_filter(request, payload, user, models):
                     f"{url}/{filter['id']}/filter/outlet",
                     headers={"Authorization": f"Bearer {key}"},
                     json={
-                        "user": {
-                            "id": user.id,
-                            "name": user.name,
-                            "email": user.email,
-                            "role": user.role,
-                        },
-                        "body": data,
+                        "user": user,
+                        "body": payload,
                     },
                 )
 
                 r.raise_for_status()
                 data = r.json()
+                payload = data
         except Exception as e:
             # Handle connection error here
             print(f"Connection error: {e}")

+ 107 - 6
backend/open_webui/routers/retrieval.py

@@ -7,7 +7,7 @@ import shutil
 import uuid
 from datetime import datetime
 from pathlib import Path
-from typing import Iterator, Optional, Sequence, Union
+from typing import Iterator, List, Optional, Sequence, Union
 
 from fastapi import (
     Depends,
@@ -28,7 +28,7 @@ import tiktoken
 from langchain.text_splitter import RecursiveCharacterTextSplitter, TokenTextSplitter
 from langchain_core.documents import Document
 
-from open_webui.models.files import Files
+from open_webui.models.files import FileModel, Files
 from open_webui.models.knowledge import Knowledges
 from open_webui.storage.provider import Storage
 
@@ -347,6 +347,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
     return {
         "status": True,
         "pdf_extract_images": request.app.state.config.PDF_EXTRACT_IMAGES,
+        "enable_google_drive_integration": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
         "content_extraction": {
             "engine": request.app.state.config.CONTENT_EXTRACTION_ENGINE,
             "tika_server_url": request.app.state.config.TIKA_SERVER_URL,
@@ -369,6 +370,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
             "web_loader_ssl_verification": request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
             "search": {
                 "enabled": request.app.state.config.ENABLE_RAG_WEB_SEARCH,
+                "drive": request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION,
                 "engine": request.app.state.config.RAG_WEB_SEARCH_ENGINE,
                 "searxng_query_url": request.app.state.config.SEARXNG_QUERY_URL,
                 "google_pse_api_key": request.app.state.config.GOOGLE_PSE_API_KEY,
@@ -445,6 +447,7 @@ class WebConfig(BaseModel):
 
 class ConfigUpdateForm(BaseModel):
     pdf_extract_images: Optional[bool] = None
+    enable_google_drive_integration: Optional[bool] = None
     file: Optional[FileConfig] = None
     content_extraction: Optional[ContentExtractionConfig] = None
     chunk: Optional[ChunkParamUpdateForm] = None
@@ -462,6 +465,12 @@ async def update_rag_config(
         else request.app.state.config.PDF_EXTRACT_IMAGES
     )
 
+    request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION = (
+        form_data.enable_google_drive_integration
+        if form_data.enable_google_drive_integration is not None
+        else request.app.state.config.ENABLE_GOOGLE_DRIVE_INTEGRATION
+    )
+
     if form_data.file is not None:
         request.app.state.config.FILE_MAX_SIZE = form_data.file.max_size
         request.app.state.config.FILE_MAX_COUNT = form_data.file.max_count
@@ -1247,21 +1256,22 @@ def process_web_search(
             detail=ERROR_MESSAGES.WEB_SEARCH_ERROR(e),
         )
 
+    log.debug(f"web_results: {web_results}")
+
     try:
         collection_name = form_data.collection_name
-        if collection_name == "":
+        if collection_name == "" or collection_name is None:
             collection_name = f"web-search-{calculate_sha256_string(form_data.query)}"[
                 :63
             ]
 
         urls = [result.link for result in web_results]
         loader = get_web_loader(
-            urls=urls,
+            urls,
             verify_ssl=request.app.state.config.ENABLE_RAG_WEB_LOADER_SSL_VERIFICATION,
             requests_per_second=request.app.state.config.RAG_WEB_SEARCH_CONCURRENT_REQUESTS,
         )
-        docs = loader.aload()
-
+        docs = loader.load()
         save_docs_to_vector_db(request, docs, collection_name, overwrite=True)
 
         return {
@@ -1428,3 +1438,94 @@ if ENV == "dev":
     @router.get("/ef/{text}")
     async def get_embeddings(request: Request, text: Optional[str] = "Hello World!"):
         return {"result": request.app.state.EMBEDDING_FUNCTION(text)}
+
+
+class BatchProcessFilesForm(BaseModel):
+    files: List[FileModel]
+    collection_name: str
+
+
+class BatchProcessFilesResult(BaseModel):
+    file_id: str
+    status: str
+    error: Optional[str] = None
+
+
+class BatchProcessFilesResponse(BaseModel):
+    results: List[BatchProcessFilesResult]
+    errors: List[BatchProcessFilesResult]
+
+
+@router.post("/process/files/batch")
+def process_files_batch(
+    request: Request,
+    form_data: BatchProcessFilesForm,
+    user=Depends(get_verified_user),
+) -> BatchProcessFilesResponse:
+    """
+    Process a batch of files and save them to the vector database.
+    """
+    results: List[BatchProcessFilesResult] = []
+    errors: List[BatchProcessFilesResult] = []
+    collection_name = form_data.collection_name
+
+    # Prepare all documents first
+    all_docs: List[Document] = []
+    for file in form_data.files:
+        try:
+            text_content = file.data.get("content", "")
+
+            docs: List[Document] = [
+                Document(
+                    page_content=text_content.replace("<br/>", "\n"),
+                    metadata={
+                        **file.meta,
+                        "name": file.filename,
+                        "created_by": file.user_id,
+                        "file_id": file.id,
+                        "source": file.filename,
+                    },
+                )
+            ]
+
+            hash = calculate_sha256_string(text_content)
+            Files.update_file_hash_by_id(file.id, hash)
+            Files.update_file_data_by_id(file.id, {"content": text_content})
+
+            all_docs.extend(docs)
+            results.append(BatchProcessFilesResult(file_id=file.id, status="prepared"))
+
+        except Exception as e:
+            log.error(f"process_files_batch: Error processing file {file.id}: {str(e)}")
+            errors.append(
+                BatchProcessFilesResult(file_id=file.id, status="failed", error=str(e))
+            )
+
+    # Save all documents in one batch
+    if all_docs:
+        try:
+            save_docs_to_vector_db(
+                request=request,
+                docs=all_docs,
+                collection_name=collection_name,
+                add=True,
+            )
+
+            # Update all files with collection name
+            for result in results:
+                Files.update_file_metadata_by_id(
+                    result.file_id, {"collection_name": collection_name}
+                )
+                result.status = "completed"
+
+        except Exception as e:
+            log.error(
+                f"process_files_batch: Error saving documents to vector DB: {str(e)}"
+            )
+            for result in results:
+                result.status = "failed"
+                errors.append(
+                    BatchProcessFilesResult(file_id=result.file_id, error=str(e))
+                )
+
+    return BatchProcessFilesResponse(results=results, errors=errors)

+ 9 - 6
backend/open_webui/routers/tasks.py

@@ -186,9 +186,10 @@ async def generate_title(
     try:
         return await generate_chat_completion(request, form_data=payload, user=user)
     except Exception as e:
+        log.error("Exception occurred", exc_info=True)
         return JSONResponse(
             status_code=status.HTTP_400_BAD_REQUEST,
-            content={"detail": str(e)},
+            content={"detail": "An internal error has occurred."},
         )
 
 
@@ -248,9 +249,10 @@ async def generate_chat_tags(
     try:
         return await generate_chat_completion(request, form_data=payload, user=user)
     except Exception as e:
+        log.error(f"Error generating chat completion: {e}")
         return JSONResponse(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            content={"detail": str(e)},
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            content={"detail": "An internal error has occurred."},
         )
 
 
@@ -393,9 +395,10 @@ async def generate_autocompletion(
     try:
         return await generate_chat_completion(request, form_data=payload, user=user)
     except Exception as e:
+        log.error(f"Error generating chat completion: {e}")
         return JSONResponse(
-            status_code=status.HTTP_400_BAD_REQUEST,
-            content={"detail": str(e)},
+            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+            content={"detail": "An internal error has occurred."},
         )
 
 
@@ -496,8 +499,8 @@ async def generate_moa_response(
         "model": task_model_id,
         "messages": [{"role": "user", "content": content}],
         "stream": form_data.get("stream", False),
-        "chat_id": form_data.get("chat_id", None),
         "metadata": {
+            "chat_id": form_data.get("chat_id", None),
             "task": str(TASKS.MOA_RESPONSE_GENERATION),
             "task_body": form_data,
         },

+ 16 - 2
backend/open_webui/routers/users.py

@@ -10,6 +10,9 @@ from open_webui.models.users import (
     UserSettings,
     UserUpdateForm,
 )
+
+
+from open_webui.socket.main import get_active_status_by_user_id
 from open_webui.constants import ERROR_MESSAGES
 from open_webui.env import SRC_LOG_LEVELS
 from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -27,7 +30,11 @@ router = APIRouter()
 
 
 @router.get("/", response_model=list[UserModel])
-async def get_users(skip: int = 0, limit: int = 50, user=Depends(get_admin_user)):
+async def get_users(
+    skip: Optional[int] = None,
+    limit: Optional[int] = None,
+    user=Depends(get_admin_user),
+):
     return Users.get_users(skip, limit)
 
 
@@ -192,6 +199,7 @@ async def update_user_info_by_session_user(
 class UserResponse(BaseModel):
     name: str
     profile_image_url: str
+    active: Optional[bool] = None
 
 
 @router.get("/{user_id}", response_model=UserResponse)
@@ -212,7 +220,13 @@ async def get_user_by_id(user_id: str, user=Depends(get_verified_user)):
     user = Users.get_user_by_id(user_id)
 
     if user:
-        return UserResponse(name=user.name, profile_image_url=user.profile_image_url)
+        return UserResponse(
+            **{
+                "name": user.name,
+                "profile_image_url": user.profile_image_url,
+                "active": get_active_status_by_user_id(user_id),
+            }
+        )
     else:
         raise HTTPException(
             status_code=status.HTTP_400_BAD_REQUEST,

+ 201 - 58
backend/open_webui/socket/main.py

@@ -4,14 +4,17 @@ import logging
 import sys
 import time
 
-from open_webui.models.users import Users
+from open_webui.models.users import Users, UserNameResponse
+from open_webui.models.channels import Channels
+from open_webui.models.chats import Chats
+
 from open_webui.env import (
     ENABLE_WEBSOCKET_SUPPORT,
     WEBSOCKET_MANAGER,
     WEBSOCKET_REDIS_URL,
 )
 from open_webui.utils.auth import decode_token
-from open_webui.socket.utils import RedisDict
+from open_webui.socket.utils import RedisDict, RedisLock
 
 from open_webui.env import (
     GLOBAL_LOG_LEVEL,
@@ -29,9 +32,7 @@ if WEBSOCKET_MANAGER == "redis":
     sio = socketio.AsyncServer(
         cors_allowed_origins=[],
         async_mode="asgi",
-        transports=(
-            ["polling", "websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]
-        ),
+        transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]),
         allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
         always_connect=True,
         client_manager=mgr,
@@ -40,54 +41,77 @@ else:
     sio = socketio.AsyncServer(
         cors_allowed_origins=[],
         async_mode="asgi",
-        transports=(
-            ["polling", "websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]
-        ),
+        transports=(["websocket"] if ENABLE_WEBSOCKET_SUPPORT else ["polling"]),
         allow_upgrades=ENABLE_WEBSOCKET_SUPPORT,
         always_connect=True,
     )
 
 
+# Timeout duration in seconds
+TIMEOUT_DURATION = 3
+
 # Dictionary to maintain the user pool
 
 if WEBSOCKET_MANAGER == "redis":
+    log.debug("Using Redis to manage websockets.")
     SESSION_POOL = RedisDict("open-webui:session_pool", redis_url=WEBSOCKET_REDIS_URL)
     USER_POOL = RedisDict("open-webui:user_pool", redis_url=WEBSOCKET_REDIS_URL)
     USAGE_POOL = RedisDict("open-webui:usage_pool", redis_url=WEBSOCKET_REDIS_URL)
+
+    clean_up_lock = RedisLock(
+        redis_url=WEBSOCKET_REDIS_URL,
+        lock_name="usage_cleanup_lock",
+        timeout_secs=TIMEOUT_DURATION * 2,
+    )
+    aquire_func = clean_up_lock.aquire_lock
+    renew_func = clean_up_lock.renew_lock
+    release_func = clean_up_lock.release_lock
 else:
     SESSION_POOL = {}
     USER_POOL = {}
     USAGE_POOL = {}
-
-
-# Timeout duration in seconds
-TIMEOUT_DURATION = 3
+    aquire_func = release_func = renew_func = lambda: True
 
 
 async def periodic_usage_pool_cleanup():
-    while True:
-        now = int(time.time())
-        for model_id, connections in list(USAGE_POOL.items()):
-            # Creating a list of sids to remove if they have timed out
-            expired_sids = [
-                sid
-                for sid, details in connections.items()
-                if now - details["updated_at"] > TIMEOUT_DURATION
-            ]
-
-            for sid in expired_sids:
-                del connections[sid]
-
-            if not connections:
-                log.debug(f"Cleaning up model {model_id} from usage pool")
-                del USAGE_POOL[model_id]
-            else:
-                USAGE_POOL[model_id] = connections
-
-            # Emit updated usage information after cleaning
-            await sio.emit("usage", {"models": get_models_in_use()})
-
-        await asyncio.sleep(TIMEOUT_DURATION)
+    if not aquire_func():
+        log.debug("Usage pool cleanup lock already exists. Not running it.")
+        return
+    log.debug("Running periodic_usage_pool_cleanup")
+    try:
+        while True:
+            if not renew_func():
+                log.error(f"Unable to renew cleanup lock. Exiting usage pool cleanup.")
+                raise Exception("Unable to renew usage pool cleanup lock.")
+
+            now = int(time.time())
+            send_usage = False
+            for model_id, connections in list(USAGE_POOL.items()):
+                # Creating a list of sids to remove if they have timed out
+                expired_sids = [
+                    sid
+                    for sid, details in connections.items()
+                    if now - details["updated_at"] > TIMEOUT_DURATION
+                ]
+
+                for sid in expired_sids:
+                    del connections[sid]
+
+                if not connections:
+                    log.debug(f"Cleaning up model {model_id} from usage pool")
+                    del USAGE_POOL[model_id]
+                else:
+                    USAGE_POOL[model_id] = connections
+
+                send_usage = True
+
+            if send_usage:
+                # Emit updated usage information after cleaning
+                await sio.emit("usage", {"models": get_models_in_use()})
+
+            await asyncio.sleep(TIMEOUT_DURATION)
+    finally:
+        release_func()
 
 
 app = socketio.ASGIApp(
@@ -128,20 +152,19 @@ async def connect(sid, environ, auth):
             user = Users.get_user_by_id(data["id"])
 
         if user:
-            SESSION_POOL[sid] = user.id
+            SESSION_POOL[sid] = user.model_dump()
             if user.id in USER_POOL:
-                USER_POOL[user.id].append(sid)
+                USER_POOL[user.id] = USER_POOL[user.id] + [sid]
             else:
                 USER_POOL[user.id] = [sid]
 
             # print(f"user {user.name}({user.id}) connected with session ID {sid}")
-            await sio.emit("user-count", {"count": len(USER_POOL.items())})
+            await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
             await sio.emit("usage", {"models": get_models_in_use()})
 
 
 @sio.on("user-join")
 async def user_join(sid, data):
-    # print("user-join", sid, data)
 
     auth = data["auth"] if "auth" in data else None
     if not auth or "token" not in auth:
@@ -155,39 +178,91 @@ async def user_join(sid, data):
     if not user:
         return
 
-    SESSION_POOL[sid] = user.id
+    SESSION_POOL[sid] = user.model_dump()
     if user.id in USER_POOL:
-        USER_POOL[user.id].append(sid)
+        USER_POOL[user.id] = USER_POOL[user.id] + [sid]
     else:
         USER_POOL[user.id] = [sid]
 
+    # Join all the channels
+    channels = Channels.get_channels_by_user_id(user.id)
+    log.debug(f"{channels=}")
+    for channel in channels:
+        await sio.enter_room(sid, f"channel:{channel.id}")
+
     # print(f"user {user.name}({user.id}) connected with session ID {sid}")
 
-    await sio.emit("user-count", {"count": len(USER_POOL.items())})
+    await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
+    return {"id": user.id, "name": user.name}
 
 
-@sio.on("user-count")
-async def user_count(sid):
-    await sio.emit("user-count", {"count": len(USER_POOL.items())})
+@sio.on("join-channels")
+async def join_channel(sid, data):
+    auth = data["auth"] if "auth" in data else None
+    if not auth or "token" not in auth:
+        return
 
+    data = decode_token(auth["token"])
+    if data is None or "id" not in data:
+        return
+
+    user = Users.get_user_by_id(data["id"])
+    if not user:
+        return
+
+    # Join all the channels
+    channels = Channels.get_channels_by_user_id(user.id)
+    log.debug(f"{channels=}")
+    for channel in channels:
+        await sio.enter_room(sid, f"channel:{channel.id}")
 
-@sio.on("chat")
-async def chat(sid, data):
-    print("chat", sid, SESSION_POOL[sid], data)
+
+@sio.on("channel-events")
+async def channel_events(sid, data):
+    room = f"channel:{data['channel_id']}"
+    participants = sio.manager.get_participants(
+        namespace="/",
+        room=room,
+    )
+
+    sids = [sid for sid, _ in participants]
+    if sid not in sids:
+        return
+
+    event_data = data["data"]
+    event_type = event_data["type"]
+
+    if event_type == "typing":
+        await sio.emit(
+            "channel-events",
+            {
+                "channel_id": data["channel_id"],
+                "message_id": data.get("message_id", None),
+                "data": event_data,
+                "user": UserNameResponse(**SESSION_POOL[sid]).model_dump(),
+            },
+            room=room,
+        )
+
+
+@sio.on("user-list")
+async def user_list(sid):
+    await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
 
 
 @sio.event
 async def disconnect(sid):
     if sid in SESSION_POOL:
-        user_id = SESSION_POOL[sid]
+        user = SESSION_POOL[sid]
         del SESSION_POOL[sid]
 
+        user_id = user["id"]
         USER_POOL[user_id] = [_sid for _sid in USER_POOL[user_id] if _sid != sid]
 
         if len(USER_POOL[user_id]) == 0:
             del USER_POOL[user_id]
 
-        await sio.emit("user-count", {"count": len(USER_POOL)})
+        await sio.emit("user-list", {"user_ids": list(USER_POOL.keys())})
     else:
         pass
         # print(f"Unknown session ID {sid} disconnected")
@@ -195,16 +270,57 @@ async def disconnect(sid):
 
 def get_event_emitter(request_info):
     async def __event_emitter__(event_data):
-        await sio.emit(
-            "chat-events",
-            {
-                "chat_id": request_info["chat_id"],
-                "message_id": request_info["message_id"],
-                "data": event_data,
-            },
-            to=request_info["session_id"],
+        user_id = request_info["user_id"]
+        session_ids = list(
+            set(USER_POOL.get(user_id, []) + [request_info["session_id"]])
         )
 
+        for session_id in session_ids:
+            await sio.emit(
+                "chat-events",
+                {
+                    "chat_id": request_info["chat_id"],
+                    "message_id": request_info["message_id"],
+                    "data": event_data,
+                },
+                to=session_id,
+            )
+
+        if "type" in event_data and event_data["type"] == "status":
+            Chats.add_message_status_to_chat_by_id_and_message_id(
+                request_info["chat_id"],
+                request_info["message_id"],
+                event_data.get("data", {}),
+            )
+
+        if "type" in event_data and event_data["type"] == "message":
+            message = Chats.get_message_by_id_and_message_id(
+                request_info["chat_id"],
+                request_info["message_id"],
+            )
+
+            content = message.get("content", "")
+            content += event_data.get("data", {}).get("content", "")
+
+            Chats.upsert_message_to_chat_by_id_and_message_id(
+                request_info["chat_id"],
+                request_info["message_id"],
+                {
+                    "content": content,
+                },
+            )
+
+        if "type" in event_data and event_data["type"] == "replace":
+            content = event_data.get("data", {}).get("content", "")
+
+            Chats.upsert_message_to_chat_by_id_and_message_id(
+                request_info["chat_id"],
+                request_info["message_id"],
+                {
+                    "content": content,
+                },
+            )
+
     return __event_emitter__
 
 
@@ -222,3 +338,30 @@ def get_event_call(request_info):
         return response
 
     return __event_call__
+
+
+def get_user_id_from_session_pool(sid):
+    user = SESSION_POOL.get(sid)
+    if user:
+        return user["id"]
+    return None
+
+
+def get_user_ids_from_room(room):
+    active_session_ids = sio.manager.get_participants(
+        namespace="/",
+        room=room,
+    )
+
+    active_user_ids = list(
+        set(
+            [SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
+        )
+    )
+    return active_user_ids
+
+
+def get_active_status_by_user_id(user_id):
+    if user_id in USER_POOL:
+        return True
+    return False

+ 28 - 0
backend/open_webui/socket/utils.py

@@ -1,5 +1,33 @@
 import json
 import redis
+import uuid
+
+
+class RedisLock:
+    def __init__(self, redis_url, lock_name, timeout_secs):
+        self.lock_name = lock_name
+        self.lock_id = str(uuid.uuid4())
+        self.timeout_secs = timeout_secs
+        self.lock_obtained = False
+        self.redis = redis.Redis.from_url(redis_url, decode_responses=True)
+
+    def aquire_lock(self):
+        # nx=True will only set this key if it _hasn't_ already been set
+        self.lock_obtained = self.redis.set(
+            self.lock_name, self.lock_id, nx=True, ex=self.timeout_secs
+        )
+        return self.lock_obtained
+
+    def renew_lock(self):
+        # xx=True will only set this key if it _has_ already been set
+        return self.redis.set(
+            self.lock_name, self.lock_id, xx=True, ex=self.timeout_secs
+        )
+
+    def release_lock(self):
+        lock_value = self.redis.get(self.lock_name)
+        if lock_value and lock_value.decode("utf-8") == self.lock_id:
+            self.redis.delete(self.lock_name)
 
 
 class RedisDict:

+ 2 - 2
backend/open_webui/static/assets/pdf-style.css

@@ -26,8 +26,8 @@
 
 html {
 	font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'NotoSans', 'NotoSansJP', 'NotoSansKR',
-		'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium', Roboto,
-		'Helvetica Neue', Arial, sans-serif;
+		'NotoSansSC', 'Twemoji', 'STSong-Light', 'MSung-Light', 'HeiseiMin-W3', 'HYSMyeongJo-Medium',
+		Roboto, 'Helvetica Neue', Arial, sans-serif;
 	font-size: 14px; /* Default font size */
 	line-height: 1.5;
 }

二进制
backend/open_webui/static/swagger-ui/favicon.png


文件差异内容过多而无法显示
+ 65166 - 0
backend/open_webui/static/swagger-ui/swagger-ui-bundle.js


+ 9312 - 0
backend/open_webui/static/swagger-ui/swagger-ui.css

@@ -0,0 +1,9312 @@
+.swagger-ui {
+	color: #3b4151;
+	font-family: sans-serif; /*! normalize.css v7.0.0 | MIT License | github.com/necolas/normalize.css */
+}
+.swagger-ui html {
+	line-height: 1.15;
+	-ms-text-size-adjust: 100%;
+	-webkit-text-size-adjust: 100%;
+}
+.swagger-ui body {
+	margin: 0;
+}
+.swagger-ui article,
+.swagger-ui aside,
+.swagger-ui footer,
+.swagger-ui header,
+.swagger-ui nav,
+.swagger-ui section {
+	display: block;
+}
+.swagger-ui h1 {
+	font-size: 2em;
+	margin: 0.67em 0;
+}
+.swagger-ui figcaption,
+.swagger-ui figure,
+.swagger-ui main {
+	display: block;
+}
+.swagger-ui figure {
+	margin: 1em 40px;
+}
+.swagger-ui hr {
+	box-sizing: content-box;
+	height: 0;
+	overflow: visible;
+}
+.swagger-ui pre {
+	font-family: monospace, monospace;
+	font-size: 1em;
+}
+.swagger-ui a {
+	background-color: transparent;
+	-webkit-text-decoration-skip: objects;
+}
+.swagger-ui abbr[title] {
+	border-bottom: none;
+	text-decoration: underline;
+	-webkit-text-decoration: underline dotted;
+	text-decoration: underline dotted;
+}
+.swagger-ui b,
+.swagger-ui strong {
+	font-weight: inherit;
+	font-weight: bolder;
+}
+.swagger-ui code,
+.swagger-ui kbd,
+.swagger-ui samp {
+	font-family: monospace, monospace;
+	font-size: 1em;
+}
+.swagger-ui dfn {
+	font-style: italic;
+}
+.swagger-ui mark {
+	background-color: #ff0;
+	color: #000;
+}
+.swagger-ui small {
+	font-size: 80%;
+}
+.swagger-ui sub,
+.swagger-ui sup {
+	font-size: 75%;
+	line-height: 0;
+	position: relative;
+	vertical-align: baseline;
+}
+.swagger-ui sub {
+	bottom: -0.25em;
+}
+.swagger-ui sup {
+	top: -0.5em;
+}
+.swagger-ui audio,
+.swagger-ui video {
+	display: inline-block;
+}
+.swagger-ui audio:not([controls]) {
+	display: none;
+	height: 0;
+}
+.swagger-ui img {
+	border-style: none;
+}
+.swagger-ui svg:not(:root) {
+	overflow: hidden;
+}
+.swagger-ui button,
+.swagger-ui input,
+.swagger-ui optgroup,
+.swagger-ui select,
+.swagger-ui textarea {
+	font-family: sans-serif;
+	font-size: 100%;
+	line-height: 1.15;
+	margin: 0;
+}
+.swagger-ui button,
+.swagger-ui input {
+	overflow: visible;
+}
+.swagger-ui button,
+.swagger-ui select {
+	text-transform: none;
+}
+.swagger-ui [type='reset'],
+.swagger-ui [type='submit'],
+.swagger-ui button,
+.swagger-ui html [type='button'] {
+	-webkit-appearance: button;
+}
+.swagger-ui [type='button']::-moz-focus-inner,
+.swagger-ui [type='reset']::-moz-focus-inner,
+.swagger-ui [type='submit']::-moz-focus-inner,
+.swagger-ui button::-moz-focus-inner {
+	border-style: none;
+	padding: 0;
+}
+.swagger-ui [type='button']:-moz-focusring,
+.swagger-ui [type='reset']:-moz-focusring,
+.swagger-ui [type='submit']:-moz-focusring,
+.swagger-ui button:-moz-focusring {
+	outline: 1px dotted ButtonText;
+}
+.swagger-ui fieldset {
+	padding: 0.35em 0.75em 0.625em;
+}
+.swagger-ui legend {
+	box-sizing: border-box;
+	color: inherit;
+	display: table;
+	max-width: 100%;
+	padding: 0;
+	white-space: normal;
+}
+.swagger-ui progress {
+	display: inline-block;
+	vertical-align: baseline;
+}
+.swagger-ui textarea {
+	overflow: auto;
+}
+.swagger-ui [type='checkbox'],
+.swagger-ui [type='radio'] {
+	box-sizing: border-box;
+	padding: 0;
+}
+.swagger-ui [type='number']::-webkit-inner-spin-button,
+.swagger-ui [type='number']::-webkit-outer-spin-button {
+	height: auto;
+}
+.swagger-ui [type='search'] {
+	-webkit-appearance: textfield;
+	outline-offset: -2px;
+}
+.swagger-ui [type='search']::-webkit-search-cancel-button,
+.swagger-ui [type='search']::-webkit-search-decoration {
+	-webkit-appearance: none;
+}
+.swagger-ui ::-webkit-file-upload-button {
+	-webkit-appearance: button;
+	font: inherit;
+}
+.swagger-ui details,
+.swagger-ui menu {
+	display: block;
+}
+.swagger-ui summary {
+	display: list-item;
+}
+.swagger-ui canvas {
+	display: inline-block;
+}
+.swagger-ui [hidden],
+.swagger-ui template {
+	display: none;
+}
+.swagger-ui .debug * {
+	outline: 1px solid gold;
+}
+.swagger-ui .debug-white * {
+	outline: 1px solid #fff;
+}
+.swagger-ui .debug-black * {
+	outline: 1px solid #000;
+}
+.swagger-ui .debug-grid {
+	background: transparent
+		url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTRDOTY4N0U2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTRDOTY4N0Q2N0VFMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3NjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3NzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PsBS+GMAAAAjSURBVHjaYvz//z8DLsD4gcGXiYEAGBIKGBne//fFpwAgwAB98AaF2pjlUQAAAABJRU5ErkJggg==)
+		repeat 0 0;
+}
+.swagger-ui .debug-grid-16 {
+	background: transparent
+		url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6ODYyRjhERDU2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6ODYyRjhERDQ2N0YyMTFFNjg2MzZDQjkwNkQ4MjgwMEIiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QTY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3QjY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PvCS01IAAABMSURBVHjaYmR4/5+BFPBfAMFm/MBgx8RAGWCn1AAmSg34Q6kBDKMGMDCwICeMIemF/5QawEipAWwUhwEjMDvbAWlWkvVBwu8vQIABAEwBCph8U6c0AAAAAElFTkSuQmCC)
+		repeat 0 0;
+}
+.swagger-ui .debug-grid-8-solid {
+	background: #fff
+		url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAAAAD/4QMxaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjYtYzExMSA3OS4xNTgzMjUsIDIwMTUvMDkvMTAtMDE6MTA6MjAgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDQyAyMDE1IChNYWNpbnRvc2gpIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOkIxMjI0OTczNjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOkIxMjI0OTc0NjdCMzExRTZCMkJDRTI0MDgxMDAyMTcxIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QjEyMjQ5NzE2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QjEyMjQ5NzI2N0IzMTFFNkIyQkNFMjQwODEwMDIxNzEiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAbGhopHSlBJiZBQi8vL0JHPz4+P0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHAR0pKTQmND8oKD9HPzU/R0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0f/wAARCAAIAAgDASIAAhEBAxEB/8QAWQABAQAAAAAAAAAAAAAAAAAAAAYBAQEAAAAAAAAAAAAAAAAAAAIEEAEBAAMBAAAAAAAAAAAAAAABADECA0ERAAEDBQAAAAAAAAAAAAAAAAARITFBUWESIv/aAAwDAQACEQMRAD8AoOnTV1QTD7JJshP3vSM3P//Z)
+		repeat 0 0;
+}
+.swagger-ui .debug-grid-16-solid {
+	background: #fff
+		url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6NzY3MkJEN0U2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6NzY3MkJEN0Y2N0M1MTFFNkIyQkNFMjQwODEwMDIxNzEiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDo3NjcyQkQ3QzY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDo3NjcyQkQ3RDY3QzUxMUU2QjJCQ0UyNDA4MTAwMjE3MSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pve6J3kAAAAzSURBVHjaYvz//z8D0UDsMwMjSRoYP5Gq4SPNbRjVMEQ1fCRDg+in/6+J1AJUxsgAEGAA31BAJMS0GYEAAAAASUVORK5CYII=)
+		repeat 0 0;
+}
+.swagger-ui .border-box,
+.swagger-ui a,
+.swagger-ui article,
+.swagger-ui body,
+.swagger-ui code,
+.swagger-ui dd,
+.swagger-ui div,
+.swagger-ui dl,
+.swagger-ui dt,
+.swagger-ui fieldset,
+.swagger-ui footer,
+.swagger-ui form,
+.swagger-ui h1,
+.swagger-ui h2,
+.swagger-ui h3,
+.swagger-ui h4,
+.swagger-ui h5,
+.swagger-ui h6,
+.swagger-ui header,
+.swagger-ui html,
+.swagger-ui input[type='email'],
+.swagger-ui input[type='number'],
+.swagger-ui input[type='password'],
+.swagger-ui input[type='tel'],
+.swagger-ui input[type='text'],
+.swagger-ui input[type='url'],
+.swagger-ui legend,
+.swagger-ui li,
+.swagger-ui main,
+.swagger-ui ol,
+.swagger-ui p,
+.swagger-ui pre,
+.swagger-ui section,
+.swagger-ui table,
+.swagger-ui td,
+.swagger-ui textarea,
+.swagger-ui th,
+.swagger-ui tr,
+.swagger-ui ul {
+	box-sizing: border-box;
+}
+.swagger-ui .aspect-ratio {
+	height: 0;
+	position: relative;
+}
+.swagger-ui .aspect-ratio--16x9 {
+	padding-bottom: 56.25%;
+}
+.swagger-ui .aspect-ratio--9x16 {
+	padding-bottom: 177.77%;
+}
+.swagger-ui .aspect-ratio--4x3 {
+	padding-bottom: 75%;
+}
+.swagger-ui .aspect-ratio--3x4 {
+	padding-bottom: 133.33%;
+}
+.swagger-ui .aspect-ratio--6x4 {
+	padding-bottom: 66.6%;
+}
+.swagger-ui .aspect-ratio--4x6 {
+	padding-bottom: 150%;
+}
+.swagger-ui .aspect-ratio--8x5 {
+	padding-bottom: 62.5%;
+}
+.swagger-ui .aspect-ratio--5x8 {
+	padding-bottom: 160%;
+}
+.swagger-ui .aspect-ratio--7x5 {
+	padding-bottom: 71.42%;
+}
+.swagger-ui .aspect-ratio--5x7 {
+	padding-bottom: 140%;
+}
+.swagger-ui .aspect-ratio--1x1 {
+	padding-bottom: 100%;
+}
+.swagger-ui .aspect-ratio--object {
+	bottom: 0;
+	height: 100%;
+	left: 0;
+	position: absolute;
+	right: 0;
+	top: 0;
+	width: 100%;
+	z-index: 100;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .aspect-ratio-ns {
+		height: 0;
+		position: relative;
+	}
+	.swagger-ui .aspect-ratio--16x9-ns {
+		padding-bottom: 56.25%;
+	}
+	.swagger-ui .aspect-ratio--9x16-ns {
+		padding-bottom: 177.77%;
+	}
+	.swagger-ui .aspect-ratio--4x3-ns {
+		padding-bottom: 75%;
+	}
+	.swagger-ui .aspect-ratio--3x4-ns {
+		padding-bottom: 133.33%;
+	}
+	.swagger-ui .aspect-ratio--6x4-ns {
+		padding-bottom: 66.6%;
+	}
+	.swagger-ui .aspect-ratio--4x6-ns {
+		padding-bottom: 150%;
+	}
+	.swagger-ui .aspect-ratio--8x5-ns {
+		padding-bottom: 62.5%;
+	}
+	.swagger-ui .aspect-ratio--5x8-ns {
+		padding-bottom: 160%;
+	}
+	.swagger-ui .aspect-ratio--7x5-ns {
+		padding-bottom: 71.42%;
+	}
+	.swagger-ui .aspect-ratio--5x7-ns {
+		padding-bottom: 140%;
+	}
+	.swagger-ui .aspect-ratio--1x1-ns {
+		padding-bottom: 100%;
+	}
+	.swagger-ui .aspect-ratio--object-ns {
+		bottom: 0;
+		height: 100%;
+		left: 0;
+		position: absolute;
+		right: 0;
+		top: 0;
+		width: 100%;
+		z-index: 100;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .aspect-ratio-m {
+		height: 0;
+		position: relative;
+	}
+	.swagger-ui .aspect-ratio--16x9-m {
+		padding-bottom: 56.25%;
+	}
+	.swagger-ui .aspect-ratio--9x16-m {
+		padding-bottom: 177.77%;
+	}
+	.swagger-ui .aspect-ratio--4x3-m {
+		padding-bottom: 75%;
+	}
+	.swagger-ui .aspect-ratio--3x4-m {
+		padding-bottom: 133.33%;
+	}
+	.swagger-ui .aspect-ratio--6x4-m {
+		padding-bottom: 66.6%;
+	}
+	.swagger-ui .aspect-ratio--4x6-m {
+		padding-bottom: 150%;
+	}
+	.swagger-ui .aspect-ratio--8x5-m {
+		padding-bottom: 62.5%;
+	}
+	.swagger-ui .aspect-ratio--5x8-m {
+		padding-bottom: 160%;
+	}
+	.swagger-ui .aspect-ratio--7x5-m {
+		padding-bottom: 71.42%;
+	}
+	.swagger-ui .aspect-ratio--5x7-m {
+		padding-bottom: 140%;
+	}
+	.swagger-ui .aspect-ratio--1x1-m {
+		padding-bottom: 100%;
+	}
+	.swagger-ui .aspect-ratio--object-m {
+		bottom: 0;
+		height: 100%;
+		left: 0;
+		position: absolute;
+		right: 0;
+		top: 0;
+		width: 100%;
+		z-index: 100;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .aspect-ratio-l {
+		height: 0;
+		position: relative;
+	}
+	.swagger-ui .aspect-ratio--16x9-l {
+		padding-bottom: 56.25%;
+	}
+	.swagger-ui .aspect-ratio--9x16-l {
+		padding-bottom: 177.77%;
+	}
+	.swagger-ui .aspect-ratio--4x3-l {
+		padding-bottom: 75%;
+	}
+	.swagger-ui .aspect-ratio--3x4-l {
+		padding-bottom: 133.33%;
+	}
+	.swagger-ui .aspect-ratio--6x4-l {
+		padding-bottom: 66.6%;
+	}
+	.swagger-ui .aspect-ratio--4x6-l {
+		padding-bottom: 150%;
+	}
+	.swagger-ui .aspect-ratio--8x5-l {
+		padding-bottom: 62.5%;
+	}
+	.swagger-ui .aspect-ratio--5x8-l {
+		padding-bottom: 160%;
+	}
+	.swagger-ui .aspect-ratio--7x5-l {
+		padding-bottom: 71.42%;
+	}
+	.swagger-ui .aspect-ratio--5x7-l {
+		padding-bottom: 140%;
+	}
+	.swagger-ui .aspect-ratio--1x1-l {
+		padding-bottom: 100%;
+	}
+	.swagger-ui .aspect-ratio--object-l {
+		bottom: 0;
+		height: 100%;
+		left: 0;
+		position: absolute;
+		right: 0;
+		top: 0;
+		width: 100%;
+		z-index: 100;
+	}
+}
+.swagger-ui img {
+	max-width: 100%;
+}
+.swagger-ui .cover {
+	background-size: cover !important;
+}
+.swagger-ui .contain {
+	background-size: contain !important;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .cover-ns {
+		background-size: cover !important;
+	}
+	.swagger-ui .contain-ns {
+		background-size: contain !important;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .cover-m {
+		background-size: cover !important;
+	}
+	.swagger-ui .contain-m {
+		background-size: contain !important;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .cover-l {
+		background-size: cover !important;
+	}
+	.swagger-ui .contain-l {
+		background-size: contain !important;
+	}
+}
+.swagger-ui .bg-center {
+	background-position: 50%;
+	background-repeat: no-repeat;
+}
+.swagger-ui .bg-top {
+	background-position: top;
+	background-repeat: no-repeat;
+}
+.swagger-ui .bg-right {
+	background-position: 100%;
+	background-repeat: no-repeat;
+}
+.swagger-ui .bg-bottom {
+	background-position: bottom;
+	background-repeat: no-repeat;
+}
+.swagger-ui .bg-left {
+	background-position: 0;
+	background-repeat: no-repeat;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .bg-center-ns {
+		background-position: 50%;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-top-ns {
+		background-position: top;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-right-ns {
+		background-position: 100%;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-bottom-ns {
+		background-position: bottom;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-left-ns {
+		background-position: 0;
+		background-repeat: no-repeat;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .bg-center-m {
+		background-position: 50%;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-top-m {
+		background-position: top;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-right-m {
+		background-position: 100%;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-bottom-m {
+		background-position: bottom;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-left-m {
+		background-position: 0;
+		background-repeat: no-repeat;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .bg-center-l {
+		background-position: 50%;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-top-l {
+		background-position: top;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-right-l {
+		background-position: 100%;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-bottom-l {
+		background-position: bottom;
+		background-repeat: no-repeat;
+	}
+	.swagger-ui .bg-left-l {
+		background-position: 0;
+		background-repeat: no-repeat;
+	}
+}
+.swagger-ui .outline {
+	outline: 1px solid;
+}
+.swagger-ui .outline-transparent {
+	outline: 1px solid transparent;
+}
+.swagger-ui .outline-0 {
+	outline: 0;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .outline-ns {
+		outline: 1px solid;
+	}
+	.swagger-ui .outline-transparent-ns {
+		outline: 1px solid transparent;
+	}
+	.swagger-ui .outline-0-ns {
+		outline: 0;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .outline-m {
+		outline: 1px solid;
+	}
+	.swagger-ui .outline-transparent-m {
+		outline: 1px solid transparent;
+	}
+	.swagger-ui .outline-0-m {
+		outline: 0;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .outline-l {
+		outline: 1px solid;
+	}
+	.swagger-ui .outline-transparent-l {
+		outline: 1px solid transparent;
+	}
+	.swagger-ui .outline-0-l {
+		outline: 0;
+	}
+}
+.swagger-ui .ba {
+	border-style: solid;
+	border-width: 1px;
+}
+.swagger-ui .bt {
+	border-top-style: solid;
+	border-top-width: 1px;
+}
+.swagger-ui .br {
+	border-right-style: solid;
+	border-right-width: 1px;
+}
+.swagger-ui .bb {
+	border-bottom-style: solid;
+	border-bottom-width: 1px;
+}
+.swagger-ui .bl {
+	border-left-style: solid;
+	border-left-width: 1px;
+}
+.swagger-ui .bn {
+	border-style: none;
+	border-width: 0;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .ba-ns {
+		border-style: solid;
+		border-width: 1px;
+	}
+	.swagger-ui .bt-ns {
+		border-top-style: solid;
+		border-top-width: 1px;
+	}
+	.swagger-ui .br-ns {
+		border-right-style: solid;
+		border-right-width: 1px;
+	}
+	.swagger-ui .bb-ns {
+		border-bottom-style: solid;
+		border-bottom-width: 1px;
+	}
+	.swagger-ui .bl-ns {
+		border-left-style: solid;
+		border-left-width: 1px;
+	}
+	.swagger-ui .bn-ns {
+		border-style: none;
+		border-width: 0;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .ba-m {
+		border-style: solid;
+		border-width: 1px;
+	}
+	.swagger-ui .bt-m {
+		border-top-style: solid;
+		border-top-width: 1px;
+	}
+	.swagger-ui .br-m {
+		border-right-style: solid;
+		border-right-width: 1px;
+	}
+	.swagger-ui .bb-m {
+		border-bottom-style: solid;
+		border-bottom-width: 1px;
+	}
+	.swagger-ui .bl-m {
+		border-left-style: solid;
+		border-left-width: 1px;
+	}
+	.swagger-ui .bn-m {
+		border-style: none;
+		border-width: 0;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .ba-l {
+		border-style: solid;
+		border-width: 1px;
+	}
+	.swagger-ui .bt-l {
+		border-top-style: solid;
+		border-top-width: 1px;
+	}
+	.swagger-ui .br-l {
+		border-right-style: solid;
+		border-right-width: 1px;
+	}
+	.swagger-ui .bb-l {
+		border-bottom-style: solid;
+		border-bottom-width: 1px;
+	}
+	.swagger-ui .bl-l {
+		border-left-style: solid;
+		border-left-width: 1px;
+	}
+	.swagger-ui .bn-l {
+		border-style: none;
+		border-width: 0;
+	}
+}
+.swagger-ui .b--black {
+	border-color: #000;
+}
+.swagger-ui .b--near-black {
+	border-color: #111;
+}
+.swagger-ui .b--dark-gray {
+	border-color: #333;
+}
+.swagger-ui .b--mid-gray {
+	border-color: #555;
+}
+.swagger-ui .b--gray {
+	border-color: #777;
+}
+.swagger-ui .b--silver {
+	border-color: #999;
+}
+.swagger-ui .b--light-silver {
+	border-color: #aaa;
+}
+.swagger-ui .b--moon-gray {
+	border-color: #ccc;
+}
+.swagger-ui .b--light-gray {
+	border-color: #eee;
+}
+.swagger-ui .b--near-white {
+	border-color: #f4f4f4;
+}
+.swagger-ui .b--white {
+	border-color: #fff;
+}
+.swagger-ui .b--white-90 {
+	border-color: hsla(0, 0%, 100%, 0.9);
+}
+.swagger-ui .b--white-80 {
+	border-color: hsla(0, 0%, 100%, 0.8);
+}
+.swagger-ui .b--white-70 {
+	border-color: hsla(0, 0%, 100%, 0.7);
+}
+.swagger-ui .b--white-60 {
+	border-color: hsla(0, 0%, 100%, 0.6);
+}
+.swagger-ui .b--white-50 {
+	border-color: hsla(0, 0%, 100%, 0.5);
+}
+.swagger-ui .b--white-40 {
+	border-color: hsla(0, 0%, 100%, 0.4);
+}
+.swagger-ui .b--white-30 {
+	border-color: hsla(0, 0%, 100%, 0.3);
+}
+.swagger-ui .b--white-20 {
+	border-color: hsla(0, 0%, 100%, 0.2);
+}
+.swagger-ui .b--white-10 {
+	border-color: hsla(0, 0%, 100%, 0.1);
+}
+.swagger-ui .b--white-05 {
+	border-color: hsla(0, 0%, 100%, 0.05);
+}
+.swagger-ui .b--white-025 {
+	border-color: hsla(0, 0%, 100%, 0.025);
+}
+.swagger-ui .b--white-0125 {
+	border-color: hsla(0, 0%, 100%, 0.013);
+}
+.swagger-ui .b--black-90 {
+	border-color: rgba(0, 0, 0, 0.9);
+}
+.swagger-ui .b--black-80 {
+	border-color: rgba(0, 0, 0, 0.8);
+}
+.swagger-ui .b--black-70 {
+	border-color: rgba(0, 0, 0, 0.7);
+}
+.swagger-ui .b--black-60 {
+	border-color: rgba(0, 0, 0, 0.6);
+}
+.swagger-ui .b--black-50 {
+	border-color: rgba(0, 0, 0, 0.5);
+}
+.swagger-ui .b--black-40 {
+	border-color: rgba(0, 0, 0, 0.4);
+}
+.swagger-ui .b--black-30 {
+	border-color: rgba(0, 0, 0, 0.3);
+}
+.swagger-ui .b--black-20 {
+	border-color: rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .b--black-10 {
+	border-color: rgba(0, 0, 0, 0.1);
+}
+.swagger-ui .b--black-05 {
+	border-color: rgba(0, 0, 0, 0.05);
+}
+.swagger-ui .b--black-025 {
+	border-color: rgba(0, 0, 0, 0.025);
+}
+.swagger-ui .b--black-0125 {
+	border-color: rgba(0, 0, 0, 0.013);
+}
+.swagger-ui .b--dark-red {
+	border-color: #e7040f;
+}
+.swagger-ui .b--red {
+	border-color: #ff4136;
+}
+.swagger-ui .b--light-red {
+	border-color: #ff725c;
+}
+.swagger-ui .b--orange {
+	border-color: #ff6300;
+}
+.swagger-ui .b--gold {
+	border-color: #ffb700;
+}
+.swagger-ui .b--yellow {
+	border-color: gold;
+}
+.swagger-ui .b--light-yellow {
+	border-color: #fbf1a9;
+}
+.swagger-ui .b--purple {
+	border-color: #5e2ca5;
+}
+.swagger-ui .b--light-purple {
+	border-color: #a463f2;
+}
+.swagger-ui .b--dark-pink {
+	border-color: #d5008f;
+}
+.swagger-ui .b--hot-pink {
+	border-color: #ff41b4;
+}
+.swagger-ui .b--pink {
+	border-color: #ff80cc;
+}
+.swagger-ui .b--light-pink {
+	border-color: #ffa3d7;
+}
+.swagger-ui .b--dark-green {
+	border-color: #137752;
+}
+.swagger-ui .b--green {
+	border-color: #19a974;
+}
+.swagger-ui .b--light-green {
+	border-color: #9eebcf;
+}
+.swagger-ui .b--navy {
+	border-color: #001b44;
+}
+.swagger-ui .b--dark-blue {
+	border-color: #00449e;
+}
+.swagger-ui .b--blue {
+	border-color: #357edd;
+}
+.swagger-ui .b--light-blue {
+	border-color: #96ccff;
+}
+.swagger-ui .b--lightest-blue {
+	border-color: #cdecff;
+}
+.swagger-ui .b--washed-blue {
+	border-color: #f6fffe;
+}
+.swagger-ui .b--washed-green {
+	border-color: #e8fdf5;
+}
+.swagger-ui .b--washed-yellow {
+	border-color: #fffceb;
+}
+.swagger-ui .b--washed-red {
+	border-color: #ffdfdf;
+}
+.swagger-ui .b--transparent {
+	border-color: transparent;
+}
+.swagger-ui .b--inherit {
+	border-color: inherit;
+}
+.swagger-ui .br0 {
+	border-radius: 0;
+}
+.swagger-ui .br1 {
+	border-radius: 0.125rem;
+}
+.swagger-ui .br2 {
+	border-radius: 0.25rem;
+}
+.swagger-ui .br3 {
+	border-radius: 0.5rem;
+}
+.swagger-ui .br4 {
+	border-radius: 1rem;
+}
+.swagger-ui .br-100 {
+	border-radius: 100%;
+}
+.swagger-ui .br-pill {
+	border-radius: 9999px;
+}
+.swagger-ui .br--bottom {
+	border-top-left-radius: 0;
+	border-top-right-radius: 0;
+}
+.swagger-ui .br--top {
+	border-bottom-left-radius: 0;
+	border-bottom-right-radius: 0;
+}
+.swagger-ui .br--right {
+	border-bottom-left-radius: 0;
+	border-top-left-radius: 0;
+}
+.swagger-ui .br--left {
+	border-bottom-right-radius: 0;
+	border-top-right-radius: 0;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .br0-ns {
+		border-radius: 0;
+	}
+	.swagger-ui .br1-ns {
+		border-radius: 0.125rem;
+	}
+	.swagger-ui .br2-ns {
+		border-radius: 0.25rem;
+	}
+	.swagger-ui .br3-ns {
+		border-radius: 0.5rem;
+	}
+	.swagger-ui .br4-ns {
+		border-radius: 1rem;
+	}
+	.swagger-ui .br-100-ns {
+		border-radius: 100%;
+	}
+	.swagger-ui .br-pill-ns {
+		border-radius: 9999px;
+	}
+	.swagger-ui .br--bottom-ns {
+		border-top-left-radius: 0;
+		border-top-right-radius: 0;
+	}
+	.swagger-ui .br--top-ns {
+		border-bottom-left-radius: 0;
+		border-bottom-right-radius: 0;
+	}
+	.swagger-ui .br--right-ns {
+		border-bottom-left-radius: 0;
+		border-top-left-radius: 0;
+	}
+	.swagger-ui .br--left-ns {
+		border-bottom-right-radius: 0;
+		border-top-right-radius: 0;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .br0-m {
+		border-radius: 0;
+	}
+	.swagger-ui .br1-m {
+		border-radius: 0.125rem;
+	}
+	.swagger-ui .br2-m {
+		border-radius: 0.25rem;
+	}
+	.swagger-ui .br3-m {
+		border-radius: 0.5rem;
+	}
+	.swagger-ui .br4-m {
+		border-radius: 1rem;
+	}
+	.swagger-ui .br-100-m {
+		border-radius: 100%;
+	}
+	.swagger-ui .br-pill-m {
+		border-radius: 9999px;
+	}
+	.swagger-ui .br--bottom-m {
+		border-top-left-radius: 0;
+		border-top-right-radius: 0;
+	}
+	.swagger-ui .br--top-m {
+		border-bottom-left-radius: 0;
+		border-bottom-right-radius: 0;
+	}
+	.swagger-ui .br--right-m {
+		border-bottom-left-radius: 0;
+		border-top-left-radius: 0;
+	}
+	.swagger-ui .br--left-m {
+		border-bottom-right-radius: 0;
+		border-top-right-radius: 0;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .br0-l {
+		border-radius: 0;
+	}
+	.swagger-ui .br1-l {
+		border-radius: 0.125rem;
+	}
+	.swagger-ui .br2-l {
+		border-radius: 0.25rem;
+	}
+	.swagger-ui .br3-l {
+		border-radius: 0.5rem;
+	}
+	.swagger-ui .br4-l {
+		border-radius: 1rem;
+	}
+	.swagger-ui .br-100-l {
+		border-radius: 100%;
+	}
+	.swagger-ui .br-pill-l {
+		border-radius: 9999px;
+	}
+	.swagger-ui .br--bottom-l {
+		border-top-left-radius: 0;
+		border-top-right-radius: 0;
+	}
+	.swagger-ui .br--top-l {
+		border-bottom-left-radius: 0;
+		border-bottom-right-radius: 0;
+	}
+	.swagger-ui .br--right-l {
+		border-bottom-left-radius: 0;
+		border-top-left-radius: 0;
+	}
+	.swagger-ui .br--left-l {
+		border-bottom-right-radius: 0;
+		border-top-right-radius: 0;
+	}
+}
+.swagger-ui .b--dotted {
+	border-style: dotted;
+}
+.swagger-ui .b--dashed {
+	border-style: dashed;
+}
+.swagger-ui .b--solid {
+	border-style: solid;
+}
+.swagger-ui .b--none {
+	border-style: none;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .b--dotted-ns {
+		border-style: dotted;
+	}
+	.swagger-ui .b--dashed-ns {
+		border-style: dashed;
+	}
+	.swagger-ui .b--solid-ns {
+		border-style: solid;
+	}
+	.swagger-ui .b--none-ns {
+		border-style: none;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .b--dotted-m {
+		border-style: dotted;
+	}
+	.swagger-ui .b--dashed-m {
+		border-style: dashed;
+	}
+	.swagger-ui .b--solid-m {
+		border-style: solid;
+	}
+	.swagger-ui .b--none-m {
+		border-style: none;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .b--dotted-l {
+		border-style: dotted;
+	}
+	.swagger-ui .b--dashed-l {
+		border-style: dashed;
+	}
+	.swagger-ui .b--solid-l {
+		border-style: solid;
+	}
+	.swagger-ui .b--none-l {
+		border-style: none;
+	}
+}
+.swagger-ui .bw0 {
+	border-width: 0;
+}
+.swagger-ui .bw1 {
+	border-width: 0.125rem;
+}
+.swagger-ui .bw2 {
+	border-width: 0.25rem;
+}
+.swagger-ui .bw3 {
+	border-width: 0.5rem;
+}
+.swagger-ui .bw4 {
+	border-width: 1rem;
+}
+.swagger-ui .bw5 {
+	border-width: 2rem;
+}
+.swagger-ui .bt-0 {
+	border-top-width: 0;
+}
+.swagger-ui .br-0 {
+	border-right-width: 0;
+}
+.swagger-ui .bb-0 {
+	border-bottom-width: 0;
+}
+.swagger-ui .bl-0 {
+	border-left-width: 0;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .bw0-ns {
+		border-width: 0;
+	}
+	.swagger-ui .bw1-ns {
+		border-width: 0.125rem;
+	}
+	.swagger-ui .bw2-ns {
+		border-width: 0.25rem;
+	}
+	.swagger-ui .bw3-ns {
+		border-width: 0.5rem;
+	}
+	.swagger-ui .bw4-ns {
+		border-width: 1rem;
+	}
+	.swagger-ui .bw5-ns {
+		border-width: 2rem;
+	}
+	.swagger-ui .bt-0-ns {
+		border-top-width: 0;
+	}
+	.swagger-ui .br-0-ns {
+		border-right-width: 0;
+	}
+	.swagger-ui .bb-0-ns {
+		border-bottom-width: 0;
+	}
+	.swagger-ui .bl-0-ns {
+		border-left-width: 0;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .bw0-m {
+		border-width: 0;
+	}
+	.swagger-ui .bw1-m {
+		border-width: 0.125rem;
+	}
+	.swagger-ui .bw2-m {
+		border-width: 0.25rem;
+	}
+	.swagger-ui .bw3-m {
+		border-width: 0.5rem;
+	}
+	.swagger-ui .bw4-m {
+		border-width: 1rem;
+	}
+	.swagger-ui .bw5-m {
+		border-width: 2rem;
+	}
+	.swagger-ui .bt-0-m {
+		border-top-width: 0;
+	}
+	.swagger-ui .br-0-m {
+		border-right-width: 0;
+	}
+	.swagger-ui .bb-0-m {
+		border-bottom-width: 0;
+	}
+	.swagger-ui .bl-0-m {
+		border-left-width: 0;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .bw0-l {
+		border-width: 0;
+	}
+	.swagger-ui .bw1-l {
+		border-width: 0.125rem;
+	}
+	.swagger-ui .bw2-l {
+		border-width: 0.25rem;
+	}
+	.swagger-ui .bw3-l {
+		border-width: 0.5rem;
+	}
+	.swagger-ui .bw4-l {
+		border-width: 1rem;
+	}
+	.swagger-ui .bw5-l {
+		border-width: 2rem;
+	}
+	.swagger-ui .bt-0-l {
+		border-top-width: 0;
+	}
+	.swagger-ui .br-0-l {
+		border-right-width: 0;
+	}
+	.swagger-ui .bb-0-l {
+		border-bottom-width: 0;
+	}
+	.swagger-ui .bl-0-l {
+		border-left-width: 0;
+	}
+}
+.swagger-ui .shadow-1 {
+	box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .shadow-2 {
+	box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .shadow-3 {
+	box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .shadow-4 {
+	box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .shadow-5 {
+	box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2);
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .shadow-1-ns {
+		box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-2-ns {
+		box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-3-ns {
+		box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-4-ns {
+		box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-5-ns {
+		box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2);
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .shadow-1-m {
+		box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-2-m {
+		box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-3-m {
+		box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-4-m {
+		box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-5-m {
+		box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2);
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .shadow-1-l {
+		box-shadow: 0 0 4px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-2-l {
+		box-shadow: 0 0 8px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-3-l {
+		box-shadow: 2px 2px 4px 2px rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-4-l {
+		box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.2);
+	}
+	.swagger-ui .shadow-5-l {
+		box-shadow: 4px 4px 8px 0 rgba(0, 0, 0, 0.2);
+	}
+}
+.swagger-ui .pre {
+	overflow-x: auto;
+	overflow-y: hidden;
+	overflow: scroll;
+}
+.swagger-ui .top-0 {
+	top: 0;
+}
+.swagger-ui .right-0 {
+	right: 0;
+}
+.swagger-ui .bottom-0 {
+	bottom: 0;
+}
+.swagger-ui .left-0 {
+	left: 0;
+}
+.swagger-ui .top-1 {
+	top: 1rem;
+}
+.swagger-ui .right-1 {
+	right: 1rem;
+}
+.swagger-ui .bottom-1 {
+	bottom: 1rem;
+}
+.swagger-ui .left-1 {
+	left: 1rem;
+}
+.swagger-ui .top-2 {
+	top: 2rem;
+}
+.swagger-ui .right-2 {
+	right: 2rem;
+}
+.swagger-ui .bottom-2 {
+	bottom: 2rem;
+}
+.swagger-ui .left-2 {
+	left: 2rem;
+}
+.swagger-ui .top--1 {
+	top: -1rem;
+}
+.swagger-ui .right--1 {
+	right: -1rem;
+}
+.swagger-ui .bottom--1 {
+	bottom: -1rem;
+}
+.swagger-ui .left--1 {
+	left: -1rem;
+}
+.swagger-ui .top--2 {
+	top: -2rem;
+}
+.swagger-ui .right--2 {
+	right: -2rem;
+}
+.swagger-ui .bottom--2 {
+	bottom: -2rem;
+}
+.swagger-ui .left--2 {
+	left: -2rem;
+}
+.swagger-ui .absolute--fill {
+	bottom: 0;
+	left: 0;
+	right: 0;
+	top: 0;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .top-0-ns {
+		top: 0;
+	}
+	.swagger-ui .left-0-ns {
+		left: 0;
+	}
+	.swagger-ui .right-0-ns {
+		right: 0;
+	}
+	.swagger-ui .bottom-0-ns {
+		bottom: 0;
+	}
+	.swagger-ui .top-1-ns {
+		top: 1rem;
+	}
+	.swagger-ui .left-1-ns {
+		left: 1rem;
+	}
+	.swagger-ui .right-1-ns {
+		right: 1rem;
+	}
+	.swagger-ui .bottom-1-ns {
+		bottom: 1rem;
+	}
+	.swagger-ui .top-2-ns {
+		top: 2rem;
+	}
+	.swagger-ui .left-2-ns {
+		left: 2rem;
+	}
+	.swagger-ui .right-2-ns {
+		right: 2rem;
+	}
+	.swagger-ui .bottom-2-ns {
+		bottom: 2rem;
+	}
+	.swagger-ui .top--1-ns {
+		top: -1rem;
+	}
+	.swagger-ui .right--1-ns {
+		right: -1rem;
+	}
+	.swagger-ui .bottom--1-ns {
+		bottom: -1rem;
+	}
+	.swagger-ui .left--1-ns {
+		left: -1rem;
+	}
+	.swagger-ui .top--2-ns {
+		top: -2rem;
+	}
+	.swagger-ui .right--2-ns {
+		right: -2rem;
+	}
+	.swagger-ui .bottom--2-ns {
+		bottom: -2rem;
+	}
+	.swagger-ui .left--2-ns {
+		left: -2rem;
+	}
+	.swagger-ui .absolute--fill-ns {
+		bottom: 0;
+		left: 0;
+		right: 0;
+		top: 0;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .top-0-m {
+		top: 0;
+	}
+	.swagger-ui .left-0-m {
+		left: 0;
+	}
+	.swagger-ui .right-0-m {
+		right: 0;
+	}
+	.swagger-ui .bottom-0-m {
+		bottom: 0;
+	}
+	.swagger-ui .top-1-m {
+		top: 1rem;
+	}
+	.swagger-ui .left-1-m {
+		left: 1rem;
+	}
+	.swagger-ui .right-1-m {
+		right: 1rem;
+	}
+	.swagger-ui .bottom-1-m {
+		bottom: 1rem;
+	}
+	.swagger-ui .top-2-m {
+		top: 2rem;
+	}
+	.swagger-ui .left-2-m {
+		left: 2rem;
+	}
+	.swagger-ui .right-2-m {
+		right: 2rem;
+	}
+	.swagger-ui .bottom-2-m {
+		bottom: 2rem;
+	}
+	.swagger-ui .top--1-m {
+		top: -1rem;
+	}
+	.swagger-ui .right--1-m {
+		right: -1rem;
+	}
+	.swagger-ui .bottom--1-m {
+		bottom: -1rem;
+	}
+	.swagger-ui .left--1-m {
+		left: -1rem;
+	}
+	.swagger-ui .top--2-m {
+		top: -2rem;
+	}
+	.swagger-ui .right--2-m {
+		right: -2rem;
+	}
+	.swagger-ui .bottom--2-m {
+		bottom: -2rem;
+	}
+	.swagger-ui .left--2-m {
+		left: -2rem;
+	}
+	.swagger-ui .absolute--fill-m {
+		bottom: 0;
+		left: 0;
+		right: 0;
+		top: 0;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .top-0-l {
+		top: 0;
+	}
+	.swagger-ui .left-0-l {
+		left: 0;
+	}
+	.swagger-ui .right-0-l {
+		right: 0;
+	}
+	.swagger-ui .bottom-0-l {
+		bottom: 0;
+	}
+	.swagger-ui .top-1-l {
+		top: 1rem;
+	}
+	.swagger-ui .left-1-l {
+		left: 1rem;
+	}
+	.swagger-ui .right-1-l {
+		right: 1rem;
+	}
+	.swagger-ui .bottom-1-l {
+		bottom: 1rem;
+	}
+	.swagger-ui .top-2-l {
+		top: 2rem;
+	}
+	.swagger-ui .left-2-l {
+		left: 2rem;
+	}
+	.swagger-ui .right-2-l {
+		right: 2rem;
+	}
+	.swagger-ui .bottom-2-l {
+		bottom: 2rem;
+	}
+	.swagger-ui .top--1-l {
+		top: -1rem;
+	}
+	.swagger-ui .right--1-l {
+		right: -1rem;
+	}
+	.swagger-ui .bottom--1-l {
+		bottom: -1rem;
+	}
+	.swagger-ui .left--1-l {
+		left: -1rem;
+	}
+	.swagger-ui .top--2-l {
+		top: -2rem;
+	}
+	.swagger-ui .right--2-l {
+		right: -2rem;
+	}
+	.swagger-ui .bottom--2-l {
+		bottom: -2rem;
+	}
+	.swagger-ui .left--2-l {
+		left: -2rem;
+	}
+	.swagger-ui .absolute--fill-l {
+		bottom: 0;
+		left: 0;
+		right: 0;
+		top: 0;
+	}
+}
+.swagger-ui .cf:after,
+.swagger-ui .cf:before {
+	content: ' ';
+	display: table;
+}
+.swagger-ui .cf:after {
+	clear: both;
+}
+.swagger-ui .cf {
+	zoom: 1;
+}
+.swagger-ui .cl {
+	clear: left;
+}
+.swagger-ui .cr {
+	clear: right;
+}
+.swagger-ui .cb {
+	clear: both;
+}
+.swagger-ui .cn {
+	clear: none;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .cl-ns {
+		clear: left;
+	}
+	.swagger-ui .cr-ns {
+		clear: right;
+	}
+	.swagger-ui .cb-ns {
+		clear: both;
+	}
+	.swagger-ui .cn-ns {
+		clear: none;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .cl-m {
+		clear: left;
+	}
+	.swagger-ui .cr-m {
+		clear: right;
+	}
+	.swagger-ui .cb-m {
+		clear: both;
+	}
+	.swagger-ui .cn-m {
+		clear: none;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .cl-l {
+		clear: left;
+	}
+	.swagger-ui .cr-l {
+		clear: right;
+	}
+	.swagger-ui .cb-l {
+		clear: both;
+	}
+	.swagger-ui .cn-l {
+		clear: none;
+	}
+}
+.swagger-ui .flex {
+	display: flex;
+}
+.swagger-ui .inline-flex {
+	display: inline-flex;
+}
+.swagger-ui .flex-auto {
+	flex: 1 1 auto;
+	min-height: 0;
+	min-width: 0;
+}
+.swagger-ui .flex-none {
+	flex: none;
+}
+.swagger-ui .flex-column {
+	flex-direction: column;
+}
+.swagger-ui .flex-row {
+	flex-direction: row;
+}
+.swagger-ui .flex-wrap {
+	flex-wrap: wrap;
+}
+.swagger-ui .flex-nowrap {
+	flex-wrap: nowrap;
+}
+.swagger-ui .flex-wrap-reverse {
+	flex-wrap: wrap-reverse;
+}
+.swagger-ui .flex-column-reverse {
+	flex-direction: column-reverse;
+}
+.swagger-ui .flex-row-reverse {
+	flex-direction: row-reverse;
+}
+.swagger-ui .items-start {
+	align-items: flex-start;
+}
+.swagger-ui .items-end {
+	align-items: flex-end;
+}
+.swagger-ui .items-center {
+	align-items: center;
+}
+.swagger-ui .items-baseline {
+	align-items: baseline;
+}
+.swagger-ui .items-stretch {
+	align-items: stretch;
+}
+.swagger-ui .self-start {
+	align-self: flex-start;
+}
+.swagger-ui .self-end {
+	align-self: flex-end;
+}
+.swagger-ui .self-center {
+	align-self: center;
+}
+.swagger-ui .self-baseline {
+	align-self: baseline;
+}
+.swagger-ui .self-stretch {
+	align-self: stretch;
+}
+.swagger-ui .justify-start {
+	justify-content: flex-start;
+}
+.swagger-ui .justify-end {
+	justify-content: flex-end;
+}
+.swagger-ui .justify-center {
+	justify-content: center;
+}
+.swagger-ui .justify-between {
+	justify-content: space-between;
+}
+.swagger-ui .justify-around {
+	justify-content: space-around;
+}
+.swagger-ui .content-start {
+	align-content: flex-start;
+}
+.swagger-ui .content-end {
+	align-content: flex-end;
+}
+.swagger-ui .content-center {
+	align-content: center;
+}
+.swagger-ui .content-between {
+	align-content: space-between;
+}
+.swagger-ui .content-around {
+	align-content: space-around;
+}
+.swagger-ui .content-stretch {
+	align-content: stretch;
+}
+.swagger-ui .order-0 {
+	order: 0;
+}
+.swagger-ui .order-1 {
+	order: 1;
+}
+.swagger-ui .order-2 {
+	order: 2;
+}
+.swagger-ui .order-3 {
+	order: 3;
+}
+.swagger-ui .order-4 {
+	order: 4;
+}
+.swagger-ui .order-5 {
+	order: 5;
+}
+.swagger-ui .order-6 {
+	order: 6;
+}
+.swagger-ui .order-7 {
+	order: 7;
+}
+.swagger-ui .order-8 {
+	order: 8;
+}
+.swagger-ui .order-last {
+	order: 99999;
+}
+.swagger-ui .flex-grow-0 {
+	flex-grow: 0;
+}
+.swagger-ui .flex-grow-1 {
+	flex-grow: 1;
+}
+.swagger-ui .flex-shrink-0 {
+	flex-shrink: 0;
+}
+.swagger-ui .flex-shrink-1 {
+	flex-shrink: 1;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .flex-ns {
+		display: flex;
+	}
+	.swagger-ui .inline-flex-ns {
+		display: inline-flex;
+	}
+	.swagger-ui .flex-auto-ns {
+		flex: 1 1 auto;
+		min-height: 0;
+		min-width: 0;
+	}
+	.swagger-ui .flex-none-ns {
+		flex: none;
+	}
+	.swagger-ui .flex-column-ns {
+		flex-direction: column;
+	}
+	.swagger-ui .flex-row-ns {
+		flex-direction: row;
+	}
+	.swagger-ui .flex-wrap-ns {
+		flex-wrap: wrap;
+	}
+	.swagger-ui .flex-nowrap-ns {
+		flex-wrap: nowrap;
+	}
+	.swagger-ui .flex-wrap-reverse-ns {
+		flex-wrap: wrap-reverse;
+	}
+	.swagger-ui .flex-column-reverse-ns {
+		flex-direction: column-reverse;
+	}
+	.swagger-ui .flex-row-reverse-ns {
+		flex-direction: row-reverse;
+	}
+	.swagger-ui .items-start-ns {
+		align-items: flex-start;
+	}
+	.swagger-ui .items-end-ns {
+		align-items: flex-end;
+	}
+	.swagger-ui .items-center-ns {
+		align-items: center;
+	}
+	.swagger-ui .items-baseline-ns {
+		align-items: baseline;
+	}
+	.swagger-ui .items-stretch-ns {
+		align-items: stretch;
+	}
+	.swagger-ui .self-start-ns {
+		align-self: flex-start;
+	}
+	.swagger-ui .self-end-ns {
+		align-self: flex-end;
+	}
+	.swagger-ui .self-center-ns {
+		align-self: center;
+	}
+	.swagger-ui .self-baseline-ns {
+		align-self: baseline;
+	}
+	.swagger-ui .self-stretch-ns {
+		align-self: stretch;
+	}
+	.swagger-ui .justify-start-ns {
+		justify-content: flex-start;
+	}
+	.swagger-ui .justify-end-ns {
+		justify-content: flex-end;
+	}
+	.swagger-ui .justify-center-ns {
+		justify-content: center;
+	}
+	.swagger-ui .justify-between-ns {
+		justify-content: space-between;
+	}
+	.swagger-ui .justify-around-ns {
+		justify-content: space-around;
+	}
+	.swagger-ui .content-start-ns {
+		align-content: flex-start;
+	}
+	.swagger-ui .content-end-ns {
+		align-content: flex-end;
+	}
+	.swagger-ui .content-center-ns {
+		align-content: center;
+	}
+	.swagger-ui .content-between-ns {
+		align-content: space-between;
+	}
+	.swagger-ui .content-around-ns {
+		align-content: space-around;
+	}
+	.swagger-ui .content-stretch-ns {
+		align-content: stretch;
+	}
+	.swagger-ui .order-0-ns {
+		order: 0;
+	}
+	.swagger-ui .order-1-ns {
+		order: 1;
+	}
+	.swagger-ui .order-2-ns {
+		order: 2;
+	}
+	.swagger-ui .order-3-ns {
+		order: 3;
+	}
+	.swagger-ui .order-4-ns {
+		order: 4;
+	}
+	.swagger-ui .order-5-ns {
+		order: 5;
+	}
+	.swagger-ui .order-6-ns {
+		order: 6;
+	}
+	.swagger-ui .order-7-ns {
+		order: 7;
+	}
+	.swagger-ui .order-8-ns {
+		order: 8;
+	}
+	.swagger-ui .order-last-ns {
+		order: 99999;
+	}
+	.swagger-ui .flex-grow-0-ns {
+		flex-grow: 0;
+	}
+	.swagger-ui .flex-grow-1-ns {
+		flex-grow: 1;
+	}
+	.swagger-ui .flex-shrink-0-ns {
+		flex-shrink: 0;
+	}
+	.swagger-ui .flex-shrink-1-ns {
+		flex-shrink: 1;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .flex-m {
+		display: flex;
+	}
+	.swagger-ui .inline-flex-m {
+		display: inline-flex;
+	}
+	.swagger-ui .flex-auto-m {
+		flex: 1 1 auto;
+		min-height: 0;
+		min-width: 0;
+	}
+	.swagger-ui .flex-none-m {
+		flex: none;
+	}
+	.swagger-ui .flex-column-m {
+		flex-direction: column;
+	}
+	.swagger-ui .flex-row-m {
+		flex-direction: row;
+	}
+	.swagger-ui .flex-wrap-m {
+		flex-wrap: wrap;
+	}
+	.swagger-ui .flex-nowrap-m {
+		flex-wrap: nowrap;
+	}
+	.swagger-ui .flex-wrap-reverse-m {
+		flex-wrap: wrap-reverse;
+	}
+	.swagger-ui .flex-column-reverse-m {
+		flex-direction: column-reverse;
+	}
+	.swagger-ui .flex-row-reverse-m {
+		flex-direction: row-reverse;
+	}
+	.swagger-ui .items-start-m {
+		align-items: flex-start;
+	}
+	.swagger-ui .items-end-m {
+		align-items: flex-end;
+	}
+	.swagger-ui .items-center-m {
+		align-items: center;
+	}
+	.swagger-ui .items-baseline-m {
+		align-items: baseline;
+	}
+	.swagger-ui .items-stretch-m {
+		align-items: stretch;
+	}
+	.swagger-ui .self-start-m {
+		align-self: flex-start;
+	}
+	.swagger-ui .self-end-m {
+		align-self: flex-end;
+	}
+	.swagger-ui .self-center-m {
+		align-self: center;
+	}
+	.swagger-ui .self-baseline-m {
+		align-self: baseline;
+	}
+	.swagger-ui .self-stretch-m {
+		align-self: stretch;
+	}
+	.swagger-ui .justify-start-m {
+		justify-content: flex-start;
+	}
+	.swagger-ui .justify-end-m {
+		justify-content: flex-end;
+	}
+	.swagger-ui .justify-center-m {
+		justify-content: center;
+	}
+	.swagger-ui .justify-between-m {
+		justify-content: space-between;
+	}
+	.swagger-ui .justify-around-m {
+		justify-content: space-around;
+	}
+	.swagger-ui .content-start-m {
+		align-content: flex-start;
+	}
+	.swagger-ui .content-end-m {
+		align-content: flex-end;
+	}
+	.swagger-ui .content-center-m {
+		align-content: center;
+	}
+	.swagger-ui .content-between-m {
+		align-content: space-between;
+	}
+	.swagger-ui .content-around-m {
+		align-content: space-around;
+	}
+	.swagger-ui .content-stretch-m {
+		align-content: stretch;
+	}
+	.swagger-ui .order-0-m {
+		order: 0;
+	}
+	.swagger-ui .order-1-m {
+		order: 1;
+	}
+	.swagger-ui .order-2-m {
+		order: 2;
+	}
+	.swagger-ui .order-3-m {
+		order: 3;
+	}
+	.swagger-ui .order-4-m {
+		order: 4;
+	}
+	.swagger-ui .order-5-m {
+		order: 5;
+	}
+	.swagger-ui .order-6-m {
+		order: 6;
+	}
+	.swagger-ui .order-7-m {
+		order: 7;
+	}
+	.swagger-ui .order-8-m {
+		order: 8;
+	}
+	.swagger-ui .order-last-m {
+		order: 99999;
+	}
+	.swagger-ui .flex-grow-0-m {
+		flex-grow: 0;
+	}
+	.swagger-ui .flex-grow-1-m {
+		flex-grow: 1;
+	}
+	.swagger-ui .flex-shrink-0-m {
+		flex-shrink: 0;
+	}
+	.swagger-ui .flex-shrink-1-m {
+		flex-shrink: 1;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .flex-l {
+		display: flex;
+	}
+	.swagger-ui .inline-flex-l {
+		display: inline-flex;
+	}
+	.swagger-ui .flex-auto-l {
+		flex: 1 1 auto;
+		min-height: 0;
+		min-width: 0;
+	}
+	.swagger-ui .flex-none-l {
+		flex: none;
+	}
+	.swagger-ui .flex-column-l {
+		flex-direction: column;
+	}
+	.swagger-ui .flex-row-l {
+		flex-direction: row;
+	}
+	.swagger-ui .flex-wrap-l {
+		flex-wrap: wrap;
+	}
+	.swagger-ui .flex-nowrap-l {
+		flex-wrap: nowrap;
+	}
+	.swagger-ui .flex-wrap-reverse-l {
+		flex-wrap: wrap-reverse;
+	}
+	.swagger-ui .flex-column-reverse-l {
+		flex-direction: column-reverse;
+	}
+	.swagger-ui .flex-row-reverse-l {
+		flex-direction: row-reverse;
+	}
+	.swagger-ui .items-start-l {
+		align-items: flex-start;
+	}
+	.swagger-ui .items-end-l {
+		align-items: flex-end;
+	}
+	.swagger-ui .items-center-l {
+		align-items: center;
+	}
+	.swagger-ui .items-baseline-l {
+		align-items: baseline;
+	}
+	.swagger-ui .items-stretch-l {
+		align-items: stretch;
+	}
+	.swagger-ui .self-start-l {
+		align-self: flex-start;
+	}
+	.swagger-ui .self-end-l {
+		align-self: flex-end;
+	}
+	.swagger-ui .self-center-l {
+		align-self: center;
+	}
+	.swagger-ui .self-baseline-l {
+		align-self: baseline;
+	}
+	.swagger-ui .self-stretch-l {
+		align-self: stretch;
+	}
+	.swagger-ui .justify-start-l {
+		justify-content: flex-start;
+	}
+	.swagger-ui .justify-end-l {
+		justify-content: flex-end;
+	}
+	.swagger-ui .justify-center-l {
+		justify-content: center;
+	}
+	.swagger-ui .justify-between-l {
+		justify-content: space-between;
+	}
+	.swagger-ui .justify-around-l {
+		justify-content: space-around;
+	}
+	.swagger-ui .content-start-l {
+		align-content: flex-start;
+	}
+	.swagger-ui .content-end-l {
+		align-content: flex-end;
+	}
+	.swagger-ui .content-center-l {
+		align-content: center;
+	}
+	.swagger-ui .content-between-l {
+		align-content: space-between;
+	}
+	.swagger-ui .content-around-l {
+		align-content: space-around;
+	}
+	.swagger-ui .content-stretch-l {
+		align-content: stretch;
+	}
+	.swagger-ui .order-0-l {
+		order: 0;
+	}
+	.swagger-ui .order-1-l {
+		order: 1;
+	}
+	.swagger-ui .order-2-l {
+		order: 2;
+	}
+	.swagger-ui .order-3-l {
+		order: 3;
+	}
+	.swagger-ui .order-4-l {
+		order: 4;
+	}
+	.swagger-ui .order-5-l {
+		order: 5;
+	}
+	.swagger-ui .order-6-l {
+		order: 6;
+	}
+	.swagger-ui .order-7-l {
+		order: 7;
+	}
+	.swagger-ui .order-8-l {
+		order: 8;
+	}
+	.swagger-ui .order-last-l {
+		order: 99999;
+	}
+	.swagger-ui .flex-grow-0-l {
+		flex-grow: 0;
+	}
+	.swagger-ui .flex-grow-1-l {
+		flex-grow: 1;
+	}
+	.swagger-ui .flex-shrink-0-l {
+		flex-shrink: 0;
+	}
+	.swagger-ui .flex-shrink-1-l {
+		flex-shrink: 1;
+	}
+}
+.swagger-ui .dn {
+	display: none;
+}
+.swagger-ui .di {
+	display: inline;
+}
+.swagger-ui .db {
+	display: block;
+}
+.swagger-ui .dib {
+	display: inline-block;
+}
+.swagger-ui .dit {
+	display: inline-table;
+}
+.swagger-ui .dt {
+	display: table;
+}
+.swagger-ui .dtc {
+	display: table-cell;
+}
+.swagger-ui .dt-row {
+	display: table-row;
+}
+.swagger-ui .dt-row-group {
+	display: table-row-group;
+}
+.swagger-ui .dt-column {
+	display: table-column;
+}
+.swagger-ui .dt-column-group {
+	display: table-column-group;
+}
+.swagger-ui .dt--fixed {
+	table-layout: fixed;
+	width: 100%;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .dn-ns {
+		display: none;
+	}
+	.swagger-ui .di-ns {
+		display: inline;
+	}
+	.swagger-ui .db-ns {
+		display: block;
+	}
+	.swagger-ui .dib-ns {
+		display: inline-block;
+	}
+	.swagger-ui .dit-ns {
+		display: inline-table;
+	}
+	.swagger-ui .dt-ns {
+		display: table;
+	}
+	.swagger-ui .dtc-ns {
+		display: table-cell;
+	}
+	.swagger-ui .dt-row-ns {
+		display: table-row;
+	}
+	.swagger-ui .dt-row-group-ns {
+		display: table-row-group;
+	}
+	.swagger-ui .dt-column-ns {
+		display: table-column;
+	}
+	.swagger-ui .dt-column-group-ns {
+		display: table-column-group;
+	}
+	.swagger-ui .dt--fixed-ns {
+		table-layout: fixed;
+		width: 100%;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .dn-m {
+		display: none;
+	}
+	.swagger-ui .di-m {
+		display: inline;
+	}
+	.swagger-ui .db-m {
+		display: block;
+	}
+	.swagger-ui .dib-m {
+		display: inline-block;
+	}
+	.swagger-ui .dit-m {
+		display: inline-table;
+	}
+	.swagger-ui .dt-m {
+		display: table;
+	}
+	.swagger-ui .dtc-m {
+		display: table-cell;
+	}
+	.swagger-ui .dt-row-m {
+		display: table-row;
+	}
+	.swagger-ui .dt-row-group-m {
+		display: table-row-group;
+	}
+	.swagger-ui .dt-column-m {
+		display: table-column;
+	}
+	.swagger-ui .dt-column-group-m {
+		display: table-column-group;
+	}
+	.swagger-ui .dt--fixed-m {
+		table-layout: fixed;
+		width: 100%;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .dn-l {
+		display: none;
+	}
+	.swagger-ui .di-l {
+		display: inline;
+	}
+	.swagger-ui .db-l {
+		display: block;
+	}
+	.swagger-ui .dib-l {
+		display: inline-block;
+	}
+	.swagger-ui .dit-l {
+		display: inline-table;
+	}
+	.swagger-ui .dt-l {
+		display: table;
+	}
+	.swagger-ui .dtc-l {
+		display: table-cell;
+	}
+	.swagger-ui .dt-row-l {
+		display: table-row;
+	}
+	.swagger-ui .dt-row-group-l {
+		display: table-row-group;
+	}
+	.swagger-ui .dt-column-l {
+		display: table-column;
+	}
+	.swagger-ui .dt-column-group-l {
+		display: table-column-group;
+	}
+	.swagger-ui .dt--fixed-l {
+		table-layout: fixed;
+		width: 100%;
+	}
+}
+.swagger-ui .fl {
+	_display: inline;
+	float: left;
+}
+.swagger-ui .fr {
+	_display: inline;
+	float: right;
+}
+.swagger-ui .fn {
+	float: none;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .fl-ns {
+		_display: inline;
+		float: left;
+	}
+	.swagger-ui .fr-ns {
+		_display: inline;
+		float: right;
+	}
+	.swagger-ui .fn-ns {
+		float: none;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .fl-m {
+		_display: inline;
+		float: left;
+	}
+	.swagger-ui .fr-m {
+		_display: inline;
+		float: right;
+	}
+	.swagger-ui .fn-m {
+		float: none;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .fl-l {
+		_display: inline;
+		float: left;
+	}
+	.swagger-ui .fr-l {
+		_display: inline;
+		float: right;
+	}
+	.swagger-ui .fn-l {
+		float: none;
+	}
+}
+.swagger-ui .sans-serif {
+	font-family:
+		-apple-system,
+		BlinkMacSystemFont,
+		avenir next,
+		avenir,
+		helvetica,
+		helvetica neue,
+		ubuntu,
+		roboto,
+		noto,
+		segoe ui,
+		arial,
+		sans-serif;
+}
+.swagger-ui .serif {
+	font-family: georgia, serif;
+}
+.swagger-ui .system-sans-serif {
+	font-family: sans-serif;
+}
+.swagger-ui .system-serif {
+	font-family: serif;
+}
+.swagger-ui .code,
+.swagger-ui code {
+	font-family: Consolas, monaco, monospace;
+}
+.swagger-ui .courier {
+	font-family:
+		Courier Next,
+		courier,
+		monospace;
+}
+.swagger-ui .helvetica {
+	font-family:
+		helvetica neue,
+		helvetica,
+		sans-serif;
+}
+.swagger-ui .avenir {
+	font-family:
+		avenir next,
+		avenir,
+		sans-serif;
+}
+.swagger-ui .athelas {
+	font-family: athelas, georgia, serif;
+}
+.swagger-ui .georgia {
+	font-family: georgia, serif;
+}
+.swagger-ui .times {
+	font-family: times, serif;
+}
+.swagger-ui .bodoni {
+	font-family:
+		Bodoni MT,
+		serif;
+}
+.swagger-ui .calisto {
+	font-family:
+		Calisto MT,
+		serif;
+}
+.swagger-ui .garamond {
+	font-family: garamond, serif;
+}
+.swagger-ui .baskerville {
+	font-family: baskerville, serif;
+}
+.swagger-ui .i {
+	font-style: italic;
+}
+.swagger-ui .fs-normal {
+	font-style: normal;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .i-ns {
+		font-style: italic;
+	}
+	.swagger-ui .fs-normal-ns {
+		font-style: normal;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .i-m {
+		font-style: italic;
+	}
+	.swagger-ui .fs-normal-m {
+		font-style: normal;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .i-l {
+		font-style: italic;
+	}
+	.swagger-ui .fs-normal-l {
+		font-style: normal;
+	}
+}
+.swagger-ui .normal {
+	font-weight: 400;
+}
+.swagger-ui .b {
+	font-weight: 700;
+}
+.swagger-ui .fw1 {
+	font-weight: 100;
+}
+.swagger-ui .fw2 {
+	font-weight: 200;
+}
+.swagger-ui .fw3 {
+	font-weight: 300;
+}
+.swagger-ui .fw4 {
+	font-weight: 400;
+}
+.swagger-ui .fw5 {
+	font-weight: 500;
+}
+.swagger-ui .fw6 {
+	font-weight: 600;
+}
+.swagger-ui .fw7 {
+	font-weight: 700;
+}
+.swagger-ui .fw8 {
+	font-weight: 800;
+}
+.swagger-ui .fw9 {
+	font-weight: 900;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .normal-ns {
+		font-weight: 400;
+	}
+	.swagger-ui .b-ns {
+		font-weight: 700;
+	}
+	.swagger-ui .fw1-ns {
+		font-weight: 100;
+	}
+	.swagger-ui .fw2-ns {
+		font-weight: 200;
+	}
+	.swagger-ui .fw3-ns {
+		font-weight: 300;
+	}
+	.swagger-ui .fw4-ns {
+		font-weight: 400;
+	}
+	.swagger-ui .fw5-ns {
+		font-weight: 500;
+	}
+	.swagger-ui .fw6-ns {
+		font-weight: 600;
+	}
+	.swagger-ui .fw7-ns {
+		font-weight: 700;
+	}
+	.swagger-ui .fw8-ns {
+		font-weight: 800;
+	}
+	.swagger-ui .fw9-ns {
+		font-weight: 900;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .normal-m {
+		font-weight: 400;
+	}
+	.swagger-ui .b-m {
+		font-weight: 700;
+	}
+	.swagger-ui .fw1-m {
+		font-weight: 100;
+	}
+	.swagger-ui .fw2-m {
+		font-weight: 200;
+	}
+	.swagger-ui .fw3-m {
+		font-weight: 300;
+	}
+	.swagger-ui .fw4-m {
+		font-weight: 400;
+	}
+	.swagger-ui .fw5-m {
+		font-weight: 500;
+	}
+	.swagger-ui .fw6-m {
+		font-weight: 600;
+	}
+	.swagger-ui .fw7-m {
+		font-weight: 700;
+	}
+	.swagger-ui .fw8-m {
+		font-weight: 800;
+	}
+	.swagger-ui .fw9-m {
+		font-weight: 900;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .normal-l {
+		font-weight: 400;
+	}
+	.swagger-ui .b-l {
+		font-weight: 700;
+	}
+	.swagger-ui .fw1-l {
+		font-weight: 100;
+	}
+	.swagger-ui .fw2-l {
+		font-weight: 200;
+	}
+	.swagger-ui .fw3-l {
+		font-weight: 300;
+	}
+	.swagger-ui .fw4-l {
+		font-weight: 400;
+	}
+	.swagger-ui .fw5-l {
+		font-weight: 500;
+	}
+	.swagger-ui .fw6-l {
+		font-weight: 600;
+	}
+	.swagger-ui .fw7-l {
+		font-weight: 700;
+	}
+	.swagger-ui .fw8-l {
+		font-weight: 800;
+	}
+	.swagger-ui .fw9-l {
+		font-weight: 900;
+	}
+}
+.swagger-ui .input-reset {
+	-webkit-appearance: none;
+	-moz-appearance: none;
+}
+.swagger-ui .button-reset::-moz-focus-inner,
+.swagger-ui .input-reset::-moz-focus-inner {
+	border: 0;
+	padding: 0;
+}
+.swagger-ui .h1 {
+	height: 1rem;
+}
+.swagger-ui .h2 {
+	height: 2rem;
+}
+.swagger-ui .h3 {
+	height: 4rem;
+}
+.swagger-ui .h4 {
+	height: 8rem;
+}
+.swagger-ui .h5 {
+	height: 16rem;
+}
+.swagger-ui .h-25 {
+	height: 25%;
+}
+.swagger-ui .h-50 {
+	height: 50%;
+}
+.swagger-ui .h-75 {
+	height: 75%;
+}
+.swagger-ui .h-100 {
+	height: 100%;
+}
+.swagger-ui .min-h-100 {
+	min-height: 100%;
+}
+.swagger-ui .vh-25 {
+	height: 25vh;
+}
+.swagger-ui .vh-50 {
+	height: 50vh;
+}
+.swagger-ui .vh-75 {
+	height: 75vh;
+}
+.swagger-ui .vh-100 {
+	height: 100vh;
+}
+.swagger-ui .min-vh-100 {
+	min-height: 100vh;
+}
+.swagger-ui .h-auto {
+	height: auto;
+}
+.swagger-ui .h-inherit {
+	height: inherit;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .h1-ns {
+		height: 1rem;
+	}
+	.swagger-ui .h2-ns {
+		height: 2rem;
+	}
+	.swagger-ui .h3-ns {
+		height: 4rem;
+	}
+	.swagger-ui .h4-ns {
+		height: 8rem;
+	}
+	.swagger-ui .h5-ns {
+		height: 16rem;
+	}
+	.swagger-ui .h-25-ns {
+		height: 25%;
+	}
+	.swagger-ui .h-50-ns {
+		height: 50%;
+	}
+	.swagger-ui .h-75-ns {
+		height: 75%;
+	}
+	.swagger-ui .h-100-ns {
+		height: 100%;
+	}
+	.swagger-ui .min-h-100-ns {
+		min-height: 100%;
+	}
+	.swagger-ui .vh-25-ns {
+		height: 25vh;
+	}
+	.swagger-ui .vh-50-ns {
+		height: 50vh;
+	}
+	.swagger-ui .vh-75-ns {
+		height: 75vh;
+	}
+	.swagger-ui .vh-100-ns {
+		height: 100vh;
+	}
+	.swagger-ui .min-vh-100-ns {
+		min-height: 100vh;
+	}
+	.swagger-ui .h-auto-ns {
+		height: auto;
+	}
+	.swagger-ui .h-inherit-ns {
+		height: inherit;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .h1-m {
+		height: 1rem;
+	}
+	.swagger-ui .h2-m {
+		height: 2rem;
+	}
+	.swagger-ui .h3-m {
+		height: 4rem;
+	}
+	.swagger-ui .h4-m {
+		height: 8rem;
+	}
+	.swagger-ui .h5-m {
+		height: 16rem;
+	}
+	.swagger-ui .h-25-m {
+		height: 25%;
+	}
+	.swagger-ui .h-50-m {
+		height: 50%;
+	}
+	.swagger-ui .h-75-m {
+		height: 75%;
+	}
+	.swagger-ui .h-100-m {
+		height: 100%;
+	}
+	.swagger-ui .min-h-100-m {
+		min-height: 100%;
+	}
+	.swagger-ui .vh-25-m {
+		height: 25vh;
+	}
+	.swagger-ui .vh-50-m {
+		height: 50vh;
+	}
+	.swagger-ui .vh-75-m {
+		height: 75vh;
+	}
+	.swagger-ui .vh-100-m {
+		height: 100vh;
+	}
+	.swagger-ui .min-vh-100-m {
+		min-height: 100vh;
+	}
+	.swagger-ui .h-auto-m {
+		height: auto;
+	}
+	.swagger-ui .h-inherit-m {
+		height: inherit;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .h1-l {
+		height: 1rem;
+	}
+	.swagger-ui .h2-l {
+		height: 2rem;
+	}
+	.swagger-ui .h3-l {
+		height: 4rem;
+	}
+	.swagger-ui .h4-l {
+		height: 8rem;
+	}
+	.swagger-ui .h5-l {
+		height: 16rem;
+	}
+	.swagger-ui .h-25-l {
+		height: 25%;
+	}
+	.swagger-ui .h-50-l {
+		height: 50%;
+	}
+	.swagger-ui .h-75-l {
+		height: 75%;
+	}
+	.swagger-ui .h-100-l {
+		height: 100%;
+	}
+	.swagger-ui .min-h-100-l {
+		min-height: 100%;
+	}
+	.swagger-ui .vh-25-l {
+		height: 25vh;
+	}
+	.swagger-ui .vh-50-l {
+		height: 50vh;
+	}
+	.swagger-ui .vh-75-l {
+		height: 75vh;
+	}
+	.swagger-ui .vh-100-l {
+		height: 100vh;
+	}
+	.swagger-ui .min-vh-100-l {
+		min-height: 100vh;
+	}
+	.swagger-ui .h-auto-l {
+		height: auto;
+	}
+	.swagger-ui .h-inherit-l {
+		height: inherit;
+	}
+}
+.swagger-ui .tracked {
+	letter-spacing: 0.1em;
+}
+.swagger-ui .tracked-tight {
+	letter-spacing: -0.05em;
+}
+.swagger-ui .tracked-mega {
+	letter-spacing: 0.25em;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .tracked-ns {
+		letter-spacing: 0.1em;
+	}
+	.swagger-ui .tracked-tight-ns {
+		letter-spacing: -0.05em;
+	}
+	.swagger-ui .tracked-mega-ns {
+		letter-spacing: 0.25em;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .tracked-m {
+		letter-spacing: 0.1em;
+	}
+	.swagger-ui .tracked-tight-m {
+		letter-spacing: -0.05em;
+	}
+	.swagger-ui .tracked-mega-m {
+		letter-spacing: 0.25em;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .tracked-l {
+		letter-spacing: 0.1em;
+	}
+	.swagger-ui .tracked-tight-l {
+		letter-spacing: -0.05em;
+	}
+	.swagger-ui .tracked-mega-l {
+		letter-spacing: 0.25em;
+	}
+}
+.swagger-ui .lh-solid {
+	line-height: 1;
+}
+.swagger-ui .lh-title {
+	line-height: 1.25;
+}
+.swagger-ui .lh-copy {
+	line-height: 1.5;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .lh-solid-ns {
+		line-height: 1;
+	}
+	.swagger-ui .lh-title-ns {
+		line-height: 1.25;
+	}
+	.swagger-ui .lh-copy-ns {
+		line-height: 1.5;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .lh-solid-m {
+		line-height: 1;
+	}
+	.swagger-ui .lh-title-m {
+		line-height: 1.25;
+	}
+	.swagger-ui .lh-copy-m {
+		line-height: 1.5;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .lh-solid-l {
+		line-height: 1;
+	}
+	.swagger-ui .lh-title-l {
+		line-height: 1.25;
+	}
+	.swagger-ui .lh-copy-l {
+		line-height: 1.5;
+	}
+}
+.swagger-ui .link {
+	-webkit-text-decoration: none;
+	text-decoration: none;
+}
+.swagger-ui .link,
+.swagger-ui .link:active,
+.swagger-ui .link:focus,
+.swagger-ui .link:hover,
+.swagger-ui .link:link,
+.swagger-ui .link:visited {
+	transition: color 0.15s ease-in;
+}
+.swagger-ui .link:focus {
+	outline: 1px dotted currentColor;
+}
+.swagger-ui .list {
+	list-style-type: none;
+}
+.swagger-ui .mw-100 {
+	max-width: 100%;
+}
+.swagger-ui .mw1 {
+	max-width: 1rem;
+}
+.swagger-ui .mw2 {
+	max-width: 2rem;
+}
+.swagger-ui .mw3 {
+	max-width: 4rem;
+}
+.swagger-ui .mw4 {
+	max-width: 8rem;
+}
+.swagger-ui .mw5 {
+	max-width: 16rem;
+}
+.swagger-ui .mw6 {
+	max-width: 32rem;
+}
+.swagger-ui .mw7 {
+	max-width: 48rem;
+}
+.swagger-ui .mw8 {
+	max-width: 64rem;
+}
+.swagger-ui .mw9 {
+	max-width: 96rem;
+}
+.swagger-ui .mw-none {
+	max-width: none;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .mw-100-ns {
+		max-width: 100%;
+	}
+	.swagger-ui .mw1-ns {
+		max-width: 1rem;
+	}
+	.swagger-ui .mw2-ns {
+		max-width: 2rem;
+	}
+	.swagger-ui .mw3-ns {
+		max-width: 4rem;
+	}
+	.swagger-ui .mw4-ns {
+		max-width: 8rem;
+	}
+	.swagger-ui .mw5-ns {
+		max-width: 16rem;
+	}
+	.swagger-ui .mw6-ns {
+		max-width: 32rem;
+	}
+	.swagger-ui .mw7-ns {
+		max-width: 48rem;
+	}
+	.swagger-ui .mw8-ns {
+		max-width: 64rem;
+	}
+	.swagger-ui .mw9-ns {
+		max-width: 96rem;
+	}
+	.swagger-ui .mw-none-ns {
+		max-width: none;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .mw-100-m {
+		max-width: 100%;
+	}
+	.swagger-ui .mw1-m {
+		max-width: 1rem;
+	}
+	.swagger-ui .mw2-m {
+		max-width: 2rem;
+	}
+	.swagger-ui .mw3-m {
+		max-width: 4rem;
+	}
+	.swagger-ui .mw4-m {
+		max-width: 8rem;
+	}
+	.swagger-ui .mw5-m {
+		max-width: 16rem;
+	}
+	.swagger-ui .mw6-m {
+		max-width: 32rem;
+	}
+	.swagger-ui .mw7-m {
+		max-width: 48rem;
+	}
+	.swagger-ui .mw8-m {
+		max-width: 64rem;
+	}
+	.swagger-ui .mw9-m {
+		max-width: 96rem;
+	}
+	.swagger-ui .mw-none-m {
+		max-width: none;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .mw-100-l {
+		max-width: 100%;
+	}
+	.swagger-ui .mw1-l {
+		max-width: 1rem;
+	}
+	.swagger-ui .mw2-l {
+		max-width: 2rem;
+	}
+	.swagger-ui .mw3-l {
+		max-width: 4rem;
+	}
+	.swagger-ui .mw4-l {
+		max-width: 8rem;
+	}
+	.swagger-ui .mw5-l {
+		max-width: 16rem;
+	}
+	.swagger-ui .mw6-l {
+		max-width: 32rem;
+	}
+	.swagger-ui .mw7-l {
+		max-width: 48rem;
+	}
+	.swagger-ui .mw8-l {
+		max-width: 64rem;
+	}
+	.swagger-ui .mw9-l {
+		max-width: 96rem;
+	}
+	.swagger-ui .mw-none-l {
+		max-width: none;
+	}
+}
+.swagger-ui .w1 {
+	width: 1rem;
+}
+.swagger-ui .w2 {
+	width: 2rem;
+}
+.swagger-ui .w3 {
+	width: 4rem;
+}
+.swagger-ui .w4 {
+	width: 8rem;
+}
+.swagger-ui .w5 {
+	width: 16rem;
+}
+.swagger-ui .w-10 {
+	width: 10%;
+}
+.swagger-ui .w-20 {
+	width: 20%;
+}
+.swagger-ui .w-25 {
+	width: 25%;
+}
+.swagger-ui .w-30 {
+	width: 30%;
+}
+.swagger-ui .w-33 {
+	width: 33%;
+}
+.swagger-ui .w-34 {
+	width: 34%;
+}
+.swagger-ui .w-40 {
+	width: 40%;
+}
+.swagger-ui .w-50 {
+	width: 50%;
+}
+.swagger-ui .w-60 {
+	width: 60%;
+}
+.swagger-ui .w-70 {
+	width: 70%;
+}
+.swagger-ui .w-75 {
+	width: 75%;
+}
+.swagger-ui .w-80 {
+	width: 80%;
+}
+.swagger-ui .w-90 {
+	width: 90%;
+}
+.swagger-ui .w-100 {
+	width: 100%;
+}
+.swagger-ui .w-third {
+	width: 33.3333333333%;
+}
+.swagger-ui .w-two-thirds {
+	width: 66.6666666667%;
+}
+.swagger-ui .w-auto {
+	width: auto;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .w1-ns {
+		width: 1rem;
+	}
+	.swagger-ui .w2-ns {
+		width: 2rem;
+	}
+	.swagger-ui .w3-ns {
+		width: 4rem;
+	}
+	.swagger-ui .w4-ns {
+		width: 8rem;
+	}
+	.swagger-ui .w5-ns {
+		width: 16rem;
+	}
+	.swagger-ui .w-10-ns {
+		width: 10%;
+	}
+	.swagger-ui .w-20-ns {
+		width: 20%;
+	}
+	.swagger-ui .w-25-ns {
+		width: 25%;
+	}
+	.swagger-ui .w-30-ns {
+		width: 30%;
+	}
+	.swagger-ui .w-33-ns {
+		width: 33%;
+	}
+	.swagger-ui .w-34-ns {
+		width: 34%;
+	}
+	.swagger-ui .w-40-ns {
+		width: 40%;
+	}
+	.swagger-ui .w-50-ns {
+		width: 50%;
+	}
+	.swagger-ui .w-60-ns {
+		width: 60%;
+	}
+	.swagger-ui .w-70-ns {
+		width: 70%;
+	}
+	.swagger-ui .w-75-ns {
+		width: 75%;
+	}
+	.swagger-ui .w-80-ns {
+		width: 80%;
+	}
+	.swagger-ui .w-90-ns {
+		width: 90%;
+	}
+	.swagger-ui .w-100-ns {
+		width: 100%;
+	}
+	.swagger-ui .w-third-ns {
+		width: 33.3333333333%;
+	}
+	.swagger-ui .w-two-thirds-ns {
+		width: 66.6666666667%;
+	}
+	.swagger-ui .w-auto-ns {
+		width: auto;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .w1-m {
+		width: 1rem;
+	}
+	.swagger-ui .w2-m {
+		width: 2rem;
+	}
+	.swagger-ui .w3-m {
+		width: 4rem;
+	}
+	.swagger-ui .w4-m {
+		width: 8rem;
+	}
+	.swagger-ui .w5-m {
+		width: 16rem;
+	}
+	.swagger-ui .w-10-m {
+		width: 10%;
+	}
+	.swagger-ui .w-20-m {
+		width: 20%;
+	}
+	.swagger-ui .w-25-m {
+		width: 25%;
+	}
+	.swagger-ui .w-30-m {
+		width: 30%;
+	}
+	.swagger-ui .w-33-m {
+		width: 33%;
+	}
+	.swagger-ui .w-34-m {
+		width: 34%;
+	}
+	.swagger-ui .w-40-m {
+		width: 40%;
+	}
+	.swagger-ui .w-50-m {
+		width: 50%;
+	}
+	.swagger-ui .w-60-m {
+		width: 60%;
+	}
+	.swagger-ui .w-70-m {
+		width: 70%;
+	}
+	.swagger-ui .w-75-m {
+		width: 75%;
+	}
+	.swagger-ui .w-80-m {
+		width: 80%;
+	}
+	.swagger-ui .w-90-m {
+		width: 90%;
+	}
+	.swagger-ui .w-100-m {
+		width: 100%;
+	}
+	.swagger-ui .w-third-m {
+		width: 33.3333333333%;
+	}
+	.swagger-ui .w-two-thirds-m {
+		width: 66.6666666667%;
+	}
+	.swagger-ui .w-auto-m {
+		width: auto;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .w1-l {
+		width: 1rem;
+	}
+	.swagger-ui .w2-l {
+		width: 2rem;
+	}
+	.swagger-ui .w3-l {
+		width: 4rem;
+	}
+	.swagger-ui .w4-l {
+		width: 8rem;
+	}
+	.swagger-ui .w5-l {
+		width: 16rem;
+	}
+	.swagger-ui .w-10-l {
+		width: 10%;
+	}
+	.swagger-ui .w-20-l {
+		width: 20%;
+	}
+	.swagger-ui .w-25-l {
+		width: 25%;
+	}
+	.swagger-ui .w-30-l {
+		width: 30%;
+	}
+	.swagger-ui .w-33-l {
+		width: 33%;
+	}
+	.swagger-ui .w-34-l {
+		width: 34%;
+	}
+	.swagger-ui .w-40-l {
+		width: 40%;
+	}
+	.swagger-ui .w-50-l {
+		width: 50%;
+	}
+	.swagger-ui .w-60-l {
+		width: 60%;
+	}
+	.swagger-ui .w-70-l {
+		width: 70%;
+	}
+	.swagger-ui .w-75-l {
+		width: 75%;
+	}
+	.swagger-ui .w-80-l {
+		width: 80%;
+	}
+	.swagger-ui .w-90-l {
+		width: 90%;
+	}
+	.swagger-ui .w-100-l {
+		width: 100%;
+	}
+	.swagger-ui .w-third-l {
+		width: 33.3333333333%;
+	}
+	.swagger-ui .w-two-thirds-l {
+		width: 66.6666666667%;
+	}
+	.swagger-ui .w-auto-l {
+		width: auto;
+	}
+}
+.swagger-ui .overflow-visible {
+	overflow: visible;
+}
+.swagger-ui .overflow-hidden {
+	overflow: hidden;
+}
+.swagger-ui .overflow-scroll {
+	overflow: scroll;
+}
+.swagger-ui .overflow-auto {
+	overflow: auto;
+}
+.swagger-ui .overflow-x-visible {
+	overflow-x: visible;
+}
+.swagger-ui .overflow-x-hidden {
+	overflow-x: hidden;
+}
+.swagger-ui .overflow-x-scroll {
+	overflow-x: scroll;
+}
+.swagger-ui .overflow-x-auto {
+	overflow-x: auto;
+}
+.swagger-ui .overflow-y-visible {
+	overflow-y: visible;
+}
+.swagger-ui .overflow-y-hidden {
+	overflow-y: hidden;
+}
+.swagger-ui .overflow-y-scroll {
+	overflow-y: scroll;
+}
+.swagger-ui .overflow-y-auto {
+	overflow-y: auto;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .overflow-visible-ns {
+		overflow: visible;
+	}
+	.swagger-ui .overflow-hidden-ns {
+		overflow: hidden;
+	}
+	.swagger-ui .overflow-scroll-ns {
+		overflow: scroll;
+	}
+	.swagger-ui .overflow-auto-ns {
+		overflow: auto;
+	}
+	.swagger-ui .overflow-x-visible-ns {
+		overflow-x: visible;
+	}
+	.swagger-ui .overflow-x-hidden-ns {
+		overflow-x: hidden;
+	}
+	.swagger-ui .overflow-x-scroll-ns {
+		overflow-x: scroll;
+	}
+	.swagger-ui .overflow-x-auto-ns {
+		overflow-x: auto;
+	}
+	.swagger-ui .overflow-y-visible-ns {
+		overflow-y: visible;
+	}
+	.swagger-ui .overflow-y-hidden-ns {
+		overflow-y: hidden;
+	}
+	.swagger-ui .overflow-y-scroll-ns {
+		overflow-y: scroll;
+	}
+	.swagger-ui .overflow-y-auto-ns {
+		overflow-y: auto;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .overflow-visible-m {
+		overflow: visible;
+	}
+	.swagger-ui .overflow-hidden-m {
+		overflow: hidden;
+	}
+	.swagger-ui .overflow-scroll-m {
+		overflow: scroll;
+	}
+	.swagger-ui .overflow-auto-m {
+		overflow: auto;
+	}
+	.swagger-ui .overflow-x-visible-m {
+		overflow-x: visible;
+	}
+	.swagger-ui .overflow-x-hidden-m {
+		overflow-x: hidden;
+	}
+	.swagger-ui .overflow-x-scroll-m {
+		overflow-x: scroll;
+	}
+	.swagger-ui .overflow-x-auto-m {
+		overflow-x: auto;
+	}
+	.swagger-ui .overflow-y-visible-m {
+		overflow-y: visible;
+	}
+	.swagger-ui .overflow-y-hidden-m {
+		overflow-y: hidden;
+	}
+	.swagger-ui .overflow-y-scroll-m {
+		overflow-y: scroll;
+	}
+	.swagger-ui .overflow-y-auto-m {
+		overflow-y: auto;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .overflow-visible-l {
+		overflow: visible;
+	}
+	.swagger-ui .overflow-hidden-l {
+		overflow: hidden;
+	}
+	.swagger-ui .overflow-scroll-l {
+		overflow: scroll;
+	}
+	.swagger-ui .overflow-auto-l {
+		overflow: auto;
+	}
+	.swagger-ui .overflow-x-visible-l {
+		overflow-x: visible;
+	}
+	.swagger-ui .overflow-x-hidden-l {
+		overflow-x: hidden;
+	}
+	.swagger-ui .overflow-x-scroll-l {
+		overflow-x: scroll;
+	}
+	.swagger-ui .overflow-x-auto-l {
+		overflow-x: auto;
+	}
+	.swagger-ui .overflow-y-visible-l {
+		overflow-y: visible;
+	}
+	.swagger-ui .overflow-y-hidden-l {
+		overflow-y: hidden;
+	}
+	.swagger-ui .overflow-y-scroll-l {
+		overflow-y: scroll;
+	}
+	.swagger-ui .overflow-y-auto-l {
+		overflow-y: auto;
+	}
+}
+.swagger-ui .static {
+	position: static;
+}
+.swagger-ui .relative {
+	position: relative;
+}
+.swagger-ui .absolute {
+	position: absolute;
+}
+.swagger-ui .fixed {
+	position: fixed;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .static-ns {
+		position: static;
+	}
+	.swagger-ui .relative-ns {
+		position: relative;
+	}
+	.swagger-ui .absolute-ns {
+		position: absolute;
+	}
+	.swagger-ui .fixed-ns {
+		position: fixed;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .static-m {
+		position: static;
+	}
+	.swagger-ui .relative-m {
+		position: relative;
+	}
+	.swagger-ui .absolute-m {
+		position: absolute;
+	}
+	.swagger-ui .fixed-m {
+		position: fixed;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .static-l {
+		position: static;
+	}
+	.swagger-ui .relative-l {
+		position: relative;
+	}
+	.swagger-ui .absolute-l {
+		position: absolute;
+	}
+	.swagger-ui .fixed-l {
+		position: fixed;
+	}
+}
+.swagger-ui .o-100 {
+	opacity: 1;
+}
+.swagger-ui .o-90 {
+	opacity: 0.9;
+}
+.swagger-ui .o-80 {
+	opacity: 0.8;
+}
+.swagger-ui .o-70 {
+	opacity: 0.7;
+}
+.swagger-ui .o-60 {
+	opacity: 0.6;
+}
+.swagger-ui .o-50 {
+	opacity: 0.5;
+}
+.swagger-ui .o-40 {
+	opacity: 0.4;
+}
+.swagger-ui .o-30 {
+	opacity: 0.3;
+}
+.swagger-ui .o-20 {
+	opacity: 0.2;
+}
+.swagger-ui .o-10 {
+	opacity: 0.1;
+}
+.swagger-ui .o-05 {
+	opacity: 0.05;
+}
+.swagger-ui .o-025 {
+	opacity: 0.025;
+}
+.swagger-ui .o-0 {
+	opacity: 0;
+}
+.swagger-ui .rotate-45 {
+	transform: rotate(45deg);
+}
+.swagger-ui .rotate-90 {
+	transform: rotate(90deg);
+}
+.swagger-ui .rotate-135 {
+	transform: rotate(135deg);
+}
+.swagger-ui .rotate-180 {
+	transform: rotate(180deg);
+}
+.swagger-ui .rotate-225 {
+	transform: rotate(225deg);
+}
+.swagger-ui .rotate-270 {
+	transform: rotate(270deg);
+}
+.swagger-ui .rotate-315 {
+	transform: rotate(315deg);
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .rotate-45-ns {
+		transform: rotate(45deg);
+	}
+	.swagger-ui .rotate-90-ns {
+		transform: rotate(90deg);
+	}
+	.swagger-ui .rotate-135-ns {
+		transform: rotate(135deg);
+	}
+	.swagger-ui .rotate-180-ns {
+		transform: rotate(180deg);
+	}
+	.swagger-ui .rotate-225-ns {
+		transform: rotate(225deg);
+	}
+	.swagger-ui .rotate-270-ns {
+		transform: rotate(270deg);
+	}
+	.swagger-ui .rotate-315-ns {
+		transform: rotate(315deg);
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .rotate-45-m {
+		transform: rotate(45deg);
+	}
+	.swagger-ui .rotate-90-m {
+		transform: rotate(90deg);
+	}
+	.swagger-ui .rotate-135-m {
+		transform: rotate(135deg);
+	}
+	.swagger-ui .rotate-180-m {
+		transform: rotate(180deg);
+	}
+	.swagger-ui .rotate-225-m {
+		transform: rotate(225deg);
+	}
+	.swagger-ui .rotate-270-m {
+		transform: rotate(270deg);
+	}
+	.swagger-ui .rotate-315-m {
+		transform: rotate(315deg);
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .rotate-45-l {
+		transform: rotate(45deg);
+	}
+	.swagger-ui .rotate-90-l {
+		transform: rotate(90deg);
+	}
+	.swagger-ui .rotate-135-l {
+		transform: rotate(135deg);
+	}
+	.swagger-ui .rotate-180-l {
+		transform: rotate(180deg);
+	}
+	.swagger-ui .rotate-225-l {
+		transform: rotate(225deg);
+	}
+	.swagger-ui .rotate-270-l {
+		transform: rotate(270deg);
+	}
+	.swagger-ui .rotate-315-l {
+		transform: rotate(315deg);
+	}
+}
+.swagger-ui .black-90 {
+	color: rgba(0, 0, 0, 0.9);
+}
+.swagger-ui .black-80 {
+	color: rgba(0, 0, 0, 0.8);
+}
+.swagger-ui .black-70 {
+	color: rgba(0, 0, 0, 0.7);
+}
+.swagger-ui .black-60 {
+	color: rgba(0, 0, 0, 0.6);
+}
+.swagger-ui .black-50 {
+	color: rgba(0, 0, 0, 0.5);
+}
+.swagger-ui .black-40 {
+	color: rgba(0, 0, 0, 0.4);
+}
+.swagger-ui .black-30 {
+	color: rgba(0, 0, 0, 0.3);
+}
+.swagger-ui .black-20 {
+	color: rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .black-10 {
+	color: rgba(0, 0, 0, 0.1);
+}
+.swagger-ui .black-05 {
+	color: rgba(0, 0, 0, 0.05);
+}
+.swagger-ui .white-90 {
+	color: hsla(0, 0%, 100%, 0.9);
+}
+.swagger-ui .white-80 {
+	color: hsla(0, 0%, 100%, 0.8);
+}
+.swagger-ui .white-70 {
+	color: hsla(0, 0%, 100%, 0.7);
+}
+.swagger-ui .white-60 {
+	color: hsla(0, 0%, 100%, 0.6);
+}
+.swagger-ui .white-50 {
+	color: hsla(0, 0%, 100%, 0.5);
+}
+.swagger-ui .white-40 {
+	color: hsla(0, 0%, 100%, 0.4);
+}
+.swagger-ui .white-30 {
+	color: hsla(0, 0%, 100%, 0.3);
+}
+.swagger-ui .white-20 {
+	color: hsla(0, 0%, 100%, 0.2);
+}
+.swagger-ui .white-10 {
+	color: hsla(0, 0%, 100%, 0.1);
+}
+.swagger-ui .black {
+	color: #000;
+}
+.swagger-ui .near-black {
+	color: #111;
+}
+.swagger-ui .dark-gray {
+	color: #333;
+}
+.swagger-ui .mid-gray {
+	color: #555;
+}
+.swagger-ui .gray {
+	color: #777;
+}
+.swagger-ui .silver {
+	color: #999;
+}
+.swagger-ui .light-silver {
+	color: #aaa;
+}
+.swagger-ui .moon-gray {
+	color: #ccc;
+}
+.swagger-ui .light-gray {
+	color: #eee;
+}
+.swagger-ui .near-white {
+	color: #f4f4f4;
+}
+.swagger-ui .white {
+	color: #fff;
+}
+.swagger-ui .dark-red {
+	color: #e7040f;
+}
+.swagger-ui .red {
+	color: #ff4136;
+}
+.swagger-ui .light-red {
+	color: #ff725c;
+}
+.swagger-ui .orange {
+	color: #ff6300;
+}
+.swagger-ui .gold {
+	color: #ffb700;
+}
+.swagger-ui .yellow {
+	color: gold;
+}
+.swagger-ui .light-yellow {
+	color: #fbf1a9;
+}
+.swagger-ui .purple {
+	color: #5e2ca5;
+}
+.swagger-ui .light-purple {
+	color: #a463f2;
+}
+.swagger-ui .dark-pink {
+	color: #d5008f;
+}
+.swagger-ui .hot-pink {
+	color: #ff41b4;
+}
+.swagger-ui .pink {
+	color: #ff80cc;
+}
+.swagger-ui .light-pink {
+	color: #ffa3d7;
+}
+.swagger-ui .dark-green {
+	color: #137752;
+}
+.swagger-ui .green {
+	color: #19a974;
+}
+.swagger-ui .light-green {
+	color: #9eebcf;
+}
+.swagger-ui .navy {
+	color: #001b44;
+}
+.swagger-ui .dark-blue {
+	color: #00449e;
+}
+.swagger-ui .blue {
+	color: #357edd;
+}
+.swagger-ui .light-blue {
+	color: #96ccff;
+}
+.swagger-ui .lightest-blue {
+	color: #cdecff;
+}
+.swagger-ui .washed-blue {
+	color: #f6fffe;
+}
+.swagger-ui .washed-green {
+	color: #e8fdf5;
+}
+.swagger-ui .washed-yellow {
+	color: #fffceb;
+}
+.swagger-ui .washed-red {
+	color: #ffdfdf;
+}
+.swagger-ui .color-inherit {
+	color: inherit;
+}
+.swagger-ui .bg-black-90 {
+	background-color: rgba(0, 0, 0, 0.9);
+}
+.swagger-ui .bg-black-80 {
+	background-color: rgba(0, 0, 0, 0.8);
+}
+.swagger-ui .bg-black-70 {
+	background-color: rgba(0, 0, 0, 0.7);
+}
+.swagger-ui .bg-black-60 {
+	background-color: rgba(0, 0, 0, 0.6);
+}
+.swagger-ui .bg-black-50 {
+	background-color: rgba(0, 0, 0, 0.5);
+}
+.swagger-ui .bg-black-40 {
+	background-color: rgba(0, 0, 0, 0.4);
+}
+.swagger-ui .bg-black-30 {
+	background-color: rgba(0, 0, 0, 0.3);
+}
+.swagger-ui .bg-black-20 {
+	background-color: rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .bg-black-10 {
+	background-color: rgba(0, 0, 0, 0.1);
+}
+.swagger-ui .bg-black-05 {
+	background-color: rgba(0, 0, 0, 0.05);
+}
+.swagger-ui .bg-white-90 {
+	background-color: hsla(0, 0%, 100%, 0.9);
+}
+.swagger-ui .bg-white-80 {
+	background-color: hsla(0, 0%, 100%, 0.8);
+}
+.swagger-ui .bg-white-70 {
+	background-color: hsla(0, 0%, 100%, 0.7);
+}
+.swagger-ui .bg-white-60 {
+	background-color: hsla(0, 0%, 100%, 0.6);
+}
+.swagger-ui .bg-white-50 {
+	background-color: hsla(0, 0%, 100%, 0.5);
+}
+.swagger-ui .bg-white-40 {
+	background-color: hsla(0, 0%, 100%, 0.4);
+}
+.swagger-ui .bg-white-30 {
+	background-color: hsla(0, 0%, 100%, 0.3);
+}
+.swagger-ui .bg-white-20 {
+	background-color: hsla(0, 0%, 100%, 0.2);
+}
+.swagger-ui .bg-white-10 {
+	background-color: hsla(0, 0%, 100%, 0.1);
+}
+.swagger-ui .bg-black {
+	background-color: #000;
+}
+.swagger-ui .bg-near-black {
+	background-color: #111;
+}
+.swagger-ui .bg-dark-gray {
+	background-color: #333;
+}
+.swagger-ui .bg-mid-gray {
+	background-color: #555;
+}
+.swagger-ui .bg-gray {
+	background-color: #777;
+}
+.swagger-ui .bg-silver {
+	background-color: #999;
+}
+.swagger-ui .bg-light-silver {
+	background-color: #aaa;
+}
+.swagger-ui .bg-moon-gray {
+	background-color: #ccc;
+}
+.swagger-ui .bg-light-gray {
+	background-color: #eee;
+}
+.swagger-ui .bg-near-white {
+	background-color: #f4f4f4;
+}
+.swagger-ui .bg-white {
+	background-color: #fff;
+}
+.swagger-ui .bg-transparent {
+	background-color: transparent;
+}
+.swagger-ui .bg-dark-red {
+	background-color: #e7040f;
+}
+.swagger-ui .bg-red {
+	background-color: #ff4136;
+}
+.swagger-ui .bg-light-red {
+	background-color: #ff725c;
+}
+.swagger-ui .bg-orange {
+	background-color: #ff6300;
+}
+.swagger-ui .bg-gold {
+	background-color: #ffb700;
+}
+.swagger-ui .bg-yellow {
+	background-color: gold;
+}
+.swagger-ui .bg-light-yellow {
+	background-color: #fbf1a9;
+}
+.swagger-ui .bg-purple {
+	background-color: #5e2ca5;
+}
+.swagger-ui .bg-light-purple {
+	background-color: #a463f2;
+}
+.swagger-ui .bg-dark-pink {
+	background-color: #d5008f;
+}
+.swagger-ui .bg-hot-pink {
+	background-color: #ff41b4;
+}
+.swagger-ui .bg-pink {
+	background-color: #ff80cc;
+}
+.swagger-ui .bg-light-pink {
+	background-color: #ffa3d7;
+}
+.swagger-ui .bg-dark-green {
+	background-color: #137752;
+}
+.swagger-ui .bg-green {
+	background-color: #19a974;
+}
+.swagger-ui .bg-light-green {
+	background-color: #9eebcf;
+}
+.swagger-ui .bg-navy {
+	background-color: #001b44;
+}
+.swagger-ui .bg-dark-blue {
+	background-color: #00449e;
+}
+.swagger-ui .bg-blue {
+	background-color: #357edd;
+}
+.swagger-ui .bg-light-blue {
+	background-color: #96ccff;
+}
+.swagger-ui .bg-lightest-blue {
+	background-color: #cdecff;
+}
+.swagger-ui .bg-washed-blue {
+	background-color: #f6fffe;
+}
+.swagger-ui .bg-washed-green {
+	background-color: #e8fdf5;
+}
+.swagger-ui .bg-washed-yellow {
+	background-color: #fffceb;
+}
+.swagger-ui .bg-washed-red {
+	background-color: #ffdfdf;
+}
+.swagger-ui .bg-inherit {
+	background-color: inherit;
+}
+.swagger-ui .hover-black:focus,
+.swagger-ui .hover-black:hover {
+	color: #000;
+}
+.swagger-ui .hover-near-black:focus,
+.swagger-ui .hover-near-black:hover {
+	color: #111;
+}
+.swagger-ui .hover-dark-gray:focus,
+.swagger-ui .hover-dark-gray:hover {
+	color: #333;
+}
+.swagger-ui .hover-mid-gray:focus,
+.swagger-ui .hover-mid-gray:hover {
+	color: #555;
+}
+.swagger-ui .hover-gray:focus,
+.swagger-ui .hover-gray:hover {
+	color: #777;
+}
+.swagger-ui .hover-silver:focus,
+.swagger-ui .hover-silver:hover {
+	color: #999;
+}
+.swagger-ui .hover-light-silver:focus,
+.swagger-ui .hover-light-silver:hover {
+	color: #aaa;
+}
+.swagger-ui .hover-moon-gray:focus,
+.swagger-ui .hover-moon-gray:hover {
+	color: #ccc;
+}
+.swagger-ui .hover-light-gray:focus,
+.swagger-ui .hover-light-gray:hover {
+	color: #eee;
+}
+.swagger-ui .hover-near-white:focus,
+.swagger-ui .hover-near-white:hover {
+	color: #f4f4f4;
+}
+.swagger-ui .hover-white:focus,
+.swagger-ui .hover-white:hover {
+	color: #fff;
+}
+.swagger-ui .hover-black-90:focus,
+.swagger-ui .hover-black-90:hover {
+	color: rgba(0, 0, 0, 0.9);
+}
+.swagger-ui .hover-black-80:focus,
+.swagger-ui .hover-black-80:hover {
+	color: rgba(0, 0, 0, 0.8);
+}
+.swagger-ui .hover-black-70:focus,
+.swagger-ui .hover-black-70:hover {
+	color: rgba(0, 0, 0, 0.7);
+}
+.swagger-ui .hover-black-60:focus,
+.swagger-ui .hover-black-60:hover {
+	color: rgba(0, 0, 0, 0.6);
+}
+.swagger-ui .hover-black-50:focus,
+.swagger-ui .hover-black-50:hover {
+	color: rgba(0, 0, 0, 0.5);
+}
+.swagger-ui .hover-black-40:focus,
+.swagger-ui .hover-black-40:hover {
+	color: rgba(0, 0, 0, 0.4);
+}
+.swagger-ui .hover-black-30:focus,
+.swagger-ui .hover-black-30:hover {
+	color: rgba(0, 0, 0, 0.3);
+}
+.swagger-ui .hover-black-20:focus,
+.swagger-ui .hover-black-20:hover {
+	color: rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .hover-black-10:focus,
+.swagger-ui .hover-black-10:hover {
+	color: rgba(0, 0, 0, 0.1);
+}
+.swagger-ui .hover-white-90:focus,
+.swagger-ui .hover-white-90:hover {
+	color: hsla(0, 0%, 100%, 0.9);
+}
+.swagger-ui .hover-white-80:focus,
+.swagger-ui .hover-white-80:hover {
+	color: hsla(0, 0%, 100%, 0.8);
+}
+.swagger-ui .hover-white-70:focus,
+.swagger-ui .hover-white-70:hover {
+	color: hsla(0, 0%, 100%, 0.7);
+}
+.swagger-ui .hover-white-60:focus,
+.swagger-ui .hover-white-60:hover {
+	color: hsla(0, 0%, 100%, 0.6);
+}
+.swagger-ui .hover-white-50:focus,
+.swagger-ui .hover-white-50:hover {
+	color: hsla(0, 0%, 100%, 0.5);
+}
+.swagger-ui .hover-white-40:focus,
+.swagger-ui .hover-white-40:hover {
+	color: hsla(0, 0%, 100%, 0.4);
+}
+.swagger-ui .hover-white-30:focus,
+.swagger-ui .hover-white-30:hover {
+	color: hsla(0, 0%, 100%, 0.3);
+}
+.swagger-ui .hover-white-20:focus,
+.swagger-ui .hover-white-20:hover {
+	color: hsla(0, 0%, 100%, 0.2);
+}
+.swagger-ui .hover-white-10:focus,
+.swagger-ui .hover-white-10:hover {
+	color: hsla(0, 0%, 100%, 0.1);
+}
+.swagger-ui .hover-inherit:focus,
+.swagger-ui .hover-inherit:hover {
+	color: inherit;
+}
+.swagger-ui .hover-bg-black:focus,
+.swagger-ui .hover-bg-black:hover {
+	background-color: #000;
+}
+.swagger-ui .hover-bg-near-black:focus,
+.swagger-ui .hover-bg-near-black:hover {
+	background-color: #111;
+}
+.swagger-ui .hover-bg-dark-gray:focus,
+.swagger-ui .hover-bg-dark-gray:hover {
+	background-color: #333;
+}
+.swagger-ui .hover-bg-mid-gray:focus,
+.swagger-ui .hover-bg-mid-gray:hover {
+	background-color: #555;
+}
+.swagger-ui .hover-bg-gray:focus,
+.swagger-ui .hover-bg-gray:hover {
+	background-color: #777;
+}
+.swagger-ui .hover-bg-silver:focus,
+.swagger-ui .hover-bg-silver:hover {
+	background-color: #999;
+}
+.swagger-ui .hover-bg-light-silver:focus,
+.swagger-ui .hover-bg-light-silver:hover {
+	background-color: #aaa;
+}
+.swagger-ui .hover-bg-moon-gray:focus,
+.swagger-ui .hover-bg-moon-gray:hover {
+	background-color: #ccc;
+}
+.swagger-ui .hover-bg-light-gray:focus,
+.swagger-ui .hover-bg-light-gray:hover {
+	background-color: #eee;
+}
+.swagger-ui .hover-bg-near-white:focus,
+.swagger-ui .hover-bg-near-white:hover {
+	background-color: #f4f4f4;
+}
+.swagger-ui .hover-bg-white:focus,
+.swagger-ui .hover-bg-white:hover {
+	background-color: #fff;
+}
+.swagger-ui .hover-bg-transparent:focus,
+.swagger-ui .hover-bg-transparent:hover {
+	background-color: transparent;
+}
+.swagger-ui .hover-bg-black-90:focus,
+.swagger-ui .hover-bg-black-90:hover {
+	background-color: rgba(0, 0, 0, 0.9);
+}
+.swagger-ui .hover-bg-black-80:focus,
+.swagger-ui .hover-bg-black-80:hover {
+	background-color: rgba(0, 0, 0, 0.8);
+}
+.swagger-ui .hover-bg-black-70:focus,
+.swagger-ui .hover-bg-black-70:hover {
+	background-color: rgba(0, 0, 0, 0.7);
+}
+.swagger-ui .hover-bg-black-60:focus,
+.swagger-ui .hover-bg-black-60:hover {
+	background-color: rgba(0, 0, 0, 0.6);
+}
+.swagger-ui .hover-bg-black-50:focus,
+.swagger-ui .hover-bg-black-50:hover {
+	background-color: rgba(0, 0, 0, 0.5);
+}
+.swagger-ui .hover-bg-black-40:focus,
+.swagger-ui .hover-bg-black-40:hover {
+	background-color: rgba(0, 0, 0, 0.4);
+}
+.swagger-ui .hover-bg-black-30:focus,
+.swagger-ui .hover-bg-black-30:hover {
+	background-color: rgba(0, 0, 0, 0.3);
+}
+.swagger-ui .hover-bg-black-20:focus,
+.swagger-ui .hover-bg-black-20:hover {
+	background-color: rgba(0, 0, 0, 0.2);
+}
+.swagger-ui .hover-bg-black-10:focus,
+.swagger-ui .hover-bg-black-10:hover {
+	background-color: rgba(0, 0, 0, 0.1);
+}
+.swagger-ui .hover-bg-white-90:focus,
+.swagger-ui .hover-bg-white-90:hover {
+	background-color: hsla(0, 0%, 100%, 0.9);
+}
+.swagger-ui .hover-bg-white-80:focus,
+.swagger-ui .hover-bg-white-80:hover {
+	background-color: hsla(0, 0%, 100%, 0.8);
+}
+.swagger-ui .hover-bg-white-70:focus,
+.swagger-ui .hover-bg-white-70:hover {
+	background-color: hsla(0, 0%, 100%, 0.7);
+}
+.swagger-ui .hover-bg-white-60:focus,
+.swagger-ui .hover-bg-white-60:hover {
+	background-color: hsla(0, 0%, 100%, 0.6);
+}
+.swagger-ui .hover-bg-white-50:focus,
+.swagger-ui .hover-bg-white-50:hover {
+	background-color: hsla(0, 0%, 100%, 0.5);
+}
+.swagger-ui .hover-bg-white-40:focus,
+.swagger-ui .hover-bg-white-40:hover {
+	background-color: hsla(0, 0%, 100%, 0.4);
+}
+.swagger-ui .hover-bg-white-30:focus,
+.swagger-ui .hover-bg-white-30:hover {
+	background-color: hsla(0, 0%, 100%, 0.3);
+}
+.swagger-ui .hover-bg-white-20:focus,
+.swagger-ui .hover-bg-white-20:hover {
+	background-color: hsla(0, 0%, 100%, 0.2);
+}
+.swagger-ui .hover-bg-white-10:focus,
+.swagger-ui .hover-bg-white-10:hover {
+	background-color: hsla(0, 0%, 100%, 0.1);
+}
+.swagger-ui .hover-dark-red:focus,
+.swagger-ui .hover-dark-red:hover {
+	color: #e7040f;
+}
+.swagger-ui .hover-red:focus,
+.swagger-ui .hover-red:hover {
+	color: #ff4136;
+}
+.swagger-ui .hover-light-red:focus,
+.swagger-ui .hover-light-red:hover {
+	color: #ff725c;
+}
+.swagger-ui .hover-orange:focus,
+.swagger-ui .hover-orange:hover {
+	color: #ff6300;
+}
+.swagger-ui .hover-gold:focus,
+.swagger-ui .hover-gold:hover {
+	color: #ffb700;
+}
+.swagger-ui .hover-yellow:focus,
+.swagger-ui .hover-yellow:hover {
+	color: gold;
+}
+.swagger-ui .hover-light-yellow:focus,
+.swagger-ui .hover-light-yellow:hover {
+	color: #fbf1a9;
+}
+.swagger-ui .hover-purple:focus,
+.swagger-ui .hover-purple:hover {
+	color: #5e2ca5;
+}
+.swagger-ui .hover-light-purple:focus,
+.swagger-ui .hover-light-purple:hover {
+	color: #a463f2;
+}
+.swagger-ui .hover-dark-pink:focus,
+.swagger-ui .hover-dark-pink:hover {
+	color: #d5008f;
+}
+.swagger-ui .hover-hot-pink:focus,
+.swagger-ui .hover-hot-pink:hover {
+	color: #ff41b4;
+}
+.swagger-ui .hover-pink:focus,
+.swagger-ui .hover-pink:hover {
+	color: #ff80cc;
+}
+.swagger-ui .hover-light-pink:focus,
+.swagger-ui .hover-light-pink:hover {
+	color: #ffa3d7;
+}
+.swagger-ui .hover-dark-green:focus,
+.swagger-ui .hover-dark-green:hover {
+	color: #137752;
+}
+.swagger-ui .hover-green:focus,
+.swagger-ui .hover-green:hover {
+	color: #19a974;
+}
+.swagger-ui .hover-light-green:focus,
+.swagger-ui .hover-light-green:hover {
+	color: #9eebcf;
+}
+.swagger-ui .hover-navy:focus,
+.swagger-ui .hover-navy:hover {
+	color: #001b44;
+}
+.swagger-ui .hover-dark-blue:focus,
+.swagger-ui .hover-dark-blue:hover {
+	color: #00449e;
+}
+.swagger-ui .hover-blue:focus,
+.swagger-ui .hover-blue:hover {
+	color: #357edd;
+}
+.swagger-ui .hover-light-blue:focus,
+.swagger-ui .hover-light-blue:hover {
+	color: #96ccff;
+}
+.swagger-ui .hover-lightest-blue:focus,
+.swagger-ui .hover-lightest-blue:hover {
+	color: #cdecff;
+}
+.swagger-ui .hover-washed-blue:focus,
+.swagger-ui .hover-washed-blue:hover {
+	color: #f6fffe;
+}
+.swagger-ui .hover-washed-green:focus,
+.swagger-ui .hover-washed-green:hover {
+	color: #e8fdf5;
+}
+.swagger-ui .hover-washed-yellow:focus,
+.swagger-ui .hover-washed-yellow:hover {
+	color: #fffceb;
+}
+.swagger-ui .hover-washed-red:focus,
+.swagger-ui .hover-washed-red:hover {
+	color: #ffdfdf;
+}
+.swagger-ui .hover-bg-dark-red:focus,
+.swagger-ui .hover-bg-dark-red:hover {
+	background-color: #e7040f;
+}
+.swagger-ui .hover-bg-red:focus,
+.swagger-ui .hover-bg-red:hover {
+	background-color: #ff4136;
+}
+.swagger-ui .hover-bg-light-red:focus,
+.swagger-ui .hover-bg-light-red:hover {
+	background-color: #ff725c;
+}
+.swagger-ui .hover-bg-orange:focus,
+.swagger-ui .hover-bg-orange:hover {
+	background-color: #ff6300;
+}
+.swagger-ui .hover-bg-gold:focus,
+.swagger-ui .hover-bg-gold:hover {
+	background-color: #ffb700;
+}
+.swagger-ui .hover-bg-yellow:focus,
+.swagger-ui .hover-bg-yellow:hover {
+	background-color: gold;
+}
+.swagger-ui .hover-bg-light-yellow:focus,
+.swagger-ui .hover-bg-light-yellow:hover {
+	background-color: #fbf1a9;
+}
+.swagger-ui .hover-bg-purple:focus,
+.swagger-ui .hover-bg-purple:hover {
+	background-color: #5e2ca5;
+}
+.swagger-ui .hover-bg-light-purple:focus,
+.swagger-ui .hover-bg-light-purple:hover {
+	background-color: #a463f2;
+}
+.swagger-ui .hover-bg-dark-pink:focus,
+.swagger-ui .hover-bg-dark-pink:hover {
+	background-color: #d5008f;
+}
+.swagger-ui .hover-bg-hot-pink:focus,
+.swagger-ui .hover-bg-hot-pink:hover {
+	background-color: #ff41b4;
+}
+.swagger-ui .hover-bg-pink:focus,
+.swagger-ui .hover-bg-pink:hover {
+	background-color: #ff80cc;
+}
+.swagger-ui .hover-bg-light-pink:focus,
+.swagger-ui .hover-bg-light-pink:hover {
+	background-color: #ffa3d7;
+}
+.swagger-ui .hover-bg-dark-green:focus,
+.swagger-ui .hover-bg-dark-green:hover {
+	background-color: #137752;
+}
+.swagger-ui .hover-bg-green:focus,
+.swagger-ui .hover-bg-green:hover {
+	background-color: #19a974;
+}
+.swagger-ui .hover-bg-light-green:focus,
+.swagger-ui .hover-bg-light-green:hover {
+	background-color: #9eebcf;
+}
+.swagger-ui .hover-bg-navy:focus,
+.swagger-ui .hover-bg-navy:hover {
+	background-color: #001b44;
+}
+.swagger-ui .hover-bg-dark-blue:focus,
+.swagger-ui .hover-bg-dark-blue:hover {
+	background-color: #00449e;
+}
+.swagger-ui .hover-bg-blue:focus,
+.swagger-ui .hover-bg-blue:hover {
+	background-color: #357edd;
+}
+.swagger-ui .hover-bg-light-blue:focus,
+.swagger-ui .hover-bg-light-blue:hover {
+	background-color: #96ccff;
+}
+.swagger-ui .hover-bg-lightest-blue:focus,
+.swagger-ui .hover-bg-lightest-blue:hover {
+	background-color: #cdecff;
+}
+.swagger-ui .hover-bg-washed-blue:focus,
+.swagger-ui .hover-bg-washed-blue:hover {
+	background-color: #f6fffe;
+}
+.swagger-ui .hover-bg-washed-green:focus,
+.swagger-ui .hover-bg-washed-green:hover {
+	background-color: #e8fdf5;
+}
+.swagger-ui .hover-bg-washed-yellow:focus,
+.swagger-ui .hover-bg-washed-yellow:hover {
+	background-color: #fffceb;
+}
+.swagger-ui .hover-bg-washed-red:focus,
+.swagger-ui .hover-bg-washed-red:hover {
+	background-color: #ffdfdf;
+}
+.swagger-ui .hover-bg-inherit:focus,
+.swagger-ui .hover-bg-inherit:hover {
+	background-color: inherit;
+}
+.swagger-ui .pa0 {
+	padding: 0;
+}
+.swagger-ui .pa1 {
+	padding: 0.25rem;
+}
+.swagger-ui .pa2 {
+	padding: 0.5rem;
+}
+.swagger-ui .pa3 {
+	padding: 1rem;
+}
+.swagger-ui .pa4 {
+	padding: 2rem;
+}
+.swagger-ui .pa5 {
+	padding: 4rem;
+}
+.swagger-ui .pa6 {
+	padding: 8rem;
+}
+.swagger-ui .pa7 {
+	padding: 16rem;
+}
+.swagger-ui .pl0 {
+	padding-left: 0;
+}
+.swagger-ui .pl1 {
+	padding-left: 0.25rem;
+}
+.swagger-ui .pl2 {
+	padding-left: 0.5rem;
+}
+.swagger-ui .pl3 {
+	padding-left: 1rem;
+}
+.swagger-ui .pl4 {
+	padding-left: 2rem;
+}
+.swagger-ui .pl5 {
+	padding-left: 4rem;
+}
+.swagger-ui .pl6 {
+	padding-left: 8rem;
+}
+.swagger-ui .pl7 {
+	padding-left: 16rem;
+}
+.swagger-ui .pr0 {
+	padding-right: 0;
+}
+.swagger-ui .pr1 {
+	padding-right: 0.25rem;
+}
+.swagger-ui .pr2 {
+	padding-right: 0.5rem;
+}
+.swagger-ui .pr3 {
+	padding-right: 1rem;
+}
+.swagger-ui .pr4 {
+	padding-right: 2rem;
+}
+.swagger-ui .pr5 {
+	padding-right: 4rem;
+}
+.swagger-ui .pr6 {
+	padding-right: 8rem;
+}
+.swagger-ui .pr7 {
+	padding-right: 16rem;
+}
+.swagger-ui .pb0 {
+	padding-bottom: 0;
+}
+.swagger-ui .pb1 {
+	padding-bottom: 0.25rem;
+}
+.swagger-ui .pb2 {
+	padding-bottom: 0.5rem;
+}
+.swagger-ui .pb3 {
+	padding-bottom: 1rem;
+}
+.swagger-ui .pb4 {
+	padding-bottom: 2rem;
+}
+.swagger-ui .pb5 {
+	padding-bottom: 4rem;
+}
+.swagger-ui .pb6 {
+	padding-bottom: 8rem;
+}
+.swagger-ui .pb7 {
+	padding-bottom: 16rem;
+}
+.swagger-ui .pt0 {
+	padding-top: 0;
+}
+.swagger-ui .pt1 {
+	padding-top: 0.25rem;
+}
+.swagger-ui .pt2 {
+	padding-top: 0.5rem;
+}
+.swagger-ui .pt3 {
+	padding-top: 1rem;
+}
+.swagger-ui .pt4 {
+	padding-top: 2rem;
+}
+.swagger-ui .pt5 {
+	padding-top: 4rem;
+}
+.swagger-ui .pt6 {
+	padding-top: 8rem;
+}
+.swagger-ui .pt7 {
+	padding-top: 16rem;
+}
+.swagger-ui .pv0 {
+	padding-bottom: 0;
+	padding-top: 0;
+}
+.swagger-ui .pv1 {
+	padding-bottom: 0.25rem;
+	padding-top: 0.25rem;
+}
+.swagger-ui .pv2 {
+	padding-bottom: 0.5rem;
+	padding-top: 0.5rem;
+}
+.swagger-ui .pv3 {
+	padding-bottom: 1rem;
+	padding-top: 1rem;
+}
+.swagger-ui .pv4 {
+	padding-bottom: 2rem;
+	padding-top: 2rem;
+}
+.swagger-ui .pv5 {
+	padding-bottom: 4rem;
+	padding-top: 4rem;
+}
+.swagger-ui .pv6 {
+	padding-bottom: 8rem;
+	padding-top: 8rem;
+}
+.swagger-ui .pv7 {
+	padding-bottom: 16rem;
+	padding-top: 16rem;
+}
+.swagger-ui .ph0 {
+	padding-left: 0;
+	padding-right: 0;
+}
+.swagger-ui .ph1 {
+	padding-left: 0.25rem;
+	padding-right: 0.25rem;
+}
+.swagger-ui .ph2 {
+	padding-left: 0.5rem;
+	padding-right: 0.5rem;
+}
+.swagger-ui .ph3 {
+	padding-left: 1rem;
+	padding-right: 1rem;
+}
+.swagger-ui .ph4 {
+	padding-left: 2rem;
+	padding-right: 2rem;
+}
+.swagger-ui .ph5 {
+	padding-left: 4rem;
+	padding-right: 4rem;
+}
+.swagger-ui .ph6 {
+	padding-left: 8rem;
+	padding-right: 8rem;
+}
+.swagger-ui .ph7 {
+	padding-left: 16rem;
+	padding-right: 16rem;
+}
+.swagger-ui .ma0 {
+	margin: 0;
+}
+.swagger-ui .ma1 {
+	margin: 0.25rem;
+}
+.swagger-ui .ma2 {
+	margin: 0.5rem;
+}
+.swagger-ui .ma3 {
+	margin: 1rem;
+}
+.swagger-ui .ma4 {
+	margin: 2rem;
+}
+.swagger-ui .ma5 {
+	margin: 4rem;
+}
+.swagger-ui .ma6 {
+	margin: 8rem;
+}
+.swagger-ui .ma7 {
+	margin: 16rem;
+}
+.swagger-ui .ml0 {
+	margin-left: 0;
+}
+.swagger-ui .ml1 {
+	margin-left: 0.25rem;
+}
+.swagger-ui .ml2 {
+	margin-left: 0.5rem;
+}
+.swagger-ui .ml3 {
+	margin-left: 1rem;
+}
+.swagger-ui .ml4 {
+	margin-left: 2rem;
+}
+.swagger-ui .ml5 {
+	margin-left: 4rem;
+}
+.swagger-ui .ml6 {
+	margin-left: 8rem;
+}
+.swagger-ui .ml7 {
+	margin-left: 16rem;
+}
+.swagger-ui .mr0 {
+	margin-right: 0;
+}
+.swagger-ui .mr1 {
+	margin-right: 0.25rem;
+}
+.swagger-ui .mr2 {
+	margin-right: 0.5rem;
+}
+.swagger-ui .mr3 {
+	margin-right: 1rem;
+}
+.swagger-ui .mr4 {
+	margin-right: 2rem;
+}
+.swagger-ui .mr5 {
+	margin-right: 4rem;
+}
+.swagger-ui .mr6 {
+	margin-right: 8rem;
+}
+.swagger-ui .mr7 {
+	margin-right: 16rem;
+}
+.swagger-ui .mb0 {
+	margin-bottom: 0;
+}
+.swagger-ui .mb1 {
+	margin-bottom: 0.25rem;
+}
+.swagger-ui .mb2 {
+	margin-bottom: 0.5rem;
+}
+.swagger-ui .mb3 {
+	margin-bottom: 1rem;
+}
+.swagger-ui .mb4 {
+	margin-bottom: 2rem;
+}
+.swagger-ui .mb5 {
+	margin-bottom: 4rem;
+}
+.swagger-ui .mb6 {
+	margin-bottom: 8rem;
+}
+.swagger-ui .mb7 {
+	margin-bottom: 16rem;
+}
+.swagger-ui .mt0 {
+	margin-top: 0;
+}
+.swagger-ui .mt1 {
+	margin-top: 0.25rem;
+}
+.swagger-ui .mt2 {
+	margin-top: 0.5rem;
+}
+.swagger-ui .mt3 {
+	margin-top: 1rem;
+}
+.swagger-ui .mt4 {
+	margin-top: 2rem;
+}
+.swagger-ui .mt5 {
+	margin-top: 4rem;
+}
+.swagger-ui .mt6 {
+	margin-top: 8rem;
+}
+.swagger-ui .mt7 {
+	margin-top: 16rem;
+}
+.swagger-ui .mv0 {
+	margin-bottom: 0;
+	margin-top: 0;
+}
+.swagger-ui .mv1 {
+	margin-bottom: 0.25rem;
+	margin-top: 0.25rem;
+}
+.swagger-ui .mv2 {
+	margin-bottom: 0.5rem;
+	margin-top: 0.5rem;
+}
+.swagger-ui .mv3 {
+	margin-bottom: 1rem;
+	margin-top: 1rem;
+}
+.swagger-ui .mv4 {
+	margin-bottom: 2rem;
+	margin-top: 2rem;
+}
+.swagger-ui .mv5 {
+	margin-bottom: 4rem;
+	margin-top: 4rem;
+}
+.swagger-ui .mv6 {
+	margin-bottom: 8rem;
+	margin-top: 8rem;
+}
+.swagger-ui .mv7 {
+	margin-bottom: 16rem;
+	margin-top: 16rem;
+}
+.swagger-ui .mh0 {
+	margin-left: 0;
+	margin-right: 0;
+}
+.swagger-ui .mh1 {
+	margin-left: 0.25rem;
+	margin-right: 0.25rem;
+}
+.swagger-ui .mh2 {
+	margin-left: 0.5rem;
+	margin-right: 0.5rem;
+}
+.swagger-ui .mh3 {
+	margin-left: 1rem;
+	margin-right: 1rem;
+}
+.swagger-ui .mh4 {
+	margin-left: 2rem;
+	margin-right: 2rem;
+}
+.swagger-ui .mh5 {
+	margin-left: 4rem;
+	margin-right: 4rem;
+}
+.swagger-ui .mh6 {
+	margin-left: 8rem;
+	margin-right: 8rem;
+}
+.swagger-ui .mh7 {
+	margin-left: 16rem;
+	margin-right: 16rem;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .pa0-ns {
+		padding: 0;
+	}
+	.swagger-ui .pa1-ns {
+		padding: 0.25rem;
+	}
+	.swagger-ui .pa2-ns {
+		padding: 0.5rem;
+	}
+	.swagger-ui .pa3-ns {
+		padding: 1rem;
+	}
+	.swagger-ui .pa4-ns {
+		padding: 2rem;
+	}
+	.swagger-ui .pa5-ns {
+		padding: 4rem;
+	}
+	.swagger-ui .pa6-ns {
+		padding: 8rem;
+	}
+	.swagger-ui .pa7-ns {
+		padding: 16rem;
+	}
+	.swagger-ui .pl0-ns {
+		padding-left: 0;
+	}
+	.swagger-ui .pl1-ns {
+		padding-left: 0.25rem;
+	}
+	.swagger-ui .pl2-ns {
+		padding-left: 0.5rem;
+	}
+	.swagger-ui .pl3-ns {
+		padding-left: 1rem;
+	}
+	.swagger-ui .pl4-ns {
+		padding-left: 2rem;
+	}
+	.swagger-ui .pl5-ns {
+		padding-left: 4rem;
+	}
+	.swagger-ui .pl6-ns {
+		padding-left: 8rem;
+	}
+	.swagger-ui .pl7-ns {
+		padding-left: 16rem;
+	}
+	.swagger-ui .pr0-ns {
+		padding-right: 0;
+	}
+	.swagger-ui .pr1-ns {
+		padding-right: 0.25rem;
+	}
+	.swagger-ui .pr2-ns {
+		padding-right: 0.5rem;
+	}
+	.swagger-ui .pr3-ns {
+		padding-right: 1rem;
+	}
+	.swagger-ui .pr4-ns {
+		padding-right: 2rem;
+	}
+	.swagger-ui .pr5-ns {
+		padding-right: 4rem;
+	}
+	.swagger-ui .pr6-ns {
+		padding-right: 8rem;
+	}
+	.swagger-ui .pr7-ns {
+		padding-right: 16rem;
+	}
+	.swagger-ui .pb0-ns {
+		padding-bottom: 0;
+	}
+	.swagger-ui .pb1-ns {
+		padding-bottom: 0.25rem;
+	}
+	.swagger-ui .pb2-ns {
+		padding-bottom: 0.5rem;
+	}
+	.swagger-ui .pb3-ns {
+		padding-bottom: 1rem;
+	}
+	.swagger-ui .pb4-ns {
+		padding-bottom: 2rem;
+	}
+	.swagger-ui .pb5-ns {
+		padding-bottom: 4rem;
+	}
+	.swagger-ui .pb6-ns {
+		padding-bottom: 8rem;
+	}
+	.swagger-ui .pb7-ns {
+		padding-bottom: 16rem;
+	}
+	.swagger-ui .pt0-ns {
+		padding-top: 0;
+	}
+	.swagger-ui .pt1-ns {
+		padding-top: 0.25rem;
+	}
+	.swagger-ui .pt2-ns {
+		padding-top: 0.5rem;
+	}
+	.swagger-ui .pt3-ns {
+		padding-top: 1rem;
+	}
+	.swagger-ui .pt4-ns {
+		padding-top: 2rem;
+	}
+	.swagger-ui .pt5-ns {
+		padding-top: 4rem;
+	}
+	.swagger-ui .pt6-ns {
+		padding-top: 8rem;
+	}
+	.swagger-ui .pt7-ns {
+		padding-top: 16rem;
+	}
+	.swagger-ui .pv0-ns {
+		padding-bottom: 0;
+		padding-top: 0;
+	}
+	.swagger-ui .pv1-ns {
+		padding-bottom: 0.25rem;
+		padding-top: 0.25rem;
+	}
+	.swagger-ui .pv2-ns {
+		padding-bottom: 0.5rem;
+		padding-top: 0.5rem;
+	}
+	.swagger-ui .pv3-ns {
+		padding-bottom: 1rem;
+		padding-top: 1rem;
+	}
+	.swagger-ui .pv4-ns {
+		padding-bottom: 2rem;
+		padding-top: 2rem;
+	}
+	.swagger-ui .pv5-ns {
+		padding-bottom: 4rem;
+		padding-top: 4rem;
+	}
+	.swagger-ui .pv6-ns {
+		padding-bottom: 8rem;
+		padding-top: 8rem;
+	}
+	.swagger-ui .pv7-ns {
+		padding-bottom: 16rem;
+		padding-top: 16rem;
+	}
+	.swagger-ui .ph0-ns {
+		padding-left: 0;
+		padding-right: 0;
+	}
+	.swagger-ui .ph1-ns {
+		padding-left: 0.25rem;
+		padding-right: 0.25rem;
+	}
+	.swagger-ui .ph2-ns {
+		padding-left: 0.5rem;
+		padding-right: 0.5rem;
+	}
+	.swagger-ui .ph3-ns {
+		padding-left: 1rem;
+		padding-right: 1rem;
+	}
+	.swagger-ui .ph4-ns {
+		padding-left: 2rem;
+		padding-right: 2rem;
+	}
+	.swagger-ui .ph5-ns {
+		padding-left: 4rem;
+		padding-right: 4rem;
+	}
+	.swagger-ui .ph6-ns {
+		padding-left: 8rem;
+		padding-right: 8rem;
+	}
+	.swagger-ui .ph7-ns {
+		padding-left: 16rem;
+		padding-right: 16rem;
+	}
+	.swagger-ui .ma0-ns {
+		margin: 0;
+	}
+	.swagger-ui .ma1-ns {
+		margin: 0.25rem;
+	}
+	.swagger-ui .ma2-ns {
+		margin: 0.5rem;
+	}
+	.swagger-ui .ma3-ns {
+		margin: 1rem;
+	}
+	.swagger-ui .ma4-ns {
+		margin: 2rem;
+	}
+	.swagger-ui .ma5-ns {
+		margin: 4rem;
+	}
+	.swagger-ui .ma6-ns {
+		margin: 8rem;
+	}
+	.swagger-ui .ma7-ns {
+		margin: 16rem;
+	}
+	.swagger-ui .ml0-ns {
+		margin-left: 0;
+	}
+	.swagger-ui .ml1-ns {
+		margin-left: 0.25rem;
+	}
+	.swagger-ui .ml2-ns {
+		margin-left: 0.5rem;
+	}
+	.swagger-ui .ml3-ns {
+		margin-left: 1rem;
+	}
+	.swagger-ui .ml4-ns {
+		margin-left: 2rem;
+	}
+	.swagger-ui .ml5-ns {
+		margin-left: 4rem;
+	}
+	.swagger-ui .ml6-ns {
+		margin-left: 8rem;
+	}
+	.swagger-ui .ml7-ns {
+		margin-left: 16rem;
+	}
+	.swagger-ui .mr0-ns {
+		margin-right: 0;
+	}
+	.swagger-ui .mr1-ns {
+		margin-right: 0.25rem;
+	}
+	.swagger-ui .mr2-ns {
+		margin-right: 0.5rem;
+	}
+	.swagger-ui .mr3-ns {
+		margin-right: 1rem;
+	}
+	.swagger-ui .mr4-ns {
+		margin-right: 2rem;
+	}
+	.swagger-ui .mr5-ns {
+		margin-right: 4rem;
+	}
+	.swagger-ui .mr6-ns {
+		margin-right: 8rem;
+	}
+	.swagger-ui .mr7-ns {
+		margin-right: 16rem;
+	}
+	.swagger-ui .mb0-ns {
+		margin-bottom: 0;
+	}
+	.swagger-ui .mb1-ns {
+		margin-bottom: 0.25rem;
+	}
+	.swagger-ui .mb2-ns {
+		margin-bottom: 0.5rem;
+	}
+	.swagger-ui .mb3-ns {
+		margin-bottom: 1rem;
+	}
+	.swagger-ui .mb4-ns {
+		margin-bottom: 2rem;
+	}
+	.swagger-ui .mb5-ns {
+		margin-bottom: 4rem;
+	}
+	.swagger-ui .mb6-ns {
+		margin-bottom: 8rem;
+	}
+	.swagger-ui .mb7-ns {
+		margin-bottom: 16rem;
+	}
+	.swagger-ui .mt0-ns {
+		margin-top: 0;
+	}
+	.swagger-ui .mt1-ns {
+		margin-top: 0.25rem;
+	}
+	.swagger-ui .mt2-ns {
+		margin-top: 0.5rem;
+	}
+	.swagger-ui .mt3-ns {
+		margin-top: 1rem;
+	}
+	.swagger-ui .mt4-ns {
+		margin-top: 2rem;
+	}
+	.swagger-ui .mt5-ns {
+		margin-top: 4rem;
+	}
+	.swagger-ui .mt6-ns {
+		margin-top: 8rem;
+	}
+	.swagger-ui .mt7-ns {
+		margin-top: 16rem;
+	}
+	.swagger-ui .mv0-ns {
+		margin-bottom: 0;
+		margin-top: 0;
+	}
+	.swagger-ui .mv1-ns {
+		margin-bottom: 0.25rem;
+		margin-top: 0.25rem;
+	}
+	.swagger-ui .mv2-ns {
+		margin-bottom: 0.5rem;
+		margin-top: 0.5rem;
+	}
+	.swagger-ui .mv3-ns {
+		margin-bottom: 1rem;
+		margin-top: 1rem;
+	}
+	.swagger-ui .mv4-ns {
+		margin-bottom: 2rem;
+		margin-top: 2rem;
+	}
+	.swagger-ui .mv5-ns {
+		margin-bottom: 4rem;
+		margin-top: 4rem;
+	}
+	.swagger-ui .mv6-ns {
+		margin-bottom: 8rem;
+		margin-top: 8rem;
+	}
+	.swagger-ui .mv7-ns {
+		margin-bottom: 16rem;
+		margin-top: 16rem;
+	}
+	.swagger-ui .mh0-ns {
+		margin-left: 0;
+		margin-right: 0;
+	}
+	.swagger-ui .mh1-ns {
+		margin-left: 0.25rem;
+		margin-right: 0.25rem;
+	}
+	.swagger-ui .mh2-ns {
+		margin-left: 0.5rem;
+		margin-right: 0.5rem;
+	}
+	.swagger-ui .mh3-ns {
+		margin-left: 1rem;
+		margin-right: 1rem;
+	}
+	.swagger-ui .mh4-ns {
+		margin-left: 2rem;
+		margin-right: 2rem;
+	}
+	.swagger-ui .mh5-ns {
+		margin-left: 4rem;
+		margin-right: 4rem;
+	}
+	.swagger-ui .mh6-ns {
+		margin-left: 8rem;
+		margin-right: 8rem;
+	}
+	.swagger-ui .mh7-ns {
+		margin-left: 16rem;
+		margin-right: 16rem;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .pa0-m {
+		padding: 0;
+	}
+	.swagger-ui .pa1-m {
+		padding: 0.25rem;
+	}
+	.swagger-ui .pa2-m {
+		padding: 0.5rem;
+	}
+	.swagger-ui .pa3-m {
+		padding: 1rem;
+	}
+	.swagger-ui .pa4-m {
+		padding: 2rem;
+	}
+	.swagger-ui .pa5-m {
+		padding: 4rem;
+	}
+	.swagger-ui .pa6-m {
+		padding: 8rem;
+	}
+	.swagger-ui .pa7-m {
+		padding: 16rem;
+	}
+	.swagger-ui .pl0-m {
+		padding-left: 0;
+	}
+	.swagger-ui .pl1-m {
+		padding-left: 0.25rem;
+	}
+	.swagger-ui .pl2-m {
+		padding-left: 0.5rem;
+	}
+	.swagger-ui .pl3-m {
+		padding-left: 1rem;
+	}
+	.swagger-ui .pl4-m {
+		padding-left: 2rem;
+	}
+	.swagger-ui .pl5-m {
+		padding-left: 4rem;
+	}
+	.swagger-ui .pl6-m {
+		padding-left: 8rem;
+	}
+	.swagger-ui .pl7-m {
+		padding-left: 16rem;
+	}
+	.swagger-ui .pr0-m {
+		padding-right: 0;
+	}
+	.swagger-ui .pr1-m {
+		padding-right: 0.25rem;
+	}
+	.swagger-ui .pr2-m {
+		padding-right: 0.5rem;
+	}
+	.swagger-ui .pr3-m {
+		padding-right: 1rem;
+	}
+	.swagger-ui .pr4-m {
+		padding-right: 2rem;
+	}
+	.swagger-ui .pr5-m {
+		padding-right: 4rem;
+	}
+	.swagger-ui .pr6-m {
+		padding-right: 8rem;
+	}
+	.swagger-ui .pr7-m {
+		padding-right: 16rem;
+	}
+	.swagger-ui .pb0-m {
+		padding-bottom: 0;
+	}
+	.swagger-ui .pb1-m {
+		padding-bottom: 0.25rem;
+	}
+	.swagger-ui .pb2-m {
+		padding-bottom: 0.5rem;
+	}
+	.swagger-ui .pb3-m {
+		padding-bottom: 1rem;
+	}
+	.swagger-ui .pb4-m {
+		padding-bottom: 2rem;
+	}
+	.swagger-ui .pb5-m {
+		padding-bottom: 4rem;
+	}
+	.swagger-ui .pb6-m {
+		padding-bottom: 8rem;
+	}
+	.swagger-ui .pb7-m {
+		padding-bottom: 16rem;
+	}
+	.swagger-ui .pt0-m {
+		padding-top: 0;
+	}
+	.swagger-ui .pt1-m {
+		padding-top: 0.25rem;
+	}
+	.swagger-ui .pt2-m {
+		padding-top: 0.5rem;
+	}
+	.swagger-ui .pt3-m {
+		padding-top: 1rem;
+	}
+	.swagger-ui .pt4-m {
+		padding-top: 2rem;
+	}
+	.swagger-ui .pt5-m {
+		padding-top: 4rem;
+	}
+	.swagger-ui .pt6-m {
+		padding-top: 8rem;
+	}
+	.swagger-ui .pt7-m {
+		padding-top: 16rem;
+	}
+	.swagger-ui .pv0-m {
+		padding-bottom: 0;
+		padding-top: 0;
+	}
+	.swagger-ui .pv1-m {
+		padding-bottom: 0.25rem;
+		padding-top: 0.25rem;
+	}
+	.swagger-ui .pv2-m {
+		padding-bottom: 0.5rem;
+		padding-top: 0.5rem;
+	}
+	.swagger-ui .pv3-m {
+		padding-bottom: 1rem;
+		padding-top: 1rem;
+	}
+	.swagger-ui .pv4-m {
+		padding-bottom: 2rem;
+		padding-top: 2rem;
+	}
+	.swagger-ui .pv5-m {
+		padding-bottom: 4rem;
+		padding-top: 4rem;
+	}
+	.swagger-ui .pv6-m {
+		padding-bottom: 8rem;
+		padding-top: 8rem;
+	}
+	.swagger-ui .pv7-m {
+		padding-bottom: 16rem;
+		padding-top: 16rem;
+	}
+	.swagger-ui .ph0-m {
+		padding-left: 0;
+		padding-right: 0;
+	}
+	.swagger-ui .ph1-m {
+		padding-left: 0.25rem;
+		padding-right: 0.25rem;
+	}
+	.swagger-ui .ph2-m {
+		padding-left: 0.5rem;
+		padding-right: 0.5rem;
+	}
+	.swagger-ui .ph3-m {
+		padding-left: 1rem;
+		padding-right: 1rem;
+	}
+	.swagger-ui .ph4-m {
+		padding-left: 2rem;
+		padding-right: 2rem;
+	}
+	.swagger-ui .ph5-m {
+		padding-left: 4rem;
+		padding-right: 4rem;
+	}
+	.swagger-ui .ph6-m {
+		padding-left: 8rem;
+		padding-right: 8rem;
+	}
+	.swagger-ui .ph7-m {
+		padding-left: 16rem;
+		padding-right: 16rem;
+	}
+	.swagger-ui .ma0-m {
+		margin: 0;
+	}
+	.swagger-ui .ma1-m {
+		margin: 0.25rem;
+	}
+	.swagger-ui .ma2-m {
+		margin: 0.5rem;
+	}
+	.swagger-ui .ma3-m {
+		margin: 1rem;
+	}
+	.swagger-ui .ma4-m {
+		margin: 2rem;
+	}
+	.swagger-ui .ma5-m {
+		margin: 4rem;
+	}
+	.swagger-ui .ma6-m {
+		margin: 8rem;
+	}
+	.swagger-ui .ma7-m {
+		margin: 16rem;
+	}
+	.swagger-ui .ml0-m {
+		margin-left: 0;
+	}
+	.swagger-ui .ml1-m {
+		margin-left: 0.25rem;
+	}
+	.swagger-ui .ml2-m {
+		margin-left: 0.5rem;
+	}
+	.swagger-ui .ml3-m {
+		margin-left: 1rem;
+	}
+	.swagger-ui .ml4-m {
+		margin-left: 2rem;
+	}
+	.swagger-ui .ml5-m {
+		margin-left: 4rem;
+	}
+	.swagger-ui .ml6-m {
+		margin-left: 8rem;
+	}
+	.swagger-ui .ml7-m {
+		margin-left: 16rem;
+	}
+	.swagger-ui .mr0-m {
+		margin-right: 0;
+	}
+	.swagger-ui .mr1-m {
+		margin-right: 0.25rem;
+	}
+	.swagger-ui .mr2-m {
+		margin-right: 0.5rem;
+	}
+	.swagger-ui .mr3-m {
+		margin-right: 1rem;
+	}
+	.swagger-ui .mr4-m {
+		margin-right: 2rem;
+	}
+	.swagger-ui .mr5-m {
+		margin-right: 4rem;
+	}
+	.swagger-ui .mr6-m {
+		margin-right: 8rem;
+	}
+	.swagger-ui .mr7-m {
+		margin-right: 16rem;
+	}
+	.swagger-ui .mb0-m {
+		margin-bottom: 0;
+	}
+	.swagger-ui .mb1-m {
+		margin-bottom: 0.25rem;
+	}
+	.swagger-ui .mb2-m {
+		margin-bottom: 0.5rem;
+	}
+	.swagger-ui .mb3-m {
+		margin-bottom: 1rem;
+	}
+	.swagger-ui .mb4-m {
+		margin-bottom: 2rem;
+	}
+	.swagger-ui .mb5-m {
+		margin-bottom: 4rem;
+	}
+	.swagger-ui .mb6-m {
+		margin-bottom: 8rem;
+	}
+	.swagger-ui .mb7-m {
+		margin-bottom: 16rem;
+	}
+	.swagger-ui .mt0-m {
+		margin-top: 0;
+	}
+	.swagger-ui .mt1-m {
+		margin-top: 0.25rem;
+	}
+	.swagger-ui .mt2-m {
+		margin-top: 0.5rem;
+	}
+	.swagger-ui .mt3-m {
+		margin-top: 1rem;
+	}
+	.swagger-ui .mt4-m {
+		margin-top: 2rem;
+	}
+	.swagger-ui .mt5-m {
+		margin-top: 4rem;
+	}
+	.swagger-ui .mt6-m {
+		margin-top: 8rem;
+	}
+	.swagger-ui .mt7-m {
+		margin-top: 16rem;
+	}
+	.swagger-ui .mv0-m {
+		margin-bottom: 0;
+		margin-top: 0;
+	}
+	.swagger-ui .mv1-m {
+		margin-bottom: 0.25rem;
+		margin-top: 0.25rem;
+	}
+	.swagger-ui .mv2-m {
+		margin-bottom: 0.5rem;
+		margin-top: 0.5rem;
+	}
+	.swagger-ui .mv3-m {
+		margin-bottom: 1rem;
+		margin-top: 1rem;
+	}
+	.swagger-ui .mv4-m {
+		margin-bottom: 2rem;
+		margin-top: 2rem;
+	}
+	.swagger-ui .mv5-m {
+		margin-bottom: 4rem;
+		margin-top: 4rem;
+	}
+	.swagger-ui .mv6-m {
+		margin-bottom: 8rem;
+		margin-top: 8rem;
+	}
+	.swagger-ui .mv7-m {
+		margin-bottom: 16rem;
+		margin-top: 16rem;
+	}
+	.swagger-ui .mh0-m {
+		margin-left: 0;
+		margin-right: 0;
+	}
+	.swagger-ui .mh1-m {
+		margin-left: 0.25rem;
+		margin-right: 0.25rem;
+	}
+	.swagger-ui .mh2-m {
+		margin-left: 0.5rem;
+		margin-right: 0.5rem;
+	}
+	.swagger-ui .mh3-m {
+		margin-left: 1rem;
+		margin-right: 1rem;
+	}
+	.swagger-ui .mh4-m {
+		margin-left: 2rem;
+		margin-right: 2rem;
+	}
+	.swagger-ui .mh5-m {
+		margin-left: 4rem;
+		margin-right: 4rem;
+	}
+	.swagger-ui .mh6-m {
+		margin-left: 8rem;
+		margin-right: 8rem;
+	}
+	.swagger-ui .mh7-m {
+		margin-left: 16rem;
+		margin-right: 16rem;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .pa0-l {
+		padding: 0;
+	}
+	.swagger-ui .pa1-l {
+		padding: 0.25rem;
+	}
+	.swagger-ui .pa2-l {
+		padding: 0.5rem;
+	}
+	.swagger-ui .pa3-l {
+		padding: 1rem;
+	}
+	.swagger-ui .pa4-l {
+		padding: 2rem;
+	}
+	.swagger-ui .pa5-l {
+		padding: 4rem;
+	}
+	.swagger-ui .pa6-l {
+		padding: 8rem;
+	}
+	.swagger-ui .pa7-l {
+		padding: 16rem;
+	}
+	.swagger-ui .pl0-l {
+		padding-left: 0;
+	}
+	.swagger-ui .pl1-l {
+		padding-left: 0.25rem;
+	}
+	.swagger-ui .pl2-l {
+		padding-left: 0.5rem;
+	}
+	.swagger-ui .pl3-l {
+		padding-left: 1rem;
+	}
+	.swagger-ui .pl4-l {
+		padding-left: 2rem;
+	}
+	.swagger-ui .pl5-l {
+		padding-left: 4rem;
+	}
+	.swagger-ui .pl6-l {
+		padding-left: 8rem;
+	}
+	.swagger-ui .pl7-l {
+		padding-left: 16rem;
+	}
+	.swagger-ui .pr0-l {
+		padding-right: 0;
+	}
+	.swagger-ui .pr1-l {
+		padding-right: 0.25rem;
+	}
+	.swagger-ui .pr2-l {
+		padding-right: 0.5rem;
+	}
+	.swagger-ui .pr3-l {
+		padding-right: 1rem;
+	}
+	.swagger-ui .pr4-l {
+		padding-right: 2rem;
+	}
+	.swagger-ui .pr5-l {
+		padding-right: 4rem;
+	}
+	.swagger-ui .pr6-l {
+		padding-right: 8rem;
+	}
+	.swagger-ui .pr7-l {
+		padding-right: 16rem;
+	}
+	.swagger-ui .pb0-l {
+		padding-bottom: 0;
+	}
+	.swagger-ui .pb1-l {
+		padding-bottom: 0.25rem;
+	}
+	.swagger-ui .pb2-l {
+		padding-bottom: 0.5rem;
+	}
+	.swagger-ui .pb3-l {
+		padding-bottom: 1rem;
+	}
+	.swagger-ui .pb4-l {
+		padding-bottom: 2rem;
+	}
+	.swagger-ui .pb5-l {
+		padding-bottom: 4rem;
+	}
+	.swagger-ui .pb6-l {
+		padding-bottom: 8rem;
+	}
+	.swagger-ui .pb7-l {
+		padding-bottom: 16rem;
+	}
+	.swagger-ui .pt0-l {
+		padding-top: 0;
+	}
+	.swagger-ui .pt1-l {
+		padding-top: 0.25rem;
+	}
+	.swagger-ui .pt2-l {
+		padding-top: 0.5rem;
+	}
+	.swagger-ui .pt3-l {
+		padding-top: 1rem;
+	}
+	.swagger-ui .pt4-l {
+		padding-top: 2rem;
+	}
+	.swagger-ui .pt5-l {
+		padding-top: 4rem;
+	}
+	.swagger-ui .pt6-l {
+		padding-top: 8rem;
+	}
+	.swagger-ui .pt7-l {
+		padding-top: 16rem;
+	}
+	.swagger-ui .pv0-l {
+		padding-bottom: 0;
+		padding-top: 0;
+	}
+	.swagger-ui .pv1-l {
+		padding-bottom: 0.25rem;
+		padding-top: 0.25rem;
+	}
+	.swagger-ui .pv2-l {
+		padding-bottom: 0.5rem;
+		padding-top: 0.5rem;
+	}
+	.swagger-ui .pv3-l {
+		padding-bottom: 1rem;
+		padding-top: 1rem;
+	}
+	.swagger-ui .pv4-l {
+		padding-bottom: 2rem;
+		padding-top: 2rem;
+	}
+	.swagger-ui .pv5-l {
+		padding-bottom: 4rem;
+		padding-top: 4rem;
+	}
+	.swagger-ui .pv6-l {
+		padding-bottom: 8rem;
+		padding-top: 8rem;
+	}
+	.swagger-ui .pv7-l {
+		padding-bottom: 16rem;
+		padding-top: 16rem;
+	}
+	.swagger-ui .ph0-l {
+		padding-left: 0;
+		padding-right: 0;
+	}
+	.swagger-ui .ph1-l {
+		padding-left: 0.25rem;
+		padding-right: 0.25rem;
+	}
+	.swagger-ui .ph2-l {
+		padding-left: 0.5rem;
+		padding-right: 0.5rem;
+	}
+	.swagger-ui .ph3-l {
+		padding-left: 1rem;
+		padding-right: 1rem;
+	}
+	.swagger-ui .ph4-l {
+		padding-left: 2rem;
+		padding-right: 2rem;
+	}
+	.swagger-ui .ph5-l {
+		padding-left: 4rem;
+		padding-right: 4rem;
+	}
+	.swagger-ui .ph6-l {
+		padding-left: 8rem;
+		padding-right: 8rem;
+	}
+	.swagger-ui .ph7-l {
+		padding-left: 16rem;
+		padding-right: 16rem;
+	}
+	.swagger-ui .ma0-l {
+		margin: 0;
+	}
+	.swagger-ui .ma1-l {
+		margin: 0.25rem;
+	}
+	.swagger-ui .ma2-l {
+		margin: 0.5rem;
+	}
+	.swagger-ui .ma3-l {
+		margin: 1rem;
+	}
+	.swagger-ui .ma4-l {
+		margin: 2rem;
+	}
+	.swagger-ui .ma5-l {
+		margin: 4rem;
+	}
+	.swagger-ui .ma6-l {
+		margin: 8rem;
+	}
+	.swagger-ui .ma7-l {
+		margin: 16rem;
+	}
+	.swagger-ui .ml0-l {
+		margin-left: 0;
+	}
+	.swagger-ui .ml1-l {
+		margin-left: 0.25rem;
+	}
+	.swagger-ui .ml2-l {
+		margin-left: 0.5rem;
+	}
+	.swagger-ui .ml3-l {
+		margin-left: 1rem;
+	}
+	.swagger-ui .ml4-l {
+		margin-left: 2rem;
+	}
+	.swagger-ui .ml5-l {
+		margin-left: 4rem;
+	}
+	.swagger-ui .ml6-l {
+		margin-left: 8rem;
+	}
+	.swagger-ui .ml7-l {
+		margin-left: 16rem;
+	}
+	.swagger-ui .mr0-l {
+		margin-right: 0;
+	}
+	.swagger-ui .mr1-l {
+		margin-right: 0.25rem;
+	}
+	.swagger-ui .mr2-l {
+		margin-right: 0.5rem;
+	}
+	.swagger-ui .mr3-l {
+		margin-right: 1rem;
+	}
+	.swagger-ui .mr4-l {
+		margin-right: 2rem;
+	}
+	.swagger-ui .mr5-l {
+		margin-right: 4rem;
+	}
+	.swagger-ui .mr6-l {
+		margin-right: 8rem;
+	}
+	.swagger-ui .mr7-l {
+		margin-right: 16rem;
+	}
+	.swagger-ui .mb0-l {
+		margin-bottom: 0;
+	}
+	.swagger-ui .mb1-l {
+		margin-bottom: 0.25rem;
+	}
+	.swagger-ui .mb2-l {
+		margin-bottom: 0.5rem;
+	}
+	.swagger-ui .mb3-l {
+		margin-bottom: 1rem;
+	}
+	.swagger-ui .mb4-l {
+		margin-bottom: 2rem;
+	}
+	.swagger-ui .mb5-l {
+		margin-bottom: 4rem;
+	}
+	.swagger-ui .mb6-l {
+		margin-bottom: 8rem;
+	}
+	.swagger-ui .mb7-l {
+		margin-bottom: 16rem;
+	}
+	.swagger-ui .mt0-l {
+		margin-top: 0;
+	}
+	.swagger-ui .mt1-l {
+		margin-top: 0.25rem;
+	}
+	.swagger-ui .mt2-l {
+		margin-top: 0.5rem;
+	}
+	.swagger-ui .mt3-l {
+		margin-top: 1rem;
+	}
+	.swagger-ui .mt4-l {
+		margin-top: 2rem;
+	}
+	.swagger-ui .mt5-l {
+		margin-top: 4rem;
+	}
+	.swagger-ui .mt6-l {
+		margin-top: 8rem;
+	}
+	.swagger-ui .mt7-l {
+		margin-top: 16rem;
+	}
+	.swagger-ui .mv0-l {
+		margin-bottom: 0;
+		margin-top: 0;
+	}
+	.swagger-ui .mv1-l {
+		margin-bottom: 0.25rem;
+		margin-top: 0.25rem;
+	}
+	.swagger-ui .mv2-l {
+		margin-bottom: 0.5rem;
+		margin-top: 0.5rem;
+	}
+	.swagger-ui .mv3-l {
+		margin-bottom: 1rem;
+		margin-top: 1rem;
+	}
+	.swagger-ui .mv4-l {
+		margin-bottom: 2rem;
+		margin-top: 2rem;
+	}
+	.swagger-ui .mv5-l {
+		margin-bottom: 4rem;
+		margin-top: 4rem;
+	}
+	.swagger-ui .mv6-l {
+		margin-bottom: 8rem;
+		margin-top: 8rem;
+	}
+	.swagger-ui .mv7-l {
+		margin-bottom: 16rem;
+		margin-top: 16rem;
+	}
+	.swagger-ui .mh0-l {
+		margin-left: 0;
+		margin-right: 0;
+	}
+	.swagger-ui .mh1-l {
+		margin-left: 0.25rem;
+		margin-right: 0.25rem;
+	}
+	.swagger-ui .mh2-l {
+		margin-left: 0.5rem;
+		margin-right: 0.5rem;
+	}
+	.swagger-ui .mh3-l {
+		margin-left: 1rem;
+		margin-right: 1rem;
+	}
+	.swagger-ui .mh4-l {
+		margin-left: 2rem;
+		margin-right: 2rem;
+	}
+	.swagger-ui .mh5-l {
+		margin-left: 4rem;
+		margin-right: 4rem;
+	}
+	.swagger-ui .mh6-l {
+		margin-left: 8rem;
+		margin-right: 8rem;
+	}
+	.swagger-ui .mh7-l {
+		margin-left: 16rem;
+		margin-right: 16rem;
+	}
+}
+.swagger-ui .na1 {
+	margin: -0.25rem;
+}
+.swagger-ui .na2 {
+	margin: -0.5rem;
+}
+.swagger-ui .na3 {
+	margin: -1rem;
+}
+.swagger-ui .na4 {
+	margin: -2rem;
+}
+.swagger-ui .na5 {
+	margin: -4rem;
+}
+.swagger-ui .na6 {
+	margin: -8rem;
+}
+.swagger-ui .na7 {
+	margin: -16rem;
+}
+.swagger-ui .nl1 {
+	margin-left: -0.25rem;
+}
+.swagger-ui .nl2 {
+	margin-left: -0.5rem;
+}
+.swagger-ui .nl3 {
+	margin-left: -1rem;
+}
+.swagger-ui .nl4 {
+	margin-left: -2rem;
+}
+.swagger-ui .nl5 {
+	margin-left: -4rem;
+}
+.swagger-ui .nl6 {
+	margin-left: -8rem;
+}
+.swagger-ui .nl7 {
+	margin-left: -16rem;
+}
+.swagger-ui .nr1 {
+	margin-right: -0.25rem;
+}
+.swagger-ui .nr2 {
+	margin-right: -0.5rem;
+}
+.swagger-ui .nr3 {
+	margin-right: -1rem;
+}
+.swagger-ui .nr4 {
+	margin-right: -2rem;
+}
+.swagger-ui .nr5 {
+	margin-right: -4rem;
+}
+.swagger-ui .nr6 {
+	margin-right: -8rem;
+}
+.swagger-ui .nr7 {
+	margin-right: -16rem;
+}
+.swagger-ui .nb1 {
+	margin-bottom: -0.25rem;
+}
+.swagger-ui .nb2 {
+	margin-bottom: -0.5rem;
+}
+.swagger-ui .nb3 {
+	margin-bottom: -1rem;
+}
+.swagger-ui .nb4 {
+	margin-bottom: -2rem;
+}
+.swagger-ui .nb5 {
+	margin-bottom: -4rem;
+}
+.swagger-ui .nb6 {
+	margin-bottom: -8rem;
+}
+.swagger-ui .nb7 {
+	margin-bottom: -16rem;
+}
+.swagger-ui .nt1 {
+	margin-top: -0.25rem;
+}
+.swagger-ui .nt2 {
+	margin-top: -0.5rem;
+}
+.swagger-ui .nt3 {
+	margin-top: -1rem;
+}
+.swagger-ui .nt4 {
+	margin-top: -2rem;
+}
+.swagger-ui .nt5 {
+	margin-top: -4rem;
+}
+.swagger-ui .nt6 {
+	margin-top: -8rem;
+}
+.swagger-ui .nt7 {
+	margin-top: -16rem;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .na1-ns {
+		margin: -0.25rem;
+	}
+	.swagger-ui .na2-ns {
+		margin: -0.5rem;
+	}
+	.swagger-ui .na3-ns {
+		margin: -1rem;
+	}
+	.swagger-ui .na4-ns {
+		margin: -2rem;
+	}
+	.swagger-ui .na5-ns {
+		margin: -4rem;
+	}
+	.swagger-ui .na6-ns {
+		margin: -8rem;
+	}
+	.swagger-ui .na7-ns {
+		margin: -16rem;
+	}
+	.swagger-ui .nl1-ns {
+		margin-left: -0.25rem;
+	}
+	.swagger-ui .nl2-ns {
+		margin-left: -0.5rem;
+	}
+	.swagger-ui .nl3-ns {
+		margin-left: -1rem;
+	}
+	.swagger-ui .nl4-ns {
+		margin-left: -2rem;
+	}
+	.swagger-ui .nl5-ns {
+		margin-left: -4rem;
+	}
+	.swagger-ui .nl6-ns {
+		margin-left: -8rem;
+	}
+	.swagger-ui .nl7-ns {
+		margin-left: -16rem;
+	}
+	.swagger-ui .nr1-ns {
+		margin-right: -0.25rem;
+	}
+	.swagger-ui .nr2-ns {
+		margin-right: -0.5rem;
+	}
+	.swagger-ui .nr3-ns {
+		margin-right: -1rem;
+	}
+	.swagger-ui .nr4-ns {
+		margin-right: -2rem;
+	}
+	.swagger-ui .nr5-ns {
+		margin-right: -4rem;
+	}
+	.swagger-ui .nr6-ns {
+		margin-right: -8rem;
+	}
+	.swagger-ui .nr7-ns {
+		margin-right: -16rem;
+	}
+	.swagger-ui .nb1-ns {
+		margin-bottom: -0.25rem;
+	}
+	.swagger-ui .nb2-ns {
+		margin-bottom: -0.5rem;
+	}
+	.swagger-ui .nb3-ns {
+		margin-bottom: -1rem;
+	}
+	.swagger-ui .nb4-ns {
+		margin-bottom: -2rem;
+	}
+	.swagger-ui .nb5-ns {
+		margin-bottom: -4rem;
+	}
+	.swagger-ui .nb6-ns {
+		margin-bottom: -8rem;
+	}
+	.swagger-ui .nb7-ns {
+		margin-bottom: -16rem;
+	}
+	.swagger-ui .nt1-ns {
+		margin-top: -0.25rem;
+	}
+	.swagger-ui .nt2-ns {
+		margin-top: -0.5rem;
+	}
+	.swagger-ui .nt3-ns {
+		margin-top: -1rem;
+	}
+	.swagger-ui .nt4-ns {
+		margin-top: -2rem;
+	}
+	.swagger-ui .nt5-ns {
+		margin-top: -4rem;
+	}
+	.swagger-ui .nt6-ns {
+		margin-top: -8rem;
+	}
+	.swagger-ui .nt7-ns {
+		margin-top: -16rem;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .na1-m {
+		margin: -0.25rem;
+	}
+	.swagger-ui .na2-m {
+		margin: -0.5rem;
+	}
+	.swagger-ui .na3-m {
+		margin: -1rem;
+	}
+	.swagger-ui .na4-m {
+		margin: -2rem;
+	}
+	.swagger-ui .na5-m {
+		margin: -4rem;
+	}
+	.swagger-ui .na6-m {
+		margin: -8rem;
+	}
+	.swagger-ui .na7-m {
+		margin: -16rem;
+	}
+	.swagger-ui .nl1-m {
+		margin-left: -0.25rem;
+	}
+	.swagger-ui .nl2-m {
+		margin-left: -0.5rem;
+	}
+	.swagger-ui .nl3-m {
+		margin-left: -1rem;
+	}
+	.swagger-ui .nl4-m {
+		margin-left: -2rem;
+	}
+	.swagger-ui .nl5-m {
+		margin-left: -4rem;
+	}
+	.swagger-ui .nl6-m {
+		margin-left: -8rem;
+	}
+	.swagger-ui .nl7-m {
+		margin-left: -16rem;
+	}
+	.swagger-ui .nr1-m {
+		margin-right: -0.25rem;
+	}
+	.swagger-ui .nr2-m {
+		margin-right: -0.5rem;
+	}
+	.swagger-ui .nr3-m {
+		margin-right: -1rem;
+	}
+	.swagger-ui .nr4-m {
+		margin-right: -2rem;
+	}
+	.swagger-ui .nr5-m {
+		margin-right: -4rem;
+	}
+	.swagger-ui .nr6-m {
+		margin-right: -8rem;
+	}
+	.swagger-ui .nr7-m {
+		margin-right: -16rem;
+	}
+	.swagger-ui .nb1-m {
+		margin-bottom: -0.25rem;
+	}
+	.swagger-ui .nb2-m {
+		margin-bottom: -0.5rem;
+	}
+	.swagger-ui .nb3-m {
+		margin-bottom: -1rem;
+	}
+	.swagger-ui .nb4-m {
+		margin-bottom: -2rem;
+	}
+	.swagger-ui .nb5-m {
+		margin-bottom: -4rem;
+	}
+	.swagger-ui .nb6-m {
+		margin-bottom: -8rem;
+	}
+	.swagger-ui .nb7-m {
+		margin-bottom: -16rem;
+	}
+	.swagger-ui .nt1-m {
+		margin-top: -0.25rem;
+	}
+	.swagger-ui .nt2-m {
+		margin-top: -0.5rem;
+	}
+	.swagger-ui .nt3-m {
+		margin-top: -1rem;
+	}
+	.swagger-ui .nt4-m {
+		margin-top: -2rem;
+	}
+	.swagger-ui .nt5-m {
+		margin-top: -4rem;
+	}
+	.swagger-ui .nt6-m {
+		margin-top: -8rem;
+	}
+	.swagger-ui .nt7-m {
+		margin-top: -16rem;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .na1-l {
+		margin: -0.25rem;
+	}
+	.swagger-ui .na2-l {
+		margin: -0.5rem;
+	}
+	.swagger-ui .na3-l {
+		margin: -1rem;
+	}
+	.swagger-ui .na4-l {
+		margin: -2rem;
+	}
+	.swagger-ui .na5-l {
+		margin: -4rem;
+	}
+	.swagger-ui .na6-l {
+		margin: -8rem;
+	}
+	.swagger-ui .na7-l {
+		margin: -16rem;
+	}
+	.swagger-ui .nl1-l {
+		margin-left: -0.25rem;
+	}
+	.swagger-ui .nl2-l {
+		margin-left: -0.5rem;
+	}
+	.swagger-ui .nl3-l {
+		margin-left: -1rem;
+	}
+	.swagger-ui .nl4-l {
+		margin-left: -2rem;
+	}
+	.swagger-ui .nl5-l {
+		margin-left: -4rem;
+	}
+	.swagger-ui .nl6-l {
+		margin-left: -8rem;
+	}
+	.swagger-ui .nl7-l {
+		margin-left: -16rem;
+	}
+	.swagger-ui .nr1-l {
+		margin-right: -0.25rem;
+	}
+	.swagger-ui .nr2-l {
+		margin-right: -0.5rem;
+	}
+	.swagger-ui .nr3-l {
+		margin-right: -1rem;
+	}
+	.swagger-ui .nr4-l {
+		margin-right: -2rem;
+	}
+	.swagger-ui .nr5-l {
+		margin-right: -4rem;
+	}
+	.swagger-ui .nr6-l {
+		margin-right: -8rem;
+	}
+	.swagger-ui .nr7-l {
+		margin-right: -16rem;
+	}
+	.swagger-ui .nb1-l {
+		margin-bottom: -0.25rem;
+	}
+	.swagger-ui .nb2-l {
+		margin-bottom: -0.5rem;
+	}
+	.swagger-ui .nb3-l {
+		margin-bottom: -1rem;
+	}
+	.swagger-ui .nb4-l {
+		margin-bottom: -2rem;
+	}
+	.swagger-ui .nb5-l {
+		margin-bottom: -4rem;
+	}
+	.swagger-ui .nb6-l {
+		margin-bottom: -8rem;
+	}
+	.swagger-ui .nb7-l {
+		margin-bottom: -16rem;
+	}
+	.swagger-ui .nt1-l {
+		margin-top: -0.25rem;
+	}
+	.swagger-ui .nt2-l {
+		margin-top: -0.5rem;
+	}
+	.swagger-ui .nt3-l {
+		margin-top: -1rem;
+	}
+	.swagger-ui .nt4-l {
+		margin-top: -2rem;
+	}
+	.swagger-ui .nt5-l {
+		margin-top: -4rem;
+	}
+	.swagger-ui .nt6-l {
+		margin-top: -8rem;
+	}
+	.swagger-ui .nt7-l {
+		margin-top: -16rem;
+	}
+}
+.swagger-ui .collapse {
+	border-collapse: collapse;
+	border-spacing: 0;
+}
+.swagger-ui .striped--light-silver:nth-child(odd) {
+	background-color: #aaa;
+}
+.swagger-ui .striped--moon-gray:nth-child(odd) {
+	background-color: #ccc;
+}
+.swagger-ui .striped--light-gray:nth-child(odd) {
+	background-color: #eee;
+}
+.swagger-ui .striped--near-white:nth-child(odd) {
+	background-color: #f4f4f4;
+}
+.swagger-ui .stripe-light:nth-child(odd) {
+	background-color: hsla(0, 0%, 100%, 0.1);
+}
+.swagger-ui .stripe-dark:nth-child(odd) {
+	background-color: rgba(0, 0, 0, 0.1);
+}
+.swagger-ui .strike {
+	-webkit-text-decoration: line-through;
+	text-decoration: line-through;
+}
+.swagger-ui .underline {
+	-webkit-text-decoration: underline;
+	text-decoration: underline;
+}
+.swagger-ui .no-underline {
+	-webkit-text-decoration: none;
+	text-decoration: none;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .strike-ns {
+		-webkit-text-decoration: line-through;
+		text-decoration: line-through;
+	}
+	.swagger-ui .underline-ns {
+		-webkit-text-decoration: underline;
+		text-decoration: underline;
+	}
+	.swagger-ui .no-underline-ns {
+		-webkit-text-decoration: none;
+		text-decoration: none;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .strike-m {
+		-webkit-text-decoration: line-through;
+		text-decoration: line-through;
+	}
+	.swagger-ui .underline-m {
+		-webkit-text-decoration: underline;
+		text-decoration: underline;
+	}
+	.swagger-ui .no-underline-m {
+		-webkit-text-decoration: none;
+		text-decoration: none;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .strike-l {
+		-webkit-text-decoration: line-through;
+		text-decoration: line-through;
+	}
+	.swagger-ui .underline-l {
+		-webkit-text-decoration: underline;
+		text-decoration: underline;
+	}
+	.swagger-ui .no-underline-l {
+		-webkit-text-decoration: none;
+		text-decoration: none;
+	}
+}
+.swagger-ui .tl {
+	text-align: left;
+}
+.swagger-ui .tr {
+	text-align: right;
+}
+.swagger-ui .tc {
+	text-align: center;
+}
+.swagger-ui .tj {
+	text-align: justify;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .tl-ns {
+		text-align: left;
+	}
+	.swagger-ui .tr-ns {
+		text-align: right;
+	}
+	.swagger-ui .tc-ns {
+		text-align: center;
+	}
+	.swagger-ui .tj-ns {
+		text-align: justify;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .tl-m {
+		text-align: left;
+	}
+	.swagger-ui .tr-m {
+		text-align: right;
+	}
+	.swagger-ui .tc-m {
+		text-align: center;
+	}
+	.swagger-ui .tj-m {
+		text-align: justify;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .tl-l {
+		text-align: left;
+	}
+	.swagger-ui .tr-l {
+		text-align: right;
+	}
+	.swagger-ui .tc-l {
+		text-align: center;
+	}
+	.swagger-ui .tj-l {
+		text-align: justify;
+	}
+}
+.swagger-ui .ttc {
+	text-transform: capitalize;
+}
+.swagger-ui .ttl {
+	text-transform: lowercase;
+}
+.swagger-ui .ttu {
+	text-transform: uppercase;
+}
+.swagger-ui .ttn {
+	text-transform: none;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .ttc-ns {
+		text-transform: capitalize;
+	}
+	.swagger-ui .ttl-ns {
+		text-transform: lowercase;
+	}
+	.swagger-ui .ttu-ns {
+		text-transform: uppercase;
+	}
+	.swagger-ui .ttn-ns {
+		text-transform: none;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .ttc-m {
+		text-transform: capitalize;
+	}
+	.swagger-ui .ttl-m {
+		text-transform: lowercase;
+	}
+	.swagger-ui .ttu-m {
+		text-transform: uppercase;
+	}
+	.swagger-ui .ttn-m {
+		text-transform: none;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .ttc-l {
+		text-transform: capitalize;
+	}
+	.swagger-ui .ttl-l {
+		text-transform: lowercase;
+	}
+	.swagger-ui .ttu-l {
+		text-transform: uppercase;
+	}
+	.swagger-ui .ttn-l {
+		text-transform: none;
+	}
+}
+.swagger-ui .f-6,
+.swagger-ui .f-headline {
+	font-size: 6rem;
+}
+.swagger-ui .f-5,
+.swagger-ui .f-subheadline {
+	font-size: 5rem;
+}
+.swagger-ui .f1 {
+	font-size: 3rem;
+}
+.swagger-ui .f2 {
+	font-size: 2.25rem;
+}
+.swagger-ui .f3 {
+	font-size: 1.5rem;
+}
+.swagger-ui .f4 {
+	font-size: 1.25rem;
+}
+.swagger-ui .f5 {
+	font-size: 1rem;
+}
+.swagger-ui .f6 {
+	font-size: 0.875rem;
+}
+.swagger-ui .f7 {
+	font-size: 0.75rem;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .f-6-ns,
+	.swagger-ui .f-headline-ns {
+		font-size: 6rem;
+	}
+	.swagger-ui .f-5-ns,
+	.swagger-ui .f-subheadline-ns {
+		font-size: 5rem;
+	}
+	.swagger-ui .f1-ns {
+		font-size: 3rem;
+	}
+	.swagger-ui .f2-ns {
+		font-size: 2.25rem;
+	}
+	.swagger-ui .f3-ns {
+		font-size: 1.5rem;
+	}
+	.swagger-ui .f4-ns {
+		font-size: 1.25rem;
+	}
+	.swagger-ui .f5-ns {
+		font-size: 1rem;
+	}
+	.swagger-ui .f6-ns {
+		font-size: 0.875rem;
+	}
+	.swagger-ui .f7-ns {
+		font-size: 0.75rem;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .f-6-m,
+	.swagger-ui .f-headline-m {
+		font-size: 6rem;
+	}
+	.swagger-ui .f-5-m,
+	.swagger-ui .f-subheadline-m {
+		font-size: 5rem;
+	}
+	.swagger-ui .f1-m {
+		font-size: 3rem;
+	}
+	.swagger-ui .f2-m {
+		font-size: 2.25rem;
+	}
+	.swagger-ui .f3-m {
+		font-size: 1.5rem;
+	}
+	.swagger-ui .f4-m {
+		font-size: 1.25rem;
+	}
+	.swagger-ui .f5-m {
+		font-size: 1rem;
+	}
+	.swagger-ui .f6-m {
+		font-size: 0.875rem;
+	}
+	.swagger-ui .f7-m {
+		font-size: 0.75rem;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .f-6-l,
+	.swagger-ui .f-headline-l {
+		font-size: 6rem;
+	}
+	.swagger-ui .f-5-l,
+	.swagger-ui .f-subheadline-l {
+		font-size: 5rem;
+	}
+	.swagger-ui .f1-l {
+		font-size: 3rem;
+	}
+	.swagger-ui .f2-l {
+		font-size: 2.25rem;
+	}
+	.swagger-ui .f3-l {
+		font-size: 1.5rem;
+	}
+	.swagger-ui .f4-l {
+		font-size: 1.25rem;
+	}
+	.swagger-ui .f5-l {
+		font-size: 1rem;
+	}
+	.swagger-ui .f6-l {
+		font-size: 0.875rem;
+	}
+	.swagger-ui .f7-l {
+		font-size: 0.75rem;
+	}
+}
+.swagger-ui .measure {
+	max-width: 30em;
+}
+.swagger-ui .measure-wide {
+	max-width: 34em;
+}
+.swagger-ui .measure-narrow {
+	max-width: 20em;
+}
+.swagger-ui .indent {
+	margin-bottom: 0;
+	margin-top: 0;
+	text-indent: 1em;
+}
+.swagger-ui .small-caps {
+	font-feature-settings: 'smcp';
+	font-variant: small-caps;
+}
+.swagger-ui .truncate {
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .measure-ns {
+		max-width: 30em;
+	}
+	.swagger-ui .measure-wide-ns {
+		max-width: 34em;
+	}
+	.swagger-ui .measure-narrow-ns {
+		max-width: 20em;
+	}
+	.swagger-ui .indent-ns {
+		margin-bottom: 0;
+		margin-top: 0;
+		text-indent: 1em;
+	}
+	.swagger-ui .small-caps-ns {
+		font-feature-settings: 'smcp';
+		font-variant: small-caps;
+	}
+	.swagger-ui .truncate-ns {
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .measure-m {
+		max-width: 30em;
+	}
+	.swagger-ui .measure-wide-m {
+		max-width: 34em;
+	}
+	.swagger-ui .measure-narrow-m {
+		max-width: 20em;
+	}
+	.swagger-ui .indent-m {
+		margin-bottom: 0;
+		margin-top: 0;
+		text-indent: 1em;
+	}
+	.swagger-ui .small-caps-m {
+		font-feature-settings: 'smcp';
+		font-variant: small-caps;
+	}
+	.swagger-ui .truncate-m {
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .measure-l {
+		max-width: 30em;
+	}
+	.swagger-ui .measure-wide-l {
+		max-width: 34em;
+	}
+	.swagger-ui .measure-narrow-l {
+		max-width: 20em;
+	}
+	.swagger-ui .indent-l {
+		margin-bottom: 0;
+		margin-top: 0;
+		text-indent: 1em;
+	}
+	.swagger-ui .small-caps-l {
+		font-feature-settings: 'smcp';
+		font-variant: small-caps;
+	}
+	.swagger-ui .truncate-l {
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+}
+.swagger-ui .overflow-container {
+	overflow-y: scroll;
+}
+.swagger-ui .center {
+	margin-left: auto;
+	margin-right: auto;
+}
+.swagger-ui .mr-auto {
+	margin-right: auto;
+}
+.swagger-ui .ml-auto {
+	margin-left: auto;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .center-ns {
+		margin-left: auto;
+		margin-right: auto;
+	}
+	.swagger-ui .mr-auto-ns {
+		margin-right: auto;
+	}
+	.swagger-ui .ml-auto-ns {
+		margin-left: auto;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .center-m {
+		margin-left: auto;
+		margin-right: auto;
+	}
+	.swagger-ui .mr-auto-m {
+		margin-right: auto;
+	}
+	.swagger-ui .ml-auto-m {
+		margin-left: auto;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .center-l {
+		margin-left: auto;
+		margin-right: auto;
+	}
+	.swagger-ui .mr-auto-l {
+		margin-right: auto;
+	}
+	.swagger-ui .ml-auto-l {
+		margin-left: auto;
+	}
+}
+.swagger-ui .clip {
+	position: fixed !important;
+	_position: absolute !important;
+	clip: rect(1px 1px 1px 1px);
+	clip: rect(1px, 1px, 1px, 1px);
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .clip-ns {
+		position: fixed !important;
+		_position: absolute !important;
+		clip: rect(1px 1px 1px 1px);
+		clip: rect(1px, 1px, 1px, 1px);
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .clip-m {
+		position: fixed !important;
+		_position: absolute !important;
+		clip: rect(1px 1px 1px 1px);
+		clip: rect(1px, 1px, 1px, 1px);
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .clip-l {
+		position: fixed !important;
+		_position: absolute !important;
+		clip: rect(1px 1px 1px 1px);
+		clip: rect(1px, 1px, 1px, 1px);
+	}
+}
+.swagger-ui .ws-normal {
+	white-space: normal;
+}
+.swagger-ui .nowrap {
+	white-space: nowrap;
+}
+.swagger-ui .pre {
+	white-space: pre;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .ws-normal-ns {
+		white-space: normal;
+	}
+	.swagger-ui .nowrap-ns {
+		white-space: nowrap;
+	}
+	.swagger-ui .pre-ns {
+		white-space: pre;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .ws-normal-m {
+		white-space: normal;
+	}
+	.swagger-ui .nowrap-m {
+		white-space: nowrap;
+	}
+	.swagger-ui .pre-m {
+		white-space: pre;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .ws-normal-l {
+		white-space: normal;
+	}
+	.swagger-ui .nowrap-l {
+		white-space: nowrap;
+	}
+	.swagger-ui .pre-l {
+		white-space: pre;
+	}
+}
+.swagger-ui .v-base {
+	vertical-align: baseline;
+}
+.swagger-ui .v-mid {
+	vertical-align: middle;
+}
+.swagger-ui .v-top {
+	vertical-align: top;
+}
+.swagger-ui .v-btm {
+	vertical-align: bottom;
+}
+@media screen and (min-width: 30em) {
+	.swagger-ui .v-base-ns {
+		vertical-align: baseline;
+	}
+	.swagger-ui .v-mid-ns {
+		vertical-align: middle;
+	}
+	.swagger-ui .v-top-ns {
+		vertical-align: top;
+	}
+	.swagger-ui .v-btm-ns {
+		vertical-align: bottom;
+	}
+}
+@media screen and (min-width: 30em) and (max-width: 60em) {
+	.swagger-ui .v-base-m {
+		vertical-align: baseline;
+	}
+	.swagger-ui .v-mid-m {
+		vertical-align: middle;
+	}
+	.swagger-ui .v-top-m {
+		vertical-align: top;
+	}
+	.swagger-ui .v-btm-m {
+		vertical-align: bottom;
+	}
+}
+@media screen and (min-width: 60em) {
+	.swagger-ui .v-base-l {
+		vertical-align: baseline;
+	}
+	.swagger-ui .v-mid-l {
+		vertical-align: middle;
+	}
+	.swagger-ui .v-top-l {
+		vertical-align: top;
+	}
+	.swagger-ui .v-btm-l {
+		vertical-align: bottom;
+	}
+}
+.swagger-ui .dim {
+	opacity: 1;
+	transition: opacity 0.15s ease-in;
+}
+.swagger-ui .dim:focus,
+.swagger-ui .dim:hover {
+	opacity: 0.5;
+	transition: opacity 0.15s ease-in;
+}
+.swagger-ui .dim:active {
+	opacity: 0.8;
+	transition: opacity 0.15s ease-out;
+}
+.swagger-ui .glow {
+	transition: opacity 0.15s ease-in;
+}
+.swagger-ui .glow:focus,
+.swagger-ui .glow:hover {
+	opacity: 1;
+	transition: opacity 0.15s ease-in;
+}
+.swagger-ui .hide-child .child {
+	opacity: 0;
+	transition: opacity 0.15s ease-in;
+}
+.swagger-ui .hide-child:active .child,
+.swagger-ui .hide-child:focus .child,
+.swagger-ui .hide-child:hover .child {
+	opacity: 1;
+	transition: opacity 0.15s ease-in;
+}
+.swagger-ui .underline-hover:focus,
+.swagger-ui .underline-hover:hover {
+	-webkit-text-decoration: underline;
+	text-decoration: underline;
+}
+.swagger-ui .grow {
+	-moz-osx-font-smoothing: grayscale;
+	backface-visibility: hidden;
+	transform: translateZ(0);
+	transition: transform 0.25s ease-out;
+}
+.swagger-ui .grow:focus,
+.swagger-ui .grow:hover {
+	transform: scale(1.05);
+}
+.swagger-ui .grow:active {
+	transform: scale(0.9);
+}
+.swagger-ui .grow-large {
+	-moz-osx-font-smoothing: grayscale;
+	backface-visibility: hidden;
+	transform: translateZ(0);
+	transition: transform 0.25s ease-in-out;
+}
+.swagger-ui .grow-large:focus,
+.swagger-ui .grow-large:hover {
+	transform: scale(1.2);
+}
+.swagger-ui .grow-large:active {
+	transform: scale(0.95);
+}
+.swagger-ui .pointer:hover {
+	cursor: pointer;
+}
+.swagger-ui .shadow-hover {
+	cursor: pointer;
+	position: relative;
+	transition: all 0.5s cubic-bezier(0.165, 0.84, 0.44, 1);
+}
+.swagger-ui .shadow-hover:after {
+	border-radius: inherit;
+	box-shadow: 0 0 16px 2px rgba(0, 0, 0, 0.2);
+	content: '';
+	height: 100%;
+	left: 0;
+	opacity: 0;
+	position: absolute;
+	top: 0;
+	transition: opacity 0.5s cubic-bezier(0.165, 0.84, 0.44, 1);
+	width: 100%;
+	z-index: -1;
+}
+.swagger-ui .shadow-hover:focus:after,
+.swagger-ui .shadow-hover:hover:after {
+	opacity: 1;
+}
+.swagger-ui .bg-animate,
+.swagger-ui .bg-animate:focus,
+.swagger-ui .bg-animate:hover {
+	transition: background-color 0.15s ease-in-out;
+}
+.swagger-ui .z-0 {
+	z-index: 0;
+}
+.swagger-ui .z-1 {
+	z-index: 1;
+}
+.swagger-ui .z-2 {
+	z-index: 2;
+}
+.swagger-ui .z-3 {
+	z-index: 3;
+}
+.swagger-ui .z-4 {
+	z-index: 4;
+}
+.swagger-ui .z-5 {
+	z-index: 5;
+}
+.swagger-ui .z-999 {
+	z-index: 999;
+}
+.swagger-ui .z-9999 {
+	z-index: 9999;
+}
+.swagger-ui .z-max {
+	z-index: 2147483647;
+}
+.swagger-ui .z-inherit {
+	z-index: inherit;
+}
+.swagger-ui .z-initial,
+.swagger-ui .z-unset {
+	z-index: auto;
+}
+.swagger-ui .nested-copy-line-height ol,
+.swagger-ui .nested-copy-line-height p,
+.swagger-ui .nested-copy-line-height ul {
+	line-height: 1.5;
+}
+.swagger-ui .nested-headline-line-height h1,
+.swagger-ui .nested-headline-line-height h2,
+.swagger-ui .nested-headline-line-height h3,
+.swagger-ui .nested-headline-line-height h4,
+.swagger-ui .nested-headline-line-height h5,
+.swagger-ui .nested-headline-line-height h6 {
+	line-height: 1.25;
+}
+.swagger-ui .nested-list-reset ol,
+.swagger-ui .nested-list-reset ul {
+	list-style-type: none;
+	margin-left: 0;
+	padding-left: 0;
+}
+.swagger-ui .nested-copy-indent p + p {
+	margin-bottom: 0;
+	margin-top: 0;
+	text-indent: 0.1em;
+}
+.swagger-ui .nested-copy-seperator p + p {
+	margin-top: 1.5em;
+}
+.swagger-ui .nested-img img {
+	display: block;
+	max-width: 100%;
+	width: 100%;
+}
+.swagger-ui .nested-links a {
+	color: #357edd;
+	transition: color 0.15s ease-in;
+}
+.swagger-ui .nested-links a:focus,
+.swagger-ui .nested-links a:hover {
+	color: #96ccff;
+	transition: color 0.15s ease-in;
+}
+.swagger-ui .wrapper {
+	box-sizing: border-box;
+	margin: 0 auto;
+	max-width: 1460px;
+	padding: 0 20px;
+	width: 100%;
+}
+.swagger-ui .opblock-tag-section {
+	display: flex;
+	flex-direction: column;
+}
+.swagger-ui .try-out.btn-group {
+	display: flex;
+	flex: 0.1 2 auto;
+	padding: 0;
+}
+.swagger-ui .try-out__btn {
+	margin-left: 1.25rem;
+}
+.swagger-ui .opblock-tag {
+	align-items: center;
+	border-bottom: 1px solid rgba(59, 65, 81, 0.3);
+	cursor: pointer;
+	display: flex;
+	padding: 10px 20px 10px 10px;
+	transition: all 0.2s;
+}
+.swagger-ui .opblock-tag:hover {
+	background: rgba(0, 0, 0, 0.02);
+}
+.swagger-ui .opblock-tag {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 24px;
+	margin: 0 0 5px;
+}
+.swagger-ui .opblock-tag.no-desc span {
+	flex: 1;
+}
+.swagger-ui .opblock-tag svg {
+	transition: all 0.4s;
+}
+.swagger-ui .opblock-tag small {
+	color: #3b4151;
+	flex: 2;
+	font-family: sans-serif;
+	font-size: 14px;
+	font-weight: 400;
+	padding: 0 10px;
+}
+.swagger-ui .opblock-tag > div {
+	flex: 1 1 150px;
+	font-weight: 400;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+@media (max-width: 640px) {
+	.swagger-ui .opblock-tag small,
+	.swagger-ui .opblock-tag > div {
+		flex: 1;
+	}
+}
+.swagger-ui .opblock-tag .info__externaldocs {
+	text-align: right;
+}
+.swagger-ui .parameter__type {
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 600;
+	padding: 5px 0;
+}
+.swagger-ui .parameter-controls {
+	margin-top: 0.75em;
+}
+.swagger-ui .examples__title {
+	display: block;
+	font-size: 1.1em;
+	font-weight: 700;
+	margin-bottom: 0.75em;
+}
+.swagger-ui .examples__section {
+	margin-top: 1.5em;
+}
+.swagger-ui .examples__section-header {
+	font-size: 0.9rem;
+	font-weight: 700;
+	margin-bottom: 0.5rem;
+}
+.swagger-ui .examples-select {
+	display: inline-block;
+	margin-bottom: 0.75em;
+}
+.swagger-ui .examples-select .examples-select-element {
+	width: 100%;
+}
+.swagger-ui .examples-select__section-label {
+	font-size: 0.9rem;
+	font-weight: 700;
+	margin-right: 0.5rem;
+}
+.swagger-ui .example__section {
+	margin-top: 1.5em;
+}
+.swagger-ui .example__section-header {
+	font-size: 0.9rem;
+	font-weight: 700;
+	margin-bottom: 0.5rem;
+}
+.swagger-ui .view-line-link {
+	cursor: pointer;
+	margin: 0 5px;
+	position: relative;
+	top: 3px;
+	transition: all 0.5s;
+	width: 20px;
+}
+.swagger-ui .opblock {
+	border: 1px solid #000;
+	border-radius: 4px;
+	box-shadow: 0 0 3px rgba(0, 0, 0, 0.19);
+	margin: 0 0 15px;
+}
+.swagger-ui .opblock .tab-header {
+	display: flex;
+	flex: 1;
+}
+.swagger-ui .opblock .tab-header .tab-item {
+	cursor: pointer;
+	padding: 0 40px;
+}
+.swagger-ui .opblock .tab-header .tab-item:first-of-type {
+	padding: 0 40px 0 0;
+}
+.swagger-ui .opblock .tab-header .tab-item.active h4 span {
+	position: relative;
+}
+.swagger-ui .opblock .tab-header .tab-item.active h4 span:after {
+	background: grey;
+	bottom: -15px;
+	content: '';
+	height: 4px;
+	left: 50%;
+	position: absolute;
+	transform: translateX(-50%);
+	width: 120%;
+}
+.swagger-ui .opblock.is-open .opblock-summary {
+	border-bottom: 1px solid #000;
+}
+.swagger-ui .opblock .opblock-section-header {
+	align-items: center;
+	background: hsla(0, 0%, 100%, 0.8);
+	box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+	display: flex;
+	min-height: 50px;
+	padding: 8px 20px;
+}
+.swagger-ui .opblock .opblock-section-header > label {
+	align-items: center;
+	color: #3b4151;
+	display: flex;
+	font-family: sans-serif;
+	font-size: 12px;
+	font-weight: 700;
+	margin: 0 0 0 auto;
+}
+.swagger-ui .opblock .opblock-section-header > label > span {
+	padding: 0 10px 0 0;
+}
+.swagger-ui .opblock .opblock-section-header h4 {
+	color: #3b4151;
+	flex: 1;
+	font-family: sans-serif;
+	font-size: 14px;
+	margin: 0;
+}
+.swagger-ui .opblock .opblock-summary-method {
+	background: #000;
+	border-radius: 3px;
+	color: #fff;
+	font-family: sans-serif;
+	font-size: 14px;
+	font-weight: 700;
+	min-width: 80px;
+	padding: 6px 0;
+	text-align: center;
+	text-shadow: 0 1px 0 rgba(0, 0, 0, 0.1);
+}
+@media (max-width: 768px) {
+	.swagger-ui .opblock .opblock-summary-method {
+		font-size: 12px;
+	}
+}
+.swagger-ui .opblock .opblock-summary-operation-id,
+.swagger-ui .opblock .opblock-summary-path,
+.swagger-ui .opblock .opblock-summary-path__deprecated {
+	align-items: center;
+	color: #3b4151;
+	display: flex;
+	font-family: monospace;
+	font-size: 16px;
+	font-weight: 600;
+	word-break: break-word;
+}
+@media (max-width: 768px) {
+	.swagger-ui .opblock .opblock-summary-operation-id,
+	.swagger-ui .opblock .opblock-summary-path,
+	.swagger-ui .opblock .opblock-summary-path__deprecated {
+		font-size: 12px;
+	}
+}
+.swagger-ui .opblock .opblock-summary-path {
+	flex-shrink: 1;
+}
+@media (max-width: 640px) {
+	.swagger-ui .opblock .opblock-summary-path {
+		max-width: 100%;
+	}
+}
+.swagger-ui .opblock .opblock-summary-path__deprecated {
+	-webkit-text-decoration: line-through;
+	text-decoration: line-through;
+}
+.swagger-ui .opblock .opblock-summary-operation-id {
+	font-size: 14px;
+}
+.swagger-ui .opblock .opblock-summary-description {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 13px;
+	word-break: break-word;
+}
+.swagger-ui .opblock .opblock-summary-path-description-wrapper {
+	align-items: center;
+	display: flex;
+	flex-direction: row;
+	flex-wrap: wrap;
+	gap: 0 10px;
+	padding: 0 10px;
+	width: 100%;
+}
+@media (max-width: 550px) {
+	.swagger-ui .opblock .opblock-summary-path-description-wrapper {
+		align-items: flex-start;
+		flex-direction: column;
+	}
+}
+.swagger-ui .opblock .opblock-summary {
+	align-items: center;
+	cursor: pointer;
+	display: flex;
+	padding: 5px;
+}
+.swagger-ui .opblock .opblock-summary .view-line-link {
+	cursor: pointer;
+	margin: 0;
+	position: relative;
+	top: 2px;
+	transition: all 0.5s;
+	width: 0;
+}
+.swagger-ui .opblock .opblock-summary:hover .view-line-link {
+	margin: 0 5px;
+	width: 18px;
+}
+.swagger-ui .opblock .opblock-summary:hover .view-line-link.copy-to-clipboard {
+	width: 24px;
+}
+.swagger-ui .opblock.opblock-post {
+	background: rgba(73, 204, 144, 0.1);
+	border-color: #49cc90;
+}
+.swagger-ui .opblock.opblock-post .opblock-summary-method {
+	background: #49cc90;
+}
+.swagger-ui .opblock.opblock-post .opblock-summary {
+	border-color: #49cc90;
+}
+.swagger-ui .opblock.opblock-post .tab-header .tab-item.active h4 span:after {
+	background: #49cc90;
+}
+.swagger-ui .opblock.opblock-put {
+	background: rgba(252, 161, 48, 0.1);
+	border-color: #fca130;
+}
+.swagger-ui .opblock.opblock-put .opblock-summary-method {
+	background: #fca130;
+}
+.swagger-ui .opblock.opblock-put .opblock-summary {
+	border-color: #fca130;
+}
+.swagger-ui .opblock.opblock-put .tab-header .tab-item.active h4 span:after {
+	background: #fca130;
+}
+.swagger-ui .opblock.opblock-delete {
+	background: rgba(249, 62, 62, 0.1);
+	border-color: #f93e3e;
+}
+.swagger-ui .opblock.opblock-delete .opblock-summary-method {
+	background: #f93e3e;
+}
+.swagger-ui .opblock.opblock-delete .opblock-summary {
+	border-color: #f93e3e;
+}
+.swagger-ui .opblock.opblock-delete .tab-header .tab-item.active h4 span:after {
+	background: #f93e3e;
+}
+.swagger-ui .opblock.opblock-get {
+	background: rgba(97, 175, 254, 0.1);
+	border-color: #61affe;
+}
+.swagger-ui .opblock.opblock-get .opblock-summary-method {
+	background: #61affe;
+}
+.swagger-ui .opblock.opblock-get .opblock-summary {
+	border-color: #61affe;
+}
+.swagger-ui .opblock.opblock-get .tab-header .tab-item.active h4 span:after {
+	background: #61affe;
+}
+.swagger-ui .opblock.opblock-patch {
+	background: rgba(80, 227, 194, 0.1);
+	border-color: #50e3c2;
+}
+.swagger-ui .opblock.opblock-patch .opblock-summary-method {
+	background: #50e3c2;
+}
+.swagger-ui .opblock.opblock-patch .opblock-summary {
+	border-color: #50e3c2;
+}
+.swagger-ui .opblock.opblock-patch .tab-header .tab-item.active h4 span:after {
+	background: #50e3c2;
+}
+.swagger-ui .opblock.opblock-head {
+	background: rgba(144, 18, 254, 0.1);
+	border-color: #9012fe;
+}
+.swagger-ui .opblock.opblock-head .opblock-summary-method {
+	background: #9012fe;
+}
+.swagger-ui .opblock.opblock-head .opblock-summary {
+	border-color: #9012fe;
+}
+.swagger-ui .opblock.opblock-head .tab-header .tab-item.active h4 span:after {
+	background: #9012fe;
+}
+.swagger-ui .opblock.opblock-options {
+	background: rgba(13, 90, 167, 0.1);
+	border-color: #0d5aa7;
+}
+.swagger-ui .opblock.opblock-options .opblock-summary-method {
+	background: #0d5aa7;
+}
+.swagger-ui .opblock.opblock-options .opblock-summary {
+	border-color: #0d5aa7;
+}
+.swagger-ui .opblock.opblock-options .tab-header .tab-item.active h4 span:after {
+	background: #0d5aa7;
+}
+.swagger-ui .opblock.opblock-deprecated {
+	background: hsla(0, 0%, 92%, 0.1);
+	border-color: #ebebeb;
+	opacity: 0.6;
+}
+.swagger-ui .opblock.opblock-deprecated .opblock-summary-method {
+	background: #ebebeb;
+}
+.swagger-ui .opblock.opblock-deprecated .opblock-summary {
+	border-color: #ebebeb;
+}
+.swagger-ui .opblock.opblock-deprecated .tab-header .tab-item.active h4 span:after {
+	background: #ebebeb;
+}
+.swagger-ui .opblock .opblock-schemes {
+	padding: 8px 20px;
+}
+.swagger-ui .opblock .opblock-schemes .schemes-title {
+	padding: 0 10px 0 0;
+}
+.swagger-ui .filter .operation-filter-input {
+	border: 2px solid #d8dde7;
+	margin: 20px 0;
+	padding: 10px;
+	width: 100%;
+}
+.swagger-ui .download-url-wrapper .failed,
+.swagger-ui .filter .failed {
+	color: red;
+}
+.swagger-ui .download-url-wrapper .loading,
+.swagger-ui .filter .loading {
+	color: #aaa;
+}
+.swagger-ui .model-example {
+	margin-top: 1em;
+}
+.swagger-ui .tab {
+	display: flex;
+	list-style: none;
+	padding: 0;
+}
+.swagger-ui .tab li {
+	color: #3b4151;
+	cursor: pointer;
+	font-family: sans-serif;
+	font-size: 12px;
+	min-width: 60px;
+	padding: 0;
+}
+.swagger-ui .tab li:first-of-type {
+	padding-left: 0;
+	padding-right: 12px;
+	position: relative;
+}
+.swagger-ui .tab li:first-of-type:after {
+	background: rgba(0, 0, 0, 0.2);
+	content: '';
+	height: 100%;
+	position: absolute;
+	right: 6px;
+	top: 0;
+	width: 1px;
+}
+.swagger-ui .tab li.active {
+	font-weight: 700;
+}
+.swagger-ui .tab li button.tablinks {
+	background: none;
+	border: 0;
+	color: inherit;
+	font-family: inherit;
+	font-weight: inherit;
+	padding: 0;
+}
+.swagger-ui .opblock-description-wrapper,
+.swagger-ui .opblock-external-docs-wrapper,
+.swagger-ui .opblock-title_normal {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 12px;
+	margin: 0 0 5px;
+	padding: 15px 20px;
+}
+.swagger-ui .opblock-description-wrapper h4,
+.swagger-ui .opblock-external-docs-wrapper h4,
+.swagger-ui .opblock-title_normal h4 {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 12px;
+	margin: 0 0 5px;
+}
+.swagger-ui .opblock-description-wrapper p,
+.swagger-ui .opblock-external-docs-wrapper p,
+.swagger-ui .opblock-title_normal p {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 14px;
+	margin: 0;
+}
+.swagger-ui .opblock-external-docs-wrapper h4 {
+	padding-left: 0;
+}
+.swagger-ui .execute-wrapper {
+	padding: 20px;
+	text-align: right;
+}
+.swagger-ui .execute-wrapper .btn {
+	padding: 8px 40px;
+	width: 100%;
+}
+.swagger-ui .body-param-options {
+	display: flex;
+	flex-direction: column;
+}
+.swagger-ui .body-param-options .body-param-edit {
+	padding: 10px 0;
+}
+.swagger-ui .body-param-options label {
+	padding: 8px 0;
+}
+.swagger-ui .body-param-options label select {
+	margin: 3px 0 0;
+}
+.swagger-ui .responses-inner {
+	padding: 20px;
+}
+.swagger-ui .responses-inner h4,
+.swagger-ui .responses-inner h5 {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 12px;
+	margin: 10px 0 5px;
+}
+.swagger-ui .responses-inner .curl {
+	max-height: 400px;
+	min-height: 6em;
+	overflow-y: auto;
+}
+.swagger-ui .response-col_status {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 14px;
+}
+.swagger-ui .response-col_status .response-undocumented {
+	color: #909090;
+	font-family: monospace;
+	font-size: 11px;
+	font-weight: 600;
+}
+.swagger-ui .response-col_links {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 14px;
+	max-width: 40em;
+	padding-left: 2em;
+}
+.swagger-ui .response-col_links .response-undocumented {
+	color: #909090;
+	font-family: monospace;
+	font-size: 11px;
+	font-weight: 600;
+}
+.swagger-ui .response-col_links .operation-link {
+	margin-bottom: 1.5em;
+}
+.swagger-ui .response-col_links .operation-link .description {
+	margin-bottom: 0.5em;
+}
+.swagger-ui .opblock-body .opblock-loading-animation {
+	display: block;
+	margin: 3em auto;
+}
+.swagger-ui .opblock-body pre.microlight {
+	background: #333;
+	border-radius: 4px;
+	font-size: 12px;
+	hyphens: auto;
+	margin: 0;
+	padding: 10px;
+	white-space: pre-wrap;
+	word-break: break-all;
+	word-break: break-word;
+	word-wrap: break-word;
+	color: #fff;
+	font-family: monospace;
+	font-weight: 600;
+}
+.swagger-ui .opblock-body pre.microlight .headerline {
+	display: block;
+}
+.swagger-ui .highlight-code {
+	position: relative;
+}
+.swagger-ui .highlight-code > .microlight {
+	max-height: 400px;
+	min-height: 6em;
+	overflow-y: auto;
+}
+.swagger-ui .highlight-code > .microlight code {
+	white-space: pre-wrap !important;
+	word-break: break-all;
+}
+.swagger-ui .curl-command {
+	position: relative;
+}
+.swagger-ui .download-contents {
+	align-items: center;
+	background: #7d8293;
+	border: none;
+	border-radius: 4px;
+	bottom: 10px;
+	color: #fff;
+	display: flex;
+	font-family: sans-serif;
+	font-size: 14px;
+	font-weight: 600;
+	height: 30px;
+	justify-content: center;
+	padding: 5px;
+	position: absolute;
+	right: 10px;
+	text-align: center;
+}
+.swagger-ui .scheme-container {
+	background: #fff;
+	box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.15);
+	margin: 0 0 20px;
+	padding: 30px 0;
+}
+.swagger-ui .scheme-container .schemes {
+	align-items: flex-end;
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+	justify-content: space-between;
+}
+.swagger-ui .scheme-container .schemes > .schemes-server-container {
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+}
+.swagger-ui .scheme-container .schemes > .schemes-server-container > label {
+	color: #3b4151;
+	display: flex;
+	flex-direction: column;
+	font-family: sans-serif;
+	font-size: 12px;
+	font-weight: 700;
+	margin: -20px 15px 0 0;
+}
+.swagger-ui .scheme-container .schemes > .schemes-server-container > label select {
+	min-width: 130px;
+	text-transform: uppercase;
+}
+.swagger-ui .scheme-container .schemes:not(:has(.schemes-server-container)) {
+	justify-content: flex-end;
+}
+.swagger-ui .scheme-container .schemes .auth-wrapper {
+	flex: none;
+	justify-content: start;
+}
+.swagger-ui .scheme-container .schemes .auth-wrapper .authorize {
+	display: flex;
+	flex-wrap: nowrap;
+	margin: 0;
+	padding-right: 20px;
+}
+.swagger-ui .loading-container {
+	align-items: center;
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+	margin-top: 1em;
+	min-height: 1px;
+	padding: 40px 0 60px;
+}
+.swagger-ui .loading-container .loading {
+	position: relative;
+}
+.swagger-ui .loading-container .loading:after {
+	color: #3b4151;
+	content: 'loading';
+	font-family: sans-serif;
+	font-size: 10px;
+	font-weight: 700;
+	left: 50%;
+	position: absolute;
+	text-transform: uppercase;
+	top: 50%;
+	transform: translate(-50%, -50%);
+}
+.swagger-ui .loading-container .loading:before {
+	animation:
+		rotation 1s linear infinite,
+		opacity 0.5s;
+	backface-visibility: hidden;
+	border: 2px solid rgba(85, 85, 85, 0.1);
+	border-radius: 100%;
+	border-top-color: rgba(0, 0, 0, 0.6);
+	content: '';
+	display: block;
+	height: 60px;
+	left: 50%;
+	margin: -30px;
+	opacity: 1;
+	position: absolute;
+	top: 50%;
+	width: 60px;
+}
+@keyframes rotation {
+	to {
+		transform: rotate(1turn);
+	}
+}
+.swagger-ui .response-controls {
+	display: flex;
+	padding-top: 1em;
+}
+.swagger-ui .response-control-media-type {
+	margin-right: 1em;
+}
+.swagger-ui .response-control-media-type--accept-controller select {
+	border-color: green;
+}
+.swagger-ui .response-control-media-type__accept-message {
+	color: green;
+	font-size: 0.7em;
+}
+.swagger-ui .response-control-examples__title,
+.swagger-ui .response-control-media-type__title {
+	display: block;
+	font-size: 0.7em;
+	margin-bottom: 0.2em;
+}
+@keyframes blinker {
+	50% {
+		opacity: 0;
+	}
+}
+.swagger-ui .hidden {
+	display: none;
+}
+.swagger-ui .no-margin {
+	border: none;
+	height: auto;
+	margin: 0;
+	padding: 0;
+}
+.swagger-ui .float-right {
+	float: right;
+}
+.swagger-ui .svg-assets {
+	height: 0;
+	position: absolute;
+	width: 0;
+}
+.swagger-ui section h3 {
+	color: #3b4151;
+	font-family: sans-serif;
+}
+.swagger-ui a.nostyle {
+	display: inline;
+}
+.swagger-ui a.nostyle,
+.swagger-ui a.nostyle:visited {
+	color: inherit;
+	cursor: pointer;
+	text-decoration: inherit;
+}
+.swagger-ui .fallback {
+	color: #aaa;
+	padding: 1em;
+}
+.swagger-ui .version-pragma {
+	height: 100%;
+	padding: 5em 0;
+}
+.swagger-ui .version-pragma__message {
+	display: flex;
+	font-size: 1.2em;
+	height: 100%;
+	justify-content: center;
+	line-height: 1.5em;
+	padding: 0 0.6em;
+	text-align: center;
+}
+.swagger-ui .version-pragma__message > div {
+	flex: 1;
+	max-width: 55ch;
+}
+.swagger-ui .version-pragma__message code {
+	background-color: #dedede;
+	padding: 4px 4px 2px;
+	white-space: pre;
+}
+.swagger-ui .opblock-link {
+	font-weight: 400;
+}
+.swagger-ui .opblock-link.shown {
+	font-weight: 700;
+}
+.swagger-ui span.token-string {
+	color: #555;
+}
+.swagger-ui span.token-not-formatted {
+	color: #555;
+	font-weight: 700;
+}
+.swagger-ui .btn {
+	background: transparent;
+	border: 2px solid grey;
+	border-radius: 4px;
+	box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 14px;
+	font-weight: 700;
+	padding: 5px 23px;
+	transition: all 0.3s;
+}
+.swagger-ui .btn.btn-sm {
+	font-size: 12px;
+	padding: 4px 23px;
+}
+.swagger-ui .btn[disabled] {
+	cursor: not-allowed;
+	opacity: 0.3;
+}
+.swagger-ui .btn:hover {
+	box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
+}
+.swagger-ui .btn.cancel {
+	background-color: transparent;
+	border-color: #ff6060;
+	color: #ff6060;
+	font-family: sans-serif;
+}
+.swagger-ui .btn.authorize {
+	background-color: transparent;
+	border-color: #49cc90;
+	color: #49cc90;
+	display: inline;
+	line-height: 1;
+}
+.swagger-ui .btn.authorize span {
+	float: left;
+	padding: 4px 20px 0 0;
+}
+.swagger-ui .btn.authorize svg {
+	fill: #49cc90;
+}
+.swagger-ui .btn.execute {
+	background-color: #4990e2;
+	border-color: #4990e2;
+	color: #fff;
+}
+.swagger-ui .btn-group {
+	display: flex;
+	padding: 30px;
+}
+.swagger-ui .btn-group .btn {
+	flex: 1;
+}
+.swagger-ui .btn-group .btn:first-child {
+	border-radius: 4px 0 0 4px;
+}
+.swagger-ui .btn-group .btn:last-child {
+	border-radius: 0 4px 4px 0;
+}
+.swagger-ui .authorization__btn {
+	background: none;
+	border: none;
+	padding: 0 0 0 10px;
+}
+.swagger-ui .authorization__btn .locked {
+	opacity: 1;
+}
+.swagger-ui .authorization__btn .unlocked {
+	opacity: 0.4;
+}
+.swagger-ui .model-box-control,
+.swagger-ui .models-control,
+.swagger-ui .opblock-summary-control {
+	all: inherit;
+	border-bottom: 0;
+	cursor: pointer;
+	flex: 1;
+	padding: 0;
+}
+.swagger-ui .model-box-control:focus,
+.swagger-ui .models-control:focus,
+.swagger-ui .opblock-summary-control:focus {
+	outline: auto;
+}
+.swagger-ui .expand-methods,
+.swagger-ui .expand-operation {
+	background: none;
+	border: none;
+}
+.swagger-ui .expand-methods svg,
+.swagger-ui .expand-operation svg {
+	height: 20px;
+	width: 20px;
+}
+.swagger-ui .expand-methods {
+	padding: 0 10px;
+}
+.swagger-ui .expand-methods:hover svg {
+	fill: #404040;
+}
+.swagger-ui .expand-methods svg {
+	transition: all 0.3s;
+	fill: #707070;
+}
+.swagger-ui button {
+	cursor: pointer;
+}
+.swagger-ui button.invalid {
+	animation: shake 0.4s 1;
+	background: #feebeb;
+	border-color: #f93e3e;
+}
+.swagger-ui .copy-to-clipboard {
+	align-items: center;
+	background: #7d8293;
+	border: none;
+	border-radius: 4px;
+	bottom: 10px;
+	display: flex;
+	height: 30px;
+	justify-content: center;
+	position: absolute;
+	right: 100px;
+	width: 30px;
+}
+.swagger-ui .copy-to-clipboard button {
+	background: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="15" aria-hidden="true"><path fill="%23fff" fill-rule="evenodd" d="M4 12h4v1H4zm5-6H4v1h5zm2 3V7l-3 3 3 3v-2h5V9zM6.5 8H4v1h2.5zM4 11h2.5v-1H4zm9 1h1v2c-.02.28-.11.52-.3.7s-.42.28-.7.3H3c-.55 0-1-.45-1-1V3c0-.55.45-1 1-1h3c0-1.11.89-2 2-2s2 .89 2 2h3c.55 0 1 .45 1 1v5h-1V5H3v9h10zM4 4h8c0-.55-.45-1-1-1h-1c-.55 0-1-.45-1-1s-.45-1-1-1-1 .45-1 1-.45 1-1 1H5c-.55 0-1 .45-1 1"/></svg>')
+		50% no-repeat;
+	border: none;
+	flex-grow: 1;
+	flex-shrink: 1;
+	height: 25px;
+}
+.swagger-ui .copy-to-clipboard:active {
+	background: #5e626f;
+}
+.swagger-ui .opblock-control-arrow {
+	background: none;
+	border: none;
+	text-align: center;
+}
+.swagger-ui .curl-command .copy-to-clipboard {
+	bottom: 5px;
+	height: 20px;
+	right: 10px;
+	width: 20px;
+}
+.swagger-ui .curl-command .copy-to-clipboard button {
+	height: 18px;
+}
+.swagger-ui .opblock .opblock-summary .view-line-link.copy-to-clipboard {
+	height: 26px;
+	position: static;
+}
+.swagger-ui select {
+	-webkit-appearance: none;
+	-moz-appearance: none;
+	appearance: none;
+	background: #f7f7f7
+		url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"><path d="M13.418 7.859a.695.695 0 0 1 .978 0 .68.68 0 0 1 0 .969l-3.908 3.83a.697.697 0 0 1-.979 0l-3.908-3.83a.68.68 0 0 1 0-.969.695.695 0 0 1 .978 0L10 11z"/></svg>')
+		right 10px center no-repeat;
+	background-size: 20px;
+	border: 2px solid #41444e;
+	border-radius: 4px;
+	box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.25);
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 14px;
+	font-weight: 700;
+	padding: 5px 40px 5px 10px;
+}
+.swagger-ui select[multiple] {
+	background: #f7f7f7;
+	margin: 5px 0;
+	padding: 5px;
+}
+.swagger-ui select.invalid {
+	animation: shake 0.4s 1;
+	background: #feebeb;
+	border-color: #f93e3e;
+}
+.swagger-ui .opblock-body select {
+	min-width: 230px;
+}
+@media (max-width: 768px) {
+	.swagger-ui .opblock-body select {
+		min-width: 180px;
+	}
+}
+@media (max-width: 640px) {
+	.swagger-ui .opblock-body select {
+		min-width: 100%;
+		width: 100%;
+	}
+}
+.swagger-ui label {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 12px;
+	font-weight: 700;
+	margin: 0 0 5px;
+}
+.swagger-ui input[type='email'],
+.swagger-ui input[type='file'],
+.swagger-ui input[type='password'],
+.swagger-ui input[type='search'],
+.swagger-ui input[type='text'] {
+	line-height: 1;
+}
+@media (max-width: 768px) {
+	.swagger-ui input[type='email'],
+	.swagger-ui input[type='file'],
+	.swagger-ui input[type='password'],
+	.swagger-ui input[type='search'],
+	.swagger-ui input[type='text'] {
+		max-width: 175px;
+	}
+}
+.swagger-ui input[type='email'],
+.swagger-ui input[type='file'],
+.swagger-ui input[type='password'],
+.swagger-ui input[type='search'],
+.swagger-ui input[type='text'],
+.swagger-ui textarea {
+	background: #fff;
+	border: 1px solid #d9d9d9;
+	border-radius: 4px;
+	margin: 5px 0;
+	min-width: 100px;
+	padding: 8px 10px;
+}
+.swagger-ui input[type='email'].invalid,
+.swagger-ui input[type='file'].invalid,
+.swagger-ui input[type='password'].invalid,
+.swagger-ui input[type='search'].invalid,
+.swagger-ui input[type='text'].invalid,
+.swagger-ui textarea.invalid {
+	animation: shake 0.4s 1;
+	background: #feebeb;
+	border-color: #f93e3e;
+}
+.swagger-ui input[disabled],
+.swagger-ui select[disabled],
+.swagger-ui textarea[disabled] {
+	background-color: #fafafa;
+	color: #888;
+	cursor: not-allowed;
+}
+.swagger-ui select[disabled] {
+	border-color: #888;
+}
+.swagger-ui textarea[disabled] {
+	background-color: #41444e;
+	color: #fff;
+}
+@keyframes shake {
+	10%,
+	90% {
+		transform: translate3d(-1px, 0, 0);
+	}
+	20%,
+	80% {
+		transform: translate3d(2px, 0, 0);
+	}
+	30%,
+	50%,
+	70% {
+		transform: translate3d(-4px, 0, 0);
+	}
+	40%,
+	60% {
+		transform: translate3d(4px, 0, 0);
+	}
+}
+.swagger-ui textarea {
+	background: hsla(0, 0%, 100%, 0.8);
+	border: none;
+	border-radius: 4px;
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 600;
+	min-height: 280px;
+	outline: none;
+	padding: 10px;
+	width: 100%;
+}
+.swagger-ui textarea:focus {
+	border: 2px solid #61affe;
+}
+.swagger-ui textarea.curl {
+	background: #41444e;
+	border-radius: 4px;
+	color: #fff;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 600;
+	margin: 0;
+	min-height: 100px;
+	padding: 10px;
+	resize: none;
+}
+.swagger-ui .checkbox {
+	color: #303030;
+	padding: 5px 0 10px;
+	transition: opacity 0.5s;
+}
+.swagger-ui .checkbox label {
+	display: flex;
+}
+.swagger-ui .checkbox p {
+	color: #3b4151;
+	font-family: monospace;
+	font-style: italic;
+	font-weight: 400 !important;
+	font-weight: 600;
+	margin: 0 !important;
+}
+.swagger-ui .checkbox input[type='checkbox'] {
+	display: none;
+}
+.swagger-ui .checkbox input[type='checkbox'] + label > .item {
+	background: #e8e8e8;
+	border-radius: 1px;
+	box-shadow: 0 0 0 2px #e8e8e8;
+	cursor: pointer;
+	display: inline-block;
+	flex: none;
+	height: 16px;
+	margin: 0 8px 0 0;
+	padding: 5px;
+	position: relative;
+	top: 3px;
+	width: 16px;
+}
+.swagger-ui .checkbox input[type='checkbox'] + label > .item:active {
+	transform: scale(0.9);
+}
+.swagger-ui .checkbox input[type='checkbox']:checked + label > .item {
+	background: #e8e8e8
+		url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="10" height="8" viewBox="3 7 10 8"><path fill="%2341474E" fill-rule="evenodd" d="M6.333 15 3 11.667l1.333-1.334 2 2L11.667 7 13 8.333z"/></svg>')
+		50% no-repeat;
+}
+.swagger-ui .dialog-ux {
+	bottom: 0;
+	left: 0;
+	position: fixed;
+	right: 0;
+	top: 0;
+	z-index: 9999;
+}
+.swagger-ui .dialog-ux .backdrop-ux {
+	background: rgba(0, 0, 0, 0.8);
+	bottom: 0;
+	left: 0;
+	position: fixed;
+	right: 0;
+	top: 0;
+}
+.swagger-ui .dialog-ux .modal-ux {
+	background: #fff;
+	border: 1px solid #ebebeb;
+	border-radius: 4px;
+	box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.2);
+	left: 50%;
+	max-width: 650px;
+	min-width: 300px;
+	position: absolute;
+	top: 50%;
+	transform: translate(-50%, -50%);
+	width: 100%;
+	z-index: 9999;
+}
+.swagger-ui .dialog-ux .modal-ux-content {
+	max-height: 540px;
+	overflow-y: auto;
+	padding: 20px;
+}
+.swagger-ui .dialog-ux .modal-ux-content p {
+	color: #41444e;
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 12px;
+	margin: 0 0 5px;
+}
+.swagger-ui .dialog-ux .modal-ux-content h4 {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 18px;
+	font-weight: 600;
+	margin: 15px 0 0;
+}
+.swagger-ui .dialog-ux .modal-ux-header {
+	align-items: center;
+	border-bottom: 1px solid #ebebeb;
+	display: flex;
+	padding: 12px 0;
+}
+.swagger-ui .dialog-ux .modal-ux-header .close-modal {
+	-webkit-appearance: none;
+	-moz-appearance: none;
+	appearance: none;
+	background: none;
+	border: none;
+	padding: 0 10px;
+}
+.swagger-ui .dialog-ux .modal-ux-header h3 {
+	color: #3b4151;
+	flex: 1;
+	font-family: sans-serif;
+	font-size: 20px;
+	font-weight: 600;
+	margin: 0;
+	padding: 0 20px;
+}
+.swagger-ui .model {
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 300;
+	font-weight: 600;
+}
+.swagger-ui .model .deprecated span,
+.swagger-ui .model .deprecated td {
+	color: #a0a0a0 !important;
+}
+.swagger-ui .model .deprecated > td:first-of-type {
+	-webkit-text-decoration: line-through;
+	text-decoration: line-through;
+}
+.swagger-ui .model-toggle {
+	cursor: pointer;
+	display: inline-block;
+	font-size: 10px;
+	margin: auto 0.3em;
+	position: relative;
+	top: 6px;
+	transform: rotate(90deg);
+	transform-origin: 50% 50%;
+	transition: transform 0.15s ease-in;
+}
+.swagger-ui .model-toggle.collapsed {
+	transform: rotate(0deg);
+}
+.swagger-ui .model-toggle:after {
+	background: url('data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>')
+		50% no-repeat;
+	background-size: 100%;
+	content: '';
+	display: block;
+	height: 20px;
+	width: 20px;
+}
+.swagger-ui .model-jump-to-path {
+	cursor: pointer;
+	position: relative;
+}
+.swagger-ui .model-jump-to-path .view-line-link {
+	cursor: pointer;
+	position: absolute;
+	top: -0.4em;
+}
+.swagger-ui .model-title {
+	position: relative;
+}
+.swagger-ui .model-title:hover .model-hint {
+	visibility: visible;
+}
+.swagger-ui .model-hint {
+	background: rgba(0, 0, 0, 0.7);
+	border-radius: 4px;
+	color: #ebebeb;
+	padding: 0.1em 0.5em;
+	position: absolute;
+	top: -1.8em;
+	visibility: hidden;
+	white-space: nowrap;
+}
+.swagger-ui .model p {
+	margin: 0 0 1em;
+}
+.swagger-ui .model .property {
+	color: #999;
+	font-style: italic;
+}
+.swagger-ui .model .property.primitive {
+	color: #6b6b6b;
+}
+.swagger-ui .model .external-docs,
+.swagger-ui table.model tr.description {
+	color: #666;
+	font-weight: 400;
+}
+.swagger-ui table.model tr.description td:first-child,
+.swagger-ui table.model tr.property-row.required td:first-child {
+	font-weight: 700;
+}
+.swagger-ui table.model tr.property-row td {
+	vertical-align: top;
+}
+.swagger-ui table.model tr.property-row td:first-child {
+	padding-right: 0.2em;
+}
+.swagger-ui table.model tr.property-row .star {
+	color: red;
+}
+.swagger-ui table.model tr.extension {
+	color: #777;
+}
+.swagger-ui table.model tr.extension td:last-child {
+	vertical-align: top;
+}
+.swagger-ui table.model tr.external-docs td:first-child {
+	font-weight: 700;
+}
+.swagger-ui table.model tr .renderedMarkdown p:first-child {
+	margin-top: 0;
+}
+.swagger-ui section.models {
+	border: 1px solid rgba(59, 65, 81, 0.3);
+	border-radius: 4px;
+	margin: 30px 0;
+}
+.swagger-ui section.models .pointer {
+	cursor: pointer;
+}
+.swagger-ui section.models.is-open {
+	padding: 0 0 20px;
+}
+.swagger-ui section.models.is-open h4 {
+	border-bottom: 1px solid rgba(59, 65, 81, 0.3);
+	margin: 0 0 5px;
+}
+.swagger-ui section.models h4 {
+	align-items: center;
+	color: #606060;
+	cursor: pointer;
+	display: flex;
+	font-family: sans-serif;
+	font-size: 16px;
+	margin: 0;
+	padding: 10px 20px 10px 10px;
+	transition: all 0.2s;
+}
+.swagger-ui section.models h4 svg {
+	transition: all 0.4s;
+}
+.swagger-ui section.models h4 span {
+	flex: 1;
+}
+.swagger-ui section.models h4:hover {
+	background: rgba(0, 0, 0, 0.02);
+}
+.swagger-ui section.models h5 {
+	color: #707070;
+	font-family: sans-serif;
+	font-size: 16px;
+	margin: 0 0 10px;
+}
+.swagger-ui section.models .model-jump-to-path {
+	position: relative;
+	top: 5px;
+}
+.swagger-ui section.models .model-container {
+	background: rgba(0, 0, 0, 0.05);
+	border-radius: 4px;
+	margin: 0 20px 15px;
+	position: relative;
+	transition: all 0.5s;
+}
+.swagger-ui section.models .model-container:hover {
+	background: rgba(0, 0, 0, 0.07);
+}
+.swagger-ui section.models .model-container:first-of-type {
+	margin: 20px;
+}
+.swagger-ui section.models .model-container:last-of-type {
+	margin: 0 20px;
+}
+.swagger-ui section.models .model-container .models-jump-to-path {
+	opacity: 0.65;
+	position: absolute;
+	right: 5px;
+	top: 8px;
+}
+.swagger-ui section.models .model-box {
+	background: none;
+}
+.swagger-ui .model-box {
+	background: rgba(0, 0, 0, 0.1);
+	border-radius: 4px;
+	display: inline-block;
+	padding: 10px;
+}
+.swagger-ui .model-box .model-jump-to-path {
+	position: relative;
+	top: 4px;
+}
+.swagger-ui .model-box.deprecated {
+	opacity: 0.5;
+}
+.swagger-ui .model-title {
+	color: #505050;
+	font-family: sans-serif;
+	font-size: 16px;
+}
+.swagger-ui .model-title img {
+	bottom: 0;
+	margin-left: 1em;
+	position: relative;
+}
+.swagger-ui .model-deprecated-warning {
+	color: #f93e3e;
+	font-family: sans-serif;
+	font-size: 16px;
+	font-weight: 600;
+	margin-right: 1em;
+}
+.swagger-ui span > span.model .brace-close {
+	padding: 0 0 0 10px;
+}
+.swagger-ui .prop-name {
+	display: inline-block;
+	margin-right: 1em;
+}
+.swagger-ui .prop-type {
+	color: #55a;
+}
+.swagger-ui .prop-enum {
+	display: block;
+}
+.swagger-ui .prop-format {
+	color: #606060;
+}
+.swagger-ui .servers > label {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 12px;
+	margin: -20px 15px 0 0;
+}
+.swagger-ui .servers > label select {
+	max-width: 100%;
+	min-width: 130px;
+	width: 100%;
+}
+.swagger-ui .servers h4.message {
+	padding-bottom: 2em;
+}
+.swagger-ui .servers table tr {
+	width: 30em;
+}
+.swagger-ui .servers table td {
+	display: inline-block;
+	max-width: 15em;
+	padding-bottom: 10px;
+	padding-top: 10px;
+	vertical-align: middle;
+}
+.swagger-ui .servers table td:first-of-type {
+	padding-right: 1em;
+}
+.swagger-ui .servers table td input {
+	height: 100%;
+	width: 100%;
+}
+.swagger-ui .servers .computed-url {
+	margin: 2em 0;
+}
+.swagger-ui .servers .computed-url code {
+	display: inline-block;
+	font-size: 16px;
+	margin: 0 1em;
+	padding: 4px;
+}
+.swagger-ui .servers-title {
+	font-size: 12px;
+	font-weight: 700;
+}
+.swagger-ui .operation-servers h4.message {
+	margin-bottom: 2em;
+}
+.swagger-ui table {
+	border-collapse: collapse;
+	padding: 0 10px;
+	width: 100%;
+}
+.swagger-ui table.model tbody tr td {
+	padding: 0;
+	vertical-align: top;
+}
+.swagger-ui table.model tbody tr td:first-of-type {
+	padding: 0 0 0 2em;
+	width: 174px;
+}
+.swagger-ui table.headers td {
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 300;
+	font-weight: 600;
+	vertical-align: middle;
+}
+.swagger-ui table.headers .header-example {
+	color: #999;
+	font-style: italic;
+}
+.swagger-ui table tbody tr td {
+	padding: 10px 0 0;
+	vertical-align: top;
+}
+.swagger-ui table tbody tr td:first-of-type {
+	min-width: 6em;
+	padding: 10px 0;
+}
+.swagger-ui table thead tr td,
+.swagger-ui table thead tr th {
+	border-bottom: 1px solid rgba(59, 65, 81, 0.2);
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 12px;
+	font-weight: 700;
+	padding: 12px 0;
+	text-align: left;
+}
+.swagger-ui .parameters-col_description {
+	margin-bottom: 2em;
+	width: 99%;
+}
+.swagger-ui .parameters-col_description input {
+	max-width: 340px;
+	width: 100%;
+}
+.swagger-ui .parameters-col_description select {
+	border-width: 1px;
+}
+.swagger-ui .parameters-col_description .markdown p,
+.swagger-ui .parameters-col_description .renderedMarkdown p {
+	margin: 0;
+}
+.swagger-ui .parameter__name {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 16px;
+	font-weight: 400;
+	margin-right: 0.75em;
+}
+.swagger-ui .parameter__name.required {
+	font-weight: 700;
+}
+.swagger-ui .parameter__name.required span {
+	color: red;
+}
+.swagger-ui .parameter__name.required:after {
+	color: rgba(255, 0, 0, 0.6);
+	content: 'required';
+	font-size: 10px;
+	padding: 5px;
+	position: relative;
+	top: -6px;
+}
+.swagger-ui .parameter__extension,
+.swagger-ui .parameter__in {
+	color: grey;
+	font-family: monospace;
+	font-size: 12px;
+	font-style: italic;
+	font-weight: 600;
+}
+.swagger-ui .parameter__deprecated {
+	color: red;
+	font-family: monospace;
+	font-size: 12px;
+	font-style: italic;
+	font-weight: 600;
+}
+.swagger-ui .parameter__empty_value_toggle {
+	display: block;
+	font-size: 13px;
+	padding-bottom: 12px;
+	padding-top: 5px;
+}
+.swagger-ui .parameter__empty_value_toggle input {
+	margin-right: 7px;
+	width: auto;
+}
+.swagger-ui .parameter__empty_value_toggle.disabled {
+	opacity: 0.7;
+}
+.swagger-ui .table-container {
+	padding: 20px;
+}
+.swagger-ui .response-col_description {
+	width: 99%;
+}
+.swagger-ui .response-col_description .markdown p,
+.swagger-ui .response-col_description .renderedMarkdown p {
+	margin: 0;
+}
+.swagger-ui .response-col_links {
+	min-width: 6em;
+}
+.swagger-ui .response__extension {
+	color: grey;
+	font-family: monospace;
+	font-size: 12px;
+	font-style: italic;
+	font-weight: 600;
+}
+.swagger-ui .topbar {
+	background-color: #1b1b1b;
+	padding: 10px 0;
+}
+.swagger-ui .topbar .topbar-wrapper {
+	align-items: center;
+	display: flex;
+	flex-wrap: wrap;
+	gap: 10px;
+}
+@media (max-width: 550px) {
+	.swagger-ui .topbar .topbar-wrapper {
+		align-items: start;
+		flex-direction: column;
+	}
+}
+.swagger-ui .topbar a {
+	align-items: center;
+	color: #fff;
+	display: flex;
+	flex: 1;
+	font-family: sans-serif;
+	font-size: 1.5em;
+	font-weight: 700;
+	max-width: 300px;
+	-webkit-text-decoration: none;
+	text-decoration: none;
+}
+.swagger-ui .topbar a span {
+	margin: 0;
+	padding: 0 10px;
+}
+.swagger-ui .topbar .download-url-wrapper {
+	display: flex;
+	flex: 3;
+	justify-content: flex-end;
+}
+.swagger-ui .topbar .download-url-wrapper input[type='text'] {
+	border: 2px solid #62a03f;
+	border-radius: 4px 0 0 4px;
+	margin: 0;
+	max-width: 100%;
+	outline: none;
+	width: 100%;
+}
+.swagger-ui .topbar .download-url-wrapper .select-label {
+	align-items: center;
+	color: #f0f0f0;
+	display: flex;
+	margin: 0;
+	max-width: 600px;
+	width: 100%;
+}
+.swagger-ui .topbar .download-url-wrapper .select-label span {
+	flex: 1;
+	font-size: 16px;
+	padding: 0 10px 0 0;
+	text-align: right;
+}
+.swagger-ui .topbar .download-url-wrapper .select-label select {
+	border: 2px solid #62a03f;
+	box-shadow: none;
+	flex: 2;
+	outline: none;
+	width: 100%;
+}
+.swagger-ui .topbar .download-url-wrapper .download-url-button {
+	background: #62a03f;
+	border: none;
+	border-radius: 0 4px 4px 0;
+	color: #fff;
+	font-family: sans-serif;
+	font-size: 16px;
+	font-weight: 700;
+	padding: 4px 30px;
+}
+@media (max-width: 550px) {
+	.swagger-ui .topbar .download-url-wrapper {
+		width: 100%;
+	}
+}
+.swagger-ui .info {
+	margin: 50px 0;
+}
+.swagger-ui .info.failed-config {
+	margin-left: auto;
+	margin-right: auto;
+	max-width: 880px;
+	text-align: center;
+}
+.swagger-ui .info hgroup.main {
+	margin: 0 0 20px;
+}
+.swagger-ui .info hgroup.main a {
+	font-size: 12px;
+}
+.swagger-ui .info pre {
+	font-size: 14px;
+}
+.swagger-ui .info li,
+.swagger-ui .info p,
+.swagger-ui .info table {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 14px;
+}
+.swagger-ui .info h1,
+.swagger-ui .info h2,
+.swagger-ui .info h3,
+.swagger-ui .info h4,
+.swagger-ui .info h5 {
+	color: #3b4151;
+	font-family: sans-serif;
+}
+.swagger-ui .info a {
+	color: #4990e2;
+	font-family: sans-serif;
+	font-size: 14px;
+	transition: all 0.4s;
+}
+.swagger-ui .info a:hover {
+	color: #1f69c0;
+}
+.swagger-ui .info > div {
+	margin: 0 0 5px;
+}
+.swagger-ui .info .base-url {
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 300 !important;
+	font-weight: 600;
+	margin: 0;
+}
+.swagger-ui .info .title {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 36px;
+	margin: 0;
+}
+.swagger-ui .info .title small {
+	background: #7d8492;
+	border-radius: 57px;
+	display: inline-block;
+	font-size: 10px;
+	margin: 0 0 0 5px;
+	padding: 2px 4px;
+	position: relative;
+	top: -5px;
+	vertical-align: super;
+}
+.swagger-ui .info .title small.version-stamp {
+	background-color: #89bf04;
+}
+.swagger-ui .info .title small pre {
+	color: #fff;
+	font-family: sans-serif;
+	margin: 0;
+	padding: 0;
+}
+.swagger-ui .auth-btn-wrapper {
+	display: flex;
+	justify-content: center;
+	padding: 10px 0;
+}
+.swagger-ui .auth-btn-wrapper .btn-done {
+	margin-right: 1em;
+}
+.swagger-ui .auth-wrapper {
+	display: flex;
+	flex: 1;
+	justify-content: flex-end;
+}
+.swagger-ui .auth-wrapper .authorize {
+	margin-left: 10px;
+	margin-right: 10px;
+	padding-right: 20px;
+}
+.swagger-ui .auth-container {
+	border-bottom: 1px solid #ebebeb;
+	margin: 0 0 10px;
+	padding: 10px 20px;
+}
+.swagger-ui .auth-container:last-of-type {
+	border: 0;
+	margin: 0;
+	padding: 10px 20px;
+}
+.swagger-ui .auth-container h4 {
+	margin: 5px 0 15px !important;
+}
+.swagger-ui .auth-container .wrapper {
+	margin: 0;
+	padding: 0;
+}
+.swagger-ui .auth-container input[type='password'],
+.swagger-ui .auth-container input[type='text'] {
+	min-width: 230px;
+}
+.swagger-ui .auth-container .errors {
+	background-color: #fee;
+	border-radius: 4px;
+	color: red;
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 600;
+	margin: 1em;
+	padding: 10px;
+}
+.swagger-ui .auth-container .errors b {
+	margin-right: 1em;
+	text-transform: capitalize;
+}
+.swagger-ui .scopes h2 {
+	color: #3b4151;
+	font-family: sans-serif;
+	font-size: 14px;
+}
+.swagger-ui .scopes h2 a {
+	color: #4990e2;
+	cursor: pointer;
+	font-size: 12px;
+	padding-left: 10px;
+	-webkit-text-decoration: underline;
+	text-decoration: underline;
+}
+.swagger-ui .scope-def {
+	padding: 0 0 20px;
+}
+.swagger-ui .errors-wrapper {
+	animation: scaleUp 0.5s;
+	background: rgba(249, 62, 62, 0.1);
+	border: 2px solid #f93e3e;
+	border-radius: 4px;
+	margin: 20px;
+	padding: 10px 20px;
+}
+.swagger-ui .errors-wrapper .error-wrapper {
+	margin: 0 0 10px;
+}
+.swagger-ui .errors-wrapper .errors h4 {
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 14px;
+	font-weight: 600;
+	margin: 0;
+}
+.swagger-ui .errors-wrapper .errors small {
+	color: #606060;
+}
+.swagger-ui .errors-wrapper .errors .message {
+	white-space: pre-line;
+}
+.swagger-ui .errors-wrapper .errors .message.thrown {
+	max-width: 100%;
+}
+.swagger-ui .errors-wrapper .errors .error-line {
+	cursor: pointer;
+	-webkit-text-decoration: underline;
+	text-decoration: underline;
+}
+.swagger-ui .errors-wrapper hgroup {
+	align-items: center;
+	display: flex;
+}
+.swagger-ui .errors-wrapper hgroup h4 {
+	color: #3b4151;
+	flex: 1;
+	font-family: sans-serif;
+	font-size: 20px;
+	margin: 0;
+}
+@keyframes scaleUp {
+	0% {
+		opacity: 0;
+		transform: scale(0.8);
+	}
+	to {
+		opacity: 1;
+		transform: scale(1);
+	}
+}
+.swagger-ui .Resizer.vertical.disabled {
+	display: none;
+}
+.swagger-ui .markdown p,
+.swagger-ui .markdown pre,
+.swagger-ui .renderedMarkdown p,
+.swagger-ui .renderedMarkdown pre {
+	margin: 1em auto;
+	word-break: break-all;
+	word-break: break-word;
+}
+.swagger-ui .markdown pre,
+.swagger-ui .renderedMarkdown pre {
+	background: none;
+	color: #000;
+	font-weight: 400;
+	padding: 0;
+	white-space: pre-wrap;
+}
+.swagger-ui .markdown code,
+.swagger-ui .renderedMarkdown code {
+	background: rgba(0, 0, 0, 0.05);
+	border-radius: 4px;
+	color: #9012fe;
+	font-family: monospace;
+	font-size: 14px;
+	font-weight: 600;
+	padding: 5px 7px;
+}
+.swagger-ui .markdown pre > code,
+.swagger-ui .renderedMarkdown pre > code {
+	display: block;
+}
+.swagger-ui .json-schema-2020-12 {
+	background-color: rgba(0, 0, 0, 0.05);
+	border-radius: 4px;
+	margin: 0 20px 15px;
+	padding: 12px 0 12px 20px;
+}
+.swagger-ui .json-schema-2020-12:first-of-type {
+	margin: 20px;
+}
+.swagger-ui .json-schema-2020-12:last-of-type {
+	margin: 0 20px;
+}
+.swagger-ui .json-schema-2020-12--embedded {
+	background-color: inherit;
+	padding-bottom: 0;
+	padding-left: inherit;
+	padding-right: inherit;
+	padding-top: 0;
+}
+.swagger-ui .json-schema-2020-12-body {
+	border-left: 1px dashed rgba(0, 0, 0, 0.1);
+	margin: 2px 0;
+}
+.swagger-ui .json-schema-2020-12-body--collapsed {
+	display: none;
+}
+.swagger-ui .json-schema-2020-12-accordion {
+	border: none;
+	outline: none;
+	padding-left: 0;
+}
+.swagger-ui .json-schema-2020-12-accordion__children {
+	display: inline-block;
+}
+.swagger-ui .json-schema-2020-12-accordion__icon {
+	display: inline-block;
+	height: 18px;
+	vertical-align: bottom;
+	width: 18px;
+}
+.swagger-ui .json-schema-2020-12-accordion__icon--expanded {
+	transform: rotate(-90deg);
+	transform-origin: 50% 50%;
+	transition: transform 0.15s ease-in;
+}
+.swagger-ui .json-schema-2020-12-accordion__icon--collapsed {
+	transform: rotate(0deg);
+	transform-origin: 50% 50%;
+	transition: transform 0.15s ease-in;
+}
+.swagger-ui .json-schema-2020-12-accordion__icon svg {
+	height: 20px;
+	width: 20px;
+}
+.swagger-ui .json-schema-2020-12-expand-deep-button {
+	border: none;
+	color: #505050;
+	color: #afaeae;
+	font-family: sans-serif;
+	font-size: 12px;
+	padding-right: 0;
+}
+.swagger-ui .json-schema-2020-12-keyword {
+	margin: 5px 0;
+}
+.swagger-ui .json-schema-2020-12-keyword__children {
+	border-left: 1px dashed rgba(0, 0, 0, 0.1);
+	margin: 0 0 0 20px;
+	padding: 0;
+}
+.swagger-ui .json-schema-2020-12-keyword__children--collapsed {
+	display: none;
+}
+.swagger-ui .json-schema-2020-12-keyword__name {
+	font-size: 12px;
+	font-weight: 700;
+	margin-left: 20px;
+}
+.swagger-ui .json-schema-2020-12-keyword__name--primary {
+	color: #3b4151;
+	font-style: normal;
+}
+.swagger-ui .json-schema-2020-12-keyword__name--secondary {
+	color: #6b6b6b;
+	font-style: italic;
+}
+.swagger-ui .json-schema-2020-12-keyword__value {
+	color: #6b6b6b;
+	font-size: 12px;
+	font-style: italic;
+	font-weight: 400;
+}
+.swagger-ui .json-schema-2020-12-keyword__value--primary {
+	color: #3b4151;
+	font-style: normal;
+}
+.swagger-ui .json-schema-2020-12-keyword__value--secondary {
+	color: #6b6b6b;
+	font-style: italic;
+}
+.swagger-ui .json-schema-2020-12-keyword__value--const,
+.swagger-ui .json-schema-2020-12-keyword__value--warning {
+	border: 1px dashed #6b6b6b;
+	border-radius: 4px;
+	color: #3b4151;
+	color: #6b6b6b;
+	display: inline-block;
+	font-family: monospace;
+	font-style: normal;
+	font-weight: 600;
+	line-height: 1.5;
+	margin-left: 10px;
+	padding: 1px 4px;
+}
+.swagger-ui .json-schema-2020-12-keyword__value--warning {
+	border: 1px dashed red;
+	color: red;
+}
+.swagger-ui
+	.json-schema-2020-12-keyword__name--secondary
+	+ .json-schema-2020-12-keyword__value--secondary:before {
+	content: '=';
+}
+.swagger-ui .json-schema-2020-12__attribute {
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	padding-left: 10px;
+	text-transform: lowercase;
+}
+.swagger-ui .json-schema-2020-12__attribute--primary {
+	color: #55a;
+}
+.swagger-ui .json-schema-2020-12__attribute--muted {
+	color: gray;
+}
+.swagger-ui .json-schema-2020-12__attribute--warning {
+	color: red;
+}
+.swagger-ui .json-schema-2020-12-keyword--\$vocabulary ul {
+	border-left: 1px dashed rgba(0, 0, 0, 0.1);
+	margin: 0 0 0 20px;
+}
+.swagger-ui .json-schema-2020-12-\$vocabulary-uri {
+	margin-left: 35px;
+}
+.swagger-ui .json-schema-2020-12-\$vocabulary-uri--disabled {
+	-webkit-text-decoration: line-through;
+	text-decoration: line-through;
+}
+.swagger-ui .json-schema-2020-12-keyword--description {
+	color: #6b6b6b;
+	font-size: 12px;
+	margin-left: 20px;
+}
+.swagger-ui .json-schema-2020-12-keyword--description p {
+	margin: 0;
+}
+.swagger-ui .json-schema-2020-12__title {
+	color: #505050;
+	display: inline-block;
+	font-family: sans-serif;
+	font-size: 12px;
+	font-weight: 700;
+	line-height: normal;
+}
+.swagger-ui .json-schema-2020-12__title .json-schema-2020-12-keyword__name {
+	margin: 0;
+}
+.swagger-ui .json-schema-2020-12-property {
+	margin: 7px 0;
+}
+.swagger-ui .json-schema-2020-12-property .json-schema-2020-12__title {
+	color: #3b4151;
+	font-family: monospace;
+	font-size: 12px;
+	font-weight: 600;
+	vertical-align: middle;
+}
+.swagger-ui .json-schema-2020-12-keyword--properties > ul {
+	border: none;
+	margin: 0;
+	padding: 0;
+}
+.swagger-ui .json-schema-2020-12-property {
+	list-style-type: none;
+}
+.swagger-ui
+	.json-schema-2020-12-property--required
+	> .json-schema-2020-12:first-of-type
+	> .json-schema-2020-12-head
+	.json-schema-2020-12__title:after {
+	color: red;
+	content: '*';
+	font-weight: 700;
+}
+.swagger-ui .json-schema-2020-12-keyword--patternProperties ul {
+	border: none;
+	margin: 0;
+	padding: 0;
+}
+.swagger-ui
+	.json-schema-2020-12-keyword--patternProperties
+	.json-schema-2020-12__title:first-of-type:after,
+.swagger-ui
+	.json-schema-2020-12-keyword--patternProperties
+	.json-schema-2020-12__title:first-of-type:before {
+	color: #55a;
+	content: '/';
+}
+.swagger-ui .json-schema-2020-12-keyword--enum > ul {
+	display: inline-block;
+	margin: 0;
+	padding: 0;
+}
+.swagger-ui .json-schema-2020-12-keyword--enum > ul li {
+	display: inline;
+	list-style-type: none;
+}
+.swagger-ui .json-schema-2020-12__constraint {
+	background-color: #805ad5;
+	border-radius: 4px;
+	color: #3b4151;
+	color: #fff;
+	font-family: monospace;
+	font-weight: 600;
+	line-height: 1.5;
+	margin-left: 10px;
+	padding: 1px 3px;
+}
+.swagger-ui .json-schema-2020-12__constraint--string {
+	background-color: #d69e2e;
+	color: #fff;
+}
+.swagger-ui .json-schema-2020-12-keyword--dependentRequired > ul {
+	display: inline-block;
+	margin: 0;
+	padding: 0;
+}
+.swagger-ui .json-schema-2020-12-keyword--dependentRequired > ul li {
+	display: inline;
+	list-style-type: none;
+}
+.swagger-ui
+	.model-box
+	.json-schema-2020-12:not(.json-schema-2020-12--embedded)
+	> .json-schema-2020-12-head
+	.json-schema-2020-12__title:first-of-type {
+	font-size: 16px;
+}
+.swagger-ui .model-box > .json-schema-2020-12 {
+	margin: 0;
+}
+.swagger-ui .model-box .json-schema-2020-12 {
+	background-color: transparent;
+	padding: 0;
+}
+.swagger-ui .model-box .json-schema-2020-12-accordion,
+.swagger-ui .model-box .json-schema-2020-12-expand-deep-button {
+	background-color: transparent;
+}
+.swagger-ui
+	.models
+	.json-schema-2020-12:not(.json-schema-2020-12--embedded)
+	> .json-schema-2020-12-head
+	.json-schema-2020-12__title:first-of-type {
+	font-size: 16px;
+}
+
+/*# sourceMappingURL=swagger-ui.css.map*/

+ 3 - 1
backend/open_webui/storage/provider.py

@@ -147,8 +147,10 @@ class StorageProvider:
             return self._get_file_from_s3(file_path)
         return self._get_file_from_local(file_path)
 
-    def delete_file(self, filename: str) -> None:
+    def delete_file(self, file_path: str) -> None:
         """Deletes a file either from S3 or the local file system."""
+        filename = file_path.split("/")[-1]
+
         if self.storage_provider == "s3":
             self._delete_from_s3(filename)
 

+ 61 - 0
backend/open_webui/tasks.py

@@ -0,0 +1,61 @@
+# tasks.py
+import asyncio
+from typing import Dict
+from uuid import uuid4
+
+# A dictionary to keep track of active tasks
+tasks: Dict[str, asyncio.Task] = {}
+
+
+def cleanup_task(task_id: str):
+    """
+    Remove a completed or canceled task from the global `tasks` dictionary.
+    """
+    tasks.pop(task_id, None)  # Remove the task if it exists
+
+
+def create_task(coroutine):
+    """
+    Create a new asyncio task and add it to the global task dictionary.
+    """
+    task_id = str(uuid4())  # Generate a unique ID for the task
+    task = asyncio.create_task(coroutine)  # Create the task
+
+    # Add a done callback for cleanup
+    task.add_done_callback(lambda t: cleanup_task(task_id))
+
+    tasks[task_id] = task
+    return task_id, task
+
+
+def get_task(task_id: str):
+    """
+    Retrieve a task by its task ID.
+    """
+    return tasks.get(task_id)
+
+
+def list_tasks():
+    """
+    List all currently active task IDs.
+    """
+    return list(tasks.keys())
+
+
+async def stop_task(task_id: str):
+    """
+    Cancel a running task and remove it from the global task list.
+    """
+    task = tasks.get(task_id)
+    if not task:
+        raise ValueError(f"Task with ID {task_id} not found.")
+
+    task.cancel()  # Request task cancellation
+    try:
+        await task  # Wait for the task to handle the cancellation
+    except asyncio.CancelledError:
+        # Task successfully canceled
+        tasks.pop(task_id, None)  # Remove it from the dictionary
+        return {"status": True, "message": f"Task {task_id} successfully stopped."}
+
+    return {"status": False, "message": f"Failed to stop task {task_id}."}

+ 22 - 0
backend/open_webui/utils/access_control.py

@@ -1,4 +1,5 @@
 from typing import Optional, Union, List, Dict, Any
+from open_webui.models.users import Users, UserModel
 from open_webui.models.groups import Groups
 import json
 
@@ -93,3 +94,24 @@ def has_access(
     return user_id in permitted_user_ids or any(
         group_id in permitted_group_ids for group_id in user_group_ids
     )
+
+
+# Get all users with access to a resource
+def get_users_with_access(
+    type: str = "write", access_control: Optional[dict] = None
+) -> List[UserModel]:
+    if access_control is None:
+        return Users.get_users()
+
+    permission_access = access_control.get(type, {})
+    permitted_group_ids = permission_access.get("group_ids", [])
+    permitted_user_ids = permission_access.get("user_ids", [])
+
+    user_ids_with_access = set(permitted_user_ids)
+
+    for group_id in permitted_group_ids:
+        group_user_ids = Groups.get_group_user_ids_by_id(group_id)
+        if group_user_ids:
+            user_ids_with_access.update(group_user_ids)
+
+    return Users.get_users_by_user_ids(list(user_ids_with_access))

+ 14 - 0
backend/open_webui/utils/auth.py

@@ -95,6 +95,20 @@ def get_current_user(
             raise HTTPException(
                 status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
             )
+
+        if request.app.state.config.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS:
+            allowed_paths = [
+                path.strip()
+                for path in str(
+                    request.app.state.config.API_KEY_ALLOWED_ENDPOINTS
+                ).split(",")
+            ]
+
+            if request.url.path not in allowed_paths:
+                raise HTTPException(
+                    status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
+                )
+
         return get_current_user_by_api_key(token)
 
     # auth by jwt token

+ 24 - 8
backend/open_webui/utils/chat.py

@@ -89,7 +89,7 @@ async def generate_chat_completion(
         if model_ids and filter_mode == "exclude":
             model_ids = [
                 model["id"]
-                for model in await get_all_models(request)
+                for model in list(request.app.state.MODELS.values())
                 if model.get("owned_by") != "arena" and model["id"] not in model_ids
             ]
 
@@ -99,7 +99,7 @@ async def generate_chat_completion(
         else:
             model_ids = [
                 model["id"]
-                for model in await get_all_models(request)
+                for model in list(request.app.state.MODELS.values())
                 if model.get("owned_by") != "arena"
             ]
             selected_model_id = random.choice(model_ids)
@@ -114,21 +114,27 @@ async def generate_chat_completion(
                     yield chunk
 
             response = await generate_chat_completion(
-                form_data, user, bypass_filter=True
+                request, form_data, user, bypass_filter=True
             )
             return StreamingResponse(
-                stream_wrapper(response.body_iterator), media_type="text/event-stream"
+                stream_wrapper(response.body_iterator),
+                media_type="text/event-stream",
+                background=response.background,
             )
         else:
             return {
-                **(await generate_chat_completion(form_data, user, bypass_filter=True)),
+                **(
+                    await generate_chat_completion(
+                        request, form_data, user, bypass_filter=True
+                    )
+                ),
                 "selected_model_id": selected_model_id,
             }
 
     if model.get("pipe"):
         # Below does not require bypass_filter because this is the only route the uses this function and it is already bypassing the filter
         return await generate_function_chat_completion(
-            form_data, user=user, models=models
+            request, form_data, user=user, models=models
         )
     if model["owned_by"] == "ollama":
         # Using /ollama/api/chat endpoint
@@ -141,6 +147,7 @@ async def generate_chat_completion(
             return StreamingResponse(
                 convert_streaming_response_ollama_to_openai(response),
                 headers=dict(response.headers),
+                background=response.background,
             )
         else:
             return convert_response_ollama_to_openai(response)
@@ -150,8 +157,12 @@ async def generate_chat_completion(
         )
 
 
+chat_completion = generate_chat_completion
+
+
 async def chat_completed(request: Request, form_data: dict, user: Any):
-    await get_all_models(request)
+    if not request.app.state.MODELS:
+        await get_all_models(request)
     models = request.app.state.MODELS
 
     data = form_data
@@ -171,6 +182,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
             "chat_id": data["chat_id"],
             "message_id": data["id"],
             "session_id": data["session_id"],
+            "user_id": user.id,
         }
     )
 
@@ -179,6 +191,7 @@ async def chat_completed(request: Request, form_data: dict, user: Any):
             "chat_id": data["chat_id"],
             "message_id": data["id"],
             "session_id": data["session_id"],
+            "user_id": user.id,
         }
     )
 
@@ -286,7 +299,8 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
     if not action:
         raise Exception(f"Action not found: {action_id}")
 
-    await get_all_models(request)
+    if not request.app.state.MODELS:
+        await get_all_models(request)
     models = request.app.state.MODELS
 
     data = form_data
@@ -301,6 +315,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
             "chat_id": data["chat_id"],
             "message_id": data["id"],
             "session_id": data["session_id"],
+            "user_id": user.id,
         }
     )
     __event_call__ = get_event_call(
@@ -308,6 +323,7 @@ async def chat_action(request: Request, action_id: str, form_data: dict, user: A
             "chat_id": data["chat_id"],
             "message_id": data["id"],
             "session_id": data["session_id"],
+            "user_id": user.id,
         }
     )
 

+ 19 - 12
backend/open_webui/utils/images/comfyui.py

@@ -16,14 +16,16 @@ log.setLevel(SRC_LOG_LEVELS["COMFYUI"])
 default_headers = {"User-Agent": "Mozilla/5.0"}
 
 
-def queue_prompt(prompt, client_id, base_url):
+def queue_prompt(prompt, client_id, base_url, api_key):
     log.info("queue_prompt")
     p = {"prompt": prompt, "client_id": client_id}
     data = json.dumps(p).encode("utf-8")
     log.debug(f"queue_prompt data: {data}")
     try:
         req = urllib.request.Request(
-            f"{base_url}/prompt", data=data, headers=default_headers
+            f"{base_url}/prompt",
+            data=data,
+            headers={**default_headers, "Authorization": f"Bearer {api_key}"},
         )
         response = urllib.request.urlopen(req).read()
         return json.loads(response)
@@ -32,12 +34,13 @@ def queue_prompt(prompt, client_id, base_url):
         raise e
 
 
-def get_image(filename, subfolder, folder_type, base_url):
+def get_image(filename, subfolder, folder_type, base_url, api_key):
     log.info("get_image")
     data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
     url_values = urllib.parse.urlencode(data)
     req = urllib.request.Request(
-        f"{base_url}/view?{url_values}", headers=default_headers
+        f"{base_url}/view?{url_values}",
+        headers={**default_headers, "Authorization": f"Bearer {api_key}"},
     )
     with urllib.request.urlopen(req) as response:
         return response.read()
@@ -50,18 +53,19 @@ def get_image_url(filename, subfolder, folder_type, base_url):
     return f"{base_url}/view?{url_values}"
 
 
-def get_history(prompt_id, base_url):
+def get_history(prompt_id, base_url, api_key):
     log.info("get_history")
 
     req = urllib.request.Request(
-        f"{base_url}/history/{prompt_id}", headers=default_headers
+        f"{base_url}/history/{prompt_id}",
+        headers={**default_headers, "Authorization": f"Bearer {api_key}"},
     )
     with urllib.request.urlopen(req) as response:
         return json.loads(response.read())
 
 
-def get_images(ws, prompt, client_id, base_url):
-    prompt_id = queue_prompt(prompt, client_id, base_url)["prompt_id"]
+def get_images(ws, prompt, client_id, base_url, api_key):
+    prompt_id = queue_prompt(prompt, client_id, base_url, api_key)["prompt_id"]
     output_images = []
     while True:
         out = ws.recv()
@@ -74,7 +78,7 @@ def get_images(ws, prompt, client_id, base_url):
         else:
             continue  # previews are binary data
 
-    history = get_history(prompt_id, base_url)[prompt_id]
+    history = get_history(prompt_id, base_url, api_key)[prompt_id]
     for o in history["outputs"]:
         for node_id in history["outputs"]:
             node_output = history["outputs"][node_id]
@@ -113,7 +117,7 @@ class ComfyUIGenerateImageForm(BaseModel):
 
 
 async def comfyui_generate_image(
-    model: str, payload: ComfyUIGenerateImageForm, client_id, base_url
+    model: str, payload: ComfyUIGenerateImageForm, client_id, base_url, api_key
 ):
     ws_url = base_url.replace("http://", "ws://").replace("https://", "wss://")
     workflow = json.loads(payload.workflow.workflow)
@@ -167,7 +171,8 @@ async def comfyui_generate_image(
 
     try:
         ws = websocket.WebSocket()
-        ws.connect(f"{ws_url}/ws?clientId={client_id}")
+        headers = {"Authorization": f"Bearer {api_key}"}
+        ws.connect(f"{ws_url}/ws?clientId={client_id}", header=headers)
         log.info("WebSocket connection established.")
     except Exception as e:
         log.exception(f"Failed to connect to WebSocket server: {e}")
@@ -176,7 +181,9 @@ async def comfyui_generate_image(
     try:
         log.info("Sending workflow to WebSocket server.")
         log.info(f"Workflow: {workflow}")
-        images = await asyncio.to_thread(get_images, ws, workflow, client_id, base_url)
+        images = await asyncio.to_thread(
+            get_images, ws, workflow, client_id, base_url, api_key
+        )
     except Exception as e:
         log.exception(f"Error while receiving images: {e}")
         images = None

+ 618 - 31
backend/open_webui/utils/middleware.py

@@ -2,21 +2,36 @@ import time
 import logging
 import sys
 
+import asyncio
 from aiocache import cached
 from typing import Any, Optional
 import random
 import json
 import inspect
+from uuid import uuid4
+from concurrent.futures import ThreadPoolExecutor
+
 
 from fastapi import Request
+from fastapi import BackgroundTasks
+
 from starlette.responses import Response, StreamingResponse
 
 
+from open_webui.models.chats import Chats
+from open_webui.models.users import Users
 from open_webui.socket.main import (
     get_event_call,
     get_event_emitter,
+    get_active_status_by_user_id,
+)
+from open_webui.routers.tasks import (
+    generate_queries,
+    generate_title,
+    generate_chat_tags,
 )
-from open_webui.routers.tasks import generate_queries
+from open_webui.routers.retrieval import process_web_search, SearchForm
+from open_webui.utils.webhook import post_webhook
 
 
 from open_webui.models.users import UserModel
@@ -33,16 +48,25 @@ from open_webui.utils.task import (
     tools_function_calling_generation_template,
 )
 from open_webui.utils.misc import (
+    get_message_list,
     add_or_update_system_message,
     get_last_user_message,
+    get_last_assistant_message,
     prepend_to_first_user_message_content,
 )
 from open_webui.utils.tools import get_tools
 from open_webui.utils.plugin import load_function_module_by_id
 
 
+from open_webui.tasks import create_task
+
 from open_webui.config import DEFAULT_TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
-from open_webui.env import SRC_LOG_LEVELS, GLOBAL_LOG_LEVEL, BYPASS_MODEL_ACCESS_CONTROL
+from open_webui.env import (
+    SRC_LOG_LEVELS,
+    GLOBAL_LOG_LEVEL,
+    BYPASS_MODEL_ACCESS_CONTROL,
+    ENABLE_REALTIME_CHAT_SAVE,
+)
 from open_webui.constants import TASKS
 
 
@@ -312,6 +336,156 @@ async def chat_completion_tools_handler(
     return body, {"sources": sources}
 
 
+async def chat_web_search_handler(
+    request: Request, form_data: dict, extra_params: dict, user
+):
+    event_emitter = extra_params["__event_emitter__"]
+    await event_emitter(
+        {
+            "type": "status",
+            "data": {
+                "action": "web_search",
+                "description": "Generating search query",
+                "done": False,
+            },
+        }
+    )
+
+    messages = form_data["messages"]
+    user_message = get_last_user_message(messages)
+
+    queries = []
+    try:
+        res = await generate_queries(
+            request,
+            {
+                "model": form_data["model"],
+                "messages": messages,
+                "prompt": user_message,
+                "type": "web_search",
+            },
+            user,
+        )
+
+        response = res["choices"][0]["message"]["content"]
+
+        try:
+            bracket_start = response.find("{")
+            bracket_end = response.rfind("}") + 1
+
+            if bracket_start == -1 or bracket_end == -1:
+                raise Exception("No JSON object found in the response")
+
+            response = response[bracket_start:bracket_end]
+            queries = json.loads(response)
+            queries = queries.get("queries", [])
+        except Exception as e:
+            queries = [response]
+
+    except Exception as e:
+        log.exception(e)
+        queries = [user_message]
+
+    if len(queries) == 0:
+        await event_emitter(
+            {
+                "type": "status",
+                "data": {
+                    "action": "web_search",
+                    "description": "No search query generated",
+                    "done": True,
+                },
+            }
+        )
+        return
+
+    searchQuery = queries[0]
+
+    await event_emitter(
+        {
+            "type": "status",
+            "data": {
+                "action": "web_search",
+                "description": 'Searching "{{searchQuery}}"',
+                "query": searchQuery,
+                "done": False,
+            },
+        }
+    )
+
+    try:
+
+        # Offload process_web_search to a separate thread
+        loop = asyncio.get_running_loop()
+        with ThreadPoolExecutor() as executor:
+            results = await loop.run_in_executor(
+                executor,
+                lambda: process_web_search(
+                    request,
+                    SearchForm(
+                        **{
+                            "query": searchQuery,
+                        }
+                    ),
+                    user,
+                ),
+            )
+
+        if results:
+            await event_emitter(
+                {
+                    "type": "status",
+                    "data": {
+                        "action": "web_search",
+                        "description": "Searched {{count}} sites",
+                        "query": searchQuery,
+                        "urls": results["filenames"],
+                        "done": True,
+                    },
+                }
+            )
+
+            files = form_data.get("files", [])
+            files.append(
+                {
+                    "collection_name": results["collection_name"],
+                    "name": searchQuery,
+                    "type": "web_search_results",
+                    "urls": results["filenames"],
+                }
+            )
+            form_data["files"] = files
+        else:
+            await event_emitter(
+                {
+                    "type": "status",
+                    "data": {
+                        "action": "web_search",
+                        "description": "No search results found",
+                        "query": searchQuery,
+                        "done": True,
+                        "error": True,
+                    },
+                }
+            )
+    except Exception as e:
+        log.exception(e)
+        await event_emitter(
+            {
+                "type": "status",
+                "data": {
+                    "action": "web_search",
+                    "description": 'Error searching "{{searchQuery}}"',
+                    "query": searchQuery,
+                    "done": True,
+                    "error": True,
+                },
+            }
+        )
+
+    return form_data
+
+
 async def chat_completion_files_handler(
     request: Request, body: dict, user: UserModel
 ) -> tuple[dict, dict[str, list]]:
@@ -320,6 +494,7 @@ async def chat_completion_files_handler(
     if files := body.get("metadata", {}).get("files", None):
         try:
             queries_response = await generate_queries(
+                request,
                 {
                     "model": body["model"],
                     "messages": body["messages"],
@@ -362,19 +537,44 @@ async def chat_completion_files_handler(
     return body, {"sources": sources}
 
 
-async def process_chat_payload(request, form_data, user, model):
-    metadata = {
-        "chat_id": form_data.pop("chat_id", None),
-        "message_id": form_data.pop("id", None),
-        "session_id": form_data.pop("session_id", None),
-        "tool_ids": form_data.get("tool_ids", None),
-        "files": form_data.get("files", None),
-    }
-    form_data["metadata"] = metadata
+def apply_params_to_form_data(form_data, model):
+    params = form_data.pop("params", {})
+    if model.get("ollama"):
+        form_data["options"] = params
+
+        if "format" in params:
+            form_data["format"] = params["format"]
+
+        if "keep_alive" in params:
+            form_data["keep_alive"] = params["keep_alive"]
+    else:
+        if "seed" in params:
+            form_data["seed"] = params["seed"]
+
+        if "stop" in params:
+            form_data["stop"] = params["stop"]
+
+        if "temperature" in params:
+            form_data["temperature"] = params["temperature"]
+
+        if "top_p" in params:
+            form_data["top_p"] = params["top_p"]
+
+        if "frequency_penalty" in params:
+            form_data["frequency_penalty"] = params["frequency_penalty"]
+    return form_data
+
+
+async def process_chat_payload(request, form_data, metadata, user, model):
+    form_data = apply_params_to_form_data(form_data, model)
+    log.debug(f"form_data: {form_data}")
+
+    event_emitter = get_event_emitter(metadata)
+    event_call = get_event_call(metadata)
 
     extra_params = {
-        "__event_emitter__": get_event_emitter(metadata),
-        "__event_call__": get_event_call(metadata),
+        "__event_emitter__": event_emitter,
+        "__event_call__": event_call,
         "__user__": {
             "id": user.id,
             "email": user.email,
@@ -388,18 +588,70 @@ async def process_chat_payload(request, form_data, user, model):
     # Initialize events to store additional event to be sent to the client
     # Initialize contexts and citation
     models = request.app.state.MODELS
+
     events = []
     sources = []
 
+    user_message = get_last_user_message(form_data["messages"])
+    model_knowledge = model.get("info", {}).get("meta", {}).get("knowledge", False)
+
+    if model_knowledge:
+        await event_emitter(
+            {
+                "type": "status",
+                "data": {
+                    "action": "knowledge_search",
+                    "query": user_message,
+                    "done": False,
+                },
+            }
+        )
+
+        knowledge_files = []
+        for item in model_knowledge:
+            if item.get("collection_name"):
+                knowledge_files.append(
+                    {
+                        "id": item.get("collection_name"),
+                        "name": item.get("name"),
+                        "legacy": True,
+                    }
+                )
+            elif item.get("collection_names"):
+                knowledge_files.append(
+                    {
+                        "name": item.get("name"),
+                        "type": "collection",
+                        "collection_names": item.get("collection_names"),
+                        "legacy": True,
+                    }
+                )
+            else:
+                knowledge_files.append(item)
+
+        files = form_data.get("files", [])
+        files.extend(knowledge_files)
+        form_data["files"] = files
+
+    features = form_data.pop("features", None)
+    if features:
+        if "web_search" in features and features["web_search"]:
+            form_data = await chat_web_search_handler(
+                request, form_data, extra_params, user
+            )
+
     try:
         form_data, flags = await chat_completion_filter_functions_handler(
             request, form_data, model, extra_params
         )
     except Exception as e:
-        return Exception(f"Error: {e}")
+        raise Exception(f"Error: {e}")
 
     tool_ids = form_data.pop("tool_ids", None)
     files = form_data.pop("files", None)
+    # Remove files duplicates
+    if files:
+        files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
 
     metadata = {
         **metadata,
@@ -478,31 +730,366 @@ async def process_chat_payload(request, form_data, user, model):
     if len(sources) > 0:
         events.append({"sources": sources})
 
+    if model_knowledge:
+        await event_emitter(
+            {
+                "type": "status",
+                "data": {
+                    "action": "knowledge_search",
+                    "query": user_message,
+                    "done": True,
+                    "hidden": True,
+                },
+            }
+        )
+
     return form_data, events
 
 
-async def process_chat_response(response, events):
+async def process_chat_response(
+    request, response, form_data, user, events, metadata, 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
+
+        if message:
+            messages = get_message_list(message_map, message.get("id"))
+
+            if tasks:
+                if TASKS.TITLE_GENERATION in tasks:
+                    if tasks[TASKS.TITLE_GENERATION]:
+                        res = await generate_title(
+                            request,
+                            {
+                                "model": message["model"],
+                                "messages": messages,
+                                "chat_id": metadata["chat_id"],
+                            },
+                            user,
+                        )
+
+                        if res and isinstance(res, dict):
+                            title = (
+                                res.get("choices", [])[0]
+                                .get("message", {})
+                                .get(
+                                    "content",
+                                    message.get("content", "New Chat"),
+                                )
+                            ).strip()
+
+                            if not title:
+                                title = messages[0].get("content", "New Chat")
+
+                            Chats.update_chat_title_by_id(metadata["chat_id"], title)
+
+                            await event_emitter(
+                                {
+                                    "type": "chat:title",
+                                    "data": title,
+                                }
+                            )
+                    elif len(messages) == 2:
+                        title = messages[0].get("content", "New Chat")
+
+                        Chats.update_chat_title_by_id(metadata["chat_id"], title)
+
+                        await event_emitter(
+                            {
+                                "type": "chat:title",
+                                "data": message.get("content", "New Chat"),
+                            }
+                        )
+
+                if TASKS.TAGS_GENERATION in tasks and tasks[TASKS.TAGS_GENERATION]:
+                    res = await generate_chat_tags(
+                        request,
+                        {
+                            "model": message["model"],
+                            "messages": messages,
+                            "chat_id": metadata["chat_id"],
+                        },
+                        user,
+                    )
+
+                    if res and isinstance(res, dict):
+                        tags_string = (
+                            res.get("choices", [])[0]
+                            .get("message", {})
+                            .get("content", "")
+                        )
+
+                        tags_string = tags_string[
+                            tags_string.find("{") : tags_string.rfind("}") + 1
+                        ]
+
+                        try:
+                            tags = json.loads(tags_string).get("tags", [])
+                            Chats.update_chat_tags_by_id(
+                                metadata["chat_id"], tags, user
+                            )
+
+                            await event_emitter(
+                                {
+                                    "type": "chat:tags",
+                                    "data": tags,
+                                }
+                            )
+                        except Exception as e:
+                            print(f"Error: {e}")
+
+    event_emitter = None
+    if (
+        "session_id" in metadata
+        and metadata["session_id"]
+        and "chat_id" in metadata
+        and metadata["chat_id"]
+        and "message_id" in metadata
+        and metadata["message_id"]
+    ):
+        event_emitter = get_event_emitter(metadata)
+
     if not isinstance(response, StreamingResponse):
-        return response
+        if event_emitter:
+
+            if "selected_model_id" in response:
+                Chats.upsert_message_to_chat_by_id_and_message_id(
+                    metadata["chat_id"],
+                    metadata["message_id"],
+                    {
+                        "selectedModelId": response["selected_model_id"],
+                    },
+                )
+
+            if response.get("choices", [])[0].get("message", {}).get("content"):
+                content = response["choices"][0]["message"]["content"]
+
+                if content:
+
+                    await event_emitter(
+                        {
+                            "type": "chat:completion",
+                            "data": response,
+                        }
+                    )
+
+                    title = Chats.get_chat_title_by_id(metadata["chat_id"])
+
+                    await event_emitter(
+                        {
+                            "type": "chat:completion",
+                            "data": {
+                                "done": True,
+                                "content": content,
+                                "title": title,
+                            },
+                        }
+                    )
+
+                    # Save message in the database
+                    Chats.upsert_message_to_chat_by_id_and_message_id(
+                        metadata["chat_id"],
+                        metadata["message_id"],
+                        {
+                            "content": content,
+                        },
+                    )
 
-    content_type = response.headers["Content-Type"]
-    is_openai = "text/event-stream" in content_type
-    is_ollama = "application/x-ndjson" in content_type
+                    # Send a webhook notification if the user is not active
+                    if get_active_status_by_user_id(user.id) is None:
+                        webhook_url = Users.get_user_webhook_url_by_id(user.id)
+                        if webhook_url:
+                            post_webhook(
+                                webhook_url,
+                                f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
+                                {
+                                    "action": "chat",
+                                    "message": content,
+                                    "title": title,
+                                    "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
+                                },
+                            )
+
+                    await background_tasks_handler()
 
-    if not is_openai and not is_ollama:
+            return response
+        else:
+            return response
+
+    if not any(
+        content_type in response.headers["Content-Type"]
+        for content_type in ["text/event-stream", "application/x-ndjson"]
+    ):
         return response
 
-    async def stream_wrapper(original_generator, events):
-        def wrap_item(item):
-            return f"data: {item}\n\n" if is_openai else f"{item}\n"
+    if event_emitter:
 
-        for event in events:
-            yield wrap_item(json.dumps(event))
+        task_id = str(uuid4())  # Create a unique task ID.
 
-        async for data in original_generator:
-            yield data
+        # Handle as a background task
+        async def post_response_handler(response, events):
+            message = Chats.get_message_by_id_and_message_id(
+                metadata["chat_id"], metadata["message_id"]
+            )
+            content = message.get("content", "") if message else ""
 
-    return StreamingResponse(
-        stream_wrapper(response.body_iterator, events),
-        headers=dict(response.headers),
-    )
+            try:
+                for event in events:
+                    await event_emitter(
+                        {
+                            "type": "chat:completion",
+                            "data": event,
+                        }
+                    )
+
+                    # Save message in the database
+                    Chats.upsert_message_to_chat_by_id_and_message_id(
+                        metadata["chat_id"],
+                        metadata["message_id"],
+                        {
+                            **event,
+                        },
+                    )
+
+                async for line in response.body_iterator:
+                    line = line.decode("utf-8") if isinstance(line, bytes) else line
+                    data = line
+
+                    # Skip empty lines
+                    if not data.strip():
+                        continue
+
+                    # "data: " is the prefix for each event
+                    if not data.startswith("data: "):
+                        continue
+
+                    # Remove the prefix
+                    data = data[len("data: ") :]
+
+                    try:
+                        data = json.loads(data)
+
+                        if "selected_model_id" in data:
+                            Chats.upsert_message_to_chat_by_id_and_message_id(
+                                metadata["chat_id"],
+                                metadata["message_id"],
+                                {
+                                    "selectedModelId": data["selected_model_id"],
+                                },
+                            )
+
+                        else:
+                            value = (
+                                data.get("choices", [])[0]
+                                .get("delta", {})
+                                .get("content")
+                            )
+
+                            if value:
+                                content = f"{content}{value}"
+
+                                if ENABLE_REALTIME_CHAT_SAVE:
+                                    # Save message in the database
+                                    Chats.upsert_message_to_chat_by_id_and_message_id(
+                                        metadata["chat_id"],
+                                        metadata["message_id"],
+                                        {
+                                            "content": content,
+                                        },
+                                    )
+                                else:
+                                    data = {
+                                        "content": content,
+                                    }
+
+                        await event_emitter(
+                            {
+                                "type": "chat:completion",
+                                "data": data,
+                            }
+                        )
+
+                    except Exception as e:
+                        done = "data: [DONE]" in line
+
+                        if done:
+                            pass
+                        else:
+                            continue
+
+                title = Chats.get_chat_title_by_id(metadata["chat_id"])
+                data = {"done": True, "content": content, "title": title}
+
+                if not ENABLE_REALTIME_CHAT_SAVE:
+                    # Save message in the database
+                    Chats.upsert_message_to_chat_by_id_and_message_id(
+                        metadata["chat_id"],
+                        metadata["message_id"],
+                        {
+                            "content": content,
+                        },
+                    )
+
+                # Send a webhook notification if the user is not active
+                if get_active_status_by_user_id(user.id) is None:
+                    webhook_url = Users.get_user_webhook_url_by_id(user.id)
+                    if webhook_url:
+                        post_webhook(
+                            webhook_url,
+                            f"{title} - {request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}\n\n{content}",
+                            {
+                                "action": "chat",
+                                "message": content,
+                                "title": title,
+                                "url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_id']}",
+                            },
+                        )
+
+                await event_emitter(
+                    {
+                        "type": "chat:completion",
+                        "data": data,
+                    }
+                )
+
+                await background_tasks_handler()
+            except asyncio.CancelledError:
+                print("Task was cancelled!")
+                await event_emitter({"type": "task-cancelled"})
+
+                if not ENABLE_REALTIME_CHAT_SAVE:
+                    # Save message in the database
+                    Chats.upsert_message_to_chat_by_id_and_message_id(
+                        metadata["chat_id"],
+                        metadata["message_id"],
+                        {
+                            "content": content,
+                        },
+                    )
+
+            if response.background is not None:
+                await response.background()
+
+        # background_tasks.add_task(post_response_handler, response, events)
+        task_id, _ = create_task(post_response_handler(response, events))
+        return {"status": True, "task_id": task_id}
+
+    else:
+
+        # Fallback to the original response
+        async def stream_wrapper(original_generator, events):
+            def wrap_item(item):
+                return f"data: {item}\n\n"
+
+            for event in events:
+                yield wrap_item(json.dumps(event))
+
+            async for data in original_generator:
+                yield data
+
+        return StreamingResponse(
+            stream_wrapper(response.body_iterator, events),
+            headers=dict(response.headers),
+            background=response.background,
+        )

+ 35 - 0
backend/open_webui/utils/misc.py

@@ -7,6 +7,34 @@ from pathlib import Path
 from typing import Callable, Optional
 
 
+def get_message_list(messages, message_id):
+    """
+    Reconstructs a list of messages in order up to the specified message_id.
+
+    :param message_id: ID of the message to reconstruct the chain
+    :param messages: Message history dict containing all messages
+    :return: List of ordered messages starting from the root to the given message
+    """
+
+    # Find the message by its id
+    current_message = messages.get(message_id)
+
+    if not current_message:
+        return f"Message ID {message_id} not found in the history."
+
+    # Reconstruct the chain by following the parentId links
+    message_list = []
+
+    while current_message:
+        message_list.insert(
+            0, current_message
+        )  # Insert the message at the beginning of the list
+        parent_id = current_message["parentId"]
+        current_message = messages.get(parent_id) if parent_id else None
+
+    return message_list
+
+
 def get_messages_content(messages: list[dict]) -> str:
     return "\n".join(
         [
@@ -40,6 +68,13 @@ def get_last_user_message(messages: list[dict]) -> Optional[str]:
     return get_content_from_message(message)
 
 
+def get_last_assistant_message_item(messages: list[dict]) -> Optional[dict]:
+    for message in reversed(messages):
+        if message["role"] == "assistant":
+            return message
+    return None
+
+
 def get_last_assistant_message(messages: list[dict]) -> Optional[str]:
     for message in reversed(messages):
         if message["role"] == "assistant":

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

@@ -58,7 +58,6 @@ async def get_all_base_models(request: Request):
     return models
 
 
-@cached(ttl=3)
 async def get_all_models(request):
     models = await get_all_base_models(request)
 

+ 67 - 0
backend/open_webui/utils/oauth.py

@@ -14,13 +14,16 @@ from starlette.responses import RedirectResponse
 
 from open_webui.models.auths import Auths
 from open_webui.models.users import Users
+from open_webui.models.groups import Groups, GroupModel, GroupUpdateForm
 from open_webui.config import (
     DEFAULT_USER_ROLE,
     ENABLE_OAUTH_SIGNUP,
     OAUTH_MERGE_ACCOUNTS_BY_EMAIL,
     OAUTH_PROVIDERS,
     ENABLE_OAUTH_ROLE_MANAGEMENT,
+    ENABLE_OAUTH_GROUP_MANAGEMENT,
     OAUTH_ROLES_CLAIM,
+    OAUTH_GROUPS_CLAIM,
     OAUTH_EMAIL_CLAIM,
     OAUTH_PICTURE_CLAIM,
     OAUTH_USERNAME_CLAIM,
@@ -44,7 +47,9 @@ auth_manager_config.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE
 auth_manager_config.ENABLE_OAUTH_SIGNUP = ENABLE_OAUTH_SIGNUP
 auth_manager_config.OAUTH_MERGE_ACCOUNTS_BY_EMAIL = OAUTH_MERGE_ACCOUNTS_BY_EMAIL
 auth_manager_config.ENABLE_OAUTH_ROLE_MANAGEMENT = ENABLE_OAUTH_ROLE_MANAGEMENT
+auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT = ENABLE_OAUTH_GROUP_MANAGEMENT
 auth_manager_config.OAUTH_ROLES_CLAIM = OAUTH_ROLES_CLAIM
+auth_manager_config.OAUTH_GROUPS_CLAIM = OAUTH_GROUPS_CLAIM
 auth_manager_config.OAUTH_EMAIL_CLAIM = OAUTH_EMAIL_CLAIM
 auth_manager_config.OAUTH_PICTURE_CLAIM = OAUTH_PICTURE_CLAIM
 auth_manager_config.OAUTH_USERNAME_CLAIM = OAUTH_USERNAME_CLAIM
@@ -119,6 +124,61 @@ class OAuthManager:
 
         return role
 
+    def update_user_groups(self, user, user_data, default_permissions):
+        oauth_claim = auth_manager_config.OAUTH_GROUPS_CLAIM
+
+        user_oauth_groups: list[str] = user_data.get(oauth_claim, list())
+        user_current_groups: list[GroupModel] = Groups.get_groups_by_member_id(user.id)
+        all_available_groups: list[GroupModel] = Groups.get_groups()
+
+        # Remove groups that user is no longer a part of
+        for group_model in user_current_groups:
+            if group_model.name not in user_oauth_groups:
+                # Remove group from user
+
+                user_ids = group_model.user_ids
+                user_ids = [i for i in user_ids if i != user.id]
+
+                # In case a group is created, but perms are never assigned to the group by hitting "save"
+                group_permissions = group_model.permissions
+                if not group_permissions:
+                    group_permissions = default_permissions
+
+                update_form = GroupUpdateForm(
+                    name=group_model.name,
+                    description=group_model.description,
+                    permissions=group_permissions,
+                    user_ids=user_ids,
+                )
+                Groups.update_group_by_id(
+                    id=group_model.id, form_data=update_form, overwrite=False
+                )
+
+        # Add user to new groups
+        for group_model in all_available_groups:
+            if group_model.name in user_oauth_groups and not any(
+                gm.name == group_model.name for gm in user_current_groups
+            ):
+                # Add user to group
+
+                user_ids = group_model.user_ids
+                user_ids.append(user.id)
+
+                # In case a group is created, but perms are never assigned to the group by hitting "save"
+                group_permissions = group_model.permissions
+                if not group_permissions:
+                    group_permissions = default_permissions
+
+                update_form = GroupUpdateForm(
+                    name=group_model.name,
+                    description=group_model.description,
+                    permissions=group_permissions,
+                    user_ids=user_ids,
+                )
+                Groups.update_group_by_id(
+                    id=group_model.id, form_data=update_form, overwrite=False
+                )
+
     async def handle_login(self, provider, request):
         if provider not in OAUTH_PROVIDERS:
             raise HTTPException(404)
@@ -254,6 +314,13 @@ class OAuthManager:
             expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
         )
 
+        if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
+            self.update_user_groups(
+                user=user,
+                user_data=user_data,
+                default_permissions=request.app.state.config.USER_PERMISSIONS,
+            )
+
         # Set the cookie token
         response.set_cookie(
             key="token",

+ 7 - 0
backend/open_webui/utils/payload.py

@@ -154,9 +154,16 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
     )
     ollama_payload["stream"] = openai_payload.get("stream", False)
 
+    if "format" in openai_payload:
+        ollama_payload["format"] = openai_payload["format"]
+
     # If there are advanced parameters in the payload, format them in Ollama's options field
     ollama_options = {}
 
+    if openai_payload.get("options"):
+        ollama_payload["options"] = openai_payload["options"]
+        ollama_options = openai_payload["options"]
+
     # Handle parameters which map directly
     for param in ["temperature", "top_p", "seed"]:
         if param in openai_payload:

+ 6 - 19
backend/open_webui/utils/response.py

@@ -29,7 +29,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
                         (
                             (
                                 data.get("eval_count", 0)
-                                / ((data.get("eval_duration", 0) / 1_000_000_000))
+                                / ((data.get("eval_duration", 0) / 10_000_000))
                             )
                             * 100
                         ),
@@ -43,12 +43,7 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
                         (
                             (
                                 data.get("prompt_eval_count", 0)
-                                / (
-                                    (
-                                        data.get("prompt_eval_duration", 0)
-                                        / 1_000_000_000
-                                    )
-                                )
+                                / ((data.get("prompt_eval_duration", 0) / 10_000_000))
                             )
                             * 100
                         ),
@@ -57,20 +52,12 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response)
                     if data.get("prompt_eval_duration", 0) > 0
                     else "N/A"
                 ),
-                "total_duration": round(
-                    ((data.get("total_duration", 0) / 1_000_000) * 100), 2
-                ),
-                "load_duration": round(
-                    ((data.get("load_duration", 0) / 1_000_000) * 100), 2
-                ),
+                "total_duration": data.get("total_duration", 0),
+                "load_duration": data.get("load_duration", 0),
                 "prompt_eval_count": data.get("prompt_eval_count", 0),
-                "prompt_eval_duration": round(
-                    ((data.get("prompt_eval_duration", 0) / 1_000_000) * 100), 2
-                ),
+                "prompt_eval_duration": data.get("prompt_eval_duration", 0),
                 "eval_count": data.get("eval_count", 0),
-                "eval_duration": round(
-                    ((data.get("eval_duration", 0) / 1_000_000) * 100), 2
-                ),
+                "eval_duration": data.get("eval_duration", 0),
                 "approximate_total": (
                     lambda s: f"{s // 3600}h{(s % 3600) // 60}m{s % 60}s"
                 )((data.get("total_duration", 0) or 0) // 1_000_000_000),

+ 6 - 1
backend/open_webui/utils/webhook.py

@@ -11,6 +11,7 @@ log.setLevel(SRC_LOG_LEVELS["WEBHOOK"])
 
 def post_webhook(url: str, message: str, event_data: dict) -> bool:
     try:
+        log.debug(f"post_webhook: {url}, {message}, {event_data}")
         payload = {}
 
         # Slack and Google Chat Webhooks
@@ -18,7 +19,11 @@ def post_webhook(url: str, message: str, event_data: dict) -> bool:
             payload["text"] = message
         # Discord Webhooks
         elif "https://discord.com/api/webhooks" in url:
-            payload["content"] = message
+            payload["content"] = (
+                message
+                if len(message) < 2000
+                else f"{message[: 2000 - 20]}... (truncated)"
+            )
         # Microsoft Teams Webhooks
         elif "webhook.office.com" in url:
             action = event_data.get("action", "undefined")

+ 10 - 5
backend/requirements.txt

@@ -3,7 +3,7 @@ uvicorn[standard]==0.30.6
 pydantic==2.9.2
 python-multipart==0.0.18
 
-Flask==3.0.3
+Flask==3.1.0
 Flask-Cors==5.0.0
 
 python-socketio==5.11.3
@@ -18,7 +18,7 @@ aiofiles
 
 sqlalchemy==2.0.32
 alembic==1.14.0
-peewee==3.17.6
+peewee==3.17.8
 peewee-migrate==1.12.2
 psycopg2-binary==2.9.9
 pgvector==0.3.5
@@ -55,7 +55,7 @@ einops==0.8.0
 
 ftfy==6.2.3
 pypdf==4.3.1
-fpdf2==2.7.9
+fpdf2==2.8.2
 pymdown-extensions==10.11.2
 docx2txt==0.8
 python-pptx==1.0.0
@@ -67,7 +67,7 @@ pandas==2.2.3
 openpyxl==3.1.5
 pyxlsb==1.0.10
 xlrd==2.0.1
-validators==0.33.0
+validators==0.34.0
 psutil
 sentencepiece
 soundfile==0.12.1
@@ -78,7 +78,7 @@ rank-bm25==0.2.2
 
 faster-whisper==1.0.3
 
-PyJWT[crypto]==2.9.0
+PyJWT[crypto]==2.10.1
 authlib==1.3.2
 
 black==24.8.0
@@ -90,6 +90,11 @@ extract_msg
 pydub
 duckduckgo-search~=6.3.5
 
+## Google Drive
+google-api-python-client
+google-auth-httplib2
+google-auth-oauthlib
+
 ## Tests
 docker~=7.1.0
 pytest~=8.3.2

+ 20 - 11
package-lock.json

@@ -1,12 +1,12 @@
 {
 	"name": "open-webui",
-	"version": "0.4.8",
+	"version": "0.5.4",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "open-webui",
-			"version": "0.4.8",
+			"version": "0.5.4",
 			"dependencies": {
 				"@codemirror/lang-javascript": "^6.2.2",
 				"@codemirror/lang-python": "^6.1.6",
@@ -16,6 +16,7 @@
 				"@mediapipe/tasks-vision": "^0.10.17",
 				"@pyscript/core": "^0.4.32",
 				"@sveltejs/adapter-node": "^2.0.0",
+				"@sveltejs/svelte-virtual-list": "^3.0.1",
 				"@tiptap/core": "^2.10.0",
 				"@tiptap/extension-code-block-lowlight": "^2.10.0",
 				"@tiptap/extension-highlight": "^2.10.0",
@@ -2260,9 +2261,9 @@
 			}
 		},
 		"node_modules/@sveltejs/kit": {
-			"version": "2.9.0",
-			"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.9.0.tgz",
-			"integrity": "sha512-W3E7ed3ChB6kPqRs2H7tcHp+Z7oiTFC6m+lLyAQQuyXeqw6LdNuuwEUla+5VM0OGgqQD+cYD6+7Xq80vVm17Vg==",
+			"version": "2.12.1",
+			"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.12.1.tgz",
+			"integrity": "sha512-M3rPijGImeOkI0DBJSwjqz+YFX2DyOf6NzWgHVk3mqpT06dlYCpcv5xh1q4rYEqB58yQlk4QA1Y35PUqnUiFKw==",
 			"hasInstallScript": true,
 			"license": "MIT",
 			"dependencies": {
@@ -2291,6 +2292,12 @@
 				"vite": "^5.0.3 || ^6.0.0"
 			}
 		},
+		"node_modules/@sveltejs/svelte-virtual-list": {
+			"version": "3.0.1",
+			"resolved": "https://registry.npmjs.org/@sveltejs/svelte-virtual-list/-/svelte-virtual-list-3.0.1.tgz",
+			"integrity": "sha512-aF9TptS7NKKS7/TqpsxQBSDJ9Q0XBYzBehCeIC5DzdMEgrJZpIYao9LRLnyyo6SVodpapm2B7FE/Lj+FSA5/SQ==",
+			"license": "LIL"
+		},
 		"node_modules/@sveltejs/vite-plugin-svelte": {
 			"version": "3.1.1",
 			"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz",
@@ -8267,15 +8274,16 @@
 			}
 		},
 		"node_modules/nanoid": {
-			"version": "5.0.6",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.6.tgz",
-			"integrity": "sha512-rRq0eMHoGZxlvaFOUdK1Ev83Bd1IgzzR+WJ3IbDJ7QOSdAxYjlurSPqFs9s4lJg29RT6nPwizFtJhQS6V5xgiA==",
+			"version": "5.0.9",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.9.tgz",
+			"integrity": "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q==",
 			"funding": [
 				{
 					"type": "github",
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"bin": {
 				"nanoid": "bin/nanoid.js"
 			},
@@ -8976,15 +8984,16 @@
 			"dev": true
 		},
 		"node_modules/postcss/node_modules/nanoid": {
-			"version": "3.3.7",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
-			"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+			"version": "3.3.8",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
+			"integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
 			"funding": [
 				{
 					"type": "github",
 					"url": "https://github.com/sponsors/ai"
 				}
 			],
+			"license": "MIT",
 			"bin": {
 				"nanoid": "bin/nanoid.cjs"
 			},

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "open-webui",
-	"version": "0.4.8",
+	"version": "0.5.4",
 	"private": true,
 	"scripts": {
 		"dev": "npm run pyodide:fetch && vite dev --host",
@@ -50,7 +50,6 @@
 	"type": "module",
 	"dependencies": {
 		"@codemirror/lang-javascript": "^6.2.2",
-		"codemirror-lang-hcl": "^0.0.0-beta.2",
 		"@codemirror/lang-python": "^6.1.6",
 		"@codemirror/language-data": "^6.5.1",
 		"@codemirror/theme-one-dark": "^6.1.2",
@@ -58,6 +57,7 @@
 		"@mediapipe/tasks-vision": "^0.10.17",
 		"@pyscript/core": "^0.4.32",
 		"@sveltejs/adapter-node": "^2.0.0",
+		"@sveltejs/svelte-virtual-list": "^3.0.1",
 		"@tiptap/core": "^2.10.0",
 		"@tiptap/extension-code-block-lowlight": "^2.10.0",
 		"@tiptap/extension-highlight": "^2.10.0",
@@ -69,6 +69,7 @@
 		"async": "^3.2.5",
 		"bits-ui": "^0.19.7",
 		"codemirror": "^6.0.1",
+		"codemirror-lang-hcl": "^0.0.0-beta.2",
 		"crc-32": "^1.2.2",
 		"dayjs": "^1.11.10",
 		"dompurify": "^3.1.6",

+ 12 - 5
pyproject.toml

@@ -11,7 +11,7 @@ dependencies = [
     "pydantic==2.9.2",
     "python-multipart==0.0.18",
 
-    "Flask==3.0.3",
+    "Flask==3.1.0",
     "Flask-Cors==5.0.0",
 
     "python-socketio==5.11.3",
@@ -26,7 +26,7 @@ dependencies = [
 
     "sqlalchemy==2.0.32",
     "alembic==1.14.0",
-    "peewee==3.17.6",
+    "peewee==3.17.8",
     "peewee-migrate==1.12.2",
     "psycopg2-binary==2.9.9",
     "pgvector==0.3.5",
@@ -61,7 +61,7 @@ dependencies = [
 
     "ftfy==6.2.3",
     "pypdf==4.3.1",
-    "fpdf2==2.7.9",
+    "fpdf2==2.8.2",
     "pymdown-extensions==10.11.2",
     "docx2txt==0.8",
     "python-pptx==1.0.0",
@@ -73,7 +73,7 @@ dependencies = [
     "openpyxl==3.1.5",
     "pyxlsb==1.0.10",
     "xlrd==2.0.1",
-    "validators==0.33.0",
+    "validators==0.34.0",
     "psutil",
     "sentencepiece",
     "soundfile==0.12.1",
@@ -84,7 +84,7 @@ dependencies = [
 
     "faster-whisper==1.0.3",
 
-    "PyJWT[crypto]==2.9.0",
+    "PyJWT[crypto]==2.10.1",
     "authlib==1.3.2",
 
     "black==24.8.0",
@@ -151,3 +151,10 @@ exclude = [
     "chroma.sqlite3",
 ]
 force-include = { "CHANGELOG.md" = "open_webui/CHANGELOG.md", build = "open_webui/frontend" }
+
+[tool.codespell]
+# Ref: https://github.com/codespell-project/codespell#using-a-config-file
+skip = '.git*,*.svg,package-lock.json,i18n,*.lock,*.css,*-bundle.js,locales,example-doc.txt,emoji-shortcodes.json'
+check-hidden = true
+# ignore-regex = ''
+ignore-words-list = 'ans'

+ 5 - 1
src/app.css

@@ -53,7 +53,11 @@ math {
 }
 
 .markdown-prose {
-	@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 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-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;
+}
+
+.markdown-prose-xs {
+	@apply text-xs prose dark:prose-invert prose-headings:font-semibold prose-hr:my-0  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;
 }
 
 .markdown a {

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

@@ -0,0 +1,442 @@
+import { WEBUI_API_BASE_URL } from '$lib/constants';
+import { t } from 'i18next';
+
+type ChannelForm = {
+	name: string;
+	data?: object;
+	meta?: object;
+	access_control?: object;
+};
+
+export const createNewChannel = async (token: string = '', channel: ChannelForm) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/channels/create`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({ ...channel })
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getChannels = async (token: string = '') => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/channels/`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getChannelById = async (token: string = '', channel_id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}`, {
+		method: 'GET',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const updateChannelById = async (
+	token: string = '',
+	channel_id: string,
+	channel: ChannelForm
+) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/update`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({ ...channel })
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const deleteChannelById = async (token: string = '', channel_id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/delete`, {
+		method: 'DELETE',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getChannelMessages = async (
+	token: string = '',
+	channel_id: string,
+	skip: number = 0,
+	limit: number = 50
+) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages?skip=${skip}&limit=${limit}`,
+		{
+			method: 'GET',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			}
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const getChannelThreadMessages = async (
+	token: string = '',
+	channel_id: string,
+	message_id: string,
+	skip: number = 0,
+	limit: number = 50
+) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/thread?skip=${skip}&limit=${limit}`,
+		{
+			method: 'GET',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			}
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+type MessageForm = {
+	parent_id?: string;
+	content: string;
+	data?: object;
+	meta?: object;
+};
+
+export const sendMessage = async (token: string = '', channel_id: string, message: MessageForm) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/post`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			authorization: `Bearer ${token}`
+		},
+		body: JSON.stringify({ ...message })
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const updateMessage = async (
+	token: string = '',
+	channel_id: string,
+	message_id: string,
+	message: MessageForm
+) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/update`,
+		{
+			method: 'POST',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			},
+			body: JSON.stringify({ ...message })
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const addReaction = async (
+	token: string = '',
+	channel_id: string,
+	message_id: string,
+	name: string
+) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/add`,
+		{
+			method: 'POST',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			},
+			body: JSON.stringify({ name })
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const removeReaction = async (
+	token: string = '',
+	channel_id: string,
+	message_id: string,
+	name: string
+) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/reactions/remove`,
+		{
+			method: 'POST',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			},
+			body: JSON.stringify({ name })
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
+export const deleteMessage = async (token: string = '', channel_id: string, message_id: string) => {
+	let error = null;
+
+	const res = await fetch(
+		`${WEBUI_API_BASE_URL}/channels/${channel_id}/messages/${message_id}/delete`,
+		{
+			method: 'DELETE',
+			headers: {
+				Accept: 'application/json',
+				'Content-Type': 'application/json',
+				authorization: `Bearer ${token}`
+			}
+		}
+	)
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err.detail;
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};

+ 38 - 0
src/lib/apis/chats/index.ts

@@ -618,6 +618,44 @@ export const cloneChatById = async (token: string, id: string) => {
 	return res;
 };
 
+export const cloneSharedChatById = async (token: string, id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/${id}/clone/shared`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.then((json) => {
+			return json;
+		})
+		.catch((err) => {
+			error = err;
+
+			if ('detail' in err) {
+				error = err.detail;
+			} else {
+				error = err;
+			}
+
+			console.log(err);
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
 export const shareChatById = async (token: string, id: string) => {
 	let error = null;
 

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

@@ -107,6 +107,38 @@ export const chatAction = async (token: string, action_id: string, body: ChatAct
 	return res;
 };
 
+export const stopTask = async (token: string, id: string) => {
+	let error = null;
+
+	const res = await fetch(`${WEBUI_BASE_URL}/api/tasks/stop/${id}`, {
+		method: 'POST',
+		headers: {
+			Accept: 'application/json',
+			'Content-Type': 'application/json',
+			...(token && { authorization: `Bearer ${token}` })
+		}
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			console.log(err);
+			if ('detail' in err) {
+				error = err.detail;
+			} else {
+				error = err;
+			}
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
 export const getTaskConfig = async (token: string = '') => {
 	let error = null;
 
@@ -650,7 +682,7 @@ export const getPipelines = async (token: string, urlIdx?: string) => {
 		searchParams.append('urlIdx', urlIdx);
 	}
 
-	const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines?${searchParams.toString()}`, {
+	const res = await fetch(`${WEBUI_BASE_URL}/api/v1/pipelines/?${searchParams.toString()}`, {
 		method: 'GET',
 		headers: {
 			Accept: 'application/json',

+ 34 - 3
src/lib/apis/openai/index.ts

@@ -1,4 +1,4 @@
-import { OPENAI_API_BASE_URL } from '$lib/constants';
+import { OPENAI_API_BASE_URL, WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
 
 export const getOpenAIConfig = async (token: string = '') => {
 	let error = null;
@@ -273,10 +273,10 @@ export const verifyOpenAIConnection = async (
 	return res;
 };
 
-export const generateOpenAIChatCompletion = async (
+export const chatCompletion = async (
 	token: string = '',
 	body: object,
-	url: string = OPENAI_API_BASE_URL
+	url: string = `${WEBUI_BASE_URL}/api`
 ): Promise<[Response | null, AbortController]> => {
 	const controller = new AbortController();
 	let error = null;
@@ -302,6 +302,37 @@ export const generateOpenAIChatCompletion = async (
 	return [res, controller];
 };
 
+export const generateOpenAIChatCompletion = async (
+	token: string = '',
+	body: object,
+	url: string = `${WEBUI_BASE_URL}/api`
+) => {
+	let error = null;
+
+	const res = await fetch(`${url}/chat/completions`, {
+		method: 'POST',
+		headers: {
+			Authorization: `Bearer ${token}`,
+			'Content-Type': 'application/json'
+		},
+		body: JSON.stringify(body)
+	})
+		.then(async (res) => {
+			if (!res.ok) throw await res.json();
+			return res.json();
+		})
+		.catch((err) => {
+			error = `${err?.detail ?? 'Network Problem'}`;
+			return null;
+		});
+
+	if (error) {
+		throw error;
+	}
+
+	return res;
+};
+
 export const synthesizeOpenAISpeech = async (
 	token: string = '',
 	speaker: string = 'alloy',

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

@@ -4,7 +4,7 @@ type PromptItem = {
 	command: string;
 	title: string;
 	content: string;
-	access_control: null | object;
+	access_control?: null | object;
 };
 
 export const createNewPrompt = async (token: string, prompt: PromptItem) => {

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

@@ -45,6 +45,7 @@ type YoutubeConfigForm = {
 
 type RAGConfigForm = {
 	pdf_extract_images?: boolean;
+	enable_google_drive_integration?: boolean;
 	chunk?: ChunkConfigForm;
 	content_extraction?: ContentExtractConfigForm;
 	web_loader_ssl_verification?: boolean;

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

@@ -84,7 +84,7 @@ async function* openAIStreamToIterator(
 
 			yield {
 				done: false,
-				value: parsedData.choices?.[0]?.delta?.content ?? '',
+				value: parsedData.choices?.[0]?.delta?.content ?? ''
 			};
 		} catch (e) {
 			console.error('Error extracting delta from SSE event:', e);
@@ -120,8 +120,6 @@ async function* streamLargeDeltasAsRandomChunks(
 			continue;
 		}
 
-
-
 		let content = textStreamUpdate.value;
 		if (content.length < 5) {
 			yield { done: false, value: content };

+ 53 - 0
src/lib/components/NotificationToast.svelte

@@ -0,0 +1,53 @@
+<script lang="ts">
+	import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
+	import DOMPurify from 'dompurify';
+
+	import { marked } from 'marked';
+	import { createEventDispatcher, onMount } from 'svelte';
+
+	const dispatch = createEventDispatcher();
+
+	export let onClick: Function = () => {};
+	export let title: string = 'HI';
+	export let content: string;
+
+	onMount(() => {
+		if (!navigator.userActivation.hasBeenActive) {
+			return;
+		}
+
+		if ($settings?.notificationSound ?? true) {
+			if (!$playingNotificationSound && $isLastActiveTab) {
+				playingNotificationSound.set(true);
+
+				const audio = new Audio(`/audio/notification.mp3`);
+				audio.play().finally(() => {
+					// Ensure the global state is reset after the sound finishes
+					playingNotificationSound.set(false);
+				});
+			}
+		}
+	});
+</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-50 dark:border-gray-800 rounded-xl px-3.5 py-3.5"
+	on:click={() => {
+		onClick();
+		dispatch('closeToast');
+	}}
+>
+	<div class="flex-shrink-0 self-top -translate-y-0.5">
+		<img src={'/static/favicon.png'} alt="favicon" class="size-7 rounded-full" />
+	</div>
+
+	<div>
+		{#if title}
+			<div class=" text-[13px] font-medium mb-0.5 line-clamp-1 capitalize">{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>

+ 21 - 1
src/lib/components/admin/Settings/Documents.svelte

@@ -56,6 +56,8 @@
 	let chunkOverlap = 0;
 	let pdfExtractImages = true;
 
+	let enableGoogleDriveIntegration = false;
+
 	let OpenAIUrl = '';
 	let OpenAIKey = '';
 
@@ -175,6 +177,7 @@
 		}
 		const res = await updateRAGConfig(localStorage.token, {
 			pdf_extract_images: pdfExtractImages,
+			enable_google_drive_integration: enableGoogleDriveIntegration,
 			file: {
 				max_size: fileMaxSize === '' ? null : fileMaxSize,
 				max_count: fileMaxCount === '' ? null : fileMaxCount
@@ -245,6 +248,8 @@
 
 			fileMaxSize = res?.file.max_size ?? '';
 			fileMaxCount = res?.file.max_count ?? '';
+
+			enableGoogleDriveIntegration = res.enable_google_drive_integration;
 		}
 	});
 </script>
@@ -586,6 +591,19 @@
 
 		<hr class=" dark:border-gray-850" />
 
+		<div class="text-sm font-medium mb-1">{$i18n.t('Google Drive')}</div>
+
+		<div class="">
+			<div class="flex justify-between items-center text-xs">
+				<div class="text-xs font-medium">{$i18n.t('Enable Google Drive')}</div>
+				<div>
+					<Switch bind:state={enableGoogleDriveIntegration} />
+				</div>
+			</div>
+		</div>
+
+		<hr class=" dark:border-gray-850" />
+
 		<div class=" ">
 			<div class=" text-sm font-medium mb-1">{$i18n.t('Query Params')}</div>
 
@@ -669,7 +687,9 @@
 
 			<div class=" flex gap-1.5">
 				<div class="  w-full justify-between">
-					<div class="self-center text-xs font-medium min-w-fit mb-1">{$i18n.t('Chunk Size')}</div>
+					<div class="self-center text-xs font-medium min-w-fit mb-1">
+						{$i18n.t('Chunk Size')}
+					</div>
 					<div class="self-center">
 						<input
 							class=" w-full rounded-lg py-1.5 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"

+ 71 - 2
src/lib/components/admin/Settings/General.svelte

@@ -112,12 +112,48 @@
 					</div>
 				</div>
 
-				<div class="  flex w-full justify-between pr-2">
-					<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key Auth')}</div>
+				<div class=" flex w-full justify-between pr-2 my-3">
+					<div class=" self-center text-xs font-medium">{$i18n.t('Enable API Key')}</div>
 
 					<Switch bind:state={adminConfig.ENABLE_API_KEY} />
 				</div>
 
+				{#if adminConfig?.ENABLE_API_KEY}
+					<div class=" flex w-full justify-between pr-2 my-3">
+						<div class=" self-center text-xs font-medium">
+							{$i18n.t('API Key Endpoint Restrictions')}
+						</div>
+
+						<Switch bind:state={adminConfig.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS} />
+					</div>
+
+					{#if adminConfig?.ENABLE_API_KEY_ENDPOINT_RESTRICTIONS}
+						<div class=" flex w-full flex-col pr-2">
+							<div class=" text-xs font-medium">
+								{$i18n.t('Allowed Endpoints')}
+							</div>
+
+							<input
+								class="w-full mt-1 rounded-lg text-sm dark:text-gray-300 bg-transparent outline-none"
+								type="text"
+								placeholder={`e.g.) /api/v1/messages, /api/v1/channels`}
+								bind:value={adminConfig.API_KEY_ALLOWED_ENDPOINTS}
+							/>
+
+							<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
+								<!-- https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints -->
+								<a
+									href="https://docs.openwebui.com/getting-started/advanced-topics/api-endpoints"
+									target="_blank"
+									class=" text-gray-300 font-medium underline"
+								>
+									{$i18n.t('To learn more about available endpoints, visit our documentation.')}
+								</a>
+							</div>
+						</div>
+					{/if}
+				{/if}
+
 				<hr class=" border-gray-50 dark:border-gray-850 my-2" />
 
 				<div class="my-3 flex w-full items-center justify-between pr-2">
@@ -142,6 +178,29 @@
 
 				<hr class=" border-gray-50 dark:border-gray-850 my-2" />
 
+				<div class=" w-full justify-between">
+					<div class="flex w-full justify-between">
+						<div class=" self-center text-xs font-medium">{$i18n.t('WebUI URL')}</div>
+					</div>
+
+					<div class="flex mt-2 space-x-2">
+						<input
+							class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
+							type="text"
+							placeholder={`e.g.) "http://localhost:3000"`}
+							bind:value={adminConfig.WEBUI_URL}
+						/>
+					</div>
+
+					<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
+						{$i18n.t(
+							'Enter the public URL of your WebUI. This URL will be used to generate links in the notifications.'
+						)}
+					</div>
+				</div>
+
+				<hr class=" border-gray-50 dark:border-gray-850 my-2" />
+
 				<div class=" w-full justify-between">
 					<div class="flex w-full justify-between">
 						<div class=" self-center text-xs font-medium">{$i18n.t('JWT Expiration')}</div>
@@ -180,6 +239,16 @@
 						/>
 					</div>
 				</div>
+
+				<hr class=" border-gray-50 dark:border-gray-850 my-2" />
+
+				<div class="pt-1 flex w-full justify-between pr-2">
+					<div class=" self-center text-sm font-medium">
+						{$i18n.t('Channels')} ({$i18n.t('Beta')})
+					</div>
+
+					<Switch bind:state={adminConfig.ENABLE_CHANNELS} />
+				</div>
 			</div>
 		{/if}
 

+ 13 - 0
src/lib/components/admin/Settings/Images.svelte

@@ -470,6 +470,19 @@
 						</div>
 					</div>
 
+					<div class="">
+						<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI API Key')}</div>
+						<div class="flex w-full">
+							<div class="flex-1 mr-2">
+								<SensitiveInput
+									placeholder={$i18n.t('sk-1234')}
+									bind:value={config.comfyui.COMFYUI_API_KEY}
+									required={false}
+								/>
+							</div>
+						</div>
+					</div>
+
 					<div class="">
 						<div class=" mb-2 text-sm font-medium">{$i18n.t('ComfyUI Workflow')}</div>
 

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

@@ -128,7 +128,7 @@
 			await toggleModelById(localStorage.token, model.id);
 		}
 
-		await init();
+		// await init();
 		_models.set(await getModels(localStorage.token));
 	};
 

+ 302 - 0
src/lib/components/channel/Channel.svelte

@@ -0,0 +1,302 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+	import { Pane, PaneGroup, PaneResizer } from 'paneforge';
+
+	import { onDestroy, onMount, tick } from 'svelte';
+	import { goto } from '$app/navigation';
+
+	import { chatId, showSidebar, socket, user } from '$lib/stores';
+	import { getChannelById, getChannelMessages, sendMessage } from '$lib/apis/channels';
+
+	import Messages from './Messages.svelte';
+	import MessageInput from './MessageInput.svelte';
+	import Navbar from './Navbar.svelte';
+	import Drawer from '../common/Drawer.svelte';
+	import EllipsisVertical from '../icons/EllipsisVertical.svelte';
+	import Thread from './Thread.svelte';
+
+	export let id = '';
+
+	let scrollEnd = true;
+	let messagesContainerElement = null;
+
+	let top = false;
+
+	let channel = null;
+	let messages = null;
+
+	let threadId = null;
+
+	let typingUsers = [];
+	let typingUsersTimeout = {};
+
+	$: if (id) {
+		initHandler();
+	}
+
+	const scrollToBottom = () => {
+		if (messagesContainerElement) {
+			messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
+		}
+	};
+
+	const initHandler = async () => {
+		top = false;
+		messages = null;
+		channel = null;
+		threadId = null;
+
+		typingUsers = [];
+		typingUsersTimeout = {};
+
+		channel = await getChannelById(localStorage.token, id).catch((error) => {
+			return null;
+		});
+
+		if (channel) {
+			messages = await getChannelMessages(localStorage.token, id, 0);
+
+			if (messages) {
+				scrollToBottom();
+
+				if (messages.length < 50) {
+					top = true;
+				}
+			}
+		} else {
+			goto('/');
+		}
+	};
+
+	const channelEventHandler = async (event) => {
+		if (event.channel_id === id) {
+			const type = event?.data?.type ?? null;
+			const data = event?.data?.data ?? null;
+
+			if (type === 'message') {
+				if ((data?.parent_id ?? null) === null) {
+					messages = [data, ...messages];
+
+					if (typingUsers.find((user) => user.id === event.user.id)) {
+						typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
+					}
+
+					await tick();
+					if (scrollEnd) {
+						scrollToBottom();
+					}
+				}
+			} else if (type === 'message:update') {
+				const idx = messages.findIndex((message) => message.id === data.id);
+
+				if (idx !== -1) {
+					messages[idx] = data;
+				}
+			} else if (type === 'message:delete') {
+				messages = messages.filter((message) => message.id !== data.id);
+			} else if (type === 'message:reply') {
+				const idx = messages.findIndex((message) => message.id === data.id);
+
+				if (idx !== -1) {
+					messages[idx] = data;
+				}
+			} else if (type.includes('message:reaction')) {
+				const idx = messages.findIndex((message) => message.id === data.id);
+				if (idx !== -1) {
+					messages[idx] = data;
+				}
+			} else if (type === 'typing' && event.message_id === null) {
+				if (event.user.id === $user.id) {
+					return;
+				}
+
+				typingUsers = data.typing
+					? [
+							...typingUsers,
+							...(typingUsers.find((user) => user.id === event.user.id)
+								? []
+								: [
+										{
+											id: event.user.id,
+											name: event.user.name
+										}
+									])
+						]
+					: typingUsers.filter((user) => user.id !== event.user.id);
+
+				if (typingUsersTimeout[event.user.id]) {
+					clearTimeout(typingUsersTimeout[event.user.id]);
+				}
+
+				typingUsersTimeout[event.user.id] = setTimeout(() => {
+					typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
+				}, 5000);
+			}
+		}
+	};
+
+	const submitHandler = async ({ content, data }) => {
+		if (!content && (data?.files ?? []).length === 0) {
+			return;
+		}
+
+		const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
+			(error) => {
+				toast.error(error);
+				return null;
+			}
+		);
+
+		if (res) {
+			messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
+		}
+	};
+
+	const onChange = async () => {
+		$socket?.emit('channel-events', {
+			channel_id: id,
+			message_id: null,
+			data: {
+				type: 'typing',
+				data: {
+					typing: true
+				}
+			}
+		});
+	};
+
+	let mediaQuery;
+	let largeScreen = false;
+
+	onMount(() => {
+		if ($chatId) {
+			chatId.set('');
+		}
+
+		$socket?.on('channel-events', channelEventHandler);
+
+		mediaQuery = window.matchMedia('(min-width: 1024px)');
+
+		const handleMediaQuery = async (e) => {
+			if (e.matches) {
+				largeScreen = true;
+			} else {
+				largeScreen = false;
+			}
+		};
+
+		mediaQuery.addEventListener('change', handleMediaQuery);
+		handleMediaQuery(mediaQuery);
+	});
+
+	onDestroy(() => {
+		$socket?.off('channel-events', channelEventHandler);
+	});
+</script>
+
+<svelte:head>
+	<title>#{channel?.name ?? 'Channel'} | Open WebUI</title>
+</svelte:head>
+
+<div
+	class="h-screen max-h-[100dvh] {$showSidebar
+		? 'md:max-w-[calc(100%-260px)]'
+		: ''} w-full max-w-full flex flex-col"
+	id="channel-container"
+>
+	<PaneGroup direction="horizontal" class="w-full h-full">
+		<Pane defaultSize={50} minSize={50} class="h-full flex flex-col w-full relative">
+			<Navbar {channel} />
+
+			<div class="flex-1 overflow-y-auto">
+				{#if channel}
+					<div
+						class=" pb-2.5 max-w-full z-10 scrollbar-hidden w-full h-full pt-6 flex-1 flex flex-col-reverse overflow-auto"
+						id="messages-container"
+						bind:this={messagesContainerElement}
+						on:scroll={(e) => {
+							scrollEnd = Math.abs(messagesContainerElement.scrollTop) <= 50;
+						}}
+					>
+						{#key id}
+							<Messages
+								{channel}
+								{messages}
+								{top}
+								onThread={(id) => {
+									threadId = id;
+								}}
+								onLoad={async () => {
+									const newMessages = await getChannelMessages(
+										localStorage.token,
+										id,
+										messages.length
+									);
+
+									messages = [...messages, ...newMessages];
+
+									if (newMessages.length < 50) {
+										top = true;
+										return;
+									}
+								}}
+							/>
+						{/key}
+					</div>
+				{/if}
+			</div>
+
+			<div class=" pb-[1rem]">
+				<MessageInput
+					id="root"
+					{typingUsers}
+					{onChange}
+					onSubmit={submitHandler}
+					{scrollToBottom}
+					{scrollEnd}
+				/>
+			</div>
+		</Pane>
+
+		{#if !largeScreen}
+			{#if threadId !== null}
+				<Drawer
+					show={threadId !== null}
+					on:close={() => {
+						threadId = null;
+					}}
+				>
+					<div class=" {threadId !== null ? ' h-screen  w-screen' : 'px-6 py-4'} h-full">
+						<Thread
+							{threadId}
+							{channel}
+							onClose={() => {
+								threadId = null;
+							}}
+						/>
+					</div>
+				</Drawer>
+			{/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"
+			>
+				<div class="z-10 flex h-7 w-5 items-center justify-center rounded-sm">
+					<EllipsisVertical className="size-4 invisible group-hover:visible" />
+				</div>
+			</PaneResizer>
+
+			<Pane defaultSize={50} minSize={30} class="h-full w-full">
+				<div class="h-full w-full shadow-xl">
+					<Thread
+						{threadId}
+						{channel}
+						onClose={() => {
+							threadId = null;
+						}}
+					/>
+				</div>
+			</Pane>
+		{/if}
+	</PaneGroup>
+</div>

+ 613 - 0
src/lib/components/channel/MessageInput.svelte

@@ -0,0 +1,613 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+	import { v4 as uuidv4 } from 'uuid';
+
+	import { tick, getContext, onMount, onDestroy } from 'svelte';
+
+	const i18n = getContext('i18n');
+
+	import { config, mobile, settings, socket } from '$lib/stores';
+	import { blobToFile, compressImage } from '$lib/utils';
+
+	import Tooltip from '../common/Tooltip.svelte';
+	import RichTextInput from '../common/RichTextInput.svelte';
+	import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
+	import InputMenu from './MessageInput/InputMenu.svelte';
+	import { uploadFile } from '$lib/apis/files';
+	import { WEBUI_API_BASE_URL } from '$lib/constants';
+	import FileItem from '../common/FileItem.svelte';
+	import Image from '../common/Image.svelte';
+	import { transcribeAudio } from '$lib/apis/audio';
+	import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
+
+	export let placeholder = $i18n.t('Send a Message');
+	export let transparentBackground = false;
+
+	export let id = null;
+
+	let draggedOver = false;
+
+	let recording = false;
+	let content = '';
+	let files = [];
+
+	let filesInputElement;
+	let inputFiles;
+
+	export let typingUsers = [];
+
+	export let onSubmit: Function;
+	export let onChange: Function;
+	export let scrollEnd = true;
+	export let scrollToBottom: Function = () => {};
+
+	const screenCaptureHandler = async () => {
+		try {
+			// Request screen media
+			const mediaStream = await navigator.mediaDevices.getDisplayMedia({
+				video: { cursor: 'never' },
+				audio: false
+			});
+			// Once the user selects a screen, temporarily create a video element
+			const video = document.createElement('video');
+			video.srcObject = mediaStream;
+			// Ensure the video loads without affecting user experience or tab switching
+			await video.play();
+			// Set up the canvas to match the video dimensions
+			const canvas = document.createElement('canvas');
+			canvas.width = video.videoWidth;
+			canvas.height = video.videoHeight;
+			// Grab a single frame from the video stream using the canvas
+			const context = canvas.getContext('2d');
+			context.drawImage(video, 0, 0, canvas.width, canvas.height);
+			// Stop all video tracks (stop screen sharing) after capturing the image
+			mediaStream.getTracks().forEach((track) => track.stop());
+
+			// bring back focus to this current tab, so that the user can see the screen capture
+			window.focus();
+
+			// Convert the canvas to a Base64 image URL
+			const imageUrl = canvas.toDataURL('image/png');
+			// Add the captured image to the files array to render it
+			files = [...files, { type: 'image', url: imageUrl }];
+			// Clean memory: Clear video srcObject
+			video.srcObject = null;
+		} catch (error) {
+			// Handle any errors (e.g., user cancels screen sharing)
+			console.error('Error capturing screen:', error);
+		}
+	};
+
+	const inputFilesHandler = async (inputFiles) => {
+		inputFiles.forEach((file) => {
+			console.log('Processing file:', {
+				name: file.name,
+				type: file.type,
+				size: file.size,
+				extension: file.name.split('.').at(-1)
+			});
+
+			if (
+				($config?.file?.max_size ?? null) !== null &&
+				file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
+			) {
+				console.log('File exceeds max size limit:', {
+					fileSize: file.size,
+					maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
+				});
+				toast.error(
+					$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
+						maxSize: $config?.file?.max_size
+					})
+				);
+				return;
+			}
+
+			if (['image/gif', 'image/webp', 'image/jpeg', 'image/png'].includes(file['type'])) {
+				let reader = new FileReader();
+
+				reader.onload = async (event) => {
+					let imageUrl = event.target.result;
+
+					if ($settings?.imageCompression ?? false) {
+						const width = $settings?.imageCompressionSize?.width ?? null;
+						const height = $settings?.imageCompressionSize?.height ?? null;
+
+						if (width || height) {
+							imageUrl = await compressImage(imageUrl, width, height);
+						}
+					}
+
+					files = [
+						...files,
+						{
+							type: 'image',
+							url: `${imageUrl}`
+						}
+					];
+				};
+
+				reader.readAsDataURL(file);
+			} else {
+				uploadFileHandler(file);
+			}
+		});
+	};
+
+	const uploadFileHandler = async (file) => {
+		const tempItemId = uuidv4();
+		const fileItem = {
+			type: 'file',
+			file: '',
+			id: null,
+			url: '',
+			name: file.name,
+			collection_name: '',
+			status: 'uploading',
+			size: file.size,
+			error: '',
+			itemId: tempItemId
+		};
+
+		if (fileItem.size == 0) {
+			toast.error($i18n.t('You cannot upload an empty file.'));
+			return null;
+		}
+
+		files = [...files, fileItem];
+		// Check if the file is an audio file and transcribe/convert it to text file
+		if (['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/x-m4a'].includes(file['type'])) {
+			const res = await transcribeAudio(localStorage.token, file).catch((error) => {
+				toast.error(error);
+				return null;
+			});
+
+			if (res) {
+				console.log(res);
+				const blob = new Blob([res.text], { type: 'text/plain' });
+				file = blobToFile(blob, `${file.name}.txt`);
+
+				fileItem.name = file.name;
+				fileItem.size = file.size;
+			}
+		}
+
+		try {
+			// During the file upload, file content is automatically extracted.
+			const uploadedFile = await uploadFile(localStorage.token, file);
+
+			if (uploadedFile) {
+				console.log('File upload completed:', {
+					id: uploadedFile.id,
+					name: fileItem.name,
+					collection: uploadedFile?.meta?.collection_name
+				});
+
+				if (uploadedFile.error) {
+					console.warn('File upload warning:', uploadedFile.error);
+					toast.warning(uploadedFile.error);
+				}
+
+				fileItem.status = 'uploaded';
+				fileItem.file = uploadedFile;
+				fileItem.id = uploadedFile.id;
+				fileItem.collection_name =
+					uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
+				fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
+
+				files = files;
+			} else {
+				files = files.filter((item) => item?.itemId !== tempItemId);
+			}
+		} catch (e) {
+			toast.error(e);
+			files = files.filter((item) => item?.itemId !== tempItemId);
+		}
+	};
+
+	const handleKeyDown = (event: KeyboardEvent) => {
+		if (event.key === 'Escape') {
+			console.log('Escape');
+			draggedOver = false;
+		}
+	};
+
+	const onDragOver = (e) => {
+		e.preventDefault();
+
+		// Check if a file is being draggedOver.
+		if (e.dataTransfer?.types?.includes('Files')) {
+			draggedOver = true;
+		} else {
+			draggedOver = false;
+		}
+	};
+
+	const onDragLeave = () => {
+		draggedOver = false;
+	};
+
+	const onDrop = async (e) => {
+		e.preventDefault();
+		console.log(e);
+
+		if (e.dataTransfer?.files) {
+			const inputFiles = Array.from(e.dataTransfer?.files);
+			if (inputFiles && inputFiles.length > 0) {
+				console.log(inputFiles);
+				inputFilesHandler(inputFiles);
+			}
+		}
+
+		draggedOver = false;
+	};
+
+	const submitHandler = async () => {
+		if (content === '' && files.length === 0) {
+			return;
+		}
+
+		onSubmit({
+			content,
+			data: {
+				files: files
+			}
+		});
+
+		content = '';
+		files = [];
+
+		await tick();
+
+		const chatInputElement = document.getElementById(`chat-input-${id}`);
+		chatInputElement?.focus();
+	};
+
+	$: if (content) {
+		onChange();
+	}
+
+	onMount(async () => {
+		window.setTimeout(() => {
+			const chatInput = document.getElementById(`chat-input-${id}`);
+			chatInput?.focus();
+		}, 0);
+
+		window.addEventListener('keydown', handleKeyDown);
+		await tick();
+
+		const dropzoneElement = document.getElementById('channel-container');
+
+		dropzoneElement?.addEventListener('dragover', onDragOver);
+		dropzoneElement?.addEventListener('drop', onDrop);
+		dropzoneElement?.addEventListener('dragleave', onDragLeave);
+	});
+
+	onDestroy(() => {
+		console.log('destroy');
+		window.removeEventListener('keydown', handleKeyDown);
+
+		const dropzoneElement = document.getElementById('channel-container');
+
+		if (dropzoneElement) {
+			dropzoneElement?.removeEventListener('dragover', onDragOver);
+			dropzoneElement?.removeEventListener('drop', onDrop);
+			dropzoneElement?.removeEventListener('dragleave', onDragLeave);
+		}
+	});
+</script>
+
+<FilesOverlay show={draggedOver} />
+
+<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 = '';
+	}}
+/>
+<div class="bg-transparent">
+	<div
+		class="{($settings?.widescreenMode ?? null)
+			? 'max-w-full'
+			: 'max-w-6xl'} px-2.5 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();
+								}}
+							>
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									viewBox="0 0 20 20"
+									fill="currentColor"
+									class="w-5 h-5"
+								>
+									<path
+										fill-rule="evenodd"
+										d="M10 3a.75.75 0 01.75.75v10.638l3.96-4.158a.75.75 0 111.08 1.04l-5.25 5.5a.75.75 0 01-1.08 0l-5.25-5.5a.75.75 0 111.08-1.04l3.96 4.158V3.75A.75.75 0 0110 3z"
+										clip-rule="evenodd"
+									/>
+								</svg>
+							</button>
+						</div>
+					{/if}
+				</div>
+
+				<div class="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...')}
+							</div>
+						{/if}
+					</div>
+				</div>
+			</div>
+		</div>
+
+		<div class="">
+			{#if recording}
+				<VoiceRecording
+					bind:recording
+					on:cancel={async () => {
+						recording = false;
+
+						await tick();
+						document.getElementById(`chat-input-${id}`)?.focus();
+					}}
+					on:confirm={async (e) => {
+						const { text, filename } = e.detail;
+						content = `${content}${text} `;
+						recording = false;
+
+						await tick();
+						document.getElementById(`chat-input-${id}`)?.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 ?? 'LTR'}
+					>
+						{#if files.length > 0}
+							<div class="mx-1 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>
+											<div class=" absolute -top-1 -right-1">
+												<button
+													class=" bg-gray-400 text-white border border-white rounded-full group-hover:visible invisible transition"
+													type="button"
+													on:click={() => {
+														files.splice(fileIdx, 1);
+														files = files;
+													}}
+												>
+													<svg
+														xmlns="http://www.w3.org/2000/svg"
+														viewBox="0 0 20 20"
+														fill="currentColor"
+														class="w-4 h-4"
+													>
+														<path
+															d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
+														/>
+													</svg>
+												</button>
+											</div>
+										</div>
+									{:else}
+										<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=" flex">
+							<div class="ml-1 self-end mb-1.5 flex space-x-1">
+								<InputMenu
+									{screenCaptureHandler}
+									uploadFilesHandler={() => {
+										filesInputElement.click();
+									}}
+								>
+									<button
+										class="bg-transparent hover:bg-white/80 text-gray-800 dark:text-white dark:hover:bg-gray-800 transition rounded-full p-2 outline-none focus:outline-none"
+										type="button"
+										aria-label="More"
+									>
+										<svg
+											xmlns="http://www.w3.org/2000/svg"
+											viewBox="0 0 20 20"
+											fill="currentColor"
+											class="size-5"
+										>
+											<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"
+											/>
+										</svg>
+									</button>
+								</InputMenu>
+							</div>
+
+							<div
+								class="scrollbar-hidden text-left bg-transparent dark:text-gray-100 outline-none w-full py-2.5 px-1 rounded-xl resize-none h-fit max-h-80 overflow-auto"
+							>
+								<RichTextInput
+									bind:value={content}
+									id={`chat-input-${id}`}
+									messageInput={true}
+									shiftEnter={!$mobile ||
+										!(
+											'ontouchstart' in window ||
+											navigator.maxTouchPoints > 0 ||
+											navigator.msMaxTouchPoints > 0
+										)}
+									{placeholder}
+									largeTextAsFile={$settings?.largeTextAsFile ?? false}
+									on:keydown={async (e) => {
+										e = e.detail.event;
+										const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
+										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.log('Escape');
+										}
+									}}
+									on:paste={async (e) => {
+										e = e.detail.event;
+										console.log(e);
+									}}
+								/>
+							</div>
+
+							<div class="self-end mb-1.5 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">
+									<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-6"
+												>
+													<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>
+								</div>
+							</div>
+						</div>
+					</div>
+				</form>
+			{/if}
+		</div>
+	</div>
+</div>

+ 77 - 0
src/lib/components/channel/MessageInput/InputMenu.svelte

@@ -0,0 +1,77 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { flyAndScale } from '$lib/utils/transitions';
+	import { getContext, onMount, tick } from 'svelte';
+
+	import { config, user, tools as _tools, mobile } from '$lib/stores';
+	import { getTools } from '$lib/apis/tools';
+
+	import Dropdown from '$lib/components/common/Dropdown.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import DocumentArrowUpSolid from '$lib/components/icons/DocumentArrowUpSolid.svelte';
+	import Switch from '$lib/components/common/Switch.svelte';
+	import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
+	import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
+	import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
+
+	const i18n = getContext('i18n');
+
+	export let screenCaptureHandler: Function;
+	export let uploadFilesHandler: Function;
+
+	export let onClose: Function = () => {};
+
+	let show = false;
+
+	$: if (show) {
+		init();
+	}
+
+	const init = async () => {};
+</script>
+
+<Dropdown
+	bind:show
+	on:change={(e) => {
+		if (e.detail === false) {
+			onClose();
+		}
+	}}
+>
+	<Tooltip content={$i18n.t('More')}>
+		<slot />
+	</Tooltip>
+
+	<div slot="content">
+		<DropdownMenu.Content
+			class="w-full max-w-[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"
+			sideOffset={15}
+			alignOffset={-8}
+			side="top"
+			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"
+				on:click={() => {
+					uploadFilesHandler();
+				}}
+			>
+				<DocumentArrowUpSolid />
+				<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
+			</DropdownMenu.Item>
+		</DropdownMenu.Content>
+	</div>
+</Dropdown>

+ 194 - 0
src/lib/components/channel/Messages.svelte

@@ -0,0 +1,194 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+
+	import dayjs from 'dayjs';
+	import relativeTime from 'dayjs/plugin/relativeTime';
+	import isToday from 'dayjs/plugin/isToday';
+	import isYesterday from 'dayjs/plugin/isYesterday';
+
+	dayjs.extend(relativeTime);
+	dayjs.extend(isToday);
+	dayjs.extend(isYesterday);
+	import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
+
+	import { settings, user } from '$lib/stores';
+
+	import Message from './Messages/Message.svelte';
+	import Loader from '../common/Loader.svelte';
+	import Spinner from '../common/Spinner.svelte';
+	import { addReaction, deleteMessage, removeReaction, updateMessage } from '$lib/apis/channels';
+
+	const i18n = getContext('i18n');
+
+	export let id = null;
+	export let channel = null;
+	export let messages = [];
+	export let top = false;
+	export let thread = false;
+
+	export let onLoad: Function = () => {};
+	export let onThread: Function = () => {};
+
+	let messagesLoading = false;
+
+	const loadMoreMessages = async () => {
+		// scroll slightly down to disable continuous loading
+		const element = document.getElementById('messages-container');
+		element.scrollTop = element.scrollTop + 100;
+
+		messagesLoading = true;
+
+		await onLoad();
+
+		await tick();
+		messagesLoading = false;
+	};
+</script>
+
+{#if messages}
+	{@const messageList = messages.slice().reverse()}
+	<div>
+		{#if !top}
+			<Loader
+				on:visible={(e) => {
+					console.log('visible');
+					if (!messagesLoading) {
+						loadMoreMessages();
+					}
+				}}
+			>
+				<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
+					<Spinner className=" size-4" />
+					<div class=" ">Loading...</div>
+				</div>
+			</Loader>
+		{:else if !thread}
+			<div
+				class="px-5
+			
+			{($settings?.widescreenMode ?? null) ? 'max-w-full' : 'max-w-5xl'} 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>
+
+						<div class=" text-gray-500">
+							This channel was created on {dayjs(channel.created_at / 1000000).format(
+								'MMMM D, YYYY'
+							)}. This is the very beginning of the {channel.name}
+							channel.
+						</div>
+					</div>
+				{:else}
+					<div class="flex justify-center text-xs items-center gap-2 py-5">
+						<div class=" ">Start of the channel</div>
+					</div>
+				{/if}
+
+				{#if messageList.length > 0}
+					<hr class=" border-gray-50 dark:border-gray-700/20 py-2.5 w-full" />
+				{/if}
+			</div>
+		{/if}
+
+		{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
+			<Message
+				{message}
+				{thread}
+				showUserProfile={messageIdx === 0 ||
+					messageList.at(messageIdx - 1)?.user_id !== message.user_id}
+				onDelete={() => {
+					messages = messages.filter((m) => m.id !== message.id);
+
+					const res = deleteMessage(localStorage.token, message.channel_id, message.id).catch(
+						(error) => {
+							toast.error(error);
+							return null;
+						}
+					);
+				}}
+				onEdit={(content) => {
+					messages = messages.map((m) => {
+						if (m.id === message.id) {
+							m.content = content;
+						}
+						return m;
+					});
+
+					const res = updateMessage(localStorage.token, message.channel_id, message.id, {
+						content: content
+					}).catch((error) => {
+						toast.error(error);
+						return null;
+					});
+				}}
+				onThread={(id) => {
+					onThread(id);
+				}}
+				onReaction={(name) => {
+					if (
+						(message?.reactions ?? [])
+							.find((reaction) => reaction.name === name)
+							?.user_ids?.includes($user.id) ??
+						false
+					) {
+						messages = messages.map((m) => {
+							if (m.id === message.id) {
+								const reaction = m.reactions.find((reaction) => reaction.name === name);
+
+								if (reaction) {
+									reaction.user_ids = reaction.user_ids.filter((id) => id !== $user.id);
+									reaction.count = reaction.user_ids.length;
+
+									if (reaction.count === 0) {
+										m.reactions = m.reactions.filter((r) => r.name !== name);
+									}
+								}
+							}
+							return m;
+						});
+
+						const res = removeReaction(
+							localStorage.token,
+							message.channel_id,
+							message.id,
+							name
+						).catch((error) => {
+							toast.error(error);
+							return null;
+						});
+					} else {
+						messages = messages.map((m) => {
+							if (m.id === message.id) {
+								if (m.reactions) {
+									const reaction = m.reactions.find((reaction) => reaction.name === name);
+
+									if (reaction) {
+										reaction.user_ids.push($user.id);
+										reaction.count = reaction.user_ids.length;
+									} else {
+										m.reactions.push({
+											name: name,
+											user_ids: [$user.id],
+											count: 1
+										});
+									}
+								}
+							}
+							return m;
+						});
+
+						const res = addReaction(localStorage.token, message.channel_id, message.id, name).catch(
+							(error) => {
+								toast.error(error);
+								return null;
+							}
+						);
+					}
+				}}
+			/>
+		{/each}
+
+		<div class="pb-6" />
+	</div>
+{/if}

+ 357 - 0
src/lib/components/channel/Messages/Message.svelte

@@ -0,0 +1,357 @@
+<script lang="ts">
+	import dayjs from 'dayjs';
+	import relativeTime from 'dayjs/plugin/relativeTime';
+	import isToday from 'dayjs/plugin/isToday';
+	import isYesterday from 'dayjs/plugin/isYesterday';
+
+	dayjs.extend(relativeTime);
+	dayjs.extend(isToday);
+	dayjs.extend(isYesterday);
+
+	import { getContext, onMount } from 'svelte';
+	const i18n = getContext<Writable<i18nType>>('i18n');
+
+	import { settings, user, shortCodesToEmojis } from '$lib/stores';
+
+	import { WEBUI_BASE_URL } from '$lib/constants';
+
+	import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
+	import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
+	import Name from '$lib/components/chat/Messages/Name.svelte';
+	import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+	import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
+	import Pencil from '$lib/components/icons/Pencil.svelte';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import Textarea from '$lib/components/common/Textarea.svelte';
+	import Image from '$lib/components/common/Image.svelte';
+	import FileItem from '$lib/components/common/FileItem.svelte';
+	import ProfilePreview from './Message/ProfilePreview.svelte';
+	import ChatBubbleOvalEllipsis from '$lib/components/icons/ChatBubbleOvalEllipsis.svelte';
+	import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
+	import ReactionPicker from './Message/ReactionPicker.svelte';
+	import ChevronRight from '$lib/components/icons/ChevronRight.svelte';
+
+	export let message;
+	export let showUserProfile = true;
+	export let thread = false;
+
+	export let onDelete: Function = () => {};
+	export let onEdit: Function = () => {};
+	export let onThread: Function = () => {};
+	export let onReaction: Function = () => {};
+
+	let showButtons = false;
+
+	let edit = false;
+	let editedContent = null;
+	let showDeleteConfirmDialog = false;
+
+	const formatDate = (inputDate) => {
+		const date = dayjs(inputDate);
+		const now = dayjs();
+
+		if (date.isToday()) {
+			return `Today at ${date.format('HH:mm')}`;
+		} else if (date.isYesterday()) {
+			return `Yesterday at ${date.format('HH:mm')}`;
+		} else {
+			return `${date.format('DD/MM/YYYY')} at ${date.format('HH:mm')}`;
+		}
+	};
+</script>
+
+<ConfirmDialog
+	bind:show={showDeleteConfirmDialog}
+	title={$i18n.t('Delete Message')}
+	message={$i18n.t('Are you sure you want to delete this message?')}
+	onConfirm={async () => {
+		await onDelete();
+	}}
+/>
+
+{#if message}
+	<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"
+	>
+		{#if !edit}
+			<div
+				class=" absolute {showButtons ? '' : 'invisible group-hover:visible'} right-1 -top-2 z-10"
+			>
+				<div
+					class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100 dark:border-gray-800"
+				>
+					<ReactionPicker
+						onClose={() => (showButtons = false)}
+						onSubmit={(name) => {
+							showButtons = false;
+							onReaction(name);
+						}}
+					>
+						<Tooltip content={$i18n.t('Add Reaction')}>
+							<button
+								class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
+								on:click={() => {
+									showButtons = true;
+								}}
+							>
+								<FaceSmile />
+							</button>
+						</Tooltip>
+					</ReactionPicker>
+
+					{#if !thread}
+						<Tooltip content={$i18n.t('Reply in Thread')}>
+							<button
+								class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
+								on:click={() => {
+									onThread(message.id);
+								}}
+							>
+								<ChatBubbleOvalEllipsis />
+							</button>
+						</Tooltip>
+					{/if}
+
+					{#if message.user_id === $user.id || $user.role === 'admin'}
+						<Tooltip content={$i18n.t('Edit')}>
+							<button
+								class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
+								on:click={() => {
+									edit = true;
+									editedContent = message.content;
+								}}
+							>
+								<Pencil />
+							</button>
+						</Tooltip>
+
+						<Tooltip content={$i18n.t('Delete')}>
+							<button
+								class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
+								on:click={() => (showDeleteConfirmDialog = true)}
+							>
+								<GarbageBin />
+							</button>
+						</Tooltip>
+					{/if}
+				</div>
+			</div>
+		{/if}
+
+		<div
+			class=" flex w-full message-{message.id}"
+			id="message-{message.id}"
+			dir={$settings.chatDirection}
+		>
+			<div
+				class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
+			>
+				{#if showUserProfile}
+					<ProfilePreview user={message.user}>
+						<ProfileImage
+							src={message.user?.profile_image_url ??
+								($i18n.language === 'dg-DG' ? `/doge.png` : `${WEBUI_BASE_URL}/static/favicon.png`)}
+							className={'size-8 translate-y-1 ml-0.5'}
+						/>
+					</ProfilePreview>
+				{:else}
+					<!-- <div class="w-7 h-7 rounded-full bg-transparent" /> -->
+
+					{#if message.created_at}
+						<div
+							class="mt-1.5 flex flex-shrink-0 items-center text-xs self-center invisible group-hover:visible text-gray-500 font-medium first-letter:capitalize"
+						>
+							<Tooltip
+								content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
+							>
+								{dayjs(message.created_at / 1000000).format('HH:mm')}
+							</Tooltip>
+						</div>
+					{/if}
+				{/if}
+			</div>
+
+			<div class="flex-auto w-0 pl-1">
+				{#if showUserProfile}
+					<Name>
+						<div class=" self-end text-base shrink-0 font-medium truncate">
+							{message?.user?.name}
+						</div>
+
+						{#if message.created_at}
+							<div
+								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('dddd, DD MMMM YYYY HH:mm')}
+								>
+									<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
+								</Tooltip>
+							</div>
+						{/if}
+					</Name>
+				{/if}
+
+				{#if (message?.data?.files ?? []).length > 0}
+					<div class="my-2.5 w-full flex overflow-x-auto gap-2 flex-wrap">
+						{#each message?.data?.files as file}
+							<div>
+								{#if file.type === 'image'}
+									<Image src={file.url} alt={file.name} imageClassName=" max-h-96 rounded-lg" />
+								{:else}
+									<FileItem
+										item={file}
+										url={file.url}
+										name={file.name}
+										type={file.type}
+										size={file?.size}
+										colorClassName="bg-white dark:bg-gray-850 "
+									/>
+								{/if}
+							</div>
+						{/each}
+					</div>
+				{/if}
+
+				{#if edit}
+					<div class="py-2">
+						<Textarea
+							className=" bg-transparent outline-none w-full resize-none"
+							bind:value={editedContent}
+							onKeydown={(e) => {
+								if (e.key === 'Escape') {
+									document.getElementById('close-edit-message-button')?.click();
+								}
+
+								const isCmdOrCtrlPressed = e.metaKey || e.ctrlKey;
+								const isEnterPressed = e.key === 'Enter';
+
+								if (isCmdOrCtrlPressed && isEnterPressed) {
+									document.getElementById('confirm-edit-message-button')?.click();
+								}
+							}}
+						/>
+						<div class=" mt-2 mb-1 flex justify-end text-sm font-medium">
+							<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"
+									on:click={() => {
+										edit = false;
+										editedContent = null;
+									}}
+								>
+									{$i18n.t('Cancel')}
+								</button>
+
+								<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"
+									on:click={async () => {
+										onEdit(editedContent);
+										edit = false;
+										editedContent = null;
+									}}
+								>
+									{$i18n.t('Save')}
+								</button>
+							</div>
+						</div>
+					</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}
+					</div>
+
+					{#if (message?.reactions ?? []).length > 0}
+						<div>
+							<div class="flex items-center flex-wrap gap-y-1.5 gap-1 mt-1 mb-2">
+								{#each message.reactions as reaction}
+									<Tooltip content={`:${reaction.name}:`}>
+										<button
+											class="flex items-center gap-1.5 transition rounded-xl px-2 py-1 cursor-pointer {reaction.user_ids.includes(
+												$user.id
+											)
+												? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
+												: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
+											on:click={() => {
+												onReaction(reaction.name);
+											}}
+										>
+											{#if $shortCodesToEmojis[reaction.name]}
+												<img
+													src="/assets/emojis/{$shortCodesToEmojis[
+														reaction.name
+													].toLowerCase()}.svg"
+													alt={reaction.name}
+													class=" size-4"
+													loading="lazy"
+												/>
+											{:else}
+												<div>
+													{reaction.name}
+												</div>
+											{/if}
+
+											{#if reaction.user_ids.length > 0}
+												<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
+													{reaction.user_ids?.length}
+												</div>
+											{/if}
+										</button>
+									</Tooltip>
+								{/each}
+
+								<ReactionPicker
+									onSubmit={(name) => {
+										onReaction(name);
+									}}
+								>
+									<Tooltip content={$i18n.t('Add Reaction')}>
+										<div
+											class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-xl px-1 py-1 cursor-pointer text-gray-500 dark:text-gray-400"
+										>
+											<FaceSmile />
+										</div>
+									</Tooltip>
+								</ReactionPicker>
+							</div>
+						</div>
+					{/if}
+
+					{#if !thread && message.reply_count > 0}
+						<div class="flex items-center gap-1.5 -mt-0.5 mb-1.5">
+							<button
+								class="flex items-center text-xs py-1 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition"
+								on:click={() => {
+									onThread(message.id);
+								}}
+							>
+								<span class="font-medium mr-1">
+									{$i18n.t('{{COUNT}} Replies', { COUNT: message.reply_count })}</span
+								><span>
+									{' - '}{$i18n.t('Last reply')}
+									{dayjs.unix(message.latest_reply_at / 1000000000).fromNow()}</span
+								>
+
+								<span class="ml-1">
+									<ChevronRight className="size-2.5" strokeWidth="3" />
+								</span>
+								<!-- {$i18n.t('View Replies')} -->
+							</button>
+						</div>
+					{/if}
+				{/if}
+			</div>
+		</div>
+	</div>
+{/if}

+ 85 - 0
src/lib/components/channel/Messages/Message/ProfilePreview.svelte

@@ -0,0 +1,85 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { createEventDispatcher } from 'svelte';
+
+	import { flyAndScale } from '$lib/utils/transitions';
+	import { WEBUI_BASE_URL } from '$lib/constants';
+	import { activeUserIds } from '$lib/stores';
+
+	export let side = 'right';
+	export let align = 'top';
+
+	export let user = null;
+	let show = false;
+
+	const dispatch = createEventDispatcher();
+</script>
+
+<DropdownMenu.Root
+	bind:open={show}
+	closeFocus={false}
+	onOpenChange={(state) => {
+		dispatch('change', state);
+	}}
+	typeahead={false}
+>
+	<DropdownMenu.Trigger>
+		<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 $activeUserIds.includes(user.id)}
+								<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"> 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>
+
+								<div class=" -translate-y-[1px]">
+									<span class="text-xs"> Away </span>
+								</div>
+							{/if}
+						</div>
+					</div>
+				</div>
+			{/if}
+		</DropdownMenu.Content>
+	</slot>
+</DropdownMenu.Root>

+ 166 - 0
src/lib/components/channel/Messages/Message/ReactionPicker.svelte

@@ -0,0 +1,166 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { flyAndScale } from '$lib/utils/transitions';
+	import emojiGroups from '$lib/emoji-groups.json';
+	import emojiShortCodes from '$lib/emoji-shortcodes.json';
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import VirtualList from '@sveltejs/svelte-virtual-list';
+
+	export let onClose = () => {};
+	export let onSubmit = (name) => {};
+	export let side = 'top';
+	export let align = 'start';
+	export let user = null;
+
+	let show = false;
+	let emojis = emojiShortCodes;
+	let search = '';
+	let flattenedEmojis = [];
+	let emojiRows = [];
+
+	// Reactive statement to filter the emojis based on search query
+	$: {
+		if (search) {
+			emojis = Object.keys(emojiShortCodes).reduce((acc, key) => {
+				if (key.includes(search)) {
+					acc[key] = emojiShortCodes[key];
+				} else {
+					if (Array.isArray(emojiShortCodes[key])) {
+						const filtered = emojiShortCodes[key].filter((emoji) => emoji.includes(search));
+						if (filtered.length) {
+							acc[key] = filtered;
+						}
+					} else {
+						if (emojiShortCodes[key].includes(search)) {
+							acc[key] = emojiShortCodes[key];
+						}
+					}
+				}
+				return acc;
+			}, {});
+		} else {
+			emojis = emojiShortCodes;
+		}
+	}
+	// Flatten emoji groups and group them into rows of 8 for virtual scrolling
+	$: {
+		flattenedEmojis = [];
+		Object.keys(emojiGroups).forEach((group) => {
+			const groupEmojis = emojiGroups[group].filter((emoji) => emojis[emoji]);
+			if (groupEmojis.length > 0) {
+				flattenedEmojis.push({ type: 'group', label: group });
+				flattenedEmojis.push(
+					...groupEmojis.map((emoji) => ({
+						type: 'emoji',
+						name: emoji,
+						shortCodes:
+							typeof emojiShortCodes[emoji] === 'string'
+								? [emojiShortCodes[emoji]]
+								: emojiShortCodes[emoji]
+					}))
+				);
+			}
+		});
+		// Group emojis into rows of 8
+		emojiRows = [];
+		let currentRow = [];
+		flattenedEmojis.forEach((item) => {
+			if (item.type === 'emoji') {
+				currentRow.push(item);
+				if (currentRow.length === 8) {
+					emojiRows.push(currentRow);
+					currentRow = [];
+				}
+			} else if (item.type === 'group') {
+				if (currentRow.length > 0) {
+					emojiRows.push(currentRow); // Push the remaining row
+					currentRow = [];
+				}
+				emojiRows.push([item]); // Add the group label as a separate row
+			}
+		});
+		if (currentRow.length > 0) {
+			emojiRows.push(currentRow); // Push the final row
+		}
+	}
+	const ROW_HEIGHT = 48; // Approximate height for a row with multiple emojis
+	// Handle emoji selection
+	function selectEmoji(emoji) {
+		const selectedCode = emoji.shortCodes[0];
+		onSubmit(selectedCode);
+		show = false;
+	}
+</script>
+
+<DropdownMenu.Root
+	bind:open={show}
+	closeFocus={false}
+	onOpenChange={(state) => {
+		if (!state) {
+			search = '';
+			onClose();
+		}
+	}}
+	typeahead={false}
+>
+	<DropdownMenu.Trigger>
+		<slot />
+	</DropdownMenu.Trigger>
+	<DropdownMenu.Content
+		class="max-w-full w-80 bg-gray-50 dark:bg-gray-850 rounded-lg z-[9999] shadow-lg dark:text-white"
+		sideOffset={8}
+		{side}
+		{align}
+		transition={flyAndScale}
+	>
+		<div class="mb-1 px-3 pt-2 pb-2">
+			<input
+				type="text"
+				class="w-full text-sm bg-transparent outline-none"
+				placeholder="Search all emojis"
+				bind:value={search}
+			/>
+		</div>
+		<!-- Virtualized Emoji List -->
+		<div class="w-full flex justify-start h-96 overflow-y-auto px-3 pb-3 text-sm">
+			{#if emojiRows.length === 0}
+				<div class="text-center text-xs text-gray-500 dark:text-gray-400">No results</div>
+			{:else}
+				<div class="w-full flex ml-0.5">
+					<VirtualList rowHeight={ROW_HEIGHT} items={emojiRows} height={384} let:item>
+						<div class="w-full">
+							{#if item.length === 1 && item[0].type === 'group'}
+								<!-- Render group header -->
+								<div class="text-xs font-medium mb-2 text-gray-500 dark:text-gray-400">
+									{item[0].label}
+								</div>
+							{:else}
+								<!-- Render emojis in a row -->
+								<div class="flex items-center gap-1.5 w-full">
+									{#each item as emojiItem}
+										<Tooltip
+											content={emojiItem.shortCodes.map((code) => `:${code}:`).join(', ')}
+											placement="top"
+										>
+											<button
+												class="p-1.5 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition"
+												on:click={() => selectEmoji(emojiItem)}
+											>
+												<img
+													src="/assets/emojis/{emojiItem.name.toLowerCase()}.svg"
+													alt={emojiItem.name}
+													class="size-5"
+													loading="lazy"
+												/>
+											</button>
+										</Tooltip>
+									{/each}
+								</div>
+							{/if}
+						</div>
+					</VirtualList>
+				</div>
+			{/if}
+		</div>
+	</DropdownMenu.Content>
+</DropdownMenu.Root>

+ 86 - 0
src/lib/components/channel/Navbar.svelte

@@ -0,0 +1,86 @@
+<script lang="ts">
+	import { getContext } from 'svelte';
+	import { toast } from 'svelte-sonner';
+
+	import { showArchivedChats, showSidebar, user } from '$lib/stores';
+
+	import { slide } from 'svelte/transition';
+	import { page } from '$app/stores';
+
+	import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
+	import MenuLines from '../icons/MenuLines.svelte';
+	import PencilSquare from '../icons/PencilSquare.svelte';
+
+	const i18n = getContext('i18n');
+
+	export let channel;
+</script>
+
+<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
+	<div
+		class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
+	></div>
+
+	<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
+		<div class="flex items-center w-full max-w-full">
+			<div
+				class="{$showSidebar
+					? 'md:hidden'
+					: ''} mr-1 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
+			>
+				<button
+					id="sidebar-toggle-button"
+					class="cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+					on:click={() => {
+						showSidebar.set(!$showSidebar);
+					}}
+					aria-label="Toggle Sidebar"
+				>
+					<div class=" m-auto self-center">
+						<MenuLines />
+					</div>
+				</button>
+			</div>
+
+			<div
+				class="flex-1 overflow-hidden max-w-full py-0.5
+			{$showSidebar ? 'ml-1' : ''}
+			"
+			>
+				{#if channel}
+					<div class="line-clamp-1 capitalize font-medium font-primary text-lg">
+						{channel.name}
+					</div>
+				{/if}
+			</div>
+
+			<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
+				{#if $user !== undefined}
+					<UserMenu
+						className="max-w-[200px]"
+						role={$user.role}
+						on:show={(e) => {
+							if (e.detail === 'archived-chat') {
+								showArchivedChats.set(true);
+							}
+						}}
+					>
+						<button
+							class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+							aria-label="User Menu"
+						>
+							<div class=" self-center">
+								<img
+									src={$user.profile_image_url}
+									class="size-6 object-cover rounded-full"
+									alt="User profile"
+									draggable="false"
+								/>
+							</div>
+						</button>
+					</UserMenu>
+				{/if}
+			</div>
+		</div>
+	</div>
+</div>

+ 204 - 0
src/lib/components/channel/Thread.svelte

@@ -0,0 +1,204 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+
+	import { socket, user } from '$lib/stores';
+
+	import { getChannelThreadMessages, sendMessage } from '$lib/apis/channels';
+
+	import XMark from '$lib/components/icons/XMark.svelte';
+	import MessageInput from './MessageInput.svelte';
+	import Messages from './Messages.svelte';
+	import { onDestroy, onMount, tick } from 'svelte';
+	import { toast } from 'svelte-sonner';
+
+	export let threadId = null;
+	export let channel = null;
+
+	export let onClose = () => {};
+
+	let messages = null;
+	let top = false;
+
+	let typingUsers = [];
+	let typingUsersTimeout = {};
+
+	let messagesContainerElement = null;
+
+	$: if (threadId) {
+		initHandler();
+	}
+
+	const scrollToBottom = () => {
+		messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
+	};
+
+	const initHandler = async () => {
+		messages = null;
+		top = false;
+
+		typingUsers = [];
+		typingUsersTimeout = {};
+
+		if (channel) {
+			messages = await getChannelThreadMessages(localStorage.token, channel.id, threadId);
+
+			if (messages.length < 50) {
+				top = true;
+			}
+
+			await tick();
+			scrollToBottom();
+		} else {
+			goto('/');
+		}
+	};
+
+	const channelEventHandler = async (event) => {
+		console.log(event);
+		if (event.channel_id === channel.id) {
+			const type = event?.data?.type ?? null;
+			const data = event?.data?.data ?? null;
+
+			if (type === 'message') {
+				if ((data?.parent_id ?? null) === threadId) {
+					if (messages) {
+						messages = [data, ...messages];
+
+						if (typingUsers.find((user) => user.id === event.user.id)) {
+							typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
+						}
+					}
+				}
+			} else if (type === 'message:update') {
+				if (messages) {
+					const idx = messages.findIndex((message) => message.id === data.id);
+
+					if (idx !== -1) {
+						messages[idx] = data;
+					}
+				}
+			} else if (type === 'message:delete') {
+				if (messages) {
+					messages = messages.filter((message) => message.id !== data.id);
+				}
+			} else if (type.includes('message:reaction')) {
+				if (messages) {
+					const idx = messages.findIndex((message) => message.id === data.id);
+					if (idx !== -1) {
+						messages[idx] = data;
+					}
+				}
+			} else if (type === 'typing' && event.message_id === threadId) {
+				if (event.user.id === $user.id) {
+					return;
+				}
+
+				typingUsers = data.typing
+					? [
+							...typingUsers,
+							...(typingUsers.find((user) => user.id === event.user.id)
+								? []
+								: [
+										{
+											id: event.user.id,
+											name: event.user.name
+										}
+									])
+						]
+					: typingUsers.filter((user) => user.id !== event.user.id);
+
+				if (typingUsersTimeout[event.user.id]) {
+					clearTimeout(typingUsersTimeout[event.user.id]);
+				}
+
+				typingUsersTimeout[event.user.id] = setTimeout(() => {
+					typingUsers = typingUsers.filter((user) => user.id !== event.user.id);
+				}, 5000);
+			}
+		}
+	};
+
+	const submitHandler = async ({ content, data }) => {
+		if (!content) {
+			return;
+		}
+
+		const res = await sendMessage(localStorage.token, channel.id, {
+			parent_id: threadId,
+			content: content,
+			data: data
+		}).catch((error) => {
+			toast.error(error);
+			return null;
+		});
+	};
+
+	const onChange = async () => {
+		$socket?.emit('channel-events', {
+			channel_id: channel.id,
+			message_id: threadId,
+			data: {
+				type: 'typing',
+				data: {
+					typing: true
+				}
+			}
+		});
+	};
+
+	onMount(() => {
+		$socket?.on('channel-events', channelEventHandler);
+	});
+
+	onDestroy(() => {
+		$socket?.off('channel-events', channelEventHandler);
+	});
+</script>
+
+{#if channel}
+	<div class="flex flex-col w-full h-full bg-gray-50 dark:bg-gray-850">
+		<div class="flex items-center justify-between px-3.5 pt-3">
+			<div class=" font-medium text-lg">Thread</div>
+
+			<div>
+				<button
+					class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-2"
+					on:click={() => {
+						onClose();
+					}}
+				>
+					<XMark />
+				</button>
+			</div>
+		</div>
+
+		<div class=" max-h-full w-full overflow-y-auto pt-3" bind:this={messagesContainerElement}>
+			<Messages
+				id={threadId}
+				{channel}
+				{messages}
+				{top}
+				thread={true}
+				onLoad={async () => {
+					const newMessages = await getChannelThreadMessages(
+						localStorage.token,
+						channel.id,
+						threadId,
+						messages.length
+					);
+
+					messages = [...messages, ...newMessages];
+
+					if (newMessages.length < 50) {
+						top = true;
+						return;
+					}
+				}}
+			/>
+
+			<div class=" pb-[1rem]">
+				<MessageInput id={threadId} {typingUsers} {onChange} onSubmit={submitHandler} />
+			</div>
+		</div>
+	</div>
+{/if}

文件差异内容过多而无法显示
+ 565 - 775
src/lib/components/chat/Chat.svelte


+ 339 - 0
src/lib/components/chat/ContentRenderer/FloatingButtons.svelte

@@ -0,0 +1,339 @@
+<script lang="ts">
+	import { toast } from 'svelte-sonner';
+
+	import DOMPurify from 'dompurify';
+	import { marked } from 'marked';
+
+	import { getContext, tick } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import { chatCompletion } from '$lib/apis/openai';
+
+	import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
+	import LightBlub from '$lib/components/icons/LightBlub.svelte';
+	import Markdown from '../Messages/Markdown.svelte';
+	import Skeleton from '../Messages/Skeleton.svelte';
+
+	export let id = '';
+	export let model = null;
+	export let messages = [];
+	export let onAdd = () => {};
+
+	let floatingInput = false;
+
+	let selectedText = '';
+	let floatingInputValue = '';
+
+	let prompt = '';
+	let responseContent = null;
+	let responseDone = false;
+
+	const autoScroll = async () => {
+		// Scroll to bottom only if the scroll is at the bottom give 50px buffer
+		const responseContainer = document.getElementById('response-container');
+		if (
+			responseContainer.scrollHeight - responseContainer.clientHeight <=
+			responseContainer.scrollTop + 50
+		) {
+			responseContainer.scrollTop = responseContainer.scrollHeight;
+		}
+	};
+
+	const askHandler = async () => {
+		if (!model) {
+			toast.error('Model not selected');
+			return;
+		}
+		prompt = `${floatingInputValue}\n\`\`\`\n${selectedText}\n\`\`\``;
+		floatingInputValue = '';
+
+		responseContent = '';
+		const [res, controller] = await chatCompletion(localStorage.token, {
+			model: model,
+			messages: [
+				...messages,
+				{
+					role: 'user',
+					content: prompt
+				}
+			].map((message) => ({
+				role: message.role,
+				content: message.content
+			})),
+			stream: true // Enable streaming
+		});
+
+		if (res && res.ok) {
+			const reader = res.body.getReader();
+			const decoder = new TextDecoder();
+
+			const processStream = async () => {
+				while (true) {
+					// Read data chunks from the response stream
+					const { done, value } = await reader.read();
+					if (done) {
+						break;
+					}
+
+					// Decode the received chunk
+					const chunk = decoder.decode(value, { stream: true });
+
+					// Process lines within the chunk
+					const lines = chunk.split('\n').filter((line) => line.trim() !== '');
+
+					for (const line of lines) {
+						if (line.startsWith('data: ')) {
+							if (line.startsWith('data: [DONE]')) {
+								responseDone = true;
+
+								await tick();
+								autoScroll();
+								continue;
+							} else {
+								// Parse the JSON chunk
+								try {
+									const data = JSON.parse(line.slice(6));
+
+									// Append the `content` field from the "choices" object
+									if (data.choices && data.choices[0]?.delta?.content) {
+										responseContent += data.choices[0].delta.content;
+
+										autoScroll();
+									}
+								} catch (e) {
+									console.error(e);
+								}
+							}
+						}
+					}
+				}
+			};
+
+			// Process the stream in the background
+			await processStream();
+		} else {
+			toast.error('An error occurred while fetching the explanation');
+		}
+	};
+
+	const explainHandler = async () => {
+		if (!model) {
+			toast.error('Model not selected');
+			return;
+		}
+		prompt = `Explain this section to me in more detail\n\n\`\`\`\n${selectedText}\n\`\`\``;
+
+		responseContent = '';
+		const [res, controller] = await chatCompletion(localStorage.token, {
+			model: model,
+			messages: [
+				...messages,
+				{
+					role: 'user',
+					content: prompt
+				}
+			].map((message) => ({
+				role: message.role,
+				content: message.content
+			})),
+			stream: true // Enable streaming
+		});
+
+		if (res && res.ok) {
+			const reader = res.body.getReader();
+			const decoder = new TextDecoder();
+
+			const processStream = async () => {
+				while (true) {
+					// Read data chunks from the response stream
+					const { done, value } = await reader.read();
+					if (done) {
+						break;
+					}
+
+					// Decode the received chunk
+					const chunk = decoder.decode(value, { stream: true });
+
+					// Process lines within the chunk
+					const lines = chunk.split('\n').filter((line) => line.trim() !== '');
+
+					for (const line of lines) {
+						if (line.startsWith('data: ')) {
+							if (line.startsWith('data: [DONE]')) {
+								responseDone = true;
+
+								await tick();
+								autoScroll();
+								continue;
+							} else {
+								// Parse the JSON chunk
+								try {
+									const data = JSON.parse(line.slice(6));
+
+									// Append the `content` field from the "choices" object
+									if (data.choices && data.choices[0]?.delta?.content) {
+										responseContent += data.choices[0].delta.content;
+
+										autoScroll();
+									}
+								} catch (e) {
+									console.error(e);
+								}
+							}
+						}
+					}
+				}
+			};
+
+			// Process the stream in the background
+			await processStream();
+		} else {
+			toast.error('An error occurred while fetching the explanation');
+		}
+	};
+
+	const addHandler = async () => {
+		const messages = [
+			{
+				role: 'user',
+				content: prompt
+			},
+			{
+				role: 'assistant',
+				content: responseContent
+			}
+		];
+
+		onAdd({
+			modelId: model,
+			parentId: id,
+			messages: messages
+		});
+	};
+
+	export const closeHandler = () => {
+		responseContent = null;
+		responseDone = false;
+		floatingInput = false;
+		floatingInputValue = '';
+	};
+</script>
+
+<div
+	id={`floating-buttons-${id}`}
+	class="absolute rounded-lg mt-1 text-xs z-[9999]"
+	style="display: none"
+>
+	{#if responseContent === null}
+		{#if !floatingInput}
+			<div
+				class="flex flex-row gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl"
+			>
+				<button
+					class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
+					on:click={async () => {
+						selectedText = window.getSelection().toString();
+						floatingInput = true;
+
+						await tick();
+						setTimeout(() => {
+							const input = document.getElementById('floating-message-input');
+							if (input) {
+								input.focus();
+							}
+						}, 0);
+					}}
+				>
+					<ChatBubble className="size-3 shrink-0" />
+
+					<div class="shrink-0">Ask</div>
+				</button>
+				<button
+					class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
+					on:click={() => {
+						selectedText = window.getSelection().toString();
+						explainHandler();
+					}}
+				>
+					<LightBlub className="size-3 shrink-0" />
+
+					<div class="shrink-0">Explain</div>
+				</button>
+			</div>
+		{:else}
+			<div
+				class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-800 w-72 rounded-full shadow-xl"
+			>
+				<input
+					type="text"
+					id="floating-message-input"
+					class="ml-5 bg-transparent outline-none w-full flex-1 text-sm"
+					placeholder={$i18n.t('Ask a question')}
+					bind:value={floatingInputValue}
+					on:keydown={(e) => {
+						if (e.key === 'Enter') {
+							askHandler();
+						}
+					}}
+				/>
+
+				<div class="ml-1 mr-2">
+					<button
+						class="{floatingInputValue !== ''
+							? '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 m-0.5 self-center"
+						on:click={() => {
+							askHandler();
+						}}
+					>
+						<svg
+							xmlns="http://www.w3.org/2000/svg"
+							viewBox="0 0 16 16"
+							fill="currentColor"
+							class="size-4"
+						>
+							<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>
+				</div>
+			</div>
+		{/if}
+	{:else}
+		<div class="bg-white dark:bg-gray-850 dark:text-gray-100 rounded-xl shadow-xl w-80 max-w-full">
+			<div
+				class="bg-gray-50/50 dark:bg-gray-800 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full"
+			>
+				<div class="font-medium">
+					<Markdown id={`${id}-float-prompt`} content={prompt} />
+				</div>
+			</div>
+
+			<div
+				class="bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl px-3.5 py-3 w-full"
+			>
+				<div class=" max-h-80 overflow-y-auto w-full markdown-prose-xs" id="response-container">
+					{#if responseContent.trim() === ''}
+						<Skeleton size="sm" />
+					{:else}
+						<Markdown id={`${id}-float-response`} content={responseContent} />
+					{/if}
+
+					{#if responseDone}
+						<div class="flex justify-end pt-3 text-sm font-medium">
+							<button
+								class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
+								on:click={addHandler}
+							>
+								{$i18n.t('Add')}
+							</button>
+						</div>
+					{/if}
+				</div>
+			</div>
+		</div>
+	{/if}
+</div>

+ 130 - 20
src/lib/components/chat/MessageInput.svelte

@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
 	import { v4 as uuidv4 } from 'uuid';
+	import { createPicker, getAuthToken } from '$lib/utils/google-drive-picker';
 
 	import { onMount, tick, getContext, createEventDispatcher, onDestroy } from 'svelte';
 	const dispatch = createEventDispatcher();
@@ -18,7 +19,7 @@
 		showControls
 	} from '$lib/stores';
 
-	import { blobToFile, createMessagesList, findWordIndices } from '$lib/utils';
+	import { blobToFile, compressImage, createMessagesList, findWordIndices } from '$lib/utils';
 	import { transcribeAudio } from '$lib/apis/audio';
 	import { uploadFile } from '$lib/apis/files';
 	import { getTools } from '$lib/apis/tools';
@@ -36,17 +37,19 @@
 	import RichTextInput from '../common/RichTextInput.svelte';
 	import { generateAutoCompletion } from '$lib/apis';
 	import { error, text } from '@sveltejs/kit';
+	import Image from '../common/Image.svelte';
 
 	const i18n = getContext('i18n');
 
 	export let transparentBackground = false;
 
+	export let onChange: Function = () => {};
 	export let createMessagePair: Function;
 	export let stopResponse: Function;
 
 	export let autoScroll = false;
 
-	export let atSelectedModel: Model | undefined;
+	export let atSelectedModel: Model | undefined = undefined;
 	export let selectedModels: [''];
 
 	let selectedModelIds = [];
@@ -60,6 +63,13 @@
 	export let selectedToolIds = [];
 	export let webSearchEnabled = false;
 
+	$: onChange({
+		prompt,
+		files,
+		selectedToolIds,
+		webSearchEnabled
+	});
+
 	let loaded = false;
 	let recording = false;
 
@@ -88,14 +98,49 @@
 		});
 	};
 
+	const screenCaptureHandler = async () => {
+		try {
+			// Request screen media
+			const mediaStream = await navigator.mediaDevices.getDisplayMedia({
+				video: { cursor: 'never' },
+				audio: false
+			});
+			// Once the user selects a screen, temporarily create a video element
+			const video = document.createElement('video');
+			video.srcObject = mediaStream;
+			// Ensure the video loads without affecting user experience or tab switching
+			await video.play();
+			// Set up the canvas to match the video dimensions
+			const canvas = document.createElement('canvas');
+			canvas.width = video.videoWidth;
+			canvas.height = video.videoHeight;
+			// Grab a single frame from the video stream using the canvas
+			const context = canvas.getContext('2d');
+			context.drawImage(video, 0, 0, canvas.width, canvas.height);
+			// Stop all video tracks (stop screen sharing) after capturing the image
+			mediaStream.getTracks().forEach((track) => track.stop());
+
+			// bring back focus to this current tab, so that the user can see the screen capture
+			window.focus();
+
+			// Convert the canvas to a Base64 image URL
+			const imageUrl = canvas.toDataURL('image/png');
+			// Add the captured image to the files array to render it
+			files = [...files, { type: 'image', url: imageUrl }];
+			// Clean memory: Clear video srcObject
+			video.srcObject = null;
+		} catch (error) {
+			// Handle any errors (e.g., user cancels screen sharing)
+			console.error('Error capturing screen:', error);
+		}
+	};
+
 	const uploadFileHandler = async (file, fullContext: boolean = false) => {
 		if ($_user?.role !== 'admin' && !($_user?.permissions?.chat?.file_upload ?? true)) {
 			toast.error($i18n.t('You do not have permission to upload files.'));
 			return null;
 		}
 
-		console.log(file);
-
 		const tempItemId = uuidv4();
 		const fileItem = {
 			type: 'file',
@@ -139,14 +184,22 @@
 			const uploadedFile = await uploadFile(localStorage.token, file);
 
 			if (uploadedFile) {
+				console.log('File upload completed:', {
+					id: uploadedFile.id,
+					name: fileItem.name,
+					collection: uploadedFile?.meta?.collection_name
+				});
+
 				if (uploadedFile.error) {
+					console.warn('File upload warning:', uploadedFile.error);
 					toast.warning(uploadedFile.error);
 				}
 
 				fileItem.status = 'uploaded';
 				fileItem.file = uploadedFile;
 				fileItem.id = uploadedFile.id;
-				fileItem.collection_name = uploadedFile?.meta?.collection_name;
+				fileItem.collection_name =
+					uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
 				fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
 
 				files = files;
@@ -160,13 +213,23 @@
 	};
 
 	const inputFilesHandler = async (inputFiles) => {
+		console.log('Input files handler called with:', inputFiles);
 		inputFiles.forEach((file) => {
-			console.log(file, file.name.split('.').at(-1));
+			console.log('Processing file:', {
+				name: file.name,
+				type: file.type,
+				size: file.size,
+				extension: file.name.split('.').at(-1)
+			});
 
 			if (
 				($config?.file?.max_size ?? null) !== null &&
 				file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
 			) {
+				console.log('File exceeds max size limit:', {
+					fileSize: file.size,
+					maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
+				});
 				toast.error(
 					$i18n.t(`File size should not exceed {{maxSize}} MB.`, {
 						maxSize: $config?.file?.max_size
@@ -181,12 +244,23 @@
 					return;
 				}
 				let reader = new FileReader();
-				reader.onload = (event) => {
+				reader.onload = async (event) => {
+					let imageUrl = event.target.result;
+
+					if ($settings?.imageCompression ?? false) {
+						const width = $settings?.imageCompressionSize?.width ?? null;
+						const height = $settings?.imageCompressionSize?.height ?? null;
+
+						if (width || height) {
+							imageUrl = await compressImage(imageUrl, width, height);
+						}
+					}
+
 					files = [
 						...files,
 						{
 							type: 'image',
-							url: `${event.target.result}`
+							url: `${imageUrl}`
 						}
 					];
 				};
@@ -272,7 +346,11 @@
 {#if loaded}
 	<div class="w-full font-primary">
 		<div class=" mx-auto inset-x-0 bg-transparent flex justify-center">
-			<div class="flex flex-col px-3 max-w-6xl w-full">
+			<div
+				class="flex flex-col px-3 {($settings?.widescreenMode ?? null)
+					? 'max-w-full'
+					: 'max-w-6xl'} w-full"
+			>
 				<div class="relative">
 					{#if autoScroll === false && history?.currentId}
 						<div
@@ -410,7 +488,11 @@
 		</div>
 
 		<div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
-			<div class="max-w-6xl px-2.5 mx-auto inset-x-0">
+			<div
+				class="{($settings?.widescreenMode ?? null)
+					? 'max-w-full'
+					: 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
+			>
 				<div class="">
 					<input
 						bind:this={filesInputElement}
@@ -462,7 +544,7 @@
 							}}
 						>
 							<div
-								class="flex-1 flex flex-col relative w-full rounded-3xl px-1 bg-gray-50 dark:bg-gray-400/5 dark:text-gray-100"
+								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 ?? 'LTR'}
 							>
 								{#if files.length > 0}
@@ -471,10 +553,10 @@
 											{#if file.type === 'image'}
 												<div class=" relative group">
 													<div class="relative">
-														<img
+														<Image
 															src={file.url}
 															alt="input"
-															class=" h-16 w-16 rounded-xl object-cover"
+															imageClassName=" h-16 w-16 rounded-xl object-cover"
 														/>
 														{#if atSelectedModel ? visionCapableModels.length === 0 : selectedModels.length !== visionCapableModels.length}
 															<Tooltip
@@ -551,9 +633,30 @@
 										<InputMenu
 											bind:webSearchEnabled
 											bind:selectedToolIds
+											{screenCaptureHandler}
 											uploadFilesHandler={() => {
 												filesInputElement.click();
 											}}
+											uploadGoogleDriveHandler={async () => {
+												try {
+													const fileData = await createPicker();
+													if (fileData) {
+														const file = new File([fileData.blob], fileData.name, {
+															type: fileData.blob.type
+														});
+														await uploadFileHandler(file);
+													} else {
+														console.log('No file was selected from Google Drive');
+													}
+												} catch (error) {
+													console.error('Google Drive Error:', error);
+													toast.error(
+														$i18n.t('Error accessing Google Drive: {{error}}', {
+															error: error.message
+														})
+													);
+												}
+											}}
 											onClose={async () => {
 												await tick();
 
@@ -626,6 +729,10 @@
 													const commandsContainerElement =
 														document.getElementById('commands-container');
 
+													if (e.key === 'Escape') {
+														stopResponse();
+													}
+
 													// Command/Ctrl + Shift + Enter to submit a message pair
 													if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
 														e.preventDefault();
@@ -651,14 +758,14 @@
 															...document.getElementsByClassName('user-message')
 														]?.at(-1);
 
-														const editButton = [
-															...document.getElementsByClassName('edit-user-message-button')
-														]?.at(-1);
-
-														console.log(userMessageElement);
+														if (userMessageElement) {
+															userMessageElement.scrollIntoView({ block: 'center' });
+															const editButton = [
+																...document.getElementsByClassName('edit-user-message-button')
+															]?.at(-1);
 
-														userMessageElement.scrollIntoView({ block: 'center' });
-														editButton?.click();
+															editButton?.click();
+														}
 													}
 
 													if (commandsContainerElement) {
@@ -809,6 +916,9 @@
 												const commandsContainerElement =
 													document.getElementById('commands-container');
 
+												if (e.key === 'Escape') {
+													stopResponse();
+												}
 												// Command/Ctrl + Shift + Enter to submit a message pair
 												if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
 													e.preventDefault();

+ 7 - 1
src/lib/components/chat/MessageInput/CallOverlay.svelte

@@ -217,7 +217,13 @@
 	const startRecording = async () => {
 		if ($showCallOverlay) {
 			if (!audioStream) {
-				audioStream = await navigator.mediaDevices.getUserMedia({ audio: true });
+				audioStream = await navigator.mediaDevices.getUserMedia({
+					audio: {
+						echoCancellation: true,
+						noiseSuppression: true,
+						autoGainControl: true
+					}
+				});
 			}
 			mediaRecorder = new MediaRecorder(audioStream);
 

+ 57 - 3
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -3,7 +3,8 @@
 	import { flyAndScale } from '$lib/utils/transitions';
 	import { getContext, onMount, tick } from 'svelte';
 
-	import { config, user, tools as _tools } from '$lib/stores';
+	import { config, user, tools as _tools, mobile } from '$lib/stores';
+	import { createPicker } from '$lib/utils/google-drive-picker';
 	import { getTools } from '$lib/apis/tools';
 
 	import Dropdown from '$lib/components/common/Dropdown.svelte';
@@ -12,10 +13,14 @@
 	import Switch from '$lib/components/common/Switch.svelte';
 	import GlobeAltSolid from '$lib/components/icons/GlobeAltSolid.svelte';
 	import WrenchSolid from '$lib/components/icons/WrenchSolid.svelte';
+	import CameraSolid from '$lib/components/icons/CameraSolid.svelte';
 
 	const i18n = getContext('i18n');
 
+	export let screenCaptureHandler: Function;
 	export let uploadFilesHandler: Function;
+	export let uploadGoogleDriveHandler: Function;
+
 	export let selectedToolIds: string[] = [];
 
 	export let webSearchEnabled: boolean;
@@ -127,15 +132,64 @@
 				<hr class="border-black/5 dark:border-white/5 my-1" />
 			{/if}
 
+			{#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-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
 				on:click={() => {
 					uploadFilesHandler();
 				}}
 			>
 				<DocumentArrowUpSolid />
-				<div class=" line-clamp-1">{$i18n.t('Upload Files')}</div>
+				<div class="line-clamp-1">{$i18n.t('Upload Files')}</div>
 			</DropdownMenu.Item>
+
+			{#if $config?.features?.enable_google_drive_integration}
+				<DropdownMenu.Item
+					class="flex gap-2 items-center px-3 py-2 text-sm font-medium cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
+					on:click={() => {
+						uploadGoogleDriveHandler();
+					}}
+				>
+					<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 87.3 78" class="w-5 h-5">
+						<path
+							d="m6.6 66.85 3.85 6.65c.8 1.4 1.95 2.5 3.3 3.3l13.75-23.8h-27.5c0 1.55.4 3.1 1.2 4.5z"
+							fill="#0066da"
+						/>
+						<path
+							d="m43.65 25-13.75-23.8c-1.35.8-2.5 1.9-3.3 3.3l-25.4 44a9.06 9.06 0 0 0 -1.2 4.5h27.5z"
+							fill="#00ac47"
+						/>
+						<path
+							d="m73.55 76.8c1.35-.8 2.5-1.9 3.3-3.3l1.6-2.75 7.65-13.25c.8-1.4 1.2-2.95 1.2-4.5h-27.502l5.852 11.5z"
+							fill="#ea4335"
+						/>
+						<path
+							d="m43.65 25 13.75-23.8c-1.35-.8-2.9-1.2-4.5-1.2h-18.5c-1.6 0-3.15.45-4.5 1.2z"
+							fill="#00832d"
+						/>
+						<path
+							d="m59.8 53h-32.3l-13.75 23.8c1.35.8 2.9 1.2 4.5 1.2h50.8c1.6 0 3.15-.45 4.5-1.2z"
+							fill="#2684fc"
+						/>
+						<path
+							d="m73.4 26.5-12.7-22c-.8-1.4-1.95-2.5-3.3-3.3l-13.75 23.8 16.15 28h27.45c0-1.55-.4-3.1-1.2-4.5z"
+							fill="#ffba00"
+						/>
+					</svg>
+					<div class="line-clamp-1">{$i18n.t('Google Drive')}</div>
+				</DropdownMenu.Item>
+			{/if}
 		</DropdownMenu.Content>
 	</div>
 </Dropdown>

+ 7 - 1
src/lib/components/chat/MessageInput/VoiceRecording.svelte

@@ -161,7 +161,13 @@
 	const startRecording = async () => {
 		startDurationCounter();
 
-		stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+		stream = await navigator.mediaDevices.getUserMedia({
+			audio: {
+				echoCancellation: true,
+				noiseSuppression: true,
+				autoGainControl: true
+			}
+		});
 		mediaRecorder = new MediaRecorder(stream);
 		mediaRecorder.onstart = () => {
 			console.log('Recording started');

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

@@ -16,6 +16,8 @@
 
 	const i18n = getContext('i18n');
 
+	export let className = 'h-full flex pt-8';
+
 	export let chatId = '';
 	export let user = $_user;
 
@@ -33,6 +35,7 @@
 	export let chatActionHandler: Function;
 	export let showMessage: Function = () => {};
 	export let submitMessage: Function = () => {};
+	export let addMessages: Function = () => {};
 
 	export let readOnly = false;
 
@@ -332,7 +335,7 @@
 	};
 </script>
 
-<div class="h-full flex pt-8">
+<div class={className}>
 	{#if Object.keys(history?.messages ?? {}).length == 0}
 		<ChatPlaceholder
 			modelIds={selectedModels}
@@ -404,6 +407,7 @@
 							{regenerateResponse}
 							{continueResponse}
 							{mergeResponses}
+							{addMessages}
 							{triggerScroll}
 							{readOnly}
 						/>

+ 26 - 113
src/lib/components/chat/Messages/ContentRenderer.svelte

@@ -4,28 +4,28 @@
 	const dispatch = createEventDispatcher();
 
 	import Markdown from './Markdown.svelte';
-	import LightBlub from '$lib/components/icons/LightBlub.svelte';
 	import { chatId, mobile, showArtifacts, showControls, showOverview } from '$lib/stores';
-	import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
-	import { stringify } from 'postcss';
+	import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte';
+	import { createMessagesList } from '$lib/utils';
 
 	export let id;
 	export let content;
+	export let history;
 	export let model = null;
 	export let sources = null;
 
 	export let save = false;
 	export let floatingButtons = true;
+
 	export let onSourceClick = () => {};
+	export let onAddMessages = () => {};
 
 	let contentContainerElement;
-	let buttonsContainerElement;
 
-	let selectedText = '';
-	let floatingInput = false;
-	let floatingInputValue = '';
+	let floatingButtonsElement;
 
 	const updateButtonPosition = (event) => {
+		const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
 		if (
 			!contentContainerElement?.contains(event.target) &&
 			!buttonsContainerElement?.contains(event.target)
@@ -42,7 +42,6 @@
 			let selection = window.getSelection();
 
 			if (selection.toString().trim().length > 0) {
-				floatingInput = false;
 				const range = selection.getRangeAt(0);
 				const rect = range.getBoundingClientRect();
 
@@ -56,11 +55,10 @@
 					buttonsContainerElement.style.display = 'block';
 
 					// Calculate space available on the right
-					const spaceOnRight = parentRect.width - (left + buttonsContainerElement.offsetWidth);
-
-					let thirdScreenWidth = window.innerWidth / 3;
+					const spaceOnRight = parentRect.width - left;
+					let halfScreenWidth = $mobile ? window.innerWidth / 2 : window.innerWidth / 3;
 
-					if (spaceOnRight < thirdScreenWidth) {
+					if (spaceOnRight < halfScreenWidth) {
 						const right = parentRect.right - rect.right;
 						buttonsContainerElement.style.right = `${right}px`;
 						buttonsContainerElement.style.left = 'auto'; // Reset left
@@ -69,7 +67,6 @@
 						buttonsContainerElement.style.left = `${left}px`;
 						buttonsContainerElement.style.right = 'auto'; // Reset right
 					}
-
 					buttonsContainerElement.style.top = `${top + 5}px`; // +5 to add some spacing
 				}
 			} else {
@@ -79,28 +76,14 @@
 	};
 
 	const closeFloatingButtons = () => {
+		const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
 		if (buttonsContainerElement) {
 			buttonsContainerElement.style.display = 'none';
-			selectedText = '';
-			floatingInput = false;
-			floatingInputValue = '';
 		}
-	};
 
-	const selectAskHandler = () => {
-		dispatch('select', {
-			type: 'ask',
-			content: selectedText,
-			input: floatingInputValue
-		});
-
-		floatingInput = false;
-		floatingInputValue = '';
-		selectedText = '';
-
-		// Clear selection
-		window.getSelection().removeAllRanges();
-		buttonsContainerElement.style.display = 'none';
+		if (floatingButtonsElement) {
+			floatingButtonsElement.closeHandler();
+		}
 	};
 
 	const keydownHandler = (e) => {
@@ -176,86 +159,16 @@
 	/>
 </div>
 
-{#if floatingButtons}
-	<div
-		bind:this={buttonsContainerElement}
-		class="absolute rounded-lg mt-1 text-xs z-[9999]"
-		style="display: none"
-	>
-		{#if !floatingInput}
-			<div
-				class="flex flex-row gap-0.5 shrink-0 p-1 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-lg shadow-xl"
-			>
-				<button
-					class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
-					on:click={() => {
-						selectedText = window.getSelection().toString();
-						floatingInput = true;
-					}}
-				>
-					<ChatBubble className="size-3 shrink-0" />
-
-					<div class="shrink-0">Ask</div>
-				</button>
-				<button
-					class="px-1 hover:bg-gray-50 dark:hover:bg-gray-800 rounded flex items-center gap-1 min-w-fit"
-					on:click={() => {
-						const selection = window.getSelection();
-						dispatch('select', {
-							type: 'explain',
-							content: selection.toString()
-						});
-
-						// Clear selection
-						selection.removeAllRanges();
-						buttonsContainerElement.style.display = 'none';
-					}}
-				>
-					<LightBlub className="size-3 shrink-0" />
-
-					<div class="shrink-0">Explain</div>
-				</button>
-			</div>
-		{:else}
-			<div
-				class="py-1 flex dark:text-gray-100 bg-gray-50 dark:bg-gray-800 border dark:border-gray-800 w-72 rounded-full shadow-xl"
-			>
-				<input
-					type="text"
-					class="ml-5 bg-transparent outline-none w-full flex-1 text-sm"
-					placeholder={$i18n.t('Ask a question')}
-					bind:value={floatingInputValue}
-					on:keydown={(e) => {
-						if (e.key === 'Enter') {
-							selectAskHandler();
-						}
-					}}
-				/>
-
-				<div class="ml-1 mr-2">
-					<button
-						class="{floatingInputValue !== ''
-							? '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 m-0.5 self-center"
-						on:click={() => {
-							selectAskHandler();
-						}}
-					>
-						<svg
-							xmlns="http://www.w3.org/2000/svg"
-							viewBox="0 0 16 16"
-							fill="currentColor"
-							class="size-4"
-						>
-							<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>
-				</div>
-			</div>
-		{/if}
-	</div>
+{#if floatingButtons && model}
+	<FloatingButtons
+		bind:this={floatingButtonsElement}
+		{id}
+		model={model?.id}
+		messages={createMessagesList(history, id)}
+		onAdd={({ modelId, parentId, messages }) => {
+			console.log(modelId, parentId, messages);
+			onAddMessages({ modelId, parentId, messages });
+			closeFloatingButtons();
+		}}
+	/>
 {/if}

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

@@ -34,16 +34,27 @@
 	const exportTableToCSVHandler = (token, tokenIdx = 0) => {
 		console.log('Exporting table to CSV');
 
+		// Extract header row text and escape for CSV.
+		const header = token.header.map((headerCell) => `"${headerCell.text.replace(/"/g, '""')}"`);
+
 		// Create an array for rows that will hold the mapped cell text.
 		const rows = token.rows.map((row) =>
-			row.map((cell) => cell.tokens.map((token) => token.text).join(''))
+			row.map((cell) => {
+				// Map tokens into a single text
+				const cellContent = cell.tokens.map((token) => token.text).join('');
+				// Escape double quotes and wrap the content in double quotes
+				return `"${cellContent.replace(/"/g, '""')}"`;
+			})
 		);
 
+		// Combine header and rows
+		const csvData = [header, ...rows];
+
 		// Join the rows using commas (,) as the separator and rows using newline (\n).
-		const csvContent = rows.map((row) => row.join(',')).join('\n');
+		const csvContent = csvData.map((row) => row.join(',')).join('\n');
 
 		// Log rows and CSV content to ensure everything is correct.
-		console.log(rows);
+		console.log(csvData);
 		console.log(csvContent);
 
 		// To handle Unicode characters, you need to prefix the data with a BOM:
@@ -100,7 +111,7 @@
 							{#each token.header as header, headerIdx}
 								<th
 									scope="col"
-									class="!px-3 !py-1.5 cursor-pointer select-none border border-gray-50 dark:border-gray-850"
+									class="!px-3 !py-1.5 cursor-pointer border border-gray-50 dark:border-gray-850"
 									style={token.align[headerIdx] ? '' : `text-align: ${token.align[headerIdx]}`}
 								>
 									<div class="flex flex-col gap-1.5 text-left">

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

@@ -35,6 +35,7 @@
 	export let continueResponse;
 	export let mergeResponses;
 
+	export let addMessages;
 	export let triggerScroll;
 	export let readOnly = false;
 </script>
@@ -79,6 +80,7 @@
 				{submitMessage}
 				{continueResponse}
 				{regenerateResponse}
+				{addMessages}
 				{readOnly}
 			/>
 		{:else}
@@ -97,6 +99,7 @@
 				{regenerateResponse}
 				{mergeResponses}
 				{triggerScroll}
+				{addMessages}
 				{readOnly}
 			/>
 		{/if}

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

@@ -36,6 +36,8 @@
 	export let regenerateResponse: Function;
 	export let mergeResponses: Function;
 
+	export let addMessages: Function;
+
 	export let triggerScroll: Function;
 
 	const dispatch = createEventDispatcher();
@@ -233,6 +235,7 @@
 										groupedMessageIdsIdx[modelIdx] =
 											groupedMessageIds[modelIdx].messageIds.length - 1;
 									}}
+									{addMessages}
 									{readOnly}
 								/>
 							{/if}

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

@@ -1,3 +1,3 @@
-<div class=" self-center font-semibold mb-0.5 line-clamp-1 contents">
+<div class=" self-center font-semibold mb-0.5 line-clamp-1 flex gap-1 items-center">
 	<slot />
 </div>

+ 12 - 3
src/lib/components/chat/Messages/RateComment.svelte

@@ -52,12 +52,21 @@
 	}
 
 	const init = () => {
-		selectedReason = message?.annotation?.reason ?? '';
-		comment = message?.annotation?.comment ?? '';
+		if (!selectedReason) {
+			selectedReason = message?.annotation?.reason ?? '';
+		}
+
+		if (!comment) {
+			comment = message?.annotation?.comment ?? '';
+		}
+
 		tags = (message?.annotation?.tags ?? []).map((tag) => ({
 			name: tag
 		}));
-		detailedRating = message?.annotation?.details?.rating ?? null;
+
+		if (!detailedRating) {
+			detailedRating = message?.annotation?.details?.rating ?? null;
+		}
 	};
 
 	onMount(() => {

+ 73 - 24
src/lib/components/chat/Messages/ResponseMessage.svelte

@@ -118,6 +118,8 @@
 	export let continueResponse: Function;
 	export let regenerateResponse: Function;
 
+	export let addMessages: Function;
+
 	export let isLastMessage = true;
 	export let readOnly = false;
 
@@ -487,11 +489,15 @@
 
 		<div class="flex-auto w-0 pl-1">
 			<Name>
-				{model?.name ?? message.model}
+				<Tooltip content={model?.name ?? message.model} placement="top-start">
+					<span class="line-clamp-1">
+						{model?.name ?? message.model}
+					</span>
+				</Tooltip>
 
 				{#if message.timestamp}
 					<span
-						class=" self-center invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5"
+						class=" self-center shrink-0 translate-y-0.5 invisible group-hover:visible text-gray-400 text-xs font-medium uppercase ml-0.5 -mt-0.5"
 					>
 						{dayjs(message.timestamp * 1000).format($i18n.t('h:mm a'))}
 					</span>
@@ -517,37 +523,76 @@
 							{@const status = (
 								message?.statusHistory ?? [...(message?.status ? [message?.status] : [])]
 							).at(-1)}
-							<div class="status-description flex items-center gap-2 py-0.5">
-								{#if status?.done === false}
-									<div class="">
-										<Spinner className="size-4" />
-									</div>
-								{/if}
+							{#if !status?.hidden}
+								<div class="status-description flex items-center gap-2 py-0.5">
+									{#if status?.done === false}
+										<div class="">
+											<Spinner className="size-4" />
+										</div>
+									{/if}
 
-								{#if status?.action === 'web_search' && status?.urls}
-									<WebSearchResults {status}>
+									{#if status?.action === 'web_search' && status?.urls}
+										<WebSearchResults {status}>
+											<div class="flex flex-col justify-center -space-y-0.5">
+												<div
+													class="{status?.done === false
+														? 'shimmer'
+														: ''} text-base line-clamp-1 text-wrap"
+												>
+													<!-- $i18n.t("Generating search query") -->
+													<!-- $i18n.t("No search query generated") -->
+
+													<!-- $i18n.t('Searched {{count}} sites') -->
+													{#if status?.description.includes('{{count}}')}
+														{$i18n.t(status?.description, {
+															count: status?.urls.length
+														})}
+													{:else if status?.description === 'No search query generated'}
+														{$i18n.t('No search query generated')}
+													{:else if status?.description === 'Generating search query'}
+														{$i18n.t('Generating search query')}
+													{:else}
+														{status?.description}
+													{/if}
+												</div>
+											</div>
+										</WebSearchResults>
+									{:else if status?.action === 'knowledge_search'}
 										<div class="flex flex-col justify-center -space-y-0.5">
 											<div
 												class="{status?.done === false
 													? 'shimmer'
-													: ''} text-base line-clamp-1 text-wrap"
+													: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
 											>
-												{status?.description}
+												{$i18n.t(`Searching Knowledge for "{{searchQuery}}"`, {
+													searchQuery: status.query
+												})}
 											</div>
 										</div>
-									</WebSearchResults>
-								{:else}
-									<div class="flex flex-col justify-center -space-y-0.5">
-										<div
-											class="{status?.done === false
-												? 'shimmer'
-												: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
-										>
-											{status?.description}
+									{:else}
+										<div class="flex flex-col justify-center -space-y-0.5">
+											<div
+												class="{status?.done === false
+													? 'shimmer'
+													: ''} text-gray-500 dark:text-gray-500 text-base line-clamp-1 text-wrap"
+											>
+												<!-- $i18n.t(`Searching "{{searchQuery}}"`) -->
+												{#if status?.description.includes('{{searchQuery}}')}
+													{$i18n.t(status?.description, {
+														searchQuery: status?.query
+													})}
+												{:else if status?.description === 'No search query generated'}
+													{$i18n.t('No search query generated')}
+												{:else if status?.description === 'Generating search query'}
+													{$i18n.t('Generating search query')}
+												{:else}
+													{status?.description}
+												{/if}
+											</div>
 										</div>
-									</div>
-								{/if}
-							</div>
+									{/if}
+								</div>
+							{/if}
 						{/if}
 
 						{#if edit === true}
@@ -620,6 +665,7 @@
 									<!-- unless message.error === true which is legacy error handling, where the error message is stored in message.content -->
 									<ContentRenderer
 										id={message.id}
+										{history}
 										content={message.content}
 										sources={message.sources}
 										floatingButtons={message?.done}
@@ -633,6 +679,9 @@
 												sourceButton.click();
 											}
 										}}
+										onAddMessages={({ modelId, parentId, messages }) => {
+											addMessages({ modelId, parentId, messages });
+										}}
 										on:update={(e) => {
 											const { raw, oldContent, newContent } = e.detail;
 

+ 24 - 8
src/lib/components/chat/Messages/Skeleton.svelte

@@ -1,19 +1,35 @@
+<script lang="ts">
+	export let size = 'md';
+</script>
+
 <div class="w-full mt-2 mb-2">
 	<div class="animate-pulse flex w-full">
-		<div class="space-y-2 w-full">
-			<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded mr-14" />
+		<div class="{size === 'md' ? 'space-y-2' : 'space-y-1.5'} w-full">
+			<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded mr-14" />
 
 			<div class="grid grid-cols-3 gap-4">
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-2"
+				/>
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1"
+				/>
 			</div>
 			<div class="grid grid-cols-4 gap-4">
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1" />
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-2" />
-				<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4" />
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1"
+				/>
+				<div
+					class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-2"
+				/>
+				<div
+					class="{size === 'md'
+						? 'h-2'
+						: 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded col-span-1 mr-4"
+				/>
 			</div>
 
-			<div class="h-2 bg-gray-200 dark:bg-gray-600 rounded" />
+			<div class="{size === 'md' ? 'h-2' : 'h-1.5'} bg-gray-200 dark:bg-gray-600 rounded" />
 		</div>
 	</div>
 </div>

+ 194 - 0
src/lib/components/chat/Navbar.svelte

@@ -0,0 +1,194 @@
+<script lang="ts">
+	import { getContext } from 'svelte';
+	import { toast } from 'svelte-sonner';
+
+	import {
+		WEBUI_NAME,
+		chatId,
+		mobile,
+		settings,
+		showArchivedChats,
+		showControls,
+		showSidebar,
+		temporaryChatEnabled,
+		user
+	} from '$lib/stores';
+
+	import { slide } from 'svelte/transition';
+	import { page } from '$app/stores';
+
+	import ShareChatModal from '../chat/ShareChatModal.svelte';
+	import ModelSelector from '../chat/ModelSelector.svelte';
+	import Tooltip from '../common/Tooltip.svelte';
+	import Menu from '$lib/components/layout/Navbar/Menu.svelte';
+	import UserMenu from '$lib/components/layout/Sidebar/UserMenu.svelte';
+	import MenuLines from '../icons/MenuLines.svelte';
+	import AdjustmentsHorizontal from '../icons/AdjustmentsHorizontal.svelte';
+
+	import PencilSquare from '../icons/PencilSquare.svelte';
+
+	const i18n = getContext('i18n');
+
+	export let initNewChat: Function;
+	export let title: string = $WEBUI_NAME;
+	export let shareEnabled: boolean = false;
+
+	export let chat;
+	export let selectedModels;
+	export let showModelSelector = true;
+
+	let showShareChatModal = false;
+	let showDownloadChatModal = false;
+</script>
+
+<ShareChatModal bind:show={showShareChatModal} chatId={$chatId} />
+
+<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
+	<div
+		class=" bg-gradient-to-b via-50% from-white via-white to-transparent dark:from-gray-900 dark:via-gray-900 dark:to-transparent pointer-events-none absolute inset-0 -bottom-7 z-[-1] blur"
+	></div>
+
+	<div class=" flex max-w-full w-full mx-auto px-1 pt-0.5 bg-transparent">
+		<div class="flex items-center w-full max-w-full">
+			<div
+				class="{$showSidebar
+					? 'md:hidden'
+					: ''} mr-1 self-start flex flex-none items-center text-gray-600 dark:text-gray-400"
+			>
+				<button
+					id="sidebar-toggle-button"
+					class="cursor-pointer px-2 py-2 flex rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+					on:click={() => {
+						showSidebar.set(!$showSidebar);
+					}}
+					aria-label="Toggle Sidebar"
+				>
+					<div class=" m-auto self-center">
+						<MenuLines />
+					</div>
+				</button>
+			</div>
+
+			<div
+				class="flex-1 overflow-hidden max-w-full py-0.5
+			{$showSidebar ? 'ml-1' : ''}
+			"
+			>
+				{#if showModelSelector}
+					<ModelSelector bind:selectedModels showSetDefault={!shareEnabled} />
+				{/if}
+			</div>
+
+			<div class="self-start flex flex-none items-center text-gray-600 dark:text-gray-400">
+				<!-- <div class="md:hidden flex self-center w-[1px] h-5 mx-2 bg-gray-300 dark:bg-stone-700" /> -->
+				{#if shareEnabled && chat && (chat.id || $temporaryChatEnabled)}
+					<Menu
+						{chat}
+						{shareEnabled}
+						shareHandler={() => {
+							showShareChatModal = !showShareChatModal;
+						}}
+						downloadHandler={() => {
+							showDownloadChatModal = !showDownloadChatModal;
+						}}
+					>
+						<button
+							class="flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+							id="chat-context-menu-button"
+						>
+							<div class=" m-auto self-center">
+								<svg
+									xmlns="http://www.w3.org/2000/svg"
+									fill="none"
+									viewBox="0 0 24 24"
+									stroke-width="1.5"
+									stroke="currentColor"
+									class="size-5"
+								>
+									<path
+										stroke-linecap="round"
+										stroke-linejoin="round"
+										d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
+									/>
+								</svg>
+							</div>
+						</button>
+					</Menu>
+				{:else if $mobile}
+					<Tooltip content={$i18n.t('Controls')}>
+						<button
+							class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+							on:click={async () => {
+								await showControls.set(!$showControls);
+							}}
+							aria-label="Controls"
+						>
+							<div class=" m-auto self-center">
+								<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
+							</div>
+						</button>
+					</Tooltip>
+				{/if}
+
+				{#if !$mobile}
+					<Tooltip content={$i18n.t('Controls')}>
+						<button
+							class=" flex cursor-pointer px-2 py-2 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+							on:click={async () => {
+								await showControls.set(!$showControls);
+							}}
+							aria-label="Controls"
+						>
+							<div class=" m-auto self-center">
+								<AdjustmentsHorizontal className=" size-5" strokeWidth="0.5" />
+							</div>
+						</button>
+					</Tooltip>
+				{/if}
+
+				<Tooltip content={$i18n.t('New Chat')}>
+					<button
+						id="new-chat-button"
+						class=" flex {$showSidebar
+							? 'md:hidden'
+							: ''} cursor-pointer px-2 py-2 rounded-xl text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+						on:click={() => {
+							initNewChat();
+						}}
+						aria-label="New Chat"
+					>
+						<div class=" m-auto self-center">
+							<PencilSquare className=" size-5" strokeWidth="2" />
+						</div>
+					</button>
+				</Tooltip>
+
+				{#if $user !== undefined}
+					<UserMenu
+						className="max-w-[200px]"
+						role={$user.role}
+						on:show={(e) => {
+							if (e.detail === 'archived-chat') {
+								showArchivedChats.set(true);
+							}
+						}}
+					>
+						<button
+							class="select-none flex rounded-xl p-1.5 w-full hover:bg-gray-50 dark:hover:bg-gray-850 transition"
+							aria-label="User Menu"
+						>
+							<div class=" self-center">
+								<img
+									src={$user.profile_image_url}
+									class="size-6 object-cover rounded-full"
+									alt="User profile"
+									draggable="false"
+								/>
+							</div>
+						</button>
+					</UserMenu>
+				{/if}
+			</div>
+		</div>
+	</div>
+</div>

+ 42 - 0
src/lib/components/chat/Settings/About.svelte

@@ -106,6 +106,12 @@
 
 		<hr class=" dark:border-gray-850" />
 
+		<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
+			Emoji graphics provided by
+			<a href="https://github.com/jdecked/twemoji" target="_blank">Twemoji</a>, licensed under
+			<a href="https://creativecommons.org/licenses/by/4.0/" target="_blank">CC-BY 4.0</a>.
+		</div>
+
 		<div class="flex space-x-1">
 			<a href="https://discord.gg/5rJgQTnV4s" target="_blank">
 				<img
@@ -129,6 +135,42 @@
 			</a>
 		</div>
 
+		<div>
+			<pre
+				class="text-xs text-gray-400 dark:text-gray-500">Copyright (c) {new Date().getFullYear()} <a
+					href="https://openwebui.com"
+					target="_blank"
+					class="underline">Open WebUI (Timothy Jaeryang Baek)</a
+				>
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+</pre>
+		</div>
+
 		<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
 			{#if !$WEBUI_NAME.includes('Open WebUI')}
 				<span class=" text-gray-500 dark:text-gray-300 font-medium">{$WEBUI_NAME}</span> -

+ 29 - 1
src/lib/components/chat/Settings/Account.svelte

@@ -2,7 +2,7 @@
 	import { toast } from 'svelte-sonner';
 	import { onMount, getContext } from 'svelte';
 
-	import { user, config } from '$lib/stores';
+	import { user, config, settings } from '$lib/stores';
 	import { updateUserProfile, createAPIKey, getAPIKey } from '$lib/apis/auths';
 
 	import UpdatePassword from './Account/UpdatePassword.svelte';
@@ -16,10 +16,12 @@
 	const i18n = getContext('i18n');
 
 	export let saveHandler: Function;
+	export let saveSettings: Function;
 
 	let profileImageUrl = '';
 	let name = '';
 
+	let webhookUrl = '';
 	let showAPIKeys = false;
 
 	let JWTTokenCopied = false;
@@ -35,6 +37,15 @@
 			}
 		}
 
+		if (webhookUrl !== $settings?.notifications?.webhook_url) {
+			saveSettings({
+				notifications: {
+					...$settings.notifications,
+					webhook_url: webhookUrl
+				}
+			});
+		}
+
 		const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch(
 			(error) => {
 				toast.error(error);
@@ -60,6 +71,7 @@
 	onMount(async () => {
 		name = $user.name;
 		profileImageUrl = $user.profile_image_url;
+		webhookUrl = $settings?.notifications?.webhook_url ?? '';
 
 		APIKey = await getAPIKey(localStorage.token).catch((error) => {
 			console.log(error);
@@ -226,6 +238,22 @@
 					</div>
 				</div>
 			</div>
+
+			<div class="pt-2">
+				<div class="flex flex-col w-full">
+					<div class=" mb-1 text-xs font-medium">{$i18n.t('Notification Webhook')}</div>
+
+					<div class="flex-1">
+						<input
+							class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-none"
+							type="url"
+							placeholder={$i18n.t('Enter your webhook URL')}
+							bind:value={webhookUrl}
+							required
+						/>
+					</div>
+				</div>
+			</div>
 		</div>
 
 		<div class="py-0.5">

+ 7 - 4
src/lib/components/chat/Settings/Account/UpdatePassword.svelte

@@ -60,9 +60,10 @@
 
 				<div class="flex-1">
 					<input
-						class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+						class="w-full bg-transparent dark:text-gray-300 outline-none placeholder:opacity-30"
 						type="password"
 						bind:value={currentPassword}
+						placeholder={$i18n.t('Enter your current password')}
 						autocomplete="current-password"
 						required
 					/>
@@ -74,9 +75,10 @@
 
 				<div class="flex-1">
 					<input
-						class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+						class="w-full bg-transparent text-sm dark:text-gray-300 outline-none placeholder:opacity-30"
 						type="password"
 						bind:value={newPassword}
+						placeholder={$i18n.t('Enter your new password')}
 						autocomplete="new-password"
 						required
 					/>
@@ -88,9 +90,10 @@
 
 				<div class="flex-1">
 					<input
-						class="w-full rounded py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none"
+						class="w-full bg-transparent text-sm dark:text-gray-300 outline-none placeholder:opacity-30"
 						type="password"
 						bind:value={newPasswordConfirm}
+						placeholder={$i18n.t('Confirm your new password')}
 						autocomplete="off"
 						required
 					/>
@@ -100,7 +103,7 @@
 
 		<div class="mt-3 flex justify-end">
 			<button
-				class=" px-4 py-2 text-xs bg-gray-800 hover:bg-gray-900 dark:bg-gray-700 dark:hover:bg-gray-800 text-gray-100 transition rounded-md font-medium"
+				class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
 			>
 				{$i18n.t('Update password')}
 			</button>

+ 95 - 3
src/lib/components/chat/Settings/Interface.svelte

@@ -32,11 +32,18 @@
 	let showUsername = false;
 	let richTextInput = true;
 	let largeTextAsFile = false;
+	let notificationSound = true;
 
 	let landingPageMode = '';
 	let chatBubble = true;
 	let chatDirection: 'LTR' | 'RTL' = 'LTR';
 
+	let imageCompression = false;
+	let imageCompressionSize = {
+		width: '',
+		height: ''
+	};
+
 	// Admin - Show Update Available Toast
 	let showUpdateToast = true;
 	let showChangelog = true;
@@ -75,6 +82,11 @@
 		saveSettings({ showUpdateToast: showUpdateToast });
 	};
 
+	const toggleNotificationSound = async () => {
+		notificationSound = !notificationSound;
+		saveSettings({ notificationSound: notificationSound });
+	};
+
 	const toggleShowChangelog = async () => {
 		showChangelog = !showChangelog;
 		saveSettings({ showChangelog: showChangelog });
@@ -95,6 +107,11 @@
 		saveSettings({ voiceInterruption: voiceInterruption });
 	};
 
+	const toggleImageCompression = async () => {
+		imageCompression = !imageCompression;
+		saveSettings({ imageCompression });
+	};
+
 	const toggleHapticFeedback = async () => {
 		hapticFeedback = !hapticFeedback;
 		saveSettings({ hapticFeedback: hapticFeedback });
@@ -176,7 +193,8 @@
 
 	const updateInterfaceHandler = async () => {
 		saveSettings({
-			models: [defaultModelId]
+			models: [defaultModelId],
+			imageCompressionSize: imageCompressionSize
 		});
 	};
 
@@ -204,8 +222,13 @@
 		chatDirection = $settings.chatDirection ?? 'LTR';
 		userLocation = $settings.userLocation ?? false;
 
+		notificationSound = $settings.notificationSound ?? true;
+
 		hapticFeedback = $settings.hapticFeedback ?? false;
 
+		imageCompression = $settings.imageCompression ?? false;
+		imageCompressionSize = $settings.imageCompressionSize ?? { width: '', height: '' };
+
 		defaultModelId = $settings?.models?.at(0) ?? '';
 		if ($config?.default_models) {
 			defaultModelId = $config.default_models.split(',')[0];
@@ -356,6 +379,28 @@
 				</div>
 			</div>
 
+			<div>
+				<div class=" py-0.5 flex w-full justify-between">
+					<div class=" self-center text-xs">
+						{$i18n.t('Notification Sound')}
+					</div>
+
+					<button
+						class="p-1 px-3 text-xs flex rounded transition"
+						on:click={() => {
+							toggleNotificationSound();
+						}}
+						type="button"
+					>
+						{#if notificationSound === true}
+							<span class="ml-2 self-center">{$i18n.t('On')}</span>
+						{:else}
+							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+						{/if}
+					</button>
+				</div>
+			</div>
+
 			{#if $user.role === 'admin'}
 				<div>
 					<div class=" py-0.5 flex w-full justify-between">
@@ -577,7 +622,7 @@
 				</div>
 			</div>
 
-			<div>
+			<!-- <div>
 				<div class=" py-0.5 flex w-full justify-between">
 					<div class=" self-center text-xs">
 						{$i18n.t('Fluidly stream large external response chunks')}
@@ -597,7 +642,7 @@
 						{/if}
 					</button>
 				</div>
-			</div>
+			</div> -->
 
 			<div>
 				<div class=" py-0.5 flex w-full justify-between">
@@ -662,6 +707,53 @@
 					</button>
 				</div>
 			</div>
+
+			<div class=" my-1.5 text-sm font-medium">{$i18n.t('File')}</div>
+
+			<div>
+				<div class=" py-0.5 flex w-full justify-between">
+					<div class=" self-center text-xs">{$i18n.t('Image Compression')}</div>
+
+					<button
+						class="p-1 px-3 text-xs flex rounded transition"
+						on:click={() => {
+							toggleImageCompression();
+						}}
+						type="button"
+					>
+						{#if imageCompression === true}
+							<span class="ml-2 self-center">{$i18n.t('On')}</span>
+						{:else}
+							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+						{/if}
+					</button>
+				</div>
+			</div>
+
+			{#if imageCompression}
+				<div>
+					<div class=" py-0.5 flex w-full justify-between text-xs">
+						<div class=" self-center text-xs">{$i18n.t('Image Max Compression Size')}</div>
+
+						<div>
+							<input
+								bind:value={imageCompressionSize.width}
+								type="number"
+								class="w-20 bg-transparent outline-none text-center"
+								min="0"
+								placeholder="Width"
+							/>x
+							<input
+								bind:value={imageCompressionSize.height}
+								type="number"
+								class="w-20 bg-transparent outline-none text-center"
+								min="0"
+								placeholder="Height"
+							/>
+						</div>
+					</div>
+				</div>
+			{/if}
 		</div>
 	</div>
 

部分文件因为文件数量过多而无法显示