This commit is contained in:
Timothy Jaeryang Baek 2026-10-30 16:01:47 +09:00
parent 09fbbd6d69
commit 9d1b4ef362
132 changed files with 4214 additions and 1106 deletions

View file

@ -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,

View file

@ -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'
)

View file

@ -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'),

View file

@ -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

View file

@ -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')

View file

@ -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')

View file

@ -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

View file

@ -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))

View file

@ -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()

View file

@ -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))
)

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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))

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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
############################

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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() != '':

View file

@ -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<T>(
path: string,
method = 'GET',
body?: unknown,
signal?: AbortSignal
): Promise<T> {
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)}` : ''}`
);

View file

@ -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;

View file

@ -108,7 +108,7 @@
</div>
<button
class="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl text-gray-400 transition hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-white/10 dark:hover:text-gray-200"
class="flex h-8 shrink-0 items-center justify-center rounded-xl text-gray-400 transition hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200 px-0"
on:click={closeModal}
aria-label={$i18n.t('Close')}
>

View file

@ -63,7 +63,7 @@
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Import')}</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;

View file

@ -103,7 +103,7 @@
<!-- Close button (visible on hover) -->
<button
bind:this={closeButtonElement}
class="absolute -top-0.5 -left-0.5 p-0.5 rounded-full opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-opacity z-10"
class="absolute -top-0.5 -left-0.5 py-0.5 rounded-full opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-opacity z-10 px-0"
on:click|stopPropagation={closeHandler}
aria-label={$i18n.t('Dismiss notification')}
>

View file

@ -188,7 +188,7 @@
</div>
</Tooltip>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={close}
aria-label={$i18n.t('Close')}
>

View file

@ -201,7 +201,7 @@
data-close-session
aria-label={$i18n.t('Close session details')}
on:click={onClose}
class="shrink-0 rounded-lg p-1 hover:bg-gray-50 dark:hover:bg-gray-800"
class="shrink-0 rounded-lg py-1 hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
><XMark className="size-4" /></button
>
</div>

View file

@ -47,7 +47,7 @@
{$i18n.t('Feedback Details')}
</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={close}
aria-label={$i18n.t('Close')}
>

View file

@ -139,7 +139,7 @@
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';

View file

@ -68,7 +68,7 @@
</div>
</Tooltip>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={close}
aria-label={$i18n.t('Close')}
>

View file

@ -409,7 +409,7 @@
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';

View file

@ -21,7 +21,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -401,7 +401,7 @@
</h1>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
type="button"
on:click={resetForm}

View file

@ -419,7 +419,7 @@
</h1>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
type="button"
on:click={() => {

View file

@ -383,6 +383,13 @@
>
<Switch bind:state={adminConfig.ENABLE_NOTES} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('settings.admin.general.comments.label')}
description={$i18n.t('settings.admin.general.comments.description')}
let:labelId
>
<Switch bind:state={adminConfig.ENABLE_COMMENTS} ariaLabelledbyId={labelId} />
</AdminSettingRow>
<AdminSettingRow
label={$i18n.t('settings.admin.general.channels.label')}
description={$i18n.t('settings.admin.general.channels.description')}

View file

@ -847,7 +847,7 @@
{#if searchValue}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
searchValue = '';

View file

@ -88,7 +88,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -226,7 +226,7 @@
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';

View file

@ -200,7 +200,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -197,7 +197,7 @@
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';

View file

@ -146,7 +146,7 @@
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Add User')}</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;

View file

@ -135,7 +135,7 @@
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Edit User')}</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;

View file

@ -122,7 +122,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -95,7 +95,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -250,7 +250,7 @@
type="button"
aria-label={$i18n.t('Remove {{name}}', { name: app.name })}
disabled={!!removingApp}
class="rounded-lg p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition disabled:opacity-50"
class="rounded-lg py-1 transition disabled:opacity-50 hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
on:click={async () => {
removingApp = app.id;
try {
@ -308,7 +308,7 @@
{#if onRemove}
<div>
<button
class=" rounded-full p-1 hover:bg-gray-100 dark:hover:bg-gray-850 transition disabled:opacity-50 disabled:cursor-not-allowed"
class="rounded-full py-1 transition disabled:opacity-50 disabled:cursor-not-allowed hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
type="button"
disabled={user.id === $_user?.id}
on:click={() => {

View file

@ -1,4 +1,5 @@
<script lang="ts">
import MessageComposerShell from '$lib/components/common/MessageComposerShell.svelte';
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
@ -980,15 +981,7 @@
{/each}
</div>
{/if}
<div
id="message-input-container"
class="flex-1 flex flex-col relative w-full shadow-sm rounded-2xl border border-gray-100/60 dark:border-gray-800/50 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white dark:bg-gray-400/5 dark:text-gray-100"
dir={$settings?.chatDirection === 'RTL'
? 'rtl'
: $settings?.chatDirection === 'LTR'
? 'ltr'
: 'auto'}
>
<MessageComposerShell id="message-input-container">
{#if replyToMessage !== null}
<div class="px-3 pt-2 text-left w-full flex flex-col z-10">
<div class="flex items-center justify-between w-full">
@ -1005,7 +998,7 @@
</div>
<div>
<button
class="flex size-6 shrink-0 items-center justify-center rounded-md text-gray-500 hover:bg-gray-50 dark:hover:bg-gray-800 dark:text-gray-400"
class="flex h-6 shrink-0 items-center justify-center rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 px-0"
aria-label={$i18n.t('Cancel')}
type="button"
on:click={() => {
@ -1353,7 +1346,7 @@
</div>
</div>
</div>
</div>
</MessageComposerShell>
</form>
{/if}
</div>

View file

@ -26,6 +26,9 @@
export let userSuggestions = false;
export let channelSuggestions = false;
export let channelId: string | null = null;
export let loadUsers:
| ((query: string, signal: AbortSignal) => Promise<{ id: string; name: string }[]>)
| null = null;
let _models = [];
let _users = [];
@ -47,6 +50,11 @@
const getUserList = async (requestQuery: string, requestChannelId: string | null) => {
const signal = getAbortSignal();
if (loadUsers) {
const users = await loadUsers(requestQuery, signal).catch(() => []);
if (!signal.aborted) _users = toUserItems(users);
return;
}
const [channelMembers, searchResults] = await Promise.all([
requestChannelId
? getChannelMembersById(

View file

@ -218,109 +218,111 @@
? ''
: 'transition: transform 0.3s cubic-bezier(0.2, 0.9, 0.3, 1);'}"
>
{#if !edit && !disabled}
<div class=" absolute {showButtons ? '' : 'hover-reveal'} right-1 -top-3 z-30">
{#if !edit && !disabled && !$$slots.header}
<div class="absolute {showButtons ? '' : 'hover-reveal'} right-1 -top-3 z-30">
<div
class="flex gap-1 rounded-lg bg-white dark:bg-gray-850 shadow-md p-0.5 border border-gray-100/30 dark:border-gray-850/30"
>
{#if onReaction}
<EmojiPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<slot name="actions">
{#if onReaction}
<EmojiPicker
onClose={() => (showButtons = false)}
onSubmit={(name) => {
showButtons = false;
onReaction(name);
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
}}
>
<FaceSmile />
</button>
</Tooltip>
</EmojiPicker>
{/if}
{#if onReply}
<Tooltip content={$i18n.t('Reply')}>
<button
aria-label={$i18n.t('Reply')}
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
showButtons = true;
onReply(message);
}}
>
<FaceSmile />
<ArrowUpLeftAlt className="size-4" />
</button>
</Tooltip>
</EmojiPicker>
{/if}
{/if}
{#if onReply}
<Tooltip content={$i18n.t('Reply')}>
<Tooltip content={message?.is_pinned ? $i18n.t('Unpin') : $i18n.t('Pin')}>
<button
aria-label={$i18n.t('Reply')}
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onReply(message);
onPin(message);
}}
>
<ArrowUpLeftAlt className="size-4" />
{#if message?.is_pinned}
<PinSlash className="size-4" />
{:else}
<Pin className="size-4" />
{/if}
</button>
</Tooltip>
{/if}
<Tooltip content={message?.is_pinned ? $i18n.t('Unpin') : $i18n.t('Pin')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onPin(message);
}}
>
{#if message?.is_pinned}
<PinSlash className="size-4" />
{:else}
<Pin className="size-4" />
{#if !thread && onThread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
aria-label={$i18n.t('Reply in Thread')}
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onThread(message.id);
}}
>
<ChatBubbleOvalEllipsis />
</button>
</Tooltip>
{/if}
{#if message.user_id === $user?.id || $user?.role === 'admin'}
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}>
<button
id="edit-message-{renderedMessageId}"
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={async () => {
edit = true;
editedContent = message.content;
await tick();
const textarea = document
.getElementById(`message-${renderedMessageId}`)
?.querySelector('textarea');
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
textarea?.scrollIntoView({ block: 'center' });
}}
>
<Pencil />
</button>
</Tooltip>
{/if}
</button>
</Tooltip>
{#if !thread && onThread}
<Tooltip content={$i18n.t('Reply in Thread')}>
<button
aria-label={$i18n.t('Reply in Thread')}
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => {
onThread(message.id);
}}
>
<ChatBubbleOvalEllipsis />
</button>
</Tooltip>
{/if}
{#if message.user_id === $user?.id || $user?.role === 'admin'}
{#if onEdit}
<Tooltip content={$i18n.t('Edit')}>
<button
id="edit-message-{renderedMessageId}"
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={async () => {
edit = true;
editedContent = message.content;
await tick();
const textarea = document
.getElementById(`message-${renderedMessageId}`)
?.querySelector('textarea');
textarea?.focus();
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
textarea?.scrollIntoView({ block: 'center' });
}}
>
<Pencil />
</button>
</Tooltip>
{#if onDelete}
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{/if}
{/if}
{#if onDelete}
<Tooltip content={$i18n.t('Delete')}>
<button
class="hover:bg-gray-100 dark:hover:bg-gray-800 transition rounded-lg p-1"
on:click={() => (showDeleteConfirmDialog = true)}
>
<GarbageBin />
</button>
</Tooltip>
{/if}
{/if}
</slot>
</div>
</div>
{/if}
@ -407,73 +409,75 @@
</div>
{/if}
<slot name="header" />
<div
class=" flex w-full message-{message.id} "
id="message-{renderedMessageId}"
dir={$settings.chatDirection}
>
<div class="w-6 shrink-0">
{#if showUserProfile}
{#if message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
alt={message.meta.model_name ?? message.meta.model_id}
class="size-6 mt-0.5 object-cover rounded-full"
on:error={(e) => {
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
e.currentTarget.src = '/favicon.png';
}}
/>
{:else if message.app_id}
{#if message.user?.profile_image_url}
<ProfileImage
src={message.user.profile_image_url}
allowExternal
className="size-6 mt-0.5 rounded-lg!"
{#if !$$slots.header}
<div class="w-6 shrink-0">
{#if showUserProfile}
{#if message?.meta?.model_id}
<img
src={`${WEBUI_API_BASE_URL}/models/model/profile/image?id=${message.meta.model_id}`}
alt={message.meta.model_name ?? message.meta.model_id}
class="size-6 mt-0.5 object-cover rounded-full"
on:error={(e) => {
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
e.currentTarget.src = '/favicon.png';
}}
/>
{:else}
<span
class="flex size-6 mt-0.5 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800"
role="img"
aria-label={$i18n.t('App')}
>
<Grid className="size-4" />
</span>
{/if}
{:else if message.user?.role === 'webhook'}
<ProfileImage
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
className={'size-6 mt-0.5 rounded-full!'}
/>
{:else}
<ProfilePreview user={message.user}>
{:else if message.app_id}
{#if message.user?.profile_image_url}
<ProfileImage
src={message.user.profile_image_url}
allowExternal
className="size-6 mt-0.5 rounded-lg!"
/>
{:else}
<span
class="flex size-6 mt-0.5 items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-800"
role="img"
aria-label={$i18n.t('App')}
>
<Grid className="size-4" />
</span>
{/if}
{:else if message.user?.role === 'webhook'}
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
src={`${WEBUI_API_BASE_URL}/channels/webhooks/${message.user?.id}/profile/image`}
className={'size-6 mt-0.5 rounded-full!'}
/>
</ProfilePreview>
{/if}
{:else if message.created_at}
<div
class="-mx-1 mt-1 translate-y-px flex items-center justify-center text-[0.6875rem] leading-4 tabular-nums hover-reveal text-gray-500 dark:text-gray-400"
>
<Tooltip
content={dayjs(message.created_at / 1000000)
.locale($i18n.language)
.format('LLLL')}
{:else}
<ProfilePreview user={message.user}>
<ProfileImage
src={`${WEBUI_API_BASE_URL}/users/${message.user?.id}/profile/image`}
className={'size-6 mt-0.5 rounded-full!'}
/>
</ProfilePreview>
{/if}
{:else if message.created_at}
<div
class="-mx-1 mt-1 translate-y-px flex items-center justify-center text-[0.6875rem] leading-4 tabular-nums hover-reveal text-gray-500 dark:text-gray-400"
>
{dayjs(message.created_at / 1000000)
.locale($i18n.language)
.format('HH:mm')}
</Tooltip>
</div>
{/if}
</div>
<div class="flex-auto w-0 pl-3">
{#if showUserProfile}
<Tooltip
content={dayjs(message.created_at / 1000000)
.locale($i18n.language)
.format('LLLL')}
>
{dayjs(message.created_at / 1000000)
.locale($i18n.language)
.format('HH:mm')}
</Tooltip>
</div>
{/if}
</div>
{/if}
<div class="flex-auto w-0 {$$slots.header ? '' : 'pl-3'}">
{#if showUserProfile && !$$slots.header}
<div class="flex min-w-0 items-baseline gap-1.5 leading-5">
<div
class=" min-w-0 truncate text-[0.8125rem] font-medium text-gray-900 dark:text-gray-100"
@ -601,7 +605,9 @@
</div>
{:else}
<div
class="min-w-full [&_.markdown-prose]:text-sm! [&_.markdown-prose]:leading-[1.375rem]! {pending
class="min-w-full {$$slots.header
? '[&_.markdown-prose]:text-[0.8125rem]! [&_.markdown-prose]:leading-5! [&_p]:leading-5!'
: '[&_.markdown-prose]:text-sm! [&_.markdown-prose]:leading-[1.375rem]!'} {pending
? 'opacity-50'
: ''}"
>
@ -644,44 +650,45 @@
<div class="flex items-center flex-wrap gap-1 mt-1 mb-1">
{#each message.reactions as reaction}
<Tooltip
content={$i18n.t('{{NAMES}} reacted with {{REACTION}}', {
NAMES: reaction.users
.reduce((acc, u, idx) => {
const name = u.id === $user?.id ? $i18n.t('You') : u.name;
const total = reaction.users.length;
content={reaction.count !== undefined
? `${reaction.count} :${reaction.name}:`
: $i18n.t('{{NAMES}} reacted with {{REACTION}}', {
NAMES: reaction.users
.reduce((acc, u, idx) => {
const name = u.id === $user?.id ? $i18n.t('You') : u.name;
const total = reaction.users.length;
// First three names always added normally
if (idx < 3) {
const separator =
idx === 0
? ''
: idx === Math.min(2, total - 1)
? ` ${$i18n.t('and')} `
: ', ';
return `${acc}${separator}${name}`;
}
// First three names always added normally
if (idx < 3) {
const separator =
idx === 0
? ''
: idx === Math.min(2, total - 1)
? ` ${$i18n.t('and')} `
: ', ';
return `${acc}${separator}${name}`;
}
// More than 4 → "and X others"
if (idx === 3 && total > 4) {
return (
acc +
` ${$i18n.t('and {{COUNT}} others', {
COUNT: total - 3
})}`
);
}
// More than 4 → "and X others"
if (idx === 3 && total > 4) {
return (
acc +
` ${$i18n.t('and {{COUNT}} others', {
COUNT: total - 3
})}`
);
}
return acc;
}, '')
.trim(),
REACTION: `:${reaction.name}:`
})}
return acc;
}, '')
.trim(),
REACTION: `:${reaction.name}:`
})}
>
<button
{disabled}
class="flex min-h-6 items-center gap-1.5 text-sm leading-4 transition rounded-lg px-1.5 py-0.5 cursor-pointer {reaction.users
.map((u) => u.id)
.includes($user?.id)
class="flex min-h-6 items-center gap-1.5 text-sm leading-4 transition rounded-lg px-1.5 py-0.5 cursor-pointer {(reaction.reacted ??
reaction.users.map((u) => u.id).includes($user?.id))
? ' bg-blue-300/10 outline outline-blue-500/50 outline-1'
: 'bg-gray-300/10 dark:bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1'}"
on:click={() => {
@ -692,11 +699,11 @@
>
<Emoji className="size-3.5" shortCode={reaction.name} />
{#if reaction.users.length > 0}
{#if (reaction.count ?? reaction.users.length) > 0}
<div
class="text-[0.6875rem] font-normal text-gray-500 dark:text-gray-400"
>
{reaction.users?.length}
{reaction.count ?? reaction.users?.length}
</div>
{/if}
</button>
@ -710,11 +717,13 @@
}}
>
<Tooltip content={$i18n.t('Add Reaction')}>
<div
<button
type="button"
aria-label={$i18n.t('Add Reaction')}
class="flex items-center gap-1.5 bg-gray-500/10 hover:outline hover:outline-gray-700/30 dark:hover:outline-gray-300/30 hover:outline-1 transition rounded-lg p-1 cursor-pointer text-gray-500 dark:text-gray-400"
>
<FaceSmile />
</div>
</button>
</Tooltip>
</EmojiPicker>
{/if}
@ -722,6 +731,8 @@
</div>
{/if}
<slot name="footer" />
{#if !thread && message.reply_count > 0}
<div class="flex items-center gap-1.5 -mt-0.5 mb-1.5">
<button

View file

@ -77,7 +77,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -226,7 +226,7 @@
{#if channel}
<div class="flex flex-col w-full h-full bg-white dark:bg-gray-900">
<div
class="sticky top-0 flex min-h-10 items-center justify-between border-b border-gray-100/60 px-4 py-1.5 dark:border-gray-800/40"
class="sticky top-0 flex min-h-10 items-center justify-between px-4 py-1.5"
>
<div class=" font-medium text-sm">{$i18n.t('Thread')}</div>
@ -234,7 +234,7 @@
<button
type="button"
aria-label={$i18n.t('Close')}
class="flex size-7 items-center justify-center rounded-lg text-gray-500 hover:bg-gray-50/60 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/60 dark:hover:text-gray-300"
class="flex h-7 items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 px-0"
on:click={() => {
onClose();
}}
@ -248,7 +248,7 @@
class="flex-1 min-h-0 w-full overflow-y-auto will-change-transform"
bind:this={messagesContainerElement}
>
<div class="pt-3">
<div>
{#if searchAnchor}<div class="px-3 py-2 text-xs text-gray-500 flex justify-between">
<span>{$i18n.t('Showing search match')}</span><button
class="underline"

View file

@ -119,7 +119,7 @@
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => (show = false)}
>
<XMark className="size-4" />

View file

@ -5068,7 +5068,7 @@
<div class="self-center text-sm font-medium">{$i18n.t('Chat Variables')}</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
showChatVariablesModal = false;
}}
@ -5243,7 +5243,7 @@
<FilesOverlay show={dragged} />
{#if embedded}
<div
class="h-10 shrink-0 flex items-center justify-between gap-2 border-b border-gray-50/80 px-3 text-gray-700 dark:border-gray-850/40 dark:text-gray-200"
class="h-8 shrink-0 flex items-center justify-between gap-2 border-b border-gray-50/80 px-3 pb-1 text-gray-700 dark:border-gray-850/40 dark:text-gray-200"
>
<div class="flex min-w-0 items-center gap-2">
<EmbeddedChatHistoryDropdown
@ -5257,16 +5257,16 @@
onDeleteChat={onDeleteEmbeddedChat}
/>
</div>
<Tooltip content={$i18n.t('Close')} placement="bottom">
<button
type="button"
class="rounded-md p-1 text-gray-500 transition hover:text-gray-900 dark:hover:text-white"
on:click={() => onCloseEmbedded?.()}
aria-label={$i18n.t('Close')}
>
<XMark className="size-4" strokeWidth="2" />
</button>
</Tooltip>
{#if onCloseEmbedded}<Tooltip content={$i18n.t('Close')} placement="bottom">
<button
type="button"
class="rounded-md py-1 text-gray-500 transition hover:text-gray-900 dark:hover:text-white px-0"
on:click={() => onCloseEmbedded?.()}
aria-label={$i18n.t('Close')}
>
<XMark className="size-4" strokeWidth="2" />
</button>
</Tooltip>{/if}
</div>
{:else}
<Navbar

View file

@ -4,9 +4,10 @@
<script lang="ts">
import type { TerminalContext } from '$lib/types';
import { onMount, tick, getContext } from 'svelte';
import { onMount, onDestroy, tick, getContext } from 'svelte';
import {
config,
temporaryChatEnabled,
terminalServers,
showControls,
showSidebar,
@ -17,9 +18,16 @@
settings,
showFileNavPath,
selectedTerminalId,
user
user,
commentsView,
chatCommentsId,
openComments,
type CommentsView
} from '$lib/stores';
import { commentsRequest, resourceQuery } from '$lib/apis/comments';
import CommentsPanel from '$lib/components/comments/CommentsPanel.svelte';
import Controls from './Controls/Controls.svelte';
import CallPanel from './MessageInput/CallPanel.svelte';
import Drawer from '../common/Drawer.svelte';
@ -66,10 +74,10 @@
let viewportWidth = 0;
// Tab state for Controls+Files panel
let activeTab = savedTab;
let activeTab: typeof savedTab | 'comments' = savedTab;
// svelte-ignore reactive_declaration_module_script_dependency
$: {
savedTab = activeTab;
if (activeTab !== 'comments') savedTab = activeTab;
}
$: hasMessages = history?.messages && Object.keys(history.messages).length > 0;
@ -98,6 +106,58 @@
terminalFilesAvailable ||
(codeInterpreterEnabled && $config?.code?.interpreter_engine !== 'jupyter');
$: showOverviewTab = hasMessages;
$: showCommentsTab = !!(
$config?.features?.enable_comments &&
$user &&
isSavedChatId(chatId) &&
!$temporaryChatEnabled
);
let commentCount: number | null = null;
let countVersion = 0;
const setCommentCount = (total: number) => {
++countVersion;
commentCount = total;
};
const loadCommentCount = async (id: string) => {
const version = ++countVersion;
commentCount = null;
try {
const result = await commentsRequest<{ total: number }>(
`/?${resourceQuery({ resource_type: 'chat', resource_id: id })}&limit=1`
);
if (version === countVersion) commentCount = result.total;
} catch {
/* The comments panel handles access errors when opened. */
}
};
$: if (showCommentsTab && $showControls) loadCommentCount(chatId);
$: chatCommentsId.set(showCommentsTab ? chatId : null);
$: currentComments =
showCommentsTab &&
!$commentsView?.modal &&
$commentsView?.resource_type === 'chat' &&
$commentsView.resource_id === chatId
? $commentsView
: null;
let previousComments: CommentsView | null = null;
$: if (currentComments !== previousComments) {
previousComments = currentComments;
if (currentComments) {
activeTab = 'comments';
showArtifacts.set(false);
showEmbeds.set(false);
showCallOverlay.set(false);
showControls.set(true);
} else if (activeTab === 'comments') {
activeTab = 'controls';
showControls.set(false);
}
}
onDestroy(() => {
++countVersion;
chatCommentsId.set(null);
if (activeTab === 'comments') showControls.set(false);
});
// Tab fallback: if active tab becomes hidden, switch to next available
$: if (!showOverviewTab && activeTab === 'overview') activeTab = 'controls';
@ -105,10 +165,18 @@
$: if (!showControlsTab && activeTab === 'controls') {
if (showFilesTab) activeTab = 'files';
else if (showOverviewTab) activeTab = 'overview';
else if (showCommentsTab && $showControls)
openComments({ resource_type: 'chat', resource_id: chatId });
}
// Auto-close if there are no visible tabs
$: if (!showControlsTab && !showFilesTab && !showOverviewTab) {
$: if (
!showControlsTab &&
!showFilesTab &&
!showOverviewTab &&
!showCommentsTab &&
!showCommentsTab
) {
showControls.set(false);
}
@ -216,7 +284,8 @@
open={$showControls}
enabled={$mobile}
side="right"
blocked={$showSidebar || (!showControlsTab && !showFilesTab && !showOverviewTab)}
blocked={$showSidebar ||
(!showControlsTab && !showFilesTab && !showOverviewTab && !showCommentsTab)}
startTarget="#chat-container"
width={viewportWidth}
onOpenChange={(open) => showControls.set(open)}
@ -272,6 +341,20 @@
{$i18n.t('Controls')}
</button>
{/if}
{#if showCommentsTab}
<button
class="inline-flex items-center justify-center gap-1 px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'comments'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => openComments({ resource_type: 'chat', resource_id: chatId })}
>
{$i18n.t('Comments')}{#if commentCount !== null && commentCount > 0}<span
class="text-[0.8125rem] leading-none text-gray-400/70 dark:text-gray-500/70"
>{commentCount}</span
>{/if}
</button>
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
@ -296,7 +379,7 @@
{/if}
</div>
<button
class="p-1 rounded-lg text-gray-500 dark:text-gray-400"
class="py-1 rounded-lg text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 px-0"
on:click={() => showControls.set(false)}
aria-label={$i18n.t('Close')}
>
@ -320,7 +403,15 @@
? 'overflow-y-auto px-3 pt-1'
: ''}"
>
{#if activeTab === 'overview'}
{#if activeTab === 'comments' && currentComments}
{#key `${$user.id}:${chatId}:${currentComments.comment_id ?? ''}`}
<CommentsPanel
embedded={true}
bind:view={$commentsView}
onCountChange={setCommentCount}
/>
{/key}
{:else if activeTab === 'overview'}
<Overview
{history}
{chatUser}
@ -396,6 +487,20 @@
{$i18n.t('Controls')}
</button>
{/if}
{#if showCommentsTab}
<button
class="inline-flex items-center justify-center gap-1 px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
'comments'
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() => openComments({ resource_type: 'chat', resource_id: chatId })}
>
{$i18n.t('Comments')}{#if commentCount !== null && commentCount > 0}<span
class="text-[0.8125rem] leading-none text-gray-400/70 dark:text-gray-500/70"
>{commentCount}</span
>{/if}
</button>
{/if}
{#if showFilesTab}
<button
class="px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {activeTab ===
@ -420,7 +525,7 @@
{/if}
</div>
<button
class="p-1 rounded-lg text-gray-500 dark:text-gray-400"
class="py-1 rounded-lg text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 px-0"
on:click={() => showControls.set(false)}
aria-label={$i18n.t('Close')}
>
@ -444,7 +549,15 @@
? 'overflow-y-auto px-3 pt-1'
: ''}"
>
{#if activeTab === 'overview'}
{#if activeTab === 'comments' && currentComments}
{#key `${$user.id}:${chatId}:${currentComments.comment_id ?? ''}`}
<CommentsPanel
embedded={true}
bind:view={$commentsView}
onCountChange={setCommentCount}
/>
{/key}
{:else if activeTab === 'overview'}
<Overview
{history}
{chatUser}

View file

@ -3,9 +3,11 @@
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
import Annotation from '$lib/components/icons/Annotation.svelte';
import LightBulb from '$lib/components/icons/LightBulb.svelte';
export let id = '';
export let onComment: (() => void) | null = null;
export let actions = [];
export let onSetInputText = (text) => {};
@ -130,6 +132,14 @@
<div class="shrink-0">{action.label}</div>
</button>
{/each}
{#if onComment}<button
type="button"
aria-label={$i18n.t('Comment')}
class="px-1.5 py-[0.0625rem] hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
on:mousedown|preventDefault
on:click={onComment}
><Annotation className="size-3 shrink-0" /><span>{$i18n.t('Comment')}</span></button
>{/if}
</div>
{:else}
<div

View file

@ -50,7 +50,7 @@
<button
type="button"
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => (show = false)}
>
<XMark className="size-4" />

View file

@ -233,7 +233,7 @@
<!-- Close -->
<Tooltip content={$i18n.t('Close')}>
<button
class="p-1 rounded text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800 hover:text-gray-700 dark:hover:text-gray-300 transition"
class="py-1 rounded text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition px-0"
on:click={onClose}
aria-label={$i18n.t('Close')}
>

View file

@ -44,7 +44,7 @@
{$i18n.t('Attach Webpage')}
</h1>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;

View file

@ -114,7 +114,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -131,7 +131,7 @@
{/if}
</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close citation modal')}
on:click={() => {
show = false;

View file

@ -44,7 +44,7 @@
{$i18n.t('Citations')}
</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -46,7 +46,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
codeExecution = null;

View file

@ -1,10 +1,13 @@
<script>
import { onDestroy, onMount, tick, getContext } from 'svelte';
const i18n = getContext('i18n');
import Markdown from './Markdown.svelte';
import StructuredOutputRenderer from './StructuredOutputRenderer.svelte';
import {
commentSurface,
contentHash,
selectedComment
} from '$lib/components/comments/anchors';
import {
openComments,
config,
user,
artifactCode,
chatId as currentChatId,
mobile,
@ -13,6 +16,11 @@
showControls,
showEmbeds
} from '$lib/stores';
import { onDestroy, onMount, tick, getContext } from 'svelte';
const i18n = getContext('i18n');
import Markdown from './Markdown.svelte';
import StructuredOutputRenderer from './StructuredOutputRenderer.svelte';
import FloatingButtons from '../ContentRenderer/FloatingButtons.svelte';
import { createMessagesList, replaceOutsideCode } from '$lib/utils';
@ -140,6 +148,8 @@
: messageContent;
let autoOpenedArtifactIds = new Set();
/** @type {ReturnType<typeof selectedComment>} */
let commentSelection = null;
const hasClosingCodeFence = (raw = '') => /(?:^|\n)```[ \t]*$/.test(raw.trimEnd());
@ -201,6 +211,7 @@
if (selection.toString().trim().length > 0) {
floatingButtonsRange = selection.getRangeAt(0);
commentSelection = $config?.features?.enable_comments && $user ? selectedComment() : null;
positionFloatingButtons();
} else {
closeFloatingButtons();
@ -234,6 +245,7 @@
};
const closeFloatingButtons = () => {
commentSelection = null;
floatingButtonsRange = null;
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
if (buttonsContainerElement) {
@ -294,6 +306,7 @@
<div bind:this={contentContainerElement}>
{#if output?.length}
<StructuredOutputRenderer
commentToolbarId={floatingButtons ? `floating-buttons-${id}` : undefined}
{id}
{chatId}
{messageId}
@ -317,7 +330,18 @@
onPreview={previewHandler}
/>
{:else if $settings?.renderMarkdownInAssistantMessages ?? true}
<div class="markdown-prose">
<div
class="markdown-prose"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: 'content',
content_hash: contentHash(content ?? ''),
enabled: done && !compactPreview && !!chatId,
toolbar_id: floatingButtons ? `floating-buttons-${id}` : undefined
}}
>
<Markdown
{id}
{chatId}
@ -361,7 +385,20 @@
</div>
{/if}
{#if extracted.plainContent}
<div class="whitespace-pre-wrap text-[0.9375rem]">{extracted.plainContent}</div>
<div
class="whitespace-pre-wrap text-[0.9375rem]"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: 'content',
content_hash: contentHash(content ?? ''),
enabled: done && !compactPreview && !!chatId,
toolbar_id: floatingButtons ? `floating-buttons-${id}` : undefined
}}
>
{extracted.plainContent}
</div>
{/if}
{/if}
</div>
@ -371,6 +408,18 @@
bind:this={floatingButtonsElement}
{id}
actions={$settings?.floatingActionButtons ?? []}
onComment={commentSelection
? () => {
if (!commentSelection) return;
const { options, anchor } = commentSelection;
openComments(
{ resource_type: options.resource_type, resource_id: options.resource_id },
{ target_type: options.target_type, target_id: options.target_id, anchor }
);
window.getSelection()?.removeAllRanges();
closeFloatingButtons();
}
: null}
onSetInputText={(text) => {
onSetInputText(text);
closeFloatingButtons();

View file

@ -1,4 +1,8 @@
<script lang="ts">
import {
commentSurface,
contentHash
} from '$lib/components/comments/anchors';
import { getContext } from 'svelte';
import Collapsible from '$lib/components/common/Collapsible.svelte';
import Image from '$lib/components/common/Image.svelte';
@ -26,6 +30,7 @@
export let model = null;
export let save = false;
export let preview = false;
export let commentToolbarId: string | undefined = undefined;
export let compactPreview = false;
export let renderMarkdown = true;
export let editCodeBlock = true;
@ -77,7 +82,18 @@
{#each displayItems as displayItem (displayItem.id)}
{#if displayItem.type === 'message'}
{#if renderMarkdown}
<div class="markdown-prose">
<div
class="markdown-prose"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: displayItem.id,
content_hash: contentHash(displayItem.text),
enabled: done && !compactPreview && !!chatId,
toolbar_id: commentToolbarId
}}
>
<Markdown
id={`${id}-${displayItem.id}`}
{chatId}
@ -101,7 +117,20 @@
/>
</div>
{:else}
<div class="whitespace-pre-wrap text-[0.9375rem]">{displayItem.text}</div>
<div
class="whitespace-pre-wrap text-[0.9375rem]"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
target_type: 'chat_message',
target_id: messageId,
surface_id: displayItem.id,
content_hash: contentHash(displayItem.text),
enabled: done && !compactPreview && !!chatId,
toolbar_id: commentToolbarId
}}
>
{displayItem.text}
</div>
{/if}
{:else if displayItem.type === 'image'}
<Image src={displayItem.src} alt={$i18n.t('Generated Image')} />

View file

@ -1,4 +1,8 @@
<script lang="ts">
import {
commentSurface,
contentHash
} from '$lib/components/comments/anchors';
import EditPencilIcon from '$lib/components/layout/Sidebar/icons/EditPencil.svelte';
import CopyIcon from '$lib/components/layout/Sidebar/icons/Copy.svelte';
import TrashIcon from '$lib/components/layout/Sidebar/icons/Trash.svelte';
@ -435,7 +439,17 @@
>
{#if message.content}
{#if $settings?.renderMarkdownInUserMessages ?? true}
<div class="markdown-prose">
<div
class="markdown-prose"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
target_type: 'chat_message',
target_id: message.id,
surface_id: 'content',
content_hash: contentHash(message.content ?? ''),
enabled: !!chatId
}}
>
<Markdown
id={`${chatId}-${message.id}`}
content={message.content}
@ -446,6 +460,14 @@
{:else}
<div
class="whitespace-pre-wrap text-[0.9375rem]"
use:commentSurface={{
resource_type: 'chat', resource_id: chatId,
target_type: 'chat_message',
target_id: message.id,
surface_id: 'content',
content_hash: contentHash(message.content ?? ''),
enabled: !!chatId
}}
dir={$settings?.chatDirection ?? 'auto'}
>
{message.content}

View file

@ -5,6 +5,7 @@
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import CommentButton from '$lib/components/comments/CommentButton.svelte';
import Pin from '$lib/components/icons/Pin.svelte';
import PinSlash from '$lib/components/icons/PinSlash.svelte';
import Link from '$lib/components/icons/Link.svelte';
@ -24,7 +25,18 @@
export let onClose: Function = () => {};
$: canEdit =
model?.preset || model?.info?.base_model_id
? model?.info?.user_id === $user?.id
: $user?.role === 'admin';
const providerSupportsDelete = (provider = '') => provider === 'llama.cpp';
$: showComments =
$config?.features?.enable_comments &&
$user &&
model?.id &&
!model?.direct &&
!model?.arena &&
(model?.info || $user?.role === 'admin');
</script>
<Dropdown
@ -49,7 +61,7 @@
<div slot="content">
<DropdownMenu className="min-w-[13.125rem] z-[9999999]">
{#if model?.preset || model?.info?.base_model_id ? model?.info?.user_id === $user?.id : $user?.role === 'admin'}
{#if canEdit}
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
@ -70,7 +82,22 @@
<div class="flex items-center">{$i18n.t('Edit')}</div>
</button>
{/if}
{#if showComments}
<CommentButton
menu
modal
resource={{ resource_type: 'model', resource_id: model?.id }}
className="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"
onOpen={() => {
show = false;
onEdit();
}}
/>
{/if}
{#if canEdit}
{#if $user?.role === 'admin' && (model?.owned_by === 'ollama' || providerSupportsDelete(model?.provider))}
<button
type="button"
@ -152,7 +179,9 @@
{#if $config?.features.enable_community_sharing}
<hr class="border-gray-50 dark:border-gray-800/30 mx-1 my-0.5" />
{/if}
{#if $config?.features.enable_community_sharing}
<button
type="button"
class="select-none flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/40 dark:hover:bg-gray-800/40 transition"

View file

@ -238,34 +238,6 @@
</h1>
{/if}
{#if shareEnabled && chat && (chat.id || $temporaryChatEnabled) && (scrollToTop || ($artifactContents ?? []).length > 0 || (!readOnly && !$temporaryChatEnabled) || $user?.role === 'admin' || ($user?.permissions?.chat?.export ?? true))}
<Menu
{chat}
{shareEnabled}
{readOnly}
{scrollToTop}
shareHandler={() => {
showShareChatModal = !showShareChatModal;
}}
{renameHandler}
archiveChatHandler={() => {
archiveChatHandler(chat.id);
}}
deleteChatHandler={() => {
deleteChatHandler(chat.id);
}}
{moveChatHandler}
>
<button
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="chat-context-menu-button"
aria-label={$i18n.t('Chat actions')}
>
<EllipsisHorizontal className="size-4.5" strokeWidth="1.5" />
</button>
</Menu>
{/if}
{#if !$temporaryChatEnabled && ($user?.role === 'admin' || ($user?.permissions?.chat?.delete ?? true))}
<button
id="delete-chat-button"
@ -291,7 +263,7 @@
{/if}
</div>
<div class="lg:mr-1 flex flex-none items-center gap-3 self-center">
<div class="lg:mr-1 flex flex-none items-center gap-2 self-center">
<!-- <div class="md:hidden flex self-center w-[0.0625rem] h-5 mx-2 bg-gray-300 dark:bg-stone-700" /> -->
{#if $user?.role === 'user' ? ($user?.permissions?.chat?.temporary ?? true) && !($user?.permissions?.chat?.temporary_enforced ?? false) : true}
@ -368,6 +340,34 @@
/>
{/if}
{#if shareEnabled && chat?.id && (scrollToTop || ($artifactContents ?? []).length > 0 || (!readOnly && !$temporaryChatEnabled) || $user?.role === 'admin' || ($user?.permissions?.chat?.export ?? true))}
<Menu
{chat}
{shareEnabled}
{readOnly}
{scrollToTop}
shareHandler={() => {
showShareChatModal = !showShareChatModal;
}}
{renameHandler}
archiveChatHandler={() => {
archiveChatHandler(chat.id);
}}
deleteChatHandler={() => {
deleteChatHandler(chat.id);
}}
{moveChatHandler}
>
<button
class="flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-50/40 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800/40 dark:hover:text-gray-200"
id="chat-context-menu-button"
aria-label={$i18n.t('Chat actions')}
>
<EllipsisHorizontal className="size-4.5" strokeWidth="1.5" />
</button>
</Menu>
{/if}
{#if $user?.role === 'admin' || ($user?.permissions.chat?.controls ?? true)}
<Tooltip content={$i18n.t('Controls')}>
<button

View file

@ -35,7 +35,7 @@
{$i18n.t('Quick Actions')}
</h1>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;

View file

@ -31,7 +31,7 @@
{$i18n.t('Manage')}
</h1>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;

View file

@ -366,7 +366,7 @@
<div class="flex justify-between px-4 pt-3 pb-1">
<div class="text-sm font-medium self-center">{$i18n.t('Sync Usage Stats')}</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close modal')}
on:click={() => {
show = false;

View file

@ -128,7 +128,7 @@
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Share Chat')}</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;

View file

@ -24,7 +24,7 @@
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Available Skills')}</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;

View file

@ -75,7 +75,7 @@
<div class=" flex justify-between dark:text-gray-300 px-4 pt-3 pb-1">
<div class=" text-sm font-medium self-center">{$i18n.t('Available Tools')}</div>
<button
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close')}
on:click={() => {
show = false;

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import { config, user, openComments, commentCounts } from '$lib/stores';
import { isSavedChatId } from '$lib/utils/chatId';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Annotation from '$lib/components/icons/Annotation.svelte';
import {
commentsRequest,
resourceQuery,
type CommentResource,
type CommentTarget
} from '$lib/apis/comments';
export let resource: CommentResource;
export let target: CommentTarget = {};
export let label = 'Comments';
export let onOpen = () => {};
export let menu = false;
export let modal: boolean | undefined = undefined;
export let className = '';
export let wrapperClassName = '';
$: buttonClass =
className ||
(menu
? 'select-none flex h-[1.6875rem] w-full cursor-pointer items-center gap-2 rounded-xl bg-transparent px-2 text-[0.8125rem] hover:text-gray-900 dark:hover:text-gray-100'
: 'inline-flex shrink-0 items-center justify-center p-0 bg-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors');
const i18n = getContext<any>('i18n');
let requestVersion = 0;
$: enabled = !!(
$config?.features?.enable_comments &&
$user &&
resource.resource_id &&
(resource.resource_type !== 'chat' ||
isSavedChatId(resource.resource_id))
);
$: countKey = enabled && !menu ? `${$user.id}:${resourceQuery(resource)}` : '';
$: count = countKey ? ($commentCounts[countKey] ?? 0) : 0;
const loadCount = async (key: string) => {
const version = ++requestVersion;
if (!key) return;
const previous = $commentCounts[key];
try {
const result = await commentsRequest<{ total: number }>(
`/?${resourceQuery(resource)}&limit=1`
);
if (version === requestVersion && $commentCounts[key] === previous)
commentCounts.update((counts) => ({ ...counts, [key]: result.total }));
} catch {
if (version === requestVersion) commentCounts.update((counts) => ({ ...counts, [key]: 0 }));
}
};
$: loadCount(countKey);
onDestroy(() => {
++requestVersion;
});
</script>
<svelte:window on:focus={() => loadCount(countKey)} />
{#if enabled}
{#snippet commentButton()}
<button
type="button"
class={buttonClass}
aria-label={count > 0 ? `${$i18n.t(label)} (${count})` : $i18n.t(label)}
on:click|stopPropagation={() => {
onOpen();
openComments(resource, { ...target, modal });
}}
><span class="relative inline-flex shrink-0">
<Annotation className={menu ? 'size-3.5' : 'size-4'} />
{#if count > 0}
<span
aria-hidden="true"
class="absolute -top-[3px] -end-[5px] flex h-3 min-w-3 items-center justify-center whitespace-nowrap rounded-full bg-red-500 px-px text-[8px] leading-none font-normal text-white"
>{count > 99 ? '99+' : count}</span
>
{/if}
</span>{#if menu}{$i18n.t(label)}{/if}</button
>
{/snippet}
{#if menu}
{@render commentButton()}
{:else}
<Tooltip content={$i18n.t(label)} className={`flex ${wrapperClassName}`}>
{@render commentButton()}
</Tooltip>
{/if}
{/if}

View file

@ -0,0 +1,186 @@
<script lang="ts">
import { getContext } from 'svelte';
import XMark from '$lib/components/icons/XMark.svelte';
import { settings, mobile, commentDrafts } from '$lib/stores';
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
import MentionList from '$lib/components/channel/MessageInput/MentionList.svelte';
import { getSuggestionRenderer } from '$lib/components/common/RichTextInput/suggestions';
import { commentsRequest, resourceQuery, type CommentResource } from '$lib/apis/comments';
export let resource: CommentResource;
export let draftKey: string;
export let contextLabel = '';
export let quote = '';
export let onClearContext: (() => void) | null = null;
export let initialContent = '';
export let submitLabel = 'Comment';
export let onSubmitted: () => void = () => {};
export function focus() {
editor?.commands.focus();
}
export let onSubmit: (content: string, tempId: string) => Promise<void>;
export let onCancel: (() => void) | null = null;
const i18n = getContext<any>('i18n');
let content = commentDrafts.get(draftKey)?.content ?? initialContent;
let tempId = commentDrafts.get(draftKey)?.temp_id ?? crypto.randomUUID();
let editor: import('@tiptap/core').Editor | null = null;
let saving = false;
let submitted = false;
let error = '';
const suggestions = [
{
char: '@',
render: getSuggestionRenderer(MentionList, {
i18n,
userSuggestions: true,
modelSuggestions: false,
loadUsers: (query: string, signal: AbortSignal) =>
commentsRequest(
`/mentions?${resourceQuery(resource)}&query=${encodeURIComponent(query)}`,
'GET',
undefined,
signal
)
})
}
];
const remember = () => {
if (submitted) return;
if (content.trim()) commentDrafts.set(draftKey, { content, temp_id: tempId });
else commentDrafts.delete(draftKey);
};
const submit = async () => {
if (saving || !content.trim()) return;
saving = true;
error = '';
remember();
try {
await onSubmit(content, tempId);
submitted = true;
content = '';
commentDrafts.delete(draftKey);
tempId = crypto.randomUUID();
editor?.commands.clearContent();
onSubmitted();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
saving = false;
}
};
</script>
<div
class="w-full"
on:focusout={(event) => {
if (
onCancel &&
!saving &&
!content.trim() &&
!event.currentTarget.contains(event.relatedTarget as Node | null)
)
onCancel();
}}
>
<div class="min-w-0 flex-1">
<div
dir={$settings?.chatDirection === 'RTL'
? 'rtl'
: $settings?.chatDirection === 'LTR'
? 'ltr'
: 'auto'}
class="relative rounded-2xl border border-gray-100/50 dark:border-gray-850/50 hover:border-gray-200 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white/5 dark:bg-gray-500/5 ps-3 pb-2.5 {contextLabel ||
quote
? 'pt-1'
: 'pt-2.5'} {($settings?.highContrastMode ?? false)
? 'focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-blue-500'
: ''}"
>
{#if contextLabel || quote}
<div class="pb-2 pe-3 text-left w-full flex flex-col">
<div
class="flex items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<span class="truncate">{contextLabel}</span>{#if onClearContext}<button
type="button"
class="flex h-6 shrink-0 items-center justify-center p-0 hover:text-gray-700 dark:hover:text-gray-200"
aria-label={$i18n.t('Remove context')}
on:click={onClearContext}><XMark className="size-3.5" /></button
>{/if}
</div>
{#if quote}<div
class="mb-1 mt-1 max-h-20 overflow-auto border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-xs text-gray-500 whitespace-pre-wrap"
>
{quote}
</div>{/if}
</div>
{/if}
<div class={submitLabel === 'Save' ? 'pe-16' : 'pe-12'}>
<div
class="scrollbar-hidden rtl:text-right ltr:text-left bg-transparent dark:text-gray-100 outline-hidden w-full resize-none h-fit max-h-96 overflow-auto"
>
<RichTextInput
id={`comment-input-${tempId}`}
bind:editor
value={content}
{suggestions}
placeholder={$i18n.t('Add a comment…')}
className="input-prose min-h-5 h-full text-[0.8125rem]! leading-5! [&_.tiptap>p:first-child]:mt-0! [&_.tiptap>p:last-child]:mb-0!"
editable={!saving}
showFormattingToolbar={false}
onChange={(value) => {
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();
}
}}
/>
</div>
</div>
{#if error}<p role="alert" class="py-1 text-xs text-red-500">{error}</p>{/if}
<div class="absolute bottom-2 end-2.5 flex items-center">
<div class="flex items-center gap-2">
<button
type="button"
aria-label={$i18n.t(submitLabel)}
title={$i18n.t(submitLabel)}
class="flex h-6 shrink-0 items-center justify-center bg-black text-white transition hover:bg-gray-900 disabled:bg-gray-200 disabled:text-gray-500 dark:bg-white dark:text-black dark:hover:bg-gray-100 dark:disabled:bg-gray-600 dark:disabled:text-gray-850 {submitLabel ===
'Save'
? 'rounded-md px-2 text-xs'
: 'size-6 rounded-full'}"
disabled={saving || !content.trim() || content.length > 20000}
on:click={submit}
>
{#if submitLabel === 'Save'}
{$i18n.t(submitLabel)}
{:else}
<svg aria-hidden="true" viewBox="0 0 16 16" fill="currentColor" class="size-4">
<path
fill-rule="evenodd"
d="M8 14a.75.75 0 0 1-.75-.75V4.56L4.03 7.78a.75.75 0 0 1-1.06-1.06l4.5-4.5a.75.75 0 0 1 1.06 0l4.5 4.5a.75.75 0 0 1-1.06 1.06L8.75 4.56v8.69A.75.75 0 0 1 8 14Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</button>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,224 @@
<script lang="ts">
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
import Message from '$lib/components/channel/Messages/Message.svelte';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import EmojiPicker from '$lib/components/common/EmojiPicker.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import FaceSmile from '$lib/components/icons/FaceSmile.svelte';
import dayjs from 'dayjs';
import ProfileImage from '$lib/components/chat/Messages/ProfileImage.svelte';
import ProfilePreview from '$lib/components/channel/Messages/Message/ProfilePreview.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import Pencil from '$lib/components/icons/Pencil.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Link from '$lib/components/icons/Link.svelte';
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
import CommentComposer from './CommentComposer.svelte';
import { commentsRequest, type ResourceComment } from '$lib/apis/comments';
import { highlightAnchor } from './anchors';
import { commentLink } from '$lib/utils';
export let comment: ResourceComment;
export let onReply: (comment: ResourceComment) => void;
export let onChange: () => Promise<void>;
const i18n = getContext<any>('i18n');
let editing = false;
let confirmDelete = false;
let unavailable = false;
let busy = false;
let editingEtag = comment.etag;
$: message = {
...comment,
user_id: comment.user?.id,
latest_reply_at: comment.latest_reply_at ?? comment.updated_at,
reactions: comment.reactions.map((r) => ({ ...r, users: [] }))
};
const action = async (fn: () => Promise<unknown>) => {
if (busy) return;
busy = true;
try {
await fn();
await onChange();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
busy = false;
}
};
const react = (name: string) => {
const remove = comment.reactions.some((r) => r.name === name && r.reacted);
return action(() =>
commentsRequest(
`/${comment.id}/reactions${remove ? `?name=${encodeURIComponent(name)}` : ''}`,
remove ? 'DELETE' : 'PUT',
remove ? undefined : { name }
)
);
};
const actionClass =
'flex size-6 items-center justify-center rounded-md text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200 transition';
</script>
<ConfirmDialog
bind:show={confirmDelete}
title={$i18n.t('Delete comment?')}
message={$i18n.t('This cannot be undone. Replies will remain visible.')}
onConfirm={() => {
action(() => commentsRequest(`/${comment.id}?etag=${comment.etag}`, 'DELETE'));
}}
/>
<div id={`comment-${comment.id}`} class="comment-item group/comment">
<Message
className="px-3 py-1.5! rounded-lg bg-transparent hover:bg-transparent!"
{message}
channel={null}
id="comments"
thread={true}
showUserProfile={!!comment.user}
disabled={!!comment.deleted_at || busy}
onReply={() => onReply(comment)}
onReaction={react}
>
<svelte:fragment slot="header">
<div class="mb-1.5 flex min-w-0 items-center gap-2">
<div class="-ml-1 size-6 shrink-0">
<ProfilePreview user={comment.user}>
<ProfileImage
src={comment.user?.id
? `${WEBUI_API_BASE_URL}/users/${comment.user.id}/profile/image`
: undefined}
className="size-6 shrink-0 rounded-full!"
/>
</ProfilePreview>
</div>
<span class="min-w-0 truncate text-[0.8125rem] font-medium text-gray-900 dark:text-gray-100"
>{comment.user?.name ?? $i18n.t('Unknown User')}</span
>
<Tooltip
className="ms-auto flex shrink-0"
content={dayjs(comment.created_at / 1000000)
.locale($i18n.language)
.format('LLLL')}
>
<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500"
>{dayjs(comment.created_at / 1000000)
.locale($i18n.language)
.fromNow()}</span
>
</Tooltip>
<div class="shrink-0">
{#if !comment.deleted_at && !busy}
<Dropdown align="end" closeOnSelect>
<Tooltip content={$i18n.t('More')}>
<button type="button" aria-label={$i18n.t('More')} class={actionClass}>
<EllipsisHorizontal className="size-4" />
</button>
</Tooltip>
<div slot="content">
<DropdownMenu className="min-w-36">
{#if !comment.reactions.length}
<EmojiPicker align="end" onSubmit={react}>
<button
type="button"
class="flex w-full h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] hover:bg-gray-50/60 dark:hover:bg-gray-800/60"
>
<FaceSmile className="size-3.5" /><span>{$i18n.t('Add Reaction')}</span>
</button>
</EmojiPicker>
{/if}
{#if comment.can_edit}
<button
type="button"
on:click={() => {
editingEtag = comment.etag;
editing = true;
}}><Pencil /><span>{$i18n.t('Edit')}</span></button
>
{/if}
<button
type="button"
on:click={() => {
navigator.clipboard
.writeText(new URL(commentLink(comment), location.origin).href)
.catch((e) => toast.error(String(e)));
}}><Link /><span>{$i18n.t('Copy link')}</span></button
>
{#if comment.can_delete}
<hr class="border-gray-100 dark:border-gray-800" />
<button type="button" on:click={() => (confirmDelete = true)}>
<GarbageBin /><span>{$i18n.t('Delete')}</span>
</button>
{/if}
</DropdownMenu>
</div>
</Dropdown>
{/if}
</div>
</div>
</svelte:fragment>
<svelte:fragment slot="content">
{#if comment.deleted_at}<p class="text-xs text-gray-500">{$i18n.t('Comment deleted')}</p>
{:else if editing}
<div class="py-2">
<CommentComposer
resource={comment}
draftKey={`edit:${comment.id}`}
initialContent={comment.content}
submitLabel="Save"
onCancel={() => (editing = false)}
onSubmit={async (content) => {
await commentsRequest(`/${comment.id}`, 'PATCH', { content, etag: editingEtag });
editing = false;
await onChange();
}}
/>
</div>
{:else}
{#if comment.meta?.anchor}
<button
type="button"
class="my-1 max-h-24 w-full overflow-auto border-l-2 border-gray-200 dark:border-gray-700 ps-2 text-left text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap break-words"
on:click={() =>
(unavailable = !highlightAnchor(
comment,
comment.target_id ?? '',
comment.meta!.anchor!
))}>{comment.meta.anchor.quote}</button
>
{#if unavailable}<p class="mb-1 text-[0.6875rem] text-gray-500">
{$i18n.t('Original passage changed or is unavailable.')}
<a class="underline" href={commentLink(comment)}>{$i18n.t('Open source')}</a>
</p>{/if}
{:else if comment.target_id}<a
class="text-[0.6875rem] text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
href={commentLink(comment)}
>{$i18n.t(comment.target_type === 'note_page' ? 'Page comment' : 'Message comment')}</a
>{/if}
<div class="markdown-prose comment-body text-gray-600 dark:text-gray-300">
<Markdown
id={`comment-body-${comment.id}`}
content={comment.content}
allowEmbeds={false}
allowHtml={false}
editCodeBlock={false}
/>
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="footer">
{#if !comment.deleted_at && !editing}
<div class="mt-1 flex h-5 items-center gap-3">
<button
type="button"
disabled={busy}
class="text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-200 transition"
on:click={() => onReply(comment)}>{$i18n.t('Reply')}</button
>
</div>
{/if}
</svelte:fragment>
</Message>
</div>

View file

@ -0,0 +1,144 @@
<script lang="ts">
import { getContext, onDestroy, tick } from 'svelte';
import { commentsRequest, type ResourceComment } from '$lib/apis/comments';
import CommentItem from './CommentItem.svelte';
import CommentReplyComposer from './CommentReplyComposer.svelte';
import { groupReplies } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
export let parent: ResourceComment;
export let replyTo: ResourceComment | null;
export let onCancel: () => void;
export let onSubmitted: (id: string) => void;
export let onPosted: (comment: ResourceComment) => void;
export let postedComment: ResourceComment | null = null;
export let revision: number;
export let linkedCommentId: string | null = null;
export let onReply: (comment: ResourceComment) => void;
export let onChange: () => Promise<void>;
export let onError: (error: unknown) => void;
const i18n = getContext<any>('i18n');
type CommentPage = { items: ResourceComment[]; next_cursor: string | null };
let items: ResourceComment[] = [];
$: children = groupReplies(items, parent.id);
let cursor: string | null = null;
let loading = false;
let alive = true;
let version = 0;
let revealedId: string | null = null;
let insertedId: string | null = null;
$: if (postedComment?.parent_id === parent.id && postedComment.id !== insertedId) {
// Show the saved reply in the same update that removes its composer.
// Ignore older requests and avoid scrolling while the layout settles.
++version;
loading = false;
items = [...items.filter((comment) => comment.id !== postedComment.id), postedComment];
insertedId = postedComment.id;
revealedId = postedComment.id;
}
const fetchPage = (cursor?: string) =>
commentsRequest<CommentPage>(
`/${parent.id}/replies${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`
);
const loadAncestors = async (comments: ResourceComment[], request: number) => {
const ids = new Set(comments.map((comment) => comment.id));
// A linked reply can be outside the loaded page, along with its ancestors.
for (const comment of comments) {
const target = comment.reply_to_id;
if (!target || target === parent.id || ids.has(target)) continue;
if (!alive || request !== version) return;
const ancestor = await commentsRequest<ResourceComment>(`/${target}`);
if (ancestor.parent_id !== parent.id) throw new Error('Reply belongs to another comment');
ids.add(ancestor.id);
comments.push(ancestor);
}
};
const refresh = async () => {
const request = ++version;
loading = true;
try {
const result = await fetchPage();
while (alive && result.next_cursor && result.items.length < items.length) {
const next = await fetchPage(result.next_cursor);
result.items.push(...next.items);
result.next_cursor = next.next_cursor;
}
if (linkedCommentId && !result.items.some((c) => c.id === linkedCommentId))
result.items.push(await commentsRequest<ResourceComment>(`/${linkedCommentId}`));
await loadAncestors(result.items, request);
if (!alive || request !== version) return;
items = result.items;
cursor = result.next_cursor;
if (linkedCommentId && revealedId !== linkedCommentId) {
revealedId = linkedCommentId;
await tick();
document.getElementById(`comment-${linkedCommentId}`)?.scrollIntoView({ block: 'nearest' });
}
} catch (e) {
if (alive && request === version) onError(e);
} finally {
if (alive && request === version) loading = false;
}
};
const more = async () => {
if (!cursor || loading) return;
const request = ++version;
loading = true;
try {
const result = await fetchPage(cursor);
const combined = [
...items,
...result.items.filter((c) => !items.some((old) => old.id === c.id))
];
await loadAncestors(combined, request);
if (!alive || request !== version) return;
items = combined;
cursor = result.next_cursor;
} catch (e) {
if (alive && request === version) onError(e);
} finally {
if (alive && request === version) loading = false;
}
};
$: if (revision >= 0 && (parent.reply_count > 0 || linkedCommentId)) refresh();
onDestroy(() => {
alive = false;
++version;
});
</script>
{#snippet branch(parentId: string)}
{#each children.get(parentId) ?? [] as comment (comment.id)}
<CommentItem {comment} {onReply} {onChange} />
{#if replyTo?.id === comment.id}
<div class="ms-4">
<CommentReplyComposer {comment} {onCancel} {onSubmitted} {onPosted} />
</div>
{/if}
{#if children.has(comment.id)}
<div
id={`comment-replies-${comment.id}`}
class="ms-4 border-s border-gray-200/40 dark:border-gray-700/30"
>
{@render branch(comment.id)}
</div>
{/if}
{/each}
{/snippet}
{#if items.length || loading}
<div
id={`comment-replies-${parent.id}`}
class="ms-4 border-s border-gray-200/40 dark:border-gray-700/30"
>
{@render branch(parent.id)}
{#if loading && !items.length}<div class="flex justify-center py-2">
<Spinner className="size-4" />
</div>{/if}
{#if cursor}<button
type="button"
class="mx-4 my-2 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
disabled={loading}
on:click={more}>{$i18n.t('Load more')}</button
>{/if}
</div>
{/if}

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { user } from '$lib/stores';
import { commentsRequest, resourceQuery, type ResourceComment } from '$lib/apis/comments';
import CommentComposer from './CommentComposer.svelte';
export let comment: ResourceComment;
export let onCancel: () => void;
export let onSubmitted: (id: string) => void;
export let onPosted: (comment: ResourceComment) => void;
let postedComment: ResourceComment | null = null;
let composer: CommentComposer;
let container: HTMLDivElement;
onMount(() => {
tick().then(() => {
container?.scrollIntoView({ block: 'nearest' });
composer?.focus();
});
});
</script>
<div class="px-3 py-2" bind:this={container}>
<CommentComposer
bind:this={composer}
resource={comment}
draftKey={`${$user?.id}:${resourceQuery(comment)}:${comment.id}`}
submitLabel="Reply"
onSubmitted={() => {
onSubmitted(comment.id);
if (postedComment) onPosted(postedComment);
}}
{onCancel}
onSubmit={async (content, temp_id) => {
postedComment = await commentsRequest<ResourceComment>(
`/${comment.parent_id ?? comment.id}/replies`,
'POST',
{ content, temp_id, reply_to_id: comment.id }
);
}}
/>
</div>

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { getContext, onMount, tick } from 'svelte';
import { page } from '$app/stores';
import {
config,
user,
mobile,
commentsView,
chatCommentsId,
noteCommentsId,
commentDrafts,
openComments
} from '$lib/stores';
import { commentsRequest, type ResourceComment } from '$lib/apis/comments';
import ResizableSidePanel from '$lib/components/common/ResizableSidePanel.svelte';
import Drawer from '$lib/components/common/Drawer.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Annotation from '$lib/components/icons/Annotation.svelte';
import CommentsPanel from './CommentsPanel.svelte';
import { selectedComment } from './anchors';
import { toast } from 'svelte-sonner';
const i18n = getContext<any>('i18n');
let selection: ReturnType<typeof selectedComment> = null;
let account: string | null = null;
let deepLink = '';
let currentUrl = $page.url.href;
let navigation = 0;
$: if ($page.url.href !== currentUrl) {
currentUrl = $page.url.href;
navigation += 1;
deepLink = '';
commentsView.set(null);
selection = null;
}
$: commentsAllowed = !!($config?.features?.enable_comments && $user && !$page.url.pathname.startsWith('/s/'));
let preparing = false;
$: if (($user?.id ?? null) !== account) {
account = $user?.id ?? null;
commentsView.set(null);
selection = null;
tick().then(() => commentDrafts.clear());
}
const loadLink = async (id: string) => {
const version = navigation;
const accountId = account;
try {
const comment = await commentsRequest<ResourceComment>(`/${encodeURIComponent(id)}`);
if (
version === navigation &&
accountId === account &&
$page.url.searchParams.get('comment') === id
)
openComments(
{ resource_type: comment.resource_type, resource_id: comment.resource_id },
{ comment_id: id }
);
} catch (e) {
if (version === navigation && accountId === account)
toast.error(e instanceof Error ? e.message : String(e));
}
};
$: if (
commentsAllowed &&
$page.url.searchParams.get('comment') &&
deepLink !== $page.url.href
) {
deepLink = $page.url.href;
loadLink($page.url.searchParams.get('comment')!);
}
const select = () => {
if (!preparing)
selection = commentsAllowed ? selectedComment() : null;
};
const commentSelection = async () => {
const selected = selection;
if (!selected || preparing) return;
preparing = true;
const version = navigation;
const accountId = account;
try {
await selected.options.prepare?.();
if (version !== navigation || accountId !== account) return;
openComments(
{
resource_type: selected.options.resource_type,
resource_id: selected.options.resource_id
},
{
target_type: selected.options.target_type,
target_id: selected.options.target_id,
anchor: selected.anchor
}
);
window.getSelection()?.removeAllRanges();
selection = null;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
preparing = false;
}
};
onMount(() => {
document.addEventListener('mouseup', select);
document.addEventListener('keyup', select);
return () => {
document.removeEventListener('mouseup', select);
document.removeEventListener('keyup', select);
};
});
</script>
<svelte:window
on:keydown={(event) => {
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}
<button
type="button"
class="fixed z-9999 flex items-center gap-1 rounded-xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 px-2 py-1 text-xs shadow-xl"
style={`left:${Math.max(8, Math.min(selection.rect.left, window.innerWidth - 130))}px;top:${Math.min(window.innerHeight - 48, selection.rect.bottom + 12)}px`}
on:mousedown|preventDefault
disabled={preparing}
on:click={commentSelection}><Annotation className="size-3" />{$i18n.t('Comment')}</button
>
{/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}
<Modal
show={true}
size="md"
ariaLabel={$i18n.t('Comments')}
className="bg-white dark:bg-gray-900 rounded-4xl overflow-hidden"
onRequestClose={() => commentsView.set(null)}
>
<div class="h-[min(70dvh,40rem)]">
<CommentsPanel bind:view={$commentsView} />
</div>
</Modal>
{:else if $mobile}
<Drawer show={true} onClose={() => commentsView.set(null)}
><div class="h-dvh"><CommentsPanel bind:view={$commentsView} /></div></Drawer
>
{:else}
<ResizableSidePanel
open={true}
width={350}
minWidth={300}
maxWidth={640}
minSiblingWidth={400}
resizerId="comments-resizer"
storageKey="comments-panel-width"
className="h-full min-h-0"
onClose={() => commentsView.set(null)}
>
<aside aria-label={$i18n.t('Comments')} class="h-full w-full">
<CommentsPanel bind:view={$commentsView} />
</aside>
</ResizableSidePanel>
{/if}
{/key}
{/if}
{/if}
<style>
:global(::highlight(resource-comment)) {
background: #fde68a;
color: #111827;
}
</style>

View file

@ -0,0 +1,334 @@
<script lang="ts">
import { getContext, onMount, onDestroy, tick } from 'svelte';
import { socket, user, commentsView, commentCounts, type CommentsView } from '$lib/stores';
import {
commentsRequest,
getComments,
resourceQuery,
CommentError,
type CommentResource,
type ResourceComment
} from '$lib/apis/comments';
import XMark from '$lib/components/icons/XMark.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import CommentItem from './CommentItem.svelte';
import CommentReplies from './CommentReplies.svelte';
import CommentComposer from './CommentComposer.svelte';
import CommentReplyComposer from './CommentReplyComposer.svelte';
export let view: CommentsView;
export let embedded = false;
export let onCountChange: (count: number) => void = () => {};
let total: number | null = null;
const i18n = getContext<any>('i18n');
const resourceLabels = {
model: 'Model',
knowledge: 'Knowledge',
prompt: 'Prompt',
skill: 'Skill',
tool: 'Tool',
note: 'Note',
chat: 'Chat'
};
let resource: CommentResource | null = null;
let title = '';
$: if (resource && total !== null && $user)
commentCounts.update((counts) => ({
...counts,
[`${$user.id}:${resourceQuery(resource)}`]: total
}));
let composerVersion = 0;
let loadedCount = 0;
let items: ResourceComment[] = [];
let cursor: string | null = null;
let replyTo: ResourceComment | null = null;
let postedComment: ResourceComment | null = null;
let linkedRootId: string | null = null;
let linkedCommentId: string | null = null;
let error = '';
let loading = true;
let alive = true;
let loadingMore = false;
let refreshTimer: ReturnType<typeof setTimeout>;
let connection: import('socket.io-client').Socket | null = null;
let refreshVersion = 0;
let revision = 0;
let previousView = view;
$: if (view !== previousView) {
previousView = view;
replyTo = null;
}
const clearContext = () => {
replyTo = null;
view = { ...view, target_id: undefined, target_type: undefined, anchor: undefined };
};
const reply = (comment: ResourceComment) => {
replyTo = comment;
};
const postedReply = (comment: ResourceComment) => {
if (!alive) return;
postedComment = comment;
linkedRootId = comment.parent_id;
linkedCommentId = comment.id;
void refresh();
};
const fail = (e: unknown) => {
if (!alive) return;
error = e instanceof Error ? e.message : String(e);
if (e instanceof CommentError && [401, 403, 404].includes(e.status)) {
items = [];
replyTo = null;
resource = null;
}
};
const refresh = async () => {
if (!resource || !alive) return;
const version = ++refreshVersion;
try {
const result = await getComments(resource);
while (alive && result.next_cursor && result.items.length < loadedCount) {
const next = await getComments(resource, result.next_cursor);
result.items.push(...next.items);
result.next_cursor = next.next_cursor;
}
if (!alive || version !== refreshVersion) return;
loadedCount = result.items.length;
if (linkedRootId && !result.items.some((c) => c.id === linkedRootId))
result.items.push(await commentsRequest<ResourceComment>(`/${linkedRootId}`));
if (!alive || version !== refreshVersion) return;
items = result.items.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id));
cursor = result.next_cursor;
total = result.total;
onCountChange(total);
revision += 1;
error = '';
} catch (e) {
if (version === refreshVersion) fail(e);
}
};
const join = () => {
if (resource)
connection?.emit('comments:join', resource, (result: { error?: string }) => {
if (result?.error) fail(new CommentError(403, result.error));
});
};
const changed = (data: CommentResource) => {
if (
data.resource_type !== resource?.resource_type ||
data.resource_id !== resource?.resource_id
)
return;
clearTimeout(refreshTimer);
refreshTimer = setTimeout(refresh, 150);
};
const reconnect = () => {
join();
refresh();
};
const unavailable = () =>
fail(new CommentError(403, $i18n.t('Comments are no longer available.')));
const setup = async () => {
loading = true;
try {
const context = await commentsRequest<CommentResource & { title: string }>(
`/context?${resourceQuery(view)}`
);
if (!alive) return;
resource = { resource_type: context.resource_type, resource_id: context.resource_id };
title = context.title;
if (view.comment_id) {
const comment = await commentsRequest<ResourceComment>(`/${view.comment_id}`);
if (!alive) return;
linkedRootId = comment.parent_id ?? comment.id;
linkedCommentId = comment.id;
}
await refresh();
join();
} catch (e) {
fail(e);
} finally {
if (alive) loading = false;
}
await tick();
if (alive && linkedCommentId === linkedRootId)
document.getElementById(`comment-${linkedRootId}`)?.scrollIntoView({ block: 'nearest' });
};
const more = async () => {
if (!resource || loadingMore) return;
loadingMore = true;
const version = ++refreshVersion;
try {
const result = await getComments(resource, cursor ?? undefined);
if (!alive || version !== refreshVersion) return;
loadedCount += result.items.length;
items = [...items, ...result.items.filter((c) => !items.some((old) => old.id === c.id))].sort(
(a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id)
);
cursor = result.next_cursor;
total = result.total;
onCountChange(total);
} catch (e) {
if (version === refreshVersion) fail(e);
} finally {
loadingMore = false;
}
};
onMount(() => {
setup();
});
$: if ($socket !== connection) {
connection?.off('comments:changed', changed);
connection?.off('connect', reconnect);
connection?.off('comments:unavailable', unavailable);
connection = $socket;
connection?.on('comments:changed', changed);
connection?.on('connect', reconnect);
connection?.on('comments:unavailable', unavailable);
join();
}
onDestroy(() => {
alive = false;
clearTimeout(refreshTimer);
connection?.emit('comments:leave');
connection?.off('comments:changed', changed);
connection?.off('connect', reconnect);
connection?.off('comments:unavailable', unavailable);
});
</script>
<svelte:window on:focus={refresh} />
<div class="flex flex-col min-h-0 w-full h-full {embedded ? '' : 'bg-white dark:bg-gray-900'}">
{#if !embedded}
<header
class="sticky top-0 flex min-h-10 shrink-0 items-center justify-between px-4 py-1.5"
>
<h2 class="flex items-center gap-1 font-normal text-sm" {title}>
{$i18n.t('Comments')}{#if total !== null && total > 0}<span
class="text-[0.8125rem] leading-none text-gray-400/70 dark:text-gray-500/70 font-normal"
>{total}</span
>{/if}
</h2>
<Tooltip content={$i18n.t('Close')}>
<button
type="button"
aria-label={$i18n.t('Close')}
class="flex h-7 items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 px-0"
on:click={() => commentsView.set(null)}><XMark className="size-4" /></button
>
</Tooltip>
</header>
{/if}
{#if view.modal && resource && title}
<div class="flex min-w-0 shrink-0 items-baseline gap-1.5 px-4 pb-1 leading-4">
<span class="shrink-0 text-[0.6875rem] text-gray-500 dark:text-gray-400">
{$i18n.t(resourceLabels[resource.resource_type])}
</span>
<span class="truncate text-xs font-normal" {title}>{title}</span>
</div>
{/if}
<div
class="flex-1 min-h-0 w-full overflow-y-auto will-change-transform"
class:flex={!items.length}
class:flex-col={!items.length}
>
{#if loading}<div class="flex justify-center py-5"><Spinner className="size-5" /></div>{/if}
{#if error}<div class="px-4 py-2">
<p role="alert" class="text-xs text-red-500">{error}</p>
<button
type="button"
class="mt-1 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
on:click={setup}>{$i18n.t('Retry')}</button
>
</div>{/if}
{#if resource && !loading}
{#if !items.length}<div
class="flex flex-1 items-center justify-center px-4 py-6 text-center text-xs text-gray-500"
>
{$i18n.t('No comments yet')}
</div>{/if}
{#each items as comment (comment.id)}
<div class="mx-1.5 my-0.5">
<CommentItem {comment} onReply={reply} onChange={refresh} />
{#if replyTo?.id === comment.id}
<div class="ms-4">
{#key replyTo.id}<CommentReplyComposer
comment={replyTo}
onCancel={() => (replyTo = null)}
onSubmitted={(id) => {
if (replyTo?.id === id) replyTo = null;
}}
onPosted={postedReply}
/>{/key}
</div>
{/if}
<CommentReplies
parent={comment}
{revision}
{replyTo}
{postedComment}
onCancel={() => (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}
/>
</div>
{/each}
{#if cursor}<button
type="button"
class="mx-4 my-3 text-xs text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
disabled={loadingMore}
on:click={more}>{$i18n.t('Load more')}</button
>{/if}
{/if}
</div>
{#if resource && !loading}
<div class="shrink-0 px-3 pt-1 pb-3 mt-2">
{#key `${resourceQuery(resource)}:${composerVersion}`}
<CommentComposer
onSubmitted={async () => {
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<ResourceComment>('/', '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}
</div>
{/if}
</div>

View file

@ -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<void>;
};
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<HTMLElement, CommentSurface>();
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;
}

View file

@ -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'
};
</script>
<div
class=" text-xs font-normal {classNames[type] ??
classNames['info']} w-fit px-[0.3125rem] rounded-lg uppercase line-clamp-1 mr-0.5"
class="font-normal {classNames[type] ?? classNames['info']} w-fit px-[0.3125rem] mr-0.5 {type ===
'subtle'
? 'shrink-0 whitespace-nowrap rounded-md text-[0.6875rem] leading-4 normal-case'
: 'rounded-lg text-xs uppercase line-clamp-1'}"
>
{content}
</div>

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { settings } from '$lib/stores';
export let id: string | undefined = undefined;
</script>
<div
{id}
dir={$settings?.chatDirection === 'RTL'
? 'rtl'
: $settings?.chatDirection === 'LTR'
? 'ltr'
: 'auto'}
class="flex-1 flex flex-col relative w-full shadow-sm rounded-2xl border border-gray-100/60 dark:border-gray-800/50 hover:border-gray-100 focus-within:border-gray-100 hover:dark:border-gray-800 focus-within:dark:border-gray-800 transition bg-white dark:bg-gray-400/5 dark:text-gray-100"
>
<slot />
</div>

View file

@ -276,7 +276,7 @@
export let image = false;
export let fileHandler = false;
export let attachments = false;
export let suggestions = null;
export let suggestions: Partial<import('@tiptap/suggestion').SuggestionOptions>[] | null = null;
export let onFileDrop = (currentEditor, files, pos) => {
files.forEach((file) => {

View file

@ -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;

View file

@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke="currentColor"
class={className}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 3a9 9 0 0 1 0 18H3v-9a9 9 0 0 1 9-9Z"
/>
</svg>

View file

@ -112,7 +112,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}
@ -160,7 +160,7 @@
{#if query}
<div class="self-center pl-1.5 pr-1 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';

View file

@ -1,4 +1,5 @@
<script lang="ts">
import CommentButton from '$lib/components/comments/CommentButton.svelte';
import { toast } from 'svelte-sonner';
import { getContext, tick } from 'svelte';
@ -58,6 +59,7 @@
export let scrollToTop: (() => void) | null = null;
let showFullMessages = false;
let show = false;
const getChatAsText = async () => {
const history = chat.chat.history;
@ -322,6 +324,7 @@
{/if}
<Dropdown
bind:show
closeOnSelect
onOpenChange={(state) => {
if (state === false) {
@ -424,6 +427,15 @@
</button>
{/if}
{#if chat?.id && !$temporaryChatEnabled}
<CommentButton
menu
resource={{ resource_type: 'chat', resource_id: chat.id }}
onOpen={() => (show = false)}
className="flex h-[1.6875rem] w-full items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none hover:bg-gray-50/40 dark:hover:bg-gray-800/40"
/>
{/if}
{#if $user?.role === 'admin' || ($user.permissions?.chat?.export ?? true)}
<DropdownSub contentClass="select-none z-50">
<button

View file

@ -345,7 +345,7 @@
on:blur={() => (suggesting = false)}
/>
{#if query}<button
class={quietButton}
class="focus-ring flex h-[1.6875rem] shrink-0 items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Clear search')}
on:click={() => {
query = '';
@ -370,8 +370,10 @@
on:click={() => (panel = panel === 'filters' ? null : 'filters')}
><Adjustments className="size-3.5" /></button
>
<button class={quietButton} aria-label={$i18n.t('Close search')} on:click={close}
><XMark className="size-3.5" /></button
<button
class="focus-ring flex h-[1.6875rem] shrink-0 items-center justify-center rounded-lg text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
aria-label={$i18n.t('Close search')}
on:click={close}><XMark className="size-3.5" /></button
>
</div>
{#if sharing !== 'all' || scope}

View file

@ -1900,7 +1900,7 @@
<button
type="button"
disabled={$selectionBusy}
class="flex size-6 items-center justify-center rounded-md hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 disabled:opacity-40"
class="flex h-6 items-center justify-center rounded-md hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-40 text-gray-500 dark:text-gray-400 px-0"
aria-label={$i18n.t('Clear selection')}
on:click={chatSelection.clear}
>

View file

@ -154,7 +154,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -146,7 +146,7 @@
{/if}
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}
@ -283,7 +283,7 @@
<button
type="button"
aria-label={$i18n.t('Reset')}
class="shrink-0 p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-200"
class="shrink-0 py-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-200 px-0"
on:click={() => (data.model_ids = [])}
>
<XMark className="size-3" />

View file

@ -78,7 +78,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -86,7 +86,7 @@
</div>
</a>
<button
class="hover-reveal self-center p-0.5 hover:bg-gray-200 dark:hover:bg-gray-800 rounded-lg transition"
class="hover-reveal self-center py-0.5 rounded-lg transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
on:click|preventDefault|stopPropagation={async () => {
await toggleNotePinnedStatusById(localStorage.token, note.id);
const _pinnedNotes = await getPinnedNoteList(localStorage.token).catch(() => []);

View file

@ -293,7 +293,7 @@
{#if showClearButton && value}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
class="py-0.5 rounded-full transition hover:text-gray-900 dark:hover:text-gray-100 text-gray-500 dark:text-gray-400 px-0"
on:click={clearSearchInput}
>
<XMark className="size-3" strokeWidth="1.5" />

View file

@ -82,7 +82,7 @@
</div>
<button
aria-label={$i18n.t('Close')}
class="self-center rounded-lg p-1 text-gray-500 transition hover:bg-gray-50 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
class="self-center rounded-lg py-1 text-gray-500 transition hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 px-0"
on:click={() => {
show = false;
}}

View file

@ -1,4 +1,24 @@
<script lang="ts">
import CommentButton from '$lib/components/comments/CommentButton.svelte';
import { commentSurface, contentHash, noteCommentText } from '$lib/components/comments/anchors';
import {
commentsView,
noteCommentsId,
commentCounts,
openComments,
config,
mobile,
models,
settings,
showSidebar,
socket,
user,
WEBUI_NAME,
pinnedNotes
} from '$lib/stores';
import CommentsPanel from '$lib/components/comments/CommentsPanel.svelte';
import { resourceQuery } from '$lib/apis/comments';
import XMark from '$lib/components/icons/XMark.svelte';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import { v4 as uuidv4 } from 'uuid';
import fileSaver from 'file-saver';
@ -33,17 +53,6 @@
import { uploadFile } from '$lib/apis/files';
import { generateOpenAIChatCompletion } from '$lib/apis/openai';
import {
config,
mobile,
models,
settings,
showSidebar,
socket,
user,
WEBUI_NAME,
pinnedNotes
} from '$lib/stores';
import { contentFiles, downloadNote } from './utils';
import NoteTabs from './NoteTabs.svelte';
@ -120,6 +129,7 @@
let note = null;
let activePageId = '';
let activePage: NotePage;
let savedCommentHashes: Record<string, string> = {};
let pageBusy = false;
let showNoteTabs = false;
let noteTabsPreferenceReady = false;
@ -279,6 +289,7 @@
previousPage = structuredClone(oldPage);
}
note.data.pages = incoming.map((remote) => {
savedCommentHashes[remote.id] = contentHash(noteCommentText(remote.content));
const local = previous.find((p) => p.id === remote.id);
if (local && local.generation > remote.generation)
return {
@ -333,7 +344,25 @@
let displayMediaRecord = false;
let showNoteChat = false;
$: if (showNoteChat) showNoteTabs = false;
$: noteCommentsId.set(note?.id ?? null);
$: commentsEnabled = !!($config?.features?.enable_comments && $user && note?.id);
$: currentNoteComments =
commentsEnabled &&
!$commentsView?.modal &&
$commentsView?.resource_type === 'note' &&
$commentsView.resource_id === note?.id
? $commentsView
: null;
$: noteCommentCount = note?.id
? ($commentCounts[
`${$user?.id}:${resourceQuery({ resource_type: 'note', resource_id: note.id })}`
] ?? 0)
: 0;
$: if (currentNoteComments) showNoteChat = true;
const closeNotePanel = () => {
if (currentNoteComments) commentsView.set(null);
showNoteChat = false;
};
let noteChatId = null;
let noteChatLoading = false;
let noteChats = [];
@ -384,6 +413,9 @@
if (note?.id && note.id !== res.id) $socket?.emit('note:leave', { note_id: note.id });
previousPage = null;
note = res;
savedCommentHashes = Object.fromEntries(
res.data.pages.map((p: NotePage) => [p.id, contentHash(noteCommentText(p.content))])
);
const requested = $page.url.searchParams.get('page');
const remembered = localStorage.getItem(`note-page:${$user?.id}:${id}`);
activePageId = '';
@ -774,7 +806,10 @@ ${content}
currentChatId: noteChatId,
sidebarOpen: showNoteChat
});
if (!note?.id || noteChatLoading) return;
if (!note?.id) return;
commentsView.set(null);
showNoteChat = true;
if (noteChatLoading || noteChatId || noteChatDraftKey) return;
noteChatLoading = true;
const chat = await getNoteChatById(localStorage.token, note.id).catch((error) => {
@ -961,6 +996,7 @@ ${content}
const local = note.data.pages.find((p) => p.id === remote.id);
if (!local || remote.revision < local.revision || remote.generation < local.generation)
return;
savedCommentHashes[remote.id] = contentHash(noteCommentText(remote.content));
if (
remote.id === activePageId &&
note.write_access &&
@ -1000,6 +1036,8 @@ ${content}
});
onDestroy(() => {
if ($noteCommentsId === note?.id) noteCommentsId.set(null);
if (currentNoteComments) commentsView.set(null);
loadGeneration++;
if (note?.id) $socket?.emit('note:leave', { note_id: note.id });
if (debounceTimeout) clearTimeout(debounceTimeout);
@ -1323,6 +1361,13 @@ ${content}
>
<EllipsisHorizontal className="size-5" />
</button>
<svelte:fragment slot="actions" let:close>
<CommentButton
menu
onOpen={close}
resource={{ resource_type: 'note', resource_id: note.id }}
/>
</svelte:fragment>
</NoteMenu>
{#if liveParticipants.length > 0}
@ -1377,7 +1422,6 @@ ${content}
aria-expanded={showNoteTabs}
aria-controls="note-tabs-panel"
on:click={() => {
if (!showNoteTabs) showNoteChat = false;
showNoteTabs = !showNoteTabs;
}}
><span class="truncate"
@ -1505,60 +1549,75 @@ ${content}
? note.title || $i18n.t('Untitled')
: activePage?.title}
>
{#each [activePage] as renderedPage (`${note.id}:${renderedPage.id}:${renderedPage.generation}:${autoFormat}`)}
<RichTextInput
{autoFormat}
bind:this={inputElement}
on:editor={(event) => (editor = event.detail)}
id={`note-${note.id}-${renderedPage.id}`}
className="input-prose-sm px-0.5 flex-1 min-h-[12rem]"
json={true}
bind:value={renderedPage.content.json}
html={editorHtml}
documentId={activeDocumentId}
bind:cursorTarget
collaboration={true}
socket={$socket}
user={$user}
dragHandle={true}
link={true}
image={true}
attachments={true}
placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && note?.write_access}
onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
<div
use:commentSurface={{
resource_type: 'note',
resource_id: note.id,
target_type: 'note_page',
target_id: activePageId,
surface_id: activePageId,
content_hash: note.write_access
? contentHash(noteCommentText(activePage.content))
: savedCommentHashes[activePageId],
enabled: versionIdx === null,
prepare: flushActivePage
}}
>
{#each [activePage] as renderedPage (`${note.id}:${renderedPage.id}:${renderedPage.generation}:${autoFormat}`)}
<RichTextInput
{autoFormat}
bind:this={inputElement}
on:editor={(event) => (editor = event.detail)}
id={`note-${note.id}-${renderedPage.id}`}
className="input-prose-sm px-0.5 flex-1 min-h-[12rem]"
json={true}
bind:value={renderedPage.content.json}
html={editorHtml}
documentId={activeDocumentId}
bind:cursorTarget
collaboration={true}
socket={$socket}
user={$user}
dragHandle={true}
link={true}
image={true}
attachments={true}
placeholder={$i18n.t('Write something...')}
editable={versionIdx === null && note?.write_access}
onSelectionUpdate={({ editor }) => {
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to, ' ');
if (selectedText.length === 0) {
selectedContent = null;
} else {
selectedContent = {
text: selectedText,
from: from,
to: to
};
}
}}
onChange={(content) => {
renderedPage.content.json = content.json;
renderedPage.content.html = content.html;
renderedPage.content.md = content.md;
}}
fileHandler={true}
onFileDrop={(currentEditor, files, pos) =>
inputFilesHandler(files, currentEditor, pos)}
onFilePaste={() => {}}
on:paste={(event) => {
const e = event.detail.event || event;
const pasted = Array.from(e.clipboardData?.files ?? []);
if (pasted.length) {
e.preventDefault();
inputFilesHandler(pasted);
}
}}
/>
{/each}
if (selectedText.length === 0) {
selectedContent = null;
} else {
selectedContent = {
text: selectedText,
from: from,
to: to
};
}
}}
onChange={(content) => {
renderedPage.content.json = content.json;
renderedPage.content.html = content.html;
renderedPage.content.md = content.md;
}}
fileHandler={true}
onFileDrop={(currentEditor, files, pos) =>
inputFilesHandler(files, currentEditor, pos)}
onFilePaste={() => {}}
on:paste={(event) => {
const e = event.detail.event || event;
const pasted = Array.from(e.clipboardData?.files ?? []);
if (pasted.length) {
e.preventDefault();
inputFilesHandler(pasted);
}
}}
/>
{/each}
</div>
</div>
</div>
</div>
@ -1591,49 +1650,86 @@ ${content}
</div>
{/if}
</div>
<NotePanel bind:show={showNoteChat}>
{#if noteChatLoading}
<div class="flex h-full items-center justify-center">
<Spinner className="size-5" />
<NotePanel bind:show={showNoteChat} onClose={closeNotePanel}>
<div class="flex items-center justify-between px-2 pt-2 pb-1 shrink-0">
<div class="flex gap-1 min-w-0 overflow-x-auto scrollbar-hidden">
{#each commentsEnabled ? ['Chat', 'Comments'] : ['Chat'] as tab}
<button
type="button"
aria-pressed={tab === 'Comments' ? !!currentNoteComments : !currentNoteComments}
class="inline-flex items-center justify-center gap-1 px-2.5 py-0.5 text-sm rounded-lg transition whitespace-nowrap {(
tab === 'Comments' ? !!currentNoteComments : !currentNoteComments
)
? 'bg-gray-100/40 dark:bg-gray-800/25 font-normal text-gray-700 dark:text-gray-200'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/30 dark:hover:bg-gray-800/20 hover:text-gray-600 dark:hover:text-gray-300'}"
on:click={() =>
tab === 'Comments'
? openComments({ resource_type: 'note', resource_id: note.id }, { modal: false })
: openNoteChat()}
>
{$i18n.t(tab)}
{#if tab === 'Comments' && noteCommentCount > 0}<span
class="text-[0.8125rem] leading-none text-gray-400/70 dark:text-gray-500/70"
>{noteCommentCount}</span
>{/if}
</button>
{/each}
</div>
<button
type="button"
aria-label={$i18n.t('Close')}
class="py-1 px-0 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
on:click={closeNotePanel}><XMark className="size-4" /></button
>
</div>
{#if currentNoteComments}
<div class="flex-1 min-h-0">
{#key `${$user.id}:${note.id}:${currentNoteComments.comment_id ?? ''}`}
<CommentsPanel embedded bind:view={$commentsView} />
{/key}
</div>
{:else if note && (noteChatId || noteChatDraftKey)}
<Chat
embedded={true}
chatIdProp={noteChatId ?? ''}
embeddedChats={noteChats}
embeddedDraftKey={noteChatDraftKey}
suggestedPrompts={noteChatSuggestedPrompts}
selectedText={selectedContent?.text ?? ''}
notePageId={activePageId}
noteFiles={noteAttachmentFiles}
onBeforeNoteSubmit={flushActivePage}
onInsertToNote={note?.write_access ? insertHandler : null}
onNewEmbeddedChat={createNoteChat}
onCreateEmbeddedChat={createNoteChatOnFirstMessage}
onSelectEmbeddedChat={(chatId) => {
if (!chatId || chatId === noteChatId) return;
noteChatId = chatId;
noteChatDraftKey = '';
}}
onDeleteEmbeddedChat={deleteNoteChat}
onEmbeddedChatTitle={(chatId, title) => {
noteChats = noteChats.map((chat) =>
chat.id === chatId
? {
...chat,
title,
chat: {
...(chat.chat ?? {}),
title
}
}
: chat
);
}}
onCloseEmbedded={() => {
showNoteChat = false;
}}
/>
{/if}
<div class="flex-1 min-h-0" class:hidden={!!currentNoteComments}>
{#if noteChatLoading}
<div class="flex h-full items-center justify-center">
<Spinner className="size-5" />
</div>
{:else if note && (noteChatId || noteChatDraftKey)}
<Chat
embedded={true}
chatIdProp={noteChatId ?? ''}
embeddedChats={noteChats}
embeddedDraftKey={noteChatDraftKey}
suggestedPrompts={noteChatSuggestedPrompts}
selectedText={selectedContent?.text ?? ''}
notePageId={activePageId}
noteFiles={noteAttachmentFiles}
onBeforeNoteSubmit={flushActivePage}
onInsertToNote={note?.write_access ? insertHandler : null}
onNewEmbeddedChat={createNoteChat}
onCreateEmbeddedChat={createNoteChatOnFirstMessage}
onSelectEmbeddedChat={(chatId) => {
if (!chatId || chatId === noteChatId) return;
noteChatId = chatId;
noteChatDraftKey = '';
}}
onDeleteEmbeddedChat={deleteNoteChat}
onEmbeddedChatTitle={(chatId, title) => {
noteChats = noteChats.map((chat) =>
chat.id === chatId
? {
...chat,
title,
chat: {
...(chat.chat ?? {}),
title
}
}
: chat
);
}}
/>
{/if}
</div>
</NotePanel>
</div>

Some files were not shown because too many files have changed in this diff Show more