diff --git a/.github/workflows/format-build-frontend.yaml b/.github/workflows/format-build-frontend.yaml index 9a007581ff..15dc53cc63 100644 --- a/.github/workflows/format-build-frontend.yaml +++ b/.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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 54053bbd82..898a7021bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.6.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 diff --git a/Dockerfile b/Dockerfile index d7de72f015..5747680e86 100644 --- a/Dockerfile +++ b/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} diff --git a/LICENSE_HISTORY b/LICENSE_HISTORY new file mode 100644 index 0000000000..a9eb5e259d --- /dev/null +++ b/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. diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 46d3b719a6..49ab1a9aad 100644 --- a/backend/open_webui/config.py +++ b/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( diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 4db919121a..61518d59c6 100644 --- a/backend/open_webui/env.py +++ b/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", "") diff --git a/backend/open_webui/models/folders.py b/backend/open_webui/models/folders.py index 56ef81f167..a42748c5b1 100644 --- a/backend/open_webui/models/folders.py +++ b/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()), diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index 096041e40f..6615f95142 100644 --- a/backend/open_webui/models/groups.py +++ b/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() diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 00d5040884..f7ea905a65 100644 --- a/backend/open_webui/models/users.py +++ b/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 diff --git a/backend/open_webui/retrieval/utils.py b/backend/open_webui/retrieval/utils.py index 154873749f..9158f8536e 100644 --- a/backend/open_webui/retrieval/utils.py +++ b/backend/open_webui/retrieval/utils.py @@ -611,6 +611,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 diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index c63ad3bfe7..a9aa93e08a 100644 --- a/backend/open_webui/routers/audio.py +++ b/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, diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index 13b6040102..628f9176b8 100644 --- a/backend/open_webui/routers/chats.py +++ b/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() + ) ############################ diff --git a/backend/open_webui/routers/files.py b/backend/open_webui/routers/files.py index bdf5780fc4..0a2b4ac97f 100644 --- a/backend/open_webui/routers/files.py +++ b/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") diff --git a/backend/open_webui/routers/folders.py b/backend/open_webui/routers/folders.py index edc9f85ff2..111d3e4d3d 100644 --- a/backend/open_webui/routers/folders.py +++ b/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) diff --git a/backend/open_webui/routers/groups.py b/backend/open_webui/routers/groups.py index ae822c0d00..bf286fe001 100755 --- a/backend/open_webui/routers/groups.py +++ b/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 ############################ diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 0c4842909f..375f59ff6c 100644 --- a/backend/open_webui/routers/notes.py +++ b/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) diff --git a/backend/open_webui/routers/ollama.py b/backend/open_webui/routers/ollama.py index 000f4b48d2..5894a72c35 100644 --- a/backend/open_webui/routers/ollama.py +++ b/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, ) diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py index a759ec7eee..5b54796a70 100644 --- a/backend/open_webui/routers/openai.py +++ b/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) diff --git a/backend/open_webui/routers/retrieval.py b/backend/open_webui/routers/retrieval.py index 97db0a72f7..fac5706f03 100644 --- a/backend/open_webui/routers/retrieval.py +++ b/backend/open_webui/routers/retrieval.py @@ -815,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( @@ -2050,11 +2054,13 @@ def query_doc_handler( ), k=form_data.k if form_data.k else request.app.state.config.TOP_K, reranking_function=( - lambda sentences: ( - request.app.state.RERANKING_FUNCTION(sentences, user=user) - if request.app.state.RERANKING_FUNCTION - else None + ( + 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, @@ -2112,8 +2118,14 @@ 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=lambda sentences: request.app.state.RERANKING_FUNCTION( - sentences, user=user + 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, diff --git a/backend/open_webui/routers/tasks.py b/backend/open_webui/routers/tasks.py index 3832c0306b..2dec218d92 100644 --- a/backend/open_webui/routers/tasks.py +++ b/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), }, } diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 16cc2c375f..d094047732 100644 --- a/backend/open_webui/routers/users.py +++ b/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), ): diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index ceeecba8c3..cc78bbb98d 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -316,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']}" @@ -450,7 +481,7 @@ async def yjs_document_state(sid, data): room = f"doc_{document_id}" active_session_ids = get_session_ids_from_room(room) - print(active_session_ids) + if sid not in active_session_ids: log.warning(f"Session {sid} not in room {room}. Cannot send state.") return @@ -520,7 +551,8 @@ 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}") diff --git a/backend/open_webui/test/util/test_redis.py b/backend/open_webui/test/util/test_redis.py new file mode 100644 index 0000000000..8c393ce9d9 --- /dev/null +++ b/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") diff --git a/backend/open_webui/utils/logger.py b/backend/open_webui/utils/logger.py index a21df2756b..ff7d5c4546 100644 --- a/backend/open_webui/utils/logger.py +++ b/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"): diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index fc21543457..7000d37863 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -653,13 +653,13 @@ async def chat_completion_files_handler( ), k=request.app.state.config.TOP_K, reranking_function=( - lambda sentences: ( - request.app.state.RERANKING_FUNCTION( + ( + lambda sentences: request.app.state.RERANKING_FUNCTION( sentences, user=user ) - if request.app.state.RERANKING_FUNCTION - else None ) + if request.app.state.RERANKING_FUNCTION + else None ), k_reranker=request.app.state.config.TOP_K_RERANKER, r=request.app.state.config.RELEVANCE_THRESHOLD, @@ -1472,12 +1472,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
\nThought for {reasoning_duration} seconds\n{reasoning_display_content}\n
\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
\nThinking…\n{reasoning_display_content}\n
\n' @@ -1574,8 +1574,16 @@ async def process_chat_response( if content_blocks[-1]["type"] == "text": for start_tag, end_tag in tags: - # Match start tag e.g., or - 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., or + # 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 = ( @@ -1626,8 +1634,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., - end_tag_pattern = rf"<{re.escape(end_tag)}>" + + if end_tag.startswith("<") and end_tag.endswith(">"): + # Match end tag e.g., + 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): @@ -1699,8 +1712,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., or + # 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, @@ -1744,18 +1766,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|"), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("<|begin_of_thought|>", "<|end_of_thought|>"), + ("◁think▷", "◁/think▷"), ] - code_interpreter_tags = [("code_interpreter", "/code_interpreter")] + code_interpreter_tags = [("", "")] - solution_tags = [("|begin_of_solution|", "|end_of_solution|")] + solution_tags = [("<|begin_of_solution|>", "<|end_of_solution|>")] try: for event in events: @@ -2039,7 +2062,7 @@ async def process_chat_response( if done: pass else: - log.debug("Error: ", e) + log.debug(f"Error: {e}") continue if content_blocks: diff --git a/backend/open_webui/utils/redis.py b/backend/open_webui/utils/redis.py index 70ae18f115..ca450028b0 100644 --- a/backend/open_webui/utils/redis.py +++ b/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: diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 8ddd502e2e..2a54b9af62 100644 --- a/backend/open_webui/utils/response.py +++ b/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) diff --git a/backend/open_webui/utils/telemetry/metrics.py b/backend/open_webui/utils/telemetry/metrics.py index 8a0298d091..f3e82c7dab 100644 --- a/backend/open_webui/utils/telemetry/metrics.py +++ b/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): diff --git a/backend/requirements.txt b/backend/requirements.txt index 5b0524efa8..94badb254d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -51,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 diff --git a/package-lock.json b/package-lock.json index bb69af3901..5150e31d5a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,42 +1,40 @@ { "name": "open-webui", - "version": "0.6.16", + "version": "0.6.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.6.16", + "version": "0.6.17", "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" - } - }, - "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==", - "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-code": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.10.0.tgz", - "integrity": "sha512-8JznKG1Jmv8gJezZGPoka8oRmfrcAAnMEOeMpKXjwMrIbQ6QynTZpqMGGVL1kfkZlLV84PYm+CGjGgjSsT4iZw==", + "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/core": "^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==", + "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/pm": "^2.7.0" + "@tiptap/core": "^3.0.7", + "@tiptap/pm": "^3.0.7" } }, "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==", + "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/extension-code-block": "^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-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": "^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": "^2.7.0" + "@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": "^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" - } - }, - "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==", - "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-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==", + "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-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": "^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" + "@tiptap/core": "^3.0.7", + "@tiptap/pm": "^3.0.7" + } + }, + "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": "^3.0.7", + "@tiptap/pm": "^3.0.7" } }, "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==", + "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-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/extension-list": "^3.0.7" + } + }, + "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": "^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==", + "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/extension-list": "^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==", + "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" - } - }, - "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==", - "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-strike": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.10.0.tgz", - "integrity": "sha512-SxApLJMQkxnmPGR3lwaskvLK61yI+Bu9hGZGdwMZqNh6o3LoDOxDaXjHD5joeMYQiqQrBE9zg46506MsXtrU7Q==", + "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": { - "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==", + "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/pm": "^2.7.0" - } - }, - "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==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0" - } - }, - "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==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0" - } - }, - "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==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0" - } - }, - "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==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^2.7.0", - "@tiptap/pm": "^2.7.0" - } - }, - "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==", - "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-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==", + "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/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==", + "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-typography": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.10.0.tgz", - "integrity": "sha512-03IOfJm4bk2hZ4SsSfxgBOVzcDxMRBlFD7ZY12H2EGNf1TKxj/0ANWhAH54FtquuOMoY5aWg5LZf0lk++8UDAw==", + "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-underline": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.25.0.tgz", - "integrity": "sha512-RqXkWSMJyllfsDukugDzWEZfWRUOgcqzuMWC40BnuDUs4KgdRA0nhVUWJbLfUEmXI0UVqN5OwYTTAdhaiF7kjQ==", + "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-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": "^3.0.7" + } + }, + "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": "^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==", + "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": "^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" + "@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" diff --git a/package.json b/package.json index a0517acb72..5b1f1530a6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.6.16", + "version": "0.6.17", "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", diff --git a/pyproject.toml b/pyproject.toml index ee0baed74e..5812a6b29a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -136,6 +136,8 @@ dependencies = [ "moto[s3]>=5.0.26", + "posthog==5.4.0", + ] readme = "README.md" requires-python = ">= 3.11, < 3.13.0a1" @@ -191,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", +] diff --git a/src/app.css b/src/app.css index 91302ca076..e1c6bb592c 100644 --- a/src/app.css +++ b/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; } diff --git a/src/lib/apis/folders/index.ts b/src/lib/apis/folders/index.ts index 243cdb1dbf..dc2d8bbf18 100644 --- a/src/lib/apis/folders/index.ts +++ b/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; +}; + +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; -}; - export const updateFolderById = async (token: string, id: string, folderForm: FolderForm) => { let error = null; diff --git a/src/lib/components/admin/Settings/Audio.svelte b/src/lib/components/admin/Settings/Audio.svelte index 9e68da7322..3b465e7c76 100644 --- a/src/lib/components/admin/Settings/Audio.svelte +++ b/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)' )} /> diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index db86eb3368..d5c318de62 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -1228,7 +1228,7 @@ await handleOpenAIError(error, message); } - if (sources) { + if (sources && !message?.sources) { message.sources = sources; } @@ -1352,6 +1352,12 @@ ); history.messages[message.id] = message; + + await tick(); + if (autoScroll) { + scrollToBottom(); + } + await chatCompletedHandler( chatId, message.model, @@ -1361,6 +1367,8 @@ } console.log(data); + await tick(); + if (autoScroll) { scrollToBottom(); } @@ -2069,12 +2077,13 @@ > {#if !loading}
- {#if $settings?.backgroundImageUrl ?? null} + {#if $settings?.backgroundImageUrl ?? $config?.license_metadata?.background_image_url ?? null}
-
- {#if $settings?.landingPageMode === 'chat' || createMessagesList(history, history.currentId).length > 0} +
+ {#if ($settings?.landingPageMode === 'chat' && !$selectedFolder) || createMessagesList(history, history.currentId).length > 0}
{:else} -
+
{}; + 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} diff --git a/src/lib/components/chat/Messages/ContentRenderer.svelte b/src/lib/components/chat/Messages/ContentRenderer.svelte index 613c1670a0..54cd2a5aab 100644 --- a/src/lib/components/chat/Messages/ContentRenderer.svelte +++ b/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) => { diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 0130718334..96ec6e06ba 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/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 @@ {}; @@ -28,7 +31,7 @@ {:else if token.type === 'link'} {#if token.tokens} - + {:else} {token.text} @@ -40,15 +43,7 @@ {:else if token.type === 'em'} {:else if token.type === 'codespan'} - - - { - copyToClipboard(unescapeHtml(token.text)); - toast.success($i18n.t('Copied to clipboard')); - }}>{unescapeHtml(token.text)} + {:else if token.type === 'br'}
{:else if token.type === 'del'} @@ -66,6 +61,6 @@ onload="this.style.height=(this.contentWindow.document.body.scrollHeight+20)+'px';" > {:else if token.type === 'text'} - {token.raw} + {/if} {/each} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte new file mode 100644 index 0000000000..c0b1ec327c --- /dev/null +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/CodespanToken.svelte @@ -0,0 +1,33 @@ + + + + +{#if done} + { + copyToClipboard(unescapeHtml(token.text)); + toast.success($i18n.t('Copied to clipboard')); + }}>{unescapeHtml(token.text)} +{:else} + { + copyToClipboard(unescapeHtml(token.text)); + toast.success($i18n.t('Copied to clipboard')); + }}>{unescapeHtml(token.text)} +{/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte new file mode 100644 index 0000000000..d5ae387afe --- /dev/null +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/TextToken.svelte @@ -0,0 +1,19 @@ + + +{#if done} + {token?.raw} +{:else} + {#each texts as text} + + {text} + + {/each} +{/if} diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte index e989d408ed..70626a44d4 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownTokens.svelte +++ b/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 @@
{:else if token.type === 'heading'} - + {:else if token.type === 'code'} {#if token.raw.includes('```')} @@ -132,6 +139,7 @@
@@ -152,6 +160,7 @@
@@ -183,7 +192,13 @@ {:else}
- +
{/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 @@

@@ -302,7 +322,12 @@ {#if top}

{#if token.tokens} - + {:else} {unescapeHtml(token.text)} {/if} @@ -311,6 +336,7 @@ {:else} diff --git a/src/lib/components/chat/Messages/Message.svelte b/src/lib/components/chat/Messages/Message.svelte index 8d7896ccb8..7dc7125598 100644 --- a/src/lib/components/chat/Messages/Message.svelte +++ b/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} diff --git a/src/lib/components/chat/Messages/MultiResponseMessages.svelte b/src/lib/components/chat/Messages/MultiResponseMessages.svelte index 52c430e4d8..3b3dd9b194 100644 --- a/src/lib/components/chat/Messages/MultiResponseMessages.svelte +++ b/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} diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 8d186df909..6924fc755c 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/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}

{ - 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); + } }} />
diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte index 5854b7f52f..5f9f1cb658 100644 --- a/src/lib/components/chat/Placeholder.svelte +++ b/src/lib/components/chat/Placeholder.svelte @@ -12,7 +12,9 @@ user, models as _models, temporaryChatEnabled, - selectedFolder + selectedFolder, + chats, + currentChatPage } from '$lib/stores'; import { sanitizeResponseContent, extractCurlyBraceWords } from '$lib/utils'; import { WEBUI_BASE_URL } from '$lib/constants'; @@ -21,9 +23,9 @@ import Tooltip from '$lib/components/common/Tooltip.svelte'; import EyeSlash from '$lib/components/icons/EyeSlash.svelte'; import MessageInput from './MessageInput.svelte'; - import FolderOpen from '../icons/FolderOpen.svelte'; - import XMark from '../icons/XMark.svelte'; - import Folder from '../icons/Folder.svelte'; + import FolderPlaceholder from './Placeholder/FolderPlaceholder.svelte'; + import FolderTitle from './Placeholder/FolderTitle.svelte'; + import { getChatList } from '$lib/apis/chats'; const i18n = getContext('i18n'); @@ -87,29 +89,21 @@ >
{#if $selectedFolder} -
-
-
- -
+ { + selectedFolder.set(folder); -
- {$selectedFolder?.name} -
-
+ 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}
@@ -249,16 +243,26 @@
-
-
- + + {#if $selectedFolder} +
+
-
+ {:else} +
+
+ +
+
+ {/if}
diff --git a/src/lib/components/chat/Placeholder/ChatList.svelte b/src/lib/components/chat/Placeholder/ChatList.svelte new file mode 100644 index 0000000000..1e48298ec5 --- /dev/null +++ b/src/lib/components/chat/Placeholder/ChatList.svelte @@ -0,0 +1,103 @@ + + +{#if chatList} +
+ {#if chatList.length === 0} +
+ {$i18n.t('No chats found')} +
+ {/if} + + {#each chatList as chat, idx (chat.id)} + {#if (idx === 0 || (idx > 0 && chat.time_range !== chatList[idx - 1].time_range)) && chat?.time_range} +
+ {$i18n.t(chat.time_range)} + +
+ {/if} + + (show = false)} + > +
+ {chat?.title} +
+ + +
+ {/each} + + +
+{/if} diff --git a/src/lib/components/chat/Placeholder/FolderKnowledge.svelte b/src/lib/components/chat/Placeholder/FolderKnowledge.svelte new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte b/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte new file mode 100644 index 0000000000..057dd7018d --- /dev/null +++ b/src/lib/components/chat/Placeholder/FolderPlaceholder.svelte @@ -0,0 +1,51 @@ + + +
+ + +
+ {#if selectedTab === 'knowledge'} + + {:else if selectedTab === 'chats'} + + {/if} +
+
diff --git a/src/lib/components/chat/Placeholder/FolderTitle.svelte b/src/lib/components/chat/Placeholder/FolderTitle.svelte new file mode 100644 index 0000000000..18f4bd0c96 --- /dev/null +++ b/src/lib/components/chat/Placeholder/FolderTitle.svelte @@ -0,0 +1,147 @@ + + +{#if folder} + + + { + deleteHandler(); + }} + > +
+ {@html DOMPurify.sanitize( + $i18n.t( + 'This will delete {{NAME}} and all its contents.', + { + NAME: folder.name + } + ) + )} +
+
+ +
+
+
+ +
+ +
+ {folder.name} +
+
+ +
+ { + showFolderModal = true; + }} + onDelete={() => { + showDeleteConfirm = true; + }} + onExport={() => { + exportHandler(); + }} + > + + +
+
+{/if} diff --git a/src/lib/components/chat/Settings/Interface.svelte b/src/lib/components/chat/Settings/Interface.svelte index bf5ad3db9e..2b517471a9 100644 --- a/src/lib/components/chat/Settings/Interface.svelte +++ b/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 @@
+
+
+
+ {$i18n.t('Fade Effect for Streaming Text')} +
+ + +
+
+ +
+
+
+ {$i18n.t('Keep Follow-Up Prompts in Chat')} +
+ + +
+
+ +
+
+
+ {$i18n.t('Insert Follow-Up Prompt to Input')} +
+ + +
+
+
diff --git a/src/lib/components/chat/Settings/Personalization/ManageModal.svelte b/src/lib/components/chat/Settings/Personalization/ManageModal.svelte index d7bae329f6..44c2724572 100644 --- a/src/lib/components/chat/Settings/Personalization/ManageModal.svelte +++ b/src/lib/components/chat/Settings/Personalization/ManageModal.svelte @@ -50,7 +50,7 @@ } - +
{$i18n.t('Memory')}
diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte index 6481dc0298..07c4a6c67c 100644 --- a/src/lib/components/chat/SettingsModal.svelte +++ b/src/lib/components/chat/SettingsModal.svelte @@ -537,7 +537,7 @@ } - +
{$i18n.t('Settings')}
diff --git a/src/lib/components/common/FileItemModal.svelte b/src/lib/components/common/FileItemModal.svelte index 0858fc87a0..d78c9e6d4e 100644 --- a/src/lib/components/common/FileItemModal.svelte +++ b/src/lib/components/common/FileItemModal.svelte @@ -133,9 +133,9 @@ >
{#if enableFullContent} - Using Entire Document + {$i18n.t('Using Entire Document')} {:else} - Using Focused Retrieval + {$i18n.t('Using Focused Retrieval')} {/if} {}; 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 = ''; @@ -141,11 +195,21 @@ 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) { @@ -217,11 +281,27 @@ 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 && (data?.sessions ?? ['']).length === 1) { - const editorYdoc = prosemirrorJSONToYDoc(editor.schema, content); - if (editorYdoc) { - Y.applyUpdate(this.doc, Y.encodeStateAsUpdate(editorYdoc)); + 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 { @@ -580,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; @@ -748,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; @@ -794,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({ @@ -873,6 +991,7 @@ onTransaction: () => { // force re-render so `editor.isActive` works as expected editor = editor; + if (!editor) return; htmlValue = editor.getHTML(); jsonValue = editor.getJSON(); @@ -1063,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; @@ -1074,7 +1196,13 @@ return false; } } - } + }, + onBeforeCreate: ({ editor }) => { + if (files) { + editor.storage.files = files; + } + }, + onSelectionUpdate: onSelectionUpdate }); if (messageInput) { @@ -1146,11 +1274,11 @@ {#if showFormattingButtons} -
+
-
+
{/if} diff --git a/src/lib/components/common/RichTextInput/FormattingButtons.svelte b/src/lib/components/common/RichTextInput/FormattingButtons.svelte index e2b9adac94..47c6e64e44 100644 --- a/src/lib/components/common/RichTextInput/FormattingButtons.svelte +++ b/src/lib/components/common/RichTextInput/FormattingButtons.svelte @@ -17,6 +17,8 @@ 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';
+ {#if editor?.isActive('bulletList') || editor?.isActive('orderedList') || editor?.isActive('taskList')} + + + + + + + + {/if} +