diff --git a/CHANGELOG.md b/CHANGELOG.md index 01bdde5f3c..15afc1685e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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.8.2] - 2026-02-16 + +### Added + +- 🧠 **Skill content handling.** User-selected skills now have their full content injected into the chat, while model-attached skills only display name and description in the available skills list. This allows users to override skill behavior while model-attached skills remain flexible. [Commit](https://github.com/open-webui/open-webui/commit/393c0071dc612c5ac982fb37dfc0288cb9911439) +- ⚙️ **Chat toggles now control built-in tools.** Users can now disable web search, image generation, and code execution on a per-conversation basis, even when those tools are enabled as builtin tools on the model. [#20641](https://github.com/open-webui/open-webui/issues/20641), [#21318](https://github.com/open-webui/open-webui/discussions/21318), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c), [Commit](https://github.com/open-webui/open-webui/commit/f1a1e64d2e9ad953b2bc2a9543e9a308b7c669c8) +- 🖼️ **Image preview in file modal.** Images uploaded to chats can now be previewed directly in the file management modal, making it easier to identify and manage image files. [#21413](https://github.com/open-webui/open-webui/issues/21413), [Commit](https://github.com/open-webui/open-webui/commit/e1b3e7252c1896c04d498547908f0fce111434e1) +- 🏷️ **Batch tag operations.** Tag creation, deletion, and orphan cleanup for chats now use batch database queries instead of per-tag loops, significantly reducing database round trips when updating, archiving, or deleting chats with multiple tags. [Commit](https://github.com/open-webui/open-webui/commit/c748c3ede) +- 💨 **Faster group list loading.** Group lists and search results now load with a single database query that joins member counts, replacing the previous pattern of fetching groups first and then counting members in a separate batch query. [Commit](https://github.com/open-webui/open-webui/commit/33308022f) +- 🔐 **Skills sharing permissions.** Administrators can now control skills sharing and public sharing permissions per-group, matching the existing capabilities for tools, knowledge, and prompts. [Commit](https://github.com/open-webui/open-webui/commit/88401e91c) +- ⚡ **Long content truncation in preview modals.** Citation and file content modals now truncate markdown-rendered content at 10,000 characters with a "Show all" expansion button, preventing UI jank when previewing very large documents. +- 🌐 **Translation updates.** Translations for Spanish and German were enhanced and expanded. + +### Fixed + +- 🔐 **OAuth session error handling.** Corrupted OAuth sessions are now gracefully handled and automatically cleaned up instead of causing errors. [Commit](https://github.com/open-webui/open-webui/commit/7e224e4a536b07ec008613f06592e34050e7067c) +- 🐛 **Task model selector validation.** The task model selector in admin settings now correctly accepts models based on the new access grants system instead of rejecting all models with an incorrect error. [Commit](https://github.com/open-webui/open-webui/commit/9a2595f0706d0c9d809ae7746001cf799f98db1d) +- 🔗 **Tool call message preservation.** Models no longer hallucinate tool outputs in multi-turn conversations because tool call history is now properly preserved instead of being merged into assistant messages. [#21098](https://github.com/open-webui/open-webui/discussions/21098), [#20600](https://github.com/open-webui/open-webui/issues/20600), [Commit](https://github.com/open-webui/open-webui/commit/f2aca781c87244cffc130aa2722e700c19a81d66) +- 🔧 **Tool server startup initialization.** External tool servers configured via the "TOOL_SERVER_CONNECTIONS" environment variable now initialize automatically on startup, eliminating the need to manually visit the Admin Panel and save for tools to become available. This enables proper GitOps and containerized deployments. [#18140](https://github.com/open-webui/open-webui/issues/18140), [#20914](https://github.com/open-webui/open-webui/pull/20914), [Commit](https://github.com/open-webui/open-webui/commit/f20cc6d7e6da493eb75ca1618f5cbd068fa57684) +- ♻️ **Resource handle cleanup.** File handles are now properly closed during audio transcription and pipeline uploads, preventing resource leaks that could cause system instability over time. [#21411](https://github.com/open-webui/open-webui/issues/21411) +- ⌨️ **Strikethrough shortcut conflict fix.** Pressing Ctrl+Shift+S to toggle the sidebar no longer causes text to become struck through in the chat input, by disabling the TipTap Strike extension's default keyboard shortcut when rich text mode is off. [Commit](https://github.com/open-webui/open-webui/commit/38ae91ae2) +- 🔧 **Tool call finish_reason fix.** API responses now correctly set finish_reason to "tool_calls" instead of "stop" when tool calls are present, fixing an issue where external API clients (such as OpenCode) would halt prematurely after tool execution when routing Ollama models through the Open WebUI API. [#20896](https://github.com/open-webui/open-webui/issues/20896) + ## [0.8.1] - 2026-02-14 ### Added diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index acb34fffcd..9ec08cc4c3 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -1385,6 +1385,18 @@ USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING = ( == "true" ) +USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING = ( + os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING", "False").lower() + == "true" +) + +USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING = ( + os.environ.get( + "USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING", "False" + ).lower() + == "true" +) + USER_PERMISSIONS_NOTES_ALLOW_SHARING = ( os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true" @@ -1543,6 +1555,8 @@ DEFAULT_USER_PERMISSIONS = { "public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING, "tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING, "public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING, + "skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING, + "public_skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING, "notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING, "public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING, }, diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index c7163a580d..b1b754724e 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -64,6 +64,7 @@ from open_webui.socket.main import ( MODELS, app as socket_app, periodic_usage_pool_cleanup, + periodic_session_pool_cleanup, get_event_emitter, get_models_in_use, ) @@ -517,6 +518,7 @@ from open_webui.utils.middleware import ( process_chat_payload, process_chat_response, ) +from open_webui.utils.tools import set_tool_servers from open_webui.utils.auth import ( get_license_data, @@ -634,6 +636,7 @@ async def lifespan(app: FastAPI): limiter.total_tokens = THREAD_POOL_SIZE asyncio.create_task(periodic_usage_pool_cleanup()) + asyncio.create_task(periodic_session_pool_cleanup()) if app.state.config.ENABLE_BASE_MODELS_CACHE: await get_all_models( @@ -656,6 +659,30 @@ async def lifespan(app: FastAPI): None, ) + # Pre-fetch tool server specs so the first request doesn't pay the latency cost + if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0: + log.info("Initializing tool servers...") + try: + mock_request = Request( + { + "type": "http", + "asgi.version": "3.0", + "asgi.spec_version": "2.0", + "method": "GET", + "path": "/internal", + "query_string": b"", + "headers": Headers({}).raw, + "client": ("127.0.0.1", 12345), + "server": ("127.0.0.1", 80), + "scheme": "http", + "app": app, + } + ) + await set_tool_servers(mock_request) + log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)") + except Exception as e: + log.warning(f"Failed to initialize tool servers at startup: {e}") + yield if hasattr(app.state, "redis_task_command_listener"): diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 7ae9f7a38b..1418abd62d 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -431,22 +431,29 @@ class ChatTable: def update_chat_tags_by_id( self, id: str, tags: list[str], user ) -> Optional[ChatModel]: - chat = self.get_chat_by_id(id) - if chat is None: - return None + with get_db_context() as db: + chat = db.get(Chat, id) + if chat is None: + return None - self.delete_all_tags_by_id_and_user_id(id, user.id) + old_tags = chat.meta.get("tags", []) + new_tags = [t for t in tags if t.replace(" ", "_").lower() != "none"] + new_tag_ids = [t.replace(" ", "_").lower() for t in new_tags] - for tag in chat.meta.get("tags", []): - if self.count_chats_by_tag_name_and_user_id(tag, user.id) == 0: - Tags.delete_tag_by_name_and_user_id(tag, user.id) + # Single meta update + chat.meta = {**chat.meta, "tags": new_tag_ids} + db.commit() + db.refresh(chat) - for tag_name in tags: - if tag_name.lower() == "none": - continue + # Batch-create any missing tag rows + Tags.ensure_tags_exist(new_tags, user.id, db=db) - self.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, tag_name) - return self.get_chat_by_id(id) + # Clean up orphaned old tags in one query + removed = set(old_tags) - set(new_tag_ids) + if removed: + self.delete_orphan_tags_for_user(list(removed), user.id, db=db) + + return ChatModel.model_validate(chat) def get_chat_title_by_id(self, id: str) -> Optional[str]: chat = self.get_chat_by_id(id) @@ -1267,8 +1274,8 @@ class ChatTable: ) -> list[TagModel]: with get_db_context(db) as db: chat = db.get(Chat, id) - tags = chat.meta.get("tags", []) - return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags] + tag_ids = chat.meta.get("tags", []) + return Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db) def get_chat_list_by_user_id_and_tag_name( self, @@ -1309,20 +1316,16 @@ class ChatTable: def add_chat_tag_by_id_and_user_id_and_tag_name( self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None ) -> Optional[ChatModel]: - tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id) - if tag is None: - tag = Tags.insert_new_tag(tag_name, user_id) + tag_id = tag_name.replace(" ", "_").lower() + Tags.ensure_tags_exist([tag_name], user_id, db=db) try: with get_db_context(db) as db: chat = db.get(Chat, id) - - tag_id = tag.id if tag_id not in chat.meta.get("tags", []): chat.meta = { **chat.meta, "tags": list(set(chat.meta.get("tags", []) + [tag_id])), } - db.commit() db.refresh(chat) return ChatModel.model_validate(chat) @@ -1332,40 +1335,53 @@ class ChatTable: def count_chats_by_tag_name_and_user_id( self, tag_name: str, user_id: str, db: Optional[Session] = None ) -> int: - with get_db_context(db) as db: # Assuming `get_db()` returns a session object + with get_db_context(db) as db: query = db.query(Chat).filter_by(user_id=user_id, archived=False) - - # Normalize the tag_name for consistency tag_id = tag_name.replace(" ", "_").lower() if db.bind.dialect.name == "sqlite": - # SQLite JSON1 support for querying the tags inside the `meta` JSON field query = query.filter( text( - f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)" + "EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)" ) ).params(tag_id=tag_id) - elif db.bind.dialect.name == "postgresql": - # PostgreSQL JSONB support for querying the tags inside the `meta` JSON field query = query.filter( text( "EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)" ) ).params(tag_id=tag_id) - else: raise NotImplementedError( f"Unsupported dialect: {db.bind.dialect.name}" ) - # Get the count of matching records - count = query.count() + return query.count() - # Debugging output for inspection - log.info(f"Count of chats for tag '{tag_name}': {count}") + def delete_orphan_tags_for_user( + self, + tag_ids: list[str], + user_id: str, + threshold: int = 0, + db: Optional[Session] = None, + ) -> None: + """Delete tag rows from *tag_ids* that appear in at most *threshold* + non-archived chats for *user_id*. One query to find orphans, one to + delete them. - return count + Use threshold=0 after a tag is already removed from a chat's meta. + Use threshold=1 when the chat itself is about to be deleted (the + referencing chat still exists at query time). + """ + if not tag_ids: + return + with get_db_context(db) as db: + orphans = [] + for tag_id in tag_ids: + count = self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=db) + if count <= threshold: + orphans.append(tag_id) + Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=db) def count_chats_by_folder_id_and_user_id( self, folder_id: str, user_id: str, db: Optional[Session] = None diff --git a/backend/open_webui/models/groups.py b/backend/open_webui/models/groups.py index 8fe720ecc6..bb3a9347be 100644 --- a/backend/open_webui/models/groups.py +++ b/backend/open_webui/models/groups.py @@ -164,7 +164,10 @@ class GroupTable: def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]: with get_db_context(db) as db: - query = db.query(Group) + member_count = func.count(GroupMember.user_id).label("member_count") + query = db.query(Group, member_count).outerjoin( + GroupMember, GroupMember.group_id == Group.id + ) if filter: if "query" in filter: @@ -179,9 +182,6 @@ class GroupTable: json_share_lower = func.lower(json_share_str) if share_value: - # Groups open to anyone: data is null, config.share is null, or share is true - # Use case-insensitive string comparison to handle variations like "True", "TRUE" - # Handle potential JSON boolean to string casting issues by checking for both string 'true' and boolean equivalence if possible, anyone_can_share = or_( Group.data.is_(None), json_share_str.is_(None), @@ -190,7 +190,6 @@ class GroupTable: ) if member_id: - # Also include member-only groups where user is a member member_groups_select = select(GroupMember.group_id).where( GroupMember.user_id == member_id ) @@ -211,21 +210,24 @@ class GroupTable: else: # Only apply member_id filter when share filter is NOT present if "member_id" in filter: - query = query.join( - GroupMember, GroupMember.group_id == Group.id - ).filter(GroupMember.user_id == filter["member_id"]) + query = query.filter( + Group.id.in_( + select(GroupMember.group_id).where( + GroupMember.user_id == filter["member_id"] + ) + ) + ) + + results = query.group_by(Group.id).order_by(Group.updated_at.desc()).all() - groups = query.order_by(Group.updated_at.desc()).all() - group_ids = [group.id for group in groups] - member_counts = self.get_group_member_counts_by_ids(group_ids, db=db) return [ GroupResponse.model_validate( { **GroupModel.model_validate(group).model_dump(), - "member_count": member_counts.get(group.id, 0), + "member_count": count or 0, } ) - for group in groups + for group, count in results ] def search_groups( @@ -242,31 +244,42 @@ class GroupTable: if "query" in filter: query = query.filter(Group.name.ilike(f"%{filter['query']}%")) if "member_id" in filter: - query = query.join( - GroupMember, GroupMember.group_id == Group.id - ).filter(GroupMember.user_id == filter["member_id"]) + query = query.filter( + Group.id.in_( + select(GroupMember.group_id).where( + GroupMember.user_id == filter["member_id"] + ) + ) + ) if "share" in filter: - # 'share' is stored in data JSON, support both sqlite and postgres share_value = filter["share"] - print("Filtering by share:", share_value) query = query.filter( Group.data.op("->>")("share") == str(share_value) ) total = query.count() - query = query.order_by(Group.updated_at.desc()) - groups = query.offset(skip).limit(limit).all() - group_ids = [group.id for group in groups] - member_counts = self.get_group_member_counts_by_ids(group_ids, db=db) + + member_count = func.count(GroupMember.user_id).label("member_count") + results = ( + query.add_columns(member_count) + .outerjoin(GroupMember, GroupMember.group_id == Group.id) + .group_by(Group.id) + .order_by(Group.updated_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) return { "items": [ GroupResponse.model_validate( - **GroupModel.model_validate(group).model_dump(), - member_count=member_counts.get(group.id, 0), + { + **GroupModel.model_validate(group).model_dump(), + "member_count": count or 0, + } ) - for group in groups + for group, count in results ], "total": total, } diff --git a/backend/open_webui/models/oauth_sessions.py b/backend/open_webui/models/oauth_sessions.py index f7ee5cceb8..538937483f 100644 --- a/backend/open_webui/models/oauth_sessions.py +++ b/backend/open_webui/models/oauth_sessions.py @@ -102,7 +102,7 @@ class OAuthSessionTable: decrypted = self.fernet.decrypt(token.encode()).decode() return json.loads(decrypted) except Exception as e: - log.error(f"Error decrypting tokens: {e}") + log.error(f"Error decrypting tokens: {type(e).__name__}: {e}") raise def create_session( @@ -209,8 +209,15 @@ class OAuthSessionTable: results = [] for session in sessions: - session.token = self._decrypt_token(session.token) - results.append(OAuthSessionModel.model_validate(session)) + try: + session.token = self._decrypt_token(session.token) + results.append(OAuthSessionModel.model_validate(session)) + except Exception as e: + log.warning( + f"Skipping OAuth session {session.id} due to decryption failure, deleting corrupted session: {type(e).__name__}: {e}" + ) + db.query(OAuthSession).filter_by(id=session.id).delete() + db.commit() return results diff --git a/backend/open_webui/models/tags.py b/backend/open_webui/models/tags.py index 64cb559547..147bb394d5 100644 --- a/backend/open_webui/models/tags.py +++ b/backend/open_webui/models/tags.py @@ -115,5 +115,45 @@ class TagTable: log.error(f"delete_tag: {e}") return False + def delete_tags_by_ids_and_user_id( + self, ids: list[str], user_id: str, db: Optional[Session] = None + ) -> bool: + """Delete all tags whose id is in *ids* for the given user, in one query.""" + if not ids: + return True + try: + with get_db_context(db) as db: + db.query(Tag).filter(Tag.id.in_(ids), Tag.user_id == user_id).delete( + synchronize_session=False + ) + db.commit() + return True + except Exception as e: + log.error(f"delete_tags_by_ids: {e}") + return False + + def ensure_tags_exist( + self, names: list[str], user_id: str, db: Optional[Session] = None + ) -> None: + """Create tag rows for any *names* that don't already exist for *user_id*.""" + if not names: + return + ids = [n.replace(" ", "_").lower() for n in names] + with get_db_context(db) as db: + existing = { + t.id + for t in db.query(Tag.id) + .filter(Tag.id.in_(ids), Tag.user_id == user_id) + .all() + } + new_tags = [ + Tag(id=tag_id, name=name, user_id=user_id) + for tag_id, name in zip(ids, names) + if tag_id not in existing + ] + if new_tags: + db.add_all(new_tags) + db.commit() + Tags = TagTable() diff --git a/backend/open_webui/routers/audio.py b/backend/open_webui/routers/audio.py index 139b64f7cf..01a857a2b6 100644 --- a/backend/open_webui/routers/audio.py +++ b/backend/open_webui/routers/audio.py @@ -639,13 +639,14 @@ def transcription_handler(request, file_path, metadata, user=None): if user and ENABLE_FORWARD_USER_INFO_HEADERS: headers = include_user_info_headers(headers, user) - r = requests.post( - url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions", - headers=headers, - files={"file": (filename, open(file_path, "rb"))}, - data=payload, - timeout=AIOHTTP_CLIENT_TIMEOUT, - ) + with open(file_path, "rb") as audio_file: + r = requests.post( + url=f"{request.app.state.config.STT_OPENAI_API_BASE_URL}/audio/transcriptions", + headers=headers, + files={"file": (filename, audio_file)}, + data=payload, + timeout=AIOHTTP_CLIENT_TIMEOUT, + ) if r.status_code == 200: # Successful transcription diff --git a/backend/open_webui/routers/chats.py b/backend/open_webui/routers/chats.py index e03cdc7ba9..69e47123f0 100644 --- a/backend/open_webui/routers/chats.py +++ b/backend/open_webui/routers/chats.py @@ -1131,9 +1131,9 @@ async def delete_chat_by_id( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - for tag in chat.meta.get("tags", []): - if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1: - Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db) + Chats.delete_orphan_tags_for_user( + chat.meta.get("tags", []), user.id, threshold=1, db=db + ) result = Chats.delete_chat_by_id(id, db=db) @@ -1153,9 +1153,9 @@ async def delete_chat_by_id( status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND, ) - for tag in chat.meta.get("tags", []): - if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 1: - Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db) + Chats.delete_orphan_tags_for_user( + chat.meta.get("tags", []), user.id, threshold=1, db=db + ) result = Chats.delete_chat_by_id_and_user_id(id, user.id, db=db) return result @@ -1317,21 +1317,13 @@ async def archive_chat_by_id( if chat: chat = Chats.toggle_chat_archive_by_id(id, db=db) - # Delete tags if chat is archived + tag_ids = chat.meta.get("tags", []) if chat.archived: - for tag_id in chat.meta.get("tags", []): - if ( - Chats.count_chats_by_tag_name_and_user_id(tag_id, user.id, db=db) - == 0 - ): - log.debug(f"deleting tag: {tag_id}") - Tags.delete_tag_by_name_and_user_id(tag_id, user.id, db=db) + # Archived chats are excluded from count — clean up orphans + Chats.delete_orphan_tags_for_user(tag_ids, user.id, db=db) else: - for tag_id in chat.meta.get("tags", []): - tag = Tags.get_tag_by_name_and_user_id(tag_id, user.id, db=db) - if tag is None: - log.debug(f"inserting tag: {tag_id}") - tag = Tags.insert_new_tag(tag_id, user.id, db=db) + # Unarchived — ensure tag rows exist + Tags.ensure_tags_exist(tag_ids, user.id, db=db) return ChatResponse(**chat.model_dump()) else: @@ -1537,11 +1529,9 @@ async def delete_all_tags_by_id( ): chat = Chats.get_chat_by_id_and_user_id(id, user.id, db=db) if chat: + old_tags = chat.meta.get("tags", []) Chats.delete_all_tags_by_id_and_user_id(id, user.id, db=db) - - for tag in chat.meta.get("tags", []): - if Chats.count_chats_by_tag_name_and_user_id(tag, user.id, db=db) == 0: - Tags.delete_tag_by_name_and_user_id(tag, user.id, db=db) + Chats.delete_orphan_tags_for_user(old_tags, user.id, db=db) return True else: diff --git a/backend/open_webui/routers/pipelines.py b/backend/open_webui/routers/pipelines.py index 20fcd75eec..ebedd3027d 100644 --- a/backend/open_webui/routers/pipelines.py +++ b/backend/open_webui/routers/pipelines.py @@ -228,22 +228,23 @@ async def upload_pipeline( headers = {"Authorization": f"Bearer {key}"} async with aiohttp.ClientSession(trust_env=True) as session: - form_data = aiohttp.FormData() - form_data.add_field( - "file", - open(file_path, "rb"), - filename=filename, - content_type="application/octet-stream", - ) + with open(file_path, "rb") as f: + form_data = aiohttp.FormData() + form_data.add_field( + "file", + f, + filename=filename, + content_type="application/octet-stream", + ) - async with session.post( - f"{url}/pipelines/upload", - headers=headers, - data=form_data, - ssl=AIOHTTP_CLIENT_SESSION_SSL, - ) as response: - response.raise_for_status() - data = await response.json() + async with session.post( + f"{url}/pipelines/upload", + headers=headers, + data=form_data, + ssl=AIOHTTP_CLIENT_SESSION_SSL, + ) as response: + response.raise_for_status() + data = await response.json() return {**data} except Exception as e: diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index 6657b34462..fab5039909 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -107,7 +107,9 @@ async def get_tools( # MCP Tool Servers for server in request.app.state.config.TOOL_SERVER_CONNECTIONS: - if server.get("type", "openapi") == "mcp": + if server.get("type", "openapi") == "mcp" and server.get("config", {}).get( + "enable" + ): server_id = server.get("info", {}).get("id") auth_type = server.get("auth_type", "none") diff --git a/backend/open_webui/routers/users.py b/backend/open_webui/routers/users.py index 87dbf1f871..f231c0e512 100644 --- a/backend/open_webui/routers/users.py +++ b/backend/open_webui/routers/users.py @@ -190,6 +190,8 @@ class SharingPermissions(BaseModel): public_prompts: bool = False tools: bool = False public_tools: bool = True + skills: bool = False + public_skills: bool = False notes: bool = False public_notes: bool = True diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index b43c56b4e6..78df66b8dc 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -99,6 +99,7 @@ else: # Timeout duration in seconds TIMEOUT_DURATION = 3 +SESSION_POOL_TIMEOUT = 120 # seconds without heartbeat before session is reaped # Dictionary to maintain the user pool @@ -147,6 +148,17 @@ if WEBSOCKET_MANAGER == "redis": aquire_func = clean_up_lock.aquire_lock renew_func = clean_up_lock.renew_lock release_func = clean_up_lock.release_lock + + session_cleanup_lock = RedisLock( + redis_url=WEBSOCKET_REDIS_URL, + lock_name=f"{REDIS_KEY_PREFIX}:session_cleanup_lock", + timeout_secs=WEBSOCKET_REDIS_LOCK_TIMEOUT, + redis_sentinels=redis_sentinels, + redis_cluster=WEBSOCKET_REDIS_CLUSTER, + ) + session_aquire_func = session_cleanup_lock.aquire_lock + session_renew_func = session_cleanup_lock.renew_lock + session_release_func = session_cleanup_lock.release_lock else: MODELS = {} @@ -154,6 +166,7 @@ else: USAGE_POOL = {} aquire_func = release_func = renew_func = lambda: True + session_aquire_func = session_release_func = session_renew_func = lambda: True YDOC_MANAGER = YdocManager( @@ -162,6 +175,31 @@ YDOC_MANAGER = YdocManager( ) +async def periodic_session_pool_cleanup(): + """Reap orphaned SESSION_POOL entries that missed heartbeats (e.g. crashed instance).""" + if not session_aquire_func(): + log.debug("Session cleanup lock held by another node. Skipping.") + return + + try: + while True: + if not session_renew_func(): + log.error("Unable to renew session cleanup lock. Exiting.") + return + + now = int(time.time()) + for sid in list(SESSION_POOL.keys()): + entry = SESSION_POOL.get(sid) + if entry and now - entry.get("last_seen_at", 0) > SESSION_POOL_TIMEOUT: + log.warning( + f"Reaping orphaned session {sid} (user {entry.get('id')})" + ) + del SESSION_POOL[sid] + await asyncio.sleep(SESSION_POOL_TIMEOUT) + finally: + session_release_func() + + async def periodic_usage_pool_cleanup(): max_retries = 2 retry_delay = random.uniform( @@ -313,15 +351,18 @@ async def connect(sid, environ, auth): user = Users.get_user_by_id(data["id"]) if user: - SESSION_POOL[sid] = user.model_dump( - exclude=[ - "profile_image_url", - "profile_banner_image_url", - "date_of_birth", - "bio", - "gender", - ] - ) + SESSION_POOL[sid] = { + **user.model_dump( + exclude=[ + "profile_image_url", + "profile_banner_image_url", + "date_of_birth", + "bio", + "gender", + ] + ), + "last_seen_at": int(time.time()), + } await sio.enter_room(sid, f"user:{user.id}") @@ -340,15 +381,18 @@ async def user_join(sid, data): if not user: return - SESSION_POOL[sid] = user.model_dump( - exclude=[ - "profile_image_url", - "profile_banner_image_url", - "date_of_birth", - "bio", - "gender", - ] - ) + SESSION_POOL[sid] = { + **user.model_dump( + exclude=[ + "profile_image_url", + "profile_banner_image_url", + "date_of_birth", + "bio", + "gender", + ] + ), + "last_seen_at": int(time.time()), + } await sio.enter_room(sid, f"user:{user.id}") @@ -366,6 +410,7 @@ async def user_join(sid, data): async def heartbeat(sid, data): user = SESSION_POOL.get(sid) if user: + SESSION_POOL[sid] = {**user, "last_seen_at": int(time.time())} Users.update_last_active_by_id(user["id"]) @@ -709,6 +754,17 @@ async def disconnect(sid): if sid in SESSION_POOL: user = SESSION_POOL[sid] del SESSION_POOL[sid] + + # Clean up USAGE_POOL entries for this session + for model_id in list(USAGE_POOL.keys()): + connections = USAGE_POOL.get(model_id) + if connections and sid in connections: + del connections[sid] + if not connections: + del USAGE_POOL[model_id] + else: + USAGE_POOL[model_id] = connections + await YDOC_MANAGER.remove_user_from_all_documents(sid) else: pass diff --git a/backend/open_webui/socket/utils.py b/backend/open_webui/socket/utils.py index 327348626a..c33af2e71d 100644 --- a/backend/open_webui/socket/utils.py +++ b/backend/open_webui/socket/utils.py @@ -118,6 +118,8 @@ class RedisDict: class YdocManager: + COMPACTION_THRESHOLD = 500 + def __init__( self, redis=None, @@ -133,10 +135,42 @@ class YdocManager: if self._redis: redis_key = f"{self._redis_key_prefix}:{document_id}:updates" await self._redis.rpush(redis_key, json.dumps(list(update))) + list_len = await self._redis.llen(redis_key) + if list_len >= self.COMPACTION_THRESHOLD: + await self._compact_updates_redis(document_id) else: if document_id not in self._updates: self._updates[document_id] = [] self._updates[document_id].append(update) + if len(self._updates[document_id]) >= self.COMPACTION_THRESHOLD: + self._compact_updates_memory(document_id) + + async def _compact_updates_redis(self, document_id: str): + """Rolling compaction: squash oldest half into one snapshot.""" + redis_key = f"{self._redis_key_prefix}:{document_id}:updates" + all_updates = await self._redis.lrange(redis_key, 0, -1) + if len(all_updates) <= 1: + return + mid = len(all_updates) // 2 + ydoc = Y.Doc() + for raw in all_updates[:mid]: + ydoc.apply_update(bytes(json.loads(raw))) + snapshot = json.dumps(list(ydoc.get_update())) + pipe = self._redis.pipeline() + pipe.delete(redis_key) + pipe.rpush(redis_key, snapshot, *all_updates[mid:]) + await pipe.execute() + + def _compact_updates_memory(self, document_id: str): + """Rolling compaction: squash oldest half into one snapshot.""" + updates = self._updates.get(document_id, []) + if len(updates) <= 1: + return + mid = len(updates) // 2 + ydoc = Y.Doc() + for update in updates[:mid]: + ydoc.apply_update(bytes(update)) + self._updates[document_id] = [ydoc.get_update()] + updates[mid:] async def get_updates(self, document_id: str) -> List[bytes]: document_id = document_id.replace(":", "_") diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 3354889e7e..7ab7537de2 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -1918,6 +1918,25 @@ async def convert_url_images_to_base64(form_data): return form_data +def load_messages_from_db(chat_id: str, message_id: str) -> Optional[list[dict]]: + """ + Load the message chain from DB up to message_id, + keeping only LLM-relevant fields (role, content, output). + """ + messages_map = Chats.get_messages_map_by_chat_id(chat_id) + if not messages_map: + return None + + db_messages = get_message_list(messages_map, message_id) + if not db_messages: + return None + + return [ + {k: v for k, v in msg.items() if k in ("role", "content", "output")} + for msg in db_messages + ] + + def process_messages_with_output(messages: list[dict]) -> list[dict]: """ Process messages with OR-aligned output items for LLM consumption. @@ -1950,6 +1969,19 @@ async def process_chat_payload(request, form_data, user, metadata, model): form_data = apply_params_to_form_data(form_data, model) log.debug(f"form_data: {form_data}") + # Load messages from DB when available — DB preserves structured 'output' items + # which the frontend strips, causing tool calls to be merged into content. + chat_id = metadata.get("chat_id") + parent_message_id = metadata.get("parent_message_id") + + if chat_id and parent_message_id and not chat_id.startswith("local:"): + db_messages = load_messages_from_db(chat_id, parent_message_id) + if db_messages: + system_message = get_system_message(form_data.get("messages", [])) + form_data["messages"] = ( + [system_message, *db_messages] if system_message else db_messages + ) + # Process messages with OR-aligned output items for clean LLM messages form_data["messages"] = process_messages_with_output(form_data.get("messages", [])) @@ -2124,23 +2156,27 @@ async def process_chat_payload(request, form_data, user, metadata, model): ) if "code_interpreter" in features and features["code_interpreter"]: - form_data["messages"] = add_or_update_user_message( - ( - request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE - if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE != "" - else DEFAULT_CODE_INTERPRETER_PROMPT - ), - form_data["messages"], - ) + # Skip XML-tag prompt injection when native FC is enabled — + # execute_code will be injected as a builtin tool instead + if metadata.get("params", {}).get("function_calling") != "native": + form_data["messages"] = add_or_update_user_message( + ( + request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE + if request.app.state.config.CODE_INTERPRETER_PROMPT_TEMPLATE + != "" + else DEFAULT_CODE_INTERPRETER_PROMPT + ), + form_data["messages"], + ) tool_ids = form_data.pop("tool_ids", None) files = form_data.pop("files", None) - # Skills: inject manifest only — model uses view_skill tool to load full content on-demand - user_skill_ids = form_data.pop("skill_ids", None) or [] - model_skill_ids = model.get("info", {}).get("meta", {}).get("skillIds", []) + # Skills + user_skill_ids = set(form_data.pop("skill_ids", None) or []) + model_skill_ids = set(model.get("info", {}).get("meta", {}).get("skillIds", [])) - all_skill_ids = list(set(user_skill_ids + model_skill_ids)) + all_skill_ids = user_skill_ids | model_skill_ids available_skills = [] if all_skill_ids: from open_webui.models.skills import Skills as SkillsModel @@ -2156,13 +2192,24 @@ async def process_chat_payload(request, form_data, user, metadata, model): and s.is_active ] - if available_skills: - manifest = "\n" - for skill in available_skills: - manifest += f"\n{skill.name}\n{skill.description or ''}\n\n" - manifest += "" + skill_descriptions = "" + for skill in available_skills: + if skill.id in user_skill_ids: + # User-selected: inject full content + form_data["messages"] = add_or_update_system_message( + f'\n{skill.content}\n', + form_data["messages"], + append=True, + ) + else: + # Model-attached: name+description only + skill_descriptions += f"\n{skill.name}\n{skill.description or ''}\n\n" + + if skill_descriptions: form_data["messages"] = add_or_update_system_message( - manifest, form_data["messages"], append=True + f"\n{skill_descriptions}", + form_data["messages"], + append=True, ) prompt = get_last_user_message(form_data["messages"]) @@ -2399,7 +2446,9 @@ async def process_chat_payload(request, form_data, user, metadata, model): { **extra_params, "__event_emitter__": event_emitter, - "__skill_ids__": [s.id for s in available_skills], + "__skill_ids__": [ + s.id for s in available_skills if s.id not in user_skill_ids + ], }, features, model, diff --git a/backend/open_webui/utils/misc.py b/backend/open_webui/utils/misc.py index 13539ca9d0..a63d425ff5 100644 --- a/backend/open_webui/utils/misc.py +++ b/backend/open_webui/utils/misc.py @@ -447,7 +447,7 @@ def openai_chat_completion_message_template( **({"tool_calls": tool_calls} if tool_calls else {}), } - template["choices"][0]["finish_reason"] = "stop" + template["choices"][0]["finish_reason"] = "tool_calls" if tool_calls else "stop" if usage: template["usage"] = usage diff --git a/backend/open_webui/utils/models.py b/backend/open_webui/utils/models.py index 8bef1591bb..86ba292c51 100644 --- a/backend/open_webui/utils/models.py +++ b/backend/open_webui/utils/models.py @@ -286,9 +286,23 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) } ] - def get_function_module_by_id(function_id): - function_module, _, _ = get_function_module_from_cache(request, function_id) - return function_module + # Batch-prefetch all needed function records to avoid N+1 queries + all_function_ids = set() + for model in models: + all_function_ids.update(model.get("action_ids", [])) + all_function_ids.update(model.get("filter_ids", [])) + all_function_ids.update(global_action_ids) + all_function_ids.update(global_filter_ids) + + functions_by_id = { + f.id: f for f in Functions.get_functions_by_ids(list(all_function_ids)) + } + + # Pre-warm the function module cache once per unique function ID. + # This ensures each function's DB freshness check runs exactly once, + # not once per (model × function) pair. + for function_id in all_function_ids: + get_function_module_from_cache(request, function_id) for model in models: action_ids = [ @@ -304,22 +318,22 @@ async def get_all_models(request, refresh: bool = False, user: UserModel = None) model["actions"] = [] for action_id in action_ids: - action_function = Functions.get_function_by_id(action_id) + action_function = functions_by_id.get(action_id) if action_function is None: raise Exception(f"Action not found: {action_id}") - function_module = get_function_module_by_id(action_id) + function_module = request.app.state.FUNCTIONS.get(action_id) model["actions"].extend( get_action_items_from_module(action_function, function_module) ) model["filters"] = [] for filter_id in filter_ids: - filter_function = Functions.get_function_by_id(filter_id) + filter_function = functions_by_id.get(filter_id) if filter_function is None: raise Exception(f"Filter not found: {filter_id}") - function_module = get_function_module_by_id(filter_id) + function_module = request.app.state.FUNCTIONS.get(filter_id) if getattr(function_module, "toggle", None): model["filters"].extend( diff --git a/backend/open_webui/utils/response.py b/backend/open_webui/utils/response.py index 9d1920651e..5a4028f11b 100644 --- a/backend/open_webui/utils/response.py +++ b/backend/open_webui/utils/response.py @@ -166,6 +166,9 @@ async def convert_streaming_response_ollama_to_openai(ollama_streaming_response) model, message_content, reasoning_content, openai_tool_calls, usage ) + if done and openai_tool_calls: + data["choices"][0]["finish_reason"] = "tool_calls" + line = f"data: {json.dumps(data)}\n\n" yield line diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index cb43cf4ef4..70b8bd3e1d 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -471,6 +471,7 @@ def get_builtin_tools( is_builtin_tool_enabled("web_search") and getattr(request.app.state.config, "ENABLE_WEB_SEARCH", False) and get_model_capability("web_search") + and features.get("web_search") ): builtin_functions.extend([search_web, fetch_url]) @@ -479,12 +480,14 @@ def get_builtin_tools( is_builtin_tool_enabled("image_generation") and getattr(request.app.state.config, "ENABLE_IMAGE_GENERATION", False) and get_model_capability("image_generation") + and features.get("image_generation") ): builtin_functions.append(generate_image) if ( is_builtin_tool_enabled("image_generation") and getattr(request.app.state.config, "ENABLE_IMAGE_EDIT", False) and get_model_capability("image_generation") + and features.get("image_generation") ): builtin_functions.append(edit_image) @@ -493,6 +496,7 @@ def get_builtin_tools( is_builtin_tool_enabled("code_interpreter") and getattr(request.app.state.config, "ENABLE_CODE_INTERPRETER", True) and get_model_capability("code_interpreter") + and features.get("code_interpreter") ): builtin_functions.append(execute_code) diff --git a/package-lock.json b/package-lock.json index b837771419..6588fbed7c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-webui", - "version": "0.8.1", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-webui", - "version": "0.8.1", + "version": "0.8.2", "dependencies": { "@azure/msal-browser": "^4.5.0", "@codemirror/lang-javascript": "^6.2.2", diff --git a/package.json b/package.json index a6e16abfa6..7b66b5f30f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-webui", - "version": "0.8.1", + "version": "0.8.2", "private": true, "scripts": { "dev": "npm run pyodide:fetch && vite dev --host", diff --git a/src/lib/components/AddToolServerModal.svelte b/src/lib/components/AddToolServerModal.svelte index 5b64b692a7..555d8ff8e3 100644 --- a/src/lib/components/AddToolServerModal.svelte +++ b/src/lib/components/AddToolServerModal.svelte @@ -250,8 +250,12 @@ const submitHandler = async () => { loading = true; - // remove trailing slash from url - url = url.replace(/\/$/, ''); + // remove trailing slash from url for non-MCP connections + // MCP servers may require a trailing slash; stripping it can cause + // 301 redirects that lose auth headers (see #21179) + if (type !== 'mcp') { + url = url.replace(/\/$/, ''); + } if (id.includes(':') || id.includes('|')) { toast.error($i18n.t('ID cannot contain ":" or "|" characters')); loading = false; diff --git a/src/lib/components/admin/Settings/Interface.svelte b/src/lib/components/admin/Settings/Interface.svelte index acd5fbf67c..cf1becbaef 100644 --- a/src/lib/components/admin/Settings/Interface.svelte +++ b/src/lib/components/admin/Settings/Interface.svelte @@ -152,7 +152,15 @@ if (taskConfig.TASK_MODEL) { const model = models.find((m) => m.id === taskConfig.TASK_MODEL); if (model) { - if (model?.access_control !== null) { + if ( + model?.access_grants && + !model.access_grants.some( + (g) => + g.principal_type === 'user' && + g.principal_id === '*' && + g.permission === 'read' + ) + ) { toast.error( $i18n.t( 'This model is not publicly available. Please select another model.' @@ -187,7 +195,15 @@ if (taskConfig.TASK_MODEL_EXTERNAL) { const model = models.find((m) => m.id === taskConfig.TASK_MODEL_EXTERNAL); if (model) { - if (model?.access_control !== null) { + if ( + model?.access_grants && + !model.access_grants.some( + (g) => + g.principal_type === 'user' && + g.principal_id === '*' && + g.permission === 'read' + ) + ) { toast.error( $i18n.t( 'This model is not publicly available. Please select another model.' diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte index 2762d3a112..9270db5c6c 100644 --- a/src/lib/components/admin/Settings/Models.svelte +++ b/src/lib/components/admin/Settings/Models.svelte @@ -160,7 +160,7 @@ name: model.name, base_model_id: null, params: {}, - access_control: {}, + access_grants: [], ...model }).catch((error) => { return null; @@ -188,7 +188,7 @@ base_model_id: null, meta: {}, params: {}, - access_control: {}, + access_grants: [], is_active: model.is_active }).catch((error) => { return null; diff --git a/src/lib/components/admin/Users/Groups/Permissions.svelte b/src/lib/components/admin/Users/Groups/Permissions.svelte index 24be0bd37a..f50170dca2 100644 --- a/src/lib/components/admin/Users/Groups/Permissions.svelte +++ b/src/lib/components/admin/Users/Groups/Permissions.svelte @@ -324,6 +324,40 @@ {/if} +
+
+
+ {$i18n.t('Skills Sharing')} +
+ +
+ {#if defaultPermissions?.sharing?.skills && !permissions.sharing.skills} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+ + {#if permissions.sharing.skills} +
+
+
+ {$i18n.t('Skills Public Sharing')} +
+ +
+ {#if defaultPermissions?.sharing?.public_skills && !permissions.sharing.public_skills} +
+
+ {$i18n.t('This is a default user permission and will remain enabled.')} +
+
+ {/if} +
+ {/if} +
diff --git a/src/lib/components/chat/Messages/Citations/CitationModal.svelte b/src/lib/components/chat/Messages/Citations/CitationModal.svelte index a90abd2590..3907b103cd 100644 --- a/src/lib/components/chat/Messages/Citations/CitationModal.svelte +++ b/src/lib/components/chat/Messages/Citations/CitationModal.svelte @@ -2,6 +2,7 @@ import { getContext, onMount, tick } from 'svelte'; import Modal from '$lib/components/common/Modal.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte'; + import Markdown from '$lib/components/chat/Messages/Markdown.svelte'; import { WEBUI_API_BASE_URL } from '$lib/constants'; import { settings } from '$lib/stores'; @@ -10,6 +11,9 @@ const i18n = getContext('i18n'); + const CONTENT_PREVIEW_LIMIT = 10000; + let expandedDocs: Set = new Set(); + export let show = false; export let citation; export let showPercentage = false; @@ -35,6 +39,7 @@ } $: if (citation) { + expandedDocs = new Set(); mergedDocuments = citation.document?.map((c, i) => { return { source: citation.source, @@ -216,9 +221,36 @@ title={$i18n.t('Content')} > {:else} -
{document.document
-										.trim()
-										.replace(/\n\n+/g, '\n\n')}
+ {@const rawContent = document.document.trim().replace(/\n\n+/g, '\n\n')} + {@const isTruncated = + ($settings?.renderMarkdownInPreviews ?? true) && + rawContent.length > CONTENT_PREVIEW_LIMIT && + !expandedDocs.has(documentIdx)} + {#if $settings?.renderMarkdownInPreviews ?? true} +
+ +
+ {#if isTruncated} + + {/if} + {:else} +
{rawContent}
+ {/if} {/if}
diff --git a/src/lib/components/chat/Messages/CodeBlock.svelte b/src/lib/components/chat/Messages/CodeBlock.svelte index c236671f5f..f575723979 100644 --- a/src/lib/components/chat/Messages/CodeBlock.svelte +++ b/src/lib/components/chat/Messages/CodeBlock.svelte @@ -417,13 +417,13 @@
{#if ['mermaid', 'vega', 'vega-lite'].includes(lang)} {#if renderHTML} @@ -441,13 +441,13 @@ {/if} {:else}
{lang}
diff --git a/src/lib/components/chat/Messages/ResponseMessage.svelte b/src/lib/components/chat/Messages/ResponseMessage.svelte index 47c1b51422..ae55ca3495 100644 --- a/src/lib/components/chat/Messages/ResponseMessage.svelte +++ b/src/lib/components/chat/Messages/ResponseMessage.svelte @@ -365,8 +365,13 @@ await tick(); + const messagesContainer = document.getElementById('messages-container'); + const savedScrollTop = messagesContainer?.scrollTop; + editTextAreaElement.style.height = ''; editTextAreaElement.style.height = `${editTextAreaElement.scrollHeight}px`; + + if (messagesContainer) messagesContainer.scrollTop = savedScrollTop; }; const editMessageConfirmHandler = async () => { @@ -697,8 +702,13 @@ class=" bg-transparent outline-hidden w-full resize-none" bind:value={editedContent} on:input={(e) => { + const messagesContainer = document.getElementById('messages-container'); + const savedScrollTop = messagesContainer?.scrollTop; + e.target.style.height = ''; e.target.style.height = `${e.target.scrollHeight}px`; + + if (messagesContainer) messagesContainer.scrollTop = savedScrollTop; }} on:keydown={(e) => { if (e.key === 'Escape') { diff --git a/src/lib/components/chat/Messages/UserMessage.svelte b/src/lib/components/chat/Messages/UserMessage.svelte index 417c6737f6..f21d91bfbc 100644 --- a/src/lib/components/chat/Messages/UserMessage.svelte +++ b/src/lib/components/chat/Messages/UserMessage.svelte @@ -50,6 +50,7 @@ let editedFiles = []; let messageEditTextAreaElement: HTMLTextAreaElement; + let editScrollContainer: HTMLDivElement; let message = JSON.parse(JSON.stringify(history.messages[messageId])); $: if (history.messages) { @@ -73,10 +74,14 @@ await tick(); if (messageEditTextAreaElement) { + const messagesContainer = document.getElementById('messages-container'); + const savedScrollTop = messagesContainer?.scrollTop; + messageEditTextAreaElement.style.height = ''; messageEditTextAreaElement.style.height = `${messageEditTextAreaElement.scrollHeight}px`; - messageEditTextAreaElement?.focus(); + if (messagesContainer) messagesContainer.scrollTop = savedScrollTop; + messageEditTextAreaElement?.focus({ preventScroll: true }); } }; @@ -283,15 +288,22 @@
{/if} -
+