diff --git a/backend/open_webui/config.py b/backend/open_webui/config.py index 9c44a90994..e20b20ffe3 100644 --- a/backend/open_webui/config.py +++ b/backend/open_webui/config.py @@ -2126,6 +2126,8 @@ AUTOMATION_MIN_INTERVAL = os.getenv('AUTOMATION_MIN_INTERVAL', '') AUTOMATION_AUTH_TOKEN_EXPIRES_IN = os.getenv('AUTOMATION_AUTH_TOKEN_EXPIRES_IN', '1h') +ENABLE_COMMENTS = os.getenv('ENABLE_COMMENTS', 'False').lower() == 'true' + ENABLE_NOTES = os.getenv('ENABLE_NOTES', 'True').lower() == 'true' ENABLE_USER_STATUS = os.getenv('ENABLE_USER_STATUS', 'True').lower() == 'true' @@ -3256,6 +3258,7 @@ DEFAULT_CONFIG = { 'automations.min_interval': AUTOMATION_MIN_INTERVAL, 'automations.auth_token_expires_in': AUTOMATION_AUTH_TOKEN_EXPIRES_IN, 'notes.enable': ENABLE_NOTES, + 'comments.enable': ENABLE_COMMENTS, 'users.enable_status': ENABLE_USER_STATUS, 'evaluation.arena.enable': ENABLE_EVALUATION_ARENA_MODELS, 'evaluation.arena.models': EVALUATION_ARENA_MODELS, diff --git a/backend/open_webui/events.py b/backend/open_webui/events.py index b5373f386b..8236e2e992 100644 --- a/backend/open_webui/events.py +++ b/backend/open_webui/events.py @@ -540,6 +540,12 @@ class EventDefinitions(BaseModel): TOOL_UPDATED: EventDefinition = EventDefinition( name='tool.updated', description='A tool was updated.', message='Tool updated' ) + TOOL_ENABLED: EventDefinition = EventDefinition( + name='tool.enabled', description='A tool was enabled.', message='Tool enabled' + ) + TOOL_DISABLED: EventDefinition = EventDefinition( + name='tool.disabled', description='A tool was disabled.', message='Tool disabled' + ) TOOL_DELETED: EventDefinition = EventDefinition( name='tool.deleted', description='A tool was deleted.', message='Tool deleted' ) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index a1abf80e79..e7410a1c03 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -158,6 +158,7 @@ from open_webui.routers import ( calendar, channels, chats, + comments, configs, evaluations, files, @@ -894,6 +895,7 @@ app.include_router(users.router, prefix='/api/v1/users', tags=['users']) app.include_router(apps.router, prefix='/api/v1/apps', tags=['apps']) app.include_router(channels.router, prefix='/api/v1/channels', tags=['channels']) app.include_router(chats.router, prefix='/api/v1/chats', tags=['chats']) +app.include_router(comments.router, prefix='/api/v1/comments', tags=['comments']) app.include_router(notes.router, prefix='/api/v1/notes', tags=['notes']) app.include_router(search.router, prefix='/api/v1/search', tags=['search']) @@ -2355,6 +2357,7 @@ async def get_app_config(request: Request): 'calendar.enable', 'automations.enable', 'notes.enable', + 'comments.enable', 'chat.context_compaction.enable', 'chat.tool_permissions.enable', 'web.search.enable', @@ -2462,6 +2465,7 @@ async def get_app_config(request: Request): 'enable_calendar': config.get('calendar.enable'), 'enable_automations': config.get('automations.enable'), 'enable_notes': config.get('notes.enable'), + 'enable_comments': config.get('comments.enable'), 'enable_context_compaction': config.get('chat.context_compaction.enable'), 'enable_tool_permissions': config.get('chat.tool_permissions.enable'), 'enable_web_search': config.get('web.search.enable'), diff --git a/backend/open_webui/migrations/env.py b/backend/open_webui/migrations/env.py index 0ddfc609ee..62a73d4f4a 100644 --- a/backend/open_webui/migrations/env.py +++ b/backend/open_webui/migrations/env.py @@ -14,6 +14,7 @@ from open_webui.models.auths import Auth from open_webui.models.calendar import Calendar, CalendarEvent, CalendarEventAttendee # noqa: F401 from open_webui.models.chat_messages import ChatMessage # noqa: F401 from open_webui.models.chats import Chat # noqa: F401 +from open_webui.models.comments import Comment, CommentReaction # noqa: F401 from sqlalchemy import create_engine, engine_from_config, inspect, pool alembic_config = alembic.context.config diff --git a/backend/open_webui/migrations/versions/6f8c2a91d4e7_add_tool_is_active.py b/backend/open_webui/migrations/versions/6f8c2a91d4e7_add_tool_is_active.py new file mode 100644 index 0000000000..470cf20e30 --- /dev/null +++ b/backend/open_webui/migrations/versions/6f8c2a91d4e7_add_tool_is_active.py @@ -0,0 +1,21 @@ +"""Add a persistent enabled state to tools. + +Revision ID: 6f8c2a91d4e7 +Revises: c41f8d9e2a10 +""" + +import sqlalchemy as sa +from alembic import op + +revision = '6f8c2a91d4e7' +down_revision = 'c41f8d9e2a10' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('tool', sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.true())) + + +def downgrade(): + op.drop_column('tool', 'is_active') diff --git a/backend/open_webui/migrations/versions/c41f8d9e2a10_add_resource_comments.py b/backend/open_webui/migrations/versions/c41f8d9e2a10_add_resource_comments.py new file mode 100644 index 0000000000..b178580abd --- /dev/null +++ b/backend/open_webui/migrations/versions/c41f8d9e2a10_add_resource_comments.py @@ -0,0 +1,50 @@ +"""Add resource comments and reactions. + +Revision ID: c41f8d9e2a10 +Revises: 0bd252b206fb +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'c41f8d9e2a10' +down_revision = '0bd252b206fb' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'comment', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('user_id', sa.Text(), nullable=True), + sa.Column('resource_type', sa.Text(), nullable=False), + sa.Column('resource_id', sa.Text(), nullable=False), + sa.Column('parent_id', sa.Text(), nullable=True), + sa.Column('reply_to_id', sa.Text(), nullable=True), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('target_type', sa.Text(), nullable=True), + sa.Column('target_id', sa.Text(), nullable=True), + sa.Column('meta', sa.JSON(), nullable=True), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.Column('updated_at', sa.BigInteger(), nullable=False), + sa.Column('deleted_at', sa.BigInteger(), nullable=True), + ) + op.create_index('ix_comment_resource', 'comment', ['resource_type', 'resource_id', 'parent_id', 'created_at', 'id']) + op.create_index('ix_comment_parent', 'comment', ['parent_id', 'created_at', 'id']) + op.create_index('ix_comment_target', 'comment', ['resource_type', 'resource_id', 'target_type', 'target_id']) + op.create_table( + 'comment_reaction', + sa.Column('id', sa.Text(), primary_key=True), + sa.Column('user_id', sa.Text(), nullable=False), + sa.Column('comment_id', sa.Text(), nullable=False), + sa.Column('name', sa.Text(), nullable=False), + sa.Column('created_at', sa.BigInteger(), nullable=False), + sa.UniqueConstraint('comment_id', 'user_id', 'name', name='uq_comment_reaction'), + ) + op.create_index('ix_comment_reaction_comment_id', 'comment_reaction', ['comment_id']) + + +def downgrade(): + op.drop_table('comment_reaction') + op.drop_table('comment') diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index 3391c000eb..f5977e8ec2 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -12,6 +12,7 @@ from typing import Any, Literal # local imports from open_webui.env import ENABLE_ADMIN_CHAT_ACCESS +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, JSONField, get_async_db, get_async_db_context from open_webui.models.access_grants import AccessGrants from open_webui.models.automations import AutomationRun @@ -2814,6 +2815,7 @@ class ChatTable: async with get_async_db_context(db) as session: await session.execute(update(AutomationRun).filter_by(chat_id=id).values(chat_id=None)) await session.execute(delete(ChatMessage).filter_by(chat_id=id)) + await delete_resource_comments(session, 'chat', select(Chat.id).filter_by(id=id)) await session.execute(delete(Chat).filter_by(id=id)) await session.commit() @@ -2826,6 +2828,7 @@ class ChatTable: async with get_async_db_context(db) as session: await session.execute(update(AutomationRun).filter_by(chat_id=id).values(chat_id=None)) await session.execute(delete(ChatMessage).filter_by(chat_id=id)) + await delete_resource_comments(session, 'chat', select(Chat.id).filter_by(id=id, user_id=user_id)) await session.execute(delete(Chat).filter_by(id=id, user_id=user_id)) await session.commit() @@ -2847,6 +2850,7 @@ class ChatTable: await session.execute( delete(ChatMessage).filter(ChatMessage.chat_id.in_(select(Chat.id).filter_by(user_id=user_id))) ) + await delete_resource_comments(session, 'chat', select(Chat.id).filter_by(user_id=user_id)) await session.execute(delete(Chat).filter_by(user_id=user_id)) await session.commit() @@ -2866,7 +2870,9 @@ class ChatTable: update(AutomationRun).filter(AutomationRun.chat_id.in_(chat_ids_stmt)).values(chat_id=None) ) await session.execute(delete(ChatMessage).filter(ChatMessage.chat_id.in_(chat_ids_stmt))) + await delete_resource_comments(session, 'chat', chat_ids_stmt) await session.execute(delete(SharedChatTable).filter(SharedChatTable.chat_id.in_(chat_ids_stmt))) + await delete_resource_comments(session, 'chat', select(Chat.id).filter_by(user_id=user_id, folder_id=folder_id)) await session.execute(delete(Chat).filter_by(user_id=user_id, folder_id=folder_id)) await session.commit() @@ -2897,6 +2903,7 @@ class ChatTable: try: async with get_async_db_context(db) as session: # Delete shared_chat rows for this user's chats + await delete_resource_comments(session, 'shared_chat', select(SharedChatTable.id).filter_by(user_id=user_id)) await session.execute(delete(SharedChatTable).filter_by(user_id=user_id)) # Clear share_id on all of this user's chats diff --git a/backend/open_webui/models/comments.py b/backend/open_webui/models/comments.py new file mode 100644 index 0000000000..f9670d308a --- /dev/null +++ b/backend/open_webui/models/comments.py @@ -0,0 +1,166 @@ +"""Resource discussions. All operations use the caller's transaction.""" + +from typing import Literal +from uuid import UUID + +from open_webui.internal.db import Base +from open_webui.models.users import User +from pydantic import BaseModel, ConfigDict, Field, field_validator +from sqlalchemy import JSON, BigInteger, Column, Index, Text, UniqueConstraint, case, delete, func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +ResourceType = Literal['model', 'knowledge', 'prompt', 'skill', 'tool', 'note', 'chat'] + + +class Comment(Base): + __tablename__ = 'comment' + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=True) + resource_type = Column(Text, nullable=False) + resource_id = Column(Text, nullable=False) + parent_id = Column(Text, nullable=True) + reply_to_id = Column(Text, nullable=True) + content = Column(Text, nullable=False) + target_type = Column(Text, nullable=True) + target_id = Column(Text, nullable=True) + meta = Column(JSON, nullable=True) + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + deleted_at = Column(BigInteger, nullable=True) + __table_args__ = ( + Index('ix_comment_resource', 'resource_type', 'resource_id', 'parent_id', 'created_at', 'id'), + Index('ix_comment_parent', 'parent_id', 'created_at', 'id'), + Index('ix_comment_target', 'resource_type', 'resource_id', 'target_type', 'target_id'), + ) + + +class CommentReaction(Base): + __tablename__ = 'comment_reaction' + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + comment_id = Column(Text, nullable=False, index=True) + name = Column(Text, nullable=False) + created_at = Column(BigInteger, nullable=False) + __table_args__ = (UniqueConstraint('comment_id', 'user_id', 'name', name='uq_comment_reaction'),) + + +class CommentAnchor(BaseModel): + model_config = ConfigDict(extra='forbid') + quote: str = Field(min_length=1, max_length=10000) + prefix: str = Field(default='', max_length=64) + suffix: str = Field(default='', max_length=64) + start: int = Field(ge=0) + end: int = Field(ge=1) + surface_id: str = Field(max_length=256) + source: str = Field(pattern=r'^[0-9a-f]{64}$') + content_hash: str = Field(pattern=r'^[0-9a-f]{64}$') + + +class CommentContentForm(BaseModel): + model_config = ConfigDict(extra='forbid') + content: str = Field(min_length=1, max_length=20000) + + @field_validator('content') + @classmethod + def nonempty(cls, value): + if not value.strip(): + raise ValueError('Comment cannot be empty') + return value + + +class CommentForm(CommentContentForm): + temp_id: UUID | None = None + resource_type: ResourceType + resource_id: str = Field(min_length=1, max_length=512) + target_type: Literal['note_page', 'chat_message'] | None = None + target_id: str | None = Field(default=None, max_length=256) + anchor: CommentAnchor | None = None + + +class CommentReplyForm(CommentContentForm): + temp_id: UUID | None = None + reply_to_id: str | None = None + + +class CommentUpdateForm(CommentContentForm): + etag: str = Field(pattern=r'^\d{1,20}$') + + +async def comment_responses(db: AsyncSession, comments: list[Comment], user, owner_id: str) -> list[dict]: + if not comments: + return [] + ids = [c.id for c in comments] + authors = { + u.id: {'id': u.id, 'name': u.name, 'profile_image_url': u.profile_image_url} + for u in (await db.scalars(select(User).where(User.id.in_({c.user_id for c in comments if c.user_id})))).all() + } + counts = { + parent_id: (count, latest) + for parent_id, count, latest in ( + await db.execute( + select(Comment.parent_id, func.count(), func.max(Comment.created_at)) + .where(Comment.parent_id.in_(ids)) + .group_by(Comment.parent_id) + ) + ).all() + } + reactions = {} + for comment_id, name, count, mine in ( + await db.execute( + select( + CommentReaction.comment_id, + CommentReaction.name, + func.count(), + func.max( + # CASE is portable across PostgreSQL and SQLite (boolean MAX isn't). + case((CommentReaction.user_id == user.id, 1), else_=0) + ), + ) + .where(CommentReaction.comment_id.in_(ids)) + .group_by(CommentReaction.comment_id, CommentReaction.name) + ) + ).all(): + reactions.setdefault(comment_id, []).append({'name': name, 'count': count, 'reacted': bool(mine)}) + return [ + { + **{c.name: getattr(comment, c.name) for c in Comment.__table__.columns}, + 'etag': str(comment.updated_at), + 'user': authors.get(comment.user_id), + 'reply_count': counts.get(comment.id, (0, None))[0], + 'latest_reply_at': counts.get(comment.id, (0, None))[1], + 'reactions': reactions.get(comment.id, []), + 'can_edit': not comment.deleted_at and comment.user_id == user.id, + 'can_delete': not comment.deleted_at + and (comment.user_id == user.id or owner_id == user.id or user.role == 'admin'), + } + for comment in comments + ] + + +async def delete_resource_comments(db: AsyncSession, resource_type: str, resource_ids): + """Lock resources before cleanup so concurrent comment creation cannot leave orphans.""" + from open_webui.utils.comments import resource_models + + # Retain cleanup for legacy snapshot comments when their shared chat is deleted. + if resource_type == 'shared_chat': + from open_webui.models.shared_chats import SharedChat + + model = SharedChat + else: + model = resource_models()[resource_type] + ids = list((await db.scalars(select(model.id).where(model.id.in_(resource_ids)).with_for_update())).all()) + if not ids: + return + if resource_type == 'chat': + from open_webui.models.shared_chats import SharedChat + + await delete_resource_comments(db, 'shared_chat', select(SharedChat.id).where(SharedChat.chat_id.in_(ids))) + comment_ids = select(Comment.id).where(Comment.resource_type == resource_type, Comment.resource_id.in_(ids)) + await db.execute(delete(CommentReaction).where(CommentReaction.comment_id.in_(comment_ids))) + await db.execute(delete(Comment).where(Comment.resource_type == resource_type, Comment.resource_id.in_(ids))) + + +async def cleanup_comments_for_deleted_user(db: AsyncSession, user_id: str): + """Remove reactions and clear authorship, preserving comment text and threads.""" + await db.execute(delete(CommentReaction).where(CommentReaction.user_id == user_id)) + await db.execute(update(Comment).where(Comment.user_id == user_id).values(user_id=None)) diff --git a/backend/open_webui/models/knowledge.py b/backend/open_webui/models/knowledge.py index 8e52cea70d..b1963f2200 100644 --- a/backend/open_webui/models/knowledge.py +++ b/backend/open_webui/models/knowledge.py @@ -4,6 +4,7 @@ import uuid from typing import Optional from open_webui.config import RAG_FILE_CONTENT_SEARCH_MAX_CHARS +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.config import Config @@ -859,6 +860,7 @@ class KnowledgeTable: try: async with get_async_db_context(db) as db: await AccessGrants.revoke_all_access('knowledge', id, db=db) + await delete_resource_comments(db, 'knowledge', [id]) await db.execute(delete(Knowledge).filter_by(id=id)) await db.commit() return True @@ -872,6 +874,7 @@ class KnowledgeTable: knowledge_ids = [row[0] for row in result.all()] for knowledge_id in knowledge_ids: await AccessGrants.revoke_all_access('knowledge', knowledge_id, db=db) + await delete_resource_comments(db, 'knowledge', knowledge_ids) await db.execute(delete(Knowledge)) await db.commit() diff --git a/backend/open_webui/models/models.py b/backend/open_webui/models/models.py index 91c0e85461..b383572c42 100755 --- a/backend/open_webui/models/models.py +++ b/backend/open_webui/models/models.py @@ -7,6 +7,7 @@ from copy import deepcopy from typing import Annotated, Any, Literal from fastapi import HTTPException +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants from open_webui.models.groups import Groups @@ -714,6 +715,7 @@ class ModelsTable: return None async def _delete_models(self, session, ids): + await delete_resource_comments(session, 'model', ids) await session.execute( delete(AccessGrant).where(AccessGrant.resource_type == 'model', AccessGrant.resource_id.in_(ids)) ) diff --git a/backend/open_webui/models/notes.py b/backend/open_webui/models/notes.py index b73457646e..801aaf5352 100644 --- a/backend/open_webui/models/notes.py +++ b/backend/open_webui/models/notes.py @@ -8,6 +8,7 @@ from typing import Literal, Optional import markdown from bs4 import BeautifulSoup +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups @@ -842,6 +843,7 @@ class NoteTable: try: async with get_async_db_context(db) as db: await AccessGrants.revoke_all_access('note', id, db=db) + await delete_resource_comments(db, 'note', [id]) await db.execute(delete(PinnedNote).filter(PinnedNote.note_id == id)) await db.execute(delete(Note).filter(Note.id == id)) await db.commit() diff --git a/backend/open_webui/models/prompts.py b/backend/open_webui/models/prompts.py index 2c50b74d7e..797784b94b 100644 --- a/backend/open_webui/models/prompts.py +++ b/backend/open_webui/models/prompts.py @@ -9,6 +9,7 @@ from typing import Optional log = logging.getLogger(__name__) +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.groups import Groups @@ -644,6 +645,7 @@ class PromptsTable: await PromptHistories.delete_history_by_prompt_id(prompt.id, db=session) await AccessGrants.revoke_all_access('prompt', prompt.id, db=session) + await delete_resource_comments(session, 'prompt', [prompt.id]) await session.delete(prompt) await session.commit() return True @@ -661,6 +663,7 @@ class PromptsTable: await PromptHistories.delete_history_by_prompt_id(prompt.id, db=session) await AccessGrants.revoke_all_access('prompt', prompt.id, db=session) + await delete_resource_comments(session, 'prompt', [prompt.id]) await session.delete(prompt) await session.commit() return True diff --git a/backend/open_webui/models/shared_chats.py b/backend/open_webui/models/shared_chats.py index 8959d6c771..157c00d21d 100644 --- a/backend/open_webui/models/shared_chats.py +++ b/backend/open_webui/models/shared_chats.py @@ -3,6 +3,7 @@ import time import uuid from typing import Literal, Optional +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, JSONField, get_async_db_context from pydantic import BaseModel, ConfigDict from sqlalchemy import JSON, BigInteger, Column, ForeignKey, Text, delete, select @@ -214,6 +215,7 @@ class SharedChatsTable: """Delete a shared chat by its share token.""" try: async with get_async_db_context(db) as db: + await delete_resource_comments(db, 'shared_chat', select(SharedChat.id).filter_by(id=share_id)) await db.execute(delete(SharedChat).filter_by(id=share_id)) await db.commit() return True @@ -224,6 +226,7 @@ class SharedChatsTable: """Delete all shared chats for a given original chat.""" try: async with get_async_db_context(db) as db: + await delete_resource_comments(db, 'shared_chat', select(SharedChat.id).filter_by(chat_id=chat_id)) await db.execute(delete(SharedChat).filter_by(chat_id=chat_id)) await db.commit() return True @@ -234,6 +237,7 @@ class SharedChatsTable: """Delete all shared chats created by a user.""" try: async with get_async_db_context(db) as db: + await delete_resource_comments(db, 'shared_chat', select(SharedChat.id).filter_by(user_id=user_id)) await db.execute(delete(SharedChat).filter_by(user_id=user_id)) await db.commit() return True diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py index ffe58336e2..1a2a2e7ace 100644 --- a/backend/open_webui/models/skills.py +++ b/backend/open_webui/models/skills.py @@ -3,6 +3,7 @@ import time from typing import Optional from fastapi import HTTPException +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, get_async_db_context from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants from open_webui.models.groups import Groups @@ -499,6 +500,7 @@ class SkillsTable: async def delete_skill_by_id(self, id: str, db: Optional[AsyncSession] = None) -> bool: try: async with get_async_db_context(db) as db: + await delete_resource_comments(db, 'skill', [id]) await db.execute(delete(AccessGrant).filter_by(resource_type='skill', resource_id=id)) await db.execute(delete(SkillHistory).filter_by(skill_id=id)) await db.execute(delete(Skill).filter_by(id=id)) diff --git a/backend/open_webui/models/tools.py b/backend/open_webui/models/tools.py index 73ed8f0991..163bd8d17e 100644 --- a/backend/open_webui/models/tools.py +++ b/backend/open_webui/models/tools.py @@ -8,6 +8,7 @@ import time from fastapi import HTTPException # local imports +from open_webui.models.comments import delete_resource_comments from open_webui.internal.db import Base, JSONField, get_async_db_context from open_webui.models.access_grants import AccessGrant, AccessGrantModel, AccessGrants from open_webui.models.groups import Groups @@ -15,7 +16,7 @@ from open_webui.models.tool_history import ToolHistories, ToolHistory, tool_snap from open_webui.models.users import UserResponse, Users from open_webui.utils.valves import decrypt_valves, encrypt_valves, validate_valves from pydantic import BaseModel, ConfigDict, Field -from sqlalchemy import BigInteger, Column, String, Text, delete, select, update +from sqlalchemy import BigInteger, Boolean, Column, String, Text, delete, select, true, update from sqlalchemy.ext.asyncio import AsyncSession log = logging.getLogger(__name__) @@ -32,6 +33,7 @@ class Tool(Base): # database table definition specs = Column(JSONField) # OpenAPI-style function specs meta = Column(JSONField) # description, manifest, etc. valves = Column(JSONField) # admin-configurable runtime parameters + is_active = Column(Boolean, nullable=False, default=True, server_default=true()) updated_at = Column(BigInteger, nullable=False) # modification timestamp created_at = Column(BigInteger, index=True) # creation timestamp @@ -54,6 +56,7 @@ class ToolModel(BaseModel): content: str | None = None specs: list[dict] meta: ToolMeta + is_active: bool = True access_grants: list[AccessGrantModel] = Field(default_factory=list) updated_at: int # timestamp in epoch @@ -77,6 +80,7 @@ class ToolResponse(BaseModel): user_id: str | None = None # may be null for legacy/malformed records name: str meta: ToolMeta + is_active: bool = True access_grants: list[AccessGrantModel] = Field(default_factory=list) updated_at: int # timestamp in epoch created_at: int # timestamp in epoch @@ -215,6 +219,26 @@ class ToolsTable: for tool in tools } + async def is_tool_active(self, id: str, db: AsyncSession | None = None) -> bool: + async with get_async_db_context(db) as session: + return (await session.scalar(select(Tool.is_active).where(Tool.id == id))) is True + + async def toggle_tool_by_id(self, id: str, db: AsyncSession | None = None) -> ToolModel | None: + async with get_async_db_context(db) as session: + try: + tool = await self._lock_tool(session, id) + if tool is None: + return None + tool.is_active = not tool.is_active + tool.updated_at = int(time.time()) + await session.flush() + result = await self._to_tool_model(tool, db=session) + await session.commit() + return result + except Exception: + await session.rollback() + raise + async def get_tools( self, defer_content: bool = False, @@ -222,6 +246,7 @@ class ToolsTable: user_id: str | None = None, user_group_ids: set[str] | None = None, permission: str = 'read', + active_only: bool = False, ) -> list[ToolUserModel]: async with get_async_db_context(db) as db: # Skip Tool.content (plugin source, potentially large) via a @@ -234,6 +259,7 @@ class ToolsTable: Tool.name, Tool.specs, Tool.meta, + Tool.is_active, Tool.updated_at, Tool.created_at, ) @@ -241,6 +267,9 @@ class ToolsTable: else select(Tool) ).order_by(Tool.updated_at.desc()) + if active_only: + stmt = stmt.where(Tool.is_active.is_(True)) + if user_id is not None: if user_group_ids is None: user_group_ids = { @@ -396,6 +425,7 @@ class ToolsTable: async def delete_tool_by_id(self, id, db=None): async with get_async_db_context(db) as session: try: + await delete_resource_comments(session, 'tool', [id]) await self._lock_tool(session, id) await session.execute(delete(AccessGrant).filter_by(resource_type='tool', resource_id=id)) await session.execute(delete(ToolHistory).filter_by(tool_id=id)) diff --git a/backend/open_webui/models/users.py b/backend/open_webui/models/users.py index 0aa6a6f167..355de84019 100644 --- a/backend/open_webui/models/users.py +++ b/backend/open_webui/models/users.py @@ -858,6 +858,9 @@ class UsersTable: deleted_chats = await Chats.delete_chats_by_user_id(id, db=session) if not deleted_chats: return False # chats deletion failed + from open_webui.models.comments import cleanup_comments_for_deleted_user + + await cleanup_comments_for_deleted_user(session, id) await session.execute(delete(User).where(User.id == id)) await session.commit() return True diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index d9b0d01d52..700ce97f47 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -128,6 +128,7 @@ ADMIN_CONFIG_KEYS = { 'AUTOMATION_MIN_INTERVAL': 'automations.min_interval', 'ENABLE_AUTOMATIONS': 'automations.enable', 'ENABLE_CHANNELS': 'channels.enable', + 'ENABLE_COMMENTS': 'comments.enable', 'ENABLE_ENCRYPTED_CHANNELS': 'channels.encryption.enable', 'ENABLE_APPS': 'apps.enable', 'CHANNEL_MODEL_RESPONSE_MODE': 'channels.model_response_mode', @@ -1188,6 +1189,7 @@ class AdminConfig(MfaConfigForm): AUTOMATION_MIN_INTERVAL: int | str | None = None ENABLE_AUTOMATIONS: bool ENABLE_CHANNELS: bool + ENABLE_COMMENTS: bool = False ENABLE_ENCRYPTED_CHANNELS: bool = False ENABLE_APPS: bool = False CHANNEL_MODEL_RESPONSE_MODE: str = 'thread' @@ -1241,6 +1243,8 @@ async def update_admin_config(request: Request, form_data: AdminConfig, user=Dep updates.pop('ui.enable_login_form', None) if 'ENABLE_ENCRYPTED_CHANNELS' not in form_data.model_fields_set: updates.pop('channels.encryption.enable', None) + if 'ENABLE_COMMENTS' not in form_data.model_fields_set: + updates.pop('comments.enable', None) if 'ENABLE_APPS' not in form_data.model_fields_set: updates.pop('apps.enable', None) if 'I18N' not in form_data.model_fields_set: diff --git a/backend/open_webui/routers/comments.py b/backend/open_webui/routers/comments.py new file mode 100644 index 0000000000..0c480cd36d --- /dev/null +++ b/backend/open_webui/routers/comments.py @@ -0,0 +1,382 @@ +import logging +import time +from uuid import UUID, uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from open_webui.internal.db import get_async_session +from open_webui.models.comments import ( + Comment, + CommentForm, + CommentReaction, + CommentReplyForm, + CommentUpdateForm, + ResourceType, + comment_responses, +) +from open_webui.models.users import User, UserModel +from open_webui.utils.auth import get_verified_user +from open_webui.utils.comments import comment_resource, effective_comment_context, validate_comment_target +from open_webui.utils.rate_limit import RateLimiter +from pydantic import BaseModel, Field +from sqlalchemy import and_, delete, func, or_, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +router = APIRouter() +write_limiter = RateLimiter(30, 60) +reaction_limiter = RateLimiter(120, 60) + + +async def limit_write(request, user, limiter=write_limiter): + if await limiter.is_limited(getattr(request.app.state, 'redis', None), f'comments:{limiter.limit}:{user.id}'): + raise HTTPException(429, 'Too many requests') + + +async def changed(resource_type, resource_id): + from open_webui.socket.comments import comment_room + from open_webui.socket.main import sio + + # No comment text, author, or activity IDs are sent to possibly stale subscribers. + try: + await sio.emit( + 'comments:changed', + {'resource_type': resource_type, 'resource_id': resource_id}, + room=comment_room(resource_type, resource_id), + ) + except Exception: + logging.getLogger(__name__).exception('Could not broadcast comment change') + + +async def accessible_comment(id, user, db, *, lock=False, request=None): + comment = await db.get(Comment, id) + if comment is None: + raise HTTPException(404, 'Comment not found') + resource = await comment_resource(comment.resource_type, comment.resource_id, user, db, lock=lock, request=request) + if lock: + comment = await db.scalar( + select(Comment).where(Comment.id == id).with_for_update().execution_options(populate_existing=True) + ) + if comment is None: + raise HTTPException(404, 'Comment not found') + return comment, resource + + +@router.get('/context') +async def context( + request: Request, + resource_type: ResourceType, + resource_id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + return await effective_comment_context(resource_type, resource_id, user, db, request=request) + + +@router.get('/mentions') +async def mentions( + request: Request, + resource_type: ResourceType, + resource_id: str, + query: str = Query('', max_length=100), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await comment_resource(resource_type, resource_id, user, db, request=request) + # Bound directory work even in deployments with many thousands of accounts. + candidates = ( + await db.scalars( + select(User) + .where( + User.role.in_(['user', 'admin']), + User.name.ilike( + '%' + query.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_') + '%', escape='\\' + ), + ) + .order_by(User.name, User.id) + .limit(40) + ) + ).all() + result = [] + for candidate in candidates: + try: + await comment_resource(resource_type, resource_id, UserModel.model_validate(candidate), db, request=request) + except HTTPException: + continue + result.append({'id': candidate.id, 'name': candidate.name}) + if len(result) == 20: + break + return result + + +async def list_comments(db, user, resource, resource_type, resource_id, parent_id, cursor, limit): + query = select(Comment).where( + Comment.resource_type == resource_type, Comment.resource_id == resource_id, Comment.parent_id == parent_id + ) + if cursor: + try: + timestamp, id = cursor.split(':', 1) + timestamp = int(timestamp) + UUID(id) + if not 0 <= timestamp <= 2**63 - 1: + raise ValueError() + except (ValueError, TypeError): + raise HTTPException(422, 'Invalid cursor') + query = query.where(or_(Comment.created_at > timestamp, and_(Comment.created_at == timestamp, Comment.id > id))) + query = query.order_by(Comment.created_at.asc(), Comment.id.asc()) + rows = list((await db.scalars(query.limit(limit + 1))).all()) + items = rows[:limit] + return { + 'items': await comment_responses(db, items, user, resource.user_id), + 'next_cursor': f'{items[-1].created_at}:{items[-1].id}' if len(rows) > limit else None, + } + + +@router.get('/') +async def list_resource_comments( + request: Request, + resource_type: ResourceType, + resource_id: str, + cursor: str | None = None, + limit: int = Query(20, ge=1, le=50), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + resource = await comment_resource(resource_type, resource_id, user, db, request=request) + result = await list_comments(db, user, resource, resource_type, resource_id, None, cursor, limit) + result['total'] = await db.scalar( + select(func.count()) + .select_from(Comment) + .where( + Comment.resource_type == resource_type, + Comment.resource_id == resource_id, + Comment.deleted_at.is_(None), + ) + ) + return result + + +@router.post('/') +async def create_comment( + request: Request, form: CommentForm, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + await limit_write(request, user) + resource = await comment_resource(form.resource_type, form.resource_id, user, db, lock=True, request=request) + now = time.time_ns() + comment = Comment( + id=str(form.temp_id or uuid4()), + user_id=user.id, + resource_type=form.resource_type, + resource_id=form.resource_id, + content=form.content, + target_type=form.target_type, + target_id=form.target_id, + meta={'anchor': form.anchor.model_dump()} if form.anchor else None, + created_at=now, + updated_at=now, + ) + previous = await db.get(Comment, comment.id) + if previous: + if ( + any( + getattr(previous, key) != getattr(comment, key) + for key in ('user_id', 'resource_type', 'resource_id', 'content', 'target_type', 'target_id', 'meta') + ) + or previous.parent_id + or previous.deleted_at + ): + raise HTTPException(409, 'Comment ID already used') + return (await comment_responses(db, [previous], user, resource.user_id))[0] + validate_comment_target(form.resource_type, resource, form) + db.add(comment) + await db.flush() + response = (await comment_responses(db, [comment], user, resource.user_id))[0] + await db.commit() + await changed(form.resource_type, form.resource_id) + return response + + +@router.get('/{id}') +async def get_comment( + request: Request, id: str, user=Depends(get_verified_user), db: AsyncSession = Depends(get_async_session) +): + comment, resource = await accessible_comment(id, user, db, request=request) + return (await comment_responses(db, [comment], user, resource.user_id))[0] + + +@router.get('/{id}/replies') +async def replies( + request: Request, + id: str, + cursor: str | None = None, + limit: int = Query(50, ge=1, le=100), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + comment, resource = await accessible_comment(id, user, db, request=request) + if comment.parent_id: + raise HTTPException(422, 'Replies must be requested for the starting comment') + return await list_comments(db, user, resource, comment.resource_type, comment.resource_id, id, cursor, limit) + + +@router.post('/{id}/replies') +async def reply( + request: Request, + id: str, + form: CommentReplyForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await limit_write(request, user) + parent, resource = await accessible_comment(id, user, db, lock=True, request=request) + if parent.parent_id: + raise HTTPException(422, 'Replies must reference the starting comment') + reply_to = await db.get(Comment, form.reply_to_id) if form.reply_to_id else parent + if not reply_to or reply_to.deleted_at or (reply_to.id != parent.id and reply_to.parent_id != parent.id): + raise HTTPException(422, 'Reply target is unavailable') + now = time.time_ns() + comment = Comment( + id=str(form.temp_id or uuid4()), + user_id=user.id, + resource_type=parent.resource_type, + resource_id=parent.resource_id, + parent_id=id, + reply_to_id=form.reply_to_id, + content=form.content, + created_at=now, + updated_at=now, + ) + previous = await db.get(Comment, comment.id) + if previous: + if ( + any( + getattr(previous, key) != getattr(comment, key) + for key in ('user_id', 'parent_id', 'reply_to_id', 'content') + ) + or previous.deleted_at + ): + raise HTTPException(409, 'Comment ID already used') + return (await comment_responses(db, [previous], user, resource.user_id))[0] + db.add(comment) + await db.flush() + response = (await comment_responses(db, [comment], user, resource.user_id))[0] + await db.commit() + await changed(parent.resource_type, parent.resource_id) + return response + + +@router.patch('/{id}') +async def edit( + request: Request, + id: str, + form: CommentUpdateForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await limit_write(request, user) + comment, resource = await accessible_comment(id, user, db, lock=True, request=request) + if comment.user_id != user.id or comment.deleted_at: + raise HTTPException(403, 'Cannot edit this comment') + if str(comment.updated_at) != form.etag: + raise HTTPException(409, 'Comment changed. Reload before editing.') + result = await db.execute( + update(Comment) + .where(Comment.id == id, Comment.updated_at == int(form.etag), Comment.deleted_at.is_(None)) + .values(content=form.content, updated_at=max(time.time_ns(), comment.updated_at + 1)) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + raise HTTPException(409, 'Comment changed. Reload before editing.') + await db.refresh(comment) + response = (await comment_responses(db, [comment], user, resource.user_id))[0] + await db.commit() + await changed(comment.resource_type, comment.resource_id) + return response + + +@router.delete('/{id}') +async def remove( + request: Request, + id: str, + etag: str = Query(pattern=r'^\d{1,20}$'), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + comment, resource = await accessible_comment(id, user, db, lock=True, request=request) + if comment.user_id != user.id and resource.user_id != user.id and user.role != 'admin': + raise HTTPException(403, 'Cannot delete this comment') + if str(comment.updated_at) != etag: + raise HTTPException(409, 'Comment changed. Reload before deleting.') + await limit_write(request, user) + kind, resource_id = comment.resource_type, comment.resource_id + now = time.time_ns() + result = await db.execute( + update(Comment) + .where(Comment.id == id, Comment.updated_at == int(etag), Comment.deleted_at.is_(None)) + .values(content='', user_id=None, meta=None, deleted_at=now, updated_at=now) + .execution_options(synchronize_session=False) + ) + if result.rowcount != 1: + raise HTTPException(409, 'Comment changed. Reload before deleting.') + await db.execute(delete(CommentReaction).where(CommentReaction.comment_id == id)) + # Keep thread and quoted-reply references intact, without retaining deleted text. + await db.commit() + await changed(kind, resource_id) + return True + + +class ReactionForm(BaseModel): + name: str = Field(min_length=1, max_length=100) + + +@router.put('/{id}/reactions') +async def add_reaction( + request: Request, + id: str, + form: ReactionForm, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await limit_write(request, user, reaction_limiter) + comment, resource = await accessible_comment(id, user, db, lock=True, request=request) + if comment.deleted_at: + raise HTTPException(409, 'Comment was deleted') + existing = await db.scalar( + select(CommentReaction.id).where( + CommentReaction.comment_id == id, CommentReaction.user_id == user.id, CommentReaction.name == form.name + ) + ) + if not existing: + try: + async with db.begin_nested(): + db.add( + CommentReaction( + id=str(uuid4()), comment_id=id, user_id=user.id, name=form.name, created_at=time.time_ns() + ) + ) + await db.flush() + except IntegrityError: + # A concurrent identical reaction is already the requested result. + pass + await db.commit() + await changed(comment.resource_type, comment.resource_id) + return True + + +@router.delete('/{id}/reactions') +async def remove_reaction( + request: Request, + id: str, + name: str = Query(min_length=1, max_length=100), + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + await limit_write(request, user, reaction_limiter) + comment, resource = await accessible_comment(id, user, db, lock=True, request=request) + await db.execute( + delete(CommentReaction).where( + CommentReaction.comment_id == id, CommentReaction.user_id == user.id, CommentReaction.name == name + ) + ) + await db.commit() + await changed(comment.resource_type, comment.resource_id) + return True diff --git a/backend/open_webui/routers/tools.py b/backend/open_webui/routers/tools.py index b75ea7b4c7..6e77e27168 100644 --- a/backend/open_webui/routers/tools.py +++ b/backend/open_webui/routers/tools.py @@ -97,6 +97,7 @@ async def get_tools( tools_cache = get_tools_cache(request) for tool in await Tools.get_tools( defer_content=True, + active_only=True, db=db, user_id=None if bypass_access_control else user.id, user_group_ids=user_group_ids, @@ -366,6 +367,38 @@ async def get_tool_list(user=Depends(get_verified_user), db: AsyncSession = Depe return result +@router.post('/id/{id}/toggle', response_model=ToolResponse) +async def toggle_tool_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + tool = await Tools.get_tool_by_id(id, db=db) + if tool is None: + raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND) + if not ( + (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) + or tool.user_id == user.id + or await AccessGrants.has_access( + user_id=user.id, resource_type='tool', resource_id=id, permission='write', db=db + ) + ): + raise HTTPException(status_code=403, detail=ERROR_MESSAGES.UNAUTHORIZED) + + tool = await Tools.toggle_tool_by_id(id, db=db) + if tool is None: + raise HTTPException(status_code=404, detail=ERROR_MESSAGES.NOT_FOUND) + await publish_event( + request, + EVENTS.TOOL_ENABLED if tool.is_active else EVENTS.TOOL_DISABLED, + actor=user, + subject_id=tool.id, + data={'name': tool.name}, + ) + return tool + + ############################ # LoadFunctionFromLink ############################ diff --git a/backend/open_webui/socket/comments.py b/backend/open_webui/socket/comments.py new file mode 100644 index 0000000000..1b3db65b5f --- /dev/null +++ b/backend/open_webui/socket/comments.py @@ -0,0 +1,71 @@ +"""Small, authorized subscriptions on the existing Socket.IO server.""" + +import hashlib + +from fastapi import HTTPException +from open_webui.internal.db import get_async_db_context +from open_webui.models.users import Users +from open_webui.utils.comments import comment_resource +from starlette.requests import HTTPConnection + + +def comment_room(resource_type, resource_id): + return 'comments:' + hashlib.sha256(f'{resource_type}:{resource_id}'.encode()).hexdigest() + + +def comment_connection(sio, sid): + scope = (sio.get_environ(sid) or {}).get('asgi.scope') or {} + return HTTPConnection(scope) if scope.get('app') else None + + +def register_comment_events(sio, get_socket_session_user): + @sio.on('comments:join') + async def join(sid, data): + session_user = await get_socket_session_user(sid) + if not session_user or not isinstance(data, dict): + return {'error': 'Unauthorized'} + kind, id = data.get('resource_type'), data.get('resource_id') + if not isinstance(kind, str) or not isinstance(id, str) or len(id) > 512: + return {'error': 'Invalid resource'} + user = await Users.get_user_by_id(session_user['id']) + if not user: + return {'error': 'Unauthorized'} + try: + async with get_async_db_context() as db: + await comment_resource(kind, id, user, db, request=comment_connection(sio, sid)) + except HTTPException: + return {'error': 'Resource unavailable'} + session = await sio.get_session(sid) + previous = session.get('comments_resource') + if previous: + await sio.leave_room(sid, comment_room(*previous)) + session['comments_resource'] = [kind, id] + await sio.save_session(sid, session) + await sio.enter_room(sid, comment_room(kind, id)) + return {'ok': True} + + @sio.on('comments:leave') + async def leave(sid, data=None): + session = await sio.get_session(sid) + resource = session.pop('comments_resource', None) + if resource: + await sio.leave_room(sid, comment_room(*resource)) + await sio.save_session(sid, session) + + +async def revalidate_comment_subscription(sio, sid, session_user): + session = await sio.get_session(sid) + resource = session.get('comments_resource') + if not resource: + return + user = await Users.get_user_by_id(session_user['id']) + try: + if not user: + raise HTTPException(403) + async with get_async_db_context() as db: + await comment_resource(*resource, user, db, request=comment_connection(sio, sid)) + except HTTPException: + await sio.leave_room(sid, comment_room(*resource)) + session.pop('comments_resource', None) + await sio.save_session(sid, session) + await sio.emit('comments:unavailable', {}, to=sid) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 1338308fb6..3ce70bece0 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -463,7 +463,14 @@ async def periodic_socket_authentication(): await asyncio.sleep(30) await encryption.sweep() for sid in tuple(LOCAL_AUTHENTICATED_SIDS): - await get_socket_session_user(sid) + session_user = await get_socket_session_user(sid) + if session_user: + from open_webui.socket.comments import revalidate_comment_subscription + + try: + await revalidate_comment_subscription(sio, sid, session_user) + except Exception: + log.exception('Could not revalidate comment subscription') async def get_socket_session_user(sid: str, *, wait_for_disconnect: bool = True) -> dict | None: @@ -1883,3 +1890,8 @@ async def get_event_call(request_info): get_event_caller = get_event_call + + +from open_webui.socket.comments import register_comment_events + +register_comment_events(sio, get_socket_session_user) diff --git a/backend/open_webui/utils/comments.py b/backend/open_webui/utils/comments.py new file mode 100644 index 0000000000..924f853982 --- /dev/null +++ b/backend/open_webui/utils/comments.py @@ -0,0 +1,160 @@ +"""Access and optional source context for comments; never returns resource contents.""" + +import hashlib +from types import SimpleNamespace + +from fastapi import HTTPException +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.models.access_grants import AccessGrants +from open_webui.models.config import Config +from open_webui.utils.access_control import has_permission +from sqlalchemy import select + + +def resource_models(): + from open_webui.models.chats import Chat + from open_webui.models.knowledge import Knowledge + from open_webui.models.models import Model + from open_webui.models.notes import Note + from open_webui.models.prompts import Prompt + from open_webui.models.skills import Skill + from open_webui.models.tools import Tool + + return dict( + model=Model, + knowledge=Knowledge, + prompt=Prompt, + skill=Skill, + tool=Tool, + note=Note, + chat=Chat, + ) + + +async def provider_comment_resource(resource_id, user, request): + # Unconfigured provider models are visible only to admins, as in the catalog. + if user.role != 'admin' or request is None: + raise HTTPException(404, 'Resource not found') + providers = getattr(request.app.state, 'BASE_MODELS', []) + if not providers: + from open_webui.utils.models import get_all_models + + await get_all_models(request, user=user) + providers = getattr(request.app.state, 'BASE_MODELS', []) + provider = next((item for item in providers if item.get('id') == resource_id), None) + if provider is None: + raise HTTPException(404, 'Resource not found') + return SimpleNamespace(id=resource_id, name=provider.get('name') or resource_id, user_id=None) + + +async def comment_resource(resource_type, resource_id, user, db, *, lock=False, request=None): + if not await Config.get('comments.enable'): + raise HTTPException(403, 'Comments are disabled') + model = resource_models().get(resource_type) + if model is None or user.role not in {'user', 'admin'}: + raise HTTPException(404, 'Resource not found') + query = select(model).where(model.id == resource_id) + resource = await db.scalar(query.with_for_update() if lock else query) + if resource is None: + if resource_type == 'model': + return await provider_comment_resource(resource_id, user, request) + raise HTTPException(404, 'Resource not found') + allowed = resource.user_id == user.id + if resource_type == 'chat': + from open_webui.models.chats import Chats + + allowed = await Chats.get_accessible_chat_by_id(resource_id, user, db=db, chat=resource) is not None + else: + if resource_type == 'note': + if not await Config.get('notes.enable') or ( + user.role != 'admin' + and not await has_permission(user.id, 'features.notes', await Config.get('user.permissions'), db=db) + ): + raise HTTPException(404, 'Resource not found') + allowed = ( + allowed + or (user.role == 'admin' and (resource_type == 'note' or BYPASS_ADMIN_ACCESS_CONTROL)) + or await AccessGrants.has_access(user.id, resource_type, resource_id, 'read', db=db) + ) + if not allowed: + raise HTTPException(404, 'Resource not found') + return resource + + +async def effective_comment_context(resource_type, resource_id, user, db, *, request=None): + resource = await comment_resource(resource_type, resource_id, user, db, request=request) + return { + 'resource_type': resource_type, + 'resource_id': resource_id, + 'title': getattr(resource, 'title', None) or getattr(resource, 'name', None) or resource_id, + } + + +def note_comment_text(content): + def text(node): + if not isinstance(node, dict): + return '' + if node.get('type') == 'text': + return node.get('text', '') + return ''.join(text(child) for child in node.get('content', [])) + + return text(content['json']) if isinstance(content.get('json'), dict) else content.get('md') or '' + + +def validate_comment_target(resource_type, resource, form): + if bool(form.target_type) != bool(form.target_id) or (form.anchor and not form.target_id): + raise HTTPException(422, 'A selection requires a target') + if not form.target_id: + return + if resource_type == 'note' and form.target_type == 'note_page': + from open_webui.models.notes import normalize_pages + + target = next((p for p in normalize_pages(resource.data)['pages'] if p['id'] == form.target_id), None) + elif resource_type == 'chat' and form.target_type == 'chat_message': + target = (resource.chat.get('history', {}).get('messages') or {}).get(form.target_id) + if target and target.get('role') == 'assistant' and target.get('done') is False: + raise HTTPException(409, 'Wait for the message to finish') + else: + target = None + if target is None: + raise HTTPException(404, 'Comment target not found') + if form.anchor and ( + form.anchor.end <= form.anchor.start or form.anchor.end - form.anchor.start != len(form.anchor.quote) + ): + raise HTTPException(422, 'Invalid text selection') + + if form.anchor: + source = comment_anchor_source(resource_type, target, form) + if not isinstance(source, str) or hashlib.sha256(source.encode()).hexdigest() != form.anchor.content_hash: + raise HTTPException(409, 'Content changed. Select the passage again or leave a general comment.') + + +def comment_anchor_source(resource_type, target, form): + if resource_type == 'note': + if form.anchor.surface_id != form.target_id: + raise HTTPException(422, 'Invalid page selection') + source = note_comment_text(target.get('content') or {}) + elif form.anchor.surface_id == 'content': + source = target.get('content') or '' + else: + matches = [ + item + for index, item in enumerate(target.get('output') or []) + if form.anchor.surface_id + == (item.get('id') or f'{"message" if item.get("type") == "message" else "output"}-{index}') + and item.get('type') + not in { + 'reasoning', + 'function_call', + 'open_webui:code_interpreter', + 'web_search_call', + 'file_search_call', + 'computer_call', + } + ] + if len(matches) != 1: + raise HTTPException(409, 'Message output changed. Select the passage again.') + source = ''.join( + part.get('text', '') for part in matches[0].get('content', []) if isinstance(part.get('text'), str) + ) + return source diff --git a/backend/open_webui/utils/plugin.py b/backend/open_webui/utils/plugin.py index 281c22375f..ad60c583a4 100644 --- a/backend/open_webui/utils/plugin.py +++ b/backend/open_webui/utils/plugin.py @@ -475,7 +475,7 @@ async def install_tool_and_function_dependencies(): return function_list = await Functions.get_functions(active_only=True) if ENABLE_FUNCTIONS else [] - tool_list = await Tools.get_tools() if ENABLE_TOOLS else [] + tool_list = await Tools.get_tools(active_only=True) if ENABLE_TOOLS else [] all_dependencies = '' try: diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 5f3886c6cd..64b76fc756 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -7,7 +7,7 @@ import inspect import logging import os import re -from functools import cache, partial, update_wrapper +from functools import cache, partial, update_wrapper, wraps from typing import ( Any, Awaitable, @@ -339,14 +339,28 @@ async def get_updated_tool_function(function: Callable, extra_params: dict): __extra_params__ = getattr(function, '__extra_params__', None) if __function__ is not None and __extra_params__ is not None: - return await get_async_tool_function_and_apply_extra_params( + updated = await get_async_tool_function_and_apply_extra_params( __function__, {**__extra_params__, **extra_params}, ) + tool_id = getattr(function, '__tool_id__', None) + return guard_tool_enabled(tool_id, updated) if tool_id is not None else updated return function +def guard_tool_enabled(tool_id: str, function: Callable) -> Callable: + @wraps(function) + async def guarded(*args, **kwargs): + # Resolve again at invocation: a chat may have prepared this callable before a toggle. + if not await Tools.is_tool_active(tool_id): + raise HTTPException(status_code=403, detail='Tool is disabled or no longer available') + return await function(*args, **kwargs) + + guarded.__tool_id__ = tool_id + return guarded + + async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extra_params: dict) -> dict[str, dict]: """Load tools for the given tool_ids, checking access control.""" if not tool_ids: @@ -373,6 +387,8 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr for tool_id in tool_ids: tool = tool_models.get(tool_id) if tool: + if not tool.is_active: + continue # Check access control for local tools if ( not (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL) @@ -431,6 +447,7 @@ async def get_tools(request: Request, tool_ids: list[str], user: UserModel, extr '__user__': __user__, }, ) + callable = guard_tool_enabled(tool_id, callable) # TODO: Support Pydantic models as parameters if callable.__doc__ and callable.__doc__.strip() != '': diff --git a/src/lib/apis/comments/index.ts b/src/lib/apis/comments/index.ts new file mode 100644 index 0000000000..fb0a2a5921 --- /dev/null +++ b/src/lib/apis/comments/index.ts @@ -0,0 +1,84 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type CommentResource = { + resource_type: + | 'model' + | 'knowledge' + | 'prompt' + | 'skill' + | 'tool' + | 'note' + | 'chat'; + resource_id: string; +}; +export type CommentAnchor = { + quote: string; + prefix: string; + suffix: string; + start: number; + end: number; + surface_id: string; + source: string; + content_hash: string; +}; +export type CommentTarget = { + target_type?: 'note_page' | 'chat_message'; + target_id?: string; + anchor?: CommentAnchor; +}; +export type ResourceComment = CommentResource & { + id: string; + parent_id: string | null; + reply_to_id: string | null; + content: string; + target_type: string | null; + target_id: string | null; + meta: { anchor?: CommentAnchor } | null; + user: { id: string; name: string; profile_image_url?: string } | null; + created_at: number; + updated_at: number; + deleted_at: number | null; + etag: string; + reply_count: number; + latest_reply_at?: number; + can_edit: boolean; + can_delete: boolean; + reactions: { name: string; count: number; reacted: boolean }[]; +}; +export class CommentError extends Error { + constructor( + public status: number, + message: string + ) { + super(message); + } +} +export async function commentsRequest( + path: string, + method = 'GET', + body?: unknown, + signal?: AbortSignal +): Promise { + const response = await fetch(`${WEBUI_API_BASE_URL}/comments${path}`, { + method, + signal, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.token}` }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }) + }); + const data = await response.json(); + if (!response.ok) + throw new CommentError( + response.status, + typeof data.detail === 'string' ? data.detail : 'Unable to load comments' + ); + return data; +} +export const resourceQuery = (resource: CommentResource) => + new URLSearchParams({ + resource_type: resource.resource_type, + resource_id: resource.resource_id + }).toString(); +export const getComments = (resource: CommentResource, cursor?: string) => + commentsRequest<{ items: ResourceComment[]; next_cursor: string | null; total: number }>( + `/?${resourceQuery(resource)}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}` + ); diff --git a/src/lib/apis/tools/index.ts b/src/lib/apis/tools/index.ts index e3fc9299b9..c1b83f29d5 100644 --- a/src/lib/apis/tools/index.ts +++ b/src/lib/apis/tools/index.ts @@ -1,5 +1,17 @@ import { WEBUI_API_BASE_URL } from '$lib/constants'; +export const toggleToolById = async (token: string, id: string) => { + const response = await fetch(`${WEBUI_API_BASE_URL}/tools/id/${encodeURIComponent(id)}/toggle`, { + method: 'POST', + headers: { Accept: 'application/json', authorization: `Bearer ${token}` } + }); + if (!response.ok) { + const error = await response.json(); + throw error.detail ?? error; + } + return response.json(); +}; + export const createNewTool = async (token: string, tool: object) => { let error = null; diff --git a/src/lib/components/ChangelogModal.svelte b/src/lib/components/ChangelogModal.svelte index af6cf935e1..828fb0ef7a 100644 --- a/src/lib/components/ChangelogModal.svelte +++ b/src/lib/components/ChangelogModal.svelte @@ -108,7 +108,7 @@ diff --git a/src/lib/components/admin/Evaluations/FeedbackModal.svelte b/src/lib/components/admin/Evaluations/FeedbackModal.svelte index ef977bcab2..efe260ceec 100644 --- a/src/lib/components/admin/Evaluations/FeedbackModal.svelte +++ b/src/lib/components/admin/Evaluations/FeedbackModal.svelte @@ -47,7 +47,7 @@ {$i18n.t('Feedback Details')} + {/if} + {#if showComments} + { + show = false; + onEdit(); + }} + /> + {/if} + + {#if canEdit} {#if $user?.role === 'admin' && (model?.owned_by === 'ollama' || providerSupportsDelete(model?.provider))} - - {/if} - {#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true))} + + {/if} + {#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)} + {/snippet} + {#if menu} + {@render commentButton()} + {:else} + + {@render commentButton()} + + {/if} +{/if} diff --git a/src/lib/components/comments/CommentComposer.svelte b/src/lib/components/comments/CommentComposer.svelte new file mode 100644 index 0000000000..3ec7612c6d --- /dev/null +++ b/src/lib/components/comments/CommentComposer.svelte @@ -0,0 +1,186 @@ + + +
{ + if ( + onCancel && + !saving && + !content.trim() && + !event.currentTarget.contains(event.relatedTarget as Node | null) + ) + onCancel(); + }} +> +
+
+ {#if contextLabel || quote} +
+
+ {contextLabel}{#if onClearContext}{/if} +
+ {#if quote}
+ {quote} +
{/if} +
+ {/if} +
+
+ { + if (submitted) return; + content = value.md; + remember(); + }} + on:keydown={(event) => { + const e = event.detail.event; + if ( + e.isComposing || + e.keyCode === 229 || + editor?.view.composing || + document.getElementById('suggestions-container') + ) + return; + if ( + e.key === 'Enter' && + !e.shiftKey && + (($settings?.ctrlEnterToSend ?? false) ? e.metaKey || e.ctrlKey : !$mobile) + ) { + e.preventDefault(); + submit(); + } + }} + /> +
+
+ {#if error}{/if} +
+
+ +
+
+
+
+
diff --git a/src/lib/components/comments/CommentItem.svelte b/src/lib/components/comments/CommentItem.svelte new file mode 100644 index 0000000000..070ae67993 --- /dev/null +++ b/src/lib/components/comments/CommentItem.svelte @@ -0,0 +1,224 @@ + + + { + action(() => commentsRequest(`/${comment.id}?etag=${comment.etag}`, 'DELETE')); + }} +/> +
+ onReply(comment)} + onReaction={react} + > + +
+
+ + + +
+ {comment.user?.name ?? $i18n.t('Unknown User')} + + {dayjs(comment.created_at / 1000000) + .locale($i18n.language) + .fromNow()} + +
+ {#if !comment.deleted_at && !busy} + + + + +
+ + {#if !comment.reactions.length} + + + + {/if} + {#if comment.can_edit} + + {/if} + + {#if comment.can_delete} +
+ + {/if} +
+
+
+ {/if} +
+
+
+ + {#if comment.deleted_at}

{$i18n.t('Comment deleted')}

+ {:else if editing} +
+ (editing = false)} + onSubmit={async (content) => { + await commentsRequest(`/${comment.id}`, 'PATCH', { content, etag: editingEtag }); + editing = false; + await onChange(); + }} + /> +
+ {:else} + {#if comment.meta?.anchor} + + {#if unavailable}

+ {$i18n.t('Original passage changed or is unavailable.')} + {$i18n.t('Open source')} +

{/if} + {:else if comment.target_id}{$i18n.t(comment.target_type === 'note_page' ? 'Page comment' : 'Message comment')}{/if} +
+ +
+ {/if} +
+ + {#if !comment.deleted_at && !editing} +
+ +
+ {/if} +
+
+
diff --git a/src/lib/components/comments/CommentReplies.svelte b/src/lib/components/comments/CommentReplies.svelte new file mode 100644 index 0000000000..8154be8a93 --- /dev/null +++ b/src/lib/components/comments/CommentReplies.svelte @@ -0,0 +1,144 @@ + + +{#snippet branch(parentId: string)} + {#each children.get(parentId) ?? [] as comment (comment.id)} + + {#if replyTo?.id === comment.id} +
+ +
+ {/if} + {#if children.has(comment.id)} +
+ {@render branch(comment.id)} +
+ {/if} + {/each} +{/snippet} + +{#if items.length || loading} +
+ {@render branch(parent.id)} + {#if loading && !items.length}
+ +
{/if} + {#if cursor}{/if} +
+{/if} diff --git a/src/lib/components/comments/CommentReplyComposer.svelte b/src/lib/components/comments/CommentReplyComposer.svelte new file mode 100644 index 0000000000..01b768bc31 --- /dev/null +++ b/src/lib/components/comments/CommentReplyComposer.svelte @@ -0,0 +1,40 @@ + + +
+ { + onSubmitted(comment.id); + if (postedComment) onPosted(postedComment); + }} + {onCancel} + onSubmit={async (content, temp_id) => { + postedComment = await commentsRequest( + `/${comment.parent_id ?? comment.id}/replies`, + 'POST', + { content, temp_id, reply_to_id: comment.id } + ); + }} + /> +
diff --git a/src/lib/components/comments/CommentsHost.svelte b/src/lib/components/comments/CommentsHost.svelte new file mode 100644 index 0000000000..0ba97c3a64 --- /dev/null +++ b/src/lib/components/comments/CommentsHost.svelte @@ -0,0 +1,176 @@ + + + { + if (event.key === 'Escape') { + if (!$commentsView?.modal) commentsView.set(null); + selection = null; + } + }} + on:scroll|capture={() => (selection = null)} +/> +{#if commentsAllowed} + {#if selection && !selection.options.toolbar_id} + + {/if} + {#if $commentsView && ($commentsView.modal || !(($commentsView.resource_type === 'chat' && $commentsView.resource_id === $chatCommentsId) || ($commentsView.resource_type === 'note' && $commentsView.resource_id === $noteCommentsId)))} + {#key `${account}:${$commentsView.resource_type}:${$commentsView.resource_id}:${$commentsView.comment_id ?? ''}`} + {#if $commentsView.modal} + commentsView.set(null)} + > +
+ +
+
+ {:else if $mobile} + commentsView.set(null)} + >
+ {:else} + commentsView.set(null)} + > + + + {/if} + {/key} + {/if} +{/if} + + diff --git a/src/lib/components/comments/CommentsPanel.svelte b/src/lib/components/comments/CommentsPanel.svelte new file mode 100644 index 0000000000..de4b769ccb --- /dev/null +++ b/src/lib/components/comments/CommentsPanel.svelte @@ -0,0 +1,334 @@ + + + +
+ {#if !embedded} +
+

+ {$i18n.t('Comments')}{#if total !== null && total > 0}{total}{/if} +

+ + + +
+ {/if} + {#if view.modal && resource && title} +
+ + {$i18n.t(resourceLabels[resource.resource_type])} + + {title} +
+ {/if} +
+ {#if loading}
{/if} + {#if error}
+ + +
{/if} + {#if resource && !loading} + {#if !items.length}
+ {$i18n.t('No comments yet')} +
{/if} + {#each items as comment (comment.id)} +
+ + + {#if replyTo?.id === comment.id} +
+ {#key replyTo.id} (replyTo = null)} + onSubmitted={(id) => { + if (replyTo?.id === id) replyTo = null; + }} + onPosted={postedReply} + />{/key} +
+ {/if} + (replyTo = null)} + onSubmitted={(id) => { + if (replyTo?.id === id) replyTo = null; + }} + onPosted={postedReply} + linkedCommentId={linkedRootId === comment.id && linkedCommentId !== comment.id + ? linkedCommentId + : null} + onReply={reply} + onChange={refresh} + onError={fail} + /> +
+ {/each} + {#if cursor}{/if} + {/if} +
+ {#if resource && !loading} +
+ {#key `${resourceQuery(resource)}:${composerVersion}`} + { + composerVersion++; + await tick(); + if (alive && linkedCommentId) + document + .getElementById(`comment-${linkedCommentId}`) + ?.scrollIntoView({ block: 'nearest' }); + }} + {resource} + draftKey={`${$user?.id}:${resourceQuery(resource)}:root`} + contextLabel={view.target_id + ? $i18n.t( + view.target_type === 'note_page' + ? 'Comment on this page' + : 'Comment on this message' + ) + : ''} + quote={view.anchor?.quote ?? ''} + onClearContext={clearContext} + onSubmit={async (content, temp_id) => { + const created = await commentsRequest('/', 'POST', { + ...resource, + content, + temp_id, + target_type: view.target_type, + target_id: view.target_id, + anchor: view.anchor + }); + if (!alive) return; + linkedRootId = created.id; + linkedCommentId = created.id; + clearContext(); + await refresh(); + }} + /> + {/key} +
+ {/if} +
diff --git a/src/lib/components/comments/anchors.ts b/src/lib/components/comments/anchors.ts new file mode 100644 index 0000000000..0721a9189c --- /dev/null +++ b/src/lib/components/comments/anchors.ts @@ -0,0 +1,176 @@ +import { isSavedChatId } from '$lib/utils/chatId'; +import { sha256 } from 'js-sha256'; +import type { CommentAnchor, CommentResource, CommentTarget } from '$lib/apis/comments'; + +export type CommentSurface = CommentResource & + CommentTarget & { + surface_id: string; + content_hash: string; + toolbar_id?: string; + enabled?: boolean; + prepare?: () => Promise; + }; +export const contentHash = (text: string) => sha256(text); +export function noteCommentText(content: { json?: unknown; md?: string }) { + const text = (node: any): string => + !node || typeof node !== 'object' + ? '' + : node.type === 'text' + ? (node.text ?? '') + : (node.content ?? []).map(text).join(''); + return content.json && typeof content.json === 'object' ? text(content.json) : (content.md ?? ''); +} +const surfaces = new Map(); +export function commentSurface(node: HTMLElement, options: CommentSurface) { + surfaces.set(node, options); + return { + update(value: CommentSurface) { + surfaces.set(node, value); + }, + destroy() { + surfaces.delete(node); + } + }; +} + +// Both capture and lookup use the same visible text nodes, excluding interactive/hidden output. +export function surfaceText(node: HTMLElement) { + const nodes: Text[] = []; + const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { + acceptNode: (text) => { + const parent = text.parentElement; + return parent && + !parent.closest( + 'button, script, style, iframe, [hidden], [aria-hidden="true"], details, .katex-mathml, [data-comment-ignore]' + ) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT; + } + }); + while (walker.nextNode()) nodes.push(walker.currentNode as Text); + return { nodes, text: nodes.map((n) => n.data).join('') }; +} +export function anchorPosition(text: string, anchor: CommentAnchor): [number, number] | null { + const chars = [...text]; + const quote = [...anchor.quote]; + const matches = (start: number) => + chars.slice(start, start + quote.length).join('') === anchor.quote && + (!anchor.prefix || + chars.slice(Math.max(0, start - [...anchor.prefix].length), start).join('') === + anchor.prefix) && + (!anchor.suffix || + chars + .slice(start + quote.length, start + quote.length + [...anchor.suffix].length) + .join('') === anchor.suffix); + if (sha256(text) === anchor.source && matches(anchor.start)) + return [anchor.start, anchor.start + quote.length]; + // A quote alone is insufficient when identical passages exist. + const candidates: number[] = []; + let from = 0; + while (from <= text.length) { + const index = text.indexOf(anchor.quote, from); + if (index < 0) break; + const start = [...text.slice(0, index)].length; + if (matches(start)) candidates.push(start); + if (candidates.length > 1) return null; + from = index + Math.max(1, anchor.quote.length); + } + return candidates.length === 1 ? [candidates[0], candidates[0] + quote.length] : null; +} +export function selectedComment() { + if (window.location.pathname.startsWith('/s/')) return null; + const selection = window.getSelection(); + if (!selection?.rangeCount || selection.isCollapsed) return null; + const range = selection.getRangeAt(0).cloneRange(); + for (const [node, options] of surfaces) { + if ( + options.enabled === false || + (options.resource_type === 'chat' && + !isSavedChatId(options.resource_id)) || + !options.resource_id || + !node.contains(range.startContainer) || + !node.contains(range.endContainer) + ) + continue; + const { nodes, text } = surfaceText(node); + if ( + Array.from(node.querySelectorAll('details, iframe, [hidden], [data-comment-ignore]')).some( + (el) => range.intersectsNode(el) + ) + ) + return null; + // Require text endpoints and no excluded content inside the selection. + if ( + !nodes.includes(range.startContainer as Text) || + !nodes.includes(range.endContainer as Text) + ) + continue; + let start = 0, + end = 0, + offset = 0; + for (const n of nodes) { + if (n === range.startContainer) start = offset + range.startOffset; + if (n === range.endContainer) end = offset + range.endOffset; + offset += n.length; + } + const quote = text.slice(start, end); + if (!quote.trim() || [...quote].length > 10000) return null; + const chars = [...text]; + const from = [...text.slice(0, start)].length; + const to = [...text.slice(0, end)].length; + const anchor: CommentAnchor = { + quote, + start: from, + end: to, + prefix: chars.slice(Math.max(0, from - 64), from).join(''), + suffix: chars.slice(to, to + 64).join(''), + surface_id: options.surface_id, + source: sha256(text), + content_hash: options.content_hash + }; + return { options, anchor, rect: range.getBoundingClientRect() }; + } + return null; +} +export function highlightAnchor( + resource: CommentResource, + targetId: string, + anchor: CommentAnchor +): boolean { + for (const [node, options] of surfaces) { + if ( + options.resource_type !== resource.resource_type || + options.resource_id !== resource.resource_id || + options.target_id !== targetId || + options.surface_id !== anchor.surface_id + ) + continue; + const { nodes, text } = surfaceText(node); + const position = anchorPosition(text, anchor); + if (!position) return false; + const range = document.createRange(); + let start = [...text].slice(0, position[0]).join('').length; + let end = [...text].slice(0, position[1]).join('').length; + let offset = 0; + for (const n of nodes) { + if (start >= offset && start <= offset + n.length) range.setStart(n, start - offset); + if (end >= offset && end <= offset + n.length) { + range.setEnd(n, end - offset); + break; + } + offset += n.length; + } + const highlights = (CSS as any).highlights; + const HighlightClass = (window as any).Highlight; + if (highlights && HighlightClass) { + highlights.set('resource-comment', new HighlightClass(range)); + setTimeout(() => highlights.delete('resource-comment'), 5000); + } else { + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + } + range.startContainer.parentElement?.scrollIntoView({ block: 'center', behavior: 'smooth' }); + return true; + } + return false; +} diff --git a/src/lib/components/common/Badge.svelte b/src/lib/components/common/Badge.svelte index 81d676f20d..94218380f5 100644 --- a/src/lib/components/common/Badge.svelte +++ b/src/lib/components/common/Badge.svelte @@ -7,13 +7,16 @@ success: 'bg-green-500/20 text-green-700 dark:text-green-200', warning: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-200', error: 'bg-red-500/20 text-red-700 dark:text-red-200', - muted: 'bg-gray-500/20 text-gray-700 dark:text-gray-200' + muted: 'bg-gray-500/20 text-gray-700 dark:text-gray-200', + subtle: 'bg-gray-100/80 text-gray-500 dark:bg-gray-800/50 dark:text-gray-400' };
{content}
diff --git a/src/lib/components/common/MessageComposerShell.svelte b/src/lib/components/common/MessageComposerShell.svelte new file mode 100644 index 0000000000..468f4fea33 --- /dev/null +++ b/src/lib/components/common/MessageComposerShell.svelte @@ -0,0 +1,16 @@ + + +
+ +
diff --git a/src/lib/components/common/RichTextInput.svelte b/src/lib/components/common/RichTextInput.svelte index 44f904b747..04528cd76a 100644 --- a/src/lib/components/common/RichTextInput.svelte +++ b/src/lib/components/common/RichTextInput.svelte @@ -276,7 +276,7 @@ export let image = false; export let fileHandler = false; export let attachments = false; - export let suggestions = null; + export let suggestions: Partial[] | null = null; export let onFileDrop = (currentEditor, files, pos) => { files.forEach((file) => { diff --git a/src/lib/components/common/RichTextInput/suggestions.ts b/src/lib/components/common/RichTextInput/suggestions.ts index 9dbfc5048b..8f7c807489 100644 --- a/src/lib/components/common/RichTextInput/suggestions.ts +++ b/src/lib/components/common/RichTextInput/suggestions.ts @@ -1,14 +1,14 @@ -import { mount, unmount } from 'svelte'; import { createClassComponent } from 'svelte/legacy'; -import tippy from 'tippy.js'; +import tippy, { type Instance } from 'tippy.js'; export function getSuggestionRenderer(Component: any, ComponentProps = {}) { return function suggestionRenderer() { let component = null; let container: HTMLDivElement | null = null; - let popup: TippyInstance | null = null; + let popup: Instance | null = null; + let resizeObserver: ResizeObserver | null = null; let refEl: HTMLDivElement | null = null; // dummy reference return { @@ -59,7 +59,7 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) { { name: 'preventOverflow', options: { - boundary: 'viewport', // keep within the viewport + rootBoundary: 'viewport', // keep within the viewport altAxis: true, // also prevent overflow on the cross axis (X) tether: true, padding: 8 @@ -68,8 +68,10 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) { { name: 'flip', options: { - boundary: 'viewport', - fallbackPlacements: ['top-end', 'bottom-start', 'bottom-end'] + rootBoundary: 'viewport', + fallbackPlacements: ['bottom-start'], + altAxis: false, + padding: 8 } }, // Ensure transforms don’t cause layout widening in some browsers @@ -79,6 +81,9 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) { // Helps avoid accidental focus/hover “linking” from far away elements interactiveBorder: 8 }); + // Async suggestions can change the menu size after its initial placement. + resizeObserver = new ResizeObserver(() => popup?.popperInstance?.update()); + resizeObserver.observe(container); popup?.show(); }, @@ -105,6 +110,8 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) { }, onExit: () => { + resizeObserver?.disconnect(); + resizeObserver = null; popup?.destroy(); popup = null; diff --git a/src/lib/components/icons/Annotation.svelte b/src/lib/components/icons/Annotation.svelte new file mode 100644 index 0000000000..fe4627ac71 --- /dev/null +++ b/src/lib/components/icons/Annotation.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/layout/ChatsModal.svelte b/src/lib/components/layout/ChatsModal.svelte index eb222dadb1..6a491d2d92 100644 --- a/src/lib/components/layout/ChatsModal.svelte +++ b/src/lib/components/layout/ChatsModal.svelte @@ -112,7 +112,7 @@