Files
Timothy Jaeryang Baek 3814b7b2f1 refac
2026-10-31 16:50:01 +09:00

161 lines
6.4 KiB
Python

"""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(resource_type: str, resource_ids, db: AsyncSession):
"""Lock resources before cleanup so concurrent comment creation cannot leave orphans."""
from open_webui.utils.comments import resource_models
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 set()
from open_webui.models.notifications import delete_notifications
recipients = await delete_notifications(resource_type=resource_type, resource_ids=ids, db=db)
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)))
return recipients
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))