Compare commits
399 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 568dbc545c | |||
| b641756020 | |||
| c6660f1d7c | |||
| 195e632ed4 | |||
| b7f743d24c | |||
| 1080ecd22c | |||
| b5bce9cbea | |||
| 647195c578 | |||
| ab4372600c | |||
| 8114223b54 | |||
| a1a658d55b | |||
| 6eb4c0aecd | |||
| 8b998420fe | |||
| c2b5200663 | |||
| ed7db1dd41 | |||
| a1e4e7c007 | |||
| 9feed97f22 | |||
| cf470e70e2 | |||
| 2b1c2942a8 | |||
| 10ffbca34b | |||
| d42f811a8d | |||
| 039d685547 | |||
| 2777d3ec49 | |||
| c9dc7299c5 | |||
| 0e6c680db8 | |||
| 8d3c73aed5 | |||
| 4c86f39a84 | |||
| 35547c4246 | |||
| a839808991 | |||
| 80e004c31f | |||
| ffd10e8b6f | |||
| 8cca31e6fe | |||
| bdc6082221 | |||
| a93124cafb | |||
| b2178b982d | |||
| 6408452e8f | |||
| 4aa9b8d5e7 | |||
| 94d0f84c0c | |||
| 45f4bc18f8 | |||
| aa442f694b | |||
| 31ed1fcdb8 | |||
| 7a70fd1312 | |||
| b1887fef1b | |||
| 006208f9a9 | |||
| a863f98c53 | |||
| af48f346f1 | |||
| 55cc127b03 | |||
| 4b56c15a3f | |||
| 44574c434d | |||
| 59c349de00 | |||
| bdc60e7850 | |||
| a1e33a82f0 | |||
| 9a794b1f8f | |||
| 149eea2377 | |||
| db8b3d29da | |||
| 6b7fe4f81a | |||
| e240b95026 | |||
| 475568d9e7 | |||
| fd3348d564 | |||
| 49f31ddcd8 | |||
| 4fa234b6e9 | |||
| 04b646eac0 | |||
| 5e65471560 | |||
| a98a84859e | |||
| 39bc76cbc4 | |||
| 03d54f97ab | |||
| 568b873fff | |||
| 09827064fe | |||
| c26e7820e1 | |||
| 0a6ba21e57 | |||
| 42db2748e6 | |||
| 3fefbb9e20 | |||
| 71ab8b9c93 | |||
| ca0285fc91 | |||
| 430854e223 | |||
| 43c4b8e518 | |||
| 2b66a7a8dc | |||
| c7c3c4a23c | |||
| 27f1ed18f7 | |||
| 66796b38bc | |||
| f5437dae4a | |||
| 70aced7f58 | |||
| 2535e1b53a | |||
| 7519f683ed | |||
| c5b67ea430 | |||
| 1827eaca2d | |||
| 217e3a13c8 | |||
| 2818f3cf95 | |||
| 3994b1c6f7 | |||
| 382dece228 | |||
| 1764de41f3 | |||
| 8efc543f49 | |||
| e7971b5840 | |||
| f3e6dacf0d | |||
| 42b7beb8a7 | |||
| 7cf4c9c89c | |||
| 5526c43853 | |||
| eb17cf6915 | |||
| 4645943066 | |||
| 612ca54afa | |||
| 4b6700a4b2 | |||
| 8468085291 | |||
| 91df1c56b2 | |||
| 0425621494 | |||
| 0360aa5520 | |||
| d3a5b9c127 | |||
| a10302d909 | |||
| 92022dd81f | |||
| 90d4bedae8 | |||
| 56f57928c2 | |||
| 2aa82d98cc | |||
| 8b3fb2a8b5 | |||
| f14467c65e | |||
| 544243fabb | |||
| 021c1f3900 | |||
| 357e7dd20f | |||
| 3aa28de5f1 | |||
| 535e59af8d | |||
| dd16c3d5c1 | |||
| 0129255478 | |||
| a3f737c0c6 | |||
| 372658be6d | |||
| d1bde9f348 | |||
| b797112322 | |||
| 3328e2ea97 | |||
| 0775ddbdf5 | |||
| 6e088df173 | |||
| 5e548fa7b8 | |||
| 32f121f019 | |||
| c8be0b20cc | |||
| b1568878e7 | |||
| 7a95df008e | |||
| f477f4f790 | |||
| f6a54c96bc | |||
| bb261fcc4a | |||
| 4044d80024 | |||
| 3a3764bf68 | |||
| c4486b4a77 | |||
| ac5bdac161 | |||
| ce1712fed1 | |||
| 15e591f30d | |||
| 84b7583982 | |||
| 4fcf0caa4d | |||
| 22ad0264c1 | |||
| f299e19002 | |||
| 147eb0d2d1 | |||
| 44da86895d | |||
| eb99785047 | |||
| d83acb2f03 | |||
| b6128efb43 | |||
| ad93341b74 | |||
| d4a26f8031 | |||
| 42e8d8ce49 | |||
| 8e08c218c5 | |||
| 3e7036151e | |||
| 913e0d391b | |||
| 805e0ec7d1 | |||
| b1bcca90b5 | |||
| 5d3f778b2a | |||
| ccbd98dfab | |||
| 21abe43f3b | |||
| dd08b457d8 | |||
| 4269df041f | |||
| 042c9fbef3 | |||
| 1972f33100 | |||
| 6aee001ab0 | |||
| 49eca68e28 | |||
| a76068d69c | |||
| cd20c578d2 | |||
| a078403c34 | |||
| 5ccdddff99 | |||
| 0be703ecb7 | |||
| 35ca4f565f | |||
| 4d349ac5c9 | |||
| a6ca6c1712 | |||
| ab6dffffd0 | |||
| 88614ec70a | |||
| f72490093a | |||
| de735b3c8c | |||
| 5b616dd34f | |||
| 942fd384de | |||
| bd9bc4d7eb | |||
| 6c4b2b0a8e | |||
| 256b9161e6 | |||
| 9516eb3f32 | |||
| 76f99d193f | |||
| 882d8f4ce1 | |||
| 130a771b60 | |||
| e6db4d017b | |||
| 987664f9b5 | |||
| b3c7ecaea1 | |||
| c79b975ad0 | |||
| 656a887db6 | |||
| e63c0aeb1b | |||
| 433168c450 | |||
| 0129f463b4 | |||
| e0054298a4 | |||
| 0e7c3d4eb8 | |||
| 70914d2764 | |||
| 156579ab3a | |||
| 81d03540bd | |||
| b3a52be401 | |||
| 91f22a8a8d | |||
| 70a6a0d9e8 | |||
| 82ff6c371b | |||
| 0c5bb6df80 | |||
| 8117bf8603 | |||
| a0e63b08c3 | |||
| 960683eced | |||
| 15a182c9d6 | |||
| 61303a216c | |||
| 1dfb479d36 | |||
| c517fa906f | |||
| 506dc0149c | |||
| d651cdb1e4 | |||
| 0c40319371 | |||
| c74083ace7 | |||
| a3ca39f377 | |||
| d11e6e40b1 | |||
| 71bbdf25c6 | |||
| f4a595db11 | |||
| c8be0ee1e0 | |||
| 6ed8c3cb70 | |||
| c434aff1a2 | |||
| 3886c82ed0 | |||
| fe59b7f39c | |||
| a4132322d9 | |||
| d477f73c7e | |||
| 614379a427 | |||
| b4d91b5cb4 | |||
| f31e2af349 | |||
| afcbf2cafb | |||
| c0b93791dc | |||
| f3a4d61f81 | |||
| 99c3194181 | |||
| 0e805e7dc4 | |||
| 226408ffe3 | |||
| 8f1953e667 | |||
| 1a928bc6a4 | |||
| 2e5fbba961 | |||
| 70b74b5217 | |||
| 996ade9090 | |||
| 2444327a47 | |||
| 6b3be4bb5b | |||
| b2b56d14cb | |||
| 3e9c92729d | |||
| 0c49553f79 | |||
| 66b8166397 | |||
| 87ba39df57 | |||
| 43964de675 | |||
| fab82b51de | |||
| 44716a4cb2 | |||
| d9fb971196 | |||
| 51d875a14e | |||
| 45cc0e6209 | |||
| c561eb7bbd | |||
| 00a089b596 | |||
| 541fa37c5c | |||
| 06301aa90d | |||
| 55fbc60ad3 | |||
| b69941caf4 | |||
| 80600dbe63 | |||
| 085f48366b | |||
| 60fc24cd39 | |||
| 7fb87ddf99 | |||
| b41c1128c6 | |||
| 5bb308a098 | |||
| 236410cd95 | |||
| f1800e2853 | |||
| 4bc9904b3c | |||
| 995f80002f | |||
| ffdb45cc4b | |||
| f5ee5deec8 | |||
| 02777ffc54 | |||
| a5627d4226 | |||
| 435a174316 | |||
| d905202afb | |||
| 221cc19ac6 | |||
| 3d69eaf34f | |||
| e7c501677c | |||
| 96b1c2024b | |||
| f7ecfafe1a | |||
| 6ecee8b920 | |||
| cce8f37ada | |||
| 60247fe18d | |||
| de4e086bd5 | |||
| 3da7ff1721 | |||
| c8cdc6377b | |||
| 85038cef69 | |||
| 016b91adda | |||
| 0846a08795 | |||
| 277eef62c2 | |||
| 9f19933e64 | |||
| 904ddcec13 | |||
| 77da0a6cc1 | |||
| a754a4388a | |||
| 93f262c6a2 | |||
| 16a48ef4eb | |||
| 841f74894e | |||
| 9c337552e6 | |||
| 584e9e6da5 | |||
| a3dfa90668 | |||
| 00e8849445 | |||
| 2840ff405b | |||
| 9d39404e6c | |||
| 371201d820 | |||
| f93c2e4a8d | |||
| 4b0fa112bb | |||
| fd0170c179 | |||
| 9b56b64cfa | |||
| 887627fa34 | |||
| 46bcf98ef2 | |||
| 947f5600d6 | |||
| 79ce6e0a3f | |||
| 46e57706c1 | |||
| ba2964cb01 | |||
| a0aee4ff28 | |||
| 45289b0fa8 | |||
| eece28ccc6 | |||
| c216d89520 | |||
| d94134ad2d | |||
| 952822ad5d | |||
| 79d7e67e51 | |||
| 6de1923151 | |||
| abdbd6a67e | |||
| a14dee87e8 | |||
| 00e6ffe83c | |||
| 36d927e1ba | |||
| 091e9e2f40 | |||
| 54daf3b765 | |||
| 76decdba2a | |||
| e6e8978f78 | |||
| fcaeb2f539 | |||
| f7d1833f41 | |||
| 95598e5435 | |||
| 8a7e8b57a8 | |||
| 5a720a4a31 | |||
| d55884b50e | |||
| c8fb11db59 | |||
| 7b3a86475d | |||
| 59ed53ec1e | |||
| 7f8b6ebbd5 | |||
| 3f0dd80898 | |||
| 2299f48430 | |||
| 99386bf680 | |||
| 4e4e2792b1 | |||
| d99e3d49e1 | |||
| e42cbf07f5 | |||
| 016862a6b8 | |||
| cd9dae0f3a | |||
| b7baf4ddef | |||
| 1584015d3b | |||
| e4b4a78e52 | |||
| 50534a0dcf | |||
| c53ace3c98 | |||
| 7fa60dd63e | |||
| 6ff6d57507 | |||
| 4f93ecf519 | |||
| 1e974439d9 | |||
| 611955bc91 | |||
| e3937ada38 | |||
| c5b8466c0e | |||
| e25b082162 | |||
| 247d7896fc | |||
| a0ad681c77 | |||
| 1b7ee5dd2b | |||
| 8b6d03e430 | |||
| efc4ad9f65 | |||
| 3cfb6507c5 | |||
| 24a314876b | |||
| 190911206b | |||
| ea11521961 | |||
| 909c94e983 | |||
| 1c7ff7b1dc | |||
| c7cec2131c | |||
| 6f3e7ed0e9 | |||
| d4ccd25760 | |||
| dd933e406f | |||
| 3c74ce3653 | |||
| de2bda3e2e | |||
| 4b3e1bb747 | |||
| bb30a83520 | |||
| 2bdf99b398 | |||
| 96a06effe2 | |||
| df4a8e167d | |||
| c16896e96d | |||
| da7fa09053 | |||
| 23bf71022e | |||
| 798886105e | |||
| 645313e321 | |||
| 0915352cb0 | |||
| 70a108e54f | |||
| a8289745da | |||
| d3ba77837a | |||
| 3161a5d1f7 | |||
| 3a35f9ae7c | |||
| 12cf2b0104 | |||
| 6f1065b56a | |||
| 332188535e |
@@ -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
|
||||
@@ -52,6 +52,8 @@ jobs:
|
||||
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v6
|
||||
env:
|
||||
LIBGL_ALWAYS_SOFTWARE: 1
|
||||
with:
|
||||
browser: chrome
|
||||
wait-on: 'http://localhost:3000'
|
||||
+102
@@ -5,6 +5,108 @@ 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.5] - 2025-01-22
|
||||
|
||||
### Added
|
||||
|
||||
- **🤔 Native 'Think' Tag Support**: Introduced the new 'think' tag support that visually displays how long the model is thinking, omitting the reasoning content itself until the next turn. Ideal for creating a more streamlined and focused interaction experience.
|
||||
- **🖼️ Toggle Image Generation On/Off**: In the chat input menu, you can now easily toggle image generation before initiating chats, providing greater control and flexibility to suit your needs.
|
||||
- **🔒 Chat Controls Permissions**: Admins can now disable chat controls access for users, offering tighter management and customization over user interactions.
|
||||
- **🔍 Web Search & Image Generation Permissions**: Easily disable web search and image generation for specific users, improving workflow governance and security for certain environments.
|
||||
- **🗂️ S3 and GCS Storage Provider Support**: Scaled deployments now benefit from expanded storage options with Amazon S3 and Google Cloud Storage seamlessly integrated as providers.
|
||||
- **🎨 Enhanced Model Management**: Reintroduced the ability to download and delete models directly in the admin models settings page to minimize user confusion and aid efficient model management.
|
||||
- **🔗 Improved Connection Handling**: Enhanced backend to smoothly handle multiple identical base URLs, allowing more flexible multi-instance configurations with fewer hiccups.
|
||||
- **✨ General UI/UX Refinements**: Numerous tweaks across the WebUI make navigation and usability even more user-friendly and intuitive.
|
||||
- **🌍 Translation Enhancements**: Various translation updates ensure smoother and more polished interactions for international users.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **⚡ MPS Functionality for Mac Users**: Fixed MPS support, ensuring smooth performance and compatibility for Mac users leveraging MPS.
|
||||
- **📡 Ollama Connection Management**: Resolved the issue where deleting all Ollama connections prevented adding new ones.
|
||||
|
||||
### Changed
|
||||
|
||||
- **⚙️ General Stability Refac**: Backend refactoring delivers a more stable, robust platform.
|
||||
- **🖥️ Desktop App Preparations**: Ongoing work to support the upcoming Open WebUI desktop app. Follow our progress and updates here: https://github.com/open-webui/desktop
|
||||
|
||||
## [0.5.4] - 2025-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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -11,7 +11,9 @@
|
||||
[](https://discord.gg/5rJgQTnV4s)
|
||||
[](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/).
|
||||
|
||||

|
||||
|
||||
@@ -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 💬
|
||||
|
||||
|
||||
@@ -5,12 +5,31 @@ from pathlib import Path
|
||||
|
||||
import typer
|
||||
import uvicorn
|
||||
from typing import Optional
|
||||
from typing_extensions import Annotated
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
KEY_FILE = Path.cwd() / ".webui_secret_key"
|
||||
|
||||
|
||||
def version_callback(value: bool):
|
||||
if value:
|
||||
from open_webui.env import VERSION
|
||||
|
||||
typer.echo(f"Open WebUI version: {VERSION}")
|
||||
raise typer.Exit()
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(
|
||||
version: Annotated[
|
||||
Optional[bool], typer.Option("--version", callback=version_callback)
|
||||
] = None,
|
||||
):
|
||||
pass
|
||||
|
||||
|
||||
@app.command()
|
||||
def serve(
|
||||
host: str = "0.0.0.0",
|
||||
|
||||
+199
-45
@@ -9,22 +9,22 @@ from urllib.parse import urlparse
|
||||
|
||||
import chromadb
|
||||
import requests
|
||||
import yaml
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import JSON, Column, DateTime, Integer, func
|
||||
|
||||
from open_webui.env import (
|
||||
OPEN_WEBUI_DIR,
|
||||
DATA_DIR,
|
||||
DATABASE_URL,
|
||||
ENV,
|
||||
FRONTEND_BUILD_DIR,
|
||||
OFFLINE_MODE,
|
||||
OPEN_WEBUI_DIR,
|
||||
WEBUI_AUTH,
|
||||
WEBUI_FAVICON_URL,
|
||||
WEBUI_NAME,
|
||||
log,
|
||||
DATABASE_URL,
|
||||
OFFLINE_MODE,
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import JSON, Column, DateTime, Integer, func
|
||||
from open_webui.internal.db import Base, get_db
|
||||
|
||||
|
||||
class EndpointFilter(logging.Filter):
|
||||
@@ -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,17 +319,6 @@ GOOGLE_CLIENT_SECRET = PersistentConfig(
|
||||
os.environ.get("GOOGLE_CLIENT_SECRET", ""),
|
||||
)
|
||||
|
||||
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", ""),
|
||||
)
|
||||
|
||||
GOOGLE_OAUTH_SCOPE = PersistentConfig(
|
||||
"GOOGLE_OAUTH_SCOPE",
|
||||
@@ -361,6 +362,30 @@ MICROSOFT_REDIRECT_URI = PersistentConfig(
|
||||
os.environ.get("MICROSOFT_REDIRECT_URI", ""),
|
||||
)
|
||||
|
||||
GITHUB_CLIENT_ID = PersistentConfig(
|
||||
"GITHUB_CLIENT_ID",
|
||||
"oauth.github.client_id",
|
||||
os.environ.get("GITHUB_CLIENT_ID", ""),
|
||||
)
|
||||
|
||||
GITHUB_CLIENT_SECRET = PersistentConfig(
|
||||
"GITHUB_CLIENT_SECRET",
|
||||
"oauth.github.client_secret",
|
||||
os.environ.get("GITHUB_CLIENT_SECRET", ""),
|
||||
)
|
||||
|
||||
GITHUB_CLIENT_SCOPE = PersistentConfig(
|
||||
"GITHUB_CLIENT_SCOPE",
|
||||
"oauth.github.scope",
|
||||
os.environ.get("GITHUB_CLIENT_SCOPE", "user:email"),
|
||||
)
|
||||
|
||||
GITHUB_CLIENT_REDIRECT_URI = PersistentConfig(
|
||||
"GITHUB_CLIENT_REDIRECT_URI",
|
||||
"oauth.github.redirect_uri",
|
||||
os.environ.get("GITHUB_CLIENT_REDIRECT_URI", ""),
|
||||
)
|
||||
|
||||
OAUTH_CLIENT_ID = PersistentConfig(
|
||||
"OAUTH_CLIENT_ID",
|
||||
"oauth.oidc.client_id",
|
||||
@@ -467,12 +492,20 @@ OAUTH_ALLOWED_DOMAINS = PersistentConfig(
|
||||
def load_oauth_providers():
|
||||
OAUTH_PROVIDERS.clear()
|
||||
if GOOGLE_CLIENT_ID.value and GOOGLE_CLIENT_SECRET.value:
|
||||
|
||||
def google_oauth_register(client):
|
||||
client.register(
|
||||
name="google",
|
||||
client_id=GOOGLE_CLIENT_ID.value,
|
||||
client_secret=GOOGLE_CLIENT_SECRET.value,
|
||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": GOOGLE_OAUTH_SCOPE.value},
|
||||
redirect_uri=GOOGLE_REDIRECT_URI.value,
|
||||
)
|
||||
|
||||
OAUTH_PROVIDERS["google"] = {
|
||||
"client_id": GOOGLE_CLIENT_ID.value,
|
||||
"client_secret": GOOGLE_CLIENT_SECRET.value,
|
||||
"server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
|
||||
"scope": GOOGLE_OAUTH_SCOPE.value,
|
||||
"redirect_uri": GOOGLE_REDIRECT_URI.value,
|
||||
"register": google_oauth_register,
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -480,12 +513,44 @@ def load_oauth_providers():
|
||||
and MICROSOFT_CLIENT_SECRET.value
|
||||
and MICROSOFT_CLIENT_TENANT_ID.value
|
||||
):
|
||||
|
||||
def microsoft_oauth_register(client):
|
||||
client.register(
|
||||
name="microsoft",
|
||||
client_id=MICROSOFT_CLIENT_ID.value,
|
||||
client_secret=MICROSOFT_CLIENT_SECRET.value,
|
||||
server_metadata_url=f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
|
||||
client_kwargs={
|
||||
"scope": MICROSOFT_OAUTH_SCOPE.value,
|
||||
},
|
||||
redirect_uri=MICROSOFT_REDIRECT_URI.value,
|
||||
)
|
||||
|
||||
OAUTH_PROVIDERS["microsoft"] = {
|
||||
"client_id": MICROSOFT_CLIENT_ID.value,
|
||||
"client_secret": MICROSOFT_CLIENT_SECRET.value,
|
||||
"server_metadata_url": f"https://login.microsoftonline.com/{MICROSOFT_CLIENT_TENANT_ID.value}/v2.0/.well-known/openid-configuration",
|
||||
"scope": MICROSOFT_OAUTH_SCOPE.value,
|
||||
"redirect_uri": MICROSOFT_REDIRECT_URI.value,
|
||||
"picture_url": "https://graph.microsoft.com/v1.0/me/photo/$value",
|
||||
"register": microsoft_oauth_register,
|
||||
}
|
||||
|
||||
if GITHUB_CLIENT_ID.value and GITHUB_CLIENT_SECRET.value:
|
||||
|
||||
def github_oauth_register(client):
|
||||
client.register(
|
||||
name="github",
|
||||
client_id=GITHUB_CLIENT_ID.value,
|
||||
client_secret=GITHUB_CLIENT_SECRET.value,
|
||||
access_token_url="https://github.com/login/oauth/access_token",
|
||||
authorize_url="https://github.com/login/oauth/authorize",
|
||||
api_base_url="https://api.github.com",
|
||||
userinfo_endpoint="https://api.github.com/user",
|
||||
client_kwargs={"scope": GITHUB_CLIENT_SCOPE.value},
|
||||
redirect_uri=GITHUB_CLIENT_REDIRECT_URI.value,
|
||||
)
|
||||
|
||||
OAUTH_PROVIDERS["github"] = {
|
||||
"redirect_uri": GITHUB_CLIENT_REDIRECT_URI.value,
|
||||
"register": github_oauth_register,
|
||||
"sub_claim": "id",
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -493,13 +558,23 @@ def load_oauth_providers():
|
||||
and OAUTH_CLIENT_SECRET.value
|
||||
and OPENID_PROVIDER_URL.value
|
||||
):
|
||||
|
||||
def oidc_oauth_register(client):
|
||||
client.register(
|
||||
name="oidc",
|
||||
client_id=OAUTH_CLIENT_ID.value,
|
||||
client_secret=OAUTH_CLIENT_SECRET.value,
|
||||
server_metadata_url=OPENID_PROVIDER_URL.value,
|
||||
client_kwargs={
|
||||
"scope": OAUTH_SCOPES.value,
|
||||
},
|
||||
redirect_uri=OPENID_REDIRECT_URI.value,
|
||||
)
|
||||
|
||||
OAUTH_PROVIDERS["oidc"] = {
|
||||
"client_id": OAUTH_CLIENT_ID.value,
|
||||
"client_secret": OAUTH_CLIENT_SECRET.value,
|
||||
"server_metadata_url": OPENID_PROVIDER_URL.value,
|
||||
"scope": OAUTH_SCOPES.value,
|
||||
"name": OAUTH_PROVIDER_NAME.value,
|
||||
"redirect_uri": OPENID_REDIRECT_URI.value,
|
||||
"register": oidc_oauth_register,
|
||||
}
|
||||
|
||||
|
||||
@@ -579,7 +654,7 @@ if CUSTOM_NAME:
|
||||
# STORAGE PROVIDER
|
||||
####################################
|
||||
|
||||
STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "") # defaults to local, s3
|
||||
STORAGE_PROVIDER = os.environ.get("STORAGE_PROVIDER", "local") # defaults to local, s3
|
||||
|
||||
S3_ACCESS_KEY_ID = os.environ.get("S3_ACCESS_KEY_ID", None)
|
||||
S3_SECRET_ACCESS_KEY = os.environ.get("S3_SECRET_ACCESS_KEY", None)
|
||||
@@ -587,6 +662,11 @@ S3_REGION_NAME = os.environ.get("S3_REGION_NAME", None)
|
||||
S3_BUCKET_NAME = os.environ.get("S3_BUCKET_NAME", None)
|
||||
S3_ENDPOINT_URL = os.environ.get("S3_ENDPOINT_URL", None)
|
||||
|
||||
GCS_BUCKET_NAME = os.environ.get("GCS_BUCKET_NAME", None)
|
||||
GOOGLE_APPLICATION_CREDENTIALS_JSON = os.environ.get(
|
||||
"GOOGLE_APPLICATION_CREDENTIALS_JSON", None
|
||||
)
|
||||
|
||||
####################################
|
||||
# File Upload DIR
|
||||
####################################
|
||||
@@ -818,6 +898,10 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS = (
|
||||
os.environ.get("USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS", "False").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_CONTROLS = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_CONTROLS", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_CHAT_FILE_UPLOAD = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_FILE_UPLOAD", "True").lower() == "true"
|
||||
)
|
||||
@@ -834,23 +918,39 @@ USER_PERMISSIONS_CHAT_TEMPORARY = (
|
||||
os.environ.get("USER_PERMISSIONS_CHAT_TEMPORARY", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_FEATURES_WEB_SEARCH = (
|
||||
os.environ.get("USER_PERMISSIONS_FEATURES_WEB_SEARCH", "True").lower() == "true"
|
||||
)
|
||||
|
||||
USER_PERMISSIONS_FEATURES_IMAGE_GENERATION = (
|
||||
os.environ.get("USER_PERMISSIONS_FEATURES_IMAGE_GENERATION", "True").lower()
|
||||
== "true"
|
||||
)
|
||||
|
||||
DEFAULT_USER_PERMISSIONS = {
|
||||
"workspace": {
|
||||
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
|
||||
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
|
||||
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
|
||||
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
|
||||
},
|
||||
"chat": {
|
||||
"controls": USER_PERMISSIONS_CHAT_CONTROLS,
|
||||
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
|
||||
"delete": USER_PERMISSIONS_CHAT_DELETE,
|
||||
"edit": USER_PERMISSIONS_CHAT_EDIT,
|
||||
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
|
||||
},
|
||||
"features": {
|
||||
"web_search": USER_PERMISSIONS_FEATURES_WEB_SEARCH,
|
||||
"image_generation": USER_PERMISSIONS_FEATURES_IMAGE_GENERATION,
|
||||
},
|
||||
}
|
||||
|
||||
USER_PERMISSIONS = PersistentConfig(
|
||||
"USER_PERMISSIONS",
|
||||
"user.permissions",
|
||||
{
|
||||
"workspace": {
|
||||
"models": USER_PERMISSIONS_WORKSPACE_MODELS_ACCESS,
|
||||
"knowledge": USER_PERMISSIONS_WORKSPACE_KNOWLEDGE_ACCESS,
|
||||
"prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ACCESS,
|
||||
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ACCESS,
|
||||
},
|
||||
"chat": {
|
||||
"file_upload": USER_PERMISSIONS_CHAT_FILE_UPLOAD,
|
||||
"delete": USER_PERMISSIONS_CHAT_DELETE,
|
||||
"edit": USER_PERMISSIONS_CHAT_EDIT,
|
||||
"temporary": USER_PERMISSIONS_CHAT_TEMPORARY,
|
||||
},
|
||||
},
|
||||
DEFAULT_USER_PERMISSIONS,
|
||||
)
|
||||
|
||||
ENABLE_CHANNELS = PersistentConfig(
|
||||
@@ -1033,6 +1133,32 @@ JSON format: { "tags": ["tag1", "tag2", "tag3"] }
|
||||
{{MESSAGES:END:6}}
|
||||
</chat_history>"""
|
||||
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = PersistentConfig(
|
||||
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE",
|
||||
"task.image.prompt_template",
|
||||
os.environ.get("IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE", ""),
|
||||
)
|
||||
|
||||
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = """### Task:
|
||||
Generate a detailed prompt for am image generation task based on the given language and context. Describe the image as if you were explaining it to someone who cannot see it. Include relevant details, colors, shapes, and any other important elements.
|
||||
|
||||
### Guidelines:
|
||||
- Be descriptive and detailed, focusing on the most important aspects of the image.
|
||||
- Avoid making assumptions or adding information not present in the image.
|
||||
- Use the chat's primary language; default to English if multilingual.
|
||||
- If the image is too complex, focus on the most prominent elements.
|
||||
|
||||
### Output:
|
||||
Strictly return in JSON format:
|
||||
{
|
||||
"prompt": "Your detailed description here."
|
||||
}
|
||||
|
||||
### Chat History:
|
||||
<chat_history>
|
||||
{{MESSAGES:END:6}}
|
||||
</chat_history>"""
|
||||
|
||||
ENABLE_TAGS_GENERATION = PersistentConfig(
|
||||
"ENABLE_TAGS_GENERATION",
|
||||
"task.tags.enable",
|
||||
@@ -1192,6 +1318,7 @@ CHROMA_HTTP_SSL = os.environ.get("CHROMA_HTTP_SSL", "false").lower() == "true"
|
||||
# Milvus
|
||||
|
||||
MILVUS_URI = os.environ.get("MILVUS_URI", f"{DATA_DIR}/vector_db/milvus.db")
|
||||
MILVUS_DB = os.environ.get("MILVUS_DB", "default")
|
||||
|
||||
# Qdrant
|
||||
QDRANT_URI = os.environ.get("QDRANT_URI", None)
|
||||
@@ -1210,6 +1337,9 @@ 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)
|
||||
@@ -1223,6 +1353,17 @@ ENABLE_GOOGLE_DRIVE_INTEGRATION = PersistentConfig(
|
||||
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(
|
||||
@@ -1587,6 +1728,13 @@ ENABLE_IMAGE_GENERATION = PersistentConfig(
|
||||
"image_generation.enable",
|
||||
os.environ.get("ENABLE_IMAGE_GENERATION", "").lower() == "true",
|
||||
)
|
||||
|
||||
ENABLE_IMAGE_PROMPT_GENERATION = PersistentConfig(
|
||||
"ENABLE_IMAGE_PROMPT_GENERATION",
|
||||
"image_generation.prompt.enable",
|
||||
os.environ.get("ENABLE_IMAGE_PROMPT_GENERATION", "true").lower() == "true",
|
||||
)
|
||||
|
||||
AUTOMATIC1111_BASE_URL = PersistentConfig(
|
||||
"AUTOMATIC1111_BASE_URL",
|
||||
"image_generation.automatic1111.base_url",
|
||||
@@ -1916,6 +2064,12 @@ LDAP_SERVER_PORT = PersistentConfig(
|
||||
int(os.environ.get("LDAP_SERVER_PORT", "389")),
|
||||
)
|
||||
|
||||
LDAP_ATTRIBUTE_FOR_MAIL = PersistentConfig(
|
||||
"LDAP_ATTRIBUTE_FOR_MAIL",
|
||||
"ldap.server.attribute_for_mail",
|
||||
os.environ.get("LDAP_ATTRIBUTE_FOR_MAIL", "mail"),
|
||||
)
|
||||
|
||||
LDAP_ATTRIBUTE_FOR_USERNAME = PersistentConfig(
|
||||
"LDAP_ATTRIBUTE_FOR_USERNAME",
|
||||
"ldap.server.attribute_for_username",
|
||||
|
||||
@@ -113,6 +113,7 @@ class TASKS(str, Enum):
|
||||
TAGS_GENERATION = "tags_generation"
|
||||
EMOJI_GENERATION = "emoji_generation"
|
||||
QUERY_GENERATION = "query_generation"
|
||||
IMAGE_PROMPT_GENERATION = "image_prompt_generation"
|
||||
AUTOCOMPLETE_GENERATION = "autocomplete_generation"
|
||||
FUNCTION_CALLING = "function_calling"
|
||||
MOA_RESPONSE_GENERATION = "moa_response_generation"
|
||||
|
||||
@@ -53,6 +53,13 @@ if USE_CUDA.lower() == "true":
|
||||
else:
|
||||
DEVICE_TYPE = "cpu"
|
||||
|
||||
try:
|
||||
import torch
|
||||
|
||||
if torch.backends.mps.is_available() and torch.backends.mps.is_built():
|
||||
DEVICE_TYPE = "mps"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
####################################
|
||||
# LOGGING
|
||||
@@ -267,6 +274,8 @@ DATABASE_URL = os.environ.get("DATABASE_URL", f"sqlite:///{DATA_DIR}/webui.db")
|
||||
if "postgres://" in DATABASE_URL:
|
||||
DATABASE_URL = DATABASE_URL.replace("postgres://", "postgresql://")
|
||||
|
||||
DATABASE_SCHEMA = os.environ.get("DATABASE_SCHEMA", None)
|
||||
|
||||
DATABASE_POOL_SIZE = os.environ.get("DATABASE_POOL_SIZE", 0)
|
||||
|
||||
if DATABASE_POOL_SIZE == "":
|
||||
@@ -311,6 +320,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
|
||||
####################################
|
||||
@@ -392,3 +406,6 @@ else:
|
||||
####################################
|
||||
|
||||
OFFLINE_MODE = os.environ.get("OFFLINE_MODE", "false").lower() == "true"
|
||||
|
||||
if OFFLINE_MODE:
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
|
||||
@@ -7,6 +7,7 @@ from open_webui.internal.wrappers import register_connection
|
||||
from open_webui.env import (
|
||||
OPEN_WEBUI_DIR,
|
||||
DATABASE_URL,
|
||||
DATABASE_SCHEMA,
|
||||
SRC_LOG_LEVELS,
|
||||
DATABASE_POOL_MAX_OVERFLOW,
|
||||
DATABASE_POOL_RECYCLE,
|
||||
@@ -14,7 +15,7 @@ from open_webui.env import (
|
||||
DATABASE_POOL_TIMEOUT,
|
||||
)
|
||||
from peewee_migrate import Router
|
||||
from sqlalchemy import Dialect, create_engine, types
|
||||
from sqlalchemy import Dialect, create_engine, MetaData, types
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import scoped_session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool, NullPool
|
||||
@@ -99,7 +100,8 @@ else:
|
||||
SessionLocal = sessionmaker(
|
||||
autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
|
||||
)
|
||||
Base = declarative_base()
|
||||
metadata_obj = MetaData(schema=DATABASE_SCHEMA)
|
||||
Base = declarative_base(metadata=metadata_obj)
|
||||
Session = scoped_session(SessionLocal)
|
||||
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ from open_webui.config import (
|
||||
COMFYUI_WORKFLOW,
|
||||
COMFYUI_WORKFLOW_NODES,
|
||||
ENABLE_IMAGE_GENERATION,
|
||||
ENABLE_IMAGE_PROMPT_GENERATION,
|
||||
IMAGE_GENERATION_ENGINE,
|
||||
IMAGE_GENERATION_MODEL,
|
||||
IMAGE_SIZE,
|
||||
@@ -199,6 +200,8 @@ 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,
|
||||
@@ -223,6 +226,7 @@ from open_webui.config import (
|
||||
LDAP_SERVER_LABEL,
|
||||
LDAP_SERVER_HOST,
|
||||
LDAP_SERVER_PORT,
|
||||
LDAP_ATTRIBUTE_FOR_MAIL,
|
||||
LDAP_ATTRIBUTE_FOR_USERNAME,
|
||||
LDAP_SEARCH_FILTERS,
|
||||
LDAP_SEARCH_BASE,
|
||||
@@ -252,6 +256,7 @@ from open_webui.config import (
|
||||
ENABLE_AUTOCOMPLETE_GENERATION,
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE,
|
||||
QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
|
||||
@@ -392,7 +397,12 @@ 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
|
||||
|
||||
@@ -430,6 +440,7 @@ app.state.config.ENABLE_LDAP = ENABLE_LDAP
|
||||
app.state.config.LDAP_SERVER_LABEL = LDAP_SERVER_LABEL
|
||||
app.state.config.LDAP_SERVER_HOST = LDAP_SERVER_HOST
|
||||
app.state.config.LDAP_SERVER_PORT = LDAP_SERVER_PORT
|
||||
app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = LDAP_ATTRIBUTE_FOR_MAIL
|
||||
app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = LDAP_ATTRIBUTE_FOR_USERNAME
|
||||
app.state.config.LDAP_APP_DN = LDAP_APP_DN
|
||||
app.state.config.LDAP_APP_PASSWORD = LDAP_APP_PASSWORD
|
||||
@@ -565,6 +576,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
|
||||
|
||||
app.state.config.IMAGE_GENERATION_ENGINE = IMAGE_GENERATION_ENGINE
|
||||
app.state.config.ENABLE_IMAGE_GENERATION = ENABLE_IMAGE_GENERATION
|
||||
app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = ENABLE_IMAGE_PROMPT_GENERATION
|
||||
|
||||
app.state.config.IMAGES_OPENAI_API_BASE_URL = IMAGES_OPENAI_API_BASE_URL
|
||||
app.state.config.IMAGES_OPENAI_API_KEY = IMAGES_OPENAI_API_KEY
|
||||
@@ -635,6 +647,10 @@ app.state.config.ENABLE_TAGS_GENERATION = ENABLE_TAGS_GENERATION
|
||||
|
||||
app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE = TITLE_GENERATION_PROMPT_TEMPLATE
|
||||
app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE = TAGS_GENERATION_PROMPT_TEMPLATE
|
||||
app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE = (
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
)
|
||||
|
||||
app.state.config.TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE = (
|
||||
TOOLS_FUNCTION_CALLING_PROMPT_TEMPLATE
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
@@ -21,6 +21,7 @@ class Channel(Base):
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
user_id = Column(Text)
|
||||
type = Column(Text, nullable=True)
|
||||
|
||||
name = Column(Text)
|
||||
description = Column(Text, nullable=True)
|
||||
@@ -38,9 +39,11 @@ class ChannelModel(BaseModel):
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
description: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
@@ -64,12 +67,13 @@ class ChannelForm(BaseModel):
|
||||
|
||||
class ChannelTable:
|
||||
def insert_new_channel(
|
||||
self, form_data: ChannelForm, user_id: str
|
||||
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,
|
||||
|
||||
@@ -212,6 +212,15 @@ class ChatTable:
|
||||
|
||||
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]:
|
||||
@@ -384,7 +393,7 @@ class ChatTable:
|
||||
limit: int = 50,
|
||||
) -> list[ChatModel]:
|
||||
with get_db() as db:
|
||||
query = db.query(Chat).filter_by(user_id=user_id).filter_by(folder_id=None)
|
||||
query = db.query(Chat).filter_by(user_id=user_id)
|
||||
if not include_archived:
|
||||
query = query.filter_by(archived=False)
|
||||
|
||||
@@ -460,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:
|
||||
|
||||
@@ -80,12 +80,11 @@ class GroupResponse(BaseModel):
|
||||
class GroupForm(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
permissions: Optional[dict] = None
|
||||
|
||||
|
||||
class GroupUpdateForm(GroupForm):
|
||||
permissions: Optional[dict] = None
|
||||
user_ids: Optional[list[str]] = None
|
||||
admin_ids: Optional[list[str]] = None
|
||||
|
||||
|
||||
class GroupTable:
|
||||
@@ -95,7 +94,7 @@ class GroupTable:
|
||||
with get_db() as db:
|
||||
group = GroupModel(
|
||||
**{
|
||||
**form_data.model_dump(),
|
||||
**form_data.model_dump(exclude_none=True),
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
"created_at": int(time.time()),
|
||||
@@ -189,5 +188,24 @@ class GroupTable:
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def remove_user_from_all_groups(self, user_id: str) -> bool:
|
||||
with get_db() as db:
|
||||
try:
|
||||
groups = self.get_groups_by_member_id(user_id)
|
||||
|
||||
for group in groups:
|
||||
group.user_ids.remove(user_id)
|
||||
db.query(Group).filter_by(id=group.id).update(
|
||||
{
|
||||
"user_ids": group.user_ids,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
Groups = GroupTable()
|
||||
|
||||
@@ -17,6 +17,25 @@ from sqlalchemy.sql import exists
|
||||
####################
|
||||
|
||||
|
||||
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)
|
||||
@@ -24,6 +43,8 @@ class Message(Base):
|
||||
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)
|
||||
@@ -39,6 +60,8 @@ class MessageModel(BaseModel):
|
||||
user_id: str
|
||||
channel_id: Optional[str] = None
|
||||
|
||||
parent_id: Optional[str] = None
|
||||
|
||||
content: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
@@ -54,10 +77,23 @@ class MessageModel(BaseModel):
|
||||
|
||||
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
|
||||
@@ -71,6 +107,7 @@ class MessageTable:
|
||||
"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,
|
||||
@@ -85,10 +122,40 @@ class MessageTable:
|
||||
db.refresh(result)
|
||||
return MessageModel.model_validate(result) if result else None
|
||||
|
||||
def get_message_by_id(self, id: str) -> Optional[MessageModel]:
|
||||
def get_message_by_id(self, id: str) -> Optional[MessageResponse]:
|
||||
with get_db() as db:
|
||||
message = db.get(Message, id)
|
||||
return MessageModel.model_validate(message) if message else None
|
||||
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
|
||||
@@ -96,7 +163,7 @@ class MessageTable:
|
||||
with get_db() as db:
|
||||
all_messages = (
|
||||
db.query(Message)
|
||||
.filter_by(channel_id=channel_id)
|
||||
.filter_by(channel_id=channel_id, parent_id=None)
|
||||
.order_by(Message.created_at.desc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
@@ -104,18 +171,28 @@ class MessageTable:
|
||||
)
|
||||
return [MessageModel.model_validate(message) for message in all_messages]
|
||||
|
||||
def get_messages_by_user_id(
|
||||
self, user_id: str, skip: int = 0, limit: int = 50
|
||||
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(user_id=user_id)
|
||||
.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(
|
||||
@@ -131,9 +208,70 @@ class MessageTable:
|
||||
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
|
||||
|
||||
|
||||
@@ -2,7 +2,12 @@ import time
|
||||
from typing import Optional
|
||||
|
||||
from open_webui.internal.db import Base, JSONField, get_db
|
||||
|
||||
|
||||
from open_webui.models.chats import Chats
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Column, String, Text
|
||||
|
||||
@@ -268,9 +273,11 @@ class UsersTable:
|
||||
|
||||
def delete_user_by_id(self, id: str) -> bool:
|
||||
try:
|
||||
# Remove User from Groups
|
||||
Groups.remove_user_from_all_groups(id)
|
||||
|
||||
# Delete User Chats
|
||||
result = Chats.delete_chats_by_user_id(id)
|
||||
|
||||
if result:
|
||||
with get_db() as db:
|
||||
# Delete User
|
||||
@@ -300,5 +307,10 @@ class UsersTable:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_valid_user_ids(self, user_ids: list[str]) -> list[str]:
|
||||
with get_db() as db:
|
||||
users = db.query(User).filter(User.id.in_(user_ids)).all()
|
||||
return [user.id for user in users]
|
||||
|
||||
|
||||
Users = UsersTable()
|
||||
|
||||
@@ -11,10 +11,12 @@ from langchain.retrievers import ContextualCompressionRetriever, EnsembleRetriev
|
||||
from langchain_community.retrievers import BM25Retriever
|
||||
from langchain_core.documents import Document
|
||||
|
||||
|
||||
from open_webui.config import VECTOR_DB
|
||||
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"])
|
||||
@@ -199,7 +201,12 @@ def query_collection(
|
||||
else:
|
||||
pass
|
||||
|
||||
return merge_and_sort_query_results(results, k=k)
|
||||
if VECTOR_DB == "chroma":
|
||||
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
|
||||
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
|
||||
return merge_and_sort_query_results(results, k=k, reverse=False)
|
||||
else:
|
||||
return merge_and_sort_query_results(results, k=k, reverse=True)
|
||||
|
||||
|
||||
def query_collection_with_hybrid_search(
|
||||
@@ -235,7 +242,12 @@ def query_collection_with_hybrid_search(
|
||||
"Hybrid search failed for all collections. Using Non hybrid search as fallback."
|
||||
)
|
||||
|
||||
return merge_and_sort_query_results(results, k=k, reverse=True)
|
||||
if VECTOR_DB == "chroma":
|
||||
# Chroma uses unconventional cosine similarity, so we don't need to reverse the results
|
||||
# https://docs.trychroma.com/docs/collections/configure#configuring-chroma-collections
|
||||
return merge_and_sort_query_results(results, k=k, reverse=False)
|
||||
else:
|
||||
return merge_and_sort_query_results(results, k=k, reverse=True)
|
||||
|
||||
|
||||
def get_embedding_function(
|
||||
@@ -375,6 +387,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,
|
||||
|
||||
@@ -51,8 +51,8 @@ class ChromaClient:
|
||||
|
||||
def has_collection(self, collection_name: str) -> bool:
|
||||
# Check if the collection exists based on the collection name.
|
||||
collections = self.client.list_collections()
|
||||
return collection_name in [collection.name for collection in collections]
|
||||
collection_names = self.client.list_collections()
|
||||
return collection_name in collection_names
|
||||
|
||||
def delete_collection(self, collection_name: str):
|
||||
# Delete the collection based on the collection name.
|
||||
|
||||
@@ -7,13 +7,14 @@ from typing import Optional
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.config import (
|
||||
MILVUS_URI,
|
||||
MILVUS_DB,
|
||||
)
|
||||
|
||||
|
||||
class MilvusClient:
|
||||
def __init__(self):
|
||||
self.collection_prefix = "open_webui"
|
||||
self.client = Client(uri=MILVUS_URI)
|
||||
self.client = Client(uri=MILVUS_URI, database=MILVUS_DB)
|
||||
|
||||
def _result_to_get_result(self, result) -> GetResult:
|
||||
ids = []
|
||||
|
||||
@@ -5,9 +5,11 @@ from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
MetaData,
|
||||
select,
|
||||
text,
|
||||
Text,
|
||||
Table,
|
||||
values,
|
||||
)
|
||||
from sqlalchemy.sql import true
|
||||
@@ -17,11 +19,12 @@ from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
|
||||
from sqlalchemy.dialects.postgresql import JSONB, array
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy.ext.mutable import MutableDict
|
||||
from sqlalchemy.exc import NoSuchTableError
|
||||
|
||||
from open_webui.retrieval.vector.main import VectorItem, SearchResult, GetResult
|
||||
from open_webui.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 +59,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 +88,41 @@ 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()
|
||||
try:
|
||||
# Attempt to reflect the 'document_chunk' table
|
||||
document_chunk_table = Table(
|
||||
"document_chunk", metadata, autoload_with=self.session.bind
|
||||
)
|
||||
except NoSuchTableError:
|
||||
# Table does not exist; no action needed
|
||||
return
|
||||
|
||||
# Proceed to check the vector column
|
||||
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."
|
||||
)
|
||||
|
||||
def adjust_vector_length(self, vector: List[float]) -> List[float]:
|
||||
# Adjust vector to have length VECTOR_LENGTH
|
||||
current_length = len(vector)
|
||||
|
||||
@@ -23,7 +23,7 @@ def search_bing(
|
||||
filter_list: Optional[list[str]] = None,
|
||||
) -> list[SearchResult]:
|
||||
mkt = locale
|
||||
params = {"q": query, "mkt": mkt, "answerCount": count}
|
||||
params = {"q": query, "mkt": mkt, "count": count}
|
||||
headers = {"Ocp-Apim-Subscription-Key": subscription_key}
|
||||
|
||||
try:
|
||||
|
||||
+1
-1
@@ -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."
|
||||
]
|
||||
|
||||
@@ -311,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",
|
||||
|
||||
@@ -51,7 +51,7 @@ from open_webui.utils.access_control import get_permissions
|
||||
from typing import Optional, List
|
||||
|
||||
from ssl import CERT_REQUIRED, PROTOCOL_TLS
|
||||
from ldap3 import Server, Connection, ALL, Tls
|
||||
from ldap3 import Server, Connection, NONE, Tls
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
|
||||
router = APIRouter()
|
||||
@@ -170,6 +170,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
LDAP_SERVER_LABEL = request.app.state.config.LDAP_SERVER_LABEL
|
||||
LDAP_SERVER_HOST = request.app.state.config.LDAP_SERVER_HOST
|
||||
LDAP_SERVER_PORT = request.app.state.config.LDAP_SERVER_PORT
|
||||
LDAP_ATTRIBUTE_FOR_MAIL = request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL
|
||||
LDAP_ATTRIBUTE_FOR_USERNAME = request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME
|
||||
LDAP_SEARCH_BASE = request.app.state.config.LDAP_SEARCH_BASE
|
||||
LDAP_SEARCH_FILTERS = request.app.state.config.LDAP_SEARCH_FILTERS
|
||||
@@ -201,7 +202,7 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
server = Server(
|
||||
host=LDAP_SERVER_HOST,
|
||||
port=LDAP_SERVER_PORT,
|
||||
get_info=ALL,
|
||||
get_info=NONE,
|
||||
use_ssl=LDAP_USE_TLS,
|
||||
tls=tls,
|
||||
)
|
||||
@@ -218,7 +219,11 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
search_success = connection_app.search(
|
||||
search_base=LDAP_SEARCH_BASE,
|
||||
search_filter=f"(&({LDAP_ATTRIBUTE_FOR_USERNAME}={escape_filter_chars(form_data.user.lower())}){LDAP_SEARCH_FILTERS})",
|
||||
attributes=[f"{LDAP_ATTRIBUTE_FOR_USERNAME}", "mail", "cn"],
|
||||
attributes=[
|
||||
f"{LDAP_ATTRIBUTE_FOR_USERNAME}",
|
||||
f"{LDAP_ATTRIBUTE_FOR_MAIL}",
|
||||
"cn",
|
||||
],
|
||||
)
|
||||
|
||||
if not search_success:
|
||||
@@ -226,7 +231,9 @@ async def ldap_auth(request: Request, response: Response, form_data: LdapForm):
|
||||
|
||||
entry = connection_app.entries[0]
|
||||
username = str(entry[f"{LDAP_ATTRIBUTE_FOR_USERNAME}"]).lower()
|
||||
mail = str(entry["mail"])
|
||||
mail = str(entry[f"{LDAP_ATTRIBUTE_FOR_MAIL}"])
|
||||
if not mail or mail == "" or mail == "[]":
|
||||
raise HTTPException(400, f"User {form_data.user} does not have mail.")
|
||||
cn = str(entry["cn"])
|
||||
user_dn = entry.entry_dn
|
||||
|
||||
@@ -616,6 +623,8 @@ async def get_admin_config(request: Request, user=Depends(get_admin_user)):
|
||||
"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,
|
||||
@@ -629,6 +638,8 @@ class AdminConfig(BaseModel):
|
||||
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
|
||||
@@ -643,7 +654,15 @@ async def update_admin_config(
|
||||
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"]:
|
||||
@@ -665,6 +684,8 @@ async def update_admin_config(
|
||||
"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,
|
||||
@@ -677,6 +698,7 @@ class LdapServerConfig(BaseModel):
|
||||
label: str
|
||||
host: str
|
||||
port: Optional[int] = None
|
||||
attribute_for_mail: str = "mail"
|
||||
attribute_for_username: str = "uid"
|
||||
app_dn: str
|
||||
app_dn_password: str
|
||||
@@ -693,6 +715,7 @@ async def get_ldap_server(request: Request, user=Depends(get_admin_user)):
|
||||
"label": request.app.state.config.LDAP_SERVER_LABEL,
|
||||
"host": request.app.state.config.LDAP_SERVER_HOST,
|
||||
"port": request.app.state.config.LDAP_SERVER_PORT,
|
||||
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
|
||||
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
|
||||
"app_dn": request.app.state.config.LDAP_APP_DN,
|
||||
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,
|
||||
@@ -711,6 +734,7 @@ async def update_ldap_server(
|
||||
required_fields = [
|
||||
"label",
|
||||
"host",
|
||||
"attribute_for_mail",
|
||||
"attribute_for_username",
|
||||
"app_dn",
|
||||
"app_dn_password",
|
||||
@@ -729,6 +753,7 @@ async def update_ldap_server(
|
||||
request.app.state.config.LDAP_SERVER_LABEL = form_data.label
|
||||
request.app.state.config.LDAP_SERVER_HOST = form_data.host
|
||||
request.app.state.config.LDAP_SERVER_PORT = form_data.port
|
||||
request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL = form_data.attribute_for_mail
|
||||
request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME = (
|
||||
form_data.attribute_for_username
|
||||
)
|
||||
@@ -744,6 +769,7 @@ async def update_ldap_server(
|
||||
"label": request.app.state.config.LDAP_SERVER_LABEL,
|
||||
"host": request.app.state.config.LDAP_SERVER_HOST,
|
||||
"port": request.app.state.config.LDAP_SERVER_PORT,
|
||||
"attribute_for_mail": request.app.state.config.LDAP_ATTRIBUTE_FOR_MAIL,
|
||||
"attribute_for_username": request.app.state.config.LDAP_ATTRIBUTE_FOR_USERNAME,
|
||||
"app_dn": request.app.state.config.LDAP_APP_DN,
|
||||
"app_dn_password": request.app.state.config.LDAP_APP_PASSWORD,
|
||||
|
||||
@@ -11,7 +11,12 @@ 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, MessageForm
|
||||
from open_webui.models.messages import (
|
||||
Messages,
|
||||
MessageModel,
|
||||
MessageResponse,
|
||||
MessageForm,
|
||||
)
|
||||
|
||||
|
||||
from open_webui.config import ENABLE_ADMIN_CHAT_ACCESS, ENABLE_ADMIN_EXPORT
|
||||
@@ -49,7 +54,7 @@ async def get_channels(user=Depends(get_verified_user)):
|
||||
@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(form_data, user.id)
|
||||
channel = Channels.insert_new_channel(None, form_data, user.id)
|
||||
return ChannelModel(**channel.model_dump())
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
@@ -134,11 +139,11 @@ async def delete_channel_by_id(id: str, user=Depends(get_admin_user)):
|
||||
############################
|
||||
|
||||
|
||||
class MessageUserModel(MessageModel):
|
||||
class MessageUserResponse(MessageResponse):
|
||||
user: UserNameResponse
|
||||
|
||||
|
||||
@router.get("/{id}/messages", response_model=list[MessageUserModel])
|
||||
@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)
|
||||
):
|
||||
@@ -164,10 +169,16 @@ async def get_channel_messages(
|
||||
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(
|
||||
MessageUserModel(
|
||||
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()),
|
||||
}
|
||||
)
|
||||
@@ -236,10 +247,17 @@ async def post_new_message(
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
},
|
||||
"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(),
|
||||
@@ -251,6 +269,35 @@ async def post_new_message(
|
||||
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(
|
||||
@@ -269,6 +316,101 @@ async def post_new_message(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# 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
|
||||
############################
|
||||
@@ -306,6 +448,8 @@ async def update_message_by_id(
|
||||
|
||||
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",
|
||||
@@ -314,10 +458,14 @@ async def update_message_by_id(
|
||||
"message_id": message.id,
|
||||
"data": {
|
||||
"type": "message:update",
|
||||
"data": {
|
||||
**message.model_dump(),
|
||||
"user": UserNameResponse(**user.model_dump()).model_dump(),
|
||||
},
|
||||
"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(),
|
||||
@@ -333,6 +481,145 @@ async def update_message_by_id(
|
||||
)
|
||||
|
||||
|
||||
############################
|
||||
# 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
|
||||
############################
|
||||
@@ -386,6 +673,35 @@ async def delete_message_by_id(
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
############################
|
||||
|
||||
@@ -345,10 +345,12 @@ async def get_file_content_by_id(id: str, user=Depends(get_verified_user)):
|
||||
async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
|
||||
file = Files.get_file_by_id(id)
|
||||
if file and (file.user_id == user.id or user.role == "admin"):
|
||||
# We should add Chroma cleanup here
|
||||
|
||||
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")
|
||||
|
||||
@@ -2,6 +2,8 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
from open_webui.models.users import Users
|
||||
from open_webui.models.groups import (
|
||||
Groups,
|
||||
GroupForm,
|
||||
@@ -80,6 +82,9 @@ async def update_group_by_id(
|
||||
id: str, form_data: GroupUpdateForm, user=Depends(get_admin_user)
|
||||
):
|
||||
try:
|
||||
if form_data.user_ids:
|
||||
form_data.user_ids = Users.get_valid_user_ids(form_data.user_ids)
|
||||
|
||||
group = Groups.update_group_by_id(id, form_data)
|
||||
if group:
|
||||
return group
|
||||
|
||||
@@ -43,6 +43,7 @@ async def get_config(request: Request, user=Depends(get_admin_user)):
|
||||
return {
|
||||
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
|
||||
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
|
||||
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
|
||||
"openai": {
|
||||
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
|
||||
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
|
||||
@@ -86,6 +87,7 @@ class ComfyUIConfigForm(BaseModel):
|
||||
class ConfigForm(BaseModel):
|
||||
enabled: bool
|
||||
engine: str
|
||||
prompt_generation: bool
|
||||
openai: OpenAIConfigForm
|
||||
automatic1111: Automatic1111ConfigForm
|
||||
comfyui: ComfyUIConfigForm
|
||||
@@ -98,6 +100,10 @@ async def update_config(
|
||||
request.app.state.config.IMAGE_GENERATION_ENGINE = form_data.engine
|
||||
request.app.state.config.ENABLE_IMAGE_GENERATION = form_data.enabled
|
||||
|
||||
request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION = (
|
||||
form_data.prompt_generation
|
||||
)
|
||||
|
||||
request.app.state.config.IMAGES_OPENAI_API_BASE_URL = (
|
||||
form_data.openai.OPENAI_API_BASE_URL
|
||||
)
|
||||
@@ -137,6 +143,7 @@ async def update_config(
|
||||
return {
|
||||
"enabled": request.app.state.config.ENABLE_IMAGE_GENERATION,
|
||||
"engine": request.app.state.config.IMAGE_GENERATION_ENGINE,
|
||||
"prompt_generation": request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION,
|
||||
"openai": {
|
||||
"OPENAI_API_BASE_URL": request.app.state.config.IMAGES_OPENAI_API_BASE_URL,
|
||||
"OPENAI_API_KEY": request.app.state.config.IMAGES_OPENAI_API_KEY,
|
||||
@@ -200,7 +207,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},
|
||||
@@ -236,7 +243,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"]
|
||||
@@ -354,7 +361,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(
|
||||
@@ -408,10 +415,14 @@ def save_b64_image(b64_str):
|
||||
return None
|
||||
|
||||
|
||||
def save_url_image(url):
|
||||
def save_url_image(url, headers=None):
|
||||
image_id = str(uuid.uuid4())
|
||||
try:
|
||||
r = requests.get(url)
|
||||
if headers:
|
||||
r = requests.get(url, headers=headers)
|
||||
else:
|
||||
r = requests.get(url)
|
||||
|
||||
r.raise_for_status()
|
||||
if r.headers["content-type"].split("/")[0] == "image":
|
||||
mime_type = r.headers["content-type"]
|
||||
@@ -535,7 +546,13 @@ async def image_generations(
|
||||
images = []
|
||||
|
||||
for image in res["data"]:
|
||||
image_filename = save_url_image(image["url"])
|
||||
headers = None
|
||||
if request.app.state.config.COMFYUI_API_KEY:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {request.app.state.config.COMFYUI_API_KEY}"
|
||||
}
|
||||
|
||||
image_filename = save_url_image(image["url"], headers)
|
||||
images.append({"url": f"/cache/image/generations/{image_filename}"})
|
||||
file_body_path = IMAGE_CACHE_DIR.joinpath(f"{image_filename}.json")
|
||||
|
||||
@@ -578,7 +595,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()
|
||||
|
||||
@@ -17,7 +17,7 @@ from open_webui.routers.retrieval import (
|
||||
process_files_batch,
|
||||
BatchProcessFilesForm,
|
||||
)
|
||||
|
||||
from open_webui.storage.provider import Storage
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.utils.auth import get_verified_user
|
||||
@@ -25,6 +25,7 @@ from open_webui.utils.access_control import has_access, has_permission
|
||||
|
||||
|
||||
from open_webui.env import SRC_LOG_LEVELS
|
||||
from open_webui.models.models import Models, ModelForm
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -212,8 +213,12 @@ async def update_knowledge_by_id(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if knowledge.user_id != user.id and user.role != "admin":
|
||||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
knowledge.user_id != user.id
|
||||
and not has_access(user.id, "write", knowledge.access_control)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
@@ -419,11 +424,16 @@ 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},
|
||||
)
|
||||
# Remove the file's collection from vector database
|
||||
file_collection = f"file-{form_data.file_id}"
|
||||
if VECTOR_DB_CLIENT.has_collection(collection_name=file_collection):
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=file_collection)
|
||||
|
||||
# Delete physical file
|
||||
if file.path:
|
||||
Storage.delete_file(file.path)
|
||||
|
||||
# Delete file from database
|
||||
Files.delete_file_by_id(form_data.file_id)
|
||||
|
||||
if knowledge:
|
||||
@@ -480,6 +490,36 @@ async def delete_knowledge_by_id(id: str, user=Depends(get_verified_user)):
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
)
|
||||
|
||||
log.info(f"Deleting knowledge base: {id} (name: {knowledge.name})")
|
||||
|
||||
# Get all models
|
||||
models = Models.get_all_models()
|
||||
log.info(f"Found {len(models)} models to check for knowledge base {id}")
|
||||
|
||||
# Update models that reference this knowledge base
|
||||
for model in models:
|
||||
if model.meta and hasattr(model.meta, "knowledge"):
|
||||
knowledge_list = model.meta.knowledge or []
|
||||
# Filter out the deleted knowledge base
|
||||
updated_knowledge = [k for k in knowledge_list if k.get("id") != id]
|
||||
|
||||
# If the knowledge list changed, update the model
|
||||
if len(updated_knowledge) != len(knowledge_list):
|
||||
log.info(f"Updating model {model.id} to remove knowledge base {id}")
|
||||
model.meta.knowledge = updated_knowledge
|
||||
# Create a ModelForm for the update
|
||||
model_form = ModelForm(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
base_model_id=model.base_model_id,
|
||||
meta=model.meta,
|
||||
params=model.params,
|
||||
access_control=model.access_control,
|
||||
is_active=model.is_active,
|
||||
)
|
||||
Models.update_model_by_id(model.id, model_form)
|
||||
|
||||
# Clean up vector DB
|
||||
try:
|
||||
VECTOR_DB_CLIENT.delete_collection(collection_name=id)
|
||||
except Exception as e:
|
||||
@@ -527,6 +567,7 @@ async def reset_knowledge_by_id(id: str, user=Depends(get_verified_user)):
|
||||
|
||||
@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),
|
||||
@@ -562,7 +603,9 @@ def add_files_to_knowledge_batch(
|
||||
# Process files
|
||||
try:
|
||||
result = process_files_batch(
|
||||
BatchProcessFilesForm(files=files, collection_name=id)
|
||||
request=request,
|
||||
form_data=BatchProcessFilesForm(files=files, collection_name=id),
|
||||
user=user,
|
||||
)
|
||||
except Exception as e:
|
||||
log.error(
|
||||
|
||||
@@ -152,10 +152,12 @@ async def send_post_request(
|
||||
)
|
||||
|
||||
|
||||
def get_api_key(url, configs):
|
||||
def get_api_key(idx, url, configs):
|
||||
parsed_url = urlparse(url)
|
||||
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
return configs.get(base_url, {}).get("key", None)
|
||||
return configs.get(str(idx), configs.get(base_url, {})).get(
|
||||
"key", None
|
||||
) # Legacy support
|
||||
|
||||
|
||||
##########################################
|
||||
@@ -238,11 +240,13 @@ async def update_config(
|
||||
request.app.state.config.OLLAMA_BASE_URLS = form_data.OLLAMA_BASE_URLS
|
||||
request.app.state.config.OLLAMA_API_CONFIGS = form_data.OLLAMA_API_CONFIGS
|
||||
|
||||
# Remove any extra configs
|
||||
config_urls = request.app.state.config.OLLAMA_API_CONFIGS.keys()
|
||||
for url in list(request.app.state.config.OLLAMA_BASE_URLS):
|
||||
if url not in config_urls:
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.pop(url, None)
|
||||
# Remove the API configs that are not in the API URLS
|
||||
keys = list(map(str, range(len(request.app.state.config.OLLAMA_BASE_URLS))))
|
||||
request.app.state.config.OLLAMA_API_CONFIGS = {
|
||||
key: value
|
||||
for key, value in request.app.state.config.OLLAMA_API_CONFIGS.items()
|
||||
if key in keys
|
||||
}
|
||||
|
||||
return {
|
||||
"ENABLE_OLLAMA_API": request.app.state.config.ENABLE_OLLAMA_API,
|
||||
@@ -256,12 +260,19 @@ async def get_all_models(request: Request):
|
||||
log.info("get_all_models()")
|
||||
if request.app.state.config.ENABLE_OLLAMA_API:
|
||||
request_tasks = []
|
||||
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS):
|
||||
if url not in request.app.state.config.OLLAMA_API_CONFIGS:
|
||||
if (str(idx) not in request.app.state.config.OLLAMA_API_CONFIGS) or (
|
||||
url not in request.app.state.config.OLLAMA_API_CONFIGS # Legacy support
|
||||
):
|
||||
request_tasks.append(send_get_request(f"{url}/api/tags"))
|
||||
else:
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
enable = api_config.get("enable", True)
|
||||
key = api_config.get("key", None)
|
||||
|
||||
@@ -275,7 +286,12 @@ async def get_all_models(request: Request):
|
||||
for idx, response in enumerate(responses):
|
||||
if response:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
model_ids = api_config.get("model_ids", [])
|
||||
@@ -349,7 +365,7 @@ async def get_ollama_tags(
|
||||
models = await get_all_models(request)
|
||||
else:
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
r = None
|
||||
try:
|
||||
@@ -393,11 +409,14 @@ async def get_ollama_versions(request: Request, url_idx: Optional[int] = None):
|
||||
request_tasks = [
|
||||
send_get_request(
|
||||
f"{url}/api/version",
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
|
||||
"key", None
|
||||
),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
).get("key", None),
|
||||
)
|
||||
for url in request.app.state.config.OLLAMA_BASE_URLS
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
|
||||
]
|
||||
responses = await asyncio.gather(*request_tasks)
|
||||
responses = list(filter(lambda x: x is not None, responses))
|
||||
@@ -454,11 +473,14 @@ async def get_ollama_loaded_models(request: Request, user=Depends(get_verified_u
|
||||
request_tasks = [
|
||||
send_get_request(
|
||||
f"{url}/api/ps",
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}).get(
|
||||
"key", None
|
||||
),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
).get("key", None),
|
||||
)
|
||||
for url in request.app.state.config.OLLAMA_BASE_URLS
|
||||
for idx, url in enumerate(request.app.state.config.OLLAMA_BASE_URLS)
|
||||
]
|
||||
responses = await asyncio.gather(*request_tasks)
|
||||
|
||||
@@ -488,7 +510,7 @@ async def pull_model(
|
||||
return await send_post_request(
|
||||
url=f"{url}/api/pull",
|
||||
payload=json.dumps(payload),
|
||||
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
)
|
||||
|
||||
|
||||
@@ -524,7 +546,7 @@ async def push_model(
|
||||
return await send_post_request(
|
||||
url=f"{url}/api/push",
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
)
|
||||
|
||||
|
||||
@@ -549,7 +571,7 @@ async def create_model(
|
||||
return await send_post_request(
|
||||
url=f"{url}/api/create",
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
)
|
||||
|
||||
|
||||
@@ -579,7 +601,7 @@ async def copy_model(
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
try:
|
||||
r = requests.request(
|
||||
@@ -634,7 +656,7 @@ async def delete_model(
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
try:
|
||||
r = requests.request(
|
||||
@@ -684,7 +706,7 @@ async def show_model_info(
|
||||
url_idx = random.choice(models[form_data.name]["urls"])
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
try:
|
||||
r = requests.request(
|
||||
@@ -753,7 +775,7 @@ async def embed(
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
try:
|
||||
r = requests.request(
|
||||
@@ -822,7 +844,7 @@ async def embeddings(
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
key = get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
key = get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS)
|
||||
|
||||
try:
|
||||
r = requests.request(
|
||||
@@ -897,7 +919,10 @@ async def generate_completion(
|
||||
)
|
||||
|
||||
url = request.app.state.config.OLLAMA_BASE_URLS[url_idx]
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
if prefix_id:
|
||||
@@ -906,7 +931,7 @@ async def generate_completion(
|
||||
return await send_post_request(
|
||||
url=f"{url}/api/generate",
|
||||
payload=form_data.model_dump_json(exclude_none=True).encode(),
|
||||
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
)
|
||||
|
||||
|
||||
@@ -1005,7 +1030,10 @@ async def generate_chat_completion(
|
||||
payload["model"] = f"{payload['model']}:latest"
|
||||
|
||||
url = await get_ollama_url(request, payload["model"], url_idx)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
if prefix_id:
|
||||
@@ -1015,7 +1043,7 @@ async def generate_chat_completion(
|
||||
url=f"{url}/api/chat",
|
||||
payload=json.dumps(payload),
|
||||
stream=form_data.stream,
|
||||
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
content_type="application/x-ndjson",
|
||||
)
|
||||
|
||||
@@ -1104,7 +1132,10 @@ async def generate_openai_completion(
|
||||
payload["model"] = f"{payload['model']}:latest"
|
||||
|
||||
url = await get_ollama_url(request, payload["model"], url_idx)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
|
||||
@@ -1115,7 +1146,7 @@ async def generate_openai_completion(
|
||||
url=f"{url}/v1/completions",
|
||||
payload=json.dumps(payload),
|
||||
stream=payload.get("stream", False),
|
||||
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
)
|
||||
|
||||
|
||||
@@ -1178,7 +1209,10 @@ async def generate_openai_chat_completion(
|
||||
payload["model"] = f"{payload['model']}:latest"
|
||||
|
||||
url = await get_ollama_url(request, payload["model"], url_idx)
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OLLAMA_API_CONFIGS.get(
|
||||
str(url_idx),
|
||||
request.app.state.config.OLLAMA_API_CONFIGS.get(url, {}), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
if prefix_id:
|
||||
@@ -1188,7 +1222,7 @@ async def generate_openai_chat_completion(
|
||||
url=f"{url}/v1/chat/completions",
|
||||
payload=json.dumps(payload),
|
||||
stream=payload.get("stream", False),
|
||||
key=get_api_key(url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -145,11 +145,13 @@ async def update_config(
|
||||
|
||||
request.app.state.config.OPENAI_API_CONFIGS = form_data.OPENAI_API_CONFIGS
|
||||
|
||||
# Remove any extra configs
|
||||
config_urls = request.app.state.config.OPENAI_API_CONFIGS.keys()
|
||||
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
|
||||
if url not in config_urls:
|
||||
request.app.state.config.OPENAI_API_CONFIGS.pop(url, None)
|
||||
# Remove the API configs that are not in the API URLS
|
||||
keys = list(map(str, range(len(request.app.state.config.OPENAI_API_BASE_URLS))))
|
||||
request.app.state.config.OPENAI_API_CONFIGS = {
|
||||
key: value
|
||||
for key, value in request.app.state.config.OPENAI_API_CONFIGS.items()
|
||||
if key in keys
|
||||
}
|
||||
|
||||
return {
|
||||
"ENABLE_OPENAI_API": request.app.state.config.ENABLE_OPENAI_API,
|
||||
@@ -264,14 +266,21 @@ async def get_all_models_responses(request: Request) -> list:
|
||||
|
||||
request_tasks = []
|
||||
for idx, url in enumerate(request.app.state.config.OPENAI_API_BASE_URLS):
|
||||
if url not in request.app.state.config.OPENAI_API_CONFIGS:
|
||||
if (str(idx) not in request.app.state.config.OPENAI_API_CONFIGS) or (
|
||||
url not in request.app.state.config.OPENAI_API_CONFIGS # Legacy support
|
||||
):
|
||||
request_tasks.append(
|
||||
send_get_request(
|
||||
f"{url}/models", request.app.state.config.OPENAI_API_KEYS[idx]
|
||||
)
|
||||
)
|
||||
else:
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
enable = api_config.get("enable", True)
|
||||
model_ids = api_config.get("model_ids", [])
|
||||
@@ -310,7 +319,12 @@ async def get_all_models_responses(request: Request) -> list:
|
||||
for idx, response in enumerate(responses):
|
||||
if response:
|
||||
url = request.app.state.config.OPENAI_API_BASE_URLS[idx]
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(url, {})
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
url, {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
|
||||
@@ -573,6 +587,7 @@ async def generate_chat_completion(
|
||||
detail="Model not found",
|
||||
)
|
||||
|
||||
await get_all_models(request)
|
||||
model = request.app.state.OPENAI_MODELS.get(model_id)
|
||||
if model:
|
||||
idx = model["urlIdx"]
|
||||
@@ -584,7 +599,10 @@ async def generate_chat_completion(
|
||||
|
||||
# Get the API config for the model
|
||||
api_config = request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
|
||||
str(idx),
|
||||
request.app.state.config.OPENAI_API_CONFIGS.get(
|
||||
request.app.state.config.OPENAI_API_BASE_URLS[idx], {}
|
||||
), # Legacy support
|
||||
)
|
||||
|
||||
prefix_id = api_config.get("prefix_id", None)
|
||||
@@ -613,9 +631,8 @@ async def generate_chat_completion(
|
||||
payload["max_tokens"] = payload["max_completion_tokens"]
|
||||
del payload["max_completion_tokens"]
|
||||
|
||||
# TODO: check if below is needed
|
||||
# if "max_tokens" in payload and "max_completion_tokens" in payload:
|
||||
# del payload["max_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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -112,7 +112,12 @@ async def update_prompt_by_command(
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if prompt.user_id != user.id and user.role != "admin":
|
||||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
prompt.user_id != user.id
|
||||
and not has_access(user.id, "write", prompt.access_control)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.ACCESS_PROHIBITED,
|
||||
|
||||
@@ -347,7 +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,
|
||||
"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,
|
||||
@@ -384,7 +384,7 @@ async def get_rag_config(request: Request, user=Depends(get_admin_user)):
|
||||
"serply_api_key": request.app.state.config.SERPLY_API_KEY,
|
||||
"tavily_api_key": request.app.state.config.TAVILY_API_KEY,
|
||||
"searchapi_api_key": request.app.state.config.SEARCHAPI_API_KEY,
|
||||
"seaarchapi_engine": request.app.state.config.SEARCHAPI_ENGINE,
|
||||
"searchapi_engine": request.app.state.config.SEARCHAPI_ENGINE,
|
||||
"jina_api_key": request.app.state.config.JINA_API_KEY,
|
||||
"bing_search_v7_endpoint": request.app.state.config.BING_SEARCH_V7_ENDPOINT,
|
||||
"bing_search_v7_subscription_key": request.app.state.config.BING_SEARCH_V7_SUBSCRIPTION_KEY,
|
||||
@@ -1458,6 +1458,7 @@ class BatchProcessFilesResponse(BaseModel):
|
||||
|
||||
@router.post("/process/files/batch")
|
||||
def process_files_batch(
|
||||
request: Request,
|
||||
form_data: BatchProcessFilesForm,
|
||||
user=Depends(get_verified_user),
|
||||
) -> BatchProcessFilesResponse:
|
||||
@@ -1504,7 +1505,10 @@ def process_files_batch(
|
||||
if all_docs:
|
||||
try:
|
||||
save_docs_to_vector_db(
|
||||
docs=all_docs, collection_name=collection_name, add=True
|
||||
request=request,
|
||||
docs=all_docs,
|
||||
collection_name=collection_name,
|
||||
add=True,
|
||||
)
|
||||
|
||||
# Update all files with collection name
|
||||
|
||||
@@ -9,6 +9,7 @@ from open_webui.utils.chat import generate_chat_completion
|
||||
from open_webui.utils.task import (
|
||||
title_generation_template,
|
||||
query_generation_template,
|
||||
image_prompt_generation_template,
|
||||
autocomplete_generation_template,
|
||||
tags_generation_template,
|
||||
emoji_generation_template,
|
||||
@@ -23,6 +24,7 @@ from open_webui.utils.task import get_task_model_id
|
||||
from open_webui.config import (
|
||||
DEFAULT_TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_QUERY_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_AUTOCOMPLETE_GENERATION_PROMPT_TEMPLATE,
|
||||
DEFAULT_EMOJI_GENERATION_PROMPT_TEMPLATE,
|
||||
@@ -50,6 +52,7 @@ async def get_task_config(request: Request, user=Depends(get_verified_user)):
|
||||
"TASK_MODEL": request.app.state.config.TASK_MODEL,
|
||||
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
|
||||
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
@@ -65,6 +68,7 @@ class TaskConfigForm(BaseModel):
|
||||
TASK_MODEL: Optional[str]
|
||||
TASK_MODEL_EXTERNAL: Optional[str]
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE: str
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: str
|
||||
ENABLE_AUTOCOMPLETE_GENERATION: bool
|
||||
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: int
|
||||
TAGS_GENERATION_PROMPT_TEMPLATE: str
|
||||
@@ -114,6 +118,7 @@ async def update_task_config(
|
||||
"TASK_MODEL": request.app.state.config.TASK_MODEL,
|
||||
"TASK_MODEL_EXTERNAL": request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
"TITLE_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TITLE_GENERATION_PROMPT_TEMPLATE,
|
||||
"IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE": request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE,
|
||||
"ENABLE_AUTOCOMPLETE_GENERATION": request.app.state.config.ENABLE_AUTOCOMPLETE_GENERATION,
|
||||
"AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH": request.app.state.config.AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH,
|
||||
"TAGS_GENERATION_PROMPT_TEMPLATE": request.app.state.config.TAGS_GENERATION_PROMPT_TEMPLATE,
|
||||
@@ -256,6 +261,66 @@ async def generate_chat_tags(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/image_prompt/completions")
|
||||
async def generate_image_prompt(
|
||||
request: Request, form_data: dict, user=Depends(get_verified_user)
|
||||
):
|
||||
models = request.app.state.MODELS
|
||||
|
||||
model_id = form_data["model"]
|
||||
if model_id not in models:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Model not found",
|
||||
)
|
||||
|
||||
# Check if the user has a custom task model
|
||||
# If the user has a custom task model, use that model
|
||||
task_model_id = get_task_model_id(
|
||||
model_id,
|
||||
request.app.state.config.TASK_MODEL,
|
||||
request.app.state.config.TASK_MODEL_EXTERNAL,
|
||||
models,
|
||||
)
|
||||
|
||||
log.debug(
|
||||
f"generating image prompt using model {task_model_id} for user {user.email} "
|
||||
)
|
||||
|
||||
if request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE != "":
|
||||
template = request.app.state.config.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
else:
|
||||
template = DEFAULT_IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE
|
||||
|
||||
content = image_prompt_generation_template(
|
||||
template,
|
||||
form_data["messages"],
|
||||
user={
|
||||
"name": user.name,
|
||||
},
|
||||
)
|
||||
|
||||
payload = {
|
||||
"model": task_model_id,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"stream": False,
|
||||
"metadata": {
|
||||
"task": str(TASKS.IMAGE_PROMPT_GENERATION),
|
||||
"task_body": form_data,
|
||||
"chat_id": form_data.get("chat_id", None),
|
||||
},
|
||||
}
|
||||
|
||||
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": "An internal error has occurred."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/queries/completions")
|
||||
async def generate_queries(
|
||||
request: Request, form_data: dict, user=Depends(get_verified_user)
|
||||
@@ -499,8 +564,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,
|
||||
},
|
||||
|
||||
@@ -70,7 +70,7 @@ async def create_new_tools(
|
||||
user=Depends(get_verified_user),
|
||||
):
|
||||
if user.role != "admin" and not has_permission(
|
||||
user.id, "workspace.knowledge", request.app.state.config.USER_PERMISSIONS
|
||||
user.id, "workspace.tools", request.app.state.config.USER_PERMISSIONS
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -165,7 +165,12 @@ async def update_tools_by_id(
|
||||
detail=ERROR_MESSAGES.NOT_FOUND,
|
||||
)
|
||||
|
||||
if tools.user_id != user.id and user.role != "admin":
|
||||
# Is the user the original creator, in a group with write access, or an admin
|
||||
if (
|
||||
tools.user_id != user.id
|
||||
and not has_access(user.id, "write", tools.access_control)
|
||||
and user.role != "admin"
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=ERROR_MESSAGES.UNAUTHORIZED,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -55,27 +62,44 @@ async def get_user_permissisions(user=Depends(get_verified_user)):
|
||||
# User Default Permissions
|
||||
############################
|
||||
class WorkspacePermissions(BaseModel):
|
||||
models: bool
|
||||
knowledge: bool
|
||||
prompts: bool
|
||||
tools: bool
|
||||
models: bool = False
|
||||
knowledge: bool = False
|
||||
prompts: bool = False
|
||||
tools: bool = False
|
||||
|
||||
|
||||
class ChatPermissions(BaseModel):
|
||||
file_upload: bool
|
||||
delete: bool
|
||||
edit: bool
|
||||
temporary: bool
|
||||
controls: bool = True
|
||||
file_upload: bool = True
|
||||
delete: bool = True
|
||||
edit: bool = True
|
||||
temporary: bool = True
|
||||
|
||||
|
||||
class FeaturesPermissions(BaseModel):
|
||||
web_search: bool = True
|
||||
image_generation: bool = True
|
||||
|
||||
|
||||
class UserPermissions(BaseModel):
|
||||
workspace: WorkspacePermissions
|
||||
chat: ChatPermissions
|
||||
features: FeaturesPermissions
|
||||
|
||||
|
||||
@router.get("/default/permissions")
|
||||
@router.get("/default/permissions", response_model=UserPermissions)
|
||||
async def get_user_permissions(request: Request, user=Depends(get_admin_user)):
|
||||
return request.app.state.config.USER_PERMISSIONS
|
||||
return {
|
||||
"workspace": WorkspacePermissions(
|
||||
**request.app.state.config.USER_PERMISSIONS.get("workspace", {})
|
||||
),
|
||||
"chat": ChatPermissions(
|
||||
**request.app.state.config.USER_PERMISSIONS.get("chat", {})
|
||||
),
|
||||
"features": FeaturesPermissions(
|
||||
**request.app.state.config.USER_PERMISSIONS.get("features", {})
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/default/permissions")
|
||||
@@ -192,6 +216,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 +237,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,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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
|
||||
|
||||
@@ -152,14 +152,14 @@ 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] = 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()})
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ 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] = USER_POOL[user.id] + [sid]
|
||||
else:
|
||||
@@ -192,7 +192,7 @@ async def user_join(sid, data):
|
||||
|
||||
# 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}
|
||||
|
||||
|
||||
@@ -217,28 +217,52 @@ async def join_channel(sid, data):
|
||||
await sio.enter_room(sid, f"channel:{channel.id}")
|
||||
|
||||
|
||||
@sio.on("user-count")
|
||||
async def user_count(sid):
|
||||
await sio.emit("user-count", {"count": len(USER_POOL.items())})
|
||||
@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("chat")
|
||||
async def chat(sid, data):
|
||||
print("chat", sid, SESSION_POOL[sid], data)
|
||||
@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")
|
||||
@@ -269,6 +293,34 @@ def get_event_emitter(request_info):
|
||||
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__
|
||||
|
||||
|
||||
@@ -289,7 +341,10 @@ def get_event_call(request_info):
|
||||
|
||||
|
||||
def get_user_id_from_session_pool(sid):
|
||||
return SESSION_POOL.get(sid)
|
||||
user = SESSION_POOL.get(sid)
|
||||
if user:
|
||||
return user["id"]
|
||||
return None
|
||||
|
||||
|
||||
def get_user_ids_from_room(room):
|
||||
@@ -299,6 +354,14 @@ def get_user_ids_from_room(room):
|
||||
)
|
||||
|
||||
active_user_ids = list(
|
||||
set([SESSION_POOL.get(session_id[0]) for session_id in active_session_ids])
|
||||
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
|
||||
|
||||
@@ -26,7 +26,7 @@ class RedisLock:
|
||||
|
||||
def release_lock(self):
|
||||
lock_value = self.redis.get(self.lock_name)
|
||||
if lock_value and lock_value.decode("utf-8") == self.lock_id:
|
||||
if lock_value and lock_value == self.lock_id:
|
||||
self.redis.delete(self.lock_name)
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 5.2 KiB |
@@ -1,121 +1,73 @@
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import BinaryIO, Tuple
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
import shutil
|
||||
|
||||
|
||||
from typing import BinaryIO, Tuple, Optional, Union
|
||||
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.config import (
|
||||
STORAGE_PROVIDER,
|
||||
S3_ACCESS_KEY_ID,
|
||||
S3_SECRET_ACCESS_KEY,
|
||||
S3_BUCKET_NAME,
|
||||
S3_REGION_NAME,
|
||||
S3_ENDPOINT_URL,
|
||||
S3_REGION_NAME,
|
||||
S3_SECRET_ACCESS_KEY,
|
||||
GCS_BUCKET_NAME,
|
||||
GOOGLE_APPLICATION_CREDENTIALS_JSON,
|
||||
STORAGE_PROVIDER,
|
||||
UPLOAD_DIR,
|
||||
)
|
||||
from google.cloud import storage
|
||||
from google.cloud.exceptions import GoogleCloudError, NotFound
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
from typing import BinaryIO, Tuple, Optional
|
||||
class StorageProvider(ABC):
|
||||
@abstractmethod
|
||||
def get_file(self, file_path: str) -> str:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_all_files(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_file(self, file_path: str) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class StorageProvider:
|
||||
def __init__(self, provider: Optional[str] = None):
|
||||
self.storage_provider: str = provider or STORAGE_PROVIDER
|
||||
|
||||
self.s3_client = None
|
||||
self.s3_bucket_name: Optional[str] = None
|
||||
|
||||
if self.storage_provider == "s3":
|
||||
self._initialize_s3()
|
||||
|
||||
def _initialize_s3(self) -> None:
|
||||
"""Initializes the S3 client and bucket name if using S3 storage."""
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
region_name=S3_REGION_NAME,
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
aws_access_key_id=S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
|
||||
)
|
||||
self.bucket_name = S3_BUCKET_NAME
|
||||
|
||||
def _upload_to_s3(self, file_path: str, filename: str) -> Tuple[bytes, str]:
|
||||
"""Handles uploading of the file to S3 storage."""
|
||||
if not self.s3_client:
|
||||
raise RuntimeError("S3 Client is not initialized.")
|
||||
|
||||
try:
|
||||
self.s3_client.upload_file(file_path, self.bucket_name, filename)
|
||||
return (
|
||||
open(file_path, "rb").read(),
|
||||
"s3://" + self.bucket_name + "/" + filename,
|
||||
)
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error uploading file to S3: {e}")
|
||||
|
||||
def _upload_to_local(self, contents: bytes, filename: str) -> Tuple[bytes, str]:
|
||||
"""Handles uploading of the file to local storage."""
|
||||
class LocalStorageProvider(StorageProvider):
|
||||
@staticmethod
|
||||
def upload_file(file: BinaryIO, filename: str) -> Tuple[bytes, str]:
|
||||
contents = file.read()
|
||||
if not contents:
|
||||
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
|
||||
file_path = f"{UPLOAD_DIR}/{filename}"
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
return contents, file_path
|
||||
|
||||
def _get_file_from_s3(self, file_path: str) -> str:
|
||||
"""Handles downloading of the file from S3 storage."""
|
||||
if not self.s3_client:
|
||||
raise RuntimeError("S3 Client is not initialized.")
|
||||
|
||||
try:
|
||||
bucket_name, key = file_path.split("//")[1].split("/")
|
||||
local_file_path = f"{UPLOAD_DIR}/{key}"
|
||||
self.s3_client.download_file(bucket_name, key, local_file_path)
|
||||
return local_file_path
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error downloading file from S3: {e}")
|
||||
|
||||
def _get_file_from_local(self, file_path: str) -> str:
|
||||
@staticmethod
|
||||
def get_file(file_path: str) -> str:
|
||||
"""Handles downloading of the file from local storage."""
|
||||
return file_path
|
||||
|
||||
def _delete_from_s3(self, filename: str) -> None:
|
||||
"""Handles deletion of the file from S3 storage."""
|
||||
if not self.s3_client:
|
||||
raise RuntimeError("S3 Client is not initialized.")
|
||||
|
||||
try:
|
||||
self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error deleting file from S3: {e}")
|
||||
|
||||
def _delete_from_local(self, filename: str) -> None:
|
||||
@staticmethod
|
||||
def delete_file(file_path: str) -> None:
|
||||
"""Handles deletion of the file from local storage."""
|
||||
filename = file_path.split("/")[-1]
|
||||
file_path = f"{UPLOAD_DIR}/{filename}"
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
else:
|
||||
print(f"File {file_path} not found in local storage.")
|
||||
|
||||
def _delete_all_from_s3(self) -> None:
|
||||
"""Handles deletion of all files from S3 storage."""
|
||||
if not self.s3_client:
|
||||
raise RuntimeError("S3 Client is not initialized.")
|
||||
|
||||
try:
|
||||
response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
|
||||
if "Contents" in response:
|
||||
for content in response["Contents"]:
|
||||
self.s3_client.delete_object(
|
||||
Bucket=self.bucket_name, Key=content["Key"]
|
||||
)
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error deleting all files from S3: {e}")
|
||||
|
||||
def _delete_all_from_local(self) -> None:
|
||||
@staticmethod
|
||||
def delete_all_files() -> None:
|
||||
"""Handles deletion of all files from local storage."""
|
||||
if os.path.exists(UPLOAD_DIR):
|
||||
for filename in os.listdir(UPLOAD_DIR):
|
||||
@@ -130,38 +82,141 @@ class StorageProvider:
|
||||
else:
|
||||
print(f"Directory {UPLOAD_DIR} not found in local storage.")
|
||||
|
||||
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
|
||||
"""Uploads a file either to S3 or the local file system."""
|
||||
contents = file.read()
|
||||
if not contents:
|
||||
raise ValueError(ERROR_MESSAGES.EMPTY_CONTENT)
|
||||
contents, file_path = self._upload_to_local(contents, filename)
|
||||
|
||||
if self.storage_provider == "s3":
|
||||
return self._upload_to_s3(file_path, filename)
|
||||
return contents, file_path
|
||||
class S3StorageProvider(StorageProvider):
|
||||
def __init__(self):
|
||||
self.s3_client = boto3.client(
|
||||
"s3",
|
||||
region_name=S3_REGION_NAME,
|
||||
endpoint_url=S3_ENDPOINT_URL,
|
||||
aws_access_key_id=S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=S3_SECRET_ACCESS_KEY,
|
||||
)
|
||||
self.bucket_name = S3_BUCKET_NAME
|
||||
|
||||
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
|
||||
"""Handles uploading of the file to S3 storage."""
|
||||
_, file_path = LocalStorageProvider.upload_file(file, filename)
|
||||
try:
|
||||
self.s3_client.upload_file(file_path, self.bucket_name, filename)
|
||||
return (
|
||||
open(file_path, "rb").read(),
|
||||
"s3://" + self.bucket_name + "/" + filename,
|
||||
)
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error uploading file to S3: {e}")
|
||||
|
||||
def get_file(self, file_path: str) -> str:
|
||||
"""Downloads a file either from S3 or the local file system and returns the file path."""
|
||||
if self.storage_provider == "s3":
|
||||
return self._get_file_from_s3(file_path)
|
||||
return self._get_file_from_local(file_path)
|
||||
"""Handles downloading of the file from S3 storage."""
|
||||
try:
|
||||
bucket_name, key = file_path.split("//")[1].split("/")
|
||||
local_file_path = f"{UPLOAD_DIR}/{key}"
|
||||
self.s3_client.download_file(bucket_name, key, local_file_path)
|
||||
return local_file_path
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error downloading file from S3: {e}")
|
||||
|
||||
def delete_file(self, filename: str) -> None:
|
||||
"""Deletes a file either from S3 or the local file system."""
|
||||
if self.storage_provider == "s3":
|
||||
self._delete_from_s3(filename)
|
||||
def delete_file(self, file_path: str) -> None:
|
||||
"""Handles deletion of the file from S3 storage."""
|
||||
filename = file_path.split("/")[-1]
|
||||
try:
|
||||
self.s3_client.delete_object(Bucket=self.bucket_name, Key=filename)
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error deleting file from S3: {e}")
|
||||
|
||||
# Always delete from local storage
|
||||
self._delete_from_local(filename)
|
||||
LocalStorageProvider.delete_file(file_path)
|
||||
|
||||
def delete_all_files(self) -> None:
|
||||
"""Deletes all files from the storage."""
|
||||
if self.storage_provider == "s3":
|
||||
self._delete_all_from_s3()
|
||||
"""Handles deletion of all files from S3 storage."""
|
||||
try:
|
||||
response = self.s3_client.list_objects_v2(Bucket=self.bucket_name)
|
||||
if "Contents" in response:
|
||||
for content in response["Contents"]:
|
||||
self.s3_client.delete_object(
|
||||
Bucket=self.bucket_name, Key=content["Key"]
|
||||
)
|
||||
except ClientError as e:
|
||||
raise RuntimeError(f"Error deleting all files from S3: {e}")
|
||||
|
||||
# Always delete from local storage
|
||||
self._delete_all_from_local()
|
||||
LocalStorageProvider.delete_all_files()
|
||||
|
||||
|
||||
Storage = StorageProvider(provider=STORAGE_PROVIDER)
|
||||
class GCSStorageProvider(StorageProvider):
|
||||
def __init__(self):
|
||||
self.bucket_name = GCS_BUCKET_NAME
|
||||
|
||||
if GOOGLE_APPLICATION_CREDENTIALS_JSON:
|
||||
self.gcs_client = storage.Client.from_service_account_info(
|
||||
info=json.loads(GOOGLE_APPLICATION_CREDENTIALS_JSON)
|
||||
)
|
||||
else:
|
||||
# if no credentials json is provided, credentials will be picked up from the environment
|
||||
# if running on local environment, credentials would be user credentials
|
||||
# if running on a Compute Engine instance, credentials would be from Google Metadata server
|
||||
self.gcs_client = storage.Client()
|
||||
self.bucket = self.gcs_client.bucket(GCS_BUCKET_NAME)
|
||||
|
||||
def upload_file(self, file: BinaryIO, filename: str) -> Tuple[bytes, str]:
|
||||
"""Handles uploading of the file to GCS storage."""
|
||||
contents, file_path = LocalStorageProvider.upload_file(file, filename)
|
||||
try:
|
||||
blob = self.bucket.blob(filename)
|
||||
blob.upload_from_filename(file_path)
|
||||
return contents, "gs://" + self.bucket_name + "/" + filename
|
||||
except GoogleCloudError as e:
|
||||
raise RuntimeError(f"Error uploading file to GCS: {e}")
|
||||
|
||||
def get_file(self, file_path: str) -> str:
|
||||
"""Handles downloading of the file from GCS storage."""
|
||||
try:
|
||||
filename = file_path.removeprefix("gs://").split("/")[1]
|
||||
local_file_path = f"{UPLOAD_DIR}/{filename}"
|
||||
blob = self.bucket.get_blob(filename)
|
||||
blob.download_to_filename(local_file_path)
|
||||
|
||||
return local_file_path
|
||||
except NotFound as e:
|
||||
raise RuntimeError(f"Error downloading file from GCS: {e}")
|
||||
|
||||
def delete_file(self, file_path: str) -> None:
|
||||
"""Handles deletion of the file from GCS storage."""
|
||||
try:
|
||||
filename = file_path.removeprefix("gs://").split("/")[1]
|
||||
blob = self.bucket.get_blob(filename)
|
||||
blob.delete()
|
||||
except NotFound as e:
|
||||
raise RuntimeError(f"Error deleting file from GCS: {e}")
|
||||
|
||||
# Always delete from local storage
|
||||
LocalStorageProvider.delete_file(file_path)
|
||||
|
||||
def delete_all_files(self) -> None:
|
||||
"""Handles deletion of all files from GCS storage."""
|
||||
try:
|
||||
blobs = self.bucket.list_blobs()
|
||||
|
||||
for blob in blobs:
|
||||
blob.delete()
|
||||
|
||||
except NotFound as e:
|
||||
raise RuntimeError(f"Error deleting all files from GCS: {e}")
|
||||
|
||||
# Always delete from local storage
|
||||
LocalStorageProvider.delete_all_files()
|
||||
|
||||
|
||||
def get_storage_provider(storage_provider: str):
|
||||
if storage_provider == "local":
|
||||
Storage = LocalStorageProvider()
|
||||
elif storage_provider == "s3":
|
||||
Storage = S3StorageProvider()
|
||||
elif storage_provider == "gcs":
|
||||
Storage = GCSStorageProvider()
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported storage provider: {storage_provider}")
|
||||
return Storage
|
||||
|
||||
|
||||
Storage = get_storage_provider(STORAGE_PROVIDER)
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import io
|
||||
import os
|
||||
import boto3
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
from open_webui.storage import provider
|
||||
from gcp_storage_emulator.server import create_server
|
||||
from google.cloud import storage
|
||||
|
||||
|
||||
def mock_upload_dir(monkeypatch, tmp_path):
|
||||
"""Fixture to monkey-patch the UPLOAD_DIR and create a temporary directory."""
|
||||
directory = tmp_path / "uploads"
|
||||
directory.mkdir()
|
||||
monkeypatch.setattr(provider, "UPLOAD_DIR", str(directory))
|
||||
return directory
|
||||
|
||||
|
||||
def test_imports():
|
||||
provider.StorageProvider
|
||||
provider.LocalStorageProvider
|
||||
provider.S3StorageProvider
|
||||
provider.GCSStorageProvider
|
||||
provider.Storage
|
||||
|
||||
|
||||
def test_get_storage_provider():
|
||||
Storage = provider.get_storage_provider("local")
|
||||
assert isinstance(Storage, provider.LocalStorageProvider)
|
||||
Storage = provider.get_storage_provider("s3")
|
||||
assert isinstance(Storage, provider.S3StorageProvider)
|
||||
Storage = provider.get_storage_provider("gcs")
|
||||
assert isinstance(Storage, provider.GCSStorageProvider)
|
||||
with pytest.raises(RuntimeError):
|
||||
provider.get_storage_provider("invalid")
|
||||
|
||||
|
||||
def test_class_instantiation():
|
||||
with pytest.raises(TypeError):
|
||||
provider.StorageProvider()
|
||||
with pytest.raises(TypeError):
|
||||
|
||||
class Test(provider.StorageProvider):
|
||||
pass
|
||||
|
||||
Test()
|
||||
provider.LocalStorageProvider()
|
||||
provider.S3StorageProvider()
|
||||
provider.GCSStorageProvider()
|
||||
|
||||
|
||||
class TestLocalStorageProvider:
|
||||
Storage = provider.LocalStorageProvider()
|
||||
file_content = b"test content"
|
||||
file_bytesio = io.BytesIO(file_content)
|
||||
filename = "test.txt"
|
||||
filename_extra = "test_exyta.txt"
|
||||
file_bytesio_empty = io.BytesIO()
|
||||
|
||||
def test_upload_file(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
contents, file_path = self.Storage.upload_file(self.file_bytesio, self.filename)
|
||||
assert (upload_dir / self.filename).exists()
|
||||
assert (upload_dir / self.filename).read_bytes() == self.file_content
|
||||
assert contents == self.file_content
|
||||
assert file_path == str(upload_dir / self.filename)
|
||||
with pytest.raises(ValueError):
|
||||
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
|
||||
|
||||
def test_get_file(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
file_path = str(upload_dir / self.filename)
|
||||
file_path_return = self.Storage.get_file(file_path)
|
||||
assert file_path == file_path_return
|
||||
|
||||
def test_delete_file(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
(upload_dir / self.filename).write_bytes(self.file_content)
|
||||
assert (upload_dir / self.filename).exists()
|
||||
file_path = str(upload_dir / self.filename)
|
||||
self.Storage.delete_file(file_path)
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
|
||||
def test_delete_all_files(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
(upload_dir / self.filename).write_bytes(self.file_content)
|
||||
(upload_dir / self.filename_extra).write_bytes(self.file_content)
|
||||
self.Storage.delete_all_files()
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
assert not (upload_dir / self.filename_extra).exists()
|
||||
|
||||
|
||||
@mock_aws
|
||||
class TestS3StorageProvider:
|
||||
|
||||
def __init__(self):
|
||||
self.Storage = provider.S3StorageProvider()
|
||||
self.Storage.bucket_name = "my-bucket"
|
||||
self.s3_client = boto3.resource("s3", region_name="us-east-1")
|
||||
self.file_content = b"test content"
|
||||
self.filename = "test.txt"
|
||||
self.filename_extra = "test_exyta.txt"
|
||||
self.file_bytesio_empty = io.BytesIO()
|
||||
super().__init__()
|
||||
|
||||
def test_upload_file(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
# S3 checks
|
||||
with pytest.raises(Exception):
|
||||
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
|
||||
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
|
||||
contents, s3_file_path = self.Storage.upload_file(
|
||||
io.BytesIO(self.file_content), self.filename
|
||||
)
|
||||
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
|
||||
assert self.file_content == object.get()["Body"].read()
|
||||
# local checks
|
||||
assert (upload_dir / self.filename).exists()
|
||||
assert (upload_dir / self.filename).read_bytes() == self.file_content
|
||||
assert contents == self.file_content
|
||||
assert s3_file_path == "s3://" + self.Storage.bucket_name + "/" + self.filename
|
||||
with pytest.raises(ValueError):
|
||||
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
|
||||
|
||||
def test_get_file(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
|
||||
contents, s3_file_path = self.Storage.upload_file(
|
||||
io.BytesIO(self.file_content), self.filename
|
||||
)
|
||||
file_path = self.Storage.get_file(s3_file_path)
|
||||
assert file_path == str(upload_dir / self.filename)
|
||||
assert (upload_dir / self.filename).exists()
|
||||
|
||||
def test_delete_file(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
|
||||
contents, s3_file_path = self.Storage.upload_file(
|
||||
io.BytesIO(self.file_content), self.filename
|
||||
)
|
||||
assert (upload_dir / self.filename).exists()
|
||||
self.Storage.delete_file(s3_file_path)
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
with pytest.raises(ClientError) as exc:
|
||||
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
|
||||
error = exc.value.response["Error"]
|
||||
assert error["Code"] == "404"
|
||||
assert error["Message"] == "Not Found"
|
||||
|
||||
def test_delete_all_files(self, monkeypatch, tmp_path):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
# create 2 files
|
||||
self.s3_client.create_bucket(Bucket=self.Storage.bucket_name)
|
||||
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
|
||||
object = self.s3_client.Object(self.Storage.bucket_name, self.filename)
|
||||
assert self.file_content == object.get()["Body"].read()
|
||||
assert (upload_dir / self.filename).exists()
|
||||
assert (upload_dir / self.filename).read_bytes() == self.file_content
|
||||
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
|
||||
object = self.s3_client.Object(self.Storage.bucket_name, self.filename_extra)
|
||||
assert self.file_content == object.get()["Body"].read()
|
||||
assert (upload_dir / self.filename).exists()
|
||||
assert (upload_dir / self.filename).read_bytes() == self.file_content
|
||||
|
||||
self.Storage.delete_all_files()
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
with pytest.raises(ClientError) as exc:
|
||||
self.s3_client.Object(self.Storage.bucket_name, self.filename).load()
|
||||
error = exc.value.response["Error"]
|
||||
assert error["Code"] == "404"
|
||||
assert error["Message"] == "Not Found"
|
||||
assert not (upload_dir / self.filename_extra).exists()
|
||||
with pytest.raises(ClientError) as exc:
|
||||
self.s3_client.Object(self.Storage.bucket_name, self.filename_extra).load()
|
||||
error = exc.value.response["Error"]
|
||||
assert error["Code"] == "404"
|
||||
assert error["Message"] == "Not Found"
|
||||
|
||||
self.Storage.delete_all_files()
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
assert not (upload_dir / self.filename_extra).exists()
|
||||
|
||||
|
||||
class TestGCSStorageProvider:
|
||||
Storage = provider.GCSStorageProvider()
|
||||
Storage.bucket_name = "my-bucket"
|
||||
file_content = b"test content"
|
||||
filename = "test.txt"
|
||||
filename_extra = "test_exyta.txt"
|
||||
file_bytesio_empty = io.BytesIO()
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def setup(self):
|
||||
host, port = "localhost", 9023
|
||||
|
||||
server = create_server(host, port, in_memory=True)
|
||||
server.start()
|
||||
os.environ["STORAGE_EMULATOR_HOST"] = f"http://{host}:{port}"
|
||||
|
||||
gcs_client = storage.Client()
|
||||
bucket = gcs_client.bucket(self.Storage.bucket_name)
|
||||
bucket.create()
|
||||
self.Storage.gcs_client, self.Storage.bucket = gcs_client, bucket
|
||||
yield
|
||||
bucket.delete(force=True)
|
||||
server.stop()
|
||||
|
||||
def test_upload_file(self, monkeypatch, tmp_path, setup):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
# catch error if bucket does not exist
|
||||
with pytest.raises(Exception):
|
||||
self.Storage.bucket = monkeypatch(self.Storage, "bucket", None)
|
||||
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
|
||||
contents, gcs_file_path = self.Storage.upload_file(
|
||||
io.BytesIO(self.file_content), self.filename
|
||||
)
|
||||
object = self.Storage.bucket.get_blob(self.filename)
|
||||
assert self.file_content == object.download_as_bytes()
|
||||
# local checks
|
||||
assert (upload_dir / self.filename).exists()
|
||||
assert (upload_dir / self.filename).read_bytes() == self.file_content
|
||||
assert contents == self.file_content
|
||||
assert gcs_file_path == "gs://" + self.Storage.bucket_name + "/" + self.filename
|
||||
# test error if file is empty
|
||||
with pytest.raises(ValueError):
|
||||
self.Storage.upload_file(self.file_bytesio_empty, self.filename)
|
||||
|
||||
def test_get_file(self, monkeypatch, tmp_path, setup):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
contents, gcs_file_path = self.Storage.upload_file(
|
||||
io.BytesIO(self.file_content), self.filename
|
||||
)
|
||||
file_path = self.Storage.get_file(gcs_file_path)
|
||||
assert file_path == str(upload_dir / self.filename)
|
||||
assert (upload_dir / self.filename).exists()
|
||||
|
||||
def test_delete_file(self, monkeypatch, tmp_path, setup):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
contents, gcs_file_path = self.Storage.upload_file(
|
||||
io.BytesIO(self.file_content), self.filename
|
||||
)
|
||||
# ensure that local directory has the uploaded file as well
|
||||
assert (upload_dir / self.filename).exists()
|
||||
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
|
||||
self.Storage.delete_file(gcs_file_path)
|
||||
# check that deleting file from gcs will delete the local file as well
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
assert self.Storage.bucket.get_blob(self.filename) == None
|
||||
|
||||
def test_delete_all_files(self, monkeypatch, tmp_path, setup):
|
||||
upload_dir = mock_upload_dir(monkeypatch, tmp_path)
|
||||
# create 2 files
|
||||
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename)
|
||||
object = self.Storage.bucket.get_blob(self.filename)
|
||||
assert (upload_dir / self.filename).exists()
|
||||
assert (upload_dir / self.filename).read_bytes() == self.file_content
|
||||
assert self.Storage.bucket.get_blob(self.filename).name == self.filename
|
||||
assert self.file_content == object.download_as_bytes()
|
||||
self.Storage.upload_file(io.BytesIO(self.file_content), self.filename_extra)
|
||||
object = self.Storage.bucket.get_blob(self.filename_extra)
|
||||
assert (upload_dir / self.filename_extra).exists()
|
||||
assert (upload_dir / self.filename_extra).read_bytes() == self.file_content
|
||||
assert (
|
||||
self.Storage.bucket.get_blob(self.filename_extra).name
|
||||
== self.filename_extra
|
||||
)
|
||||
assert self.file_content == object.download_as_bytes()
|
||||
|
||||
self.Storage.delete_all_files()
|
||||
assert not (upload_dir / self.filename).exists()
|
||||
assert not (upload_dir / self.filename_extra).exists()
|
||||
assert self.Storage.bucket.get_blob(self.filename) == None
|
||||
assert self.Storage.bucket.get_blob(self.filename_extra) == None
|
||||
@@ -1,9 +1,30 @@
|
||||
from typing import Optional, Union, List, Dict, Any
|
||||
from open_webui.models.users import Users, UserModel
|
||||
from open_webui.models.groups import Groups
|
||||
|
||||
|
||||
from open_webui.config import DEFAULT_USER_PERMISSIONS
|
||||
import json
|
||||
|
||||
|
||||
def fill_missing_permissions(
|
||||
permissions: Dict[str, Any], default_permissions: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Recursively fills in missing properties in the permissions dictionary
|
||||
using the default permissions as a template.
|
||||
"""
|
||||
for key, value in default_permissions.items():
|
||||
if key not in permissions:
|
||||
permissions[key] = value
|
||||
elif isinstance(value, dict) and isinstance(
|
||||
permissions[key], dict
|
||||
): # Both are nested dictionaries
|
||||
permissions[key] = fill_missing_permissions(permissions[key], value)
|
||||
|
||||
return permissions
|
||||
|
||||
|
||||
def get_permissions(
|
||||
user_id: str,
|
||||
default_permissions: Dict[str, Any],
|
||||
@@ -27,39 +48,45 @@ def get_permissions(
|
||||
if key not in permissions:
|
||||
permissions[key] = value
|
||||
else:
|
||||
permissions[key] = permissions[key] or value
|
||||
permissions[key] = (
|
||||
permissions[key] or value
|
||||
) # Use the most permissive value (True > False)
|
||||
return permissions
|
||||
|
||||
user_groups = Groups.get_groups_by_member_id(user_id)
|
||||
|
||||
# deep copy default permissions to avoid modifying the original dict
|
||||
# Deep copy default permissions to avoid modifying the original dict
|
||||
permissions = json.loads(json.dumps(default_permissions))
|
||||
|
||||
# Combine permissions from all user groups
|
||||
for group in user_groups:
|
||||
group_permissions = group.permissions
|
||||
permissions = combine_permissions(permissions, group_permissions)
|
||||
|
||||
# Ensure all fields from default_permissions are present and filled in
|
||||
permissions = fill_missing_permissions(permissions, default_permissions)
|
||||
|
||||
return permissions
|
||||
|
||||
|
||||
def has_permission(
|
||||
user_id: str,
|
||||
permission_key: str,
|
||||
default_permissions: Dict[str, bool] = {},
|
||||
default_permissions: Dict[str, Any] = {},
|
||||
) -> bool:
|
||||
"""
|
||||
Check if a user has a specific permission by checking the group permissions
|
||||
and falls back to default permissions if not found in any group.
|
||||
and fall back to default permissions if not found in any group.
|
||||
|
||||
Permission keys can be hierarchical and separated by dots ('.').
|
||||
"""
|
||||
|
||||
def get_permission(permissions: Dict[str, bool], keys: List[str]) -> bool:
|
||||
def get_permission(permissions: Dict[str, Any], keys: List[str]) -> bool:
|
||||
"""Traverse permissions dict using a list of keys (from dot-split permission_key)."""
|
||||
for key in keys:
|
||||
if key not in permissions:
|
||||
return False # If any part of the hierarchy is missing, deny access
|
||||
permissions = permissions[key] # Go one level deeper
|
||||
permissions = permissions[key] # Traverse one level deeper
|
||||
|
||||
return bool(permissions) # Return the boolean at the final level
|
||||
|
||||
@@ -73,7 +100,10 @@ def has_permission(
|
||||
if get_permission(group_permissions, permission_hierarchy):
|
||||
return True
|
||||
|
||||
# Check default permissions afterwards if the group permissions don't allow it
|
||||
# Check default permissions afterward if the group permissions don't allow it
|
||||
default_permissions = fill_missing_permissions(
|
||||
default_permissions, DEFAULT_USER_PERMISSIONS
|
||||
)
|
||||
return get_permission(default_permissions, permission_hierarchy)
|
||||
|
||||
|
||||
|
||||
@@ -96,11 +96,18 @@ def get_current_user(
|
||||
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.API_KEY_NOT_ALLOWED
|
||||
)
|
||||
|
||||
allowed_paths = ["/api/models", "/api/chat/completions"]
|
||||
if request.url.path not in allowed_paths:
|
||||
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)
|
||||
|
||||
|
||||
@@ -157,6 +157,9 @@ async def generate_chat_completion(
|
||||
)
|
||||
|
||||
|
||||
chat_completion = generate_chat_completion
|
||||
|
||||
|
||||
async def chat_completed(request: Request, form_data: dict, user: Any):
|
||||
if not request.app.state.MODELS:
|
||||
await get_all_models(request)
|
||||
@@ -179,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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -187,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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -310,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(
|
||||
@@ -317,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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -23,14 +23,18 @@ from open_webui.models.users import Users
|
||||
from open_webui.socket.main import (
|
||||
get_event_call,
|
||||
get_event_emitter,
|
||||
get_user_id_from_session_pool,
|
||||
get_active_status_by_user_id,
|
||||
)
|
||||
from open_webui.routers.tasks import (
|
||||
generate_queries,
|
||||
generate_title,
|
||||
generate_image_prompt,
|
||||
generate_chat_tags,
|
||||
)
|
||||
from open_webui.routers.retrieval import process_web_search, SearchForm
|
||||
from open_webui.routers.images import image_generations, GenerateImageForm
|
||||
|
||||
|
||||
from open_webui.utils.webhook import post_webhook
|
||||
|
||||
|
||||
@@ -65,6 +69,7 @@ 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
|
||||
|
||||
@@ -485,6 +490,100 @@ async def chat_web_search_handler(
|
||||
return form_data
|
||||
|
||||
|
||||
async def chat_image_generation_handler(
|
||||
request: Request, form_data: dict, extra_params: dict, user
|
||||
):
|
||||
__event_emitter__ = extra_params["__event_emitter__"]
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "Generating an image", "done": False},
|
||||
}
|
||||
)
|
||||
|
||||
messages = form_data["messages"]
|
||||
user_message = get_last_user_message(messages)
|
||||
|
||||
prompt = user_message
|
||||
negative_prompt = ""
|
||||
|
||||
if request.app.state.config.ENABLE_IMAGE_PROMPT_GENERATION:
|
||||
try:
|
||||
res = await generate_image_prompt(
|
||||
request,
|
||||
{
|
||||
"model": form_data["model"],
|
||||
"messages": messages,
|
||||
},
|
||||
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]
|
||||
response = json.loads(response)
|
||||
prompt = response.get("prompt", [])
|
||||
except Exception as e:
|
||||
prompt = user_message
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
prompt = user_message
|
||||
|
||||
system_message_content = ""
|
||||
|
||||
try:
|
||||
images = await image_generations(
|
||||
request=request,
|
||||
form_data=GenerateImageForm(**{"prompt": prompt}),
|
||||
user=user,
|
||||
)
|
||||
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {"description": "Generated an image", "done": True},
|
||||
}
|
||||
)
|
||||
|
||||
for image in images:
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "message",
|
||||
"data": {"content": f""},
|
||||
}
|
||||
)
|
||||
|
||||
system_message_content = "<context>User is shown the generated image, tell the user that the image has been generated</context>"
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
await __event_emitter__(
|
||||
{
|
||||
"type": "status",
|
||||
"data": {
|
||||
"description": f"An error occured while generating an image",
|
||||
"done": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
system_message_content = "<context>Unable to generate an image, tell the user that an error occured</context>"
|
||||
|
||||
if system_message_content:
|
||||
form_data["messages"] = add_or_update_system_message(
|
||||
system_message_content, form_data["messages"]
|
||||
)
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
async def chat_completion_files_handler(
|
||||
request: Request, body: dict, user: UserModel
|
||||
) -> tuple[dict, dict[str, list]]:
|
||||
@@ -493,6 +592,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"],
|
||||
@@ -521,17 +621,28 @@ async def chat_completion_files_handler(
|
||||
if len(queries) == 0:
|
||||
queries = [get_last_user_message(body["messages"])]
|
||||
|
||||
sources = get_sources_from_files(
|
||||
files=files,
|
||||
queries=queries,
|
||||
embedding_function=request.app.state.EMBEDDING_FUNCTION,
|
||||
k=request.app.state.config.TOP_K,
|
||||
reranking_function=request.app.state.rf,
|
||||
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||
)
|
||||
try:
|
||||
# Offload get_sources_from_files to a separate thread
|
||||
loop = asyncio.get_running_loop()
|
||||
with ThreadPoolExecutor() as executor:
|
||||
sources = await loop.run_in_executor(
|
||||
executor,
|
||||
lambda: get_sources_from_files(
|
||||
files=files,
|
||||
queries=queries,
|
||||
embedding_function=request.app.state.EMBEDDING_FUNCTION,
|
||||
k=request.app.state.config.TOP_K,
|
||||
reranking_function=request.app.state.rf,
|
||||
r=request.app.state.config.RELEVANCE_THRESHOLD,
|
||||
hybrid_search=request.app.state.config.ENABLE_RAG_HYBRID_SEARCH,
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log.exception(e)
|
||||
|
||||
log.debug(f"rag_contexts:sources: {sources}")
|
||||
|
||||
return body, {"sources": sources}
|
||||
|
||||
|
||||
@@ -560,7 +671,6 @@ def apply_params_to_form_data(form_data, model):
|
||||
|
||||
if "frequency_penalty" in params:
|
||||
form_data["frequency_penalty"] = params["frequency_penalty"]
|
||||
|
||||
return form_data
|
||||
|
||||
|
||||
@@ -639,12 +749,17 @@ async def process_chat_payload(request, form_data, metadata, user, model):
|
||||
request, form_data, extra_params, user
|
||||
)
|
||||
|
||||
if "image_generation" in features and features["image_generation"]:
|
||||
form_data = await chat_image_generation_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)
|
||||
@@ -748,14 +863,101 @@ async def process_chat_payload(request, form_data, metadata, user, model):
|
||||
async def process_chat_response(
|
||||
request, response, form_data, user, events, metadata, tasks
|
||||
):
|
||||
if not isinstance(response, StreamingResponse):
|
||||
return response
|
||||
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 not any(
|
||||
content_type in response.headers["Content-Type"]
|
||||
for content_type in ["text/event-stream", "application/x-ndjson"]
|
||||
):
|
||||
return response
|
||||
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):
|
||||
if len(res.get("choices", [])) == 1:
|
||||
title = (
|
||||
res.get("choices", [])[0]
|
||||
.get("message", {})
|
||||
.get(
|
||||
"content",
|
||||
message.get("content", "New Chat"),
|
||||
)
|
||||
).strip()
|
||||
else:
|
||||
title = None
|
||||
|
||||
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):
|
||||
if len(res.get("choices", [])) == 1:
|
||||
tags_string = (
|
||||
res.get("choices", [])[0]
|
||||
.get("message", {})
|
||||
.get("content", "")
|
||||
)
|
||||
else:
|
||||
tags_string = ""
|
||||
|
||||
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:
|
||||
pass
|
||||
|
||||
event_emitter = None
|
||||
if (
|
||||
@@ -768,12 +970,90 @@ async def process_chat_response(
|
||||
):
|
||||
event_emitter = get_event_emitter(metadata)
|
||||
|
||||
if not isinstance(response, StreamingResponse):
|
||||
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,
|
||||
},
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
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
|
||||
|
||||
if event_emitter:
|
||||
|
||||
task_id = str(uuid4()) # Create a unique task ID.
|
||||
|
||||
# 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 ""
|
||||
|
||||
try:
|
||||
for event in events:
|
||||
await event_emitter(
|
||||
@@ -792,8 +1072,15 @@ async def process_chat_response(
|
||||
},
|
||||
)
|
||||
|
||||
assistant_message = get_last_assistant_message(form_data["messages"])
|
||||
content = assistant_message if assistant_message else ""
|
||||
# We might want to disable this by default
|
||||
detect_reasoning = True
|
||||
reasoning_tags = ["think", "reason", "reasoning", "thought"]
|
||||
current_tag = None
|
||||
|
||||
reasoning_start_time = None
|
||||
|
||||
reasoning_content = ""
|
||||
ongoing_content = ""
|
||||
|
||||
async for line in response.body_iterator:
|
||||
line = line.decode("utf-8") if isinstance(line, bytes) else line
|
||||
@@ -803,12 +1090,12 @@ async def process_chat_response(
|
||||
if not data.strip():
|
||||
continue
|
||||
|
||||
# "data: " is the prefix for each event
|
||||
if not data.startswith("data: "):
|
||||
# "data:" is the prefix for each event
|
||||
if not data.startswith("data:"):
|
||||
continue
|
||||
|
||||
# Remove the prefix
|
||||
data = data[len("data: ") :]
|
||||
data = data[len("data:") :].strip()
|
||||
|
||||
try:
|
||||
data = json.loads(data)
|
||||
@@ -821,9 +1108,7 @@ async def process_chat_response(
|
||||
"selectedModelId": data["selected_model_id"],
|
||||
},
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
value = (
|
||||
data.get("choices", [])[0]
|
||||
.get("delta", {})
|
||||
@@ -833,147 +1118,150 @@ async def process_chat_response(
|
||||
if value:
|
||||
content = f"{content}{value}"
|
||||
|
||||
# Save message in the database
|
||||
Chats.upsert_message_to_chat_by_id_and_message_id(
|
||||
metadata["chat_id"],
|
||||
metadata["message_id"],
|
||||
{
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
if detect_reasoning:
|
||||
for tag in reasoning_tags:
|
||||
start_tag = f"<{tag}>\n"
|
||||
end_tag = f"</{tag}>\n"
|
||||
|
||||
except Exception as e:
|
||||
done = "data: [DONE]" in line
|
||||
title = Chats.get_chat_title_by_id(metadata["chat_id"])
|
||||
if start_tag in content:
|
||||
# Remove the start tag
|
||||
content = content.replace(start_tag, "")
|
||||
ongoing_content = content
|
||||
|
||||
if done:
|
||||
data = {"done": True, "content": content, "title": title}
|
||||
reasoning_start_time = time.time()
|
||||
reasoning_content = ""
|
||||
|
||||
# Send a webhook notification if the user is not active
|
||||
if (
|
||||
get_user_id_from_session_pool(metadata["session_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}",
|
||||
current_tag = tag
|
||||
break
|
||||
|
||||
if reasoning_start_time is not None:
|
||||
# Remove the last value from the content
|
||||
content = content[: -len(value)]
|
||||
|
||||
reasoning_content += value
|
||||
|
||||
end_tag = f"</{current_tag}>\n"
|
||||
if end_tag in reasoning_content:
|
||||
reasoning_end_time = time.time()
|
||||
reasoning_duration = int(
|
||||
reasoning_end_time
|
||||
- reasoning_start_time
|
||||
)
|
||||
reasoning_content = (
|
||||
reasoning_content.strip(
|
||||
f"<{current_tag}>\n"
|
||||
)
|
||||
.strip(end_tag)
|
||||
.strip()
|
||||
)
|
||||
|
||||
if reasoning_content:
|
||||
reasoning_display_content = "\n".join(
|
||||
(
|
||||
f"> {line}"
|
||||
if not line.startswith(">")
|
||||
else line
|
||||
)
|
||||
for line in reasoning_content.splitlines()
|
||||
)
|
||||
|
||||
# Format reasoning with <details> tag
|
||||
content = f'{ongoing_content}<details type="reasoning" done="true">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
|
||||
else:
|
||||
content = ""
|
||||
|
||||
reasoning_start_time = None
|
||||
else:
|
||||
|
||||
reasoning_display_content = "\n".join(
|
||||
(
|
||||
f"> {line}"
|
||||
if not line.startswith(">")
|
||||
else line
|
||||
)
|
||||
for line in reasoning_content.splitlines()
|
||||
)
|
||||
|
||||
# Show ongoing thought process
|
||||
content = f'{ongoing_content}<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
|
||||
|
||||
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"],
|
||||
{
|
||||
"action": "chat",
|
||||
"message": content,
|
||||
"title": title,
|
||||
"url": f"{request.app.state.config.WEBUI_URL}/c/{metadata['chat_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
|
||||
|
||||
await event_emitter(
|
||||
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"],
|
||||
{
|
||||
"type": "chat:completion",
|
||||
"data": data,
|
||||
}
|
||||
"content": content,
|
||||
},
|
||||
)
|
||||
|
||||
message_map = Chats.get_messages_by_chat_id(metadata["chat_id"])
|
||||
message = message_map.get(metadata["message_id"])
|
||||
# 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']}",
|
||||
},
|
||||
)
|
||||
|
||||
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"),
|
||||
)
|
||||
)
|
||||
|
||||
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}")
|
||||
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()
|
||||
|
||||
@@ -997,4 +1285,5 @@ async def process_chat_response(
|
||||
return StreamingResponse(
|
||||
stream_wrapper(response.body_iterator, events),
|
||||
headers=dict(response.headers),
|
||||
background=response.background,
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ from open_webui.config import (
|
||||
JWT_EXPIRES_IN,
|
||||
AppConfig,
|
||||
)
|
||||
from open_webui.constants import ERROR_MESSAGES
|
||||
from open_webui.constants import ERROR_MESSAGES, WEBHOOK_MESSAGES
|
||||
from open_webui.env import WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE
|
||||
from open_webui.utils.misc import parse_duration
|
||||
from open_webui.utils.auth import get_password_hash, create_token
|
||||
@@ -63,17 +63,8 @@ auth_manager_config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
|
||||
class OAuthManager:
|
||||
def __init__(self):
|
||||
self.oauth = OAuth()
|
||||
for provider_name, provider_config in OAUTH_PROVIDERS.items():
|
||||
self.oauth.register(
|
||||
name=provider_name,
|
||||
client_id=provider_config["client_id"],
|
||||
client_secret=provider_config["client_secret"],
|
||||
server_metadata_url=provider_config["server_metadata_url"],
|
||||
client_kwargs={
|
||||
"scope": provider_config["scope"],
|
||||
},
|
||||
redirect_uri=provider_config["redirect_uri"],
|
||||
)
|
||||
for _, provider_config in OAUTH_PROVIDERS.items():
|
||||
provider_config["register"](self.oauth)
|
||||
|
||||
def get_client(self, provider_name):
|
||||
return self.oauth.create_client(provider_name)
|
||||
@@ -200,14 +191,14 @@ class OAuthManager:
|
||||
except Exception as e:
|
||||
log.warning(f"OAuth callback error: {e}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
user_data: UserInfo = token["userinfo"]
|
||||
user_data: UserInfo = token.get("userinfo")
|
||||
if not user_data:
|
||||
user_data: UserInfo = await client.userinfo(token=token)
|
||||
if not user_data:
|
||||
log.warning(f"OAuth callback failed, user data is missing: {token}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
|
||||
sub = user_data.get("sub")
|
||||
sub = user_data.get(OAUTH_PROVIDERS[provider].get("sub_claim", "sub"))
|
||||
if not sub:
|
||||
log.warning(f"OAuth callback failed, sub is missing: {user_data}")
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_CRED)
|
||||
@@ -255,12 +246,20 @@ class OAuthManager:
|
||||
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
|
||||
|
||||
picture_claim = auth_manager_config.OAUTH_PICTURE_CLAIM
|
||||
picture_url = user_data.get(picture_claim, "")
|
||||
picture_url = user_data.get(
|
||||
picture_claim, OAUTH_PROVIDERS[provider].get("picture_url", "")
|
||||
)
|
||||
if picture_url:
|
||||
# Download the profile image into a base64 string
|
||||
try:
|
||||
access_token = token.get("access_token")
|
||||
get_kwargs = {}
|
||||
if access_token:
|
||||
get_kwargs["headers"] = {
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(picture_url) as resp:
|
||||
async with session.get(picture_url, **get_kwargs) as resp:
|
||||
picture = await resp.read()
|
||||
base64_encoded_picture = base64.b64encode(
|
||||
picture
|
||||
@@ -295,12 +294,10 @@ class OAuthManager:
|
||||
if auth_manager_config.WEBHOOK_URL:
|
||||
post_webhook(
|
||||
auth_manager_config.WEBHOOK_URL,
|
||||
auth_manager_config.WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
{
|
||||
"action": "signup",
|
||||
"message": auth_manager_config.WEBHOOK_MESSAGES.USER_SIGNUP(
|
||||
user.name
|
||||
),
|
||||
"message": WEBHOOK_MESSAGES.USER_SIGNUP(user.name),
|
||||
"user": user.model_dump_json(exclude_none=True),
|
||||
},
|
||||
)
|
||||
@@ -314,7 +311,7 @@ class OAuthManager:
|
||||
expires_delta=parse_duration(auth_manager_config.JWT_EXPIRES_IN),
|
||||
)
|
||||
|
||||
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT:
|
||||
if auth_manager_config.ENABLE_OAUTH_GROUP_MANAGEMENT and user.role != "admin":
|
||||
self.update_user_groups(
|
||||
user=user,
|
||||
user_data=user_data,
|
||||
|
||||
@@ -160,6 +160,10 @@ def convert_payload_openai_to_ollama(openai_payload: dict) -> dict:
|
||||
# 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:
|
||||
|
||||
@@ -53,6 +53,7 @@ class PDFGenerator:
|
||||
# - https://facelessuser.github.io/pymdown-extensions/usage_notes/
|
||||
# html_content = markdown(content, extensions=["pymdownx.extra"])
|
||||
|
||||
content = content.replace("\n", "<br/>")
|
||||
html_message = f"""
|
||||
<div>
|
||||
<div>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -217,6 +217,24 @@ def tags_generation_template(
|
||||
return template
|
||||
|
||||
|
||||
def image_prompt_generation_template(
|
||||
template: str, messages: list[dict], user: Optional[dict] = None
|
||||
) -> str:
|
||||
prompt = get_last_user_message(messages)
|
||||
template = replace_prompt_variable(template, prompt)
|
||||
template = replace_messages_variable(template, messages)
|
||||
|
||||
template = prompt_template(
|
||||
template,
|
||||
**(
|
||||
{"user_name": user.get("name"), "user_location": user.get("location")}
|
||||
if user
|
||||
else {}
|
||||
),
|
||||
)
|
||||
return template
|
||||
|
||||
|
||||
def emoji_generation_template(
|
||||
template: str, prompt: str, user: Optional[dict] = None
|
||||
) -> str:
|
||||
|
||||
@@ -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
|
||||
@@ -40,14 +40,15 @@ tiktoken
|
||||
|
||||
langchain==0.3.7
|
||||
langchain-community==0.3.7
|
||||
langchain-chroma==0.1.4
|
||||
|
||||
fake-useragent==1.5.1
|
||||
chromadb==0.5.15
|
||||
chromadb==0.6.2
|
||||
pymilvus==2.5.0
|
||||
qdrant-client~=1.12.0
|
||||
opensearch-py==2.7.1
|
||||
|
||||
|
||||
transformers
|
||||
sentence-transformers==3.3.1
|
||||
colbert-ai==0.2.21
|
||||
einops==0.8.0
|
||||
@@ -55,7 +56,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 +68,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 +79,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
|
||||
@@ -88,7 +89,7 @@ pytube==15.0.0
|
||||
|
||||
extract_msg
|
||||
pydub
|
||||
duckduckgo-search~=6.3.5
|
||||
duckduckgo-search~=7.2.1
|
||||
|
||||
## Google Drive
|
||||
google-api-python-client
|
||||
@@ -101,6 +102,7 @@ pytest~=8.3.2
|
||||
pytest-docker~=3.1.1
|
||||
|
||||
googleapis-common-protos==1.63.2
|
||||
google-cloud-storage==2.19.0
|
||||
|
||||
## LDAP
|
||||
ldap3==2.9.1
|
||||
|
||||
@@ -16,6 +16,9 @@ For the UI configuration, you can set up the Apache VirtualHost as follows:
|
||||
|
||||
ProxyPass / http://server.com:3000/ nocanon
|
||||
ProxyPassReverse / http://server.com:3000/
|
||||
# Needed after 0.5
|
||||
ProxyPass / ws://server.com:3000/ nocanon
|
||||
ProxyPassReverse / ws://server.com:3000/
|
||||
|
||||
</VirtualHost>
|
||||
```
|
||||
@@ -32,6 +35,9 @@ Enable the site first before you can request SSL:
|
||||
|
||||
ProxyPass / http://server.com:3000/ nocanon
|
||||
ProxyPassReverse / http://server.com:3000/
|
||||
# Needed after 0.5
|
||||
ProxyPass / ws://server.com:3000/ nocanon
|
||||
ProxyPassReverse / ws://server.com:3000/
|
||||
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/ssl/virtualmin/170514456861234/ssl.cert
|
||||
|
||||
Generated
+19
-10
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "open-webui",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.5",
|
||||
"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",
|
||||
@@ -40,7 +41,7 @@
|
||||
"i18next-resources-to-backend": "^1.2.0",
|
||||
"idb": "^7.1.1",
|
||||
"js-sha256": "^0.10.1",
|
||||
"katex": "^0.16.9",
|
||||
"katex": "^0.16.21",
|
||||
"marked": "^9.1.0",
|
||||
"mermaid": "^10.9.3",
|
||||
"paneforge": "^0.0.6",
|
||||
@@ -88,7 +89,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5",
|
||||
"vite": "^5.4.14",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -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",
|
||||
@@ -7103,13 +7110,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.10",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.10.tgz",
|
||||
"integrity": "sha512-ZiqaC04tp2O5utMsl2TEZTXxa6WSC4yo0fv5ML++D3QZv/vx2Mct0mTlRx3O+uUkjfuAgOkzsCmq5MiUEsDDdA==",
|
||||
"version": "0.16.21",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.21.tgz",
|
||||
"integrity": "sha512-XvqR7FgOHtWupfMiigNzmh+MgUVmDGU2kXZm899ZkPfcuoPuFxyHmXsgATDpFZDAXCI8tvinaVcDo8PIIJSo4A==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
},
|
||||
@@ -11670,9 +11678,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz",
|
||||
"integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==",
|
||||
"version": "5.4.14",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz",
|
||||
"integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
|
||||
+6
-4
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"name": "open-webui",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run pyodide:fetch && vite dev --host",
|
||||
"dev:5050": "npm run pyodide:fetch && vite dev --port 5050",
|
||||
"build": "npm run pyodide:fetch && vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
@@ -44,13 +45,12 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5",
|
||||
"vite": "^5.4.14",
|
||||
"vitest": "^1.6.0"
|
||||
},
|
||||
"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 +58,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 +70,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",
|
||||
@@ -81,7 +83,7 @@
|
||||
"i18next-resources-to-backend": "^1.2.0",
|
||||
"idb": "^7.1.1",
|
||||
"js-sha256": "^0.10.1",
|
||||
"katex": "^0.16.9",
|
||||
"katex": "^0.16.21",
|
||||
"marked": "^9.1.0",
|
||||
"mermaid": "^10.9.3",
|
||||
"paneforge": "^0.0.6",
|
||||
|
||||
+23
-9
@@ -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",
|
||||
@@ -47,21 +47,21 @@ dependencies = [
|
||||
|
||||
"langchain==0.3.7",
|
||||
"langchain-community==0.3.7",
|
||||
"langchain-chroma==0.1.4",
|
||||
|
||||
"fake-useragent==1.5.1",
|
||||
"chromadb==0.5.15",
|
||||
"chromadb==0.6.2",
|
||||
"pymilvus==2.5.0",
|
||||
"qdrant-client~=1.12.0",
|
||||
"opensearch-py==2.7.1",
|
||||
|
||||
"transformers",
|
||||
"sentence-transformers==3.3.1",
|
||||
"colbert-ai==0.2.21",
|
||||
"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",
|
||||
@@ -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",
|
||||
@@ -94,15 +94,22 @@ dependencies = [
|
||||
|
||||
"extract_msg",
|
||||
"pydub",
|
||||
"duckduckgo-search~=6.3.5",
|
||||
"duckduckgo-search~=7.2.1",
|
||||
|
||||
"google-api-python-client",
|
||||
"google-auth-httplib2",
|
||||
"google-auth-oauthlib",
|
||||
|
||||
"docker~=7.1.0",
|
||||
"pytest~=8.3.2",
|
||||
"pytest-docker~=3.1.1",
|
||||
"moto[s3]>=5.0.26",
|
||||
|
||||
"googleapis-common-protos==1.63.2",
|
||||
"google-cloud-storage==2.19.0",
|
||||
|
||||
"ldap3==2.9.1"
|
||||
"ldap3==2.9.1",
|
||||
"gcp-storage-emulator>=2024.8.3",
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.11, < 3.13.0a1"
|
||||
@@ -151,3 +158,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'
|
||||
|
||||
+61
-4
@@ -53,11 +53,11 @@ math {
|
||||
}
|
||||
|
||||
.markdown-prose {
|
||||
@apply prose dark:prose-invert prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
@apply prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal prose-headings:font-semibold prose-hr:my-4 prose-hr:border-gray-100 prose-hr:dark:border-gray-800 prose-p:my-0 prose-img:my-1 prose-headings:my-1 prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 whitespace-pre-line;
|
||||
}
|
||||
|
||||
.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;
|
||||
@apply text-xs prose dark:prose-invert prose-blockquote:border-gray-100 prose-blockquote:dark:border-gray-800 prose-blockquote:border-l-2 prose-blockquote:not-italic prose-blockquote:font-normal 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 {
|
||||
@@ -68,6 +68,19 @@ math {
|
||||
font-family: 'Archivo', sans-serif;
|
||||
}
|
||||
|
||||
.drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.drag-region a,
|
||||
.drag-region button {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.no-drag-region {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
iframe {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
@@ -102,18 +115,62 @@ li p {
|
||||
|
||||
select {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0.5rem center;
|
||||
background-position: right 0rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
padding-right: 2.5rem;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
/* padding-right: 2.5rem; */
|
||||
/* for Firefox */
|
||||
-moz-appearance: none;
|
||||
/* for Chrome */
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.shimmer {
|
||||
background: linear-gradient(90deg, #9a9b9e 25%, #2a2929 50%, #9a9b9e 75%);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 4s linear infinite;
|
||||
color: #818286; /* Fallback color */
|
||||
}
|
||||
|
||||
:global(.dark) .shimmer {
|
||||
background: linear-gradient(90deg, #818286 25%, #eae5e5 50%, #818286 75%);
|
||||
background-size: 200% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: shimmer 4s linear infinite;
|
||||
color: #a1a3a7; /* Darker fallback color for dark mode */
|
||||
}
|
||||
|
||||
@keyframes smoothFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.status-description {
|
||||
animation: smoothFadeIn 0.2s forwards;
|
||||
}
|
||||
|
||||
.katex-mathml {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WEBUI_API_BASE_URL } from '$lib/constants';
|
||||
import { t } from 'i18next';
|
||||
|
||||
type ChannelForm = {
|
||||
name: string;
|
||||
@@ -207,7 +208,48 @@ export const getChannelMessages = async (
|
||||
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;
|
||||
@@ -285,6 +327,86 @@ export const updateMessage = async (
|
||||
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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -682,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',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { settings, playingNotificationSound, isLastActiveTab } from '$lib/stores';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
import { marked } from 'marked';
|
||||
@@ -11,8 +12,21 @@
|
||||
export let content: string;
|
||||
|
||||
onMount(() => {
|
||||
const audio = new Audio(`/audio/notification.mp3`);
|
||||
audio.play();
|
||||
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>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
|
||||
const shareHandler = async (func) => {
|
||||
const item = await getFunctionById(localStorage.token, func.id).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
const cloneHandler = async (func) => {
|
||||
const _function = await getFunctionById(localStorage.token, func.id).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
|
||||
const exportHandler = async (func) => {
|
||||
const _function = await getFunctionById(localStorage.token, func.id).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
const deleteHandler = async (func) => {
|
||||
const res = await deleteFunctionById(localStorage.token, func.id).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
|
||||
const toggleGlobalHandler = async (func) => {
|
||||
const res = await toggleGlobalById(localStorage.token, func.id).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
@@ -418,7 +418,7 @@
|
||||
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 dark:text-gray-200 transition"
|
||||
on:click={async () => {
|
||||
const _functions = await exportFunctions(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -510,7 +510,7 @@
|
||||
|
||||
for (const func of _functions) {
|
||||
const res = await createNewFunction(localStorage.token, func).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,9 +43,8 @@
|
||||
|
||||
const updateOpenAIHandler = async () => {
|
||||
if (ENABLE_OPENAI_API !== null) {
|
||||
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.filter(
|
||||
(url, urlIdx) => OPENAI_API_BASE_URLS.indexOf(url) === urlIdx && url !== ''
|
||||
).map((url) => url.replace(/\/$/, ''));
|
||||
// Remove trailing slashes
|
||||
OPENAI_API_BASE_URLS = OPENAI_API_BASE_URLS.map((url) => url.replace(/\/$/, ''));
|
||||
|
||||
// Check if API KEYS length is same than API URLS length
|
||||
if (OPENAI_API_KEYS.length !== OPENAI_API_BASE_URLS.length) {
|
||||
@@ -69,7 +68,7 @@
|
||||
OPENAI_API_KEYS: OPENAI_API_KEYS,
|
||||
OPENAI_API_CONFIGS: OPENAI_API_CONFIGS
|
||||
}).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
@@ -81,24 +80,15 @@
|
||||
|
||||
const updateOllamaHandler = async () => {
|
||||
if (ENABLE_OLLAMA_API !== null) {
|
||||
// Remove duplicate URLs
|
||||
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter(
|
||||
(url, urlIdx) => OLLAMA_BASE_URLS.indexOf(url) === urlIdx && url !== ''
|
||||
).map((url) => url.replace(/\/$/, ''));
|
||||
|
||||
console.log(OLLAMA_BASE_URLS);
|
||||
|
||||
if (OLLAMA_BASE_URLS.length === 0) {
|
||||
ENABLE_OLLAMA_API = false;
|
||||
toast.info($i18n.t('Ollama API disabled'));
|
||||
}
|
||||
// Remove trailing slashes
|
||||
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.map((url) => url.replace(/\/$/, ''));
|
||||
|
||||
const res = await updateOllamaConfig(localStorage.token, {
|
||||
ENABLE_OLLAMA_API: ENABLE_OLLAMA_API,
|
||||
OLLAMA_BASE_URLS: OLLAMA_BASE_URLS,
|
||||
OLLAMA_API_CONFIGS: OLLAMA_API_CONFIGS
|
||||
}).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
@@ -111,14 +101,14 @@
|
||||
const addOpenAIConnectionHandler = async (connection) => {
|
||||
OPENAI_API_BASE_URLS = [...OPENAI_API_BASE_URLS, connection.url];
|
||||
OPENAI_API_KEYS = [...OPENAI_API_KEYS, connection.key];
|
||||
OPENAI_API_CONFIGS[connection.url] = connection.config;
|
||||
OPENAI_API_CONFIGS[OPENAI_API_BASE_URLS.length] = connection.config;
|
||||
|
||||
await updateOpenAIHandler();
|
||||
};
|
||||
|
||||
const addOllamaConnectionHandler = async (connection) => {
|
||||
OLLAMA_BASE_URLS = [...OLLAMA_BASE_URLS, connection.url];
|
||||
OLLAMA_API_CONFIGS[connection.url] = connection.config;
|
||||
OLLAMA_API_CONFIGS[OLLAMA_BASE_URLS.length] = connection.config;
|
||||
|
||||
await updateOllamaHandler();
|
||||
};
|
||||
@@ -148,15 +138,17 @@
|
||||
OLLAMA_API_CONFIGS = ollamaConfig.OLLAMA_API_CONFIGS;
|
||||
|
||||
if (ENABLE_OPENAI_API) {
|
||||
for (const url of OPENAI_API_BASE_URLS) {
|
||||
if (!OPENAI_API_CONFIGS[url]) {
|
||||
OPENAI_API_CONFIGS[url] = {};
|
||||
// get url and idx
|
||||
for (const [idx, url] of OPENAI_API_BASE_URLS.entries()) {
|
||||
if (!OPENAI_API_CONFIGS[idx]) {
|
||||
// Legacy support, url as key
|
||||
OPENAI_API_CONFIGS[idx] = OPENAI_API_CONFIGS[url] || {};
|
||||
}
|
||||
}
|
||||
|
||||
OPENAI_API_BASE_URLS.forEach(async (url, idx) => {
|
||||
OPENAI_API_CONFIGS[url] = OPENAI_API_CONFIGS[url] || {};
|
||||
if (!(OPENAI_API_CONFIGS[url]?.enable ?? true)) {
|
||||
OPENAI_API_CONFIGS[idx] = OPENAI_API_CONFIGS[idx] || {};
|
||||
if (!(OPENAI_API_CONFIGS[idx]?.enable ?? true)) {
|
||||
return;
|
||||
}
|
||||
const res = await getOpenAIModels(localStorage.token, idx);
|
||||
@@ -167,9 +159,9 @@
|
||||
}
|
||||
|
||||
if (ENABLE_OLLAMA_API) {
|
||||
for (const url of OLLAMA_BASE_URLS) {
|
||||
if (!OLLAMA_API_CONFIGS[url]) {
|
||||
OLLAMA_API_CONFIGS[url] = {};
|
||||
for (const [idx, url] of OLLAMA_BASE_URLS.entries()) {
|
||||
if (!OLLAMA_API_CONFIGS[idx]) {
|
||||
OLLAMA_API_CONFIGS[idx] = OLLAMA_API_CONFIGS[url] || {};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -242,7 +234,7 @@
|
||||
pipeline={pipelineUrls[url] ? true : false}
|
||||
bind:url
|
||||
bind:key={OPENAI_API_KEYS[idx]}
|
||||
bind:config={OPENAI_API_CONFIGS[url]}
|
||||
bind:config={OPENAI_API_CONFIGS[idx]}
|
||||
onSubmit={() => {
|
||||
updateOpenAIHandler();
|
||||
}}
|
||||
@@ -251,6 +243,8 @@
|
||||
(url, urlIdx) => idx !== urlIdx
|
||||
);
|
||||
OPENAI_API_KEYS = OPENAI_API_KEYS.filter((key, keyIdx) => idx !== keyIdx);
|
||||
|
||||
delete OPENAI_API_CONFIGS[idx];
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
@@ -301,13 +295,14 @@
|
||||
{#each OLLAMA_BASE_URLS as url, idx}
|
||||
<OllamaConnection
|
||||
bind:url
|
||||
bind:config={OLLAMA_API_CONFIGS[url]}
|
||||
bind:config={OLLAMA_API_CONFIGS[idx]}
|
||||
{idx}
|
||||
onSubmit={() => {
|
||||
updateOllamaHandler();
|
||||
}}
|
||||
onDelete={() => {
|
||||
OLLAMA_BASE_URLS = OLLAMA_BASE_URLS.filter((url, urlIdx) => idx !== urlIdx);
|
||||
delete OLLAMA_API_CONFIGS[idx];
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
const verifyOllamaHandler = async () => {
|
||||
const res = await verifyOllamaConnection(localStorage.token, url, key).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
@@ -47,7 +47,7 @@
|
||||
|
||||
const verifyOpenAIHandler = async () => {
|
||||
const res = await verifyOpenAIConnection(localStorage.token, url, key).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import Wrench from '$lib/components/icons/Wrench.svelte';
|
||||
import ManageOllamaModal from './ManageOllamaModal.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
|
||||
export let onDelete = () => {};
|
||||
export let onSubmit = () => {};
|
||||
@@ -70,7 +71,7 @@
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Wrench />
|
||||
<ArrowDownTray />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
reader.onload = async (e) => {
|
||||
const res = await importConfig(localStorage.token, JSON.parse(e.target.result)).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
// exportAllUserChats();
|
||||
|
||||
downloadDatabase(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
url: OpenAIUrl
|
||||
}
|
||||
}).catch(async (error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
await setEmbeddingConfig();
|
||||
return null;
|
||||
});
|
||||
@@ -142,7 +142,7 @@
|
||||
const res = await updateRerankingConfig(localStorage.token, {
|
||||
reranking_model: rerankingModel
|
||||
}).catch(async (error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
await setRerankingConfig();
|
||||
return null;
|
||||
});
|
||||
@@ -258,7 +258,7 @@
|
||||
bind:show={showResetUploadDirConfirm}
|
||||
on:confirm={async () => {
|
||||
const res = await deleteAllFiles(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
bind:show={showResetConfirm}
|
||||
on:confirm={() => {
|
||||
const res = resetVectorDB(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
label: '',
|
||||
host: '',
|
||||
port: '',
|
||||
attribute_for_mail: 'mail',
|
||||
attribute_for_username: 'uid',
|
||||
app_dn: '',
|
||||
app_dn_password: '',
|
||||
@@ -41,7 +42,7 @@
|
||||
const updateLdapServerHandler = async () => {
|
||||
if (!ENABLE_LDAP) return;
|
||||
const res = await updateLdapServer(localStorage.token, LDAP_SERVER).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
@@ -112,12 +113,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">
|
||||
@@ -306,6 +343,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
{$i18n.t('Attribute for Mail')}
|
||||
</div>
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'The LDAP attribute that maps to the mail that users use to sign in.'
|
||||
)}
|
||||
placement="top-start"
|
||||
>
|
||||
<input
|
||||
class="w-full bg-transparent outline-none py-0.5"
|
||||
required
|
||||
placeholder={$i18n.t('Example: mail')}
|
||||
bind:value={LDAP_SERVER.attribute_for_mail}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="w-full">
|
||||
<div class=" self-center text-xs font-medium min-w-fit mb-1">
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
|
||||
const getModels = async () => {
|
||||
models = await getImageGenerationModels(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
@@ -107,11 +107,11 @@
|
||||
const updateConfigHandler = async () => {
|
||||
const res = await updateConfig(localStorage.token, config)
|
||||
.catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -159,13 +159,13 @@
|
||||
}
|
||||
|
||||
await updateConfig(localStorage.token, config).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
loading = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
await updateImageGenerationConfig(localStorage.token, imageGenerationConfig).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
loading = false;
|
||||
return null;
|
||||
});
|
||||
@@ -178,7 +178,7 @@
|
||||
onMount(async () => {
|
||||
if ($user.role === 'admin') {
|
||||
const res = await getConfig(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
});
|
||||
|
||||
const imageConfigRes = await getImageGenerationConfig(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -234,7 +234,7 @@
|
||||
<div class=" mb-1 text-sm font-medium">{$i18n.t('Image Settings')}</div>
|
||||
|
||||
<div>
|
||||
<div class=" py-0.5 flex w-full justify-between">
|
||||
<div class=" py-1 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Image Generation (Experimental)')}
|
||||
</div>
|
||||
@@ -271,11 +271,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=" py-0.5 flex w-full justify-between">
|
||||
{#if config.enabled}
|
||||
<div class=" py-1 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Image Prompt Generation')}</div>
|
||||
<div class="px-1">
|
||||
<Switch bind:state={config.prompt_generation} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" py-1 flex w-full justify-between">
|
||||
<div class=" self-center text-xs font-medium">{$i18n.t('Image Generation Engine')}</div>
|
||||
<div class="flex items-center relative">
|
||||
<select
|
||||
class="w-fit pr-8 rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
|
||||
class=" dark:bg-gray-900 w-fit pr-8 cursor-pointer rounded px-2 p-1 text-xs bg-transparent outline-none text-right"
|
||||
bind:value={config.engine}
|
||||
placeholder={$i18n.t('Select Engine')}
|
||||
on:change={async () => {
|
||||
@@ -309,7 +318,7 @@
|
||||
on:click={async () => {
|
||||
await updateConfigHandler();
|
||||
const res = await verifyConfigUrl(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -445,7 +454,7 @@
|
||||
on:click={async () => {
|
||||
await updateConfigHandler();
|
||||
const res = await verifyConfigUrl(localStorage.token).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
TASK_MODEL: '',
|
||||
TASK_MODEL_EXTERNAL: '',
|
||||
TITLE_GENERATION_PROMPT_TEMPLATE: '',
|
||||
IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE: '',
|
||||
ENABLE_AUTOCOMPLETE_GENERATION: true,
|
||||
AUTOCOMPLETE_GENERATION_INPUT_MAX_LENGTH: -1,
|
||||
TAGS_GENERATION_PROMPT_TEMPLATE: '',
|
||||
@@ -140,6 +141,22 @@
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class=" mb-2.5 text-xs font-medium">{$i18n.t('Image Prompt Generation Prompt')}</div>
|
||||
|
||||
<Tooltip
|
||||
content={$i18n.t('Leave empty to use the default prompt, or enter a custom prompt')}
|
||||
placement="top-start"
|
||||
>
|
||||
<Textarea
|
||||
bind:value={taskConfig.IMAGE_PROMPT_GENERATION_PROMPT_TEMPLATE}
|
||||
placeholder={$i18n.t(
|
||||
'Leave empty to use the default prompt, or enter a custom prompt'
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-50 dark:border-gray-850 my-3" />
|
||||
|
||||
<div class="my-3 flex w-full items-center justify-between">
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import Cog6 from '$lib/components/icons/Cog6.svelte';
|
||||
import ConfigureModelsModal from './Models/ConfigureModelsModal.svelte';
|
||||
import Wrench from '$lib/components/icons/Wrench.svelte';
|
||||
import ArrowDownTray from '$lib/components/icons/ArrowDownTray.svelte';
|
||||
import ManageModelsModal from './Models/ManageModelsModal.svelte';
|
||||
|
||||
let importFiles;
|
||||
let modelsImportInputElement: HTMLInputElement;
|
||||
@@ -39,6 +42,7 @@
|
||||
let selectedModelId = null;
|
||||
|
||||
let showConfigModal = false;
|
||||
let showManageModal = false;
|
||||
|
||||
$: if (models) {
|
||||
filteredModels = models
|
||||
@@ -128,7 +132,7 @@
|
||||
await toggleModelById(localStorage.token, model.id);
|
||||
}
|
||||
|
||||
await init();
|
||||
// await init();
|
||||
_models.set(await getModels(localStorage.token));
|
||||
};
|
||||
|
||||
@@ -138,6 +142,7 @@
|
||||
</script>
|
||||
|
||||
<ConfigureModelsModal bind:show={showConfigModal} initHandler={init} />
|
||||
<ManageModelsModal bind:show={showManageModal} />
|
||||
|
||||
{#if models !== null}
|
||||
{#if selectedModelId === null}
|
||||
@@ -151,10 +156,22 @@
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Tooltip content={$i18n.t('Configure')}>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Tooltip content={$i18n.t('Manage Models')}>
|
||||
<button
|
||||
class=" px-2.5 py-1 rounded-full flex gap-1 items-center"
|
||||
class=" p-1 rounded-full flex gap-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showManageModal = true;
|
||||
}}
|
||||
>
|
||||
<ArrowDownTray />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={$i18n.t('Settings')}>
|
||||
<button
|
||||
class=" p-1 rounded-full flex gap-1 items-center"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
showConfigModal = true;
|
||||
|
||||
@@ -33,6 +33,24 @@
|
||||
init();
|
||||
}
|
||||
|
||||
$: if (selectedModelId) {
|
||||
onModelSelect();
|
||||
}
|
||||
|
||||
const onModelSelect = () => {
|
||||
if (selectedModelId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultModelIds.includes(selectedModelId)) {
|
||||
selectedModelId = '';
|
||||
return;
|
||||
}
|
||||
|
||||
defaultModelIds = [...defaultModelIds, selectedModelId];
|
||||
selectedModelId = '';
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
config = await getModelsConfig(localStorage.token);
|
||||
|
||||
@@ -95,7 +113,7 @@
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4 pb-2">
|
||||
<div class=" text-lg font-medium self-center font-primary">
|
||||
{$i18n.t('Configure Models')}
|
||||
{$i18n.t('Settings')}
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
@@ -143,6 +161,24 @@
|
||||
<div class="text-xs text-gray-500">{$i18n.t('Default Models')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center -mr-1">
|
||||
<select
|
||||
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
|
||||
? ''
|
||||
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
|
||||
bind:value={selectedModelId}
|
||||
>
|
||||
<option value="">{$i18n.t('Select a model')}</option>
|
||||
{#each $models as model}
|
||||
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
|
||||
>{model.name}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- <hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" /> -->
|
||||
|
||||
{#if defaultModelIds.length > 0}
|
||||
<div class="flex flex-col">
|
||||
{#each defaultModelIds as modelId, modelIdx}
|
||||
@@ -170,44 +206,6 @@
|
||||
{$i18n.t('No models selected')}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<hr class=" border-gray-100 dark:border-gray-700/10 my-2.5 w-full" />
|
||||
|
||||
<div class="flex items-center">
|
||||
<select
|
||||
class="w-full py-1 text-sm rounded-lg bg-transparent {selectedModelId
|
||||
? ''
|
||||
: 'text-gray-500'} placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-none"
|
||||
bind:value={selectedModelId}
|
||||
>
|
||||
<option value="">{$i18n.t('Select a model')}</option>
|
||||
{#each $models as model}
|
||||
<option value={model.id} class="bg-gray-50 dark:bg-gray-700"
|
||||
>{model.name}</option
|
||||
>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
if (selectedModelId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (defaultModelIds.includes(selectedModelId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultModelIds = [...defaultModelIds, selectedModelId];
|
||||
selectedModelId = '';
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" strokeWidth="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script>
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import ManageOllama from './ManageOllama.svelte';
|
||||
|
||||
export let ollamaConfig = null;
|
||||
|
||||
let selectedUrlIdx = 0;
|
||||
</script>
|
||||
|
||||
{#if ollamaConfig}
|
||||
<div class="flex-1 mb-2.5 pr-1.5 rounded-lg bg-gray-50 dark:text-gray-300 dark:bg-gray-850">
|
||||
<select
|
||||
class="w-full py-2 px-4 text-sm outline-none bg-transparent"
|
||||
bind:value={selectedUrlIdx}
|
||||
placeholder={$i18n.t('Select an Ollama instance')}
|
||||
>
|
||||
{#each ollamaConfig.OLLAMA_BASE_URLS as url, idx}
|
||||
<option value={idx}>{url}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ManageOllama urlIdx={selectedUrlIdx} />
|
||||
{/if}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
||||
<script>
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { createEventDispatcher, getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
import Modal from '$lib/components/common/Modal.svelte';
|
||||
import ManageOllama from './Manage/ManageOllama.svelte';
|
||||
import { getOllamaConfig } from '$lib/apis/ollama';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import ManageMultipleOllama from './Manage/ManageMultipleOllama.svelte';
|
||||
|
||||
export let show = false;
|
||||
|
||||
let selected = null;
|
||||
let ollamaConfig = null;
|
||||
|
||||
onMount(async () => {
|
||||
if ($user.role === 'admin') {
|
||||
await Promise.all([
|
||||
(async () => {
|
||||
ollamaConfig = await getOllamaConfig(localStorage.token);
|
||||
})()
|
||||
]);
|
||||
|
||||
if (ollamaConfig) {
|
||||
selected = 'ollama';
|
||||
return;
|
||||
}
|
||||
|
||||
selected = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal size="sm" bind:show>
|
||||
<div>
|
||||
<div class=" flex justify-between dark:text-gray-100 px-5 pt-4">
|
||||
<div class=" text-lg font-medium self-center font-primary">
|
||||
{$i18n.t('Manage Models')}
|
||||
</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full px-3 pb-4 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
{#if selected === ''}
|
||||
<div class=" py-5 text-gray-400 text-xs">
|
||||
<div>
|
||||
{$i18n.t('No inference engine with management support found')}
|
||||
</div>
|
||||
</div>
|
||||
{:else if selected !== null}
|
||||
<div class=" flex w-full flex-col">
|
||||
<div
|
||||
class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
||||
>
|
||||
<button
|
||||
class="min-w-fit rounded-full p-1.5 {selected === 'ollama'
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
on:click={() => {
|
||||
selected = 'ollama';
|
||||
}}>{$i18n.t('Ollama')}</button
|
||||
>
|
||||
|
||||
<!-- <button
|
||||
class="min-w-fit rounded-full p-1.5 {selected === 'llamacpp'
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
on:click={() => {
|
||||
selected = 'llamacpp';
|
||||
}}>{$i18n.t('Llama.cpp')}</button
|
||||
> -->
|
||||
</div>
|
||||
|
||||
<div class=" px-1.5 py-1">
|
||||
{#if selected === 'ollama'}
|
||||
<ManageMultipleOllama {ollamaConfig} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class=" py-5">
|
||||
<Spinner />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -57,7 +57,7 @@
|
||||
valves,
|
||||
selectedPipelinesUrlIdx
|
||||
).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
@@ -118,7 +118,7 @@
|
||||
pipelineDownloadUrl,
|
||||
selectedPipelinesUrlIdx
|
||||
).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -157,7 +157,7 @@
|
||||
}
|
||||
|
||||
pipelineFiles = null;
|
||||
const pipelineUploadInputElement = document.getElementById('pipeline-upload-input');
|
||||
const pipelineUploadInputElement = document.getElementById('pipelines-upload-input');
|
||||
|
||||
if (pipelineUploadInputElement) {
|
||||
pipelineUploadInputElement.value = null;
|
||||
@@ -172,7 +172,7 @@
|
||||
pipelines[selectedPipelineIdx].id,
|
||||
selectedPipelinesUrlIdx
|
||||
).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -53,10 +53,15 @@
|
||||
tools: false
|
||||
},
|
||||
chat: {
|
||||
controls: true,
|
||||
file_upload: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
temporary: true
|
||||
},
|
||||
features: {
|
||||
web_search: true,
|
||||
image_generation: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,7 +74,7 @@
|
||||
|
||||
const addGroupHandler = async (group) => {
|
||||
const res = await createNewGroup(localStorage.token, group).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -84,7 +89,7 @@
|
||||
|
||||
const res = await updateUserDefaultPermissions(localStorage.token, group.permissions).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -37,10 +37,15 @@
|
||||
tools: false
|
||||
},
|
||||
chat: {
|
||||
controls: true,
|
||||
file_upload: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
temporary: true
|
||||
},
|
||||
features: {
|
||||
web_search: true,
|
||||
image_generation: true
|
||||
}
|
||||
};
|
||||
export let userIds = [];
|
||||
@@ -65,20 +70,8 @@
|
||||
if (group) {
|
||||
name = group.name;
|
||||
description = group.description;
|
||||
permissions = group?.permissions ?? {
|
||||
workspace: {
|
||||
models: false,
|
||||
knowledge: false,
|
||||
prompts: false,
|
||||
tools: false
|
||||
},
|
||||
chat: {
|
||||
file_upload: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
temporary: true
|
||||
}
|
||||
};
|
||||
permissions = group?.permissions ?? {};
|
||||
|
||||
userIds = group?.user_ids ?? [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
const updateHandler = async (_group) => {
|
||||
const res = await updateGroupById(localStorage.token, group.id, _group).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
const deleteHandler = async () => {
|
||||
const res = await deleteGroupById(localStorage.token, group.id).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import Switch from '$lib/components/common/Switch.svelte';
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
|
||||
export let permissions = {
|
||||
// Default values for permissions
|
||||
const defaultPermissions = {
|
||||
workspace: {
|
||||
models: false,
|
||||
knowledge: false,
|
||||
@@ -13,12 +14,38 @@
|
||||
tools: false
|
||||
},
|
||||
chat: {
|
||||
controls: true,
|
||||
delete: true,
|
||||
edit: true,
|
||||
temporary: true,
|
||||
file_upload: true
|
||||
},
|
||||
features: {
|
||||
web_search: true,
|
||||
image_generation: true
|
||||
}
|
||||
};
|
||||
|
||||
export let permissions = {};
|
||||
|
||||
// Reactive statement to ensure all fields are present in `permissions`
|
||||
$: {
|
||||
permissions = fillMissingProperties(permissions, defaultPermissions);
|
||||
}
|
||||
|
||||
function fillMissingProperties(obj: any, defaults: any) {
|
||||
return {
|
||||
...defaults,
|
||||
...obj,
|
||||
workspace: { ...defaults.workspace, ...obj.workspace },
|
||||
chat: { ...defaults.chat, ...obj.chat },
|
||||
features: { ...defaults.features, ...obj.features }
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
permissions = fillMissingProperties(permissions, defaultPermissions);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -169,6 +196,14 @@
|
||||
<div>
|
||||
<div class=" mb-2 text-sm font-medium">{$i18n.t('Chat Permissions')}</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Allow Chat Controls')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={permissions.chat.controls} />
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Allow File Upload')}
|
||||
@@ -201,4 +236,26 @@
|
||||
<Switch bind:state={permissions.chat.temporary} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class=" border-gray-50 dark:border-gray-850 my-2" />
|
||||
|
||||
<div>
|
||||
<div class=" mb-2 text-sm font-medium">{$i18n.t('Features Permissions')}</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Web Search')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={permissions.features.web_search} />
|
||||
</div>
|
||||
|
||||
<div class=" flex w-full justify-between my-2 pr-2">
|
||||
<div class=" self-center text-xs font-medium">
|
||||
{$i18n.t('Image Generation')}
|
||||
</div>
|
||||
|
||||
<Switch bind:state={permissions.features.image_generation} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
const updateRoleHandler = async (id, role) => {
|
||||
const res = await updateUserRole(localStorage.token, id, role).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
|
||||
const deleteUserHandler = async (id) => {
|
||||
const res = await deleteUserById(localStorage.token, id).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
_user.password,
|
||||
_user.role
|
||||
).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
@@ -114,6 +114,8 @@
|
||||
toast.error($i18n.t('File not found.'));
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -148,9 +150,13 @@
|
||||
submitHandler();
|
||||
}}
|
||||
>
|
||||
<div class="flex text-center text-sm font-medium rounded-full bg-transparent/10 p-1 mb-2">
|
||||
<div
|
||||
class="flex -mt-2 mb-1.5 gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent dark:text-gray-200"
|
||||
>
|
||||
<button
|
||||
class="w-full rounded-full p-1.5 {tab === '' ? 'bg-gray-50 dark:bg-gray-850' : ''}"
|
||||
class="min-w-fit rounded-full p-1.5 {tab === ''
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
tab = '';
|
||||
@@ -158,23 +164,24 @@
|
||||
>
|
||||
|
||||
<button
|
||||
class="w-full rounded-full p-1 {tab === 'import'
|
||||
? 'bg-gray-50 dark:bg-gray-850'
|
||||
: ''}"
|
||||
class="min-w-fit rounded-full p-1.5 {tab === 'import'
|
||||
? ''
|
||||
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
|
||||
type="button"
|
||||
on:click={() => {
|
||||
tab = 'import';
|
||||
}}>{$i18n.t('CSV Import')}</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="px-1">
|
||||
{#if tab === ''}
|
||||
<div class="flex flex-col w-full">
|
||||
<div class="flex flex-col w-full mb-3">
|
||||
<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Role')}</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<select
|
||||
class="w-full capitalize rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
class="w-full capitalize rounded-lg text-sm bg-transparent dark:disabled:text-gray-500 outline-none"
|
||||
bind:value={_user.role}
|
||||
placeholder={$i18n.t('Enter Your Role')}
|
||||
required
|
||||
@@ -191,7 +198,7 @@
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
type="text"
|
||||
bind:value={_user.name}
|
||||
placeholder={$i18n.t('Enter Your Full Name')}
|
||||
@@ -208,7 +215,7 @@
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
type="email"
|
||||
bind:value={_user.email}
|
||||
placeholder={$i18n.t('Enter Your Email')}
|
||||
@@ -222,7 +229,7 @@
|
||||
|
||||
<div class="flex-1">
|
||||
<input
|
||||
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
class="w-full text-sm bg-transparent disabled:text-gray-500 dark:disabled:text-gray-500 outline-none"
|
||||
type="password"
|
||||
bind:value={_user.password}
|
||||
placeholder={$i18n.t('Enter Your Password')}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
const submitHandler = async () => {
|
||||
const res = await updateUserById(localStorage.token, selectedUser.id, _user).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
const deleteChatHandler = async (chatId) => {
|
||||
const res = await deleteChatById(localStorage.token, chatId).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
});
|
||||
|
||||
chats = await getChatListByUserId(localStorage.token, user.id);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||
import Plus from '$lib/components/icons/Plus.svelte';
|
||||
|
||||
let selected = '';
|
||||
</script>
|
||||
|
||||
<div class="min-w-[4.5rem] bg-gray-50 dark:bg-gray-950 flex gap-2.5 flex-col pt-8">
|
||||
<div class="flex justify-center relative">
|
||||
{#if selected === 'home'}
|
||||
<div class="absolute top-0 left-0 flex h-full">
|
||||
<div class="my-auto rounded-r-lg w-1 h-8 bg-black dark:bg-white"></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tooltip content="Home" placement="right">
|
||||
<button
|
||||
class=" cursor-pointer {selected === 'home' ? 'rounded-2xl' : 'rounded-full'}"
|
||||
on:click={() => {
|
||||
selected = 'home';
|
||||
|
||||
if (window.electronAPI) {
|
||||
window.electronAPI.load('home');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/static/splash.png"
|
||||
class="size-11 dark:invert p-0.5"
|
||||
alt="logo"
|
||||
draggable="false"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class=" -mt-1 border-[1.5px] border-gray-100 dark:border-gray-900 mx-4"></div>
|
||||
|
||||
<div class="flex justify-center relative group">
|
||||
{#if selected === ''}
|
||||
<div class="absolute top-0 left-0 flex h-full">
|
||||
<div class="my-auto rounded-r-lg w-1 h-8 bg-black dark:bg-white"></div>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class=" cursor-pointer bg-transparent"
|
||||
on:click={() => {
|
||||
selected = '';
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src="/static/favicon.png"
|
||||
class="size-10 {selected === '' ? 'rounded-2xl' : 'rounded-full'}"
|
||||
alt="logo"
|
||||
draggable="false"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex justify-center relative group text-gray-400">
|
||||
<button class=" cursor-pointer p-2" on:click={() => {}}>
|
||||
<Plus className="size-4" strokeWidth="2" />
|
||||
</button>
|
||||
</div> -->
|
||||
</div>
|
||||
@@ -1,14 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onDestroy, onMount, tick } from 'svelte';
|
||||
import { Pane, PaneGroup, PaneResizer } from 'paneforge';
|
||||
|
||||
import { chatId, showSidebar, socket } from '$lib/stores';
|
||||
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 { goto } from '$app/navigation';
|
||||
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 = '';
|
||||
|
||||
@@ -20,18 +25,29 @@
|
||||
let channel = null;
|
||||
let messages = null;
|
||||
|
||||
let threadId = null;
|
||||
|
||||
let typingUsers = [];
|
||||
let typingUsersTimeout = {};
|
||||
|
||||
$: if (id) {
|
||||
initHandler();
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
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;
|
||||
@@ -41,7 +57,7 @@
|
||||
messages = await getChannelMessages(localStorage.token, id, 0);
|
||||
|
||||
if (messages) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
scrollToBottom();
|
||||
|
||||
if (messages.length < 50) {
|
||||
top = true;
|
||||
@@ -53,41 +69,80 @@
|
||||
};
|
||||
|
||||
const channelEventHandler = async (event) => {
|
||||
console.log(event);
|
||||
|
||||
if (event.channel_id === id) {
|
||||
const type = event?.data?.type ?? null;
|
||||
const data = event?.data?.data ?? null;
|
||||
|
||||
if (type === 'message') {
|
||||
console.log('message', data);
|
||||
messages = [data, ...messages];
|
||||
await tick();
|
||||
if (scrollEnd) {
|
||||
messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
|
||||
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') {
|
||||
console.log('message:update', data);
|
||||
const idx = messages.findIndex((message) => message.id === data.id);
|
||||
|
||||
if (idx !== -1) {
|
||||
messages[idx] = data;
|
||||
}
|
||||
} else if (type === 'message:delete') {
|
||||
console.log('message:delete', data);
|
||||
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) {
|
||||
if (!content && (data?.files ?? []).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await sendMessage(localStorage.token, id, { content: content, data: data }).catch(
|
||||
(error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
}
|
||||
);
|
||||
@@ -97,59 +152,151 @@
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
$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
|
||||
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||
? 'md:max-w-[calc(100%-260px)]'
|
||||
: ''} w-full max-w-full flex flex-col"
|
||||
id="channel-container"
|
||||
>
|
||||
<Navbar {channel} />
|
||||
<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;
|
||||
}}
|
||||
<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-full' : '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"
|
||||
>
|
||||
{#key id}
|
||||
<Messages
|
||||
<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}
|
||||
{messages}
|
||||
{top}
|
||||
onLoad={async () => {
|
||||
const newMessages = await getChannelMessages(localStorage.token, id, messages.length);
|
||||
|
||||
messages = [...messages, ...newMessages];
|
||||
|
||||
if (newMessages.length < 50) {
|
||||
top = true;
|
||||
return;
|
||||
}
|
||||
onClose={() => {
|
||||
threadId = null;
|
||||
}}
|
||||
/>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
</Pane>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class=" pb-[1rem]">
|
||||
<MessageInput onSubmit={submitHandler} {scrollToBottom} {scrollEnd} />
|
||||
</div>
|
||||
</PaneGroup>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
import { config, mobile, settings } from '$lib/stores';
|
||||
import { config, mobile, settings, socket } from '$lib/stores';
|
||||
import { blobToFile, compressImage } from '$lib/utils';
|
||||
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
@@ -23,6 +23,8 @@
|
||||
export let placeholder = $i18n.t('Send a Message');
|
||||
export let transparentBackground = false;
|
||||
|
||||
export let id = null;
|
||||
|
||||
let draggedOver = false;
|
||||
|
||||
let recording = false;
|
||||
@@ -32,9 +34,12 @@
|
||||
let filesInputElement;
|
||||
let inputFiles;
|
||||
|
||||
export let typingUsers = [];
|
||||
|
||||
export let onSubmit: Function;
|
||||
export let onChange: Function;
|
||||
export let scrollEnd = true;
|
||||
export let scrollToBottom: Function;
|
||||
export let scrollToBottom: Function = () => {};
|
||||
|
||||
const screenCaptureHandler = async () => {
|
||||
try {
|
||||
@@ -153,7 +158,7 @@
|
||||
// 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);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -238,7 +243,7 @@
|
||||
};
|
||||
|
||||
const submitHandler = async () => {
|
||||
if (content === '') {
|
||||
if (content === '' && files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -254,13 +259,17 @@
|
||||
|
||||
await tick();
|
||||
|
||||
const chatInputElement = document.getElementById('chat-input');
|
||||
const chatInputElement = document.getElementById(`chat-input-${id}`);
|
||||
chatInputElement?.focus();
|
||||
};
|
||||
|
||||
$: if (content) {
|
||||
onChange();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
window.setTimeout(() => {
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const chatInput = document.getElementById(`chat-input-${id}`);
|
||||
chatInput?.focus();
|
||||
}, 0);
|
||||
|
||||
@@ -290,37 +299,6 @@
|
||||
|
||||
<FilesOverlay show={draggedOver} />
|
||||
|
||||
<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="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>
|
||||
</div>
|
||||
|
||||
<input
|
||||
bind:this={filesInputElement}
|
||||
bind:files={inputFiles}
|
||||
@@ -337,12 +315,58 @@
|
||||
filesInputElement.value = '';
|
||||
}}
|
||||
/>
|
||||
<div class="{transparentBackground ? 'bg-transparent' : 'bg-white dark:bg-gray-900'} ">
|
||||
<div class="bg-transparent">
|
||||
<div
|
||||
class="{($settings?.widescreenMode ?? null)
|
||||
? 'max-w-full'
|
||||
: 'max-w-6xl'} px-2.5 mx-auto inset-x-0"
|
||||
: '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
|
||||
@@ -351,7 +375,7 @@
|
||||
recording = false;
|
||||
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
document.getElementById(`chat-input-${id}`)?.focus();
|
||||
}}
|
||||
on:confirm={async (e) => {
|
||||
const { text, filename } = e.detail;
|
||||
@@ -359,7 +383,7 @@
|
||||
recording = false;
|
||||
|
||||
await tick();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
document.getElementById(`chat-input-${id}`)?.focus();
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
@@ -370,7 +394,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}
|
||||
@@ -456,61 +480,21 @@
|
||||
</InputMenu>
|
||||
</div>
|
||||
|
||||
{#if $settings?.richTextInput ?? true}
|
||||
<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"
|
||||
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>
|
||||
{:else}
|
||||
<textarea
|
||||
id="chat-input"
|
||||
class="scrollbar-hidden bg-transparent dark:text-gray-100 outline-none w-full py-3 px-1 rounded-xl resize-none h-[48px]"
|
||||
{placeholder}
|
||||
<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
|
||||
@@ -538,17 +522,12 @@
|
||||
console.log('Escape');
|
||||
}
|
||||
}}
|
||||
rows="1"
|
||||
on:input={async (e) => {
|
||||
e.target.style.height = '';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
|
||||
}}
|
||||
on:focus={async (e) => {
|
||||
e.target.style.height = '';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 320) + 'px';
|
||||
on:paste={async (e) => {
|
||||
e = e.detail.event;
|
||||
console.log(e);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="self-end mb-1.5 flex space-x-1 mr-1">
|
||||
{#if content === ''}
|
||||
@@ -602,11 +581,11 @@
|
||||
<Tooltip content={$i18n.t('Send message')}>
|
||||
<button
|
||||
id="send-message-button"
|
||||
class="{content !== ''
|
||||
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 === ''}
|
||||
disabled={content === '' && files.length === 0}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
||||
@@ -11,20 +11,23 @@
|
||||
dayjs.extend(isYesterday);
|
||||
import { tick, getContext, onMount, createEventDispatcher } from 'svelte';
|
||||
|
||||
import { settings } from '$lib/stores';
|
||||
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 { deleteMessage, updateMessage } from '$lib/apis/channels';
|
||||
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;
|
||||
|
||||
@@ -59,14 +62,14 @@
|
||||
<div class=" ">Loading...</div>
|
||||
</div>
|
||||
</Loader>
|
||||
{:else}
|
||||
{: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 py-5">
|
||||
<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">
|
||||
@@ -88,27 +91,101 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each messageList as message, messageIdx (message.id)}
|
||||
{#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);
|
||||
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);
|
||||
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}
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
const i18n = getContext<Writable<i18nType>>('i18n');
|
||||
|
||||
import { settings, user } from '$lib/stores';
|
||||
import { settings, user, shortCodesToEmojis } from '$lib/stores';
|
||||
|
||||
import { WEBUI_BASE_URL } from '$lib/constants';
|
||||
|
||||
@@ -25,29 +25,27 @@
|
||||
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';
|
||||
import { formatDate } from '$lib/utils';
|
||||
|
||||
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
|
||||
@@ -65,29 +63,69 @@
|
||||
? 'pt-1.5 pb-0.5'
|
||||
: ''} w-full {($settings?.widescreenMode ?? null)
|
||||
? 'max-w-full'
|
||||
: 'max-w-5xl'} mx-auto group hover:bg-gray-500/5 transition relative"
|
||||
: 'max-w-5xl'} mx-auto group hover:bg-gray-300/5 dark:hover:bg-gray-700/5 transition relative"
|
||||
>
|
||||
{#if (message.user_id === $user.id || $user.role === 'admin') && !edit}
|
||||
<div class=" absolute invisible group-hover:visible right-1 -top-2 z-30">
|
||||
{#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"
|
||||
>
|
||||
<button
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
|
||||
on:click={() => {
|
||||
edit = true;
|
||||
editedContent = message.content;
|
||||
<ReactionPicker
|
||||
onClose={() => (showButtons = false)}
|
||||
onSubmit={(name) => {
|
||||
showButtons = false;
|
||||
onReaction(name);
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<button
|
||||
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
|
||||
on:click={() => (showDeleteConfirmDialog = true)}
|
||||
>
|
||||
<GarbageBin />
|
||||
</button>
|
||||
{#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}
|
||||
@@ -101,11 +139,13 @@
|
||||
class={`flex-shrink-0 ${($settings?.chatDirection ?? 'LTR') === 'LTR' ? 'mr-3' : 'ml-3'} w-9`}
|
||||
>
|
||||
{#if showUserProfile}
|
||||
<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 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" /> -->
|
||||
|
||||
@@ -126,7 +166,7 @@
|
||||
<div class="flex-auto w-0 pl-1">
|
||||
{#if showUserProfile}
|
||||
<Name>
|
||||
<div class=" self-end text-base font-medium">
|
||||
<div class=" self-end text-base shrink-0 font-medium truncate">
|
||||
{message?.user?.name}
|
||||
</div>
|
||||
|
||||
@@ -137,7 +177,7 @@
|
||||
<Tooltip
|
||||
content={dayjs(message.created_at / 1000000).format('dddd, DD MMMM YYYY HH:mm')}
|
||||
>
|
||||
{formatDate(message.created_at / 1000000)}
|
||||
<span class="line-clamp-1">{formatDate(message.created_at / 1000000)}</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -219,6 +259,85 @@
|
||||
>(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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -16,7 +16,7 @@
|
||||
export let channel;
|
||||
</script>
|
||||
|
||||
<div class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center">
|
||||
<nav class="sticky top-0 z-30 w-full px-1.5 py-1.5 -mb-8 flex items-center drag-region">
|
||||
<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>
|
||||
@@ -83,4 +83,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -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}
|
||||
@@ -44,7 +44,8 @@
|
||||
extractSentencesForAudio,
|
||||
promptTemplate,
|
||||
splitStream,
|
||||
sleep
|
||||
sleep,
|
||||
removeDetailsWithReasoning
|
||||
} from '$lib/utils';
|
||||
|
||||
import { generateChatCompletion } from '$lib/apis/ollama';
|
||||
@@ -111,6 +112,7 @@
|
||||
$: selectedModelIds = atSelectedModel !== undefined ? [atSelectedModel.id] : selectedModels;
|
||||
|
||||
let selectedToolIds = [];
|
||||
let imageGenerationEnabled = false;
|
||||
let webSearchEnabled = false;
|
||||
|
||||
let chat = null;
|
||||
@@ -137,6 +139,7 @@
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
|
||||
loaded = false;
|
||||
|
||||
@@ -152,6 +155,7 @@
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
@@ -318,6 +322,19 @@
|
||||
eventConfirmationMessage = data.message;
|
||||
eventConfirmationInputPlaceholder = data.placeholder;
|
||||
eventConfirmationInputValue = data?.value ?? '';
|
||||
} else if (type === 'notification') {
|
||||
const toastType = data?.type ?? 'info';
|
||||
const toastContent = data?.content ?? '';
|
||||
|
||||
if (toastType === 'success') {
|
||||
toast.success(toastContent);
|
||||
} else if (toastType === 'error') {
|
||||
toast.error(toastContent);
|
||||
} else if (toastType === 'warning') {
|
||||
toast.warning(toastContent);
|
||||
} else {
|
||||
toast.info(toastContent);
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown message type', data);
|
||||
}
|
||||
@@ -390,11 +407,13 @@
|
||||
files = input.files;
|
||||
selectedToolIds = input.selectedToolIds;
|
||||
webSearchEnabled = input.webSearchEnabled;
|
||||
imageGenerationEnabled = input.imageGenerationEnabled;
|
||||
} catch (e) {
|
||||
prompt = '';
|
||||
files = [];
|
||||
selectedToolIds = [];
|
||||
webSearchEnabled = false;
|
||||
imageGenerationEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +446,7 @@
|
||||
onDestroy(() => {
|
||||
chatIdUnsubscriber?.();
|
||||
window.removeEventListener('message', onMessageHandler);
|
||||
// $socket?.off('chat-events');
|
||||
$socket?.off('chat-events', chatEventHandler);
|
||||
});
|
||||
|
||||
// File upload functions
|
||||
@@ -696,6 +715,9 @@
|
||||
if ($page.url.searchParams.get('web-search') === 'true') {
|
||||
webSearchEnabled = true;
|
||||
}
|
||||
if ($page.url.searchParams.get('image-generation') === 'true') {
|
||||
imageGenerationEnabled = true;
|
||||
}
|
||||
|
||||
if ($page.url.searchParams.get('tools')) {
|
||||
selectedToolIds = $page.url.searchParams
|
||||
@@ -830,22 +852,25 @@
|
||||
session_id: $socket?.id,
|
||||
id: responseMessageId
|
||||
}).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
messages.at(-1).error = { content: error };
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res !== null) {
|
||||
if (res !== null && res.messages) {
|
||||
// Update chat history with the new messages
|
||||
for (const message of res.messages) {
|
||||
history.messages[message.id] = {
|
||||
...history.messages[message.id],
|
||||
...(history.messages[message.id].content !== message.content
|
||||
? { originalContent: history.messages[message.id].content }
|
||||
: {}),
|
||||
...message
|
||||
};
|
||||
if (message?.id) {
|
||||
// Add null check for message and message.id
|
||||
history.messages[message.id] = {
|
||||
...history.messages[message.id],
|
||||
...(history.messages[message.id].content !== message.content
|
||||
? { originalContent: history.messages[message.id].content }
|
||||
: {}),
|
||||
...message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,12 +910,12 @@
|
||||
session_id: $socket?.id,
|
||||
id: responseMessageId
|
||||
}).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
messages.at(-1).error = { content: error };
|
||||
return null;
|
||||
});
|
||||
|
||||
if (res !== null) {
|
||||
if (res !== null && res.messages) {
|
||||
// Update chat history with the new messages
|
||||
for (const message of res.messages) {
|
||||
history.messages[message.id] = {
|
||||
@@ -1053,7 +1078,7 @@
|
||||
};
|
||||
|
||||
const chatCompletionEventHandler = async (data, message, chatId) => {
|
||||
const { id, done, choices, sources, selected_model_id, error, usage } = data;
|
||||
const { id, done, choices, content, sources, selected_model_id, error, usage } = data;
|
||||
|
||||
if (error) {
|
||||
await handleOpenAIError(error, message);
|
||||
@@ -1064,41 +1089,79 @@
|
||||
}
|
||||
|
||||
if (choices) {
|
||||
let value = choices[0]?.delta?.content ?? '';
|
||||
if (message.content == '' && value == '\n') {
|
||||
console.log('Empty response');
|
||||
if (choices[0]?.message?.content) {
|
||||
// Non-stream response
|
||||
message.content += choices[0]?.message?.content;
|
||||
} else {
|
||||
message.content += value;
|
||||
// Stream response
|
||||
let value = choices[0]?.delta?.content ?? '';
|
||||
if (message.content == '' && value == '\n') {
|
||||
console.log('Empty response');
|
||||
} else {
|
||||
message.content += value;
|
||||
|
||||
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
|
||||
// Emit chat event for TTS
|
||||
const messageContentParts = getMessageContentParts(
|
||||
message.content,
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
messageContentParts.pop();
|
||||
|
||||
// dispatch only last sentence and make sure it hasn't been dispatched before
|
||||
if (
|
||||
messageContentParts.length > 0 &&
|
||||
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
||||
) {
|
||||
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: messageContentParts[messageContentParts.length - 1]
|
||||
}
|
||||
})
|
||||
// Emit chat event for TTS
|
||||
const messageContentParts = getMessageContentParts(
|
||||
message.content,
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
messageContentParts.pop();
|
||||
|
||||
// dispatch only last sentence and make sure it hasn't been dispatched before
|
||||
if (
|
||||
messageContentParts.length > 0 &&
|
||||
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
||||
) {
|
||||
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: messageContentParts[messageContentParts.length - 1]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (content) {
|
||||
// REALTIME_CHAT_SAVE is disabled
|
||||
message.content = content;
|
||||
|
||||
if (navigator.vibrate && ($settings?.hapticFeedback ?? false)) {
|
||||
navigator.vibrate(5);
|
||||
}
|
||||
|
||||
// Emit chat event for TTS
|
||||
const messageContentParts = getMessageContentParts(
|
||||
message.content,
|
||||
$config?.audio?.tts?.split_on ?? 'punctuation'
|
||||
);
|
||||
messageContentParts.pop();
|
||||
|
||||
// dispatch only last sentence and make sure it hasn't been dispatched before
|
||||
if (
|
||||
messageContentParts.length > 0 &&
|
||||
messageContentParts[messageContentParts.length - 1] !== message.lastSentence
|
||||
) {
|
||||
message.lastSentence = messageContentParts[messageContentParts.length - 1];
|
||||
eventTarget.dispatchEvent(
|
||||
new CustomEvent('chat', {
|
||||
detail: {
|
||||
id: message.id,
|
||||
content: messageContentParts[messageContentParts.length - 1]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected_model_id) {
|
||||
message.selectedModelId = selected_model_id;
|
||||
message.arena = true;
|
||||
@@ -1108,16 +1171,11 @@
|
||||
message.usage = usage;
|
||||
}
|
||||
|
||||
history.messages[message.id] = message;
|
||||
|
||||
if (done) {
|
||||
message.done = true;
|
||||
|
||||
if ($settings.notificationEnabled && !document.hasFocus()) {
|
||||
new Notification(`${message.model}`, {
|
||||
body: message.content,
|
||||
icon: `${WEBUI_BASE_URL}/static/favicon.png`
|
||||
});
|
||||
}
|
||||
|
||||
if ($settings.responseAutoCopy) {
|
||||
copyToClipboard(message.content);
|
||||
}
|
||||
@@ -1152,8 +1210,6 @@
|
||||
await chatCompletedHandler(chatId, message.model, message.id, createMessagesList(message.id));
|
||||
}
|
||||
|
||||
history.messages[message.id] = message;
|
||||
|
||||
console.log(data);
|
||||
if (autoScroll) {
|
||||
scrollToBottom();
|
||||
@@ -1317,7 +1373,8 @@
|
||||
history.currentId = responseMessageId;
|
||||
|
||||
// Append messageId to childrenIds of parent message
|
||||
if (parentId !== null) {
|
||||
if (parentId !== null && history.messages[parentId]) {
|
||||
// Add null check before accessing childrenIds
|
||||
history.messages[parentId].childrenIds = [
|
||||
...history.messages[parentId].childrenIds,
|
||||
responseMessageId
|
||||
@@ -1361,7 +1418,7 @@
|
||||
if ($settings?.memory ?? false) {
|
||||
if (userContext === null) {
|
||||
const res = await queryMemory(localStorage.token, prompt).catch((error) => {
|
||||
toast.error(error);
|
||||
toast.error(`${error}`);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
@@ -1447,7 +1504,10 @@
|
||||
}`
|
||||
}
|
||||
: undefined,
|
||||
...createMessagesList(responseMessageId)
|
||||
...createMessagesList(responseMessageId).map((message) => ({
|
||||
...message,
|
||||
content: removeDetailsWithReasoning(message.content)
|
||||
}))
|
||||
]
|
||||
.filter((message) => message?.content?.trim())
|
||||
.map((message, idx, arr) => ({
|
||||
@@ -1495,9 +1555,10 @@
|
||||
: undefined
|
||||
},
|
||||
|
||||
files: files.length > 0 ? files : undefined,
|
||||
files: (files?.length ?? 0) > 0 ? files : undefined,
|
||||
tool_ids: selectedToolIds.length > 0 ? selectedToolIds : undefined,
|
||||
features: {
|
||||
image_generation: imageGenerationEnabled,
|
||||
web_search: webSearchEnabled
|
||||
},
|
||||
|
||||
@@ -1505,7 +1566,12 @@
|
||||
chat_id: $chatId,
|
||||
id: responseMessageId,
|
||||
|
||||
...(!$temporaryChatEnabled && messages.length == 1 && selectedModels[0] === model.id
|
||||
...(!$temporaryChatEnabled &&
|
||||
(messages.length == 1 ||
|
||||
(messages.length == 2 &&
|
||||
messages.at(0)?.role === 'system' &&
|
||||
messages.at(1)?.role === 'user')) &&
|
||||
selectedModels[0] === model.id
|
||||
? {
|
||||
background_tasks: {
|
||||
title_generation: $settings?.title?.auto ?? true,
|
||||
@@ -1789,13 +1855,13 @@
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if !chatIdProp || (loaded && chatIdProp)}
|
||||
<div
|
||||
class="h-screen max-h-[100dvh] {$showSidebar
|
||||
? 'md:max-w-[calc(100%-260px)]'
|
||||
: ''} w-full max-w-full flex flex-col"
|
||||
id="chat-container"
|
||||
>
|
||||
<div
|
||||
class="h-screen max-h-[100dvh] transition-width duration-200 ease-in-out {$showSidebar
|
||||
? ' md:max-w-[calc(100%-260px)]'
|
||||
: ' '} w-full max-w-full flex flex-col"
|
||||
id="chat-container"
|
||||
>
|
||||
{#if !chatIdProp || (loaded && chatIdProp)}
|
||||
{#if $settings?.backgroundImageUrl ?? null}
|
||||
<div
|
||||
class="absolute {$showSidebar
|
||||
@@ -1895,6 +1961,7 @@
|
||||
bind:prompt
|
||||
bind:autoScroll
|
||||
bind:selectedToolIds
|
||||
bind:imageGenerationEnabled
|
||||
bind:webSearchEnabled
|
||||
bind:atSelectedModel
|
||||
transparentBackground={$settings?.backgroundImageUrl ?? false}
|
||||
@@ -1945,6 +2012,7 @@
|
||||
bind:prompt
|
||||
bind:autoScroll
|
||||
bind:selectedToolIds
|
||||
bind:imageGenerationEnabled
|
||||
bind:webSearchEnabled
|
||||
bind:atSelectedModel
|
||||
transparentBackground={$settings?.backgroundImageUrl ?? false}
|
||||
@@ -1997,5 +2065,5 @@
|
||||
{eventTarget}
|
||||
/>
|
||||
</PaneGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
>
|
||||
<div
|
||||
class=" {$showCallOverlay || $showOverview || $showArtifacts
|
||||
? ' h-screen w-screen'
|
||||
? ' h-screen w-full'
|
||||
: 'px-6 py-4'} h-full"
|
||||
>
|
||||
{#if $showCallOverlay}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user