mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-14 05:02:01 -06:00
161 lines
6.4 KiB
Python
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))
|