Преглед изворни кода

Merge branch 'dev' into dev_es-ES

_00_ пре 2 месеци
родитељ
комит
e71177f776
100 измењених фајлова са 4050 додато и 802 уклоњено
  1. 2 2
      .github/workflows/format-build-frontend.yaml
  2. 112 0
      CHANGELOG.md
  3. 1 1
      Dockerfile
  4. 53 0
      LICENSE_HISTORY
  5. 2 1
      backend/dev.sh
  6. 63 0
      backend/open_webui/config.py
  7. 16 4
      backend/open_webui/env.py
  8. 20 5
      backend/open_webui/main.py
  9. 0 1
      backend/open_webui/models/chats.py
  10. 4 4
      backend/open_webui/models/folders.py
  11. 53 1
      backend/open_webui/models/groups.py
  12. 13 4
      backend/open_webui/models/memories.py
  13. 12 0
      backend/open_webui/models/users.py
  14. 16 2
      backend/open_webui/retrieval/models/external.py
  15. 28 6
      backend/open_webui/retrieval/utils.py
  16. 23 4
      backend/open_webui/retrieval/vector/dbs/pgvector.py
  17. 7 3
      backend/open_webui/routers/audio.py
  18. 18 14
      backend/open_webui/routers/channels.py
  19. 14 6
      backend/open_webui/routers/chats.py
  20. 10 4
      backend/open_webui/routers/evaluations.py
  21. 3 1
      backend/open_webui/routers/files.py
  22. 3 3
      backend/open_webui/routers/folders.py
  23. 51 0
      backend/open_webui/routers/groups.py
  24. 4 0
      backend/open_webui/routers/memories.py
  25. 9 0
      backend/open_webui/routers/notes.py
  26. 14 1
      backend/open_webui/routers/ollama.py
  27. 11 12
      backend/open_webui/routers/openai.py
  28. 30 3
      backend/open_webui/routers/retrieval.py
  29. 1 1
      backend/open_webui/routers/tasks.py
  30. 2 1
      backend/open_webui/routers/users.py
  31. 129 65
      backend/open_webui/socket/main.py
  32. 108 0
      backend/open_webui/socket/utils.py
  33. 793 0
      backend/open_webui/test/util/test_redis.py
  34. 16 3
      backend/open_webui/utils/logger.py
  35. 49 26
      backend/open_webui/utils/middleware.py
  36. 100 4
      backend/open_webui/utils/redis.py
  37. 5 6
      backend/open_webui/utils/response.py
  38. 40 0
      backend/open_webui/utils/telemetry/metrics.py
  39. 0 1
      backend/open_webui/utils/telemetry/setup.py
  40. 2 0
      backend/requirements.txt
  41. 4 4
      cypress/e2e/chat.cy.ts
  42. 1 1
      hatch_build.py
  43. 307 262
      package-lock.json
  44. 19 20
      package.json
  45. 10 0
      pyproject.toml
  46. 5 0
      src/app.css
  47. 3 2
      src/lib/apis/chats/index.ts
  48. 7 9
      src/lib/apis/folders/index.ts
  49. 1 1
      src/lib/components/admin/Settings/Audio.svelte
  50. 2 4
      src/lib/components/admin/Users/Groups.svelte
  51. 5 5
      src/lib/components/admin/Users/Groups/GroupItem.svelte
  52. 1 11
      src/lib/components/admin/Users/Groups/Users.svelte
  53. 1 1
      src/lib/components/admin/Users/UserList.svelte
  54. 1 0
      src/lib/components/channel/MessageInput.svelte
  55. 46 21
      src/lib/components/chat/Chat.svelte
  56. 22 1
      src/lib/components/chat/MessageInput.svelte
  57. 1 0
      src/lib/components/chat/MessageInput/Commands/Knowledge.svelte
  58. 1 1
      src/lib/components/chat/MessageInput/InputMenu.svelte
  59. 3 0
      src/lib/components/chat/Messages.svelte
  60. 2 0
      src/lib/components/chat/Messages/ContentRenderer.svelte
  61. 2 0
      src/lib/components/chat/Messages/Markdown.svelte
  62. 6 11
      src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte
  63. 33 0
      src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte
  64. 19 0
      src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte
  65. 29 3
      src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte
  66. 3 0
      src/lib/components/chat/Messages/Message.svelte
  67. 2 0
      src/lib/components/chat/Messages/MultiResponseMessages.svelte
  68. 13 3
      src/lib/components/chat/Messages/ResponseMessage.svelte
  69. 26 3
      src/lib/components/chat/ModelSelector/ModelItem.svelte
  70. 135 96
      src/lib/components/chat/Placeholder.svelte
  71. 103 0
      src/lib/components/chat/Placeholder/ChatList.svelte
  72. 0 0
      src/lib/components/chat/Placeholder/FolderKnowledge.svelte
  73. 51 0
      src/lib/components/chat/Placeholder/FolderPlaceholder.svelte
  74. 147 0
      src/lib/components/chat/Placeholder/FolderTitle.svelte
  75. 93 0
      src/lib/components/chat/Settings/Interface.svelte
  76. 1 1
      src/lib/components/chat/Settings/Personalization/ManageModal.svelte
  77. 1 1
      src/lib/components/chat/SettingsModal.svelte
  78. 13 5
      src/lib/components/common/FileItemModal.svelte
  79. 8 0
      src/lib/components/common/Modal.svelte
  80. 183 49
      src/lib/components/common/RichTextInput.svelte
  81. 30 2
      src/lib/components/common/RichTextInput/FormattingButtons.svelte
  82. 197 0
      src/lib/components/common/RichTextInput/Image/image.ts
  83. 5 0
      src/lib/components/common/RichTextInput/Image/index.ts
  84. 26 12
      src/lib/components/common/Tooltip.svelte
  85. 19 0
      src/lib/components/icons/AdjustmentsHorizontalOutline.svelte
  86. 20 0
      src/lib/components/icons/ArrowLeftTag.svelte
  87. 20 0
      src/lib/components/icons/ArrowRightTag.svelte
  88. 22 0
      src/lib/components/icons/CheckBox.svelte
  89. 19 0
      src/lib/components/icons/CheckCircle.svelte
  90. 19 0
      src/lib/components/icons/DocumentCheck.svelte
  91. 19 0
      src/lib/components/icons/Folder.svelte
  92. 16 0
      src/lib/components/icons/Label.svelte
  93. 19 0
      src/lib/components/icons/Tag.svelte
  94. 23 0
      src/lib/components/icons/Voice.svelte
  95. 212 64
      src/lib/components/layout/SearchModal.svelte
  96. 35 8
      src/lib/components/layout/Sidebar.svelte
  97. 36 7
      src/lib/components/layout/Sidebar/ChatItem.svelte
  98. 3 0
      src/lib/components/layout/Sidebar/Folders.svelte
  99. 10 5
      src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte
  100. 153 0
      src/lib/components/layout/Sidebar/Folders/FolderModal.svelte

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

@@ -32,7 +32,7 @@ jobs:
           node-version: '22'
 
       - name: Install Dependencies
-        run: npm install
+        run: npm install --force
 
       - name: Format Frontend
         run: npm run format
@@ -59,7 +59,7 @@ jobs:
           node-version: '22'
 
       - name: Install Dependencies
-        run: npm ci
+        run: npm ci --force
 
       - name: Run vitest
         run: npm run test:frontend

+ 112 - 0
CHANGELOG.md

@@ -5,6 +5,118 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.6.18] - 2025-07-19
+
+### Fixed
+
+- 🚑 **Users Not Loading in Groups**: Resolved an issue where user list was not displaying within user groups, restoring full visibility and management of group memberships for teams and admins.
+
+## [0.6.17] - 2025-07-19
+
+### Added
+
+- 📂 **Dedicated Folder View with Chat List**: Clicking a folder now reveals a brand-new landing page showcasing a list of all chats within that folder, making navigation simpler and giving teams immediate visibility into project-specific conversations.
+- 🆕 **Streamlined Folder Creation Modal**: Creating a new folder is now a seamless, unified experience with a dedicated modal that visually and functionally matches the edit folder flow, making workspace organization more intuitive and error-free for all users.
+- 🗃️ **Direct File Uploads to Folder Knowledge**: You can now upload files straight to a folder’s knowledge—empowering you to enrich project spaces by adding resources and documents directly, without the need to pre-create knowledge bases beforehand.
+- 🔎 **Chat Preview in Search**: When searching chats, instantly preview results in context without having to open them—making discovery, auditing, and recall dramatically quicker, especially in large, active teams.
+- 🖼️ **Image Upload and Inline Insertion in Notes**: Notes now support inserting images directly among your text, letting you create rich, visually structured documentation, brainstorms, or reports in a more natural and engaging way—no more images just as attachments.
+- 📱 **Enhanced Note Selection Editing and Q&A**: Select any portion of your notes to either edit just the highlighted part or ask focused questions about that content—streamlining workflows, boosting productivity, and making reviews or AI-powered enhancements more targeted.
+- 📝 **Copy Notes as Rich Text**: Copy entire notes—including all formatting, images, and structure—directly as rich text for seamless pasting into emails, reports, or other tools, maintaining clarity and consistency outside the WebUI.
+- ⚡ **Fade-In Streaming Text Experience**: Live-generated responses now elegantly fade in as the AI streams them, creating a more natural and visually engaging reading experience; easily toggled off in Interface settings if you prefer static displays.
+- 🔄 **Settings for Follow-Up Prompts**: Fine-tune your follow-up prompt experience—with new controls, you can choose to keep them visible or have them inserted directly into the message input instead of auto-submitting, giving you more flexibility and control over your workflow.
+- 🔗 **Prompt Variable Documentation Quick Link**: Access documentation for prompt variables in one click from the prompt editor modal—shortening the learning curve and making advanced prompt-building more accessible.
+- 📈 **Active and Total User Metrics for Telemetry**: Gain valuable insights into usage patterns and platform engagement with new metrics tracking active and total users—enhancing auditability and planning for large organizations.
+- 🏷️ **Traceability with Log Trace and Span IDs**: Each log entry now carries detailed trace and span IDs, making it much easier for admins to pinpoint and resolve issues across distributed systems or in complex troubleshooting.
+- 👥 **User Group Add/Remove Endpoints**: Effortlessly add or remove users from groups with new, improved endpoints—giving admins and team leads faster, clearer control over collaboration and permissions.
+- ⚙️ **Note Settings and Controls Streamlined**: The main “Settings” for notes are now simply called “Controls”, and note files now reside in a dedicated controls section, decluttering navigation and making it easier to find and configure note-related options.
+- 🚀 **Faster Admin User Page Loads**: The user list endpoint for admins has been optimized to exclude heavy profile images, speeding up load times for large teams and reducing waiting during administrative tasks.
+- 📡 **Chat ID Header Forwarding**: Ollama and OpenAI router requests now include the chat ID in request headers, enabling better request correlation and debugging capabilities across AI model integrations.
+- 🧠 **Enhanced Reasoning Tag Processing**: Improved and expanded reasoning tag parsing to handle various tag formats more robustly, including standard XML-style tags and custom delimiters, ensuring better AI reasoning transparency and debugging capabilities.
+- 🔐 **OAuth Token Endpoint Authentication Method**: Added configurable OAuth token endpoint authentication method support, providing enhanced flexibility and security options for enterprise OAuth integrations and identity provider compatibility.
+- 🛡️ **Redis Sentinel High Availability Support**: Comprehensive Redis Sentinel failover implementation with automatic master discovery, intelligent retry logic for connection failures, and seamless operation during master node outages—eliminating single points of failure and ensuring continuous service availability in production deployments.
+- 🌐 **Localization & Internationalization Improvements**: Refined and expanded translations for Simplified Chinese, Traditional Chinese, French, German, Korean, and Polish, ensuring a more fluent and native experience for global users across all supported languages.
+
+### Fixed
+
+- 🏷️ **Hybrid Search Functionality Restored**: Hybrid search now works seamlessly again—enabling more accurate, relevant, and comprehensive knowledge discovery across all RAG-powered workflows.
+- 🚦 **Note Chat - Edit Button Disabled During AI Generation**: The edit button when chatting with a note is now disabled while the AI is responding—preventing accidental edits and ensuring workflow clarity during chat sessions.
+- 🧹 **Cleaner Database Credentials**: Database connection no longer duplicates ‘@’ in credentials, preventing potential connection issues and ensuring smoother, more reliable integrations.
+- 🧑‍💻 **File Deletion Now Removes Related Vector Data**: When files are deleted from storage, they are now purged from the vector database as well, ensuring clean data management and preventing clutter or stale search results.
+- 📁 **Files Modal Translation Issues Fixed**: All modal dialog strings—including “Using Entire Document” and “Using Focused Retrieval”—are now fully translated for a more consistent and localized UI experience.
+- 🚫 **Drag-and-Drop File Upload Disabled for Unsupported Models**: File upload by drag-and-drop is disabled when using models that do not support attachments—removing confusion and preventing workflow interruptions.
+- 🔑 **Ollama Tool Calls Now Reliable**: Fixed issues with Ollama-based tool calls, ensuring uninterrupted AI augmentation and tool use for every chat.
+- 📄 **MIME Type Help String Correction**: Cleaned up mimetype help text by removing extraneous characters, providing clearer guidance for file upload configurations.
+- 📝 **Note Editor Permission Fix**: Removed unnecessary admin-only restriction from note chat functionality, allowing all authorized users to access note editing features as intended.
+- 📋 **Chat Sources Handling Improved**: Fixed sources handling logic to prevent duplicate source assignments in chat messages, ensuring cleaner and more accurate source attribution during conversations.
+- 😀 **Emoji Generation Error Handling**: Improved error handling in audio router and fixed metadata structure for emoji generation tasks, preventing crashes and ensuring more reliable emoji generation functionality.
+- 🔒 **Folder System Prompt Permission Enforcement**: System prompt fields in folder edit modal are now properly hidden for users without system prompt permissions, ensuring consistent security policy enforcement across all folder management interfaces.
+- 🌐 **WebSocket Redis Lock Timeout Type Conversion**: Fixed proper integer type conversion for WebSocket Redis lock timeout configuration with robust error handling, preventing potential configuration errors and ensuring stable WebSocket connections.
+- 📦 **PostHog Dependency Added**: Added PostHog 5.4.0 library to resolve ChromaDB compatibility issues, ensuring stable vector database operations and preventing library version conflicts during deployment.
+
+### Changed
+
+- 👀 **Tiptap Editor Upgraded to v3**: The underlying rich text editor has been updated for future-proofing, though some supporting libraries remain on v2 for compatibility. For now, please install dependencies using 'npm install --force' to avoid installation errors.
+- 🚫 **Removed Redundant or Unused Strings and Elements**: Miscellaneous unused, duplicate, or obsolete code and translations have been cleaned up to maintain a streamlined and high-performance experience.
+
+## [0.6.16] - 2025-07-14
+
+### Added
+
+- 🗂️ **Folders as Projects**: Organize your workflow with folder-based projects—set folder-level system prompts and associate custom knowledge, bringing seamless, context-rich management to teams and users handling multiple initiatives or clients.
+- 📁 **Instant Folder-Based Chat Creation**: Start a new chat directly from any folder; just click and your new conversation is automatically embedded in the right project context—no more manual dragging or setup, saving time and eliminating mistakes.
+- 🧩 **Prompt Variables with Automatic Input Modal**: Prompts containing variables now display a clean, auto-generated input modal that **autofocuses on the first field** for instant value entry—just select the prompt and fill in exactly what’s needed, reducing friction and guesswork.
+- 🔡 **Variable Input Typing in Prompts**: Define input types for prompt variables (e.g., text, textarea, number, select, color, date, map and more), giving everyone a clearer and more precise prompt-building experience for advanced automation or workflows.
+- 🚀 **Base Model List Caching**: Cache your base model list to speed up model selection and reduce repeated API calls; toggle this in Admin Settings > Connections for responsive model management even in large or multi-provider setups.
+- ⏱️ **Configurable Model List Cache TTL**: Take control over model list caching with the new MODEL_LIST_CACHE_TTL environment variable. Set a custom cache duration in seconds to balance performance and freshness, reducing API requests in stable environments or ensuring rapid updates when models change frequently.
+- 🔖 **Reference Notes as Knowledge or in Chats**: Use any note as knowledge for a model or folder, or reference it directly from chat—integrate living documentation into your Retrieval Augmented Generation workflows or discussions, bridging knowledge and action.
+- 📝 **Chat Directly with Notes (Experimental)**: Ask questions about any note, and directly edit or update notes from within a chat—unlock direct AI-powered brainstorming, summarization, and cleanup, like having your own collaborative AI canvas.
+- 🤝 **Collaborative Notes with Multi-User Editing**: Share notes with others and collaborate live—multiple users can edit a note in real-time, boosting cooperative knowledge building and workflow documentation.
+- 🛡️ **Collaborative Note Permissions**: Control who can view or edit each note with robust sharing permissions, ensuring privacy or collaboration per your organizational needs.
+- 🔗 **Copy Link to Notes**: Quickly copy and share direct links to notes for easier knowledge transfer within your team or external collaborators.
+- 📋 **Task List Support in Notes**: Add, organize, and manage checklists or tasks inside your notes—plan projects, track to-dos, and keep everything actionable in a single space.
+- 🧠 **AI-Generated Note Titles**: Instantly generate relevant and concise titles for your notes using AI—keep your knowledge library organized without tedious manual editing.
+- 🔄 **Full Undo/Redo Support in Notes**: Effortlessly undo or redo your latest note changes—never fear mistakes or accidental edits while collaborating or writing.
+- 📝 **Enhanced Note Word/Character Counter**: Always know the size of your notes with built-in counters, making it easier to adhere to length guidelines for shared or published content.
+- 🖊️ **Floating & Bubble Formatting Menus in Note Editor**: Access text formatting tools through both a floating menu and an intuitive bubble menu directly in the note editor—making rich text editing faster, more discoverable, and easier than ever.
+- ✍️ **Rich Text Prompt Insertion**: A new setting allows prompts to be inserted directly into the chat box as fully-formatted rich text, preserving Markdown elements like headings, lists, and bold text for a more intuitive and visually consistent editing experience.
+- 🌐 **Configurable Database URL**: WebUI now supports more flexible database configuration via new environment variables—making deployment and scaling simpler across various infrastructure setups.
+- 🎛️ **Completely Frontend-Handled File Upload in Temporary Chats**: When using temporary chats, file extraction now occurs fully in your browser with zero files sent to the backend, further strengthening privacy and giving you instant feedback.
+- 🔄 **Enhanced Banner and Chat Command Visibility**: Banner handling and command feedback in chat are now clearer and more contextually visible, making alerts, suggestions, and automation easier to spot and interact with for all users.
+- 📱 **Mobile Experience Polished**: The "new chat" button is back in mobile, plus core navigation and input controls have been smoothed out for better usability on phones and tablets.
+- 📄 **OpenDocument Text (.odt) Support**: Seamlessly upload and process .odt files from open-source office suites like LibreOffice and OpenOffice, expanding your ability to build knowledge from a wider range of document formats.
+- 📑 **Enhanced Markdown Document Splitting**: Improve knowledge retrieval from Markdown files with a new header-aware splitting strategy. This method intelligently chunks documents based on their header structure, preserving the original context and hierarchy for more accurate and relevant RAG results.
+- 📚 **Full Context Mode for Knowledge Bases**: When adding a knowledge base to a folder or custom model, you can now toggle full context mode for the entire knowledge base. This bypasses the usual chunking and retrieval process, making it perfect for leaner knowledge bases.
+- 🕰️ **Configurable OAuth Timeout**: Enhance login reliability by setting a custom timeout (OAUTH_TIMEOUT) for all OAuth providers (Google, Microsoft, GitHub, OIDC), preventing authentication failures on slow or restricted networks.
+- 🎨 **Accessibility & High-Contrast Theme Enhancements**: Major accessibility overhaul with significant updates to the high-contrast theme. Improved focus visibility, ARIA labels, and semantic HTML ensure core components like the chat interface and model selector are fully compliant and readable for visually impaired users.
+- ↕️ **Resizable System Prompt Fields**: Conveniently resize system prompt input fields to comfortably view and edit lengthy or complex instructions, improving the user experience for advanced model configuration.
+- 🔧 **Granular Update Check Control**: Gain finer control over outbound connections with the new ENABLE_VERSION_UPDATE_CHECK flag. This allows administrators to disable version update checks independently of the full OFFLINE_MODE, perfect for environments with restricted internet access that still need to download embedding models.
+- 🗃️ **Configurable Qdrant Collection Prefix**: Enhance scalability by setting a custom QDRANT_COLLECTION_PREFIX. This allows multiple Open WebUI instances to share a single Qdrant cluster safely, ensuring complete data isolation between separate deployments without conflicts.
+- ⚙️ **Improved Default Database Performance**: Enhanced out-of-the-box performance by setting smarter database connection pooling defaults, reducing API response times for users on non-SQLite databases without requiring manual configuration.
+- 🔧 **Configurable Redis Key Prefix**: Added support for the REDIS_KEY_PREFIX environment variable, allowing multiple Open WebUI instances to share a Redis cluster with isolated key namespaces for improved multi-tenancy.
+- ➡️ **Forward User Context to Reranker**: For advanced RAG integrations, user information (ID, name, email, role) can now be forwarded as HTTP headers to external reranking services, enabling personalized results or per-user access control.
+- ⚙️ **PGVector Connection Pooling**: Enhance performance and stability for PGVector-based RAG by enabling and configuring the database connection pool. New environment variables allow fine-tuning of pool size, timeout, and overflow settings to handle high-concurrency workloads efficiently.
+- ⚙️ **General Backend Refactoring**: Extensive refactoring delivers a faster, more reliable, and robust backend experience—improving chat speed, model management, and day-to-day reliability.
+- 🌍 **Expanded & Improved Translations**: Enjoy a more accessible and intuitive experience thanks to comprehensive updates and enhancements for Chinese (Simplified and Traditional), German, French, Catalan, Irish, and Spanish translations throughout the interface.
+
+### Fixed
+
+- 🛠️ **Rich Text Input Stability and Performance**: Multiple improvements ensure faster, cleaner text editing and rendering with reduced glitches—especially supporting links, color picking, checkbox controls, and code blocks in notes and chats.
+- 📷 **Seamless iPhone Image Uploads**: Effortlessly upload photos from iPhones and other devices using HEIC format—images are now correctly recognized and processed, eliminating compatibility issues.
+- 🔄 **Audio MIME Type Registration**: Issues with audio file content types have been resolved, guaranteeing smoother, error-free uploads and playback for transcription or note attachments.
+- 🖍️ **Input Commands Now Always Visible**: Input commands (like prompts or knowledge) dynamically adjust their height on small screens, ensuring nothing is cut off and every tool remains easily accessible.
+- 🛑 **Tool Result Rendering**: Fixed display problems with tool results, providing fast, clear feedback when using external or internal tools.
+- 🗂️ **Table Alignment in Markdown**: Markdown tables are now rendered and aligned as expected, keeping reports and documentation readable.
+- 🖼️ **Thread Image Handling**: Fixed an issue where messages containing only images in threads weren’t displayed correctly.
+- 🗝️ **Note Access Control Security**: Tightened access control logic for notes to guarantee that shared or collaborative notes respect all user permissions and privacy safeguards.
+- 🧾 **Ollama API Compatibility**: Fixed model parameter naming in the API to ensure uninterrupted compatibility for all Ollama endpoints.
+- 🛠️ **Detection for 'text/html' Files**: Files loaded with docling/tika are now reliably detected as the correct type, improving knowledge ingestion and document parsing.
+- 🔐 **OAuth Login Stability**: Resolved a critical OAuth bug that caused login failures on subsequent attempts after logging out. The user session is now completely cleared on logout, ensuring reliable and secure authentication across all supported providers (Google, Microsoft, GitHub, OIDC).
+- 🚪 **OAuth Logout and Redirect Reliability**: The OAuth logout process has been made more robust. Logout requests now correctly use proxy environment variables, ensuring they succeed in corporate networks. Additionally, the custom WEBUI_AUTH_SIGNOUT_REDIRECT_URL is now properly respected for all OAuth/OIDC configurations, ensuring a seamless sign-out experience.
+- 📜 **Banner Newline Rendering**: Banners now correctly render newline characters, ensuring that multi-line announcements and messages are displayed with their intended formatting.
+- ℹ️ **Consistent Model Description Rendering**: Model descriptions now render Markdown correctly in the main chat interface, matching the formatting seen in the model selection dropdown for a consistent user experience.
+- 🔄 **Offline Mode Update Check Display**: Corrected a UI bug where the "Checking for Updates..." message would display indefinitely when the application was set to offline mode.
+- 🛠️ **Tool Result Encoding**: Fixed a bug where tool calls returning non-ASCII characters would fail, ensuring robust handling of international text and special characters in tool outputs.
+
 ## [0.6.15] - 2025-06-16
 
 ### Added

+ 1 - 1
Dockerfile

@@ -30,7 +30,7 @@ WORKDIR /app
 RUN apk add --no-cache git
 
 COPY package.json package-lock.json ./
-RUN npm ci
+RUN npm ci --force
 
 COPY . .
 ENV APP_BUILD_HASH=${BUILD_HASH}

+ 53 - 0
LICENSE_HISTORY

@@ -0,0 +1,53 @@
+All code and materials created before commit `60d84a3aae9802339705826e9095e272e3c83623` are subject to the following copyright and license:
+
+Copyright (c) 2023-2025 Timothy Jaeryang Baek
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+   this list of conditions and the following disclaimer in the documentation
+   and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its
+   contributors may be used to endorse or promote products derived from
+   this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+All code and materials created before commit `a76068d69cd59568b920dfab85dc573dbbb8f131` are subject to the following copyright and license:
+
+MIT License
+
+Copyright (c) 2023 Timothy Jaeryang Baek
+
+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:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+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.

+ 2 - 1
backend/dev.sh

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

+ 63 - 0
backend/open_webui/config.py

@@ -445,6 +445,12 @@ OAUTH_TIMEOUT = PersistentConfig(
     os.environ.get("OAUTH_TIMEOUT", ""),
 )
 
+OAUTH_TOKEN_ENDPOINT_AUTH_METHOD = PersistentConfig(
+    "OAUTH_TOKEN_ENDPOINT_AUTH_METHOD",
+    "oauth.oidc.token_endpoint_auth_method",
+    os.environ.get("OAUTH_TOKEN_ENDPOINT_AUTH_METHOD", None),
+)
+
 OAUTH_CODE_CHALLENGE_METHOD = PersistentConfig(
     "OAUTH_CODE_CHALLENGE_METHOD",
     "oauth.oidc.code_challenge_method",
@@ -636,6 +642,13 @@ def load_oauth_providers():
         def oidc_oauth_register(client: OAuth):
             client_kwargs = {
                 "scope": OAUTH_SCOPES.value,
+                **(
+                    {
+                        "token_endpoint_auth_method": OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value
+                    }
+                    if OAUTH_TOKEN_ENDPOINT_AUTH_METHOD.value
+                    else {}
+                ),
                 **(
                     {"timeout": int(OAUTH_TIMEOUT.value)} if OAUTH_TIMEOUT.value else {}
                 ),
@@ -676,6 +689,17 @@ load_oauth_providers()
 
 STATIC_DIR = Path(os.getenv("STATIC_DIR", OPEN_WEBUI_DIR / "static")).resolve()
 
+try:
+    if STATIC_DIR.exists():
+        for item in STATIC_DIR.iterdir():
+            if item.is_file() or item.is_symlink():
+                try:
+                    item.unlink()
+                except Exception as e:
+                    pass
+except Exception as e:
+    pass
+
 for file_path in (FRONTEND_BUILD_DIR / "static").glob("**/*"):
     if file_path.is_file():
         target_path = STATIC_DIR / file_path.relative_to(
@@ -1886,6 +1910,45 @@ if PGVECTOR_PGCRYPTO and not PGVECTOR_PGCRYPTO_KEY:
         "PGVECTOR_PGCRYPTO is enabled but PGVECTOR_PGCRYPTO_KEY is not set. Please provide a valid key."
     )
 
+
+PGVECTOR_POOL_SIZE = os.environ.get("PGVECTOR_POOL_SIZE", None)
+
+if PGVECTOR_POOL_SIZE != None:
+    try:
+        PGVECTOR_POOL_SIZE = int(PGVECTOR_POOL_SIZE)
+    except Exception:
+        PGVECTOR_POOL_SIZE = None
+
+PGVECTOR_POOL_MAX_OVERFLOW = os.environ.get("PGVECTOR_POOL_MAX_OVERFLOW", 0)
+
+if PGVECTOR_POOL_MAX_OVERFLOW == "":
+    PGVECTOR_POOL_MAX_OVERFLOW = 0
+else:
+    try:
+        PGVECTOR_POOL_MAX_OVERFLOW = int(PGVECTOR_POOL_MAX_OVERFLOW)
+    except Exception:
+        PGVECTOR_POOL_MAX_OVERFLOW = 0
+
+PGVECTOR_POOL_TIMEOUT = os.environ.get("PGVECTOR_POOL_TIMEOUT", 30)
+
+if PGVECTOR_POOL_TIMEOUT == "":
+    PGVECTOR_POOL_TIMEOUT = 30
+else:
+    try:
+        PGVECTOR_POOL_TIMEOUT = int(PGVECTOR_POOL_TIMEOUT)
+    except Exception:
+        PGVECTOR_POOL_TIMEOUT = 30
+
+PGVECTOR_POOL_RECYCLE = os.environ.get("PGVECTOR_POOL_RECYCLE", 3600)
+
+if PGVECTOR_POOL_RECYCLE == "":
+    PGVECTOR_POOL_RECYCLE = 3600
+else:
+    try:
+        PGVECTOR_POOL_RECYCLE = int(PGVECTOR_POOL_RECYCLE)
+    except Exception:
+        PGVECTOR_POOL_RECYCLE = 3600
+
 # Pinecone
 PINECONE_API_KEY = os.environ.get("PINECONE_API_KEY", None)
 PINECONE_ENVIRONMENT = os.environ.get("PINECONE_ENVIRONMENT", None)

+ 16 - 4
backend/open_webui/env.py

@@ -276,9 +276,6 @@ if DATABASE_USER:
     DATABASE_CRED += f"{DATABASE_USER}"
 if DATABASE_PASSWORD:
     DATABASE_CRED += f":{DATABASE_PASSWORD}"
-if DATABASE_CRED:
-    DATABASE_CRED += "@"
-
 
 DB_VARS = {
     "db_type": DATABASE_TYPE,
@@ -352,6 +349,15 @@ REDIS_KEY_PREFIX = os.environ.get("REDIS_KEY_PREFIX", "open-webui")
 REDIS_SENTINEL_HOSTS = os.environ.get("REDIS_SENTINEL_HOSTS", "")
 REDIS_SENTINEL_PORT = os.environ.get("REDIS_SENTINEL_PORT", "26379")
 
+# Maximum number of retries for Redis operations when using Sentinel fail-over
+REDIS_SENTINEL_MAX_RETRY_COUNT = os.environ.get("REDIS_SENTINEL_MAX_RETRY_COUNT", "2")
+try:
+    REDIS_SENTINEL_MAX_RETRY_COUNT = int(REDIS_SENTINEL_MAX_RETRY_COUNT)
+    if REDIS_SENTINEL_MAX_RETRY_COUNT < 1:
+        REDIS_SENTINEL_MAX_RETRY_COUNT = 2
+except ValueError:
+    REDIS_SENTINEL_MAX_RETRY_COUNT = 2
+
 ####################################
 # UVICORN WORKERS
 ####################################
@@ -450,7 +456,13 @@ ENABLE_WEBSOCKET_SUPPORT = (
 WEBSOCKET_MANAGER = os.environ.get("WEBSOCKET_MANAGER", "")
 
 WEBSOCKET_REDIS_URL = os.environ.get("WEBSOCKET_REDIS_URL", REDIS_URL)
-WEBSOCKET_REDIS_LOCK_TIMEOUT = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", 60)
+
+websocket_redis_lock_timeout = os.environ.get("WEBSOCKET_REDIS_LOCK_TIMEOUT", "60")
+
+try:
+    WEBSOCKET_REDIS_LOCK_TIMEOUT = int(websocket_redis_lock_timeout)
+except ValueError:
+    WEBSOCKET_REDIS_LOCK_TIMEOUT = 60
 
 WEBSOCKET_SENTINEL_HOSTS = os.environ.get("WEBSOCKET_SENTINEL_HOSTS", "")
 

+ 20 - 5
backend/open_webui/main.py

@@ -89,6 +89,7 @@ from open_webui.routers import (
 
 from open_webui.routers.retrieval import (
     get_embedding_function,
+    get_reranking_function,
     get_ef,
     get_rf,
 )
@@ -878,6 +879,7 @@ app.state.config.FIRECRAWL_API_KEY = FIRECRAWL_API_KEY
 app.state.config.TAVILY_EXTRACT_DEPTH = TAVILY_EXTRACT_DEPTH
 
 app.state.EMBEDDING_FUNCTION = None
+app.state.RERANKING_FUNCTION = None
 app.state.ef = None
 app.state.rf = None
 
@@ -906,8 +908,8 @@ except Exception as e:
 app.state.EMBEDDING_FUNCTION = get_embedding_function(
     app.state.config.RAG_EMBEDDING_ENGINE,
     app.state.config.RAG_EMBEDDING_MODEL,
-    app.state.ef,
-    (
+    embedding_function=app.state.ef,
+    url=(
         app.state.config.RAG_OPENAI_API_BASE_URL
         if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
         else (
@@ -916,7 +918,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
             else app.state.config.RAG_AZURE_OPENAI_BASE_URL
         )
     ),
-    (
+    key=(
         app.state.config.RAG_OPENAI_API_KEY
         if app.state.config.RAG_EMBEDDING_ENGINE == "openai"
         else (
@@ -925,7 +927,7 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
             else app.state.config.RAG_AZURE_OPENAI_API_KEY
         )
     ),
-    app.state.config.RAG_EMBEDDING_BATCH_SIZE,
+    embedding_batch_size=app.state.config.RAG_EMBEDDING_BATCH_SIZE,
     azure_api_version=(
         app.state.config.RAG_AZURE_OPENAI_API_VERSION
         if app.state.config.RAG_EMBEDDING_ENGINE == "azure_openai"
@@ -933,6 +935,12 @@ app.state.EMBEDDING_FUNCTION = get_embedding_function(
     ),
 )
 
+app.state.RERANKING_FUNCTION = get_reranking_function(
+    app.state.config.RAG_RERANKING_ENGINE,
+    app.state.config.RAG_RERANKING_MODEL,
+    reranking_function=app.state.rf,
+)
+
 ########################################
 #
 # CODE EXECUTION
@@ -1396,7 +1404,6 @@ async def chat_completion(
         form_data, metadata, events = await process_chat_payload(
             request, form_data, user, metadata, model
         )
-
     except Exception as e:
         log.debug(f"Error processing chat payload: {e}")
         if metadata.get("chat_id") and metadata.get("message_id"):
@@ -1416,6 +1423,14 @@ async def chat_completion(
 
     try:
         response = await chat_completion_handler(request, form_data, user)
+        if metadata.get("chat_id") and metadata.get("message_id"):
+            Chats.upsert_message_to_chat_by_id_and_message_id(
+                metadata["chat_id"],
+                metadata["message_id"],
+                {
+                    "model": model_id,
+                },
+            )
 
         return await process_chat_response(
             request, response, form_data, user, metadata, model, events, tasks

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

@@ -73,7 +73,6 @@ class ChatForm(BaseModel):
 class ChatImportForm(ChatForm):
     meta: Optional[dict] = {}
     pinned: Optional[bool] = False
-    folder_id: Optional[str] = None
     created_at: Optional[int] = None
     updated_at: Optional[int] = None
 

+ 4 - 4
backend/open_webui/models/folders.py

@@ -63,7 +63,7 @@ class FolderForm(BaseModel):
 
 class FolderTable:
     def insert_new_folder(
-        self, user_id: str, name: str, parent_id: Optional[str] = None
+        self, user_id: str, form_data: FolderForm, parent_id: Optional[str] = None
     ) -> Optional[FolderModel]:
         with get_db() as db:
             id = str(uuid.uuid4())
@@ -71,7 +71,7 @@ class FolderTable:
                 **{
                     "id": id,
                     "user_id": user_id,
-                    "name": name,
+                    **(form_data.model_dump(exclude_unset=True) or {}),
                     "parent_id": parent_id,
                     "created_at": int(time.time()),
                     "updated_at": int(time.time()),
@@ -212,13 +212,13 @@ class FolderTable:
                     .first()
                 )
 
-                if existing_folder:
+                if existing_folder and existing_folder.id != id:
                     return None
 
                 folder.name = form_data.get("name", folder.name)
                 if "data" in form_data:
                     folder.data = {
-                        **folder.data,
+                        **(folder.data or {}),
                         **form_data["data"],
                     }
 

+ 53 - 1
backend/open_webui/models/groups.py

@@ -83,10 +83,14 @@ class GroupForm(BaseModel):
     permissions: Optional[dict] = None
 
 
-class GroupUpdateForm(GroupForm):
+class UserIdsForm(BaseModel):
     user_ids: Optional[list[str]] = None
 
 
+class GroupUpdateForm(GroupForm, UserIdsForm):
+    pass
+
+
 class GroupTable:
     def insert_new_group(
         self, user_id: str, form_data: GroupForm
@@ -275,5 +279,53 @@ class GroupTable:
                 log.exception(e)
                 return False
 
+    def add_users_to_group(
+        self, id: str, user_ids: Optional[list[str]] = None
+    ) -> Optional[GroupModel]:
+        try:
+            with get_db() as db:
+                group = db.query(Group).filter_by(id=id).first()
+                if not group:
+                    return None
+
+                if not group.user_ids:
+                    group.user_ids = []
+
+                for user_id in user_ids:
+                    if user_id not in group.user_ids:
+                        group.user_ids.append(user_id)
+
+                group.updated_at = int(time.time())
+                db.commit()
+                db.refresh(group)
+                return GroupModel.model_validate(group)
+        except Exception as e:
+            log.exception(e)
+            return None
+
+    def remove_users_from_group(
+        self, id: str, user_ids: Optional[list[str]] = None
+    ) -> Optional[GroupModel]:
+        try:
+            with get_db() as db:
+                group = db.query(Group).filter_by(id=id).first()
+                if not group:
+                    return None
+
+                if not group.user_ids:
+                    return GroupModel.model_validate(group)
+
+                for user_id in user_ids:
+                    if user_id in group.user_ids:
+                        group.user_ids.remove(user_id)
+
+                group.updated_at = int(time.time())
+                db.commit()
+                db.refresh(group)
+                return GroupModel.model_validate(group)
+        except Exception as e:
+            log.exception(e)
+            return None
+
 
 Groups = GroupTable()

+ 13 - 4
backend/open_webui/models/memories.py

@@ -71,9 +71,13 @@ class MemoriesTable:
     ) -> Optional[MemoryModel]:
         with get_db() as db:
             try:
-                db.query(Memory).filter_by(id=id, user_id=user_id).update(
-                    {"content": content, "updated_at": int(time.time())}
-                )
+                memory = db.get(Memory, id)
+                if not memory or memory.user_id != user_id:
+                    return None
+
+                memory.content = content
+                memory.updated_at = int(time.time())
+
                 db.commit()
                 return self.get_memory_by_id(id)
             except Exception:
@@ -127,7 +131,12 @@ class MemoriesTable:
     def delete_memory_by_id_and_user_id(self, id: str, user_id: str) -> bool:
         with get_db() as db:
             try:
-                db.query(Memory).filter_by(id=id, user_id=user_id).delete()
+                memory = db.get(Memory, id)
+                if not memory or memory.user_id != user_id:
+                    return None
+
+                # Delete the memory
+                db.delete(memory)
                 db.commit()
 
                 return True

+ 12 - 0
backend/open_webui/models/users.py

@@ -74,6 +74,18 @@ class UserListResponse(BaseModel):
     total: int
 
 
+class UserInfoResponse(BaseModel):
+    id: str
+    name: str
+    email: str
+    role: str
+
+
+class UserInfoListResponse(BaseModel):
+    users: list[UserInfoResponse]
+    total: int
+
+
 class UserResponse(BaseModel):
     id: str
     name: str

+ 16 - 2
backend/open_webui/retrieval/models/external.py

@@ -1,8 +1,10 @@
 import logging
 import requests
 from typing import Optional, List, Tuple
+from urllib.parse import quote
 
-from open_webui.env import SRC_LOG_LEVELS
+
+from open_webui.env import ENABLE_FORWARD_USER_INFO_HEADERS, SRC_LOG_LEVELS
 from open_webui.retrieval.models.base_reranker import BaseReranker
 
 
@@ -21,7 +23,9 @@ class ExternalReranker(BaseReranker):
         self.url = url
         self.model = model
 
-    def predict(self, sentences: List[Tuple[str, str]]) -> Optional[List[float]]:
+    def predict(
+        self, sentences: List[Tuple[str, str]], user=None
+    ) -> Optional[List[float]]:
         query = sentences[0][0]
         docs = [i[1] for i in sentences]
 
@@ -41,6 +45,16 @@ class ExternalReranker(BaseReranker):
                 headers={
                     "Content-Type": "application/json",
                     "Authorization": f"Bearer {self.api_key}",
+                    **(
+                        {
+                            "X-OpenWebUI-User-Name": quote(user.name, safe=" "),
+                            "X-OpenWebUI-User-Id": user.id,
+                            "X-OpenWebUI-User-Email": user.email,
+                            "X-OpenWebUI-User-Role": user.role,
+                        }
+                        if ENABLE_FORWARD_USER_INFO_HEADERS and user
+                        else {}
+                    ),
                 },
                 json=payload,
             )

+ 28 - 6
backend/open_webui/retrieval/utils.py

@@ -445,6 +445,17 @@ def get_embedding_function(
         raise ValueError(f"Unknown embedding engine: {embedding_engine}")
 
 
+def get_reranking_function(reranking_engine, reranking_model, reranking_function):
+    if reranking_function is None:
+        return None
+    if reranking_engine == "external":
+        return lambda sentences, user=None: reranking_function.predict(
+            sentences, user=user
+        )
+    else:
+        return lambda sentences, user=None: reranking_function.predict(sentences)
+
+
 def get_sources_from_items(
     request,
     items,
@@ -477,8 +488,12 @@ def get_sources_from_items(
             if item.get("file"):
                 # if item has file data, use it
                 query_result = {
-                    "documents": [[item.get("file").get("data", {}).get("content")]],
-                    "metadatas": [[item.get("file").get("data", {}).get("meta", {})]],
+                    "documents": [
+                        [item.get("file", {}).get("data", {}).get("content")]
+                    ],
+                    "metadatas": [
+                        [item.get("file", {}).get("data", {}).get("meta", {})]
+                    ],
                 }
             else:
                 # Fallback to item content
@@ -493,7 +508,11 @@ def get_sources_from_items(
             # Note Attached
             note = Notes.get_note_by_id(item.get("id"))
 
-            if user.role == "admin" or has_access(user.id, "read", note.access_control):
+            if note and (
+                user.role == "admin"
+                or note.user_id == user.id
+                or has_access(user.id, "read", note.access_control)
+            ):
                 # User has access to the note
                 query_result = {
                     "documents": [[note.data.get("content", {}).get("md", "")]],
@@ -505,12 +524,12 @@ def get_sources_from_items(
                 item.get("context") == "full"
                 or request.app.state.config.BYPASS_EMBEDDING_AND_RETRIEVAL
             ):
-                if item.get("file").get("data", {}):
+                if item.get("file", {}).get("data", {}).get("content", ""):
                     # Manual Full Mode Toggle
                     # Used from chat file modal, we can assume that the file content will be available from item.get("file").get("data", {}).get("content")
                     query_result = {
                         "documents": [
-                            [item.get("file").get("data", {}).get("content", "")]
+                            [item.get("file", {}).get("data", {}).get("content", "")]
                         ],
                         "metadatas": [
                             [
@@ -596,6 +615,9 @@ def get_sources_from_items(
         elif item.get("collection_name"):
             # Direct Collection Name
             collection_names.append(item["collection_name"])
+        elif item.get("collection_names"):
+            # Collection Names List
+            collection_names.extend(item["collection_names"])
 
         # If query_result is None
         # Fallback to collection names and vector search the collections
@@ -925,7 +947,7 @@ class RerankCompressor(BaseDocumentCompressor):
         reranking = self.reranking_function is not None
 
         if reranking:
-            scores = self.reranking_function.predict(
+            scores = self.reranking_function(
                 [(query, doc.page_content) for doc in documents]
             )
         else:

+ 23 - 4
backend/open_webui/retrieval/vector/dbs/pgvector.py

@@ -18,7 +18,7 @@ from sqlalchemy import (
     values,
 )
 from sqlalchemy.sql import true
-from sqlalchemy.pool import NullPool
+from sqlalchemy.pool import NullPool, QueuePool
 
 from sqlalchemy.orm import declarative_base, scoped_session, sessionmaker
 from sqlalchemy.dialects.postgresql import JSONB, array
@@ -37,6 +37,10 @@ from open_webui.config import (
     PGVECTOR_INITIALIZE_MAX_VECTOR_LENGTH,
     PGVECTOR_PGCRYPTO,
     PGVECTOR_PGCRYPTO_KEY,
+    PGVECTOR_POOL_SIZE,
+    PGVECTOR_POOL_MAX_OVERFLOW,
+    PGVECTOR_POOL_TIMEOUT,
+    PGVECTOR_POOL_RECYCLE,
 )
 
 from open_webui.env import SRC_LOG_LEVELS
@@ -80,9 +84,24 @@ class PgvectorClient(VectorDBBase):
 
             self.session = Session
         else:
-            engine = create_engine(
-                PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
-            )
+            if isinstance(PGVECTOR_POOL_SIZE, int):
+                if PGVECTOR_POOL_SIZE > 0:
+                    engine = create_engine(
+                        PGVECTOR_DB_URL,
+                        pool_size=PGVECTOR_POOL_SIZE,
+                        max_overflow=PGVECTOR_POOL_MAX_OVERFLOW,
+                        pool_timeout=PGVECTOR_POOL_TIMEOUT,
+                        pool_recycle=PGVECTOR_POOL_RECYCLE,
+                        pool_pre_ping=True,
+                        poolclass=QueuePool,
+                    )
+                else:
+                    engine = create_engine(
+                        PGVECTOR_DB_URL, pool_pre_ping=True, poolclass=NullPool
+                    )
+            else:
+                engine = create_engine(PGVECTOR_DB_URL, pool_pre_ping=True)
+
             SessionLocal = sessionmaker(
                 autocommit=False, autoflush=False, bind=engine, expire_on_commit=False
             )

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

@@ -376,9 +376,13 @@ async def speech(request: Request, user=Depends(get_verified_user)):
 
             if r is not None:
                 status_code = r.status
-                res = await r.json()
-                if "error" in res:
-                    detail = f"External: {res['error'].get('message', '')}"
+
+                try:
+                    res = await r.json()
+                    if "error" in res:
+                        detail = f"External: {res['error']}"
+                except Exception:
+                    detail = f"External: {e}"
 
             raise HTTPException(
                 status_code=status_code,

+ 18 - 14
backend/open_webui/routers/channels.py

@@ -434,13 +434,6 @@ async def update_message_by_id(
             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(
@@ -452,6 +445,15 @@ async def update_message_by_id(
             status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
         )
 
+    if (
+        user.role != "admin"
+        and message.user_id != user.id
+        and not has_access(user.id, type="read", access_control=channel.access_control)
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
     try:
         message = Messages.update_message_by_id(message_id, form_data)
         message = Messages.get_message_by_id(message_id)
@@ -641,13 +643,6 @@ async def delete_message_by_id(
             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(
@@ -659,6 +654,15 @@ async def delete_message_by_id(
             status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
         )
 
+    if (
+        user.role != "admin"
+        and message.user_id != user.id
+        and not has_access(user.id, type="read", access_control=channel.access_control)
+    ):
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()
+        )
+
     try:
         Messages.delete_message_by_id(message_id)
         await sio.emit(

+ 14 - 6
backend/open_webui/routers/chats.py

@@ -39,13 +39,21 @@ router = APIRouter()
 async def get_session_user_chat_list(
     user=Depends(get_verified_user), page: Optional[int] = None
 ):
-    if page is not None:
-        limit = 60
-        skip = (page - 1) * limit
+    try:
+        if page is not None:
+            limit = 60
+            skip = (page - 1) * limit
 
-        return Chats.get_chat_title_id_list_by_user_id(user.id, skip=skip, limit=limit)
-    else:
-        return Chats.get_chat_title_id_list_by_user_id(user.id)
+            return Chats.get_chat_title_id_list_by_user_id(
+                user.id, skip=skip, limit=limit
+            )
+        else:
+            return Chats.get_chat_title_id_list_by_user_id(user.id)
+    except Exception as e:
+        log.exception(e)
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()
+        )
 
 
 ############################

+ 10 - 4
backend/open_webui/routers/evaluations.py

@@ -129,7 +129,10 @@ async def create_feedback(
 
 @router.get("/feedback/{id}", response_model=FeedbackModel)
 async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
-    feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
+    if user.role == "admin":
+        feedback = Feedbacks.get_feedback_by_id(id=id)
+    else:
+        feedback = Feedbacks.get_feedback_by_id_and_user_id(id=id, user_id=user.id)
 
     if not feedback:
         raise HTTPException(
@@ -143,9 +146,12 @@ async def get_feedback_by_id(id: str, user=Depends(get_verified_user)):
 async def update_feedback_by_id(
     id: str, form_data: FeedbackForm, user=Depends(get_verified_user)
 ):
-    feedback = Feedbacks.update_feedback_by_id_and_user_id(
-        id=id, user_id=user.id, form_data=form_data
-    )
+    if user.role == "admin":
+        feedback = Feedbacks.update_feedback_by_id(id=id, form_data=form_data)
+    else:
+        feedback = Feedbacks.update_feedback_by_id_and_user_id(
+            id=id, user_id=user.id, form_data=form_data
+        )
 
     if not feedback:
         raise HTTPException(

+ 3 - 1
backend/open_webui/routers/files.py

@@ -21,6 +21,7 @@ from fastapi import (
 from fastapi.responses import FileResponse, StreamingResponse
 from open_webui.constants import ERROR_MESSAGES
 from open_webui.env import SRC_LOG_LEVELS
+from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
 
 from open_webui.models.users import Users
 from open_webui.models.files import (
@@ -286,6 +287,7 @@ async def delete_all_files(user=Depends(get_admin_user)):
     if result:
         try:
             Storage.delete_all_files()
+            VECTOR_DB_CLIENT.reset()
         except Exception as e:
             log.exception(e)
             log.error("Error deleting files")
@@ -603,12 +605,12 @@ async def delete_file_by_id(id: str, user=Depends(get_verified_user)):
         or user.role == "admin"
         or has_access_to_file(id, "write", user)
     ):
-        # We should add Chroma cleanup here
 
         result = Files.delete_file_by_id(id)
         if result:
             try:
                 Storage.delete_file(file.path)
+                VECTOR_DB_CLIENT.delete(collection_name=f"file-{id}")
             except Exception as e:
                 log.exception(e)
                 log.error("Error deleting files")

+ 3 - 3
backend/open_webui/routers/folders.py

@@ -49,7 +49,7 @@ async def get_folders(user=Depends(get_verified_user)):
             **folder.model_dump(),
             "items": {
                 "chats": [
-                    {"title": chat.title, "id": chat.id}
+                    {"title": chat.title, "id": chat.id, "updated_at": chat.updated_at}
                     for chat in Chats.get_chats_by_folder_id_and_user_id(
                         folder.id, user.id
                     )
@@ -78,7 +78,7 @@ def create_folder(form_data: FolderForm, user=Depends(get_verified_user)):
         )
 
     try:
-        folder = Folders.insert_new_folder(user.id, form_data.name)
+        folder = Folders.insert_new_folder(user.id, form_data)
         return folder
     except Exception as e:
         log.exception(e)
@@ -120,7 +120,7 @@ async def update_folder_name_by_id(
         existing_folder = Folders.get_folder_by_parent_id_and_user_id_and_name(
             folder.parent_id, user.id, form_data.name
         )
-        if existing_folder:
+        if existing_folder and existing_folder.id != id:
             raise HTTPException(
                 status_code=status.HTTP_400_BAD_REQUEST,
                 detail=ERROR_MESSAGES.DEFAULT("Folder already exists"),

+ 51 - 0
backend/open_webui/routers/groups.py

@@ -9,6 +9,7 @@ from open_webui.models.groups import (
     GroupForm,
     GroupUpdateForm,
     GroupResponse,
+    UserIdsForm,
 )
 
 from open_webui.config import CACHE_DIR
@@ -107,6 +108,56 @@ async def update_group_by_id(
         )
 
 
+############################
+# AddUserToGroupByUserIdAndGroupId
+############################
+
+
+@router.post("/id/{id}/users/add", response_model=Optional[GroupResponse])
+async def add_user_to_group(
+    id: str, form_data: UserIdsForm, 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.add_users_to_group(id, form_data.user_ids)
+        if group:
+            return group
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=ERROR_MESSAGES.DEFAULT("Error adding users to group"),
+            )
+    except Exception as e:
+        log.exception(f"Error adding users to group {id}: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.DEFAULT(e),
+        )
+
+
+@router.post("/id/{id}/users/remove", response_model=Optional[GroupResponse])
+async def remove_users_from_group(
+    id: str, form_data: UserIdsForm, user=Depends(get_admin_user)
+):
+    try:
+        group = Groups.remove_users_from_group(id, form_data.user_ids)
+        if group:
+            return group
+        else:
+            raise HTTPException(
+                status_code=status.HTTP_400_BAD_REQUEST,
+                detail=ERROR_MESSAGES.DEFAULT("Error removing users from group"),
+            )
+    except Exception as e:
+        log.exception(f"Error removing users from group {id}: {e}")
+        raise HTTPException(
+            status_code=status.HTTP_400_BAD_REQUEST,
+            detail=ERROR_MESSAGES.DEFAULT(e),
+        )
+
+
 ############################
 # DeleteGroupById
 ############################

+ 4 - 0
backend/open_webui/routers/memories.py

@@ -82,6 +82,10 @@ class QueryMemoryForm(BaseModel):
 async def query_memory(
     request: Request, form_data: QueryMemoryForm, user=Depends(get_verified_user)
 ):
+    memories = Memories.get_memories_by_user_id(user.id)
+    if not memories:
+        raise HTTPException(status_code=404, detail="No memories found for user")
+
     results = VECTOR_DB_CLIENT.search(
         collection_name=f"user-memory-{user.id}",
         vectors=[request.app.state.EMBEDDING_FUNCTION(form_data.content, user=user)],

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

@@ -6,6 +6,9 @@ from typing import Optional
 from fastapi import APIRouter, Depends, HTTPException, Request, status, BackgroundTasks
 from pydantic import BaseModel
 
+from open_webui.socket.main import sio
+
+
 from open_webui.models.users import Users, UserResponse
 from open_webui.models.notes import Notes, NoteModel, NoteForm, NoteUserResponse
 
@@ -170,6 +173,12 @@ async def update_note_by_id(
 
     try:
         note = Notes.update_note_by_id(id, form_data)
+        await sio.emit(
+            "note-events",
+            note.model_dump(),
+            to=f"note:{note.id}",
+        )
+
         return note
     except Exception as e:
         log.exception(e)

+ 14 - 1
backend/open_webui/routers/ollama.py

@@ -124,6 +124,7 @@ async def send_post_request(
     key: Optional[str] = None,
     content_type: Optional[str] = None,
     user: UserModel = None,
+    metadata: Optional[dict] = None,
 ):
 
     r = None
@@ -144,6 +145,11 @@ async def send_post_request(
                         "X-OpenWebUI-User-Id": user.id,
                         "X-OpenWebUI-User-Email": user.email,
                         "X-OpenWebUI-User-Role": user.role,
+                        **(
+                            {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
+                            if metadata and metadata.get("chat_id")
+                            else {}
+                        ),
                     }
                     if ENABLE_FORWARD_USER_INFO_HEADERS and user
                     else {}
@@ -184,7 +190,6 @@ async def send_post_request(
             )
         else:
             res = await r.json()
-            await cleanup_response(r, session)
             return res
 
     except HTTPException as e:
@@ -196,6 +201,9 @@ async def send_post_request(
             status_code=r.status if r else 500,
             detail=detail if e else "Open WebUI: Server Connection Error",
         )
+    finally:
+        if not stream:
+            await cleanup_response(r, session)
 
 
 def get_api_key(idx, url, configs):
@@ -1363,6 +1371,7 @@ async def generate_chat_completion(
         key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
         content_type="application/x-ndjson",
         user=user,
+        metadata=metadata,
     )
 
 
@@ -1401,6 +1410,8 @@ async def generate_openai_completion(
     url_idx: Optional[int] = None,
     user=Depends(get_verified_user),
 ):
+    metadata = form_data.pop("metadata", None)
+
     try:
         form_data = OpenAICompletionForm(**form_data)
     except Exception as e:
@@ -1466,6 +1477,7 @@ async def generate_openai_completion(
         stream=payload.get("stream", False),
         key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
         user=user,
+        metadata=metadata,
     )
 
 
@@ -1547,6 +1559,7 @@ async def generate_openai_chat_completion(
         stream=payload.get("stream", False),
         key=get_api_key(url_idx, url, request.app.state.config.OLLAMA_API_CONFIGS),
         user=user,
+        metadata=metadata,
     )
 
 

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

@@ -822,6 +822,11 @@ async def generate_chat_completion(
                 "X-OpenWebUI-User-Id": user.id,
                 "X-OpenWebUI-User-Email": user.email,
                 "X-OpenWebUI-User-Role": user.role,
+                **(
+                    {"X-OpenWebUI-Chat-Id": metadata.get("chat_id")}
+                    if metadata and metadata.get("chat_id")
+                    else {}
+                ),
             }
             if ENABLE_FORWARD_USER_INFO_HEADERS
             else {}
@@ -893,10 +898,8 @@ async def generate_chat_completion(
             detail=detail if detail else "Open WebUI: Server Connection Error",
         )
     finally:
-        if not streaming and session:
-            if r:
-                r.close()
-            await session.close()
+        if not streaming:
+            await cleanup_response(r, session)
 
 
 async def embeddings(request: Request, form_data: dict, user):
@@ -975,10 +978,8 @@ async def embeddings(request: Request, form_data: dict, user):
             detail=detail if detail else "Open WebUI: Server Connection Error",
         )
     finally:
-        if not streaming and session:
-            if r:
-                r.close()
-            await session.close()
+        if not streaming:
+            await cleanup_response(r, session)
 
 
 @router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
@@ -1074,7 +1075,5 @@ async def proxy(path: str, request: Request, user=Depends(get_verified_user)):
             detail=detail if detail else "Open WebUI: Server Connection Error",
         )
     finally:
-        if not streaming and session:
-            if r:
-                r.close()
-            await session.close()
+        if not streaming:
+            await cleanup_response(r, session)

+ 30 - 3
backend/open_webui/routers/retrieval.py

@@ -70,6 +70,7 @@ from open_webui.retrieval.web.external import search_external
 
 from open_webui.retrieval.utils import (
     get_embedding_function,
+    get_reranking_function,
     get_model_path,
     query_collection,
     query_collection_with_hybrid_search,
@@ -814,7 +815,11 @@ async def update_rag_config(
         f"Updating reranking model: {request.app.state.config.RAG_RERANKING_MODEL} to {form_data.RAG_RERANKING_MODEL}"
     )
     try:
-        request.app.state.config.RAG_RERANKING_MODEL = form_data.RAG_RERANKING_MODEL
+        request.app.state.config.RAG_RERANKING_MODEL = (
+            form_data.RAG_RERANKING_MODEL
+            if form_data.RAG_RERANKING_MODEL is not None
+            else request.app.state.config.RAG_RERANKING_MODEL
+        )
 
         try:
             request.app.state.rf = get_rf(
@@ -824,6 +829,12 @@ async def update_rag_config(
                 request.app.state.config.RAG_EXTERNAL_RERANKER_API_KEY,
                 True,
             )
+
+            request.app.state.RERANKING_FUNCTION = get_reranking_function(
+                request.app.state.config.RAG_RERANKING_ENGINE,
+                request.app.state.config.RAG_RERANKING_MODEL,
+                request.app.state.rf,
+            )
         except Exception as e:
             log.error(f"Error loading reranking model: {e}")
             request.app.state.config.ENABLE_RAG_HYBRID_SEARCH = False
@@ -2042,7 +2053,15 @@ def query_doc_handler(
                     query, prefix=prefix, user=user
                 ),
                 k=form_data.k if form_data.k else request.app.state.config.TOP_K,
-                reranking_function=request.app.state.rf,
+                reranking_function=(
+                    (
+                        lambda sentences: request.app.state.RERANKING_FUNCTION(
+                            sentences, user=user
+                        )
+                    )
+                    if request.app.state.RERANKING_FUNCTION
+                    else None
+                ),
                 k_reranker=form_data.k_reranker
                 or request.app.state.config.TOP_K_RERANKER,
                 r=(
@@ -2099,7 +2118,15 @@ def query_collection_handler(
                     query, prefix=prefix, user=user
                 ),
                 k=form_data.k if form_data.k else request.app.state.config.TOP_K,
-                reranking_function=request.app.state.rf,
+                reranking_function=(
+                    (
+                        lambda sentences: request.app.state.RERANKING_FUNCTION(
+                            sentences, user=user
+                        )
+                    )
+                    if request.app.state.RERANKING_FUNCTION
+                    else None
+                ),
                 k_reranker=form_data.k_reranker
                 or request.app.state.config.TOP_K_RERANKER,
                 r=(

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

@@ -695,11 +695,11 @@ async def generate_emoji(
                 "max_completion_tokens": 4,
             }
         ),
-        "chat_id": form_data.get("chat_id", None),
         "metadata": {
             **(request.state.metadata if hasattr(request.state, "metadata") else {}),
             "task": str(TASKS.EMOJI_GENERATION),
             "task_body": form_data,
+            "chat_id": form_data.get("chat_id", None),
         },
     }
 

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

@@ -7,6 +7,7 @@ from open_webui.models.chats import Chats
 from open_webui.models.users import (
     UserModel,
     UserListResponse,
+    UserInfoListResponse,
     UserRoleUpdateForm,
     Users,
     UserSettings,
@@ -83,7 +84,7 @@ async def get_users(
     return Users.get_users(filter=filter, skip=skip, limit=limit)
 
 
-@router.get("/all", response_model=UserListResponse)
+@router.get("/all", response_model=UserInfoListResponse)
 async def get_all_users(
     user=Depends(get_admin_user),
 ):

+ 129 - 65
backend/open_webui/socket/main.py

@@ -27,7 +27,7 @@ from open_webui.env import (
     WEBSOCKET_SENTINEL_HOSTS,
 )
 from open_webui.utils.auth import decode_token
-from open_webui.socket.utils import RedisDict, RedisLock
+from open_webui.socket.utils import RedisDict, RedisLock, YdocManager
 from open_webui.tasks import create_task, stop_item_tasks
 from open_webui.utils.redis import get_redis_connection
 from open_webui.utils.access_control import has_access, get_users_with_access
@@ -44,13 +44,7 @@ log = logging.getLogger(__name__)
 log.setLevel(SRC_LOG_LEVELS["SOCKET"])
 
 
-REDIS = get_redis_connection(
-    redis_url=WEBSOCKET_REDIS_URL,
-    redis_sentinels=get_sentinels_from_env(
-        WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
-    ),
-    async_mode=True,
-)
+REDIS = None
 
 if WEBSOCKET_MANAGER == "redis":
     if WEBSOCKET_SENTINEL_HOSTS:
@@ -86,6 +80,14 @@ TIMEOUT_DURATION = 3
 
 if WEBSOCKET_MANAGER == "redis":
     log.debug("Using Redis to manage websockets.")
+    REDIS = get_redis_connection(
+        redis_url=WEBSOCKET_REDIS_URL,
+        redis_sentinels=get_sentinels_from_env(
+            WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
+        ),
+        async_mode=True,
+    )
+
     redis_sentinels = get_sentinels_from_env(
         WEBSOCKET_SENTINEL_HOSTS, WEBSOCKET_SENTINEL_PORT
     )
@@ -105,10 +107,6 @@ if WEBSOCKET_MANAGER == "redis":
         redis_sentinels=redis_sentinels,
     )
 
-    # TODO: Implement Yjs document management with Redis
-    DOCUMENTS = {}
-    DOCUMENT_USERS = {}
-
     clean_up_lock = RedisLock(
         redis_url=WEBSOCKET_REDIS_URL,
         lock_name="usage_cleanup_lock",
@@ -123,11 +121,15 @@ else:
     USER_POOL = {}
     USAGE_POOL = {}
 
-    DOCUMENTS = {}  # document_id -> Y.YDoc instance
-    DOCUMENT_USERS = {}  # document_id -> set of user sids
     aquire_func = release_func = renew_func = lambda: True
 
 
+YDOC_MANAGER = YdocManager(
+    redis=REDIS,
+    redis_key_prefix="open-webui:ydoc:documents",
+)
+
+
 async def periodic_usage_pool_cleanup():
     max_retries = 2
     retry_delay = random.uniform(
@@ -209,16 +211,20 @@ def get_user_id_from_session_pool(sid):
     return None
 
 
-def get_user_ids_from_room(room):
+def get_session_ids_from_room(room):
+    """Get all session IDs from a specific room."""
     active_session_ids = sio.manager.get_participants(
         namespace="/",
         room=room,
     )
+    return [session_id[0] for session_id in active_session_ids]
+
+
+def get_user_ids_from_room(room):
+    active_session_ids = get_session_ids_from_room(room)
 
     active_user_ids = list(
-        set(
-            [SESSION_POOL.get(session_id[0])["id"] for session_id in active_session_ids]
-        )
+        set([SESSION_POOL.get(session_id)["id"] for session_id in active_session_ids])
     )
     return active_user_ids
 
@@ -310,6 +316,37 @@ async def join_channel(sid, data):
         await sio.enter_room(sid, f"channel:{channel.id}")
 
 
+@sio.on("join-note")
+async def join_note(sid, data):
+    auth = data["auth"] if "auth" in data else None
+    if not auth or "token" not in auth:
+        return
+
+    token_data = decode_token(auth["token"])
+    if token_data is None or "id" not in token_data:
+        return
+
+    user = Users.get_user_by_id(token_data["id"])
+    if not user:
+        return
+
+    note = Notes.get_note_by_id(data["note_id"])
+    if not note:
+        log.error(f"Note {data['note_id']} not found for user {user.id}")
+        return
+
+    if (
+        user.role != "admin"
+        and user.id != note.user_id
+        and not has_access(user.id, type="read", access_control=note.access_control)
+    ):
+        log.error(f"User {user.id} does not have access to note {data['note_id']}")
+        return
+
+    log.debug(f"Joining note {note.id} for user {user.id}")
+    await sio.enter_room(sid, f"note:{note.id}")
+
+
 @sio.on("channel-events")
 async def channel_events(sid, data):
     room = f"channel:{data['channel_id']}"
@@ -338,8 +375,8 @@ async def channel_events(sid, data):
         )
 
 
-@sio.on("yjs:document:join")
-async def yjs_document_join(sid, data):
+@sio.on("ydoc:document:join")
+async def ydoc_document_join(sid, data):
     """Handle user joining a document"""
     user = SESSION_POOL.get(sid)
 
@@ -370,39 +407,34 @@ async def yjs_document_join(sid, data):
         user_color = data.get("user_color", "#000000")
 
         log.info(f"User {user_id} joining document {document_id}")
-
-        # Initialize document if it doesn't exist
-        if document_id not in DOCUMENTS:
-            DOCUMENTS[document_id] = {
-                "ydoc": Y.Doc(),  # Create actual Yjs document
-                "users": set(),
-            }
-            DOCUMENT_USERS[document_id] = set()
-
-        # Add user to document
-        DOCUMENTS[document_id]["users"].add(sid)
-        DOCUMENT_USERS[document_id].add(sid)
+        await YDOC_MANAGER.add_user(document_id=document_id, user_id=sid)
 
         # Join Socket.IO room
         await sio.enter_room(sid, f"doc_{document_id}")
 
-        # Send current document state as a proper Yjs update
-        ydoc = DOCUMENTS[document_id]["ydoc"]
+        active_session_ids = get_session_ids_from_room(f"doc_{document_id}")
+
+        # Get the Yjs document state
+        ydoc = Y.Doc()
+        updates = await YDOC_MANAGER.get_updates(document_id)
+        for update in updates:
+            ydoc.apply_update(bytes(update))
 
         # Encode the entire document state as an update
         state_update = ydoc.get_update()
         await sio.emit(
-            "yjs:document:state",
+            "ydoc:document:state",
             {
                 "document_id": document_id,
                 "state": list(state_update),  # Convert bytes to list for JSON
+                "sessions": active_session_ids,
             },
             room=sid,
         )
 
         # Notify other users about the new user
         await sio.emit(
-            "yjs:user:joined",
+            "ydoc:user:joined",
             {
                 "document_id": document_id,
                 "user_id": user_id,
@@ -441,11 +473,51 @@ async def document_save_handler(document_id, data, user):
         Notes.update_note_by_id(note_id, NoteUpdateForm(data=data))
 
 
-@sio.on("yjs:document:update")
+@sio.on("ydoc:document:state")
+async def yjs_document_state(sid, data):
+    """Send the current state of the Yjs document to the user"""
+    try:
+        document_id = data["document_id"]
+        room = f"doc_{document_id}"
+
+        active_session_ids = get_session_ids_from_room(room)
+
+        if sid not in active_session_ids:
+            log.warning(f"Session {sid} not in room {room}. Cannot send state.")
+            return
+
+        if not await YDOC_MANAGER.document_exists(document_id):
+            log.warning(f"Document {document_id} not found")
+            return
+
+        # Get the Yjs document state
+        ydoc = Y.Doc()
+        updates = await YDOC_MANAGER.get_updates(document_id)
+        for update in updates:
+            ydoc.apply_update(bytes(update))
+
+        # Encode the entire document state as an update
+        state_update = ydoc.get_update()
+
+        await sio.emit(
+            "ydoc:document:state",
+            {
+                "document_id": document_id,
+                "state": list(state_update),  # Convert bytes to list for JSON
+                "sessions": active_session_ids,
+            },
+            room=sid,
+        )
+    except Exception as e:
+        log.error(f"Error in yjs_document_state: {e}")
+
+
+@sio.on("ydoc:document:update")
 async def yjs_document_update(sid, data):
     """Handle Yjs document updates"""
     try:
         document_id = data["document_id"]
+
         try:
             await stop_item_tasks(REDIS, document_id)
         except:
@@ -455,23 +527,14 @@ async def yjs_document_update(sid, data):
 
         update = data["update"]  # List of bytes from frontend
 
-        if document_id not in DOCUMENTS:
-            log.warning(f"Document {document_id} not found")
-            return
-
-        # Apply the update to the server's Yjs document
-        ydoc = DOCUMENTS[document_id]["ydoc"]
-        update_bytes = bytes(update)
-
-        try:
-            ydoc.apply_update(update_bytes)
-        except Exception as e:
-            log.error(f"Failed to apply Yjs update: {e}")
-            return
+        await YDOC_MANAGER.append_to_updates(
+            document_id=document_id,
+            update=update,  # Convert list of bytes to bytes
+        )
 
         # Broadcast update to all other users in the document
         await sio.emit(
-            "yjs:document:update",
+            "ydoc:document:update",
             {
                 "document_id": document_id,
                 "user_id": user_id,
@@ -488,13 +551,14 @@ async def yjs_document_update(sid, data):
                 document_id, data.get("data", {}), SESSION_POOL.get(sid)
             )
 
-        await create_task(REDIS, debounced_save(), document_id)
+        if data.get("data"):
+            await create_task(REDIS, debounced_save(), document_id)
 
     except Exception as e:
         log.error(f"Error in yjs_document_update: {e}")
 
 
-@sio.on("yjs:document:leave")
+@sio.on("ydoc:document:leave")
 async def yjs_document_leave(sid, data):
     """Handle user leaving a document"""
     try:
@@ -503,33 +567,31 @@ async def yjs_document_leave(sid, data):
 
         log.info(f"User {user_id} leaving document {document_id}")
 
-        if document_id in DOCUMENTS:
-            DOCUMENTS[document_id]["users"].discard(sid)
-
-        if document_id in DOCUMENT_USERS:
-            DOCUMENT_USERS[document_id].discard(sid)
+        # Remove user from the document
+        await YDOC_MANAGER.remove_user(document_id=document_id, user_id=sid)
 
         # Leave Socket.IO room
         await sio.leave_room(sid, f"doc_{document_id}")
 
         # Notify other users
         await sio.emit(
-            "yjs:user:left",
+            "ydoc:user:left",
             {"document_id": document_id, "user_id": user_id},
             room=f"doc_{document_id}",
         )
 
-        if document_id in DOCUMENTS and not DOCUMENTS[document_id]["users"]:
-            # If no users left, clean up the document
+        if (
+            await YDOC_MANAGER.document_exists(document_id)
+            and len(await YDOC_MANAGER.get_users(document_id)) == 0
+        ):
             log.info(f"Cleaning up document {document_id} as no users are left")
-            del DOCUMENTS[document_id]
-            del DOCUMENT_USERS[document_id]
+            await YDOC_MANAGER.clear_document(document_id)
 
     except Exception as e:
         log.error(f"Error in yjs_document_leave: {e}")
 
 
-@sio.on("yjs:awareness:update")
+@sio.on("ydoc:awareness:update")
 async def yjs_awareness_update(sid, data):
     """Handle awareness updates (cursors, selections, etc.)"""
     try:
@@ -539,7 +601,7 @@ async def yjs_awareness_update(sid, data):
 
         # Broadcast awareness update to all other users in the document
         await sio.emit(
-            "yjs:awareness:update",
+            "ydoc:awareness:update",
             {"document_id": document_id, "user_id": user_id, "update": update},
             room=f"doc_{document_id}",
             skip_sid=sid,
@@ -560,6 +622,8 @@ async def disconnect(sid):
 
         if len(USER_POOL[user_id]) == 0:
             del USER_POOL[user_id]
+
+        await YDOC_MANAGER.remove_user_from_all_documents(sid)
     else:
         pass
         # print(f"Unknown session ID {sid} disconnected")

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

@@ -1,6 +1,8 @@
 import json
 import uuid
 from open_webui.utils.redis import get_redis_connection
+from typing import Optional, List, Tuple
+import pycrdt as Y
 
 
 class RedisLock:
@@ -89,3 +91,109 @@ class RedisDict:
         if key not in self:
             self[key] = default
         return self[key]
+
+
+class YdocManager:
+    def __init__(
+        self,
+        redis=None,
+        redis_key_prefix: str = "open-webui:ydoc:documents",
+    ):
+        self._updates = {}
+        self._users = {}
+        self._redis = redis
+        self._redis_key_prefix = redis_key_prefix
+
+    async def append_to_updates(self, document_id: str, update: bytes):
+        document_id = document_id.replace(":", "_")
+        if self._redis:
+            redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+            await self._redis.rpush(redis_key, json.dumps(list(update)))
+        else:
+            if document_id not in self._updates:
+                self._updates[document_id] = []
+            self._updates[document_id].append(update)
+
+    async def get_updates(self, document_id: str) -> List[bytes]:
+        document_id = document_id.replace(":", "_")
+
+        if self._redis:
+            redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+            updates = await self._redis.lrange(redis_key, 0, -1)
+            return [bytes(json.loads(update)) for update in updates]
+        else:
+            return self._updates.get(document_id, [])
+
+    async def document_exists(self, document_id: str) -> bool:
+        document_id = document_id.replace(":", "_")
+
+        if self._redis:
+            redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+            return await self._redis.exists(redis_key) > 0
+        else:
+            return document_id in self._updates
+
+    async def get_users(self, document_id: str) -> List[str]:
+        document_id = document_id.replace(":", "_")
+
+        if self._redis:
+            redis_key = f"{self._redis_key_prefix}:{document_id}:users"
+            users = await self._redis.smembers(redis_key)
+            return list(users)
+        else:
+            return self._users.get(document_id, [])
+
+    async def add_user(self, document_id: str, user_id: str):
+        document_id = document_id.replace(":", "_")
+
+        if self._redis:
+            redis_key = f"{self._redis_key_prefix}:{document_id}:users"
+            await self._redis.sadd(redis_key, user_id)
+        else:
+            if document_id not in self._users:
+                self._users[document_id] = set()
+            self._users[document_id].add(user_id)
+
+    async def remove_user(self, document_id: str, user_id: str):
+        document_id = document_id.replace(":", "_")
+
+        if self._redis:
+            redis_key = f"{self._redis_key_prefix}:{document_id}:users"
+            await self._redis.srem(redis_key, user_id)
+        else:
+            if document_id in self._users and user_id in self._users[document_id]:
+                self._users[document_id].remove(user_id)
+
+    async def remove_user_from_all_documents(self, user_id: str):
+        if self._redis:
+            keys = await self._redis.keys(f"{self._redis_key_prefix}:*")
+            for key in keys:
+                if key.endswith(":users"):
+                    await self._redis.srem(key, user_id)
+
+                    document_id = key.split(":")[-2]
+                    if len(await self.get_users(document_id)) == 0:
+                        await self.clear_document(document_id)
+
+        else:
+            for document_id in list(self._users.keys()):
+                if user_id in self._users[document_id]:
+                    self._users[document_id].remove(user_id)
+                    if not self._users[document_id]:
+                        del self._users[document_id]
+
+                        await self.clear_document(document_id)
+
+    async def clear_document(self, document_id: str):
+        document_id = document_id.replace(":", "_")
+
+        if self._redis:
+            redis_key = f"{self._redis_key_prefix}:{document_id}:updates"
+            await self._redis.delete(redis_key)
+            redis_users_key = f"{self._redis_key_prefix}:{document_id}:users"
+            await self._redis.delete(redis_users_key)
+        else:
+            if document_id in self._updates:
+                del self._updates[document_id]
+            if document_id in self._users:
+                del self._users[document_id]

+ 793 - 0
backend/open_webui/test/util/test_redis.py

@@ -0,0 +1,793 @@
+import pytest
+from unittest.mock import Mock, patch, AsyncMock
+import redis
+from open_webui.utils.redis import (
+    SentinelRedisProxy,
+    parse_redis_service_url,
+    get_redis_connection,
+    get_sentinels_from_env,
+    MAX_RETRY_COUNT,
+)
+import inspect
+
+
+class TestSentinelRedisProxy:
+    """Test Redis Sentinel failover functionality"""
+
+    def test_parse_redis_service_url_valid(self):
+        """Test parsing valid Redis service URL"""
+        url = "redis://user:pass@mymaster:6379/0"
+        result = parse_redis_service_url(url)
+
+        assert result["username"] == "user"
+        assert result["password"] == "pass"
+        assert result["service"] == "mymaster"
+        assert result["port"] == 6379
+        assert result["db"] == 0
+
+    def test_parse_redis_service_url_defaults(self):
+        """Test parsing Redis service URL with defaults"""
+        url = "redis://mymaster"
+        result = parse_redis_service_url(url)
+
+        assert result["username"] is None
+        assert result["password"] is None
+        assert result["service"] == "mymaster"
+        assert result["port"] == 6379
+        assert result["db"] == 0
+
+    def test_parse_redis_service_url_invalid_scheme(self):
+        """Test parsing invalid URL scheme"""
+        with pytest.raises(ValueError, match="Invalid Redis URL scheme"):
+            parse_redis_service_url("http://invalid")
+
+    def test_get_sentinels_from_env(self):
+        """Test parsing sentinel hosts from environment"""
+        hosts = "sentinel1,sentinel2,sentinel3"
+        port = "26379"
+
+        result = get_sentinels_from_env(hosts, port)
+        expected = [("sentinel1", 26379), ("sentinel2", 26379), ("sentinel3", 26379)]
+
+        assert result == expected
+
+    def test_get_sentinels_from_env_empty(self):
+        """Test empty sentinel hosts"""
+        result = get_sentinels_from_env(None, "26379")
+        assert result == []
+
+    @patch("redis.sentinel.Sentinel")
+    def test_sentinel_redis_proxy_sync_success(self, mock_sentinel_class):
+        """Test successful sync operation with SentinelRedisProxy"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_master.get.return_value = "test_value"
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test attribute access
+        get_method = proxy.__getattr__("get")
+        result = get_method("test_key")
+
+        assert result == "test_value"
+        mock_sentinel.master_for.assert_called_with("mymaster")
+        mock_master.get.assert_called_with("test_key")
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_sentinel_redis_proxy_async_success(self, mock_sentinel_class):
+        """Test successful async operation with SentinelRedisProxy"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_master.get = AsyncMock(return_value="test_value")
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test async attribute access
+        get_method = proxy.__getattr__("get")
+        result = await get_method("test_key")
+
+        assert result == "test_value"
+        mock_sentinel.master_for.assert_called_with("mymaster")
+        mock_master.get.assert_called_with("test_key")
+
+    @patch("redis.sentinel.Sentinel")
+    def test_sentinel_redis_proxy_failover_retry(self, mock_sentinel_class):
+        """Test retry mechanism during failover"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # First call fails, second succeeds
+        mock_master.get.side_effect = [
+            redis.exceptions.ConnectionError("Master down"),
+            "test_value",
+        ]
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        get_method = proxy.__getattr__("get")
+        result = get_method("test_key")
+
+        assert result == "test_value"
+        assert mock_master.get.call_count == 2
+
+    @patch("redis.sentinel.Sentinel")
+    def test_sentinel_redis_proxy_max_retries_exceeded(self, mock_sentinel_class):
+        """Test failure after max retries exceeded"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # All calls fail
+        mock_master.get.side_effect = redis.exceptions.ConnectionError("Master down")
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        get_method = proxy.__getattr__("get")
+
+        with pytest.raises(redis.exceptions.ConnectionError):
+            get_method("test_key")
+
+        assert mock_master.get.call_count == MAX_RETRY_COUNT
+
+    @patch("redis.sentinel.Sentinel")
+    def test_sentinel_redis_proxy_readonly_error_retry(self, mock_sentinel_class):
+        """Test retry on ReadOnlyError"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # First call gets ReadOnlyError (old master), second succeeds (new master)
+        mock_master.get.side_effect = [
+            redis.exceptions.ReadOnlyError("Read only"),
+            "test_value",
+        ]
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        get_method = proxy.__getattr__("get")
+        result = get_method("test_key")
+
+        assert result == "test_value"
+        assert mock_master.get.call_count == 2
+
+    @patch("redis.sentinel.Sentinel")
+    def test_sentinel_redis_proxy_factory_methods(self, mock_sentinel_class):
+        """Test factory methods are passed through directly"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_pipeline = Mock()
+        mock_master.pipeline.return_value = mock_pipeline
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Factory methods should be passed through without wrapping
+        pipeline_method = proxy.__getattr__("pipeline")
+        result = pipeline_method()
+
+        assert result == mock_pipeline
+        mock_master.pipeline.assert_called_once()
+
+    @patch("redis.sentinel.Sentinel")
+    @patch("redis.from_url")
+    def test_get_redis_connection_with_sentinel(
+        self, mock_from_url, mock_sentinel_class
+    ):
+        """Test getting Redis connection with Sentinel"""
+        mock_sentinel = Mock()
+        mock_sentinel_class.return_value = mock_sentinel
+
+        sentinels = [("sentinel1", 26379), ("sentinel2", 26379)]
+        redis_url = "redis://user:pass@mymaster:6379/0"
+
+        result = get_redis_connection(
+            redis_url=redis_url, redis_sentinels=sentinels, async_mode=False
+        )
+
+        assert isinstance(result, SentinelRedisProxy)
+        mock_sentinel_class.assert_called_once()
+        mock_from_url.assert_not_called()
+
+    @patch("redis.Redis.from_url")
+    def test_get_redis_connection_without_sentinel(self, mock_from_url):
+        """Test getting Redis connection without Sentinel"""
+        mock_redis = Mock()
+        mock_from_url.return_value = mock_redis
+
+        redis_url = "redis://localhost:6379/0"
+
+        result = get_redis_connection(
+            redis_url=redis_url, redis_sentinels=None, async_mode=False
+        )
+
+        assert result == mock_redis
+        mock_from_url.assert_called_once_with(redis_url, decode_responses=True)
+
+    @patch("redis.asyncio.from_url")
+    def test_get_redis_connection_without_sentinel_async(self, mock_from_url):
+        """Test getting async Redis connection without Sentinel"""
+        mock_redis = Mock()
+        mock_from_url.return_value = mock_redis
+
+        redis_url = "redis://localhost:6379/0"
+
+        result = get_redis_connection(
+            redis_url=redis_url, redis_sentinels=None, async_mode=True
+        )
+
+        assert result == mock_redis
+        mock_from_url.assert_called_once_with(redis_url, decode_responses=True)
+
+
+class TestSentinelRedisProxyCommands:
+    """Test Redis commands through SentinelRedisProxy"""
+
+    @patch("redis.sentinel.Sentinel")
+    def test_hash_commands_sync(self, mock_sentinel_class):
+        """Test Redis hash commands in sync mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # Mock hash command responses
+        mock_master.hset.return_value = 1
+        mock_master.hget.return_value = "test_value"
+        mock_master.hgetall.return_value = {"key1": "value1", "key2": "value2"}
+        mock_master.hdel.return_value = 1
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test hset
+        hset_method = proxy.__getattr__("hset")
+        result = hset_method("test_hash", "field1", "value1")
+        assert result == 1
+        mock_master.hset.assert_called_with("test_hash", "field1", "value1")
+
+        # Test hget
+        hget_method = proxy.__getattr__("hget")
+        result = hget_method("test_hash", "field1")
+        assert result == "test_value"
+        mock_master.hget.assert_called_with("test_hash", "field1")
+
+        # Test hgetall
+        hgetall_method = proxy.__getattr__("hgetall")
+        result = hgetall_method("test_hash")
+        assert result == {"key1": "value1", "key2": "value2"}
+        mock_master.hgetall.assert_called_with("test_hash")
+
+        # Test hdel
+        hdel_method = proxy.__getattr__("hdel")
+        result = hdel_method("test_hash", "field1")
+        assert result == 1
+        mock_master.hdel.assert_called_with("test_hash", "field1")
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_hash_commands_async(self, mock_sentinel_class):
+        """Test Redis hash commands in async mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # Mock async hash command responses
+        mock_master.hset = AsyncMock(return_value=1)
+        mock_master.hget = AsyncMock(return_value="test_value")
+        mock_master.hgetall = AsyncMock(
+            return_value={"key1": "value1", "key2": "value2"}
+        )
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test hset
+        hset_method = proxy.__getattr__("hset")
+        result = await hset_method("test_hash", "field1", "value1")
+        assert result == 1
+        mock_master.hset.assert_called_with("test_hash", "field1", "value1")
+
+        # Test hget
+        hget_method = proxy.__getattr__("hget")
+        result = await hget_method("test_hash", "field1")
+        assert result == "test_value"
+        mock_master.hget.assert_called_with("test_hash", "field1")
+
+        # Test hgetall
+        hgetall_method = proxy.__getattr__("hgetall")
+        result = await hgetall_method("test_hash")
+        assert result == {"key1": "value1", "key2": "value2"}
+        mock_master.hgetall.assert_called_with("test_hash")
+
+    @patch("redis.sentinel.Sentinel")
+    def test_string_commands_sync(self, mock_sentinel_class):
+        """Test Redis string commands in sync mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # Mock string command responses
+        mock_master.set.return_value = True
+        mock_master.get.return_value = "test_value"
+        mock_master.delete.return_value = 1
+        mock_master.exists.return_value = True
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test set
+        set_method = proxy.__getattr__("set")
+        result = set_method("test_key", "test_value")
+        assert result is True
+        mock_master.set.assert_called_with("test_key", "test_value")
+
+        # Test get
+        get_method = proxy.__getattr__("get")
+        result = get_method("test_key")
+        assert result == "test_value"
+        mock_master.get.assert_called_with("test_key")
+
+        # Test delete
+        delete_method = proxy.__getattr__("delete")
+        result = delete_method("test_key")
+        assert result == 1
+        mock_master.delete.assert_called_with("test_key")
+
+        # Test exists
+        exists_method = proxy.__getattr__("exists")
+        result = exists_method("test_key")
+        assert result is True
+        mock_master.exists.assert_called_with("test_key")
+
+    @patch("redis.sentinel.Sentinel")
+    def test_list_commands_sync(self, mock_sentinel_class):
+        """Test Redis list commands in sync mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # Mock list command responses
+        mock_master.lpush.return_value = 1
+        mock_master.rpop.return_value = "test_value"
+        mock_master.llen.return_value = 5
+        mock_master.lrange.return_value = ["item1", "item2", "item3"]
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test lpush
+        lpush_method = proxy.__getattr__("lpush")
+        result = lpush_method("test_list", "item1")
+        assert result == 1
+        mock_master.lpush.assert_called_with("test_list", "item1")
+
+        # Test rpop
+        rpop_method = proxy.__getattr__("rpop")
+        result = rpop_method("test_list")
+        assert result == "test_value"
+        mock_master.rpop.assert_called_with("test_list")
+
+        # Test llen
+        llen_method = proxy.__getattr__("llen")
+        result = llen_method("test_list")
+        assert result == 5
+        mock_master.llen.assert_called_with("test_list")
+
+        # Test lrange
+        lrange_method = proxy.__getattr__("lrange")
+        result = lrange_method("test_list", 0, -1)
+        assert result == ["item1", "item2", "item3"]
+        mock_master.lrange.assert_called_with("test_list", 0, -1)
+
+    @patch("redis.sentinel.Sentinel")
+    def test_pubsub_commands_sync(self, mock_sentinel_class):
+        """Test Redis pubsub commands in sync mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_pubsub = Mock()
+
+        # Mock pubsub responses
+        mock_master.pubsub.return_value = mock_pubsub
+        mock_master.publish.return_value = 1
+        mock_pubsub.subscribe.return_value = None
+        mock_pubsub.get_message.return_value = {"type": "message", "data": "test_data"}
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test pubsub (factory method - should pass through)
+        pubsub_method = proxy.__getattr__("pubsub")
+        result = pubsub_method()
+        assert result == mock_pubsub
+        mock_master.pubsub.assert_called_once()
+
+        # Test publish
+        publish_method = proxy.__getattr__("publish")
+        result = publish_method("test_channel", "test_message")
+        assert result == 1
+        mock_master.publish.assert_called_with("test_channel", "test_message")
+
+    @patch("redis.sentinel.Sentinel")
+    def test_pipeline_commands_sync(self, mock_sentinel_class):
+        """Test Redis pipeline commands in sync mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_pipeline = Mock()
+
+        # Mock pipeline responses
+        mock_master.pipeline.return_value = mock_pipeline
+        mock_pipeline.set.return_value = mock_pipeline
+        mock_pipeline.get.return_value = mock_pipeline
+        mock_pipeline.execute.return_value = [True, "test_value"]
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test pipeline (factory method - should pass through)
+        pipeline_method = proxy.__getattr__("pipeline")
+        result = pipeline_method()
+        assert result == mock_pipeline
+        mock_master.pipeline.assert_called_once()
+
+    @patch("redis.sentinel.Sentinel")
+    def test_commands_with_failover_retry(self, mock_sentinel_class):
+        """Test Redis commands with failover retry mechanism"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # First call fails with connection error, second succeeds
+        mock_master.hget.side_effect = [
+            redis.exceptions.ConnectionError("Connection failed"),
+            "recovered_value",
+        ]
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test hget with retry
+        hget_method = proxy.__getattr__("hget")
+        result = hget_method("test_hash", "field1")
+
+        assert result == "recovered_value"
+        assert mock_master.hget.call_count == 2
+
+        # Verify both calls were made with same parameters
+        expected_calls = [(("test_hash", "field1"),), (("test_hash", "field1"),)]
+        actual_calls = [call.args for call in mock_master.hget.call_args_list]
+        assert actual_calls == expected_calls
+
+    @patch("redis.sentinel.Sentinel")
+    def test_commands_with_readonly_error_retry(self, mock_sentinel_class):
+        """Test Redis commands with ReadOnlyError retry mechanism"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # First call fails with ReadOnlyError, second succeeds
+        mock_master.hset.side_effect = [
+            redis.exceptions.ReadOnlyError(
+                "READONLY You can't write against a read only replica"
+            ),
+            1,
+        ]
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=False)
+
+        # Test hset with retry
+        hset_method = proxy.__getattr__("hset")
+        result = hset_method("test_hash", "field1", "value1")
+
+        assert result == 1
+        assert mock_master.hset.call_count == 2
+
+        # Verify both calls were made with same parameters
+        expected_calls = [
+            (("test_hash", "field1", "value1"),),
+            (("test_hash", "field1", "value1"),),
+        ]
+        actual_calls = [call.args for call in mock_master.hset.call_args_list]
+        assert actual_calls == expected_calls
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_async_commands_with_failover_retry(self, mock_sentinel_class):
+        """Test async Redis commands with failover retry mechanism"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # First call fails with connection error, second succeeds
+        mock_master.hget = AsyncMock(
+            side_effect=[
+                redis.exceptions.ConnectionError("Connection failed"),
+                "recovered_value",
+            ]
+        )
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test async hget with retry
+        hget_method = proxy.__getattr__("hget")
+        result = await hget_method("test_hash", "field1")
+
+        assert result == "recovered_value"
+        assert mock_master.hget.call_count == 2
+
+        # Verify both calls were made with same parameters
+        expected_calls = [(("test_hash", "field1"),), (("test_hash", "field1"),)]
+        actual_calls = [call.args for call in mock_master.hget.call_args_list]
+        assert actual_calls == expected_calls
+
+
+class TestSentinelRedisProxyFactoryMethods:
+    """Test Redis factory methods in async mode - these are special cases that remain sync"""
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_pubsub_factory_method_async(self, mock_sentinel_class):
+        """Test pubsub factory method in async mode - should pass through without wrapping"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_pubsub = Mock()
+
+        # Mock pubsub factory method
+        mock_master.pubsub.return_value = mock_pubsub
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test pubsub factory method - should NOT be wrapped as async
+        pubsub_method = proxy.__getattr__("pubsub")
+        result = pubsub_method()
+
+        assert result == mock_pubsub
+        mock_master.pubsub.assert_called_once()
+
+        # Verify it's not wrapped as async (no await needed)
+        assert not inspect.iscoroutine(result)
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_pipeline_factory_method_async(self, mock_sentinel_class):
+        """Test pipeline factory method in async mode - should pass through without wrapping"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_pipeline = Mock()
+
+        # Mock pipeline factory method
+        mock_master.pipeline.return_value = mock_pipeline
+        mock_pipeline.set.return_value = mock_pipeline
+        mock_pipeline.get.return_value = mock_pipeline
+        mock_pipeline.execute.return_value = [True, "test_value"]
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test pipeline factory method - should NOT be wrapped as async
+        pipeline_method = proxy.__getattr__("pipeline")
+        result = pipeline_method()
+
+        assert result == mock_pipeline
+        mock_master.pipeline.assert_called_once()
+
+        # Verify it's not wrapped as async (no await needed)
+        assert not inspect.iscoroutine(result)
+
+        # Test pipeline usage (these should also be sync)
+        pipeline_result = result.set("key", "value").get("key").execute()
+        assert pipeline_result == [True, "test_value"]
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_factory_methods_vs_regular_commands_async(self, mock_sentinel_class):
+        """Test that factory methods behave differently from regular commands in async mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # Mock both factory method and regular command
+        mock_pubsub = Mock()
+        mock_master.pubsub.return_value = mock_pubsub
+        mock_master.get = AsyncMock(return_value="test_value")
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test factory method - should NOT be wrapped
+        pubsub_method = proxy.__getattr__("pubsub")
+        pubsub_result = pubsub_method()
+
+        # Test regular command - should be wrapped as async
+        get_method = proxy.__getattr__("get")
+        get_result = get_method("test_key")
+
+        # Factory method returns directly
+        assert pubsub_result == mock_pubsub
+        assert not inspect.iscoroutine(pubsub_result)
+
+        # Regular command returns coroutine
+        assert inspect.iscoroutine(get_result)
+
+        # Regular command needs await
+        actual_value = await get_result
+        assert actual_value == "test_value"
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_factory_methods_with_failover_async(self, mock_sentinel_class):
+        """Test factory methods with failover in async mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # First call fails, second succeeds
+        mock_pubsub = Mock()
+        mock_master.pubsub.side_effect = [
+            redis.exceptions.ConnectionError("Connection failed"),
+            mock_pubsub,
+        ]
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test pubsub factory method with failover
+        pubsub_method = proxy.__getattr__("pubsub")
+        result = pubsub_method()
+
+        assert result == mock_pubsub
+        assert mock_master.pubsub.call_count == 2  # Retry happened
+
+        # Verify it's still not wrapped as async after retry
+        assert not inspect.iscoroutine(result)
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_monitor_factory_method_async(self, mock_sentinel_class):
+        """Test monitor factory method in async mode - should pass through without wrapping"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_monitor = Mock()
+
+        # Mock monitor factory method
+        mock_master.monitor.return_value = mock_monitor
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test monitor factory method - should NOT be wrapped as async
+        monitor_method = proxy.__getattr__("monitor")
+        result = monitor_method()
+
+        assert result == mock_monitor
+        mock_master.monitor.assert_called_once()
+
+        # Verify it's not wrapped as async (no await needed)
+        assert not inspect.iscoroutine(result)
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_client_factory_method_async(self, mock_sentinel_class):
+        """Test client factory method in async mode - should pass through without wrapping"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_client = Mock()
+
+        # Mock client factory method
+        mock_master.client.return_value = mock_client
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test client factory method - should NOT be wrapped as async
+        client_method = proxy.__getattr__("client")
+        result = client_method()
+
+        assert result == mock_client
+        mock_master.client.assert_called_once()
+
+        # Verify it's not wrapped as async (no await needed)
+        assert not inspect.iscoroutine(result)
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_transaction_factory_method_async(self, mock_sentinel_class):
+        """Test transaction factory method in async mode - should pass through without wrapping"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+        mock_transaction = Mock()
+
+        # Mock transaction factory method
+        mock_master.transaction.return_value = mock_transaction
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test transaction factory method - should NOT be wrapped as async
+        transaction_method = proxy.__getattr__("transaction")
+        result = transaction_method()
+
+        assert result == mock_transaction
+        mock_master.transaction.assert_called_once()
+
+        # Verify it's not wrapped as async (no await needed)
+        assert not inspect.iscoroutine(result)
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_all_factory_methods_async(self, mock_sentinel_class):
+        """Test all factory methods in async mode - comprehensive test"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # Mock all factory methods
+        mock_objects = {
+            "pipeline": Mock(),
+            "pubsub": Mock(),
+            "monitor": Mock(),
+            "client": Mock(),
+            "transaction": Mock(),
+        }
+
+        for method_name, mock_obj in mock_objects.items():
+            setattr(mock_master, method_name, Mock(return_value=mock_obj))
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Test all factory methods
+        for method_name, expected_obj in mock_objects.items():
+            method = proxy.__getattr__(method_name)
+            result = method()
+
+            assert result == expected_obj
+            assert not inspect.iscoroutine(result)
+            getattr(mock_master, method_name).assert_called_once()
+
+            # Reset mock for next iteration
+            getattr(mock_master, method_name).reset_mock()
+
+    @patch("redis.sentinel.Sentinel")
+    @pytest.mark.asyncio
+    async def test_mixed_factory_and_regular_commands_async(self, mock_sentinel_class):
+        """Test using both factory methods and regular commands in async mode"""
+        mock_sentinel = Mock()
+        mock_master = Mock()
+
+        # Mock pipeline factory and regular commands
+        mock_pipeline = Mock()
+        mock_master.pipeline.return_value = mock_pipeline
+        mock_pipeline.set.return_value = mock_pipeline
+        mock_pipeline.get.return_value = mock_pipeline
+        mock_pipeline.execute.return_value = [True, "pipeline_value"]
+
+        mock_master.get = AsyncMock(return_value="regular_value")
+
+        mock_sentinel.master_for.return_value = mock_master
+
+        proxy = SentinelRedisProxy(mock_sentinel, "mymaster", async_mode=True)
+
+        # Use factory method (sync)
+        pipeline = proxy.__getattr__("pipeline")()
+        pipeline_result = pipeline.set("key1", "value1").get("key1").execute()
+
+        # Use regular command (async)
+        get_method = proxy.__getattr__("get")
+        regular_result = await get_method("key2")
+
+        # Verify both work correctly
+        assert pipeline_result == [True, "pipeline_value"]
+        assert regular_result == "regular_value"
+
+        # Verify calls
+        mock_master.pipeline.assert_called_once()
+        mock_master.get.assert_called_with("key2")

+ 16 - 3
backend/open_webui/utils/logger.py

@@ -4,6 +4,7 @@ import sys
 from typing import TYPE_CHECKING
 
 from loguru import logger
+from opentelemetry import trace
 
 
 from open_webui.env import (
@@ -12,6 +13,7 @@ from open_webui.env import (
     AUDIT_LOG_LEVEL,
     AUDIT_LOGS_FILE_PATH,
     GLOBAL_LOG_LEVEL,
+    ENABLE_OTEL,
 )
 
 
@@ -60,9 +62,20 @@ class InterceptHandler(logging.Handler):
             frame = frame.f_back
             depth += 1
 
-        logger.opt(depth=depth, exception=record.exc_info).log(
-            level, record.getMessage()
-        )
+        logger.opt(depth=depth, exception=record.exc_info).bind(
+            **self._get_extras()
+        ).log(level, record.getMessage())
+
+    def _get_extras(self):
+        if not ENABLE_OTEL:
+            return {}
+
+        extras = {}
+        context = trace.get_current_span().get_span_context()
+        if context.is_valid:
+            extras["trace_id"] = trace.format_trace_id(context.trace_id)
+            extras["span_id"] = trace.format_span_id(context.span_id)
+        return extras
 
 
 def file_format(record: "Record"):

+ 49 - 26
backend/open_webui/utils/middleware.py

@@ -652,7 +652,15 @@ async def chat_completion_files_handler(
                             query, prefix=prefix, user=user
                         ),
                         k=request.app.state.config.TOP_K,
-                        reranking_function=request.app.state.rf,
+                        reranking_function=(
+                            (
+                                lambda sentences: request.app.state.RERANKING_FUNCTION(
+                                    sentences, user=user
+                                )
+                            )
+                            if request.app.state.RERANKING_FUNCTION
+                            else None
+                        ),
                         k_reranker=request.app.state.config.TOP_K_RERANKER,
                         r=request.app.state.config.RELEVANCE_THRESHOLD,
                         hybrid_bm25_weight=request.app.state.config.HYBRID_BM25_WEIGHT,
@@ -1373,14 +1381,6 @@ async def process_chat_response(
         task_id = str(uuid4())  # Create a unique task ID.
         model_id = form_data.get("model", "")
 
-        Chats.upsert_message_to_chat_by_id_and_message_id(
-            metadata["chat_id"],
-            metadata["message_id"],
-            {
-                "model": model_id,
-            },
-        )
-
         def split_content_and_whitespace(content):
             content_stripped = content.rstrip()
             original_whitespace = (
@@ -1464,12 +1464,12 @@ async def process_chat_response(
 
                         if reasoning_duration is not None:
                             if raw:
-                                content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n'
+                                content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n'
                             else:
                                 content = f'{content}\n<details type="reasoning" done="true" duration="{reasoning_duration}">\n<summary>Thought for {reasoning_duration} seconds</summary>\n{reasoning_display_content}\n</details>\n'
                         else:
                             if raw:
-                                content = f'{content}\n<{block["start_tag"]}>{block["content"]}<{block["end_tag"]}>\n'
+                                content = f'{content}\n{block["start_tag"]}{block["content"]}{block["end_tag"]}\n'
                             else:
                                 content = f'{content}\n<details type="reasoning" done="false">\n<summary>Thinking…</summary>\n{reasoning_display_content}\n</details>\n'
 
@@ -1566,8 +1566,16 @@ async def process_chat_response(
 
                 if content_blocks[-1]["type"] == "text":
                     for start_tag, end_tag in tags:
-                        # Match start tag e.g., <tag> or <tag attr="value">
-                        start_tag_pattern = rf"<{re.escape(start_tag)}(\s.*?)?>"
+
+                        start_tag_pattern = rf"{re.escape(start_tag)}"
+                        if start_tag.startswith("<") and start_tag.endswith(">"):
+                            # Match start tag e.g., <tag> or <tag attr="value">
+                            # remove both '<' and '>' from start_tag
+                            # Match start tag with attributes
+                            start_tag_pattern = (
+                                rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>"
+                            )
+
                         match = re.search(start_tag_pattern, content)
                         if match:
                             attr_content = (
@@ -1618,8 +1626,13 @@ async def process_chat_response(
                 elif content_blocks[-1]["type"] == content_type:
                     start_tag = content_blocks[-1]["start_tag"]
                     end_tag = content_blocks[-1]["end_tag"]
-                    # Match end tag e.g., </tag>
-                    end_tag_pattern = rf"<{re.escape(end_tag)}>"
+
+                    if end_tag.startswith("<") and end_tag.endswith(">"):
+                        # Match end tag e.g., </tag>
+                        end_tag_pattern = rf"{re.escape(end_tag)}"
+                    else:
+                        # Handle cases where end_tag is just a tag name
+                        end_tag_pattern = rf"{re.escape(end_tag)}"
 
                     # Check if the content has the end tag
                     if re.search(end_tag_pattern, content):
@@ -1691,8 +1704,17 @@ async def process_chat_response(
                                 )
 
                         # Clean processed content
+                        start_tag_pattern = rf"{re.escape(start_tag)}"
+                        if start_tag.startswith("<") and start_tag.endswith(">"):
+                            # Match start tag e.g., <tag> or <tag attr="value">
+                            # remove both '<' and '>' from start_tag
+                            # Match start tag with attributes
+                            start_tag_pattern = (
+                                rf"<{re.escape(start_tag[1:-1])}(\s.*?)?>"
+                            )
+
                         content = re.sub(
-                            rf"<{re.escape(start_tag)}(.*?)>(.|\n)*?<{re.escape(end_tag)}>",
+                            rf"{start_tag_pattern}(.|\n)*?{re.escape(end_tag)}",
                             "",
                             content,
                             flags=re.DOTALL,
@@ -1736,18 +1758,19 @@ async def process_chat_response(
             )
 
             reasoning_tags = [
-                ("think", "/think"),
-                ("thinking", "/thinking"),
-                ("reason", "/reason"),
-                ("reasoning", "/reasoning"),
-                ("thought", "/thought"),
-                ("Thought", "/Thought"),
-                ("|begin_of_thought|", "|end_of_thought|"),
+                ("<think>", "</think>"),
+                ("<thinking>", "</thinking>"),
+                ("<reason>", "</reason>"),
+                ("<reasoning>", "</reasoning>"),
+                ("<thought>", "</thought>"),
+                ("<Thought>", "</Thought>"),
+                ("<|begin_of_thought|>", "<|end_of_thought|>"),
+                ("◁think▷", "◁/think▷"),
             ]
 
-            code_interpreter_tags = [("code_interpreter", "/code_interpreter")]
+            code_interpreter_tags = [("<code_interpreter>", "</code_interpreter>")]
 
-            solution_tags = [("|begin_of_solution|", "|end_of_solution|")]
+            solution_tags = [("<|begin_of_solution|>", "<|end_of_solution|>")]
 
             try:
                 for event in events:
@@ -2031,7 +2054,7 @@ async def process_chat_response(
                             if done:
                                 pass
                             else:
-                                log.debug("Error: ", e)
+                                log.debug(f"Error: {e}")
                                 continue
 
                     if content_blocks:

+ 100 - 4
backend/open_webui/utils/redis.py

@@ -1,6 +1,94 @@
-import socketio
+import inspect
 from urllib.parse import urlparse
-from typing import Optional
+
+import logging
+
+import redis
+
+from open_webui.env import REDIS_SENTINEL_MAX_RETRY_COUNT
+
+log = logging.getLogger(__name__)
+
+
+class SentinelRedisProxy:
+    def __init__(self, sentinel, service, *, async_mode: bool = True, **kw):
+        self._sentinel = sentinel
+        self._service = service
+        self._kw = kw
+        self._async_mode = async_mode
+
+    def _master(self):
+        return self._sentinel.master_for(self._service, **self._kw)
+
+    def __getattr__(self, item):
+        master = self._master()
+        orig_attr = getattr(master, item)
+
+        if not callable(orig_attr):
+            return orig_attr
+
+        FACTORY_METHODS = {"pipeline", "pubsub", "monitor", "client", "transaction"}
+        if item in FACTORY_METHODS:
+            return orig_attr
+
+        if self._async_mode:
+
+            async def _wrapped(*args, **kwargs):
+                for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
+                    try:
+                        method = getattr(self._master(), item)
+                        result = method(*args, **kwargs)
+                        if inspect.iscoroutine(result):
+                            return await result
+                        return result
+                    except (
+                        redis.exceptions.ConnectionError,
+                        redis.exceptions.ReadOnlyError,
+                    ) as e:
+                        if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
+                            log.debug(
+                                "Redis sentinel fail-over (%s). Retry %s/%s",
+                                type(e).__name__,
+                                i + 1,
+                                REDIS_SENTINEL_MAX_RETRY_COUNT,
+                            )
+                            continue
+                        log.error(
+                            "Redis operation failed after %s retries: %s",
+                            REDIS_SENTINEL_MAX_RETRY_COUNT,
+                            e,
+                        )
+                        raise e from e
+
+            return _wrapped
+
+        else:
+
+            def _wrapped(*args, **kwargs):
+                for i in range(REDIS_SENTINEL_MAX_RETRY_COUNT):
+                    try:
+                        method = getattr(self._master(), item)
+                        return method(*args, **kwargs)
+                    except (
+                        redis.exceptions.ConnectionError,
+                        redis.exceptions.ReadOnlyError,
+                    ) as e:
+                        if i < REDIS_SENTINEL_MAX_RETRY_COUNT - 1:
+                            log.debug(
+                                "Redis sentinel fail-over (%s). Retry %s/%s",
+                                type(e).__name__,
+                                i + 1,
+                                REDIS_SENTINEL_MAX_RETRY_COUNT,
+                            )
+                            continue
+                        log.error(
+                            "Redis operation failed after %s retries: %s",
+                            REDIS_SENTINEL_MAX_RETRY_COUNT,
+                            e,
+                        )
+                        raise e from e
+
+            return _wrapped
 
 
 def parse_redis_service_url(redis_url):
@@ -34,7 +122,11 @@ def get_redis_connection(
                 password=redis_config["password"],
                 decode_responses=decode_responses,
             )
-            return sentinel.master_for(redis_config["service"])
+            return SentinelRedisProxy(
+                sentinel,
+                redis_config["service"],
+                async_mode=async_mode,
+            )
         elif redis_url:
             return redis.from_url(redis_url, decode_responses=decode_responses)
         else:
@@ -52,7 +144,11 @@ def get_redis_connection(
                 password=redis_config["password"],
                 decode_responses=decode_responses,
             )
-            return sentinel.master_for(redis_config["service"])
+            return SentinelRedisProxy(
+                sentinel,
+                redis_config["service"],
+                async_mode=async_mode,
+            )
         elif redis_url:
             return redis.Redis.from_url(redis_url, decode_responses=decode_responses)
         else:

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

@@ -6,18 +6,17 @@ from open_webui.utils.misc import (
 )
 
 
-def convert_ollama_tool_call_to_openai(tool_calls: dict) -> dict:
+def convert_ollama_tool_call_to_openai(tool_calls: list) -> list:
     openai_tool_calls = []
     for tool_call in tool_calls:
+        function = tool_call.get("function", {})
         openai_tool_call = {
-            "index": tool_call.get("index", 0),
+            "index": tool_call.get("index", function.get("index", 0)),
             "id": tool_call.get("id", f"call_{str(uuid4())}"),
             "type": "function",
             "function": {
-                "name": tool_call.get("function", {}).get("name", ""),
-                "arguments": json.dumps(
-                    tool_call.get("function", {}).get("arguments", {})
-                ),
+                "name": function.get("name", ""),
+                "arguments": json.dumps(function.get("arguments", {})),
             },
         }
         openai_tool_calls.append(openai_tool_call)

+ 40 - 0
backend/open_webui/utils/telemetry/metrics.py

@@ -34,6 +34,8 @@ from opentelemetry.sdk.resources import SERVICE_NAME, Resource
 
 from open_webui.env import OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT
 
+from open_webui.socket.main import get_active_user_ids
+from open_webui.models.users import Users
 
 _EXPORT_INTERVAL_MILLIS = 10_000  # 10 seconds
 
@@ -59,6 +61,12 @@ def _build_meter_provider() -> MeterProvider:
             instrument_name="http.server.requests",
             attribute_keys=["http.method", "http.route", "http.status_code"],
         ),
+        View(
+            instrument_name="webui.users.total",
+        ),
+        View(
+            instrument_name="webui.users.active",
+        ),
     ]
 
     provider = MeterProvider(
@@ -87,6 +95,38 @@ def setup_metrics(app: FastAPI) -> None:
         unit="ms",
     )
 
+    def observe_active_users(
+        options: metrics.CallbackOptions,
+    ) -> Sequence[metrics.Observation]:
+        return [
+            metrics.Observation(
+                value=len(get_active_user_ids()),
+            )
+        ]
+
+    def observe_total_registered_users(
+        options: metrics.CallbackOptions,
+    ) -> Sequence[metrics.Observation]:
+        return [
+            metrics.Observation(
+                value=len(Users.get_users()["users"]),
+            )
+        ]
+
+    meter.create_observable_gauge(
+        name="webui.users.total",
+        description="Total number of registered users",
+        unit="users",
+        callbacks=[observe_total_registered_users],
+    )
+
+    meter.create_observable_gauge(
+        name="webui.users.active",
+        description="Number of currently active users",
+        unit="users",
+        callbacks=[observe_active_users],
+    )
+
     # FastAPI middleware
     @app.middleware("http")
     async def _metrics_middleware(request: Request, call_next):

+ 0 - 1
backend/open_webui/utils/telemetry/setup.py

@@ -42,7 +42,6 @@ def setup(app: FastAPI, db_engine: Engine):
     if OTEL_OTLP_SPAN_EXPORTER == "http":
         exporter = HttpOTLPSpanExporter(
             endpoint=OTEL_EXPORTER_OTLP_ENDPOINT,
-            insecure=OTEL_EXPORTER_OTLP_INSECURE,
             headers=headers,
         )
     else:

+ 2 - 0
backend/requirements.txt

@@ -14,6 +14,7 @@ async-timeout
 aiocache
 aiofiles
 starlette-compress==1.6.0
+httpx[socks,http2,zstd,cli,brotli]==0.28.1
 
 sqlalchemy==2.0.38
 alembic==1.14.0
@@ -50,6 +51,7 @@ langchain-community==0.3.26
 
 fake-useragent==2.1.0
 chromadb==0.6.3
+posthog==5.4.0
 pymilvus==2.5.0
 qdrant-client==1.14.3
 opensearch-py==2.8.0

+ 4 - 4
cypress/e2e/chat.cy.ts

@@ -21,14 +21,14 @@ describe('Settings', () => {
 			// Click on the model selector
 			cy.get('button[aria-label="Select a model"]').click();
 			// Select the first model
-			cy.get('button[aria-label="model-item"]').first().click();
+			cy.get('button[aria-roledescription="model-item"]').first().click();
 		});
 
 		it('user can perform text chat', () => {
 			// Click on the model selector
 			cy.get('button[aria-label="Select a model"]').click();
 			// Select the first model
-			cy.get('button[aria-label="model-item"]').first().click();
+			cy.get('button[aria-roledescription="model-item"]').first().click();
 			// Type a message
 			cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
 				force: true
@@ -48,7 +48,7 @@ describe('Settings', () => {
 			// Click on the model selector
 			cy.get('button[aria-label="Select a model"]').click();
 			// Select the first model
-			cy.get('button[aria-label="model-item"]').first().click();
+			cy.get('button[aria-roledescription="model-item"]').first().click();
 			// Type a message
 			cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
 				force: true
@@ -83,7 +83,7 @@ describe('Settings', () => {
 			// Click on the model selector
 			cy.get('button[aria-label="Select a model"]').click();
 			// Select the first model
-			cy.get('button[aria-label="model-item"]').first().click();
+			cy.get('button[aria-roledescription="model-item"]').first().click();
 			// Type a message
 			cy.get('#chat-input').type('Hi, what can you do? A single sentence only please.', {
 				force: true

+ 1 - 1
hatch_build.py

@@ -17,7 +17,7 @@ class CustomBuildHook(BuildHookInterface):
                 "NodeJS `npm` is required for building Open Webui but it was not found"
             )
         stderr.write("### npm install\n")
-        subprocess.run([npm, "install"], check=True)  # noqa: S603
+        subprocess.run([npm, "install", "--force"], check=True)  # noqa: S603
         stderr.write("\n### npm run build\n")
         os.environ["APP_BUILD_HASH"] = version
         subprocess.run([npm, "run", "build"], check=True)  # noqa: S603

+ 307 - 262
package-lock.json

@@ -1,42 +1,40 @@
 {
 	"name": "open-webui",
-	"version": "0.6.16",
+	"version": "0.6.18",
 	"lockfileVersion": 3,
 	"requires": true,
 	"packages": {
 		"": {
 			"name": "open-webui",
-			"version": "0.6.16",
+			"version": "0.6.18",
 			"dependencies": {
 				"@azure/msal-browser": "^4.5.0",
 				"@codemirror/lang-javascript": "^6.2.2",
 				"@codemirror/lang-python": "^6.1.6",
 				"@codemirror/language-data": "^6.5.1",
 				"@codemirror/theme-one-dark": "^6.1.2",
+				"@floating-ui/dom": "^1.7.2",
 				"@huggingface/transformers": "^3.0.0",
 				"@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.11.9",
-				"@tiptap/extension-bubble-menu": "^2.25.0",
-				"@tiptap/extension-character-count": "^2.25.0",
-				"@tiptap/extension-code-block-lowlight": "^2.11.9",
-				"@tiptap/extension-floating-menu": "^2.25.0",
-				"@tiptap/extension-highlight": "^2.10.0",
-				"@tiptap/extension-history": "^2.25.1",
-				"@tiptap/extension-link": "^2.25.0",
-				"@tiptap/extension-placeholder": "^2.10.0",
-				"@tiptap/extension-table": "^2.12.0",
-				"@tiptap/extension-table-cell": "^2.12.0",
-				"@tiptap/extension-table-header": "^2.12.0",
-				"@tiptap/extension-table-row": "^2.12.0",
-				"@tiptap/extension-task-item": "^2.25.0",
-				"@tiptap/extension-task-list": "^2.25.0",
-				"@tiptap/extension-typography": "^2.10.0",
-				"@tiptap/extension-underline": "^2.25.0",
-				"@tiptap/pm": "^2.11.7",
-				"@tiptap/starter-kit": "^2.10.0",
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/extension-bubble-menu": "^2.26.1",
+				"@tiptap/extension-code-block-lowlight": "^3.0.7",
+				"@tiptap/extension-drag-handle": "^3.0.7",
+				"@tiptap/extension-file-handler": "^3.0.7",
+				"@tiptap/extension-floating-menu": "^2.26.1",
+				"@tiptap/extension-highlight": "^3.0.7",
+				"@tiptap/extension-image": "^3.0.7",
+				"@tiptap/extension-link": "^3.0.7",
+				"@tiptap/extension-list": "^3.0.7",
+				"@tiptap/extension-table": "^3.0.7",
+				"@tiptap/extension-typography": "^3.0.7",
+				"@tiptap/extension-youtube": "^3.0.7",
+				"@tiptap/extensions": "^3.0.7",
+				"@tiptap/pm": "^3.0.7",
+				"@tiptap/starter-kit": "^3.0.7",
 				"@xyflow/svelte": "^0.1.19",
 				"async": "^3.2.5",
 				"bits-ui": "^0.21.15",
@@ -64,6 +62,7 @@
 				"katex": "^0.16.22",
 				"kokoro-js": "^1.1.1",
 				"leaflet": "^1.9.4",
+				"lowlight": "^3.3.0",
 				"marked": "^9.1.0",
 				"mermaid": "^11.6.0",
 				"paneforge": "^0.0.6",
@@ -1216,28 +1215,28 @@
 			}
 		},
 		"node_modules/@floating-ui/core": {
-			"version": "1.7.1",
-			"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
-			"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
+			"version": "1.7.2",
+			"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
+			"integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
 			"license": "MIT",
 			"dependencies": {
-				"@floating-ui/utils": "^0.2.9"
+				"@floating-ui/utils": "^0.2.10"
 			}
 		},
 		"node_modules/@floating-ui/dom": {
-			"version": "1.7.1",
-			"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
-			"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
+			"version": "1.7.2",
+			"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
+			"integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
 			"license": "MIT",
 			"dependencies": {
-				"@floating-ui/core": "^1.7.1",
-				"@floating-ui/utils": "^0.2.9"
+				"@floating-ui/core": "^1.7.2",
+				"@floating-ui/utils": "^0.2.10"
 			}
 		},
 		"node_modules/@floating-ui/utils": {
-			"version": "0.2.9",
-			"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
-			"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
+			"version": "0.2.10",
+			"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+			"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
 			"license": "MIT"
 		},
 		"node_modules/@gulpjs/to-absolute-glob": {
@@ -3109,48 +3108,48 @@
 			}
 		},
 		"node_modules/@tiptap/core": {
-			"version": "2.11.9",
-			"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.9.tgz",
-			"integrity": "sha512-UZSxQLLyJst47xep3jlyKM6y1ebZnmvbGsB7njBVjfxf5H+4yFpRJwwNqrBHM/vyU55LCtPChojqaYC1wXLf6g==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.0.7.tgz",
+			"integrity": "sha512-/NC0BbekWzi5sC+s7gRrGIv33cUfuiZUG5DWx8TNedA6b6aTFPHUe+2wKRPaPQ0pfGdOWU0nsOkboUJ9dAjl4g==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-blockquote": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.10.0.tgz",
-			"integrity": "sha512-6Xmfo2lpfIRcbfkLD/NGX4YgQqfgAbu6XaZQZf5oGtHLPTrz4D7Mw20GgNBHzae2XwUCwLMt6zXOkBgU/LnlZg==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.0.7.tgz",
+			"integrity": "sha512-bYJ7r4hYcBZ7GI0LSV0Oxb9rmy/qb0idAf/osvflG2r1tf5CsiW5NYAqlOYAsIVA2OCwXELDlRGCgeKBQ26Kyw==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-bold": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.10.0.tgz",
-			"integrity": "sha512-1wL8UI1Aii0u2cbDEvwyqsZb2pgBt8HLJdsIax/ELoF2tKCD5821nElqTGLBBg4pUGPa0ru9ZemuL8GdXZp3Qg==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.0.7.tgz",
+			"integrity": "sha512-CQG07yvrIsScLe5NplAuCkVh0sd97Udv1clAGbqfzeV8YfzpV3M7J/Vb09pWyovx3SjDqfsZpkr3RemeKEPY9Q==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-bubble-menu": {
-			"version": "2.25.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.25.0.tgz",
-			"integrity": "sha512-BnbfQWRXJDDy9/x/0Atu2Nka5ZAMyXLDFqzSLMAXqXSQcG6CZRTSNRgOCnjpda6Hq2yCtq7l/YEoXkbHT1ZZdQ==",
+			"version": "2.26.1",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.26.1.tgz",
+			"integrity": "sha512-oHevUcZbTMFOTpdCEo4YEDe044MB4P1ZrWyML8CGe5tnnKdlI9BN03AXpI1mEEa5CA3H1/eEckXx8EiCgYwQ3Q==",
 			"license": "MIT",
 			"dependencies": {
 				"tippy.js": "^6.3.7"
@@ -3165,107 +3164,144 @@
 			}
 		},
 		"node_modules/@tiptap/extension-bullet-list": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.10.0.tgz",
-			"integrity": "sha512-Cl+DGu6D3SgF/hlKUDNet3gaZFy6cPEonOOkHwzXoybDXXdddFbaTvt9MLkBRUR3ldksXuVRP2/LwZsK5WyxJQ==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.0.7.tgz",
+			"integrity": "sha512-9gPc3Tw2Bw7qKLbyW0s05YntE77127pOXQXcclB4I3MXAuz/K03f+DGuSRhOq9K2Oo86BPHdL5I9Ap9cmuS0Tg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/extension-list": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-character-count": {
-			"version": "2.25.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.25.0.tgz",
-			"integrity": "sha512-F+4DxJFptbX3oioqNwS38zOTi6gH9CumV/ISeOIvr4ao7Iija3tNonGDsHhxD05njjbYNIp1OKsxtnzbWukgMA==",
+		"node_modules/@tiptap/extension-code": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.0.7.tgz",
+			"integrity": "sha512-6wdUqtXbnIuyKR7xteF2UCnsW2dLNtBKxWvAiOweA7L41HYvburh/tjbkffkNc5KP2XsKzdGbygpunwJMPj6+A==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-code": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz",
-			"integrity": "sha512-8JznKG1Jmv8gJezZGPoka8oRmfrcAAnMEOeMpKXjwMrIbQ6QynTZpqMGGVL1kfkZlLV84PYm+CGjGgjSsT4iZw==",
+		"node_modules/@tiptap/extension-code-block": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.0.7.tgz",
+			"integrity": "sha512-WifMv7N1G1Fnd2oZ+g80FjBpV/eI/fxHKCK3hw03l8LoWgeFaU/6LC93qTV6idkfia3YwiA6WnuyOqlI0FSZ9A==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-code-block": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.10.0.tgz",
-			"integrity": "sha512-QH+LP7L1s1EJlrDFnfgOP0q+Siqt0Zbkx4ICMcUGvEsycl53Ti8P0DRW7fAjRISdTCItuWJYvtmiYY7O3rYb+Q==",
+		"node_modules/@tiptap/extension-code-block-lowlight": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-3.0.7.tgz",
+			"integrity": "sha512-y1sHjzxpYqIKikdT5y5ajCOw4hDIPGjPpIBP7x7iw7jyt8a/w/bI8ozUk4epLBpgOvvAwmdIqi7eV7ORMvQaGQ==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/extension-code-block": "^3.0.7",
+				"@tiptap/pm": "^3.0.7",
+				"highlight.js": "^11",
+				"lowlight": "^2 || ^3"
 			}
 		},
-		"node_modules/@tiptap/extension-code-block-lowlight": {
-			"version": "2.11.9",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.11.9.tgz",
-			"integrity": "sha512-bB8N59A2aU18/ieyKRZAI0J0xyimmUckYePqBkUX8HFnq8yf9HsM0NPFpqZdK0eqjnZYCXcNwAI3YluLsHuutw==",
+		"node_modules/@tiptap/extension-collaboration": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.0.7.tgz",
+			"integrity": "sha512-so59vQCAS1vy6k86byk96fYvAPM5w8u8/Yp3jKF1LPi9LH4wzS4hGnOP/dEbedxPU48an9WB1lSOczSKPECJaQ==",
 			"license": "MIT",
+			"peer": true,
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/extension-code-block": "^2.7.0",
-				"@tiptap/pm": "^2.7.0",
-				"highlight.js": "^11",
-				"lowlight": "^2 || ^3"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7",
+				"@tiptap/y-tiptap": "^3.0.0-beta.3",
+				"yjs": "^13"
 			}
 		},
 		"node_modules/@tiptap/extension-document": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.10.0.tgz",
-			"integrity": "sha512-vseMW3EKiQAPgdbN48Y8F0nRqWhhrAo9DLacAfP7tu0x3uv44uotNjDBtAgp5QmJmqQVyrEdkLSZaU5vFzduhQ==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.0.7.tgz",
+			"integrity": "sha512-HJg1nPPZ9fv5oEMwpONeIfT0FjTrgNGuGAat/hgcBi/R2GUNir2/PM/3d6y8QtkR/EgkgcFakCc9azySXLmyUQ==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "^3.0.7"
+			}
+		},
+		"node_modules/@tiptap/extension-drag-handle": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-drag-handle/-/extension-drag-handle-3.0.7.tgz",
+			"integrity": "sha512-rm8+0kPz5C5JTp4f1QY61Qd5d7zlJAxLeJtOvgC9RCnrNG1F7LCsmOkvy5fsU6Qk2YCCYOiSSMC4S4HKPrUJhw==",
 			"license": "MIT",
+			"dependencies": {
+				"@floating-ui/dom": "^1.6.13"
+			},
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/extension-collaboration": "^3.0.7",
+				"@tiptap/extension-node-range": "^3.0.7",
+				"@tiptap/pm": "^3.0.7",
+				"@tiptap/y-tiptap": "^3.0.0-beta.3"
 			}
 		},
 		"node_modules/@tiptap/extension-dropcursor": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.10.0.tgz",
-			"integrity": "sha512-tifxp/a3NxTjLAuYBx9XAwVo4MSDoY/mQ8E18QtuXj0vuieCFxd8Bkyre0otubIAAQePXLTVGQoxPrKmMAa+Jg==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.0.7.tgz",
+			"integrity": "sha512-0i2XWdRgYbj6PEPC+pMcGiF/hwg0jl+MavPt1733qWzoDqMEls9cEBTQ9S4HS0TI/jbN/kNavTQ5LlI33kWrww==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/extensions": "^3.0.7"
+			}
+		},
+		"node_modules/@tiptap/extension-file-handler": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-file-handler/-/extension-file-handler-3.0.7.tgz",
+			"integrity": "sha512-eNJOqLaM91erqm6W7k+ocG09fuiVI4B+adWhv97sFim9TboF0sEIWEYdl68z06N1/+tXv6w8S4zUYQCOzxlVtw==",
+			"license": "MIT",
+			"funding": {
+				"type": "github",
+				"url": "https://github.com/sponsors/ueberdosis"
+			},
+			"peerDependencies": {
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/extension-text-style": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-floating-menu": {
-			"version": "2.25.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.25.0.tgz",
-			"integrity": "sha512-hPZ5SNpI14smTz4GpWQXTnxmeICINYiABSgXcsU5V66tik9OtxKwoCSR/gpU35esaAFUVRdjW7+sGkACLZD5AQ==",
+			"version": "2.26.1",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.26.1.tgz",
+			"integrity": "sha512-OJF+H6qhQogVTMedAGSWuoL1RPe3LZYXONuFCVyzHnvvMpK+BP1vm180E2zDNFnn/DVA+FOrzNGpZW7YjoFH1w==",
 			"license": "MIT",
 			"dependencies": {
 				"tippy.js": "^6.3.7"
@@ -3280,103 +3316,101 @@
 			}
 		},
 		"node_modules/@tiptap/extension-gapcursor": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.10.0.tgz",
-			"integrity": "sha512-GViEnSnEBE74k7SYdXrQ4aXlKmWkrd9awdj/TgDSORgpZ4Dfyqtn+ENIWWby4NhL+BPM9P5hGCjkQXZsi6JKOw==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.0.7.tgz",
+			"integrity": "sha512-F4ERd5r59WHbY0ALBbrJ/2z9dl+7VSmsMV/ZkzTgq0TZV9KKz3SsCFcCdIZEYzRCEp69/yYtkTofN10xIa+J6A==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/extensions": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-hard-break": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.10.0.tgz",
-			"integrity": "sha512-NL/xPYUhhvQyCnOO5Yn+BlBOMLC1ru32nw7ox12TShGmaeKBrnV0DhzBRkyJU0MqCS26oWjieNPxfu0lR3oMSA==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.0.7.tgz",
+			"integrity": "sha512-OWrFrKp9PDs9nKJRmyPX22YoscqmoW25VZYeUfvNcAYtI84xYz871s1JmLZkpxqOyI9TafUADFiaRISDnX5EcA==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-heading": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.10.0.tgz",
-			"integrity": "sha512-x2Uj5wrAHFaUdlChwLoQVmWtzZCuNyJpBRA19kA4idWL5z+6cIrUWepvwVBxA8ou6ictbzWW15o+blKtW7DlqA==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.0.7.tgz",
+			"integrity": "sha512-uS7fFcilFuzKEvhUgndELqlGweD+nZeLOb6oqUE5hM49vECjM7qVjVQnlhV+MH2W1w8eD08cn1lu6lDxaMOe5w==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-highlight": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.10.0.tgz",
-			"integrity": "sha512-HU8UuKU7ljlzNn7jg29pM8QtIX7QvePcBjcWAt6K3qVwF1cbBNguIjKRY2rmoonU2nu8I6GknQNgV847kZifCQ==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.0.7.tgz",
+			"integrity": "sha512-3oIRuXAg7l9+VPIMwHycXcqtZ7XJcC5vnLhPAQXIesYun6L9EoXmQox0225z8jpPG70N8zfl+YSd4qjsTMPaAg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-history": {
-			"version": "2.25.1",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.25.1.tgz",
-			"integrity": "sha512-ZoxxOAObk1U8H3d+XEG0MjccJN0ViGIKEZqnLUSswmVweYPdkJG2WF2pEif9hpwJONslvLTKa+f8jwK5LEnJLQ==",
+		"node_modules/@tiptap/extension-horizontal-rule": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.0.7.tgz",
+			"integrity": "sha512-m0r4tzfVX3r0ZD7uvDf/GAiVr7lJjYwhZHC+M+JMhYXVI6eB9OXXzhdOIsw9W5QcmhCBaqU+VuPKUusTn4TKLg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-horizontal-rule": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.10.0.tgz",
-			"integrity": "sha512-el1SzI/x/h4HW8UltxJlyMSrRsO55ypKPLQHJC9h7F6kTTR31fJUzQa3AeTFrZvXS0kNHIFRpAMstw+N0L5TYg==",
+		"node_modules/@tiptap/extension-image": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.0.7.tgz",
+			"integrity": "sha512-hs6TiSmefwvAqxwhy4+ZFCbmAXiAeWq4v5Zd65kQ7dvN7epeV0NM7ME5su/oscQgoKvNAy1r/4sJVaTnHomYMQ==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-italic": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.10.0.tgz",
-			"integrity": "sha512-MqPYbHAEeO8QBvZRIkF4J2OTf/uiUPzUiXGLJ50w1ozfMBIw1txMvfR3g2cpwfvZlcOgYTgy7M0Oq00nQz5eXg==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.0.7.tgz",
+			"integrity": "sha512-L05cehSOd7iZWI/igPb90TgQ6RKk2UuuYdatmXff3QUJpYPYct6abcrMb+CeFKJqE9vaXy46dCQkOuPW+bFwkA==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/extension-link": {
-			"version": "2.25.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.25.0.tgz",
-			"integrity": "sha512-jNd+1Fd7wiIbxlS51weBzyDtBEBSVzW0cgzdwOzBYQtPJueRyXNNVERksyinDuVgcfvEWgmNZUylgzu7mehnEg==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.0.7.tgz",
+			"integrity": "sha512-e53MddBSVKpxxQ2JmHfyZQ2VBLwqlZxqwn0DQHFMXyCKTzpdUC0DOtkvrY7OVz6HA3yz29qR+qquQxIxcDPrfg==",
 			"license": "MIT",
 			"dependencies": {
 				"linkifyjs": "^4.2.0"
@@ -3386,215 +3420,205 @@
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
-			}
-		},
-		"node_modules/@tiptap/extension-list-item": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.10.0.tgz",
-			"integrity": "sha512-BxC6NNHd2xcC+mk5hpYWURUdj/mRz6TGFwH5CsyrUXPxApx0+V+EPHaAgdpu8dr+jtTEzjXF62V6e2JmOAPimg==",
-			"license": "MIT",
-			"funding": {
-				"type": "github",
-				"url": "https://github.com/sponsors/ueberdosis"
-			},
-			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-ordered-list": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.10.0.tgz",
-			"integrity": "sha512-jsK+mvzs7HmxQuQOU3HgIga+v7zUbQlmSP4/danusqUihJ+lc1n0frDCIkVvJrnSB3FChvNgT6ZEA14HOhdJzg==",
+		"node_modules/@tiptap/extension-list": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.0.7.tgz",
+			"integrity": "sha512-rwu5dXRO0YLyxndMHI17PoxK0x0ZaMZKRZflqOy8fSnXNwd3Tdy8/6a9tsmpgO38kOZEYuvMVaeB7J/+UeBVLg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-paragraph": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.10.0.tgz",
-			"integrity": "sha512-4LUkVaJYjNdNZ7QOX6TRcA+m7oCtyrLGk49G22wl7XcPBkQPILP1mCUCU4f41bhjfhCgK5PPWP63kMtD+cEACg==",
+		"node_modules/@tiptap/extension-list-item": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.0.7.tgz",
+			"integrity": "sha512-QfW+dtukl5v6oOA1n4wtAYev5yY78nqc2O8jHGZD18xhqNVerh2xBVIH9wOGHPz4q5Em2Ju7xbqXYl0vg2De+w==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/extension-list": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-placeholder": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.10.0.tgz",
-			"integrity": "sha512-1o6azk2plgYAFgMrV3prnBb1NZjl2V1T3wwnH4n3/h9z9lJ0v5BBAk9r+TRYSrcdXknwwHAWFYnQe6dc9buG2g==",
+		"node_modules/@tiptap/extension-list-keymap": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.0.7.tgz",
+			"integrity": "sha512-KJWXsyHU8E6SGmlZMHNjSg+XrkmCncJT2l5QGEjTUjlhqwulu+4psTDRio9tCdtepiasTL7qEekGWAhz9wEgzQ==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/extension-list": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-strike": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.10.0.tgz",
-			"integrity": "sha512-SxApLJMQkxnmPGR3lwaskvLK61yI+Bu9hGZGdwMZqNh6o3LoDOxDaXjHD5joeMYQiqQrBE9zg46506MsXtrU7Q==",
+		"node_modules/@tiptap/extension-node-range": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-node-range/-/extension-node-range-3.0.7.tgz",
+			"integrity": "sha512-cHViNqtOUD9CLJxEj28rcj8tb8RYQZ7kwmtSvIye84Y3MJIzigRm4IUBNNOYnZfq5YAZIR97WKcJeFz3EU1VPg==",
 			"license": "MIT",
+			"peer": true,
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-table": {
-			"version": "2.12.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.12.0.tgz",
-			"integrity": "sha512-tT3IbbBal0vPQ1Bc/3Xl+tmqqZQCYWxnycBPl/WZBqhd57DWzfJqRPESwCGUIJgjOtTnipy/ulvj0FxHi1j9JA==",
+		"node_modules/@tiptap/extension-ordered-list": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.0.7.tgz",
+			"integrity": "sha512-F/cbG0vt1cjkoJ4A65E6vpZQizZwnE4gJHKAw3ymDdCoZKYaO4OV1UTo98W/jgryORy/HLO12+hogsRvgRvK9Q==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/extension-list": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-table-cell": {
-			"version": "2.12.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.12.0.tgz",
-			"integrity": "sha512-8i35uCkmkSiQxMiZ+DLgT/wj24P5U/Zo3jr1e0tMAAMG7sRO1MljjLmkpV8WCdBo0xoRqzkz4J7Nkq+DtzZv9Q==",
+		"node_modules/@tiptap/extension-paragraph": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.0.7.tgz",
+			"integrity": "sha512-1lp+/CbYmm1ZnR6CNlreUIWCNQk0cBzLVgS5R8SKfVyYaXo11qQq6Yq8URLhpuge4yXkPGMhClwCLzJ9D9R+eg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-table-header": {
-			"version": "2.12.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.12.0.tgz",
-			"integrity": "sha512-gRKEsy13KKLpg9RxyPeUGqh4BRFSJ2Bc2KQP1ldhef6CPRYHCbGycxXCVQ5aAb7Mhpo54L+AAkmAv1iMHUTflw==",
+		"node_modules/@tiptap/extension-strike": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.0.7.tgz",
+			"integrity": "sha512-WUCd5CMgS6pg0ZGKXsaxVrnEvO/h6XUehebL0yggAsRKSoGERInR2iLfhU4p1f4zk0cD3ydNLJdqZu0H/MIABw==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-table-row": {
-			"version": "2.12.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.12.0.tgz",
-			"integrity": "sha512-AEW/Zl9V0IoaYDBLMhF5lVl0xgoIJs3IuKCsIYxGDlxBfTVFC6PfQzvuy296CMjO5ZcZ0xalVipPV9ggsMRD+w==",
+		"node_modules/@tiptap/extension-table": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.0.7.tgz",
+			"integrity": "sha512-S4tvIgagzWnvXLHfltXucgS9TlBwPcQTjQR4llbxmKHAQM4+e77+NGcXXDcQ7E1TdAp3Tk8xRGerGIP7kjCFRA==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-task-item": {
-			"version": "2.25.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.25.0.tgz",
-			"integrity": "sha512-8F7Z7jbsyGrPLHQCn+n39zdqIgxwR1kJ1nL5ZwhEW3ZhJgkFF0WMJSv36mwIJwL08p8um/c6g72AYB/e8CD7eA==",
+		"node_modules/@tiptap/extension-text": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.0.7.tgz",
+			"integrity": "sha512-yf5dNcPLB5SbQ0cQq8qyjiMj9khx4Y4EJoyrDSAok/9zYM3ULqwTPkTSZ2eW6VX/grJeyBVleeBHk1PjJ7NiVw==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0",
-				"@tiptap/pm": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-task-list": {
-			"version": "2.25.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.25.0.tgz",
-			"integrity": "sha512-2mASqp8MJ0dyc1OK6c8P7m/zwoVDv8PV+XsRR9O3tpIz/zjUVrOl0W4IndjUPBMa7cpJX8fGj8iC3DaRNpSMcg==",
+		"node_modules/@tiptap/extension-text-style": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.0.7.tgz",
+			"integrity": "sha512-naJ1XxlbFJ1qlpA+i54lQYKuhWP1dnkUslM86OT0TZt0zJBeu7LIrqSOVGmMB++lF/btnQLMnYkYSSnkLgIw3A==",
 			"license": "MIT",
+			"peer": true,
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-text": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.10.0.tgz",
-			"integrity": "sha512-SSnNncADS1KucdEcJlF6WGCs5+1pAhPrD68vlw34oj3NDT3Zh05KiyXsCV3Nw4wpHOnbWahV+z3uT2SnR+xgoQ==",
+		"node_modules/@tiptap/extension-typography": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.0.7.tgz",
+			"integrity": "sha512-Oz0EIkq8TDd15aupMYcH2L6izdI/LEO0e7+K+OhljTK5g/sGApLxCDdTlmX2szB9EXbTbOpwLKIEz2bPc3HvBA==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-text-style": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.10.0.tgz",
-			"integrity": "sha512-VZtH1dp64wg1UcFtUPpRQK+kOm4JHBIv+WXuKX7EnpIEKjHKnyfV94BBVmaqY5UE4n3kbkkmIRB2Cmix/10AMg==",
+		"node_modules/@tiptap/extension-underline": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.0.7.tgz",
+			"integrity": "sha512-pw2v5kbkovaWaC1G2IxP7g94vmUMlRBzZlCnLEyfFxtGa9LVAsUFlFFWaYJEmq7ZPG/tblWCnFfEZuQqFVd8Sg==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-typography": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.10.0.tgz",
-			"integrity": "sha512-03IOfJm4bk2hZ4SsSfxgBOVzcDxMRBlFD7ZY12H2EGNf1TKxj/0ANWhAH54FtquuOMoY5aWg5LZf0lk++8UDAw==",
+		"node_modules/@tiptap/extension-youtube": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extension-youtube/-/extension-youtube-3.0.7.tgz",
+			"integrity": "sha512-BD4rc7Xoi3O+puXSEArHAbBVu4dhj+9TuuVYzEFgNHI+FN/py9J5AiNf4TXGKBSlMUOYPpODaEROwyGmqAmpuA==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7"
 			}
 		},
-		"node_modules/@tiptap/extension-underline": {
-			"version": "2.25.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.25.0.tgz",
-			"integrity": "sha512-RqXkWSMJyllfsDukugDzWEZfWRUOgcqzuMWC40BnuDUs4KgdRA0nhVUWJbLfUEmXI0UVqN5OwYTTAdhaiF7kjQ==",
+		"node_modules/@tiptap/extensions": {
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.0.7.tgz",
+			"integrity": "sha512-GkXX5l7Q/543BKsC14j8M3qT+75ILb7138zy7cZoHm/s1ztV1XTknpEswBZIRZA9n6qq+Wd9g5qkbR879s6xhA==",
 			"license": "MIT",
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			},
 			"peerDependencies": {
-				"@tiptap/core": "^2.7.0"
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			}
 		},
 		"node_modules/@tiptap/pm": {
-			"version": "2.11.7",
-			"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.11.7.tgz",
-			"integrity": "sha512-7gEEfz2Q6bYKXM07vzLUD0vqXFhC5geWRA6LCozTiLdVFDdHWiBrvb2rtkL5T7mfLq03zc1QhH7rI3F6VntOEA==",
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.0.7.tgz",
+			"integrity": "sha512-f8PnWjYqbMCxny8cyjbFNeIyeOYLECTa/7gj8DJr53Ns+P94b4kYIt/GkveR5KoOxsbmXi8Uc4mjcR1giQPaIQ==",
 			"license": "MIT",
 			"dependencies": {
-				"prosemirror-changeset": "^2.2.1",
+				"prosemirror-changeset": "^2.3.0",
 				"prosemirror-collab": "^1.3.1",
 				"prosemirror-commands": "^1.6.2",
 				"prosemirror-dropcursor": "^1.8.1",
@@ -3604,14 +3628,14 @@
 				"prosemirror-keymap": "^1.2.2",
 				"prosemirror-markdown": "^1.13.1",
 				"prosemirror-menu": "^1.2.4",
-				"prosemirror-model": "^1.23.0",
+				"prosemirror-model": "^1.24.1",
 				"prosemirror-schema-basic": "^1.2.3",
-				"prosemirror-schema-list": "^1.4.1",
+				"prosemirror-schema-list": "^1.5.0",
 				"prosemirror-state": "^1.4.3",
 				"prosemirror-tables": "^1.6.4",
 				"prosemirror-trailing-node": "^3.0.0",
 				"prosemirror-transform": "^1.10.2",
-				"prosemirror-view": "^1.37.0"
+				"prosemirror-view": "^1.38.1"
 			},
 			"funding": {
 				"type": "github",
@@ -3619,38 +3643,62 @@
 			}
 		},
 		"node_modules/@tiptap/starter-kit": {
-			"version": "2.10.0",
-			"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.10.0.tgz",
-			"integrity": "sha512-hMIM9a6HjYZo25EzhZHlKEIR7CFi0grRSOltEyggiyBuQqKFkI7iwCpZVVtviDV1FwV0EPANpIAxPS7aBRgFdg==",
-			"license": "MIT",
-			"dependencies": {
-				"@tiptap/core": "^2.10.0",
-				"@tiptap/extension-blockquote": "^2.10.0",
-				"@tiptap/extension-bold": "^2.10.0",
-				"@tiptap/extension-bullet-list": "^2.10.0",
-				"@tiptap/extension-code": "^2.10.0",
-				"@tiptap/extension-code-block": "^2.10.0",
-				"@tiptap/extension-document": "^2.10.0",
-				"@tiptap/extension-dropcursor": "^2.10.0",
-				"@tiptap/extension-gapcursor": "^2.10.0",
-				"@tiptap/extension-hard-break": "^2.10.0",
-				"@tiptap/extension-heading": "^2.10.0",
-				"@tiptap/extension-history": "^2.10.0",
-				"@tiptap/extension-horizontal-rule": "^2.10.0",
-				"@tiptap/extension-italic": "^2.10.0",
-				"@tiptap/extension-list-item": "^2.10.0",
-				"@tiptap/extension-ordered-list": "^2.10.0",
-				"@tiptap/extension-paragraph": "^2.10.0",
-				"@tiptap/extension-strike": "^2.10.0",
-				"@tiptap/extension-text": "^2.10.0",
-				"@tiptap/extension-text-style": "^2.10.0",
-				"@tiptap/pm": "^2.10.0"
+			"version": "3.0.7",
+			"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.0.7.tgz",
+			"integrity": "sha512-oTHZp6GXQQaZfZi8Fh7klH2YUeGq73XPF35CFw41mwdWdUUUms3ipaCKFqUyEYO21JMf3pZylJLxUucx5U7isg==",
+			"license": "MIT",
+			"dependencies": {
+				"@tiptap/core": "^3.0.7",
+				"@tiptap/extension-blockquote": "^3.0.7",
+				"@tiptap/extension-bold": "^3.0.7",
+				"@tiptap/extension-bullet-list": "^3.0.7",
+				"@tiptap/extension-code": "^3.0.7",
+				"@tiptap/extension-code-block": "^3.0.7",
+				"@tiptap/extension-document": "^3.0.7",
+				"@tiptap/extension-dropcursor": "^3.0.7",
+				"@tiptap/extension-gapcursor": "^3.0.7",
+				"@tiptap/extension-hard-break": "^3.0.7",
+				"@tiptap/extension-heading": "^3.0.7",
+				"@tiptap/extension-horizontal-rule": "^3.0.7",
+				"@tiptap/extension-italic": "^3.0.7",
+				"@tiptap/extension-link": "^3.0.7",
+				"@tiptap/extension-list": "^3.0.7",
+				"@tiptap/extension-list-item": "^3.0.7",
+				"@tiptap/extension-list-keymap": "^3.0.7",
+				"@tiptap/extension-ordered-list": "^3.0.7",
+				"@tiptap/extension-paragraph": "^3.0.7",
+				"@tiptap/extension-strike": "^3.0.7",
+				"@tiptap/extension-text": "^3.0.7",
+				"@tiptap/extension-underline": "^3.0.7",
+				"@tiptap/extensions": "^3.0.7",
+				"@tiptap/pm": "^3.0.7"
 			},
 			"funding": {
 				"type": "github",
 				"url": "https://github.com/sponsors/ueberdosis"
 			}
 		},
+		"node_modules/@tiptap/y-tiptap": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.0.tgz",
+			"integrity": "sha512-HIeJZCj+KYJde2x6fONzo4o6kd7gW7eonwhQsv2p2VQnUgwNXMVhN+D6Z3AH/2i541Sq33y1PO4U/1ThCPjqbA==",
+			"license": "MIT",
+			"peer": true,
+			"dependencies": {
+				"lib0": "^0.2.100"
+			},
+			"engines": {
+				"node": ">=16.0.0",
+				"npm": ">=8.0.0"
+			},
+			"peerDependencies": {
+				"prosemirror-model": "^1.7.1",
+				"prosemirror-state": "^1.2.3",
+				"prosemirror-view": "^1.9.10",
+				"y-protocols": "^1.0.1",
+				"yjs": "^13.5.38"
+			}
+		},
 		"node_modules/@types/cookie": {
 			"version": "0.6.0",
 			"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -3919,7 +3967,6 @@
 			"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
 			"integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"@types/unist": "*"
 			}
@@ -4003,8 +4050,7 @@
 		"node_modules/@types/unist": {
 			"version": "2.0.10",
 			"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
-			"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==",
-			"peer": true
+			"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA=="
 		},
 		"node_modules/@types/yauzl": {
 			"version": "2.10.3",
@@ -6378,7 +6424,6 @@
 			"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
 			"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"dequal": "^2.0.0"
 			},
@@ -7665,9 +7710,10 @@
 			"dev": true
 		},
 		"node_modules/highlight.js": {
-			"version": "11.9.0",
-			"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz",
-			"integrity": "sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==",
+			"version": "11.11.1",
+			"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
+			"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
+			"license": "BSD-3-Clause",
 			"engines": {
 				"node": ">=12.0.0"
 			}
@@ -8941,15 +8987,14 @@
 			}
 		},
 		"node_modules/lowlight": {
-			"version": "3.1.0",
-			"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.1.0.tgz",
-			"integrity": "sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ==",
+			"version": "3.3.0",
+			"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
+			"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
 			"license": "MIT",
-			"peer": true,
 			"dependencies": {
 				"@types/hast": "^3.0.0",
 				"devlop": "^1.0.0",
-				"highlight.js": "~11.9.0"
+				"highlight.js": "~11.11.0"
 			},
 			"funding": {
 				"type": "github",
@@ -10108,9 +10153,9 @@
 			}
 		},
 		"node_modules/prosemirror-changeset": {
-			"version": "2.2.1",
-			"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz",
-			"integrity": "sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==",
+			"version": "2.3.1",
+			"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+			"integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
 			"license": "MIT",
 			"dependencies": {
 				"prosemirror-transform": "^1.0.0"

+ 19 - 20
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "open-webui",
-	"version": "0.6.16",
+	"version": "0.6.18",
 	"private": true,
 	"scripts": {
 		"dev": "npm run pyodide:fetch && vite dev --host",
@@ -57,30 +57,28 @@
 		"@codemirror/lang-python": "^6.1.6",
 		"@codemirror/language-data": "^6.5.1",
 		"@codemirror/theme-one-dark": "^6.1.2",
+		"@floating-ui/dom": "^1.7.2",
 		"@huggingface/transformers": "^3.0.0",
 		"@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.11.9",
-		"@tiptap/extension-bubble-menu": "^2.25.0",
-		"@tiptap/extension-character-count": "^2.25.0",
-		"@tiptap/extension-code-block-lowlight": "^2.11.9",
-		"@tiptap/extension-floating-menu": "^2.25.0",
-		"@tiptap/extension-highlight": "^2.10.0",
-		"@tiptap/extension-history": "^2.25.1",
-		"@tiptap/extension-link": "^2.25.0",
-		"@tiptap/extension-placeholder": "^2.10.0",
-		"@tiptap/extension-table": "^2.12.0",
-		"@tiptap/extension-table-cell": "^2.12.0",
-		"@tiptap/extension-table-header": "^2.12.0",
-		"@tiptap/extension-table-row": "^2.12.0",
-		"@tiptap/extension-task-item": "^2.25.0",
-		"@tiptap/extension-task-list": "^2.25.0",
-		"@tiptap/extension-typography": "^2.10.0",
-		"@tiptap/extension-underline": "^2.25.0",
-		"@tiptap/pm": "^2.11.7",
-		"@tiptap/starter-kit": "^2.10.0",
+		"@tiptap/core": "^3.0.7",
+		"@tiptap/extension-bubble-menu": "^2.26.1",
+		"@tiptap/extension-code-block-lowlight": "^3.0.7",
+		"@tiptap/extension-drag-handle": "^3.0.7",
+		"@tiptap/extension-file-handler": "^3.0.7",
+		"@tiptap/extension-floating-menu": "^2.26.1",
+		"@tiptap/extension-highlight": "^3.0.7",
+		"@tiptap/extension-image": "^3.0.7",
+		"@tiptap/extension-link": "^3.0.7",
+		"@tiptap/extension-list": "^3.0.7",
+		"@tiptap/extension-table": "^3.0.7",
+		"@tiptap/extension-typography": "^3.0.7",
+		"@tiptap/extension-youtube": "^3.0.7",
+		"@tiptap/extensions": "^3.0.7",
+		"@tiptap/pm": "^3.0.7",
+		"@tiptap/starter-kit": "^3.0.7",
 		"@xyflow/svelte": "^0.1.19",
 		"async": "^3.2.5",
 		"bits-ui": "^0.21.15",
@@ -108,6 +106,7 @@
 		"katex": "^0.16.22",
 		"kokoro-js": "^1.1.1",
 		"leaflet": "^1.9.4",
+		"lowlight": "^3.3.0",
 		"marked": "^9.1.0",
 		"mermaid": "^11.6.0",
 		"paneforge": "^0.0.6",

+ 10 - 0
pyproject.toml

@@ -22,6 +22,7 @@ dependencies = [
     "aiocache",
     "aiofiles",
     "starlette-compress==1.6.0",
+    "httpx[socks,http2,zstd,cli,brotli]==0.28.1",
 
     "sqlalchemy==2.0.38",
     "alembic==1.14.0",
@@ -39,6 +40,8 @@ dependencies = [
     "argon2-cffi==23.1.0",
     "APScheduler==3.10.4",
 
+    "pycrdt==0.12.25",
+
 
     "RestrictedPython==8.0",
 
@@ -133,6 +136,8 @@ dependencies = [
 
     "moto[s3]>=5.0.26",
 
+    "posthog==5.4.0",
+
 ]
 readme = "README.md"
 requires-python = ">= 3.11, < 3.13.0a1"
@@ -188,3 +193,8 @@ skip = '.git*,*.svg,package-lock.json,i18n,*.lock,*.css,*-bundle.js,locales,exam
 check-hidden = true
 # ignore-regex = ''
 ignore-words-list = 'ans'
+
+[dependency-groups]
+dev = [
+    "pytest-asyncio>=1.0.0",
+]

+ 5 - 0
src/app.css

@@ -40,6 +40,11 @@ code {
 	width: auto;
 }
 
+.editor-selection {
+	background: rgba(180, 213, 255, 0.5);
+	border-radius: 2px;
+}
+
 .font-secondary {
 	font-family: 'InstrumentSerif', sans-serif;
 }

+ 3 - 2
src/lib/apis/chats/index.ts

@@ -1,7 +1,7 @@
 import { WEBUI_API_BASE_URL } from '$lib/constants';
 import { getTimeRange } from '$lib/utils';
 
-export const createNewChat = async (token: string, chat: object) => {
+export const createNewChat = async (token: string, chat: object, folderId: string | null) => {
 	let error = null;
 
 	const res = await fetch(`${WEBUI_API_BASE_URL}/chats/new`, {
@@ -12,7 +12,8 @@ export const createNewChat = async (token: string, chat: object) => {
 			authorization: `Bearer ${token}`
 		},
 		body: JSON.stringify({
-			chat: chat
+			chat: chat,
+			folder_id: folderId ?? null
 		})
 	})
 		.then(async (res) => {

+ 7 - 9
src/lib/apis/folders/index.ts

@@ -1,6 +1,11 @@
 import { WEBUI_API_BASE_URL } from '$lib/constants';
 
-export const createNewFolder = async (token: string, name: string) => {
+type FolderForm = {
+	name: string;
+	data?: Record<string, any>;
+};
+
+export const createNewFolder = async (token: string, folderForm: FolderForm) => {
 	let error = null;
 
 	const res = await fetch(`${WEBUI_API_BASE_URL}/folders/`, {
@@ -10,9 +15,7 @@ export const createNewFolder = async (token: string, name: string) => {
 			'Content-Type': 'application/json',
 			authorization: `Bearer ${token}`
 		},
-		body: JSON.stringify({
-			name: name
-		})
+		body: JSON.stringify(folderForm)
 	})
 		.then(async (res) => {
 			if (!res.ok) throw await res.json();
@@ -92,11 +95,6 @@ export const getFolderById = async (token: string, id: string) => {
 	return res;
 };
 
-type FolderForm = {
-	name: string;
-	data?: Record<string, any>;
-};
-
 export const updateFolderById = async (token: string, id: string, folderForm: FolderForm) => {
 	let error = null;
 

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

@@ -201,7 +201,7 @@
 									class="w-full rounded-lg py-2 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-hidden"
 									bind:value={STT_SUPPORTED_CONTENT_TYPES}
 									placeholder={$i18n.t(
-										'e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults, * for all)'
+										'e.g., audio/wav,audio/mpeg,video/* (leave blank for defaults)'
 									)}
 								/>
 							</div>

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

@@ -211,11 +211,9 @@
 		{:else}
 			<div>
 				<div class=" flex items-center gap-3 justify-between text-xs uppercase px-1 font-bold">
-					<div class="w-full">Group</div>
+					<div class="w-full basis-3/5">Group</div>
 
-					<div class="w-full">Users</div>
-
-					<div class="w-full"></div>
+					<div class="w-full basis-2/5 text-right">Users</div>
 				</div>
 
 				<hr class="mt-1.5 border-gray-100 dark:border-gray-850" />

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

@@ -61,22 +61,22 @@
 		showEdit = true;
 	}}
 >
-	<div class="flex items-center gap-1.5 w-full font-medium">
+	<div class="flex items-center gap-1.5 w-full font-medium flex-1">
 		<div>
 			<UserCircleSolid className="size-4" />
 		</div>
-		{group.name}
+		<div class="line-clamp-1">
+			{group.name}
+		</div>
 	</div>
 
-	<div class="flex items-center gap-1.5 w-full font-medium">
+	<div class="flex items-center gap-1.5 w-fit font-medium text-right justify-end">
 		{group.user_ids.length}
 
 		<div>
 			<User className="size-3.5" />
 		</div>
-	</div>
 
-	<div class="w-full flex justify-end">
 		<div class=" rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition">
 			<Pencil className="size-3.5" />
 		</div>

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

@@ -57,7 +57,7 @@
 		</div>
 	</div>
 
-	<div class="mt-3 max-h-[22rem] overflow-y-auto scrollbar-hidden">
+	<div class="mt-3 scrollbar-hidden">
 		<div class="flex flex-col gap-2.5">
 			{#if filteredUsers.length > 0}
 				{#each filteredUsers as user, userIdx (user.id)}
@@ -78,16 +78,6 @@
 						<div class="flex w-full items-center justify-between">
 							<Tooltip content={user.email} placement="top-start">
 								<div class="flex">
-									<img
-										class=" rounded-full size-5 object-cover mr-2.5"
-										src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
-										user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
-										user.profile_image_url.startsWith('data:')
-											? user.profile_image_url
-											: `${WEBUI_BASE_URL}/user.png`}
-										alt="user"
-									/>
-
 									<div class=" font-medium self-center">{user.name}</div>
 								</div>
 							</Tooltip>

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

@@ -392,7 +392,7 @@
 							<div class="flex flex-row w-max">
 								<img
 									class=" rounded-full w-6 h-6 object-cover mr-2.5"
-									src={user.profile_image_url.startsWith(WEBUI_BASE_URL) ||
+									src={user?.profile_image_url?.startsWith(WEBUI_BASE_URL) ||
 									user.profile_image_url.startsWith('https://www.gravatar.com/avatar/') ||
 									user.profile_image_url.startsWith('data:')
 										? user.profile_image_url

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

@@ -709,6 +709,7 @@
 												navigator.msMaxTouchPoints > 0
 											))}
 									largeTextAsFile={$settings?.largeTextAsFile ?? false}
+									floatingMenuPlacement={'top-start'}
 									onChange={(e) => {
 										const { md } = e;
 										content = md;

+ 46 - 21
src/lib/components/chat/Chat.svelte

@@ -36,7 +36,8 @@
 		chatTitle,
 		showArtifacts,
 		tools,
-		toolServers
+		toolServers,
+		selectedFolder
 	} from '$lib/stores';
 	import {
 		convertMessagesToHistory,
@@ -1227,7 +1228,7 @@
 			await handleOpenAIError(error, message);
 		}
 
-		if (sources) {
+		if (sources && !message?.sources) {
 			message.sources = sources;
 		}
 
@@ -1351,6 +1352,12 @@
 			);
 
 			history.messages[message.id] = message;
+
+			await tick();
+			if (autoScroll) {
+				scrollToBottom();
+			}
+
 			await chatCompletedHandler(
 				chatId,
 				message.model,
@@ -1360,6 +1367,8 @@
 		}
 
 		console.log(data);
+		await tick();
+
 		if (autoScroll) {
 			scrollToBottom();
 		}
@@ -1978,25 +1987,33 @@
 		let _chatId = $chatId;
 
 		if (!$temporaryChatEnabled) {
-			chat = await createNewChat(localStorage.token, {
-				id: _chatId,
-				title: $i18n.t('New Chat'),
-				models: selectedModels,
-				system: $settings.system ?? undefined,
-				params: params,
-				history: history,
-				messages: createMessagesList(history, history.currentId),
-				tags: [],
-				timestamp: Date.now()
-			});
+			chat = await createNewChat(
+				localStorage.token,
+				{
+					id: _chatId,
+					title: $i18n.t('New Chat'),
+					models: selectedModels,
+					system: $settings.system ?? undefined,
+					params: params,
+					history: history,
+					messages: createMessagesList(history, history.currentId),
+					tags: [],
+					timestamp: Date.now()
+				},
+				$selectedFolder?.id
+			);
 
 			_chatId = chat.id;
 			await chatId.set(_chatId);
 
+			window.history.replaceState(history.state, '', `/c/${_chatId}`);
+
+			await tick();
+
 			await chats.set(await getChatList(localStorage.token, $currentChatPage));
 			currentChatPage.set(1);
 
-			window.history.replaceState(history.state, '', `/c/${_chatId}`);
+			selectedFolder.set(null);
 		} else {
 			_chatId = 'local';
 			await chatId.set('local');
@@ -2060,12 +2077,13 @@
 >
 	{#if !loading}
 		<div in:fade={{ duration: 50 }} class="w-full h-full flex flex-col">
-			{#if $settings?.backgroundImageUrl ?? null}
+			{#if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null}
 				<div
 					class="absolute {$showSidebar
 						? 'md:max-w-[calc(100%-260px)] md:translate-x-[260px]'
 						: ''} top-0 left-0 w-full h-full bg-cover bg-center bg-no-repeat"
-					style="background-image: url({$settings.backgroundImageUrl})  "
+					style="background-image: url({$settings?.backgroundImageUrl ??
+						$config?.license_metadata?.background_image_url})  "
 				/>
 
 				<div
@@ -2096,8 +2114,8 @@
 						showBanners={!showCommands}
 					/>
 
-					<div class="flex flex-col flex-auto z-10 w-full @container">
-						{#if $settings?.landingPageMode === 'chat' || createMessagesList(history, history.currentId).length > 0}
+					<div class="flex flex-col flex-auto z-10 w-full @container overflow-auto">
+						{#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
 							<div
 								class=" pb-2.5 flex flex-col justify-between w-full flex-auto overflow-auto h-0 max-w-full z-10 scrollbar-hidden"
 								id="messages-container"
@@ -2114,6 +2132,9 @@
 										bind:history
 										bind:autoScroll
 										bind:prompt
+										setInputText={(text) => {
+											messageInput?.setText(text);
+										}}
 										{selectedModels}
 										{atSelectedModel}
 										{sendPrompt}
@@ -2147,7 +2168,9 @@
 									bind:atSelectedModel
 									bind:showCommands
 									toolServers={$toolServers}
-									transparentBackground={$settings?.backgroundImageUrl ?? false}
+									transparentBackground={$settings?.backgroundImageUrl ??
+										$config?.license_metadata?.background_image_url ??
+										false}
 									{stopResponse}
 									{createMessagePair}
 									onChange={(input) => {
@@ -2192,7 +2215,7 @@
 								</div>
 							</div>
 						{:else}
-							<div class="overflow-auto w-full h-full flex items-center">
+							<div class="flex items-center h-full">
 								<Placeholder
 									{history}
 									{selectedModels}
@@ -2207,7 +2230,9 @@
 									bind:webSearchEnabled
 									bind:atSelectedModel
 									bind:showCommands
-									transparentBackground={$settings?.backgroundImageUrl ?? false}
+									transparentBackground={$settings?.backgroundImageUrl ??
+										$config?.license_metadata?.background_image_url ??
+										false}
 									toolServers={$toolServers}
 									{stopResponse}
 									{createMessagePair}

+ 22 - 1
src/lib/components/chat/MessageInput.svelte

@@ -72,6 +72,7 @@
 
 	import { KokoroWorker } from '$lib/workers/KokoroWorker';
 	import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
+	import Voice from '../icons/Voice.svelte';
 	const i18n = getContext('i18n');
 
 	export let transparentBackground = false;
@@ -505,6 +506,11 @@
 			return null;
 		}
 
+		if (fileUploadCapableModels.length !== selectedModels.length) {
+			toast.error($i18n.t('Model(s) do not support file upload'));
+			return null;
+		}
+
 		const tempItemId = uuidv4();
 		const fileItem = {
 			type: 'file',
@@ -1279,6 +1285,13 @@
 																};
 
 																reader.readAsDataURL(blob);
+															} else if (item?.kind === 'file') {
+																const file = item.getAsFile();
+																if (file) {
+																	const _files = [file];
+																	await inputFilesHandler(_files);
+																	e.preventDefault();
+																}
 															} else if (item.type === 'text/plain') {
 																if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
 																	const text = clipboardData.getData('text/plain');
@@ -1504,6 +1517,7 @@
 
 												if (clipboardData && clipboardData.items) {
 													for (const item of clipboardData.items) {
+														console.log(item);
 														if (item.type.indexOf('image') !== -1) {
 															const blob = item.getAsFile();
 															const reader = new FileReader();
@@ -1519,6 +1533,13 @@
 															};
 
 															reader.readAsDataURL(blob);
+														} else if (item?.kind === 'file') {
+															const file = item.getAsFile();
+															if (file) {
+																const _files = [file];
+																await inputFilesHandler(_files);
+																e.preventDefault();
+															}
 														} else if (item.type === 'text/plain') {
 															if (($settings?.largeTextAsFile ?? false) && !shiftKey) {
 																const text = clipboardData.getData('text/plain');
@@ -1882,7 +1903,7 @@
 														}}
 														aria-label={$i18n.t('Voice mode')}
 													>
-														<Headphone className="size-5" />
+														<Voice className="size-5" strokeWidth="2.5" />
 													</button>
 												</Tooltip>
 											</div>

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

@@ -153,6 +153,7 @@
 								...file,
 								name: file?.meta?.name,
 								description: `${file?.collection?.name} - ${file?.collection?.description}`,
+								knowledge: true, // DO NOT REMOVE, USED TO INDICATE KNOWLEDGE BASE FILE
 								type: 'file'
 							}))
 					]

+ 1 - 1
src/lib/components/chat/MessageInput/InputMenu.svelte

@@ -100,7 +100,7 @@
 
 	<div slot="content">
 		<DropdownMenu.Content
-			class="w-full max-w-[200px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
+			class="w-full max-w-[240px] rounded-xl px-1 py-1 border border-gray-300/30 dark:border-gray-700/50 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-sm"
 			sideOffset={10}
 			alignOffset={-8}
 			side="top"

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

@@ -36,6 +36,8 @@
 
 	let messages = [];
 
+	export let setInputText: Function = () => {};
+
 	export let sendPrompt: Function;
 	export let continueResponse: Function;
 	export let regenerateResponse: Function;
@@ -426,6 +428,7 @@
 							messageId={message.id}
 							idx={messageIdx}
 							{user}
+							{setInputText}
 							{gotoMessage}
 							{showPreviousMessage}
 							{showNextMessage}

+ 2 - 0
src/lib/components/chat/Messages/ContentRenderer.svelte

@@ -20,6 +20,7 @@
 	export let history;
 	export let selectedModels = [];
 
+	export let done = true;
 	export let model = null;
 	export let sources = null;
 
@@ -133,6 +134,7 @@
 		{model}
 		{save}
 		{preview}
+		{done}
 		sourceIds={(sources ?? []).reduce((acc, s) => {
 			let ids = [];
 			s.document.forEach((document, index) => {

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

@@ -10,6 +10,7 @@
 
 	export let id = '';
 	export let content;
+	export let done = true;
 	export let model = null;
 	export let save = false;
 	export let preview = false;
@@ -47,6 +48,7 @@
 	<MarkdownTokens
 		{tokens}
 		{id}
+		{done}
 		{save}
 		{preview}
 		{onTaskClick}

+ 6 - 11
src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens.svelte

@@ -14,8 +14,11 @@
 	import KatexRenderer from './KatexRenderer.svelte';
 	import Source from './Source.svelte';
 	import HtmlToken from './HTMLToken.svelte';
+	import TextToken from './MarkdownInlineTokens/TextToken.svelte';
+	import CodespanToken from './MarkdownInlineTokens/CodespanToken.svelte';
 
 	export let id: string;
+	export let done = true;
 	export let tokens: Token[];
 	export let onSourceClick: Function = () => {};
 </script>
@@ -28,7 +31,7 @@
 	{:else if token.type === 'link'}
 		{#if token.tokens}
 			<a href={token.href} target="_blank" rel="nofollow" title={token.title}>
-				<svelte:self id={`${id}-a`} tokens={token.tokens} {onSourceClick} />
+				<svelte:self id={`${id}-a`} tokens={token.tokens} {onSourceClick} {done} />
 			</a>
 		{:else}
 			<a href={token.href} target="_blank" rel="nofollow" title={token.title}>{token.text}</a>
@@ -40,15 +43,7 @@
 	{:else if token.type === 'em'}
 		<em><svelte:self id={`${id}-em`} tokens={token.tokens} {onSourceClick} /></em>
 	{:else if token.type === 'codespan'}
-		<!-- svelte-ignore a11y-click-events-have-key-events -->
-		<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
-		<code
-			class="codespan cursor-pointer"
-			on:click={() => {
-				copyToClipboard(unescapeHtml(token.text));
-				toast.success($i18n.t('Copied to clipboard'));
-			}}>{unescapeHtml(token.text)}</code
-		>
+		<CodespanToken {token} {done} />
 	{:else if token.type === 'br'}
 		<br />
 	{:else if token.type === 'del'}
@@ -66,6 +61,6 @@
 			onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';"
 		></iframe>
 	{:else if token.type === 'text'}
-		{token.raw}
+		<TextToken {token} {done} />
 	{/if}
 {/each}

+ 33 - 0
src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte

@@ -0,0 +1,33 @@
+<script lang="ts">
+	import { copyToClipboard, unescapeHtml } from '$lib/utils';
+	import { toast } from 'svelte-sonner';
+	import { fade } from 'svelte/transition';
+
+	import { getContext } from 'svelte';
+
+	const i18n = getContext('i18n');
+
+	export let token;
+	export let done = true;
+</script>
+
+<!-- svelte-ignore a11y-click-events-have-key-events -->
+<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
+{#if done}
+	<code
+		class="codespan cursor-pointer"
+		on:click={() => {
+			copyToClipboard(unescapeHtml(token.text));
+			toast.success($i18n.t('Copied to clipboard'));
+		}}>{unescapeHtml(token.text)}</code
+	>
+{:else}
+	<code
+		transition:fade={{ duration: 100 }}
+		class="codespan cursor-pointer"
+		on:click={() => {
+			copyToClipboard(unescapeHtml(token.text));
+			toast.success($i18n.t('Copied to clipboard'));
+		}}>{unescapeHtml(token.text)}</code
+	>
+{/if}

+ 19 - 0
src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte

@@ -0,0 +1,19 @@
+<script lang="ts">
+	import { fade } from 'svelte/transition';
+
+	export let token;
+	export let done = true;
+
+	let texts = [];
+	$: texts = (token?.raw ?? '').split(' ');
+</script>
+
+{#if done}
+	{token?.raw}
+{:else}
+	{#each texts as text}
+		<span class="" transition:fade={{ duration: 100 }}>
+			{text}
+		</span>
+	{/each}
+{/if}

+ 29 - 3
src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte

@@ -28,6 +28,8 @@
 	export let top = true;
 	export let attributes = {};
 
+	export let done = true;
+
 	export let save = false;
 	export let preview = false;
 
@@ -85,7 +87,12 @@
 		<hr class=" border-gray-100 dark:border-gray-850" />
 	{:else if token.type === 'heading'}
 		<svelte:element this={headerComponent(token.depth)} dir="auto">
-			<MarkdownInlineTokens id={`${id}-${tokenIdx}-h`} tokens={token.tokens} {onSourceClick} />
+			<MarkdownInlineTokens
+				id={`${id}-${tokenIdx}-h`}
+				tokens={token.tokens}
+				{done}
+				{onSourceClick}
+			/>
 		</svelte:element>
 	{:else if token.type === 'code'}
 		{#if token.raw.includes('```')}
@@ -132,6 +139,7 @@
 											<MarkdownInlineTokens
 												id={`${id}-${tokenIdx}-header-${headerIdx}`}
 												tokens={header.tokens}
+												{done}
 												{onSourceClick}
 											/>
 										</div>
@@ -152,6 +160,7 @@
 											<MarkdownInlineTokens
 												id={`${id}-${tokenIdx}-row-${rowIdx}-${cellIdx}`}
 												tokens={cell.tokens}
+												{done}
 												{onSourceClick}
 											/>
 										</div>
@@ -183,7 +192,13 @@
 			<AlertRenderer {token} {alert} />
 		{:else}
 			<blockquote dir="auto">
-				<svelte:self id={`${id}-${tokenIdx}`} tokens={token.tokens} {onTaskClick} {onSourceClick} />
+				<svelte:self
+					id={`${id}-${tokenIdx}`}
+					tokens={token.tokens}
+					{done}
+					{onTaskClick}
+					{onSourceClick}
+				/>
 			</blockquote>
 		{/if}
 	{:else if token.type === 'list'}
@@ -213,6 +228,7 @@
 							id={`${id}-${tokenIdx}-${itemIdx}`}
 							tokens={item.tokens}
 							top={token.loose}
+							{done}
 							{onTaskClick}
 							{onSourceClick}
 						/>
@@ -245,6 +261,7 @@
 									id={`${id}-${tokenIdx}-${itemIdx}`}
 									tokens={item.tokens}
 									top={token.loose}
+									{done}
 									{onTaskClick}
 									{onSourceClick}
 								/>
@@ -254,6 +271,7 @@
 								id={`${id}-${tokenIdx}-${itemIdx}`}
 								tokens={item.tokens}
 								top={token.loose}
+								{done}
 								{onTaskClick}
 								{onSourceClick}
 							/>
@@ -275,6 +293,7 @@
 					id={`${id}-${tokenIdx}-d`}
 					tokens={marked.lexer(token.text)}
 					attributes={token?.attributes}
+					{done}
 					{onTaskClick}
 					{onSourceClick}
 				/>
@@ -295,6 +314,7 @@
 			<MarkdownInlineTokens
 				id={`${id}-${tokenIdx}-p`}
 				tokens={token.tokens ?? []}
+				{done}
 				{onSourceClick}
 			/>
 		</p>
@@ -302,7 +322,12 @@
 		{#if top}
 			<p>
 				{#if token.tokens}
-					<MarkdownInlineTokens id={`${id}-${tokenIdx}-t`} tokens={token.tokens} {onSourceClick} />
+					<MarkdownInlineTokens
+						id={`${id}-${tokenIdx}-t`}
+						tokens={token.tokens}
+						{done}
+						{onSourceClick}
+					/>
 				{:else}
 					{unescapeHtml(token.text)}
 				{/if}
@@ -311,6 +336,7 @@
 			<MarkdownInlineTokens
 				id={`${id}-${tokenIdx}-p`}
 				tokens={token.tokens ?? []}
+				{done}
 				{onSourceClick}
 			/>
 		{:else}

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

@@ -21,6 +21,7 @@
 
 	export let user;
 
+	export let setInputText: Function = () => {};
 	export let gotoMessage;
 	export let showPreviousMessage;
 	export let showNextMessage;
@@ -74,6 +75,7 @@
 				{selectedModels}
 				isLastMessage={messageId === history.currentId}
 				siblings={history.messages[history.messages[messageId].parentId]?.childrenIds ?? []}
+				{setInputText}
 				{gotoMessage}
 				{showPreviousMessage}
 				{showNextMessage}
@@ -96,6 +98,7 @@
 				{messageId}
 				{selectedModels}
 				isLastMessage={messageId === history?.currentId}
+				{setInputText}
 				{updateChat}
 				{editMessage}
 				{saveMessage}

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

@@ -28,6 +28,7 @@
 	export let isLastMessage;
 	export let readOnly = false;
 
+	export let setInputText: Function = () => {};
 	export let updateChat: Function;
 	export let editMessage: Function;
 	export let saveMessage: Function;
@@ -259,6 +260,7 @@
 									gotoMessage={(message, messageIdx) => gotoMessage(modelIdx, messageIdx)}
 									showPreviousMessage={() => showPreviousMessage(modelIdx)}
 									showNextMessage={() => showNextMessage(modelIdx)}
+									{setInputText}
 									{updateChat}
 									{editMessage}
 									{saveMessage}

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

@@ -117,6 +117,7 @@
 
 	export let siblings;
 
+	export let setInputText: Function = () => {};
 	export let gotoMessage: Function = () => {};
 	export let showPreviousMessage: Function;
 	export let showNextMessage: Function;
@@ -165,7 +166,7 @@
 			text = `${text}\n\n${$config?.ui?.response_watermark}`;
 		}
 
-		const res = await _copyToClipboard(text, $settings?.copyFormatted ?? false);
+		const res = await _copyToClipboard(text, null, $settings?.copyFormatted ?? false);
 		if (res) {
 			toast.success($i18n.t('Copying to clipboard was successful!'));
 		}
@@ -804,6 +805,9 @@
 										floatingButtons={message?.done && !readOnly}
 										save={!readOnly}
 										preview={!readOnly}
+										done={($settings?.chatFadeStreamingText ?? true)
+											? (message?.done ?? false)
+											: true}
 										{model}
 										onTaskClick={async (e) => {
 											console.log(e);
@@ -1461,12 +1465,18 @@
 						/>
 					{/if}
 
-					{#if isLastMessage && message.done && !readOnly && (message?.followUps ?? []).length > 0}
+					{#if (isLastMessage || ($settings?.keepFollowUpPrompts ?? false)) && message.done && !readOnly && (message?.followUps ?? []).length > 0}
 						<div class="mt-2.5" in:fade={{ duration: 100 }}>
 							<FollowUps
 								followUps={message?.followUps}
 								onClick={(prompt) => {
-									submitMessage(message?.id, prompt);
+									if ($settings?.insertFollowUpPrompt ?? false) {
+										// Insert the follow-up prompt into the input box
+										setInputText(prompt);
+									} else {
+										// Submit the follow-up prompt directly
+										submitMessage(message?.id, prompt);
+									}
 								}}
 							/>
 						</div>

+ 26 - 3
src/lib/components/chat/ModelSelector/ModelItem.svelte

@@ -14,6 +14,8 @@
 	import ModelItemMenu from './ModelItemMenu.svelte';
 	import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
 	import { toast } from 'svelte-sonner';
+	import Tag from '$lib/components/icons/Tag.svelte';
+	import Label from '$lib/components/icons/Label.svelte';
 
 	const i18n = getContext('i18n');
 
@@ -42,7 +44,8 @@
 </script>
 
 <button
-	aria-label="model-item"
+	aria-roledescription="model-item"
+	aria-label={item.label}
 	class="flex group/item w-full text-left font-medium line-clamp-1 select-none items-center rounded-button py-2 pl-3 pr-1.5 text-sm text-gray-700 dark:text-gray-100 outline-hidden transition-all duration-75 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg cursor-pointer data-highlighted:bg-muted {index ===
 	selectedModelIdx
 		? 'bg-gray-100 dark:bg-gray-800 group-hover:bg-transparent'
@@ -54,7 +57,7 @@
 	}}
 >
 	<div class="flex flex-col flex-1 gap-1.5">
-		{#if (item?.model?.tags ?? []).length > 0}
+		<!-- {#if (item?.model?.tags ?? []).length > 0}
 			<div
 				class="flex gap-0.5 self-center items-start h-full w-full translate-y-[0.5px] overflow-x-auto scrollbar-none"
 			>
@@ -68,7 +71,7 @@
 					</Tooltip>
 				{/each}
 			</div>
-		{/if}
+		{/if} -->
 
 		<div class="flex items-center gap-2">
 			<div class="flex items-center min-w-fit">
@@ -135,6 +138,26 @@
 
 				<!-- {JSON.stringify(item.info)} -->
 
+				{#if (item?.model?.tags ?? []).length > 0}
+					{#key item.model.id}
+						<Tooltip elementId="tags-{item.model.id}">
+							<div slot="tooltip" id="tags-{item.model.id}">
+								{#each item.model?.tags.sort((a, b) => a.name.localeCompare(b.name)) as tag}
+									<Tooltip content={tag.name} className="flex-shrink-0">
+										<div class=" text-xs font-semibold rounded-sm uppercase text-white">
+											{tag.name}
+										</div>
+									</Tooltip>
+								{/each}
+							</div>
+
+							<div class="translate-y-[1px]">
+								<Tag />
+							</div>
+						</Tooltip>
+					{/key}
+				{/if}
+
 				{#if item.model?.direct}
 					<Tooltip content={`${$i18n.t('Direct')}`}>
 						<div class="translate-y-[1px]">

+ 135 - 96
src/lib/components/chat/Placeholder.svelte

@@ -7,7 +7,15 @@
 
 	const dispatch = createEventDispatcher();
 
-	import { config, user, models as _models, temporaryChatEnabled } from '$lib/stores';
+	import {
+		config,
+		user,
+		models as _models,
+		temporaryChatEnabled,
+		selectedFolder,
+		chats,
+		currentChatPage
+	} from '$lib/stores';
 	import { sanitizeResponseContent, extractCurlyBraceWords } from '$lib/utils';
 	import { WEBUI_BASE_URL } from '$lib/constants';
 
@@ -15,6 +23,9 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import EyeSlash from '$lib/components/icons/EyeSlash.svelte';
 	import MessageInput from './MessageInput.svelte';
+	import FolderPlaceholder from './Placeholder/FolderPlaceholder.svelte';
+	import FolderTitle from './Placeholder/FolderTitle.svelte';
+	import { getChatList } from '$lib/apis/chats';
 
 	const i18n = getContext('i18n');
 
@@ -77,103 +88,121 @@
 		class="w-full text-3xl text-gray-800 dark:text-gray-100 text-center flex items-center gap-4 font-primary"
 	>
 		<div class="w-full flex flex-col justify-center items-center">
-			<div class="flex flex-row justify-center gap-3 @sm:gap-3.5 w-fit px-5 max-w-xl">
-				<div class="flex shrink-0 justify-center">
-					<div class="flex -space-x-4 mb-0.5" in:fade={{ duration: 100 }}>
-						{#each models as model, modelIdx}
+			{#if $selectedFolder}
+				<FolderTitle
+					folder={$selectedFolder}
+					onUpdate={async (folder) => {
+						selectedFolder.set(folder);
+
+						await chats.set(await getChatList(localStorage.token, $currentChatPage));
+						currentChatPage.set(1);
+					}}
+					onDelete={async () => {
+						await chats.set(await getChatList(localStorage.token, $currentChatPage));
+						currentChatPage.set(1);
+
+						selectedFolder.set(null);
+					}}
+				/>
+			{:else}
+				<div class="flex flex-row justify-center gap-3 @sm:gap-3.5 w-fit px-5 max-w-xl">
+					<div class="flex shrink-0 justify-center">
+						<div class="flex -space-x-4 mb-0.5" in:fade={{ duration: 100 }}>
+							{#each models as model, modelIdx}
+								<Tooltip
+									content={(models[modelIdx]?.info?.meta?.tags ?? [])
+										.map((tag) => tag.name.toUpperCase())
+										.join(', ')}
+									placement="top"
+								>
+									<button
+										aria-hidden={models.length <= 1}
+										aria-label={$i18n.t('Get information on {{name}} in the UI', {
+											name: models[modelIdx]?.name
+										})}
+										on:click={() => {
+											selectedModelIdx = modelIdx;
+										}}
+									>
+										<img
+											crossorigin="anonymous"
+											src={model?.info?.meta?.profile_image_url ??
+												($i18n.language === 'dg-DG'
+													? `${WEBUI_BASE_URL}/doge.png`
+													: `${WEBUI_BASE_URL}/static/favicon.png`)}
+											class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
+											aria-hidden="true"
+											draggable="false"
+										/>
+									</button>
+								</Tooltip>
+							{/each}
+						</div>
+					</div>
+
+					<div
+						class=" text-3xl @sm:text-3xl line-clamp-1 flex items-center"
+						in:fade={{ duration: 100 }}
+					>
+						{#if models[selectedModelIdx]?.name}
 							<Tooltip
-								content={(models[modelIdx]?.info?.meta?.tags ?? [])
-									.map((tag) => tag.name.toUpperCase())
-									.join(', ')}
+								content={models[selectedModelIdx]?.name}
 								placement="top"
+								className=" flex items-center "
 							>
-								<button
-									aria-hidden={models.length <= 1}
-									aria-label={$i18n.t('Get information on {{name}} in the UI', {
-										name: models[modelIdx]?.name
-									})}
-									on:click={() => {
-										selectedModelIdx = modelIdx;
-									}}
-								>
-									<img
-										crossorigin="anonymous"
-										src={model?.info?.meta?.profile_image_url ??
-											($i18n.language === 'dg-DG'
-												? `${WEBUI_BASE_URL}/doge.png`
-												: `${WEBUI_BASE_URL}/static/favicon.png`)}
-										class=" size-9 @sm:size-10 rounded-full border-[1px] border-gray-100 dark:border-none"
-										aria-hidden="true"
-										draggable="false"
-									/>
-								</button>
+								<span class="line-clamp-1">
+									{models[selectedModelIdx]?.name}
+								</span>
 							</Tooltip>
-						{/each}
+						{:else}
+							{$i18n.t('Hello, {{name}}', { name: $user?.name })}
+						{/if}
 					</div>
 				</div>
 
-				<div
-					class=" text-3xl @sm:text-3xl line-clamp-1 flex items-center"
-					in:fade={{ duration: 100 }}
-				>
-					{#if models[selectedModelIdx]?.name}
-						<Tooltip
-							content={models[selectedModelIdx]?.name}
-							placement="top"
-							className=" flex items-center "
-						>
-							<span class="line-clamp-1">
-								{models[selectedModelIdx]?.name}
-							</span>
-						</Tooltip>
-					{:else}
-						{$i18n.t('Hello, {{name}}', { name: $user?.name })}
-					{/if}
-				</div>
-			</div>
-
-			<div class="flex mt-1 mb-2">
-				<div in:fade={{ duration: 100, delay: 50 }}>
-					{#if models[selectedModelIdx]?.info?.meta?.description ?? null}
-						<Tooltip
-							className=" w-fit"
-							content={marked.parse(
-								sanitizeResponseContent(
-									models[selectedModelIdx]?.info?.meta?.description ?? ''
-								).replaceAll('\n', '<br>')
-							)}
-							placement="top"
-						>
-							<div
-								class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
-							>
-								{@html marked.parse(
+				<div class="flex mt-1 mb-2">
+					<div in:fade={{ duration: 100, delay: 50 }}>
+						{#if models[selectedModelIdx]?.info?.meta?.description ?? null}
+							<Tooltip
+								className=" w-fit"
+								content={marked.parse(
 									sanitizeResponseContent(
 										models[selectedModelIdx]?.info?.meta?.description ?? ''
 									).replaceAll('\n', '<br>')
 								)}
-							</div>
-						</Tooltip>
-
-						{#if models[selectedModelIdx]?.info?.meta?.user}
-							<div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500">
-								By
-								{#if models[selectedModelIdx]?.info?.meta?.user.community}
-									<a
-										href="https://openwebui.com/m/{models[selectedModelIdx]?.info?.meta?.user
-											.username}"
-										>{models[selectedModelIdx]?.info?.meta?.user.name
-											? models[selectedModelIdx]?.info?.meta?.user.name
-											: `@${models[selectedModelIdx]?.info?.meta?.user.username}`}</a
-									>
-								{:else}
-									{models[selectedModelIdx]?.info?.meta?.user.name}
-								{/if}
-							</div>
+								placement="top"
+							>
+								<div
+									class="mt-0.5 px-2 text-sm font-normal text-gray-500 dark:text-gray-400 line-clamp-2 max-w-xl markdown"
+								>
+									{@html marked.parse(
+										sanitizeResponseContent(
+											models[selectedModelIdx]?.info?.meta?.description ?? ''
+										).replaceAll('\n', '<br>')
+									)}
+								</div>
+							</Tooltip>
+
+							{#if models[selectedModelIdx]?.info?.meta?.user}
+								<div class="mt-0.5 text-sm font-normal text-gray-400 dark:text-gray-500">
+									By
+									{#if models[selectedModelIdx]?.info?.meta?.user.community}
+										<a
+											href="https://openwebui.com/m/{models[selectedModelIdx]?.info?.meta?.user
+												.username}"
+											>{models[selectedModelIdx]?.info?.meta?.user.name
+												? models[selectedModelIdx]?.info?.meta?.user.name
+												: `@${models[selectedModelIdx]?.info?.meta?.user.username}`}</a
+										>
+									{:else}
+										{models[selectedModelIdx]?.info?.meta?.user.name}
+									{/if}
+								</div>
+							{/if}
 						{/if}
-					{/if}
+					</div>
 				</div>
-			</div>
+			{/if}
 
 			<div class="text-base font-normal @md:max-w-3xl w-full py-3 {atSelectedModel ? 'mt-2' : ''}">
 				<MessageInput
@@ -214,16 +243,26 @@
 			</div>
 		</div>
 	</div>
-	<div class="mx-auto max-w-2xl font-primary mt-2" in:fade={{ duration: 200, delay: 200 }}>
-		<div class="mx-5">
-			<Suggestions
-				suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??
-					models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
-					$config?.default_prompt_suggestions ??
-					[]}
-				inputValue={prompt}
-				{onSelect}
-			/>
+
+	{#if $selectedFolder}
+		<div
+			class="mx-auto px-4 md:max-w-3xl md:px-6 font-primary min-h-62"
+			in:fade={{ duration: 200, delay: 200 }}
+		>
+			<FolderPlaceholder folder={$selectedFolder} />
 		</div>
-	</div>
+	{:else}
+		<div class="mx-auto max-w-2xl font-primary mt-2" in:fade={{ duration: 200, delay: 200 }}>
+			<div class="mx-5">
+				<Suggestions
+					suggestionPrompts={atSelectedModel?.info?.meta?.suggestion_prompts ??
+						models[selectedModelIdx]?.info?.meta?.suggestion_prompts ??
+						$config?.default_prompt_suggestions ??
+						[]}
+					inputValue={prompt}
+					{onSelect}
+				/>
+			</div>
+		</div>
+	{/if}
 </div>

+ 103 - 0
src/lib/components/chat/Placeholder/ChatList.svelte

@@ -0,0 +1,103 @@
+<script lang="ts">
+	import { getContext, onMount } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import dayjs from 'dayjs';
+	import localizedFormat from 'dayjs/plugin/localizedFormat';
+	import { getTimeRange } from '$lib/utils';
+
+	dayjs.extend(localizedFormat);
+
+	export let chats = [];
+
+	let chatList = null;
+
+	const init = async () => {
+		if (chats.length === 0) {
+			chatList = [];
+		} else {
+			chatList = chats.map((chat) => ({
+				...chat,
+				time_range: getTimeRange(chat.updated_at)
+			}));
+		}
+	};
+
+	$: if (chats) {
+		init();
+	}
+</script>
+
+{#if chatList}
+	<div class="text-left text-sm w-full mb-3">
+		{#if chatList.length === 0}
+			<div
+				class="text-xs text-gray-500 dark:text-gray-400 text-center px-5 min-h-20 w-full h-full flex justify-center items-center"
+			>
+				{$i18n.t('No chats found')}
+			</div>
+		{/if}
+
+		{#each chatList as chat, idx (chat.id)}
+			{#if (idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)) && chat?.time_range}
+				<div
+					class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
+						? ''
+						: 'pt-5'} pb-2 px-2"
+				>
+					{$i18n.t(chat.time_range)}
+					<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
+							{$i18n.t('Today')}
+							{$i18n.t('Yesterday')}
+							{$i18n.t('Previous 7 days')}
+							{$i18n.t('Previous 30 days')}
+							{$i18n.t('January')}
+							{$i18n.t('February')}
+							{$i18n.t('March')}
+							{$i18n.t('April')}
+							{$i18n.t('May')}
+							{$i18n.t('June')}
+							{$i18n.t('July')}
+							{$i18n.t('August')}
+							{$i18n.t('September')}
+							{$i18n.t('October')}
+							{$i18n.t('November')}
+							{$i18n.t('December')}
+							-->
+				</div>
+			{/if}
+
+			<a
+				class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
+				draggable="false"
+				href={`/c/${chat.id}`}
+				on:click={() => (show = false)}
+			>
+				<div class="text-ellipsis line-clamp-1 w-full sm:basis-3/5">
+					{chat?.title}
+				</div>
+
+				<div class="hidden sm:flex sm:basis-2/5 items-center justify-end">
+					<div class=" text-gray-500 dark:text-gray-400 text-xs">
+						{dayjs(chat?.updated_at * 1000).calendar()}
+					</div>
+				</div>
+			</a>
+		{/each}
+
+		<!-- {#if !allChatsLoaded && loadHandler}
+		<Loader
+			on:visible={(e) => {
+				if (!chatListLoading) {
+					loadHandler();
+				}
+			}}
+		>
+			<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
+				<Spinner className=" size-4" />
+				<div class=" ">Loading...</div>
+			</div>
+		</Loader>
+	{/if} -->
+	</div>
+{/if}

+ 0 - 0
src/lib/components/chat/Placeholder/FolderKnowledge.svelte


+ 51 - 0
src/lib/components/chat/Placeholder/FolderPlaceholder.svelte

@@ -0,0 +1,51 @@
+<script>
+	import { getContext } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import { fade } from 'svelte/transition';
+
+	import ChatList from './ChatList.svelte';
+	import FolderKnowledge from './FolderKnowledge.svelte';
+
+	export let folder = null;
+
+	let selectedTab = 'chats';
+</script>
+
+<div>
+	<!-- <div class="mb-1">
+		<div
+			class="flex gap-1 scrollbar-none overflow-x-auto w-fit text-center text-sm font-medium rounded-full bg-transparent py-1 touch-auto pointer-events-auto"
+		>
+			<button
+				class="min-w-fit p-1.5 {selectedTab === 'knowledge'
+					? ''
+					: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
+				type="button"
+				on:click={() => {
+					selectedTab = 'knowledge';
+				}}>{$i18n.t('Knowledge')}</button
+			>
+
+			<button
+				class="min-w-fit p-1.5 {selectedTab === 'chats'
+					? ''
+					: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
+				type="button"
+				on:click={() => {
+					selectedTab = 'chats';
+				}}
+			>
+				{$i18n.t('Chats')}
+			</button>
+		</div>
+	</div> -->
+
+	<div class="">
+		{#if selectedTab === 'knowledge'}
+			<FolderKnowledge />
+		{:else if selectedTab === 'chats'}
+			<ChatList chats={folder?.items?.chats ?? []} />
+		{/if}
+	</div>
+</div>

+ 147 - 0
src/lib/components/chat/Placeholder/FolderTitle.svelte

@@ -0,0 +1,147 @@
+<script lang="ts">
+	import { getContext } from 'svelte';
+	const i18n = getContext('i18n');
+
+	import DOMPurify from 'dompurify';
+
+	import fileSaver from 'file-saver';
+	const { saveAs } = fileSaver;
+
+	import { toast } from 'svelte-sonner';
+
+	import { selectedFolder } from '$lib/stores';
+
+	import { deleteFolderById, updateFolderById } from '$lib/apis/folders';
+	import { getChatsByFolderId } from '$lib/apis/chats';
+
+	import FolderModal from '$lib/components/layout/Sidebar/Folders/FolderModal.svelte';
+
+	import Folder from '$lib/components/icons/Folder.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
+	import FolderMenu from '$lib/components/layout/Sidebar/Folders/FolderMenu.svelte';
+	import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
+	import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
+
+	export let folder = null;
+
+	export let onUpdate: Function = (folderId) => {};
+	export let onDelete: Function = (folderId) => {};
+
+	let showFolderModal = false;
+	let showDeleteConfirm = false;
+
+	const updateHandler = async ({ name, data }) => {
+		if (name === '') {
+			toast.error($i18n.t('Folder name cannot be empty.'));
+			return;
+		}
+
+		const currentName = folder.name;
+
+		name = name.trim();
+		folder.name = name;
+
+		const res = await updateFolderById(localStorage.token, folder.id, {
+			name,
+			...(data ? { data } : {})
+		}).catch((error) => {
+			toast.error(`${error}`);
+
+			folder.name = currentName;
+			return null;
+		});
+
+		if (res) {
+			folder.name = name;
+			if (data) {
+				folder.data = data;
+			}
+
+			toast.success($i18n.t('Folder updated successfully'));
+			selectedFolder.set(folder);
+			onUpdate(folder);
+		}
+	};
+
+	const deleteHandler = async () => {
+		const res = await deleteFolderById(localStorage.token, folder.id).catch((error) => {
+			toast.error(`${error}`);
+			return null;
+		});
+
+		if (res) {
+			toast.success($i18n.t('Folder deleted successfully'));
+			onDelete(folder);
+		}
+	};
+
+	const exportHandler = async () => {
+		const chats = await getChatsByFolderId(localStorage.token, folder.id).catch((error) => {
+			toast.error(`${error}`);
+			return null;
+		});
+		if (!chats) {
+			return;
+		}
+
+		const blob = new Blob([JSON.stringify(chats)], {
+			type: 'application/json'
+		});
+
+		saveAs(blob, `folder-${folder.name}-export-${Date.now()}.json`);
+	};
+</script>
+
+{#if folder}
+	<FolderModal bind:show={showFolderModal} edit={true} {folder} onSubmit={updateHandler} />
+
+	<DeleteConfirmDialog
+		bind:show={showDeleteConfirm}
+		title={$i18n.t('Delete folder?')}
+		on:confirm={() => {
+			deleteHandler();
+		}}
+	>
+		<div class=" text-sm text-gray-700 dark:text-gray-300 flex-1 line-clamp-3">
+			{@html DOMPurify.sanitize(
+				$i18n.t(
+					'This will delete <strong>{{NAME}}</strong> and <strong>all its contents</strong>.',
+					{
+						NAME: folder.name
+					}
+				)
+			)}
+		</div>
+	</DeleteConfirmDialog>
+
+	<div class="mb-3 px-6 @md:max-w-3xl justify-between w-full flex relative group items-center">
+		<div class="text-center flex gap-3.5 items-center">
+			<div class=" rounded-full bg-gray-50 dark:bg-gray-800 p-3 w-fit">
+				<Folder className="size-4.5" strokeWidth="2" />
+			</div>
+
+			<div class="text-3xl">
+				{folder.name}
+			</div>
+		</div>
+
+		<div class="flex items-center translate-x-2.5">
+			<FolderMenu
+				align="end"
+				onEdit={() => {
+					showFolderModal = true;
+				}}
+				onDelete={() => {
+					showDeleteConfirm = true;
+				}}
+				onExport={() => {
+					exportHandler();
+				}}
+			>
+				<button class="p-1.5 dark:hover:bg-gray-850 rounded-full touch-auto" on:click={(e) => {}}>
+					<EllipsisHorizontal className="size-4" strokeWidth="2.5" />
+				</button>
+			</FolderMenu>
+		</div>
+	</div>
+{/if}

+ 93 - 0
src/lib/components/chat/Settings/Interface.svelte

@@ -43,12 +43,16 @@
 
 	let largeTextAsFile = false;
 
+	let keepFollowUpPrompts = false;
+	let insertFollowUpPrompt = false;
+
 	let landingPageMode = '';
 	let chatBubble = true;
 	let chatDirection: 'LTR' | 'RTL' | 'auto' = 'auto';
 	let ctrlEnterToSend = false;
 	let copyFormatted = false;
 
+	let chatFadeStreamingText = true;
 	let collapseCodeBlocks = false;
 	let expandDetails = false;
 
@@ -159,6 +163,11 @@
 		saveSettings({ imageCompression });
 	};
 
+	const toggleChatFadeStreamingText = async () => {
+		chatFadeStreamingText = !chatFadeStreamingText;
+		saveSettings({ chatFadeStreamingText: chatFadeStreamingText });
+	};
+
 	const toggleHapticFeedback = async () => {
 		hapticFeedback = !hapticFeedback;
 		saveSettings({ hapticFeedback: hapticFeedback });
@@ -224,6 +233,16 @@
 		saveSettings({ insertPromptAsRichText });
 	};
 
+	const toggleKeepFollowUpPrompts = async () => {
+		keepFollowUpPrompts = !keepFollowUpPrompts;
+		saveSettings({ keepFollowUpPrompts });
+	};
+
+	const toggleInsertFollowUpPrompt = async () => {
+		insertFollowUpPrompt = !insertFollowUpPrompt;
+		saveSettings({ insertFollowUpPrompt });
+	};
+
 	const toggleLargeTextAsFile = async () => {
 		largeTextAsFile = !largeTextAsFile;
 		saveSettings({ largeTextAsFile });
@@ -313,10 +332,15 @@
 		showEmojiInCall = $settings?.showEmojiInCall ?? false;
 		voiceInterruption = $settings?.voiceInterruption ?? false;
 
+		chatFadeStreamingText = $settings?.chatFadeStreamingText ?? true;
+
 		richTextInput = $settings?.richTextInput ?? true;
 		insertPromptAsRichText = $settings?.insertPromptAsRichText ?? false;
 		promptAutocomplete = $settings?.promptAutocomplete ?? false;
 
+		keepFollowUpPrompts = $settings?.keepFollowUpPrompts ?? false;
+		insertFollowUpPrompt = $settings?.insertFollowUpPrompt ?? false;
+
 		largeTextAsFile = $settings?.largeTextAsFile ?? false;
 		copyFormatted = $settings?.copyFormatted ?? false;
 
@@ -746,6 +770,75 @@
 				</div>
 			</div>
 
+			<div>
+				<div class=" py-0.5 flex w-full justify-between">
+					<div id="fade-streaming-label" class=" self-center text-xs">
+						{$i18n.t('Fade Effect for Streaming Text')}
+					</div>
+
+					<button
+						aria-labelledby="fade-streaming-label"
+						class="p-1 px-3 text-xs flex rounded-sm transition"
+						on:click={() => {
+							toggleChatFadeStreamingText();
+						}}
+						type="button"
+					>
+						{#if chatFadeStreamingText === true}
+							<span class="ml-2 self-center">{$i18n.t('On')}</span>
+						{:else}
+							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+						{/if}
+					</button>
+				</div>
+			</div>
+
+			<div>
+				<div class=" py-0.5 flex w-full justify-between">
+					<div id="keep-followup-prompts-label" class=" self-center text-xs">
+						{$i18n.t('Keep Follow-Up Prompts in Chat')}
+					</div>
+
+					<button
+						aria-labelledby="keep-followup-prompts-label"
+						class="p-1 px-3 text-xs flex rounded-sm transition"
+						on:click={() => {
+							toggleKeepFollowUpPrompts();
+						}}
+						type="button"
+					>
+						{#if keepFollowUpPrompts === true}
+							<span class="ml-2 self-center">{$i18n.t('On')}</span>
+						{:else}
+							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+						{/if}
+					</button>
+				</div>
+			</div>
+
+			<div>
+				<div class=" py-0.5 flex w-full justify-between">
+					<div id="insert-followup-prompt-label" class=" self-center text-xs">
+						{$i18n.t('Insert Follow-Up Prompt to Input')}
+					</div>
+
+					<button
+						aria-labelledby="insert-followup-prompt-label"
+						class="p-1 px-3 text-xs flex rounded-sm transition"
+						on:click={() => {
+							toggleInsertFollowUpPrompt();
+						}}
+						type="button"
+					>
+						{#if insertFollowUpPrompt === true}
+							<span class="ml-2 self-center">{$i18n.t('On')}</span>
+						{:else}
+							<span class="ml-2 self-center">{$i18n.t('Off')}</span>
+						{/if}
+					</button>
+				</div>
+			</div>
+
 			<div>
 				<div class=" py-0.5 flex w-full justify-between">
 					<div id="rich-input-label" class=" self-center text-xs">

+ 1 - 1
src/lib/components/chat/Settings/Personalization/ManageModal.svelte

@@ -50,7 +50,7 @@
 	}
 </script>
 
-<Modal size="xl" bind:show>
+<Modal size="lg" bind:show>
 	<div>
 		<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
 			<div class=" text-lg font-medium self-center">{$i18n.t('Memory')}</div>

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

@@ -537,7 +537,7 @@
 	}
 </script>
 
-<Modal size="xl" bind:show>
+<Modal size="lg" bind:show>
 	<div class="text-gray-700 dark:text-gray-100">
 		<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
 			<div class=" text-lg font-medium self-center">{$i18n.t('Settings')}</div>

+ 13 - 5
src/lib/components/common/FileItemModal.svelte

@@ -112,6 +112,12 @@
 								Formatting may be inconsistent from source.
 							</div>
 						{/if}
+
+						{#if item?.knowledge}
+							<div class="capitalize shrink-0">
+								{$i18n.t('Knowledge Base')}
+							</div>
+						{/if}
 					</div>
 
 					{#if edit}
@@ -127,9 +133,9 @@
 							>
 								<div class="flex items-center gap-1.5 text-xs">
 									{#if enableFullContent}
-										Using Entire Document
+										{$i18n.t('Using Entire Document')}
 									{:else}
-										Using Focused Retrieval
+										{$i18n.t('Using Focused Retrieval')}
 									{/if}
 									<Switch
 										bind:state={enableFullContent}
@@ -172,9 +178,11 @@
 					/>
 				{/if}
 
-				<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
-					{item?.file?.data?.content ?? 'No content'}
-				</div>
+				{#if item?.file?.data}
+					<div class="max-h-96 overflow-scroll scrollbar-hidden text-xs whitespace-pre-wrap">
+						{item?.file?.data?.content ?? 'No content'}
+					</div>
+				{/if}
 			{/if}
 		</div>
 	</div>

+ 8 - 0
src/lib/components/common/Modal.svelte

@@ -26,6 +26,14 @@
 			return 'w-[30rem]';
 		} else if (size === 'md') {
 			return 'w-[42rem]';
+		} else if (size === 'lg') {
+			return 'w-[56rem]';
+		} else if (size === 'xl') {
+			return 'w-[70rem]';
+		} else if (size === '2xl') {
+			return 'w-[84rem]';
+		} else if (size === '3xl') {
+			return 'w-[100rem]';
 		} else {
 			return 'w-[56rem]';
 		}

+ 183 - 49
src/lib/components/common/RichTextInput.svelte

@@ -56,6 +56,7 @@
 
 	import { Fragment, DOMParser } from 'prosemirror-model';
 	import { EditorState, Plugin, PluginKey, TextSelection, Selection } from 'prosemirror-state';
+	import { Decoration, DecorationSet } from 'prosemirror-view';
 	import { Editor, Extension } from '@tiptap/core';
 
 	// Yjs imports
@@ -72,32 +73,32 @@
 	import { keymap } from 'prosemirror-keymap';
 
 	import { AIAutocompletion } from './RichTextInput/AutoCompletion.js';
-	import Table from '@tiptap/extension-table';
-	import TableRow from '@tiptap/extension-table-row';
-	import TableHeader from '@tiptap/extension-table-header';
-	import TableCell from '@tiptap/extension-table-cell';
 
-	import Link from '@tiptap/extension-link';
-	import Underline from '@tiptap/extension-underline';
-	import TaskItem from '@tiptap/extension-task-item';
-	import TaskList from '@tiptap/extension-task-list';
-
-	import CharacterCount from '@tiptap/extension-character-count';
-
-	import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
-	import Placeholder from '@tiptap/extension-placeholder';
 	import StarterKit from '@tiptap/starter-kit';
-	import Highlight from '@tiptap/extension-highlight';
-	import Typography from '@tiptap/extension-typography';
 
+	// Bubble and Floating menus are currently fixed to v2 due to styling issues in v3
+	// TODO: Update to v3 when styling issues are resolved
 	import BubbleMenu from '@tiptap/extension-bubble-menu';
 	import FloatingMenu from '@tiptap/extension-floating-menu';
 
+	import { TableKit } from '@tiptap/extension-table';
+	import { ListKit } from '@tiptap/extension-list';
+	import { Placeholder, CharacterCount } from '@tiptap/extensions';
+
+	import Image from './RichTextInput/Image/index.js';
+	// import TiptapImage from '@tiptap/extension-image';
+
+	import FileHandler from '@tiptap/extension-file-handler';
+	import Typography from '@tiptap/extension-typography';
+	import Highlight from '@tiptap/extension-highlight';
+	import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
+
 	import { all, createLowlight } from 'lowlight';
 
 	import { PASTED_TEXT_CHARACTER_LIMIT } from '$lib/constants';
 
 	import FormattingButtons from './RichTextInput/FormattingButtons.svelte';
+	import { duration } from 'dayjs';
 
 	export let oncompositionstart = (e) => {};
 	export let oncompositionend = (e) => {};
@@ -110,11 +111,64 @@
 
 	export let socket = null;
 	export let user = null;
+	export let files = [];
+
 	export let documentId = '';
 
 	export let className = 'input-prose';
 	export let placeholder = 'Type here...';
 	export let link = false;
+	export let image = false;
+	export let fileHandler = false;
+
+	export let onFileDrop = (currentEditor, files, pos) => {
+		files.forEach((file) => {
+			const fileReader = new FileReader();
+
+			fileReader.readAsDataURL(file);
+			fileReader.onload = () => {
+				currentEditor
+					.chain()
+					.insertContentAt(pos, {
+						type: 'image',
+						attrs: {
+							src: fileReader.result
+						}
+					})
+					.focus()
+					.run();
+			};
+		});
+	};
+
+	export let onFilePaste = (currentEditor, files, htmlContent) => {
+		files.forEach((file) => {
+			if (htmlContent) {
+				// if there is htmlContent, stop manual insertion & let other extensions handle insertion via inputRule
+				// you could extract the pasted file from this url string and upload it to a server for example
+				console.log(htmlContent); // eslint-disable-line no-console
+				return false;
+			}
+
+			const fileReader = new FileReader();
+
+			fileReader.readAsDataURL(file);
+			fileReader.onload = () => {
+				currentEditor
+					.chain()
+					.insertContentAt(currentEditor.state.selection.anchor, {
+						type: 'image',
+						attrs: {
+							src: fileReader.result
+						}
+					})
+					.focus()
+					.run();
+			};
+		});
+	};
+
+	export let onSelectionUpdate = (e) => {};
 
 	export let id = '';
 	export let value = '';
@@ -134,17 +188,28 @@
 	export let shiftEnter = false;
 	export let largeTextAsFile = false;
 	export let insertPromptAsRichText = false;
+	export let floatingMenuPlacement = 'bottom-start';
 
 	let content = null;
 	let htmlValue = '';
 	let jsonValue = '';
 	let mdValue = '';
 
+	let lastSelectionBookmark = null;
+
 	// Yjs setup
 	let ydoc = null;
 	let yXmlFragment = null;
 	let awareness = null;
 
+	const getEditorInstance = async () => {
+		return new Promise((resolve) => {
+			setTimeout(() => {
+				resolve(editor);
+			}, 0);
+		});
+	};
+
 	// Custom Yjs Socket.IO provider
 	class SocketIOProvider {
 		constructor(doc, documentId, socket, user) {
@@ -176,7 +241,7 @@
 
 		joinDocument() {
 			const userColor = this.generateUserColor();
-			this.socket.emit('yjs:document:join', {
+			this.socket.emit('ydoc:document:join', {
 				document_id: this.documentId,
 				user_id: this.user?.id,
 				user_name: this.user?.name,
@@ -195,7 +260,7 @@
 
 		setupEventListeners() {
 			// Listen for document updates from server
-			this.socket.on('yjs:document:update', (data) => {
+			this.socket.on('ydoc:document:update', (data) => {
 				if (data.document_id === this.documentId && data.socket_id !== this.socket.id) {
 					try {
 						const update = new Uint8Array(data.update);
@@ -207,7 +272,7 @@
 			});
 
 			// Listen for document state from server
-			this.socket.on('yjs:document:state', async (data) => {
+			this.socket.on('ydoc:document:state', async (data) => {
 				if (data.document_id === this.documentId) {
 					try {
 						if (data.state) {
@@ -216,26 +281,47 @@
 							if (state.length === 2 && state[0] === 0 && state[1] === 0) {
 								// Empty state, check if we have content to initialize
 								// check if editor empty as well
+								// const editor = await getEditorInstance();
+
 								const isEmptyEditor = !editor || editor.getText().trim() === '';
-								if (content && isEmptyEditor) {
-									const pydoc = prosemirrorJSONToYDoc(editor.schema, content);
-									if (pydoc) {
-										Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(pydoc));
+								if (isEmptyEditor) {
+									if (content && (data?.sessions ?? ['']).length === 1) {
+										const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content);
+										if (editorYdoc) {
+											Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc));
+										}
+									}
+								} else {
+									// If the editor already has content, we don't need to send an empty state
+									if (this.doc.getXmlFragment('prosemirror').length > 0) {
+										this.socket.emit('ydoc:document:update', {
+											document_id: this.documentId,
+											user_id: this.user?.id,
+											socket_id: this.socket.id,
+											update: Y.encodeStateAsUpdate(this.doc)
+										});
+									} else {
+										console.warn('Yjs document is empty, not sending state.');
 									}
 								}
 							} else {
-								Y.applyUpdate(this.doc, state);
+								Y.applyUpdate(this.doc, state, 'server');
 							}
 						}
 						this.synced = true;
 					} catch (error) {
 						console.error('Error applying Yjs state:', error);
+
+						this.synced = false;
+						this.socket.emit('ydoc:document:state', {
+							document_id: this.documentId
+						});
 					}
 				}
 			});
 
 			// Listen for awareness updates
-			this.socket.on('yjs:awareness:update', (data) => {
+			this.socket.on('ydoc:awareness:update', (data) => {
 				if (data.document_id === this.documentId && awareness) {
 					try {
 						const awarenessUpdate = new Uint8Array(data.update);
@@ -254,7 +340,7 @@
 			this.doc.on('update', async (update, origin) => {
 				if (origin !== 'server' && this.isConnected) {
 					await tick(); // Ensure the DOM is updated before sending
-					this.socket.emit('yjs:document:update', {
+					this.socket.emit('ydoc:document:update', {
 						document_id: this.documentId,
 						user_id: this.user?.id,
 						socket_id: this.socket.id,
@@ -276,7 +362,7 @@
 					if (origin !== 'server' && this.isConnected) {
 						const changedClients = added.concat(updated).concat(removed);
 						const awarenessUpdate = awareness.encodeUpdate(changedClients);
-						this.socket.emit('yjs:awareness:update', {
+						this.socket.emit('ydoc:awareness:update', {
 							document_id: this.documentId,
 							user_id: this.socket.id,
 							update: Array.from(awarenessUpdate)
@@ -302,14 +388,14 @@
 		};
 
 		destroy() {
-			this.socket.off('yjs:document:update');
-			this.socket.off('yjs:document:state');
-			this.socket.off('yjs:awareness:update');
+			this.socket.off('ydoc:document:update');
+			this.socket.off('ydoc:document:state');
+			this.socket.off('ydoc:awareness:update');
 			this.socket.off('connect', this.onConnect);
 			this.socket.off('disconnect', this.onDisconnect);
 
 			if (this.isConnected) {
-				this.socket.emit('yjs:document:leave', {
+				this.socket.emit('ydoc:document:leave', {
 					document_id: this.documentId,
 					user_id: this.user?.id
 				});
@@ -574,6 +660,10 @@
 	export const setText = (text: string) => {
 		if (!editor) return;
 		text = text.replaceAll('\n\n', '\n');
+
+		// reset the editor content
+		editor.commands.clearContent();
+
 		const { state, view } = editor;
 		const { schema, tr } = state;
 
@@ -742,6 +832,33 @@
 		}
 	};
 
+	const SelectionDecoration = Extension.create({
+		name: 'selectionDecoration',
+		addProseMirrorPlugins() {
+			return [
+				new Plugin({
+					key: new PluginKey('selection'),
+					props: {
+						decorations: (state) => {
+							const { selection } = state;
+							const { focused } = this.editor;
+
+							if (focused || selection.empty) {
+								return null;
+							}
+
+							return DecorationSet.create(state.doc, [
+								Decoration.inline(selection.from, selection.to, {
+									class: 'editor-selection'
+								})
+							]);
+						}
+					}
+				})
+			];
+		}
+	});
+
 	onMount(async () => {
 		content = value;
 
@@ -788,35 +905,42 @@
 			initializeCollaboration();
 		}
 
+		console.log(bubbleMenuElement, floatingMenuElement);
+
 		editor = new Editor({
 			element: element,
 			extensions: [
-				StarterKit,
+				StarterKit.configure({
+					link: link
+				}),
+				Placeholder.configure({ placeholder }),
+				SelectionDecoration,
+
 				CodeBlockLowlight.configure({
 					lowlight
 				}),
 				Highlight,
 				Typography,
-				Underline,
 
-				Placeholder.configure({ placeholder }),
-				Table.configure({ resizable: true }),
-				TableRow,
-				TableHeader,
-				TableCell,
-				TaskList,
-				TaskItem.configure({
-					nested: true
+				TableKit.configure({
+					table: { resizable: true }
+				}),
+				ListKit.configure({
+					taskItem: {
+						nested: true
+					}
 				}),
 				CharacterCount.configure({}),
-				...(link
+				...(image ? [Image] : []),
+				...(fileHandler
 					? [
-							Link.configure({
-								openOnClick: true,
-								linkOnPaste: true
+							FileHandler.configure({
+								onDrop: onFileDrop,
+								onPaste: onFilePaste
 							})
 						]
 					: []),
+
 				...(autocomplete
 					? [
 							AIAutocompletion.configure({
@@ -853,7 +977,7 @@
 								tippyOptions: {
 									duration: 100,
 									arrow: false,
-									placement: 'bottom-start',
+									placement: floatingMenuPlacement,
 									theme: 'transparent',
 									offset: [-12, 4]
 								}
@@ -867,6 +991,7 @@
 			onTransaction: () => {
 				// force re-render so `editor.isActive` works as expected
 				editor = editor;
+				if (!editor) return;
 
 				htmlValue = editor.getHTML();
 				jsonValue = editor.getJSON();
@@ -1057,7 +1182,10 @@
 							const hasImageItem = Array.from(event.clipboardData.items).some((item) =>
 								item.type.startsWith('image/')
 							);
-							if (hasImageFile || hasImageItem) {
+
+							const hasFile = Array.from(event.clipboardData.files).length > 0;
+
+							if (hasImageFile || hasImageItem || hasFile) {
 								eventDispatch('paste', { event });
 								event.preventDefault();
 								return true;
@@ -1068,7 +1196,13 @@
 						return false;
 					}
 				}
-			}
+			},
+			onBeforeCreate: ({ editor }) => {
+				if (files) {
+					editor.storage.files = files;
+				}
+			},
+			onSelectionUpdate: onSelectionUpdate
 		});
 
 		if (messageInput) {
@@ -1140,11 +1274,11 @@
 </script>
 
 {#if showFormattingButtons}
-	<div bind:this={bubbleMenuElement} class="p-0">
+	<div bind:this={bubbleMenuElement} id="bubble-menu" class="p-0">
 		<FormattingButtons {editor} />
 	</div>
 
-	<div bind:this={floatingMenuElement} class="p-0">
+	<div bind:this={floatingMenuElement} id="floating-menu" class="p-0">
 		<FormattingButtons {editor} />
 	</div>
 {/if}

+ 30 - 2
src/lib/components/common/RichTextInput/FormattingButtons.svelte

@@ -12,10 +12,13 @@
 	import Italic from '$lib/components/icons/Italic.svelte';
 	import ListBullet from '$lib/components/icons/ListBullet.svelte';
 	import NumberedList from '$lib/components/icons/NumberedList.svelte';
-	import QueueList from '$lib/components/icons/QueueList.svelte';
 	import Strikethrough from '$lib/components/icons/Strikethrough.svelte';
 	import Underline from '$lib/components/icons/Underline.svelte';
+
 	import Tooltip from '../Tooltip.svelte';
+	import CheckBox from '$lib/components/icons/CheckBox.svelte';
+	import ArrowLeftTag from '$lib/components/icons/ArrowLeftTag.svelte';
+	import ArrowRightTag from '$lib/components/icons/ArrowRightTag.svelte';
 </script>
 
 <div
@@ -57,6 +60,31 @@
 		</button>
 	</Tooltip>
 
+	{#if editor?.isActive('bulletList') || editor?.isActive('orderedList') || editor?.isActive('taskList')}
+		<Tooltip placement="top" content={$i18n.t('Lift List')}>
+			<button
+				on:click={() => {
+					editor?.commands.liftListItem(editor?.isActive('taskList') ? 'taskItem' : 'listItem');
+				}}
+				class="hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg p-1.5 transition-all"
+				type="button"
+			>
+				<ArrowLeftTag />
+			</button>
+		</Tooltip>
+
+		<Tooltip placement="top" content={$i18n.t('Sink List')}>
+			<button
+				on:click={() =>
+					editor?.commands.sinkListItem(editor?.isActive('taskList') ? 'taskItem' : 'listItem')}
+				class="hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg p-1.5 transition-all"
+				type="button"
+			>
+				<ArrowRightTag />
+			</button>
+		</Tooltip>
+	{/if}
+
 	<Tooltip placement="top" content={$i18n.t('Bullet List')}>
 		<button
 			on:click={() => editor?.chain().focus().toggleBulletList().run()}
@@ -89,7 +117,7 @@
 				: ''} hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg p-1.5 transition-all"
 			type="button"
 		>
-			<QueueList />
+			<CheckBox />
 		</button>
 	</Tooltip>
 

+ 197 - 0
src/lib/components/common/RichTextInput/Image/image.ts

@@ -0,0 +1,197 @@
+import { mergeAttributes, Node, nodeInputRule } from '@tiptap/core';
+
+export interface ImageOptions {
+	/**
+	 * Controls if the image node should be inline or not.
+	 * @default false
+	 * @example true
+	 */
+	inline: boolean;
+
+	/**
+	 * Controls if base64 images are allowed. Enable this if you want to allow
+	 * base64 image urls in the `src` attribute.
+	 * @default false
+	 * @example true
+	 */
+	allowBase64: boolean;
+
+	/**
+	 * HTML attributes to add to the image element.
+	 * @default {}
+	 * @example { class: 'foo' }
+	 */
+	HTMLAttributes: Record<string, any>;
+}
+
+export interface SetImageOptions {
+	src: string;
+	alt?: string;
+	title?: string;
+	width?: number;
+	height?: number;
+}
+
+declare module '@tiptap/core' {
+	interface Commands<ReturnType> {
+		image: {
+			/**
+			 * Add an image
+			 * @param options The image attributes
+			 * @example
+			 * editor
+			 *   .commands
+			 *   .setImage({ src: 'https://tiptap.dev/logo.png', alt: 'tiptap', title: 'tiptap logo' })
+			 */
+			setImage: (options: SetImageOptions) => ReturnType;
+		};
+	}
+}
+
+/**
+ * Matches an image to a ![image](src "title") on input.
+ */
+export const inputRegex = /(?:^|\s)(!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\))$/;
+
+/**
+ * This extension allows you to insert images.
+ * @see https://www.tiptap.dev/api/nodes/image
+ */
+export const Image = Node.create<ImageOptions>({
+	name: 'image',
+
+	addOptions() {
+		return {
+			inline: false,
+			allowBase64: false,
+			HTMLAttributes: {}
+		};
+	},
+
+	inline() {
+		return this.options.inline;
+	},
+
+	group() {
+		return this.options.inline ? 'inline' : 'block';
+	},
+
+	draggable: true,
+
+	addAttributes() {
+		return {
+			file: {
+				default: null
+			},
+			src: {
+				default: null
+			},
+			alt: {
+				default: null
+			},
+			title: {
+				default: null
+			},
+			width: {
+				default: null
+			},
+			height: {
+				default: null
+			}
+		};
+	},
+
+	parseHTML() {
+		return [
+			{
+				tag: this.options.allowBase64 ? 'img[src]' : 'img[src]:not([src^="data:"])'
+			}
+		];
+	},
+
+	renderHTML({ HTMLAttributes }) {
+		if (HTMLAttributes.file) {
+			delete HTMLAttributes.file;
+		}
+
+		return ['img', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
+	},
+
+	addNodeView() {
+		return ({ node, editor }) => {
+			const domImg = document.createElement('img');
+			domImg.setAttribute('src', node.attrs.src || '');
+			domImg.setAttribute('alt', node.attrs.alt || '');
+			domImg.setAttribute('title', node.attrs.title || '');
+
+			const container = document.createElement('div');
+			const img = document.createElement('img');
+
+			const fileId = node.attrs.src.replace('data://', '');
+			img.setAttribute('id', `image:${fileId}`);
+
+			img.classList.add('rounded-md', 'max-h-72', 'w-fit', 'object-contain');
+
+			const editorFiles = editor.storage?.files || [];
+
+			if (editorFiles && node.attrs.src.startsWith('data://')) {
+				const file = editorFiles.find((f) => f.id === fileId);
+				if (file) {
+					img.setAttribute('src', file.url || '');
+				} else {
+					img.setAttribute('src', '/image-placeholder.png');
+				}
+			} else {
+				img.setAttribute('src', node.attrs.src || '');
+			}
+
+			img.setAttribute('alt', node.attrs.alt || '');
+			img.setAttribute('title', node.attrs.title || '');
+
+			img.addEventListener('data', (e) => {
+				const files = e?.files || [];
+				if (files && node.attrs.src.startsWith('data://')) {
+					const file = editorFiles.find((f) => f.id === fileId);
+					if (file) {
+						img.setAttribute('src', file.url || '');
+					} else {
+						img.setAttribute('src', '/image-placeholder.png');
+					}
+				}
+			});
+
+			container.append(img);
+			return {
+				dom: img,
+				contentDOM: domImg
+			};
+		};
+	},
+
+	addCommands() {
+		return {
+			setImage:
+				(options) =>
+				({ commands }) => {
+					return commands.insertContent({
+						type: this.name,
+						attrs: options
+					});
+				}
+		};
+	},
+
+	addInputRules() {
+		return [
+			nodeInputRule({
+				find: inputRegex,
+				type: this.type,
+				getAttributes: (match) => {
+					const [, , alt, src, title] = match;
+
+					return { src, alt, title };
+				}
+			})
+		];
+	}
+});

+ 5 - 0
src/lib/components/common/RichTextInput/Image/index.ts

@@ -0,0 +1,5 @@
+import { Image } from './image.js';
+
+export * from './image.js';
+
+export default Image;

+ 26 - 12
src/lib/components/common/Tooltip.svelte

@@ -5,6 +5,8 @@
 
 	import tippy from 'tippy.js';
 
+	export let elementId = '';
+
 	export let placement = 'top';
 	export let content = `I'm a tooltip!`;
 	export let touch = true;
@@ -17,20 +19,30 @@
 	let tooltipElement;
 	let tooltipInstance;
 
-	$: if (tooltipElement && content) {
+	$: if (tooltipElement && (content || elementId)) {
+		let tooltipContent = null;
+
+		if (elementId) {
+			tooltipContent = document.getElementById(`${elementId}`);
+		} else {
+			tooltipContent = DOMPurify.sanitize(content);
+		}
+
 		if (tooltipInstance) {
-			tooltipInstance.setContent(DOMPurify.sanitize(content));
+			tooltipInstance.setContent(tooltipContent);
 		} else {
-			tooltipInstance = tippy(tooltipElement, {
-				content: DOMPurify.sanitize(content),
-				placement: placement,
-				allowHTML: allowHTML,
-				touch: touch,
-				...(theme !== '' ? { theme } : { theme: 'dark' }),
-				arrow: false,
-				offset: offset,
-				...tippyOptions
-			});
+			if (content) {
+				tooltipInstance = tippy(tooltipElement, {
+					content: tooltipContent,
+					placement: placement,
+					allowHTML: allowHTML,
+					touch: touch,
+					...(theme !== '' ? { theme } : { theme: 'dark' }),
+					arrow: false,
+					offset: offset,
+					...tippyOptions
+				});
+			}
 		}
 	} else if (tooltipInstance && content === '') {
 		if (tooltipInstance) {
@@ -48,3 +60,5 @@
 <div bind:this={tooltipElement} class={className}>
 	<slot />
 </div>
+
+<slot name="tooltip"></slot>

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

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M10.5 6h9.75M10.5 6a1.5 1.5 0 1 1-3 0m3 0a1.5 1.5 0 1 0-3 0M3.75 6H7.5m3 12h9.75m-9.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-3.75 0H7.5m9-6h3.75m-3.75 0a1.5 1.5 0 0 1-3 0m3 0a1.5 1.5 0 0 0-3 0m-9.75 0h9.75"
+	/>
+</svg>

+ 20 - 0
src/lib/components/icons/ArrowLeftTag.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+	><path
+		d="M16.75 12H6.75M6.75 12L9.5 14.75M6.75 12L9.5 9.25"
+		stroke-linecap="round"
+		stroke-linejoin="round"
+	></path><path
+		d="M2 15V9C2 6.79086 3.79086 5 6 5H18C20.2091 5 22 6.79086 22 9V15C22 17.2091 20.2091 19 18 19H6C3.79086 19 2 17.2091 2 15Z"
+	></path></svg
+>

+ 20 - 0
src/lib/components/icons/ArrowRightTag.svelte

@@ -0,0 +1,20 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+	><path
+		d="M6.75 12H16.75M16.75 12L14 14.75M16.75 12L14 9.25"
+		stroke-linecap="round"
+		stroke-linejoin="round"
+	></path><path
+		d="M2 15V9C2 6.79086 3.79086 5 6 5H18C20.2091 5 22 6.79086 22 9V15C22 17.2091 20.2091 19 18 19H6C3.79086 19 2 17.2091 2 15Z"
+	></path></svg
+>

+ 22 - 0
src/lib/components/icons/CheckBox.svelte

@@ -0,0 +1,22 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+	><path
+		d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z"
+		stroke-width="1.5"
+	></path><path
+		d="M7 12.5L10 15.5L17 8.5"
+		stroke-width="1.5"
+		stroke-linecap="round"
+		stroke-linejoin="round"
+	></path>
+</svg>

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

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
+	/>
+</svg>

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

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M10.125 2.25h-4.5c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125v-9M10.125 2.25h.375a9 9 0 0 1 9 9v.375M10.125 2.25A3.375 3.375 0 0 1 13.5 5.625v1.5c0 .621.504 1.125 1.125 1.125h1.5a3.375 3.375 0 0 1 3.375 3.375M9 15l2.25 2.25L15 12"
+	/>
+</svg>

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

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<path
+		stroke-linecap="round"
+		stroke-linejoin="round"
+		d="M2.25 12.75V12A2.25 2.25 0 0 1 4.5 9.75h15A2.25 2.25 0 0 1 21.75 12v.75m-8.69-6.44-2.12-2.12a1.5 1.5 0 0 0-1.061-.44H4.5A2.25 2.25 0 0 0 2.25 6v12a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9a2.25 2.25 0 0 0-2.25-2.25h-5.379a1.5 1.5 0 0 1-1.06-.44Z"
+	/>
+</svg>

+ 16 - 0
src/lib/components/icons/Label.svelte

@@ -0,0 +1,16 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+	><path
+		d="M3 17.4V6.6C3 6.26863 3.26863 6 3.6 6H16.6789C16.8795 6 17.0668 6.10026 17.1781 6.26718L20.7781 11.6672C20.9125 11.8687 20.9125 12.1313 20.7781 12.3328L17.1781 17.7328C17.0668 17.8997 16.8795 18 16.6789 18H3.6C3.26863 18 3 17.7314 3 17.4Z"
+	></path></svg
+>

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

@@ -0,0 +1,19 @@
+<script lang="ts">
+	export let className = 'size-4';
+	export let strokeWidth = '1.8';
+</script>
+
+<svg
+	xmlns="http://www.w3.org/2000/svg"
+	fill="none"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+>
+	<!-- Tag body with pointed end -->
+	<path d="M4 12 L8 7 H21 V17 H8 L4 12 Z" stroke="currentColor" fill="none" />
+
+	<!-- Tag hole -->
+	<circle cx="10" cy="12" r="0.75" fill="currentColor" stroke="currentColor" />
+</svg>

+ 23 - 0
src/lib/components/icons/Voice.svelte

@@ -0,0 +1,23 @@
+<script lang="ts">
+	export let className = 'w-4 h-4';
+	export let strokeWidth = '1.5';
+</script>
+
+<svg
+	aria-hidden="true"
+	xmlns="http://www.w3.org/2000/svg"
+	fill="currentColor"
+	viewBox="0 0 24 24"
+	stroke-width={strokeWidth}
+	stroke="currentColor"
+	class={className}
+	><path d="M12 4L12 20" stroke-linecap="round" stroke-linejoin="round"></path><path
+		d="M8 9L8 15"
+		stroke-linecap="round"
+		stroke-linejoin="round"
+	></path><path d="M20 10L20 14" stroke-linecap="round" stroke-linejoin="round"></path><path
+		d="M4 10L4 14"
+		stroke-linecap="round"
+		stroke-linejoin="round"
+	></path><path d="M16 7L16 17" stroke-linecap="round" stroke-linejoin="round"></path></svg
+>

+ 212 - 64
src/lib/components/layout/SearchModal.svelte

@@ -1,16 +1,19 @@
 <script lang="ts">
 	import { toast } from 'svelte-sonner';
-	import { getContext, onMount } from 'svelte';
+	import { getContext, onDestroy, onMount, tick } from 'svelte';
 	const i18n = getContext('i18n');
 
 	import Modal from '$lib/components/common/Modal.svelte';
 	import SearchInput from './Sidebar/SearchInput.svelte';
-	import { getChatList, getChatListBySearchText } from '$lib/apis/chats';
+	import { getChatById, getChatList, getChatListBySearchText } from '$lib/apis/chats';
 	import Spinner from '../common/Spinner.svelte';
 
 	import dayjs from '$lib/dayjs';
 	import calendar from 'dayjs/plugin/calendar';
 	import Loader from '../common/Loader.svelte';
+	import { createMessagesList } from '$lib/utils';
+	import { user } from '$lib/stores';
+	import Messages from '../chat/Messages.svelte';
 	dayjs.extend(calendar);
 
 	export let show = false;
@@ -28,6 +31,60 @@
 
 	let selectedIdx = 0;
 
+	let selectedChat = null;
+
+	let selectedModels = [''];
+	let history = null;
+	let messages = null;
+
+	$: if (!chatListLoading && chatList) {
+		loadChatPreview(selectedIdx);
+	}
+
+	const loadChatPreview = async (selectedIdx) => {
+		if (!chatList || chatList.length === 0) {
+			selectedChat = null;
+			messages = null;
+			history = null;
+			selectedModels = [''];
+			return;
+		}
+
+		const chatId = chatList[selectedIdx].id;
+
+		const chat = await getChatById(localStorage.token, chatId).catch(async (error) => {
+			return null;
+		});
+
+		if (chat) {
+			if (chat?.chat?.history) {
+				selectedModels =
+					(chat?.chat?.models ?? undefined) !== undefined
+						? chat?.chat?.models
+						: [chat?.chat?.models ?? ''];
+
+				history = chat?.chat?.history;
+				messages = createMessagesList(chat?.chat?.history, chat?.chat?.history?.currentId);
+
+				// scroll to the bottom of the messages container
+				await tick();
+				const messagesContainerElement = document.getElementById('chat-preview');
+				if (messagesContainerElement) {
+					messagesContainerElement.scrollTop = messagesContainerElement.scrollHeight;
+				}
+			} else {
+				messages = [];
+			}
+		} else {
+			toast.error($i18n.t('Failed to load chat preview'));
+			selectedChat = null;
+			messages = null;
+			history = null;
+			selectedModels = [''];
+			return;
+		}
+	};
+
 	const searchHandler = async () => {
 		if (searchDebounceTimeout) {
 			clearTimeout(searchDebounceTimeout);
@@ -43,6 +100,11 @@
 			}, 500);
 		}
 
+		selectedChat = null;
+		messages = null;
+		history = null;
+		selectedModels = [''];
+
 		if ((chatList ?? []).length === 0) {
 			allChatsLoaded = true;
 		} else {
@@ -76,12 +138,67 @@
 		searchHandler();
 	};
 
+	const onKeyDown = (e) => {
+		if (e.code === 'Escape') {
+			show = false;
+			onClose();
+		} else if (e.code === 'Enter' && (chatList ?? []).length > 0) {
+			const item = document.querySelector(`[data-arrow-selected="true"]`);
+			if (item) {
+				item?.click();
+			}
+
+			show = false;
+			return;
+		} else if (e.code === 'ArrowDown') {
+			const searchInput = document.getElementById('search-input');
+
+			if (searchInput) {
+				// check if focused on the search input
+				if (document.activeElement === searchInput) {
+					searchInput.blur();
+					selectedIdx = 0;
+					return;
+				}
+			}
+
+			selectedIdx = Math.min(selectedIdx + 1, (chatList ?? []).length - 1);
+		} else if (e.code === 'ArrowUp') {
+			if (selectedIdx === 0) {
+				const searchInput = document.getElementById('search-input');
+
+				if (searchInput) {
+					// check if focused on the search input
+					if (document.activeElement !== searchInput) {
+						searchInput.focus();
+						selectedIdx = 0;
+						return;
+					}
+				}
+			}
+
+			selectedIdx = Math.max(selectedIdx - 1, 0);
+		}
+
+		const item = document.querySelector(`[data-arrow-selected="true"]`);
+		item?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' });
+	};
+
 	onMount(() => {
 		init();
+
+		document.addEventListener('keydown', onKeyDown);
+	});
+
+	onDestroy(() => {
+		if (searchDebounceTimeout) {
+			clearTimeout(searchDebounceTimeout);
+		}
+		document.removeEventListener('keydown', onKeyDown);
 	});
 </script>
 
-<Modal size="md" bind:show>
+<Modal size="xl" bind:show>
 	<div class="py-2.5 dark:text-gray-300 text-gray-700">
 		<div class="px-3.5 pb-1.5">
 			<SearchInput
@@ -116,23 +233,26 @@
 
 		<!-- <hr class="border-gray-100 dark:border-gray-850 my-1" /> -->
 
-		<div class="flex flex-col overflow-y-auto h-80 scrollbar-hidden px-3 pb-1">
-			{#if chatList}
-				{#if chatList.length === 0}
-					<div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5">
-						{$i18n.t('No results found')}
-					</div>
-				{/if}
+		<div class="flex px-3 pb-1">
+			<div
+				class="flex flex-col overflow-y-auto h-96 md:h-[40rem] max-h-full scrollbar-hidden w-full flex-1"
+			>
+				{#if chatList}
+					{#if chatList.length === 0}
+						<div class="text-xs text-gray-500 dark:text-gray-400 text-center px-5">
+							{$i18n.t('No results found')}
+						</div>
+					{/if}
 
-				{#each chatList as chat, idx (chat.id)}
-					{#if idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)}
-						<div
-							class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
-								? ''
-								: 'pt-5'} pb-2 px-2"
-						>
-							{$i18n.t(chat.time_range)}
-							<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
+					{#each chatList as chat, idx (chat.id)}
+						{#if idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)}
+							<div
+								class="w-full text-xs text-gray-500 dark:text-gray-500 font-medium {idx === 0
+									? ''
+									: 'pt-5'} pb-2 px-2"
+							>
+								{$i18n.t(chat.time_range)}
+								<!-- localisation keys for time_range to be recognized from the i18next parser (so they don't get automatically removed):
 							{$i18n.t('Today')}
 							{$i18n.t('Yesterday')}
 							{$i18n.t('Previous 7 days')}
@@ -150,56 +270,84 @@
 							{$i18n.t('November')}
 							{$i18n.t('December')}
 							-->
-						</div>
-					{/if}
+							</div>
+						{/if}
 
-					<a
-						class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
-						idx
-							? 'bg-gray-50 dark:bg-gray-850'
-							: ''}"
-						href="/c/{chat.id}"
-						draggable="false"
-						data-arrow-selected={selectedIdx === idx ? 'true' : undefined}
-						on:mouseenter={() => {
-							selectedIdx = idx;
-						}}
-						on:click={() => {
-							show = false;
-							onClose();
-						}}
-					>
-						<div class=" flex-1">
-							<div class="text-ellipsis line-clamp-1 w-full">
-								{chat?.title}
+						<a
+							class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850 {selectedIdx ===
+							idx
+								? 'bg-gray-50 dark:bg-gray-850'
+								: ''}"
+							href="/c/{chat.id}"
+							draggable="false"
+							data-arrow-selected={selectedIdx === idx ? 'true' : undefined}
+							on:mouseenter={() => {
+								selectedIdx = idx;
+							}}
+							on:click={() => {
+								show = false;
+								onClose();
+							}}
+						>
+							<div class=" flex-1">
+								<div class="text-ellipsis line-clamp-1 w-full">
+									{chat?.title}
+								</div>
 							</div>
-						</div>
 
-						<div class=" pl-3 shrink-0 text-gray-500 dark:text-gray-400 text-xs">
-							{dayjs(chat?.updated_at * 1000).calendar()}
-						</div>
-					</a>
-				{/each}
-
-				{#if !allChatsLoaded}
-					<Loader
-						on:visible={(e) => {
-							if (!chatListLoading) {
-								loadMoreChats();
-							}
-						}}
+							<div class=" pl-3 shrink-0 text-gray-500 dark:text-gray-400 text-xs">
+								{dayjs(chat?.updated_at * 1000).calendar()}
+							</div>
+						</a>
+					{/each}
+
+					{#if !allChatsLoaded}
+						<Loader
+							on:visible={(e) => {
+								if (!chatListLoading) {
+									loadMoreChats();
+								}
+							}}
+						>
+							<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
+								<Spinner className=" size-4" />
+								<div class=" ">Loading...</div>
+							</div>
+						</Loader>
+					{/if}
+				{:else}
+					<div class="w-full h-full flex justify-center items-center">
+						<Spinner className="size-5" />
+					</div>
+				{/if}
+			</div>
+			<div
+				id="chat-preview"
+				class="hidden md:flex md:flex-1 w-full overflow-y-auto h-96 md:h-[40rem] scrollbar-hidden"
+			>
+				{#if messages === null}
+					<div
+						class="w-full h-full flex justify-center items-center text-gray-500 dark:text-gray-400 text-sm"
 					>
-						<div class="w-full flex justify-center py-1 text-xs animate-pulse items-center gap-2">
-							<Spinner className=" size-4" />
-							<div class=" ">Loading...</div>
-						</div>
-					</Loader>
+						{$i18n.t('Select a conversation to preview')}
+					</div>
+				{:else}
+					<div class="w-full h-full flex flex-col">
+						<Messages
+							className="h-full flex pt-4 pb-8 w-full"
+							user={$user}
+							readOnly={true}
+							{selectedModels}
+							bind:history
+							bind:messages
+							autoScroll={true}
+							sendPrompt={() => {}}
+							continueResponse={() => {}}
+							regenerateResponse={() => {}}
+						/>
+					</div>
 				{/if}
-			{:else}
-				<div class="w-full h-full flex justify-center items-center">
-					<Spinner className="size-5" />
-				</div>
-			{/if}
+			</div>
 		</div>
 	</div>
 </Modal>

+ 35 - 8
src/lib/components/layout/Sidebar.svelte

@@ -22,7 +22,8 @@
 		socket,
 		config,
 		isApp,
-		models
+		models,
+		selectedFolder
 	} from '$lib/stores';
 	import { onMount, getContext, tick, onDestroy } from 'svelte';
 
@@ -57,6 +58,7 @@
 	import Home from '../icons/Home.svelte';
 	import Search from '../icons/Search.svelte';
 	import SearchModal from './SearchModal.svelte';
+	import FolderModal from './Sidebar/Folders/FolderModal.svelte';
 
 	const BREAKPOINT = 768;
 
@@ -73,6 +75,7 @@
 	let chatListLoading = false;
 	let allChatsLoaded = false;
 
+	let showCreateFolderModal = false;
 	let folders = {};
 	let newFolderId = null;
 
@@ -116,7 +119,7 @@
 		}
 	};
 
-	const createFolder = async (name = 'Untitled') => {
+	const createFolder = async ({ name, data }) => {
 		if (name === '') {
 			toast.error($i18n.t('Folder name cannot be empty.'));
 			return;
@@ -147,13 +150,16 @@
 			}
 		};
 
-		const res = await createNewFolder(localStorage.token, name).catch((error) => {
+		const res = await createNewFolder(localStorage.token, {
+			name,
+			data
+		}).catch((error) => {
 			toast.error(`${error}`);
 			return null;
 		});
 
 		if (res) {
-			newFolderId = res.id;
+			// newFolderId = res.id;
 			await initFolders();
 		}
 	};
@@ -361,6 +367,10 @@
 			}
 		});
 
+		chats.subscribe((value) => {
+			initFolders();
+		});
+
 		await initChannels();
 		await initChatList();
 
@@ -424,6 +434,14 @@
 	}}
 />
 
+<FolderModal
+	bind:show={showCreateFolderModal}
+	onSubmit={async (folder) => {
+		await createFolder(folder);
+		showCreateFolderModal = false;
+	}}
+/>
+
 <!-- svelte-ignore a11y-no-static-element-interactions -->
 
 {#if $showSidebar}
@@ -494,6 +512,7 @@
 				draggable="false"
 				on:click={async () => {
 					selectedChatId = null;
+					selectedFolder.set(null);
 
 					if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
 						await temporaryChatEnabled.set(true);
@@ -726,9 +745,13 @@
 				className="px-2 mt-0.5"
 				name={$i18n.t('Chats')}
 				onAdd={() => {
-					createFolder();
+					showCreateFolderModal = true;
 				}}
 				onAddLabel={$i18n.t('New Folder')}
+				on:change={async (e) => {
+					selectedFolder.set(null);
+					await goto('/');
+				}}
 				on:import={(e) => {
 					importChatHandler(e.detail);
 				}}
@@ -874,13 +897,17 @@
 					<Folders
 						{folders}
 						{shiftKey}
+						onDelete={(folderId) => {
+							selectedFolder.set(null);
+							initChatList();
+						}}
+						on:update={() => {
+							initChatList();
+						}}
 						on:import={(e) => {
 							const { folderId, items } = e.detail;
 							importChatHandler(items, false, folderId);
 						}}
-						on:update={async (e) => {
-							initChatList();
-						}}
 						on:change={async () => {
 							initChatList();
 						}}

+ 36 - 7
src/lib/components/layout/Sidebar/ChatItem.svelte

@@ -25,7 +25,8 @@
 		pinnedChats,
 		showSidebar,
 		currentChatPage,
-		tags
+		tags,
+		selectedFolder
 	} from '$lib/stores';
 
 	import ChatMenu from './ChatMenu.svelte';
@@ -138,7 +139,10 @@
 	let itemElement;
 
 	let generating = false;
+
+	let ignoreBlur = false;
 	let doubleClicked = false;
+
 	let dragged = false;
 	let x = 0;
 	let y = 0;
@@ -180,8 +184,18 @@
 		dragged = false;
 	};
 
+	const onClickOutside = (event) => {
+		if (confirmEdit && !event.target.closest(`#chat-title-input-${id}`)) {
+			confirmEdit = false;
+			ignoreBlur = false;
+			chatTitle = '';
+		}
+	};
+
 	onMount(() => {
 		if (itemElement) {
+			document.addEventListener('click', onClickOutside, true);
+
 			// Event listener for when dragging starts
 			itemElement.addEventListener('dragstart', onDragStart);
 			// Event listener for when dragging occurs (optional)
@@ -193,6 +207,8 @@
 
 	onDestroy(() => {
 		if (itemElement) {
+			document.removeEventListener('click', onClickOutside, true);
+
 			itemElement.removeEventListener('dragstart', onDragStart);
 			itemElement.removeEventListener('drag', onDrag);
 			itemElement.removeEventListener('dragend', onDragEnd);
@@ -301,7 +317,7 @@
 		<div
 			class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
 			confirmEdit
-				? 'bg-gray-200 dark:bg-gray-900'
+				? 'bg-gray-100 dark:bg-gray-900'
 				: selected
 					? 'bg-gray-100 dark:bg-gray-950'
 					: 'group-hover:bg-gray-100 dark:group-hover:bg-gray-950'}  whitespace-nowrap text-ellipsis relative {generating
@@ -313,10 +329,12 @@
 				bind:value={chatTitle}
 				class=" bg-transparent w-full outline-hidden mr-10"
 				placeholder={generating ? $i18n.t('Generating...') : ''}
+				disabled={generating}
 				on:keydown={chatTitleInputKeydownHandler}
 				on:blur={async (e) => {
-					// check if target is generate button
-					if (e.relatedTarget?.id === 'generate-title-button') {
+					if (ignoreBlur) {
+						ignoreBlur = false;
+
 						return;
 					}
 
@@ -347,7 +365,7 @@
 		<a
 			class=" w-full flex justify-between rounded-lg px-[11px] py-[6px] {id === $chatId ||
 			confirmEdit
-				? 'bg-gray-200 dark:bg-gray-900'
+				? 'bg-gray-100 dark:bg-gray-900'
 				: selected
 					? 'bg-gray-100 dark:bg-gray-950'
 					: ' group-hover:bg-gray-100 dark:group-hover:bg-gray-950'}  whitespace-nowrap text-ellipsis"
@@ -355,6 +373,13 @@
 			on:click={() => {
 				dispatch('select');
 
+				if (
+					$selectedFolder &&
+					!($selectedFolder?.items?.chats.map((chat) => chat.id) ?? []).includes(id)
+				) {
+					selectedFolder.set(null); // Reset selected folder if the chat is not in it
+				}
+
 				if ($mobile) {
 					showSidebar.set(false);
 				}
@@ -387,7 +412,7 @@
 	<div
 		class="
         {id === $chatId || confirmEdit
-			? 'from-gray-200 dark:from-gray-900'
+			? 'from-gray-100 dark:from-gray-900'
 			: selected
 				? 'from-gray-100 dark:from-gray-950'
 				: 'invisible group-hover:visible from-gray-100 dark:from-gray-950'}
@@ -409,8 +434,12 @@
 			>
 				<Tooltip content={$i18n.t('Generate')}>
 					<button
-						class=" self-center dark:hover:text-white transition"
+						class=" self-center dark:hover:text-white transition disabled:cursor-not-allowed"
 						id="generate-title-button"
+						disabled={generating}
+						on:mouseenter={() => {
+							ignoreBlur = true;
+						}}
 						on:click={(e) => {
 							e.preventDefault();
 							e.stopImmediatePropagation();

+ 3 - 0
src/lib/components/layout/Sidebar/Folders.svelte

@@ -6,6 +6,8 @@
 	export let folders = {};
 	export let shiftKey = false;
 
+	export let onDelete = (folderId) => {};
+
 	let folderList = [];
 	// Get the list of folders that have no parent, sorted by name alphabetically
 	$: folderList = Object.keys(folders)
@@ -24,6 +26,7 @@
 		{folders}
 		{folderId}
 		{shiftKey}
+		{onDelete}
 		on:import={(e) => {
 			dispatch('import', e.detail);
 		}}

+ 10 - 5
src/lib/components/layout/Sidebar/Folders/FolderMenu.svelte

@@ -12,6 +12,11 @@
 	import Tooltip from '$lib/components/common/Tooltip.svelte';
 	import Download from '$lib/components/icons/Download.svelte';
 
+	export let align: 'start' | 'end' = 'start';
+	export let onEdit = () => {};
+	export let onExport = () => {};
+	export let onDelete = () => {};
+
 	let show = false;
 </script>
 
@@ -32,23 +37,23 @@
 			class="w-full max-w-[170px] rounded-lg px-1 py-1.5  z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
 			sideOffset={-2}
 			side="bottom"
-			align="start"
+			{align}
 			transition={flyAndScale}
 		>
 			<DropdownMenu.Item
 				class="flex gap-2 items-center px-3 py-1.5 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
-					dispatch('rename');
+					onEdit();
 				}}
 			>
 				<Pencil strokeWidth="2" />
-				<div class="flex items-center">{$i18n.t('Rename')}</div>
+				<div class="flex items-center">{$i18n.t('Edit')}</div>
 			</DropdownMenu.Item>
 
 			<DropdownMenu.Item
 				class="flex gap-2 items-center px-3 py-1.5 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
-					dispatch('export');
+					onExport();
 				}}
 			>
 				<Download strokeWidth="2" />
@@ -59,7 +64,7 @@
 			<DropdownMenu.Item
 				class="flex  gap-2  items-center px-3 py-1.5 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
 				on:click={() => {
-					dispatch('delete');
+					onDelete();
 				}}
 			>
 				<GarbageBin strokeWidth="2" />

+ 153 - 0
src/lib/components/layout/Sidebar/Folders/FolderModal.svelte

@@ -0,0 +1,153 @@
+<script lang="ts">
+	import { getContext, createEventDispatcher, onMount } from 'svelte';
+
+	import Spinner from '$lib/components/common/Spinner.svelte';
+	import Modal from '$lib/components/common/Modal.svelte';
+	import XMark from '$lib/components/icons/XMark.svelte';
+
+	import { toast } from 'svelte-sonner';
+	import { page } from '$app/stores';
+	import { goto } from '$app/navigation';
+	import Textarea from '$lib/components/common/Textarea.svelte';
+	import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte';
+	import { user } from '$lib/stores';
+	const i18n = getContext('i18n');
+
+	export let show = false;
+	export let onSubmit: Function = (e) => {};
+
+	export let edit = false;
+
+	export let folder = null;
+
+	let name = '';
+	let data = {
+		system_prompt: '',
+		files: []
+	};
+
+	let loading = false;
+
+	const submitHandler = async () => {
+		loading = true;
+		await onSubmit({
+			name,
+			data
+		});
+		show = false;
+		loading = false;
+	};
+
+	const init = () => {
+		name = folder.name;
+		data = folder.data || {
+			system_prompt: '',
+			files: []
+		};
+	};
+
+	$: if (folder) {
+		init();
+	}
+
+	$: if (!show && !edit) {
+		name = '';
+		data = {
+			system_prompt: '',
+			files: []
+		};
+	}
+</script>
+
+<Modal size="md" bind:show>
+	<div>
+		<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-1">
+			<div class=" text-lg font-medium self-center">
+				{#if edit}
+					{$i18n.t('Edit Folder')}
+				{:else}
+					{$i18n.t('Create Folder')}
+				{/if}
+			</div>
+			<button
+				class="self-center"
+				on:click={() => {
+					show = false;
+				}}
+			>
+				<XMark className={'size-5'} />
+			</button>
+		</div>
+
+		<div class="flex flex-col md:flex-row w-full px-5 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">
+				<form
+					class="flex flex-col w-full"
+					on:submit|preventDefault={() => {
+						submitHandler();
+					}}
+				>
+					<div class="flex flex-col w-full mt-1">
+						<div class=" mb-1 text-xs text-gray-500">{$i18n.t('Folder Name')}</div>
+
+						<div class="flex-1">
+							<input
+								class="w-full text-sm bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-700 outline-hidden"
+								type="text"
+								bind:value={name}
+								placeholder={$i18n.t('Enter folder name')}
+								autocomplete="off"
+							/>
+						</div>
+					</div>
+
+					<hr class=" border-gray-50 dark:border-gray-850 my-2.5 w-full" />
+
+					{#if $user?.role === 'admin' || ($user?.permissions.chat?.system_prompt ?? true)}
+						<div class="my-1">
+							<div class="mb-2 text-xs text-gray-500">{$i18n.t('System Prompt')}</div>
+							<div>
+								<Textarea
+									className=" text-sm w-full bg-transparent outline-hidden "
+									placeholder={`Write your model system prompt content here\ne.g.) You are Mario from Super Mario Bros, acting as an assistant.`}
+									maxSize={200}
+									bind:value={data.system_prompt}
+								/>
+							</div>
+						</div>
+					{/if}
+
+					<div class="my-2">
+						<Knowledge bind:selectedItems={data.files}>
+							<div slot="label">
+								<div class="flex w-full justify-between">
+									<div class=" mb-2 text-xs text-gray-500">
+										{$i18n.t('Knowledge')}
+									</div>
+								</div>
+							</div>
+						</Knowledge>
+					</div>
+
+					<div class="flex justify-end pt-3 text-sm font-medium gap-1.5">
+						<button
+							class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-950 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex flex-row space-x-1 items-center {loading
+								? ' cursor-not-allowed'
+								: ''}"
+							type="submit"
+							disabled={loading}
+						>
+							{$i18n.t('Save')}
+
+							{#if loading}
+								<div class="ml-2 self-center">
+									<Spinner />
+								</div>
+							{/if}
+						</button>
+					</div>
+				</form>
+			</div>
+		</div>
+	</div>
+</Modal>

Неке датотеке нису приказане због велике количине промена