mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
refac
This commit is contained in:
parent
09fbbd6d69
commit
9d1b4ef362
132 changed files with 4214 additions and 1106 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
|
|
|
|||
166
backend/open_webui/models/comments.py
Normal file
166
backend/open_webui/models/comments.py
Normal 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))
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
382
backend/open_webui/routers/comments.py
Normal file
382
backend/open_webui/routers/comments.py
Normal 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
|
||||
|
|
@ -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
|
||||
############################
|
||||
|
|
|
|||
71
backend/open_webui/socket/comments.py
Normal file
71
backend/open_webui/socket/comments.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
160
backend/open_webui/utils/comments.py
Normal file
160
backend/open_webui/utils/comments.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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() != '':
|
||||
|
|
|
|||
84
src/lib/apis/comments/index.ts
Normal file
84
src/lib/apis/comments/index.ts
Normal 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)}` : ''}`
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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={() => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
88
src/lib/components/comments/CommentButton.svelte
Normal file
88
src/lib/components/comments/CommentButton.svelte
Normal 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}
|
||||
186
src/lib/components/comments/CommentComposer.svelte
Normal file
186
src/lib/components/comments/CommentComposer.svelte
Normal 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>
|
||||
224
src/lib/components/comments/CommentItem.svelte
Normal file
224
src/lib/components/comments/CommentItem.svelte
Normal 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>
|
||||
144
src/lib/components/comments/CommentReplies.svelte
Normal file
144
src/lib/components/comments/CommentReplies.svelte
Normal 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}
|
||||
40
src/lib/components/comments/CommentReplyComposer.svelte
Normal file
40
src/lib/components/comments/CommentReplyComposer.svelte
Normal 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>
|
||||
176
src/lib/components/comments/CommentsHost.svelte
Normal file
176
src/lib/components/comments/CommentsHost.svelte
Normal 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>
|
||||
334
src/lib/components/comments/CommentsPanel.svelte
Normal file
334
src/lib/components/comments/CommentsPanel.svelte
Normal 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>
|
||||
176
src/lib/components/comments/anchors.ts
Normal file
176
src/lib/components/comments/anchors.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
16
src/lib/components/common/MessageComposerShell.svelte
Normal file
16
src/lib/components/common/MessageComposerShell.svelte
Normal 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>
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
20
src/lib/components/icons/Annotation.svelte
Normal file
20
src/lib/components/icons/Annotation.svelte
Normal 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>
|
||||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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(() => []);
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue