mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge pull request #702 from CREDO23/sur-90-feat-comments-in-chats
[Feature] Comments & mentions in chats
This commit is contained in:
commit
e86462a7c3
49 changed files with 3441 additions and 78 deletions
|
|
@ -37,10 +37,5 @@ def upgrade() -> None:
|
|||
|
||||
def downgrade() -> None:
|
||||
"""Remove author_id column from new_chat_messages table."""
|
||||
op.execute(
|
||||
"""
|
||||
DROP INDEX IF EXISTS ix_new_chat_messages_author_id;
|
||||
ALTER TABLE new_chat_messages
|
||||
DROP COLUMN IF EXISTS author_id;
|
||||
"""
|
||||
)
|
||||
op.execute("DROP INDEX IF EXISTS ix_new_chat_messages_author_id")
|
||||
op.execute("ALTER TABLE new_chat_messages DROP COLUMN IF EXISTS author_id")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
"""Add chat_comments table for comments on AI responses
|
||||
|
||||
Revision ID: 68
|
||||
Revises: 67
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "68"
|
||||
down_revision: str | None = "67"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create chat_comments table."""
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_comments (
|
||||
id SERIAL PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL REFERENCES new_chat_messages(id) ON DELETE CASCADE,
|
||||
parent_id INTEGER REFERENCES chat_comments(id) ON DELETE CASCADE,
|
||||
author_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_comments_message_id ON chat_comments(message_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_comments_parent_id ON chat_comments(parent_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_comments_author_id ON chat_comments(author_id)"
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_comments_created_at ON chat_comments(created_at)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop chat_comments table."""
|
||||
op.execute(
|
||||
"""
|
||||
DROP TABLE IF EXISTS chat_comments;
|
||||
"""
|
||||
)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"""Add chat_comment_mentions table for @mentions in comments
|
||||
|
||||
Revision ID: 69
|
||||
Revises: 68
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "69"
|
||||
down_revision: str | None = "68"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create chat_comment_mentions table."""
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
comment_id INTEGER NOT NULL REFERENCES chat_comments(id) ON DELETE CASCADE,
|
||||
mentioned_user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (comment_id, mentioned_user_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id)"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop chat_comment_mentions table."""
|
||||
op.execute(
|
||||
"""
|
||||
DROP TABLE IF EXISTS chat_comment_mentions;
|
||||
"""
|
||||
)
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"""Add comments permissions to existing roles
|
||||
|
||||
Revision ID: 70
|
||||
Revises: 69
|
||||
Create Date: 2024-01-16
|
||||
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "70"
|
||||
down_revision = "69"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# Add comments:create to Admin, Editor, Viewer roles (if not already present)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE search_space_roles
|
||||
SET permissions = array_append(permissions, 'comments:create')
|
||||
WHERE name IN ('Admin', 'Editor', 'Viewer')
|
||||
AND NOT ('comments:create' = ANY(permissions))
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Add comments:read to Admin, Editor, Viewer roles (if not already present)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE search_space_roles
|
||||
SET permissions = array_append(permissions, 'comments:read')
|
||||
WHERE name IN ('Admin', 'Editor', 'Viewer')
|
||||
AND NOT ('comments:read' = ANY(permissions))
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Add comments:delete to Admin roles only (if not already present)
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE search_space_roles
|
||||
SET permissions = array_append(permissions, 'comments:delete')
|
||||
WHERE name = 'Admin'
|
||||
AND NOT ('comments:delete' = ANY(permissions))
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
connection = op.get_bind()
|
||||
|
||||
# Remove comments:create from Admin, Editor, Viewer roles
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE search_space_roles
|
||||
SET permissions = array_remove(permissions, 'comments:create')
|
||||
WHERE name IN ('Admin', 'Editor', 'Viewer')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Remove comments:read from Admin, Editor, Viewer roles
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE search_space_roles
|
||||
SET permissions = array_remove(permissions, 'comments:read')
|
||||
WHERE name IN ('Admin', 'Editor', 'Viewer')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
# Remove comments:delete from Admin roles only
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE search_space_roles
|
||||
SET permissions = array_remove(permissions, 'comments:delete')
|
||||
WHERE name = 'Admin'
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"""Add Electric SQL replication for chat_comment_mentions table
|
||||
|
||||
Revision ID: 71
|
||||
Revises: 70
|
||||
|
||||
Enables Electric SQL replication for the chat_comment_mentions table to support
|
||||
real-time live updates for mentions.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "71"
|
||||
down_revision: str | None = "70"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Enable Electric SQL replication for chat_comment_mentions table."""
|
||||
op.execute("ALTER TABLE chat_comment_mentions REPLICA IDENTITY FULL;")
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'chat_comment_mentions'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default ADD TABLE chat_comment_mentions;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove chat_comment_mentions from Electric SQL replication."""
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'electric_publication_default'
|
||||
AND tablename = 'chat_comment_mentions'
|
||||
) THEN
|
||||
ALTER PUBLICATION electric_publication_default DROP TABLE chat_comment_mentions;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute("ALTER TABLE chat_comment_mentions REPLICA IDENTITY DEFAULT;")
|
||||
|
|
@ -152,6 +152,11 @@ class Permission(str, Enum):
|
|||
CHATS_UPDATE = "chats:update"
|
||||
CHATS_DELETE = "chats:delete"
|
||||
|
||||
# Comments
|
||||
COMMENTS_CREATE = "comments:create"
|
||||
COMMENTS_READ = "comments:read"
|
||||
COMMENTS_DELETE = "comments:delete"
|
||||
|
||||
# LLM Configs
|
||||
LLM_CONFIGS_CREATE = "llm_configs:create"
|
||||
LLM_CONFIGS_READ = "llm_configs:read"
|
||||
|
|
@ -209,6 +214,10 @@ DEFAULT_ROLE_PERMISSIONS = {
|
|||
Permission.CHATS_READ.value,
|
||||
Permission.CHATS_UPDATE.value,
|
||||
Permission.CHATS_DELETE.value,
|
||||
# Comments
|
||||
Permission.COMMENTS_CREATE.value,
|
||||
Permission.COMMENTS_READ.value,
|
||||
Permission.COMMENTS_DELETE.value,
|
||||
# LLM Configs
|
||||
Permission.LLM_CONFIGS_CREATE.value,
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
|
|
@ -252,6 +261,9 @@ DEFAULT_ROLE_PERMISSIONS = {
|
|||
Permission.CHATS_READ.value,
|
||||
Permission.CHATS_UPDATE.value,
|
||||
Permission.CHATS_DELETE.value,
|
||||
# Comments (no delete)
|
||||
Permission.COMMENTS_CREATE.value,
|
||||
Permission.COMMENTS_READ.value,
|
||||
# LLM Configs (read only)
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
Permission.LLM_CONFIGS_CREATE.value,
|
||||
|
|
@ -279,6 +291,9 @@ DEFAULT_ROLE_PERMISSIONS = {
|
|||
Permission.DOCUMENTS_READ.value,
|
||||
# Chats (read only)
|
||||
Permission.CHATS_READ.value,
|
||||
# Comments (no delete)
|
||||
Permission.COMMENTS_CREATE.value,
|
||||
Permission.COMMENTS_READ.value,
|
||||
# LLM Configs (read only)
|
||||
Permission.LLM_CONFIGS_READ.value,
|
||||
# Podcasts (read only)
|
||||
|
|
@ -424,6 +439,84 @@ class NewChatMessage(BaseModel, TimestampMixin):
|
|||
# Relationships
|
||||
thread = relationship("NewChatThread", back_populates="messages")
|
||||
author = relationship("User")
|
||||
comments = relationship(
|
||||
"ChatComment",
|
||||
back_populates="message",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ChatComment(BaseModel, TimestampMixin):
|
||||
"""
|
||||
Comment model for comments on AI chat responses.
|
||||
Supports one level of nesting (replies to comments, but no replies to replies).
|
||||
"""
|
||||
|
||||
__tablename__ = "chat_comments"
|
||||
|
||||
message_id = Column(
|
||||
Integer,
|
||||
ForeignKey("new_chat_messages.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
parent_id = Column(
|
||||
Integer,
|
||||
ForeignKey("chat_comments.id", ondelete="CASCADE"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
author_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("user.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
content = Column(Text, nullable=False)
|
||||
updated_at = Column(
|
||||
TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
message = relationship("NewChatMessage", back_populates="comments")
|
||||
author = relationship("User")
|
||||
parent = relationship(
|
||||
"ChatComment", remote_side="ChatComment.id", backref="replies"
|
||||
)
|
||||
mentions = relationship(
|
||||
"ChatCommentMention",
|
||||
back_populates="comment",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ChatCommentMention(BaseModel, TimestampMixin):
|
||||
"""
|
||||
Tracks @mentions in chat comments for notification purposes.
|
||||
"""
|
||||
|
||||
__tablename__ = "chat_comment_mentions"
|
||||
|
||||
comment_id = Column(
|
||||
Integer,
|
||||
ForeignKey("chat_comments.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
mentioned_user_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# Relationships
|
||||
comment = relationship("ChatComment", back_populates="mentions")
|
||||
mentioned_user = relationship("User")
|
||||
|
||||
|
||||
class Document(BaseModel, TimestampMixin):
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from fastapi import APIRouter
|
|||
from .airtable_add_connector_route import (
|
||||
router as airtable_add_connector_router,
|
||||
)
|
||||
from .chat_comments_routes import router as chat_comments_router
|
||||
from .circleback_webhook_route import router as circleback_webhook_router
|
||||
from .clickup_add_connector_route import router as clickup_add_connector_router
|
||||
from .confluence_add_connector_route import router as confluence_add_connector_router
|
||||
|
|
@ -43,6 +44,7 @@ router.include_router(editor_router)
|
|||
router.include_router(documents_router)
|
||||
router.include_router(notes_router)
|
||||
router.include_router(new_chat_router) # Chat with assistant-ui persistence
|
||||
router.include_router(chat_comments_router)
|
||||
router.include_router(podcasts_router) # Podcast task status and audio
|
||||
router.include_router(search_source_connectors_router)
|
||||
router.include_router(google_calendar_add_connector_router)
|
||||
|
|
|
|||
95
surfsense_backend/app/routes/chat_comments_routes.py
Normal file
95
surfsense_backend/app/routes/chat_comments_routes.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
"""
|
||||
Routes for chat comments and mentions.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import User, get_async_session
|
||||
from app.schemas.chat_comments import (
|
||||
CommentCreateRequest,
|
||||
CommentListResponse,
|
||||
CommentReplyResponse,
|
||||
CommentResponse,
|
||||
CommentUpdateRequest,
|
||||
MentionListResponse,
|
||||
)
|
||||
from app.services.chat_comments_service import (
|
||||
create_comment,
|
||||
create_reply,
|
||||
delete_comment,
|
||||
get_comments_for_message,
|
||||
get_user_mentions,
|
||||
update_comment,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/messages/{message_id}/comments", response_model=CommentListResponse)
|
||||
async def list_comments(
|
||||
message_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""List all comments for a message with their replies."""
|
||||
return await get_comments_for_message(session, message_id, user)
|
||||
|
||||
|
||||
@router.post("/messages/{message_id}/comments", response_model=CommentResponse)
|
||||
async def add_comment(
|
||||
message_id: int,
|
||||
request: CommentCreateRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Create a top-level comment on an AI response."""
|
||||
return await create_comment(session, message_id, request.content, user)
|
||||
|
||||
|
||||
@router.post("/comments/{comment_id}/replies", response_model=CommentReplyResponse)
|
||||
async def add_reply(
|
||||
comment_id: int,
|
||||
request: CommentCreateRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Reply to an existing comment."""
|
||||
return await create_reply(session, comment_id, request.content, user)
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}", response_model=CommentReplyResponse)
|
||||
async def edit_comment(
|
||||
comment_id: int,
|
||||
request: CommentUpdateRequest,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Update a comment's content (author only)."""
|
||||
return await update_comment(session, comment_id, request.content, user)
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def remove_comment(
|
||||
comment_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""Delete a comment (author or user with COMMENTS_DELETE permission)."""
|
||||
return await delete_comment(session, comment_id, user)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mention Routes
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@router.get("/mentions", response_model=MentionListResponse)
|
||||
async def list_mentions(
|
||||
search_space_id: int | None = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""List mentions for the current user."""
|
||||
return await get_user_mentions(session, user, search_space_id)
|
||||
|
|
@ -19,13 +19,14 @@ from datetime import UTC, datetime
|
|||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import (
|
||||
ChatComment,
|
||||
ChatVisibility,
|
||||
NewChatMessage,
|
||||
NewChatMessageRole,
|
||||
|
|
@ -508,7 +509,19 @@ async def get_thread_full(
|
|||
# Check thread-level access based on visibility
|
||||
await check_thread_access(session, thread, user)
|
||||
|
||||
return thread
|
||||
# Check if thread has any comments
|
||||
comment_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(ChatComment)
|
||||
.join(NewChatMessage, ChatComment.message_id == NewChatMessage.id)
|
||||
.where(NewChatMessage.thread_id == thread.id)
|
||||
)
|
||||
|
||||
return {
|
||||
**thread.__dict__,
|
||||
"messages": thread.messages,
|
||||
"has_comments": (comment_count or 0) > 0,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -452,6 +452,8 @@ async def list_members(
|
|||
"created_at": membership.created_at,
|
||||
"role": membership.role,
|
||||
"user_email": member_user.email if member_user else None,
|
||||
"user_display_name": member_user.display_name if member_user else None,
|
||||
"user_avatar_url": member_user.avatar_url if member_user else None,
|
||||
}
|
||||
response.append(membership_dict)
|
||||
|
||||
|
|
|
|||
129
surfsense_backend/app/schemas/chat_comments.py
Normal file
129
surfsense_backend/app/schemas/chat_comments.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
"""
|
||||
Pydantic schemas for chat comments and mentions.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# =============================================================================
|
||||
# Request Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CommentCreateRequest(BaseModel):
|
||||
"""Schema for creating a comment or reply."""
|
||||
|
||||
content: str = Field(..., min_length=1, max_length=5000)
|
||||
|
||||
|
||||
class CommentUpdateRequest(BaseModel):
|
||||
"""Schema for updating a comment."""
|
||||
|
||||
content: str = Field(..., min_length=1, max_length=5000)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Author Schema
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class AuthorResponse(BaseModel):
|
||||
"""Author information for comments."""
|
||||
|
||||
id: UUID
|
||||
display_name: str | None = None
|
||||
avatar_url: str | None = None
|
||||
email: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Comment Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CommentReplyResponse(BaseModel):
|
||||
"""Schema for a comment reply (no nested replies)."""
|
||||
|
||||
id: int
|
||||
content: str
|
||||
content_rendered: str
|
||||
author: AuthorResponse | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
is_edited: bool
|
||||
can_edit: bool = False
|
||||
can_delete: bool = False
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CommentResponse(BaseModel):
|
||||
"""Schema for a top-level comment with replies."""
|
||||
|
||||
id: int
|
||||
message_id: int
|
||||
content: str
|
||||
content_rendered: str
|
||||
author: AuthorResponse | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
is_edited: bool
|
||||
can_edit: bool = False
|
||||
can_delete: bool = False
|
||||
reply_count: int
|
||||
replies: list[CommentReplyResponse] = []
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CommentListResponse(BaseModel):
|
||||
"""Response for listing comments on a message."""
|
||||
|
||||
comments: list[CommentResponse]
|
||||
total_count: int
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mention Schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class MentionContextResponse(BaseModel):
|
||||
"""Context information for where a mention occurred."""
|
||||
|
||||
thread_id: int
|
||||
thread_title: str
|
||||
message_id: int
|
||||
search_space_id: int
|
||||
search_space_name: str
|
||||
|
||||
|
||||
class MentionCommentResponse(BaseModel):
|
||||
"""Abbreviated comment info for mention display."""
|
||||
|
||||
id: int
|
||||
content_preview: str
|
||||
author: AuthorResponse | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MentionResponse(BaseModel):
|
||||
"""Schema for a mention notification."""
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
comment: MentionCommentResponse
|
||||
context: MentionContextResponse
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class MentionListResponse(BaseModel):
|
||||
"""Response for listing user's mentions."""
|
||||
|
||||
mentions: list[MentionResponse]
|
||||
total_count: int
|
||||
|
|
@ -105,6 +105,7 @@ class NewChatThreadWithMessages(NewChatThreadRead):
|
|||
"""Schema for reading a thread with its messages."""
|
||||
|
||||
messages: list[NewChatMessageRead] = []
|
||||
has_comments: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -73,8 +73,10 @@ class MembershipRead(BaseModel):
|
|||
created_at: datetime
|
||||
# Nested role info
|
||||
role: RoleRead | None = None
|
||||
# User email (populated separately)
|
||||
# User details (populated separately)
|
||||
user_email: str | None = None
|
||||
user_display_name: str | None = None
|
||||
user_avatar_url: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
733
surfsense_backend/app/services/chat_comments_service.py
Normal file
733
surfsense_backend/app/services/chat_comments_service.py
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
"""
|
||||
Service layer for chat comments and mentions.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import (
|
||||
ChatComment,
|
||||
ChatCommentMention,
|
||||
NewChatMessage,
|
||||
NewChatMessageRole,
|
||||
NewChatThread,
|
||||
Permission,
|
||||
SearchSpaceMembership,
|
||||
User,
|
||||
has_permission,
|
||||
)
|
||||
from app.schemas.chat_comments import (
|
||||
AuthorResponse,
|
||||
CommentListResponse,
|
||||
CommentReplyResponse,
|
||||
CommentResponse,
|
||||
MentionCommentResponse,
|
||||
MentionContextResponse,
|
||||
MentionListResponse,
|
||||
MentionResponse,
|
||||
)
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.utils.chat_comments import parse_mentions, render_mentions
|
||||
from app.utils.rbac import check_permission, get_user_permissions
|
||||
|
||||
|
||||
async def get_user_names_for_mentions(
|
||||
session: AsyncSession,
|
||||
user_ids: set[UUID],
|
||||
) -> dict[UUID, str]:
|
||||
"""
|
||||
Fetch display names for a set of user IDs.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
user_ids: Set of user UUIDs to look up
|
||||
|
||||
Returns:
|
||||
Dictionary mapping user UUID to display name
|
||||
"""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
result = await session.execute(
|
||||
select(User.id, User.display_name).filter(User.id.in_(user_ids))
|
||||
)
|
||||
return {row.id: row.display_name or "Unknown" for row in result.all()}
|
||||
|
||||
|
||||
async def process_mentions(
|
||||
session: AsyncSession,
|
||||
comment_id: int,
|
||||
content: str,
|
||||
search_space_id: int,
|
||||
) -> dict[UUID, int]:
|
||||
"""
|
||||
Parse mentions from content, validate users are members, and insert mention records.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
comment_id: ID of the comment containing mentions
|
||||
content: Comment text with @[uuid] mentions
|
||||
search_space_id: ID of the search space for membership validation
|
||||
|
||||
Returns:
|
||||
Dictionary mapping mentioned user UUID to their mention record ID
|
||||
"""
|
||||
mentioned_uuids = parse_mentions(content)
|
||||
if not mentioned_uuids:
|
||||
return {}
|
||||
|
||||
# Get valid members from the mentioned UUIDs
|
||||
result = await session.execute(
|
||||
select(SearchSpaceMembership.user_id).filter(
|
||||
SearchSpaceMembership.search_space_id == search_space_id,
|
||||
SearchSpaceMembership.user_id.in_(mentioned_uuids),
|
||||
)
|
||||
)
|
||||
valid_member_ids = result.scalars().all()
|
||||
|
||||
# Insert mention records for valid members and collect their IDs
|
||||
mentions_map: dict[UUID, int] = {}
|
||||
for user_id in valid_member_ids:
|
||||
mention = ChatCommentMention(
|
||||
comment_id=comment_id,
|
||||
mentioned_user_id=user_id,
|
||||
)
|
||||
session.add(mention)
|
||||
await session.flush()
|
||||
mentions_map[user_id] = mention.id
|
||||
|
||||
return mentions_map
|
||||
|
||||
|
||||
async def get_comments_for_message(
|
||||
session: AsyncSession,
|
||||
message_id: int,
|
||||
user: User,
|
||||
) -> CommentListResponse:
|
||||
"""
|
||||
Get all comments for a message with their replies.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
message_id: ID of the message to get comments for
|
||||
user: The current authenticated user
|
||||
|
||||
Returns:
|
||||
CommentListResponse with all top-level comments and their replies
|
||||
|
||||
Raises:
|
||||
HTTPException: If message not found or user lacks COMMENTS_READ permission
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(NewChatMessage)
|
||||
.options(selectinload(NewChatMessage.thread))
|
||||
.filter(NewChatMessage.id == message_id)
|
||||
)
|
||||
message = result.scalars().first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
search_space_id = message.thread.search_space_id
|
||||
|
||||
# Check permission to read comments
|
||||
await check_permission(
|
||||
session,
|
||||
user,
|
||||
search_space_id,
|
||||
Permission.COMMENTS_READ.value,
|
||||
"You don't have permission to read comments in this search space",
|
||||
)
|
||||
|
||||
# Get user permissions for can_delete computation
|
||||
user_permissions = await get_user_permissions(session, user.id, search_space_id)
|
||||
can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value)
|
||||
|
||||
# Get top-level comments (parent_id IS NULL) with their authors and replies
|
||||
result = await session.execute(
|
||||
select(ChatComment)
|
||||
.options(
|
||||
selectinload(ChatComment.author),
|
||||
selectinload(ChatComment.replies).selectinload(ChatComment.author),
|
||||
)
|
||||
.filter(
|
||||
ChatComment.message_id == message_id,
|
||||
ChatComment.parent_id.is_(None),
|
||||
)
|
||||
.order_by(ChatComment.created_at)
|
||||
)
|
||||
top_level_comments = result.scalars().all()
|
||||
|
||||
# Collect all mentioned UUIDs from comments and replies for rendering
|
||||
all_mentioned_uuids: set[UUID] = set()
|
||||
for comment in top_level_comments:
|
||||
all_mentioned_uuids.update(parse_mentions(comment.content))
|
||||
for reply in comment.replies:
|
||||
all_mentioned_uuids.update(parse_mentions(reply.content))
|
||||
|
||||
# Fetch display names for mentioned users
|
||||
user_names = await get_user_names_for_mentions(session, all_mentioned_uuids)
|
||||
|
||||
comments = []
|
||||
for comment in top_level_comments:
|
||||
author = None
|
||||
if comment.author:
|
||||
author = AuthorResponse(
|
||||
id=comment.author.id,
|
||||
display_name=comment.author.display_name,
|
||||
avatar_url=comment.author.avatar_url,
|
||||
email=comment.author.email,
|
||||
)
|
||||
|
||||
replies = []
|
||||
for reply in sorted(comment.replies, key=lambda r: r.created_at):
|
||||
reply_author = None
|
||||
if reply.author:
|
||||
reply_author = AuthorResponse(
|
||||
id=reply.author.id,
|
||||
display_name=reply.author.display_name,
|
||||
avatar_url=reply.author.avatar_url,
|
||||
email=reply.author.email,
|
||||
)
|
||||
|
||||
is_reply_author = reply.author_id == user.id if reply.author_id else False
|
||||
replies.append(
|
||||
CommentReplyResponse(
|
||||
id=reply.id,
|
||||
content=reply.content,
|
||||
content_rendered=render_mentions(reply.content, user_names),
|
||||
author=reply_author,
|
||||
created_at=reply.created_at,
|
||||
updated_at=reply.updated_at,
|
||||
is_edited=reply.updated_at > reply.created_at,
|
||||
can_edit=is_reply_author,
|
||||
can_delete=is_reply_author or can_delete_any,
|
||||
)
|
||||
)
|
||||
|
||||
is_comment_author = comment.author_id == user.id if comment.author_id else False
|
||||
comments.append(
|
||||
CommentResponse(
|
||||
id=comment.id,
|
||||
message_id=comment.message_id,
|
||||
content=comment.content,
|
||||
content_rendered=render_mentions(comment.content, user_names),
|
||||
author=author,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
is_edited=comment.updated_at > comment.created_at,
|
||||
can_edit=is_comment_author,
|
||||
can_delete=is_comment_author or can_delete_any,
|
||||
reply_count=len(replies),
|
||||
replies=replies,
|
||||
)
|
||||
)
|
||||
|
||||
return CommentListResponse(
|
||||
comments=comments,
|
||||
total_count=len(comments),
|
||||
)
|
||||
|
||||
|
||||
async def create_comment(
|
||||
session: AsyncSession,
|
||||
message_id: int,
|
||||
content: str,
|
||||
user: User,
|
||||
) -> CommentResponse:
|
||||
"""
|
||||
Create a top-level comment on an AI response.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
message_id: ID of the message to comment on
|
||||
content: Comment text content
|
||||
user: The current authenticated user
|
||||
|
||||
Returns:
|
||||
CommentResponse for the created comment
|
||||
|
||||
Raises:
|
||||
HTTPException: If message not found, not AI response, or user lacks COMMENTS_CREATE permission
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(NewChatMessage)
|
||||
.options(selectinload(NewChatMessage.thread))
|
||||
.filter(NewChatMessage.id == message_id)
|
||||
)
|
||||
message = result.scalars().first()
|
||||
|
||||
if not message:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
|
||||
# Validate message is an AI response
|
||||
if message.role != NewChatMessageRole.ASSISTANT:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Comments can only be added to AI responses",
|
||||
)
|
||||
|
||||
search_space_id = message.thread.search_space_id
|
||||
|
||||
# Check permission to create comments
|
||||
user_permissions = await get_user_permissions(session, user.id, search_space_id)
|
||||
if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You don't have permission to create comments in this search space",
|
||||
)
|
||||
|
||||
comment = ChatComment(
|
||||
message_id=message_id,
|
||||
author_id=user.id,
|
||||
content=content,
|
||||
)
|
||||
session.add(comment)
|
||||
await session.flush()
|
||||
|
||||
# Process mentions - returns map of user_id -> mention_id
|
||||
mentions_map = await process_mentions(session, comment.id, content, search_space_id)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(comment)
|
||||
|
||||
# Fetch user names for rendering mentions (reuse mentions_map keys)
|
||||
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
||||
|
||||
# Create notifications for mentioned users (excluding author)
|
||||
thread = message.thread
|
||||
author_name = user.display_name or user.email
|
||||
content_preview = render_mentions(content, user_names)
|
||||
for mentioned_user_id, mention_id in mentions_map.items():
|
||||
if mentioned_user_id == user.id:
|
||||
continue # Don't notify yourself
|
||||
await NotificationService.mention.notify_new_mention(
|
||||
session=session,
|
||||
mentioned_user_id=mentioned_user_id,
|
||||
mention_id=mention_id,
|
||||
comment_id=comment.id,
|
||||
message_id=message_id,
|
||||
thread_id=thread.id,
|
||||
thread_title=thread.title or "Untitled thread",
|
||||
author_id=str(user.id),
|
||||
author_name=author_name,
|
||||
content_preview=content_preview[:200],
|
||||
search_space_id=search_space_id,
|
||||
)
|
||||
|
||||
author = AuthorResponse(
|
||||
id=user.id,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
email=user.email,
|
||||
)
|
||||
|
||||
return CommentResponse(
|
||||
id=comment.id,
|
||||
message_id=comment.message_id,
|
||||
content=comment.content,
|
||||
content_rendered=render_mentions(content, user_names),
|
||||
author=author,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
is_edited=False,
|
||||
can_edit=True,
|
||||
can_delete=True,
|
||||
reply_count=0,
|
||||
replies=[],
|
||||
)
|
||||
|
||||
|
||||
async def create_reply(
|
||||
session: AsyncSession,
|
||||
comment_id: int,
|
||||
content: str,
|
||||
user: User,
|
||||
) -> CommentReplyResponse:
|
||||
"""
|
||||
Create a reply to an existing comment.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
comment_id: ID of the parent comment to reply to
|
||||
content: Reply text content
|
||||
user: The current authenticated user
|
||||
|
||||
Returns:
|
||||
CommentReplyResponse for the created reply
|
||||
|
||||
Raises:
|
||||
HTTPException: If comment not found, is already a reply, or user lacks COMMENTS_CREATE permission
|
||||
"""
|
||||
# Get parent comment with its message and thread
|
||||
result = await session.execute(
|
||||
select(ChatComment)
|
||||
.options(selectinload(ChatComment.message).selectinload(NewChatMessage.thread))
|
||||
.filter(ChatComment.id == comment_id)
|
||||
)
|
||||
parent_comment = result.scalars().first()
|
||||
|
||||
if not parent_comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
|
||||
# Validate parent is a top-level comment (cannot reply to a reply)
|
||||
if parent_comment.parent_id is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot reply to a reply",
|
||||
)
|
||||
|
||||
search_space_id = parent_comment.message.thread.search_space_id
|
||||
|
||||
# Check permission to create comments
|
||||
user_permissions = await get_user_permissions(session, user.id, search_space_id)
|
||||
if not has_permission(user_permissions, Permission.COMMENTS_CREATE.value):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You don't have permission to create comments in this search space",
|
||||
)
|
||||
|
||||
reply = ChatComment(
|
||||
message_id=parent_comment.message_id,
|
||||
parent_id=comment_id,
|
||||
author_id=user.id,
|
||||
content=content,
|
||||
)
|
||||
session.add(reply)
|
||||
await session.flush()
|
||||
|
||||
# Process mentions - returns map of user_id -> mention_id
|
||||
mentions_map = await process_mentions(session, reply.id, content, search_space_id)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(reply)
|
||||
|
||||
# Fetch user names for rendering mentions (reuse mentions_map keys)
|
||||
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
||||
|
||||
# Create notifications for mentioned users (excluding author)
|
||||
thread = parent_comment.message.thread
|
||||
author_name = user.display_name or user.email
|
||||
content_preview = render_mentions(content, user_names)
|
||||
for mentioned_user_id, mention_id in mentions_map.items():
|
||||
if mentioned_user_id == user.id:
|
||||
continue # Don't notify yourself
|
||||
await NotificationService.mention.notify_new_mention(
|
||||
session=session,
|
||||
mentioned_user_id=mentioned_user_id,
|
||||
mention_id=mention_id,
|
||||
comment_id=reply.id,
|
||||
message_id=parent_comment.message_id,
|
||||
thread_id=thread.id,
|
||||
thread_title=thread.title or "Untitled thread",
|
||||
author_id=str(user.id),
|
||||
author_name=author_name,
|
||||
content_preview=content_preview[:200],
|
||||
search_space_id=search_space_id,
|
||||
)
|
||||
|
||||
author = AuthorResponse(
|
||||
id=user.id,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
email=user.email,
|
||||
)
|
||||
|
||||
return CommentReplyResponse(
|
||||
id=reply.id,
|
||||
content=reply.content,
|
||||
content_rendered=render_mentions(content, user_names),
|
||||
author=author,
|
||||
created_at=reply.created_at,
|
||||
updated_at=reply.updated_at,
|
||||
is_edited=False,
|
||||
can_edit=True,
|
||||
can_delete=True,
|
||||
)
|
||||
|
||||
|
||||
async def update_comment(
|
||||
session: AsyncSession,
|
||||
comment_id: int,
|
||||
content: str,
|
||||
user: User,
|
||||
) -> CommentReplyResponse:
|
||||
"""
|
||||
Update a comment's content (author only).
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
comment_id: ID of the comment to update
|
||||
content: New comment text content
|
||||
user: The current authenticated user
|
||||
|
||||
Returns:
|
||||
CommentReplyResponse for the updated comment
|
||||
|
||||
Raises:
|
||||
HTTPException: If comment not found or user is not the author
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(ChatComment)
|
||||
.options(
|
||||
selectinload(ChatComment.author),
|
||||
selectinload(ChatComment.message).selectinload(NewChatMessage.thread),
|
||||
)
|
||||
.filter(ChatComment.id == comment_id)
|
||||
)
|
||||
comment = result.scalars().first()
|
||||
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
|
||||
if comment.author_id != user.id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You can only edit your own comments",
|
||||
)
|
||||
|
||||
search_space_id = comment.message.thread.search_space_id
|
||||
|
||||
# Get existing mentioned user IDs
|
||||
existing_result = await session.execute(
|
||||
select(ChatCommentMention.mentioned_user_id).filter(
|
||||
ChatCommentMention.comment_id == comment_id
|
||||
)
|
||||
)
|
||||
existing_mention_ids = set(existing_result.scalars().all())
|
||||
|
||||
# Parse new mentions from updated content
|
||||
new_mention_uuids = set(parse_mentions(content))
|
||||
|
||||
# Validate new mentions are search space members
|
||||
if new_mention_uuids:
|
||||
valid_result = await session.execute(
|
||||
select(SearchSpaceMembership.user_id).filter(
|
||||
SearchSpaceMembership.search_space_id == search_space_id,
|
||||
SearchSpaceMembership.user_id.in_(new_mention_uuids),
|
||||
)
|
||||
)
|
||||
valid_new_mentions = set(valid_result.scalars().all())
|
||||
else:
|
||||
valid_new_mentions = set()
|
||||
|
||||
# Compute diff: removed, kept (preserve read status), added
|
||||
mentions_to_remove = existing_mention_ids - valid_new_mentions
|
||||
mentions_to_add = valid_new_mentions - existing_mention_ids
|
||||
|
||||
# Delete removed mentions
|
||||
if mentions_to_remove:
|
||||
await session.execute(
|
||||
delete(ChatCommentMention).where(
|
||||
ChatCommentMention.comment_id == comment_id,
|
||||
ChatCommentMention.mentioned_user_id.in_(mentions_to_remove),
|
||||
)
|
||||
)
|
||||
|
||||
# Add new mentions and collect their IDs for notifications
|
||||
new_mentions_map: dict[UUID, int] = {}
|
||||
for user_id in mentions_to_add:
|
||||
mention = ChatCommentMention(
|
||||
comment_id=comment_id,
|
||||
mentioned_user_id=user_id,
|
||||
)
|
||||
session.add(mention)
|
||||
await session.flush()
|
||||
new_mentions_map[user_id] = mention.id
|
||||
|
||||
comment.content = content
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(comment)
|
||||
|
||||
# Fetch user names for rendering mentions
|
||||
user_names = await get_user_names_for_mentions(session, valid_new_mentions)
|
||||
|
||||
# Create notifications for newly added mentions (excluding author)
|
||||
if new_mentions_map:
|
||||
thread = comment.message.thread
|
||||
author_name = user.display_name or user.email
|
||||
content_preview = render_mentions(content, user_names)
|
||||
for mentioned_user_id, mention_id in new_mentions_map.items():
|
||||
if mentioned_user_id == user.id:
|
||||
continue # Don't notify yourself
|
||||
await NotificationService.mention.notify_new_mention(
|
||||
session=session,
|
||||
mentioned_user_id=mentioned_user_id,
|
||||
mention_id=mention_id,
|
||||
comment_id=comment_id,
|
||||
message_id=comment.message_id,
|
||||
thread_id=thread.id,
|
||||
thread_title=thread.title or "Untitled thread",
|
||||
author_id=str(user.id),
|
||||
author_name=author_name,
|
||||
content_preview=content_preview[:200],
|
||||
search_space_id=search_space_id,
|
||||
)
|
||||
|
||||
author = AuthorResponse(
|
||||
id=user.id,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
email=user.email,
|
||||
)
|
||||
|
||||
return CommentReplyResponse(
|
||||
id=comment.id,
|
||||
content=comment.content,
|
||||
content_rendered=render_mentions(content, user_names),
|
||||
author=author,
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
is_edited=comment.updated_at > comment.created_at,
|
||||
can_edit=True,
|
||||
can_delete=True,
|
||||
)
|
||||
|
||||
|
||||
async def delete_comment(
|
||||
session: AsyncSession,
|
||||
comment_id: int,
|
||||
user: User,
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a comment (author or user with COMMENTS_DELETE permission).
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
comment_id: ID of the comment to delete
|
||||
user: The current authenticated user
|
||||
|
||||
Returns:
|
||||
Dict with deletion confirmation
|
||||
|
||||
Raises:
|
||||
HTTPException: If comment not found or user lacks permission to delete
|
||||
"""
|
||||
result = await session.execute(
|
||||
select(ChatComment)
|
||||
.options(selectinload(ChatComment.message).selectinload(NewChatMessage.thread))
|
||||
.filter(ChatComment.id == comment_id)
|
||||
)
|
||||
comment = result.scalars().first()
|
||||
|
||||
if not comment:
|
||||
raise HTTPException(status_code=404, detail="Comment not found")
|
||||
|
||||
is_author = comment.author_id == user.id
|
||||
|
||||
# Check if user has COMMENTS_DELETE permission
|
||||
search_space_id = comment.message.thread.search_space_id
|
||||
user_permissions = await get_user_permissions(session, user.id, search_space_id)
|
||||
can_delete_any = has_permission(user_permissions, Permission.COMMENTS_DELETE.value)
|
||||
|
||||
if not is_author and not can_delete_any:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You do not have permission to delete this comment",
|
||||
)
|
||||
|
||||
await session.delete(comment)
|
||||
await session.commit()
|
||||
|
||||
return {"message": "Comment deleted successfully", "comment_id": comment_id}
|
||||
|
||||
|
||||
async def get_user_mentions(
|
||||
session: AsyncSession,
|
||||
user: User,
|
||||
search_space_id: int | None = None,
|
||||
) -> MentionListResponse:
|
||||
"""
|
||||
Get mentions for the current user, optionally filtered by search space.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
user: The current authenticated user
|
||||
search_space_id: Optional search space ID to filter mentions
|
||||
|
||||
Returns:
|
||||
MentionListResponse with mentions and total count
|
||||
"""
|
||||
# Build query with joins for filtering by search_space_id
|
||||
query = (
|
||||
select(ChatCommentMention)
|
||||
.join(ChatComment, ChatCommentMention.comment_id == ChatComment.id)
|
||||
.join(NewChatMessage, ChatComment.message_id == NewChatMessage.id)
|
||||
.join(NewChatThread, NewChatMessage.thread_id == NewChatThread.id)
|
||||
.options(
|
||||
selectinload(ChatCommentMention.comment).selectinload(ChatComment.author),
|
||||
selectinload(ChatCommentMention.comment).selectinload(ChatComment.message),
|
||||
)
|
||||
.filter(ChatCommentMention.mentioned_user_id == user.id)
|
||||
.order_by(ChatCommentMention.created_at.desc())
|
||||
)
|
||||
|
||||
if search_space_id is not None:
|
||||
query = query.filter(NewChatThread.search_space_id == search_space_id)
|
||||
|
||||
result = await session.execute(query)
|
||||
mention_records = result.scalars().all()
|
||||
|
||||
# Fetch search space info for context (single query for all unique search spaces)
|
||||
thread_ids = {m.comment.message.thread_id for m in mention_records}
|
||||
if thread_ids:
|
||||
thread_result = await session.execute(
|
||||
select(NewChatThread)
|
||||
.options(selectinload(NewChatThread.search_space))
|
||||
.filter(NewChatThread.id.in_(thread_ids))
|
||||
)
|
||||
threads_map = {t.id: t for t in thread_result.scalars().all()}
|
||||
else:
|
||||
threads_map = {}
|
||||
|
||||
mentions = []
|
||||
for mention in mention_records:
|
||||
comment = mention.comment
|
||||
message = comment.message
|
||||
thread = threads_map.get(message.thread_id)
|
||||
search_space = thread.search_space if thread else None
|
||||
|
||||
author = None
|
||||
if comment.author:
|
||||
author = AuthorResponse(
|
||||
id=comment.author.id,
|
||||
display_name=comment.author.display_name,
|
||||
avatar_url=comment.author.avatar_url,
|
||||
email=comment.author.email,
|
||||
)
|
||||
|
||||
content_preview = (
|
||||
comment.content[:100] + "..."
|
||||
if len(comment.content) > 100
|
||||
else comment.content
|
||||
)
|
||||
|
||||
mentions.append(
|
||||
MentionResponse(
|
||||
id=mention.id,
|
||||
created_at=mention.created_at,
|
||||
comment=MentionCommentResponse(
|
||||
id=comment.id,
|
||||
content_preview=content_preview,
|
||||
author=author,
|
||||
created_at=comment.created_at,
|
||||
),
|
||||
context=MentionContextResponse(
|
||||
thread_id=thread.id if thread else 0,
|
||||
thread_title=thread.title or "Untitled" if thread else "Unknown",
|
||||
message_id=message.id,
|
||||
search_space_id=search_space.id if search_space else 0,
|
||||
search_space_name=search_space.name if search_space else "Unknown",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return MentionListResponse(
|
||||
mentions=mentions,
|
||||
total_count=len(mentions),
|
||||
)
|
||||
|
|
@ -617,12 +617,83 @@ class DocumentProcessingNotificationHandler(BaseNotificationHandler):
|
|||
)
|
||||
|
||||
|
||||
class MentionNotificationHandler(BaseNotificationHandler):
|
||||
"""Handler for new mention notifications."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("new_mention")
|
||||
|
||||
async def notify_new_mention(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
mentioned_user_id: UUID,
|
||||
mention_id: int,
|
||||
comment_id: int,
|
||||
message_id: int,
|
||||
thread_id: int,
|
||||
thread_title: str,
|
||||
author_id: str,
|
||||
author_name: str,
|
||||
content_preview: str,
|
||||
search_space_id: int,
|
||||
) -> Notification:
|
||||
"""
|
||||
Create notification when a user is @mentioned in a comment.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
mentioned_user_id: User who was mentioned
|
||||
mention_id: ID of the mention record
|
||||
comment_id: ID of the comment containing the mention
|
||||
message_id: ID of the message being commented on
|
||||
thread_id: ID of the chat thread
|
||||
thread_title: Title of the chat thread
|
||||
author_id: ID of the comment author
|
||||
author_name: Display name of the comment author
|
||||
content_preview: First ~100 chars of the comment
|
||||
search_space_id: Search space ID
|
||||
|
||||
Returns:
|
||||
Notification: The created notification
|
||||
"""
|
||||
title = f"{author_name} mentioned you"
|
||||
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
|
||||
|
||||
metadata = {
|
||||
"mention_id": mention_id,
|
||||
"comment_id": comment_id,
|
||||
"message_id": message_id,
|
||||
"thread_id": thread_id,
|
||||
"thread_title": thread_title,
|
||||
"author_id": author_id,
|
||||
"author_name": author_name,
|
||||
"content_preview": content_preview[:200],
|
||||
}
|
||||
|
||||
notification = Notification(
|
||||
user_id=mentioned_user_id,
|
||||
search_space_id=search_space_id,
|
||||
type=self.notification_type,
|
||||
title=title,
|
||||
message=message,
|
||||
notification_metadata=metadata,
|
||||
)
|
||||
session.add(notification)
|
||||
await session.commit()
|
||||
await session.refresh(notification)
|
||||
logger.info(
|
||||
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
|
||||
)
|
||||
return notification
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for creating and managing notifications that sync via Electric SQL."""
|
||||
|
||||
# Handler instances
|
||||
connector_indexing = ConnectorIndexingNotificationHandler()
|
||||
document_processing = DocumentProcessingNotificationHandler()
|
||||
mention = MentionNotificationHandler()
|
||||
|
||||
@staticmethod
|
||||
async def create_notification(
|
||||
|
|
|
|||
64
surfsense_backend/app/utils/chat_comments.py
Normal file
64
surfsense_backend/app/utils/chat_comments.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
"""
|
||||
Utility functions for chat comments, including mention parsing.
|
||||
"""
|
||||
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
# Pattern to match @[uuid] mentions in comment content
|
||||
MENTION_PATTERN = re.compile(r"@\[([0-9a-fA-F-]{36})\]")
|
||||
|
||||
|
||||
def parse_mentions(content: str) -> list[UUID]:
|
||||
"""
|
||||
Extract user UUIDs from @[uuid] mentions in content.
|
||||
|
||||
Args:
|
||||
content: Comment text that may contain @[uuid] mentions
|
||||
|
||||
Returns:
|
||||
List of unique user UUIDs found in the content
|
||||
"""
|
||||
matches = MENTION_PATTERN.findall(content)
|
||||
unique_uuids = []
|
||||
seen = set()
|
||||
|
||||
for match in matches:
|
||||
try:
|
||||
uuid = UUID(match)
|
||||
if uuid not in seen:
|
||||
seen.add(uuid)
|
||||
unique_uuids.append(uuid)
|
||||
except ValueError:
|
||||
# Invalid UUID format, skip
|
||||
continue
|
||||
|
||||
return unique_uuids
|
||||
|
||||
|
||||
def render_mentions(content: str, user_names: dict[UUID, str]) -> str:
|
||||
"""
|
||||
Replace @[uuid] mentions with @{DisplayName} in content.
|
||||
|
||||
Uses curly braces as delimiters for unambiguous frontend parsing.
|
||||
|
||||
Args:
|
||||
content: Comment text with @[uuid] mentions
|
||||
user_names: Dict mapping user UUIDs to display names
|
||||
|
||||
Returns:
|
||||
Content with mentions rendered as @{DisplayName}
|
||||
"""
|
||||
|
||||
def replace_mention(match: re.Match) -> str:
|
||||
try:
|
||||
uuid = UUID(match.group(1))
|
||||
name = user_names.get(uuid)
|
||||
if name:
|
||||
return f"@{{{name}}}"
|
||||
# Keep original format if user not found
|
||||
return match.group(0)
|
||||
except ValueError:
|
||||
return match.group(0)
|
||||
|
||||
return MENTION_PATTERN.sub(replace_mention, content)
|
||||
|
|
@ -8,10 +8,11 @@ import {
|
|||
} from "@assistant-ui/react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
type MentionedDocumentInfo,
|
||||
mentionedDocumentIdsAtom,
|
||||
|
|
@ -251,6 +252,7 @@ export default function NewChatPage() {
|
|||
const setMentionedDocuments = useSetAtom(mentionedDocumentsAtom);
|
||||
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
|
||||
const hydratePlanState = useSetAtom(hydratePlanStateAtom);
|
||||
const setCurrentThreadState = useSetAtom(currentThreadAtom);
|
||||
|
||||
// Get current user for author info in shared chats
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
|
@ -365,6 +367,48 @@ export default function NewChatPage() {
|
|||
initializeThread();
|
||||
}, [initializeThread]);
|
||||
|
||||
// Handle scroll to comment from URL query params (e.g., from notification click)
|
||||
const searchParams = useSearchParams();
|
||||
const targetCommentId = searchParams.get("commentId");
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetCommentId || isInitializing || messages.length === 0) return;
|
||||
|
||||
const tryScroll = () => {
|
||||
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Try immediately
|
||||
if (tryScroll()) return;
|
||||
|
||||
// Retry every 200ms for up to 10 seconds
|
||||
const intervalId = setInterval(() => {
|
||||
if (tryScroll()) clearInterval(intervalId);
|
||||
}, 200);
|
||||
|
||||
const timeoutId = setTimeout(() => clearInterval(intervalId), 10000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [targetCommentId, isInitializing, messages.length]);
|
||||
|
||||
// Sync current thread state to atom
|
||||
useEffect(() => {
|
||||
setCurrentThreadState({
|
||||
id: currentThread?.id ?? null,
|
||||
visibility: currentThread?.visibility ?? null,
|
||||
hasComments: currentThread?.has_comments ?? false,
|
||||
addingCommentToMessageId: null,
|
||||
});
|
||||
}, [currentThread, setCurrentThreadState]);
|
||||
|
||||
// Cancel ongoing request
|
||||
const cancelRun = useCallback(async () => {
|
||||
if (abortControllerRef.current) {
|
||||
|
|
@ -842,10 +886,32 @@ export default function NewChatPage() {
|
|||
// Persist assistant message (with thinking steps for restoration on refresh)
|
||||
const finalContent = buildContentForPersistence();
|
||||
if (contentParts.length > 0) {
|
||||
appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
}).catch((err) => console.error("Failed to persist assistant message:", err));
|
||||
try {
|
||||
const savedMessage = await appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: finalContent,
|
||||
});
|
||||
|
||||
// Update message ID from temporary to database ID so comments work immediately
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
);
|
||||
|
||||
// Also update thinking steps map with new ID
|
||||
setMessageThinkingSteps((prev) => {
|
||||
const steps = prev.get(assistantMsgId);
|
||||
if (steps) {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(assistantMsgId);
|
||||
newMap.set(newMsgId, steps);
|
||||
return newMap;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to persist assistant message:", err);
|
||||
}
|
||||
|
||||
// Track successful response
|
||||
trackChatResponseReceived(searchSpaceId, currentThreadId);
|
||||
|
|
@ -860,10 +926,20 @@ export default function NewChatPage() {
|
|||
);
|
||||
if (hasContent && currentThreadId) {
|
||||
const partialContent = buildContentForPersistence();
|
||||
appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: partialContent,
|
||||
}).catch((err) => console.error("Failed to persist partial assistant message:", err));
|
||||
try {
|
||||
const savedMessage = await appendMessage(currentThreadId, {
|
||||
role: "assistant",
|
||||
content: partialContent,
|
||||
});
|
||||
|
||||
// Update message ID from temporary to database ID
|
||||
const newMsgId = `msg-${savedMessage.id}`;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === assistantMsgId ? { ...m, id: newMsgId } : m))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to persist partial assistant message:", err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,5 +157,33 @@ button {
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.2) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin:hover {
|
||||
scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent;
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styles */
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted-foreground) / 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--muted-foreground) / 0.4);
|
||||
}
|
||||
|
||||
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
|
||||
@source '../node_modules/streamdown/dist/*.js';
|
||||
|
|
|
|||
72
surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts
Normal file
72
surfsense_web/atoms/chat-comments/comments-mutation.atoms.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateCommentRequest,
|
||||
CreateReplyRequest,
|
||||
DeleteCommentRequest,
|
||||
UpdateCommentRequest,
|
||||
} from "@/contracts/types/chat-comments.types";
|
||||
import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
|
||||
export const createCommentMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: CreateCommentRequest) => {
|
||||
return chatCommentsApiService.createComment(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error creating comment:", error);
|
||||
toast.error("Failed to create comment");
|
||||
},
|
||||
}));
|
||||
|
||||
export const createReplyMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: CreateReplyRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.createReply(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error creating reply:", error);
|
||||
toast.error("Failed to create reply");
|
||||
},
|
||||
}));
|
||||
|
||||
export const updateCommentMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: UpdateCommentRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.updateComment(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error updating comment:", error);
|
||||
toast.error("Failed to update comment");
|
||||
},
|
||||
}));
|
||||
|
||||
export const deleteCommentMutationAtom = atomWithMutation(() => ({
|
||||
mutationFn: async (request: DeleteCommentRequest & { message_id: number }) => {
|
||||
return chatCommentsApiService.deleteComment(request);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.comments.byMessage(variables.message_id),
|
||||
});
|
||||
toast.success("Comment deleted");
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error("Error deleting comment:", error);
|
||||
toast.error("Failed to delete comment");
|
||||
},
|
||||
}));
|
||||
52
surfsense_web/atoms/chat/current-thread.atom.ts
Normal file
52
surfsense_web/atoms/chat/current-thread.atom.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { atom } from "jotai";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
|
||||
// TODO: Update `hasComments` to true when the first comment is created on a thread.
|
||||
// Currently it only updates on thread load. The gutter still works because
|
||||
// `addingCommentToMessageId` keeps it open, but the state is technically stale.
|
||||
|
||||
// TODO: Reset `addingCommentToMessageId` to null after a comment is successfully created.
|
||||
// Currently it stays set until navigation or clicking another message's bubble.
|
||||
// Not causing issues since panel visibility is driven by per-message comment count.
|
||||
|
||||
// TODO: Consider calling `resetCurrentThreadAtom` when unmounting the chat page
|
||||
// for explicit cleanup, though React navigation handles this implicitly.
|
||||
|
||||
interface CurrentThreadState {
|
||||
id: number | null;
|
||||
visibility: ChatVisibility | null;
|
||||
hasComments: boolean;
|
||||
addingCommentToMessageId: number | null;
|
||||
}
|
||||
|
||||
const initialState: CurrentThreadState = {
|
||||
id: null,
|
||||
visibility: null,
|
||||
hasComments: false,
|
||||
addingCommentToMessageId: null,
|
||||
};
|
||||
|
||||
export const currentThreadAtom = atom<CurrentThreadState>(initialState);
|
||||
|
||||
export const commentsEnabledAtom = atom(
|
||||
(get) => get(currentThreadAtom).visibility === "SEARCH_SPACE"
|
||||
);
|
||||
|
||||
export const showCommentsGutterAtom = atom((get) => {
|
||||
const thread = get(currentThreadAtom);
|
||||
return (
|
||||
thread.visibility === "SEARCH_SPACE" &&
|
||||
(thread.hasComments || thread.addingCommentToMessageId !== null)
|
||||
);
|
||||
});
|
||||
|
||||
export const addingCommentToMessageIdAtom = atom(
|
||||
(get) => get(currentThreadAtom).addingCommentToMessageId,
|
||||
(get, set, messageId: number | null) => {
|
||||
set(currentThreadAtom, { ...get(currentThreadAtom), addingCommentToMessageId: messageId });
|
||||
}
|
||||
);
|
||||
|
||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||
set(currentThreadAtom, initialState);
|
||||
});
|
||||
|
|
@ -5,9 +5,15 @@ import {
|
|||
MessagePrimitive,
|
||||
useAssistantState,
|
||||
} from "@assistant-ui/react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
addingCommentToMessageIdAtom,
|
||||
commentsEnabledAtom,
|
||||
} from "@/atoms/chat/current-thread.atom";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
|
||||
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
|
||||
import {
|
||||
|
|
@ -16,6 +22,9 @@ import {
|
|||
} from "@/components/assistant-ui/thinking-steps";
|
||||
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
|
||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
|
||||
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
|
||||
export const MessageError: FC = () => {
|
||||
return (
|
||||
|
|
@ -76,13 +85,89 @@ const AssistantMessageInner: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
function parseMessageId(assistantUiMessageId: string | undefined): number | null {
|
||||
if (!assistantUiMessageId) return null;
|
||||
const match = assistantUiMessageId.match(/^msg-(\d+)$/);
|
||||
return match ? Number.parseInt(match[1], 10) : null;
|
||||
}
|
||||
|
||||
export const AssistantMessage: FC = () => {
|
||||
const [messageHeight, setMessageHeight] = useState<number | undefined>(undefined);
|
||||
const messageRef = useRef<HTMLDivElement>(null);
|
||||
const messageId = useAssistantState(({ message }) => message?.id);
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const dbMessageId = parseMessageId(messageId);
|
||||
const commentsEnabled = useAtomValue(commentsEnabledAtom);
|
||||
const [addingCommentToMessageId, setAddingCommentToMessageId] = useAtom(
|
||||
addingCommentToMessageIdAtom
|
||||
);
|
||||
|
||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
|
||||
const isMessageStreaming = isThreadRunning && isLastMessage;
|
||||
|
||||
const { data: commentsData } = useComments({
|
||||
messageId: dbMessageId ?? 0,
|
||||
enabled: !!dbMessageId,
|
||||
});
|
||||
|
||||
const hasComments = (commentsData?.total_count ?? 0) > 0;
|
||||
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
||||
const showCommentPanel = hasComments || isAddingComment;
|
||||
|
||||
const handleToggleAddComment = () => {
|
||||
if (!dbMessageId) return;
|
||||
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageRef.current) return;
|
||||
const el = messageRef.current;
|
||||
const update = () => setMessageHeight(el.offsetHeight);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
ref={messageRef}
|
||||
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
|
||||
{searchSpaceId && commentsEnabled && !isMessageStreaming && (
|
||||
<div className="absolute left-full top-0 ml-4 hidden lg:block w-72">
|
||||
<div
|
||||
className={`sticky top-3 ${showCommentPanel ? "opacity-100" : "opacity-0 group-hover:opacity-100"} transition-opacity`}
|
||||
>
|
||||
{!hasComments && (
|
||||
<CommentTrigger
|
||||
commentCount={0}
|
||||
isOpen={isAddingComment}
|
||||
onClick={handleToggleAddComment}
|
||||
disabled={!dbMessageId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showCommentPanel && dbMessageId && (
|
||||
<div
|
||||
className={
|
||||
hasComments ? "" : "mt-2 animate-in fade-in slide-in-from-top-2 duration-200"
|
||||
}
|
||||
>
|
||||
<CommentPanelContainer
|
||||
messageId={dbMessageId}
|
||||
isOpen={true}
|
||||
maxHeight={messageHeight}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
import { useParams } from "next/navigation";
|
||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||
import {
|
||||
mentionedDocumentIdsAtom,
|
||||
mentionedDocumentsAtom,
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||
import {
|
||||
|
|
@ -59,57 +61,63 @@ import { Button } from "@/components/ui/button";
|
|||
import type { Document } from "@/contracts/types/document.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Props for the Thread component
|
||||
*/
|
||||
interface ThreadProps {
|
||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||
/** Optional header component to render at the top of the viewport (sticky) */
|
||||
header?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Thread: FC<ThreadProps> = ({ messageThinkingSteps = new Map(), header }) => {
|
||||
return (
|
||||
<ThinkingStepsContext.Provider value={messageThinkingSteps}>
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
|
||||
>
|
||||
{/* Optional sticky header for model selector etc. */}
|
||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage,
|
||||
EditComposer,
|
||||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||
<ThreadScrollToBottom />
|
||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||
<Composer />
|
||||
</div>
|
||||
</AssistantIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
<ThreadContent header={header} />
|
||||
</ThinkingStepsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||
const showGutter = useAtomValue(showCommentsGutterAtom);
|
||||
|
||||
return (
|
||||
<ThreadPrimitive.Root
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-background"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "44rem",
|
||||
}}
|
||||
>
|
||||
<ThreadPrimitive.Viewport
|
||||
turnAnchor="top"
|
||||
className={cn(
|
||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||
showGutter && "lg:pr-30"
|
||||
)}
|
||||
>
|
||||
{header && <div className="sticky top-0 z-10 mb-4">{header}</div>}
|
||||
|
||||
<AssistantIf condition={({ thread }) => thread.isEmpty}>
|
||||
<ThreadWelcome />
|
||||
</AssistantIf>
|
||||
|
||||
<ThreadPrimitive.Messages
|
||||
components={{
|
||||
UserMessage,
|
||||
EditComposer,
|
||||
AssistantMessage,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ThreadPrimitive.ViewportFooter className="aui-thread-viewport-footer sticky bottom-0 mx-auto mt-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-background pb-4 md:pb-6">
|
||||
<ThreadScrollToBottom />
|
||||
<AssistantIf condition={({ thread }) => !thread.isEmpty}>
|
||||
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
|
||||
<Composer />
|
||||
</div>
|
||||
</AssistantIf>
|
||||
</ThreadPrimitive.ViewportFooter>
|
||||
</ThreadPrimitive.Viewport>
|
||||
</ThreadPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadScrollToBottom: FC = () => {
|
||||
return (
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
|
|
@ -579,17 +587,6 @@ const AssistantMessageInner: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const AssistantMessage: FC = () => {
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className="aui-assistant-message-root fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
|
||||
data-role="assistant"
|
||||
>
|
||||
<AssistantMessageInner />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantActionBar: FC = () => {
|
||||
return (
|
||||
<ActionBarPrimitive.Root
|
||||
|
|
|
|||
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
import { Send, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MemberMentionPicker } from "../member-mention-picker/member-mention-picker";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
import type { CommentComposerProps, InsertedMention, MentionState } from "./types";
|
||||
|
||||
function convertDisplayToData(displayContent: string, mentions: InsertedMention[]): string {
|
||||
let result = displayContent;
|
||||
|
||||
const sortedMentions = [...mentions].sort((a, b) => b.displayName.length - a.displayName.length);
|
||||
|
||||
for (const mention of sortedMentions) {
|
||||
const displayPattern = new RegExp(
|
||||
`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`,
|
||||
"g"
|
||||
);
|
||||
const dataFormat = `@[${mention.id}]`;
|
||||
result = result.replace(displayPattern, dataFormat);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function findMentionTrigger(
|
||||
text: string,
|
||||
cursorPos: number,
|
||||
insertedMentions: InsertedMention[]
|
||||
): { isActive: boolean; query: string; startIndex: number } {
|
||||
const textBeforeCursor = text.slice(0, cursorPos);
|
||||
|
||||
const mentionMatch = textBeforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
||||
|
||||
if (!mentionMatch) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
|
||||
const fullMatch = mentionMatch[0];
|
||||
const query = mentionMatch[1];
|
||||
const atIndex = cursorPos - query.length - 1;
|
||||
|
||||
if (atIndex > 0) {
|
||||
const charBefore = text[atIndex - 1];
|
||||
if (charBefore && !/[\s]/.test(charBefore)) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const textFromAt = text.slice(atIndex);
|
||||
|
||||
for (const mention of insertedMentions) {
|
||||
const mentionPattern = `@${mention.displayName}`;
|
||||
|
||||
if (textFromAt.startsWith(mentionPattern)) {
|
||||
const charAfterMention = text[atIndex + mentionPattern.length];
|
||||
if (!charAfterMention || /[\s.,!?;:]/.test(charAfterMention)) {
|
||||
if (cursorPos <= atIndex + mentionPattern.length) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (query.length > 50) {
|
||||
return { isActive: false, query: "", startIndex: 0 };
|
||||
}
|
||||
|
||||
return { isActive: true, query, startIndex: atIndex };
|
||||
}
|
||||
|
||||
export function CommentComposer({
|
||||
members,
|
||||
membersLoading = false,
|
||||
placeholder = "Write a comment...",
|
||||
submitLabel = "Send",
|
||||
isSubmitting = false,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
autoFocus = false,
|
||||
initialValue = "",
|
||||
}: CommentComposerProps) {
|
||||
const [displayContent, setDisplayContent] = useState(initialValue);
|
||||
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
||||
const [mentionsInitialized, setMentionsInitialized] = useState(false);
|
||||
const [mentionState, setMentionState] = useState<MentionState>({
|
||||
isActive: false,
|
||||
query: "",
|
||||
startIndex: 0,
|
||||
});
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const filteredMembers = mentionState.query
|
||||
? members.filter(
|
||||
(member) =>
|
||||
member.displayName?.toLowerCase().includes(mentionState.query.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(mentionState.query.toLowerCase())
|
||||
)
|
||||
: members;
|
||||
|
||||
const closeMentionPicker = useCallback(() => {
|
||||
setMentionState({ isActive: false, query: "", startIndex: 0 });
|
||||
setHighlightedIndex(0);
|
||||
}, []);
|
||||
|
||||
const insertMention = useCallback(
|
||||
(member: MemberOption) => {
|
||||
const displayName = member.displayName || member.email.split("@")[0];
|
||||
const before = displayContent.slice(0, mentionState.startIndex);
|
||||
const cursorPos = textareaRef.current?.selectionStart ?? displayContent.length;
|
||||
const after = displayContent.slice(cursorPos);
|
||||
const mentionText = `@${displayName} `;
|
||||
const newContent = before + mentionText + after;
|
||||
|
||||
setDisplayContent(newContent);
|
||||
setInsertedMentions((prev) => {
|
||||
const exists = prev.some((m) => m.id === member.id && m.displayName === displayName);
|
||||
if (exists) return prev;
|
||||
return [...prev, { id: member.id, displayName }];
|
||||
});
|
||||
closeMentionPicker();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (textareaRef.current) {
|
||||
const cursorPos = before.length + mentionText.length;
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.setSelectionRange(cursorPos, cursorPos);
|
||||
}
|
||||
});
|
||||
},
|
||||
[displayContent, mentionState.startIndex, closeMentionPicker]
|
||||
);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
const cursorPos = e.target.selectionStart;
|
||||
setDisplayContent(value);
|
||||
|
||||
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
|
||||
|
||||
if (triggerResult.isActive) {
|
||||
setMentionState(triggerResult);
|
||||
setHighlightedIndex(0);
|
||||
} else if (mentionState.isActive) {
|
||||
closeMentionPicker();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!mentionState.isActive) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
case "Tab":
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev < filteredMembers.length - 1 ? prev + 1 : 0));
|
||||
} else if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (filteredMembers[highlightedIndex]) {
|
||||
insertMention(filteredMembers[highlightedIndex]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
closeMentionPicker();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmed = displayContent.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
const dataContent = convertDisplayToData(trimmed, insertedMentions);
|
||||
onSubmit(dataContent);
|
||||
setDisplayContent("");
|
||||
setInsertedMentions([]);
|
||||
};
|
||||
|
||||
// Pre-populate insertedMentions from initialValue when members are loaded
|
||||
useEffect(() => {
|
||||
if (mentionsInitialized || !initialValue || members.length === 0) return;
|
||||
|
||||
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
|
||||
const foundMentions: InsertedMention[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionPattern.exec(initialValue)) !== null) {
|
||||
const displayName = match[1];
|
||||
const member = members.find(
|
||||
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
|
||||
);
|
||||
if (member) {
|
||||
const exists = foundMentions.some((m) => m.id === member.id);
|
||||
if (!exists) {
|
||||
foundMentions.push({ id: member.id, displayName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundMentions.length > 0) {
|
||||
setInsertedMentions(foundMentions);
|
||||
}
|
||||
setMentionsInitialized(true);
|
||||
}, [initialValue, members, mentionsInitialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Popover
|
||||
open={mentionState.isActive}
|
||||
onOpenChange={(open) => !open && closeMentionPicker()}
|
||||
modal={false}
|
||||
>
|
||||
<PopoverAnchor asChild>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={displayContent}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="min-h-[80px] resize-none"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
collisionPadding={8}
|
||||
className="w-72 p-0"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<MemberMentionPicker
|
||||
members={members}
|
||||
query={mentionState.query}
|
||||
highlightedIndex={highlightedIndex}
|
||||
isLoading={membersLoading}
|
||||
onSelect={insertMention}
|
||||
onHighlightChange={setHighlightedIndex}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<X className="mr-1 size-4" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(!canSubmit && "opacity-50")}
|
||||
>
|
||||
<Send className="mr-1 size-4" />
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export interface CommentComposerProps {
|
||||
members: MemberOption[];
|
||||
membersLoading?: boolean;
|
||||
placeholder?: string;
|
||||
submitLabel?: string;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (content: string) => void;
|
||||
onCancel?: () => void;
|
||||
autoFocus?: boolean;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export interface MentionState {
|
||||
isActive: boolean;
|
||||
query: string;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
export interface InsertedMention {
|
||||
id: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"use client";
|
||||
|
||||
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { CommentActionsProps } from "./types";
|
||||
|
||||
export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: CommentActionsProps) {
|
||||
if (!canEdit && !canDelete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{canEdit && (
|
||||
<DropdownMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentActions } from "./comment-actions";
|
||||
import type { CommentItemProps } from "./types";
|
||||
|
||||
function getInitials(name: string | null, email: string): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
|
||||
function formatTimestamp(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
const timeStr = date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
|
||||
if (diffMins < 1) {
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `${diffMins}m ago`;
|
||||
}
|
||||
|
||||
if (diffHours < 24 && date.getDate() === now.getDate()) {
|
||||
return `Today at ${timeStr}`;
|
||||
}
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.getDate() === yesterday.getDate() && diffDays < 2) {
|
||||
return `Yesterday at ${timeStr}`;
|
||||
}
|
||||
|
||||
if (diffDays < 7) {
|
||||
const dayName = date.toLocaleDateString("en-US", { weekday: "long" });
|
||||
return `${dayName} at ${timeStr}`;
|
||||
}
|
||||
|
||||
return (
|
||||
date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
}) + ` at ${timeStr}`
|
||||
);
|
||||
}
|
||||
|
||||
export function convertRenderedToDisplay(contentRendered: string): string {
|
||||
// Convert @{DisplayName} format to @DisplayName for editing
|
||||
return contentRendered.replace(/@\{([^}]+)\}/g, "@$1");
|
||||
}
|
||||
|
||||
function renderMentions(content: string): React.ReactNode {
|
||||
// Match @{DisplayName} format from backend
|
||||
const mentionPattern = /@\{([^}]+)\}/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = mentionPattern.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Display as @DisplayName (without curly braces)
|
||||
parts.push(
|
||||
<span key={match.index} className="rounded bg-primary/10 px-1 font-medium text-primary">
|
||||
@{match[1]}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : content;
|
||||
}
|
||||
|
||||
export function CommentItem({
|
||||
comment,
|
||||
onEdit,
|
||||
onEditSubmit,
|
||||
onEditCancel,
|
||||
onDelete,
|
||||
onReply,
|
||||
isReply = false,
|
||||
isEditing = false,
|
||||
isSubmitting = false,
|
||||
members = [],
|
||||
membersLoading = false,
|
||||
}: CommentItemProps) {
|
||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||
|
||||
const isCurrentUser = currentUser?.id === comment.author?.id;
|
||||
const displayName = isCurrentUser
|
||||
? "Me"
|
||||
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
||||
const email = comment.author?.email || "";
|
||||
|
||||
const handleEditSubmit = (content: string) => {
|
||||
onEditSubmit?.(comment.id, content);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("group flex gap-3")} data-comment-id={comment.id}>
|
||||
<Avatar className="size-8 shrink-0">
|
||||
{comment.author?.avatarUrl && (
|
||||
<AvatarImage src={comment.author.avatarUrl} alt={displayName} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(comment.author?.displayName ?? null, email || "U")}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(comment.createdAt)}
|
||||
</span>
|
||||
{comment.isEdited && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">(edited)</span>
|
||||
)}
|
||||
{!isEditing && (
|
||||
<div className="ml-auto">
|
||||
<CommentActions
|
||||
canEdit={comment.canEdit}
|
||||
canDelete={comment.canDelete}
|
||||
onEdit={() => onEdit?.(comment.id)}
|
||||
onDelete={() => onDelete?.(comment.id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-1">
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Edit your comment..."
|
||||
submitLabel="Save"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={onEditCancel}
|
||||
initialValue={convertRenderedToDisplay(comment.contentRendered)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-sm text-foreground whitespace-pre-wrap wrap-break-word">
|
||||
{renderMentions(comment.contentRendered)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isReply && onReply && !isEditing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-1 h-7 w-fit px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onReply(comment.id)}
|
||||
>
|
||||
<MessageSquare className="mr-1 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
surfsense_web/components/chat-comments/comment-item/types.ts
Normal file
44
surfsense_web/components/chat-comments/comment-item/types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
export interface CommentAuthor {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface CommentData {
|
||||
id: number;
|
||||
content: string;
|
||||
contentRendered: string;
|
||||
author: CommentAuthor | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isEdited: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
}
|
||||
|
||||
export interface CommentItemProps {
|
||||
comment: CommentData;
|
||||
onEdit?: (commentId: number) => void;
|
||||
onEditSubmit?: (commentId: number, content: string) => void;
|
||||
onEditCancel?: () => void;
|
||||
onDelete?: (commentId: number) => void;
|
||||
onReply?: (commentId: number) => void;
|
||||
isReply?: boolean;
|
||||
isEditing?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
members?: Array<{
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}>;
|
||||
membersLoading?: boolean;
|
||||
}
|
||||
|
||||
export interface CommentActionsProps {
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
createCommentMutationAtom,
|
||||
createReplyMutationAtom,
|
||||
deleteCommentMutationAtom,
|
||||
updateCommentMutationAtom,
|
||||
} from "@/atoms/chat-comments/comments-mutation.atoms";
|
||||
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { CommentPanel } from "../comment-panel/comment-panel";
|
||||
import type { CommentPanelContainerProps } from "./types";
|
||||
import { transformComment, transformMember } from "./utils";
|
||||
|
||||
export function CommentPanelContainer({
|
||||
messageId,
|
||||
isOpen,
|
||||
maxHeight,
|
||||
}: CommentPanelContainerProps) {
|
||||
const { data: commentsData, isLoading: isCommentsLoading } = useComments({
|
||||
messageId,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
const [{ data: membersData, isLoading: isMembersLoading }] = useAtom(membersAtom);
|
||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||
|
||||
const [{ mutate: createComment, isPending: isCreating }] = useAtom(createCommentMutationAtom);
|
||||
const [{ mutate: createReply, isPending: isCreatingReply }] = useAtom(createReplyMutationAtom);
|
||||
const [{ mutate: updateComment, isPending: isUpdating }] = useAtom(updateCommentMutationAtom);
|
||||
const [{ mutate: deleteComment, isPending: isDeleting }] = useAtom(deleteCommentMutationAtom);
|
||||
|
||||
const commentThreads = useMemo(() => {
|
||||
if (!commentsData?.comments) return [];
|
||||
return commentsData.comments.map(transformComment);
|
||||
}, [commentsData]);
|
||||
|
||||
const members = useMemo(() => {
|
||||
if (!membersData) return [];
|
||||
const allMembers = membersData.map(transformMember);
|
||||
// Filter out current user from mention picker
|
||||
if (currentUser?.id) {
|
||||
return allMembers.filter((member) => member.id !== currentUser.id);
|
||||
}
|
||||
return allMembers;
|
||||
}, [membersData, currentUser?.id]);
|
||||
|
||||
const isSubmitting = isCreating || isCreatingReply || isUpdating || isDeleting;
|
||||
|
||||
const handleCreateComment = (content: string) => {
|
||||
createComment({ message_id: messageId, content });
|
||||
};
|
||||
|
||||
const handleCreateReply = (commentId: number, content: string) => {
|
||||
createReply({ comment_id: commentId, content, message_id: messageId });
|
||||
};
|
||||
|
||||
const handleEditComment = (commentId: number, content: string) => {
|
||||
updateComment({ comment_id: commentId, content, message_id: messageId });
|
||||
};
|
||||
|
||||
const handleDeleteComment = (commentId: number) => {
|
||||
deleteComment({ comment_id: commentId, message_id: messageId });
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<CommentPanel
|
||||
threads={commentThreads}
|
||||
members={members}
|
||||
membersLoading={isMembersLoading}
|
||||
isLoading={isCommentsLoading}
|
||||
onCreateComment={handleCreateComment}
|
||||
onCreateReply={handleCreateReply}
|
||||
onEditComment={handleEditComment}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
isSubmitting={isSubmitting}
|
||||
maxHeight={maxHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface CommentPanelContainerProps {
|
||||
messageId: number;
|
||||
isOpen: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { Comment, CommentReply } from "@/contracts/types/chat-comments.types";
|
||||
import type { Membership } from "@/contracts/types/members.types";
|
||||
import type { CommentData } from "../comment-item/types";
|
||||
import type { CommentThreadData } from "../comment-thread/types";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export function transformAuthor(author: Comment["author"]): CommentData["author"] {
|
||||
if (!author) return null;
|
||||
return {
|
||||
id: author.id,
|
||||
displayName: author.display_name,
|
||||
email: author.email,
|
||||
avatarUrl: author.avatar_url,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformReply(reply: CommentReply): CommentData {
|
||||
return {
|
||||
id: reply.id,
|
||||
content: reply.content,
|
||||
contentRendered: reply.content_rendered,
|
||||
author: transformAuthor(reply.author),
|
||||
createdAt: reply.created_at,
|
||||
updatedAt: reply.updated_at,
|
||||
isEdited: reply.is_edited,
|
||||
canEdit: reply.can_edit,
|
||||
canDelete: reply.can_delete,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformComment(comment: Comment): CommentThreadData {
|
||||
return {
|
||||
id: comment.id,
|
||||
messageId: comment.message_id,
|
||||
content: comment.content,
|
||||
contentRendered: comment.content_rendered,
|
||||
author: transformAuthor(comment.author),
|
||||
createdAt: comment.created_at,
|
||||
updatedAt: comment.updated_at,
|
||||
isEdited: comment.is_edited,
|
||||
canEdit: comment.can_edit,
|
||||
canDelete: comment.can_delete,
|
||||
replyCount: comment.reply_count,
|
||||
replies: comment.replies.map(transformReply),
|
||||
};
|
||||
}
|
||||
|
||||
export function transformMember(membership: Membership): MemberOption {
|
||||
return {
|
||||
id: membership.user_id,
|
||||
displayName: membership.user_display_name ?? null,
|
||||
email: membership.user_email ?? "",
|
||||
avatarUrl: membership.user_avatar_url ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentThread } from "../comment-thread/comment-thread";
|
||||
import type { CommentPanelProps } from "./types";
|
||||
|
||||
export function CommentPanel({
|
||||
threads,
|
||||
members,
|
||||
membersLoading = false,
|
||||
isLoading = false,
|
||||
onCreateComment,
|
||||
onCreateReply,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
isSubmitting = false,
|
||||
maxHeight,
|
||||
}: CommentPanelProps) {
|
||||
const [isComposerOpen, setIsComposerOpen] = useState(false);
|
||||
|
||||
const handleCommentSubmit = (content: string) => {
|
||||
onCreateComment(content);
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleComposerCancel = () => {
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[120px] w-96 items-center justify-center rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
Loading comments...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasThreads = threads.length > 0;
|
||||
const showEmptyState = !hasThreads && !isComposerOpen;
|
||||
|
||||
// Ensure minimum usable height for empty state + composer button
|
||||
const minHeight = 180;
|
||||
const effectiveMaxHeight = maxHeight ? Math.max(maxHeight, minHeight) : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-85 flex-col rounded-lg border bg-card"
|
||||
style={effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
||||
>
|
||||
{hasThreads && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className="space-y-4 p-4">
|
||||
{threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
thread={thread}
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
onCreateReply={onCreateReply}
|
||||
onEditComment={onEditComment}
|
||||
onDeleteComment={onDeleteComment}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
|
||||
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
|
||||
<p className="text-sm text-muted-foreground">No comments yet</p>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
Start a conversation about this response
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={showEmptyState ? "border-t p-3" : "p-3"}>
|
||||
{isComposerOpen ? (
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Write a comment..."
|
||||
submitLabel="Comment"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleCommentSubmit}
|
||||
onCancel={handleComposerCancel}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsComposerOpen(true)}
|
||||
>
|
||||
<MessageSquarePlus className="mr-2 size-4" />
|
||||
Add a comment...
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import type { CommentThreadData } from "../comment-thread/types";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export interface CommentPanelProps {
|
||||
threads: CommentThreadData[];
|
||||
members: MemberOption[];
|
||||
membersLoading?: boolean;
|
||||
isLoading?: boolean;
|
||||
onCreateComment: (content: string) => void;
|
||||
onCreateReply: (commentId: number, content: string) => void;
|
||||
onEditComment: (commentId: number, content: string) => void;
|
||||
onDeleteComment: (commentId: number) => void;
|
||||
isSubmitting?: boolean;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, MessageSquare } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||
import { CommentItem } from "../comment-item/comment-item";
|
||||
import type { CommentThreadProps } from "./types";
|
||||
|
||||
export function CommentThread({
|
||||
thread,
|
||||
members,
|
||||
membersLoading = false,
|
||||
onCreateReply,
|
||||
onEditComment,
|
||||
onDeleteComment,
|
||||
isSubmitting = false,
|
||||
}: CommentThreadProps) {
|
||||
const [isRepliesExpanded, setIsRepliesExpanded] = useState(true);
|
||||
const [isReplyComposerOpen, setIsReplyComposerOpen] = useState(false);
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||
|
||||
const parentComment = {
|
||||
id: thread.id,
|
||||
content: thread.content,
|
||||
contentRendered: thread.contentRendered,
|
||||
author: thread.author,
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
isEdited: thread.isEdited,
|
||||
canEdit: thread.canEdit,
|
||||
canDelete: thread.canDelete,
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setIsReplyComposerOpen(true);
|
||||
setIsRepliesExpanded(true);
|
||||
};
|
||||
|
||||
const handleReplySubmit = (content: string) => {
|
||||
onCreateReply(thread.id, content);
|
||||
setIsReplyComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleReplyCancel = () => {
|
||||
setIsReplyComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleEditStart = (commentId: number) => {
|
||||
setEditingCommentId(commentId);
|
||||
};
|
||||
|
||||
const handleEditSubmit = (commentId: number, content: string) => {
|
||||
onEditComment(commentId, content);
|
||||
setEditingCommentId(null);
|
||||
};
|
||||
|
||||
const handleEditCancel = () => {
|
||||
setEditingCommentId(null);
|
||||
};
|
||||
|
||||
const hasReplies = thread.replies.length > 0;
|
||||
const showReplies = thread.replies.length === 1 || isRepliesExpanded;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Parent comment */}
|
||||
<CommentItem
|
||||
comment={parentComment}
|
||||
onEdit={handleEditStart}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditCancel={handleEditCancel}
|
||||
onDelete={onDeleteComment}
|
||||
isEditing={editingCommentId === parentComment.id}
|
||||
isSubmitting={isSubmitting}
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
/>
|
||||
|
||||
{/* Replies and actions - using flex layout with connector */}
|
||||
{(hasReplies || isReplyComposerOpen) && (
|
||||
<div className="flex">
|
||||
{/* Connector column - vertical line */}
|
||||
<div className="flex w-7 flex-col items-center">
|
||||
<div className="w-px flex-1 bg-border" />
|
||||
</div>
|
||||
|
||||
{/* Content column */}
|
||||
<div className="min-w-0 flex-1 space-y-2 pb-1">
|
||||
{/* Expand/collapse for multiple replies */}
|
||||
{thread.replies.length > 1 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setIsRepliesExpanded(!isRepliesExpanded)}
|
||||
>
|
||||
{isRepliesExpanded ? (
|
||||
<ChevronDown className="mr-1 size-3" />
|
||||
) : (
|
||||
<ChevronRight className="mr-1 size-3" />
|
||||
)}
|
||||
{thread.replies.length} replies
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Reply items */}
|
||||
{showReplies && hasReplies && (
|
||||
<div className="space-y-3 pt-2">
|
||||
{thread.replies.map((reply) => (
|
||||
<CommentItem
|
||||
key={reply.id}
|
||||
comment={reply}
|
||||
isReply
|
||||
onEdit={handleEditStart}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditCancel={handleEditCancel}
|
||||
onDelete={onDeleteComment}
|
||||
isEditing={editingCommentId === reply.id}
|
||||
isSubmitting={isSubmitting}
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply composer or button */}
|
||||
|
||||
{isReplyComposerOpen ? (
|
||||
<>
|
||||
<div className="pt-3">
|
||||
<CommentComposer
|
||||
members={members}
|
||||
membersLoading={membersLoading}
|
||||
placeholder="Write a reply..."
|
||||
submitLabel="Reply"
|
||||
isSubmitting={isSubmitting}
|
||||
onSubmit={handleReplySubmit}
|
||||
onCancel={handleReplyCancel}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1.5 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reply button when no replies yet */}
|
||||
{!hasReplies && !isReplyComposerOpen && (
|
||||
<div className="ml-7 mt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||
<MessageSquare className="mr-1.5 size-3" />
|
||||
Reply
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import type { CommentData } from "../comment-item/types";
|
||||
import type { MemberOption } from "../member-mention-picker/types";
|
||||
|
||||
export interface CommentThreadData {
|
||||
id: number;
|
||||
messageId: number;
|
||||
content: string;
|
||||
contentRendered: string;
|
||||
author: CommentData["author"];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isEdited: boolean;
|
||||
canEdit: boolean;
|
||||
canDelete: boolean;
|
||||
replyCount: number;
|
||||
replies: CommentData[];
|
||||
}
|
||||
|
||||
export interface CommentThreadProps {
|
||||
thread: CommentThreadData;
|
||||
members: MemberOption[];
|
||||
membersLoading?: boolean;
|
||||
onCreateReply: (commentId: number, content: string) => void;
|
||||
onEditComment: (commentId: number, content: string) => void;
|
||||
onDeleteComment: (commentId: number) => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CommentTriggerProps } from "./types";
|
||||
|
||||
export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: CommentTriggerProps) {
|
||||
const hasComments = commentCount > 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={hasComments ? "outline" : isOpen ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"relative size-10 rounded-full transition-all duration-200",
|
||||
hasComments
|
||||
? "border-primary/50 bg-primary/5 text-primary hover:bg-primary/10 hover:border-primary"
|
||||
: isOpen
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
!hasComments && !isOpen && "opacity-0 group-hover:opacity-100",
|
||||
disabled && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
||||
{hasComments && (
|
||||
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||
{commentCount > 9 ? "9+" : commentCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export interface CommentTriggerProps {
|
||||
commentCount: number;
|
||||
isOpen: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MemberMentionItemProps } from "./types";
|
||||
|
||||
function getInitials(name: string | null, email: string): string {
|
||||
if (name) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
return email[0]?.toUpperCase() ?? "?";
|
||||
}
|
||||
|
||||
export function MemberMentionItem({
|
||||
member,
|
||||
isHighlighted,
|
||||
onSelect,
|
||||
onMouseEnter,
|
||||
}: MemberMentionItemProps) {
|
||||
const displayName = member.displayName || member.email.split("@")[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors",
|
||||
isHighlighted ? "bg-accent" : "hover:bg-accent/50"
|
||||
)}
|
||||
onClick={() => onSelect(member)}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<Avatar className="size-7">
|
||||
{member.avatarUrl && <AvatarImage src={member.avatarUrl} alt={displayName} />}
|
||||
<AvatarFallback className="text-xs">
|
||||
{getInitials(member.displayName, member.email)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm font-medium">{displayName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{member.email}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MemberMentionItem } from "./member-mention-item";
|
||||
import type { MemberMentionPickerProps } from "./types";
|
||||
|
||||
export function MemberMentionPicker({
|
||||
members,
|
||||
query,
|
||||
highlightedIndex,
|
||||
isLoading = false,
|
||||
onSelect,
|
||||
onHighlightChange,
|
||||
}: MemberMentionPickerProps) {
|
||||
const filteredMembers = query
|
||||
? members.filter(
|
||||
(member) =>
|
||||
member.displayName?.toLowerCase().includes(query.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
: members;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredMembers.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{query ? "No members found" : "No members available"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="py-1">
|
||||
{filteredMembers.map((member, index) => (
|
||||
<MemberMentionItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
isHighlighted={index === highlightedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => onHighlightChange(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
export interface MemberOption {
|
||||
id: string;
|
||||
displayName: string | null;
|
||||
email: string;
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MemberMentionPickerProps {
|
||||
members: MemberOption[];
|
||||
query: string;
|
||||
highlightedIndex: number;
|
||||
isLoading?: boolean;
|
||||
onSelect: (member: MemberOption) => void;
|
||||
onHighlightChange: (index: number) => void;
|
||||
}
|
||||
|
||||
export interface MemberMentionItemProps {
|
||||
member: MemberOption;
|
||||
isHighlighted: boolean;
|
||||
onSelect: (member: MemberOption) => void;
|
||||
onMouseEnter: () => void;
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bell } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
|
|
@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
|
|||
import { useParams } from "next/navigation";
|
||||
|
||||
export function NotificationButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const params = useParams();
|
||||
|
||||
|
|
@ -25,7 +27,7 @@ export function NotificationButton() {
|
|||
);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -54,6 +56,7 @@ export function NotificationButton() {
|
|||
loading={loading}
|
||||
markAsRead={markAsRead}
|
||||
markAllAsRead={markAllAsRead}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import type { Notification } from "@/hooks/use-notifications";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||
|
||||
interface NotificationPopupProps {
|
||||
notifications: Notification[];
|
||||
|
|
@ -14,6 +16,7 @@ interface NotificationPopupProps {
|
|||
loading: boolean;
|
||||
markAsRead: (id: number) => Promise<boolean>;
|
||||
markAllAsRead: () => Promise<boolean>;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function NotificationPopup({
|
||||
|
|
@ -22,15 +25,38 @@ export function NotificationPopup({
|
|||
loading,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
onClose,
|
||||
}: NotificationPopupProps) {
|
||||
const handleMarkAsRead = async (id: number) => {
|
||||
await markAsRead(id);
|
||||
};
|
||||
const router = useRouter();
|
||||
|
||||
const handleMarkAllAsRead = async () => {
|
||||
await markAllAsRead();
|
||||
};
|
||||
|
||||
const handleNotificationClick = async (notification: Notification) => {
|
||||
if (!notification.read) {
|
||||
await markAsRead(notification.id);
|
||||
}
|
||||
|
||||
if (notification.type === "new_mention") {
|
||||
const metadata = notification.metadata as {
|
||||
thread_id?: number;
|
||||
comment_id?: number;
|
||||
};
|
||||
const searchSpaceId = notification.search_space_id;
|
||||
const threadId = metadata?.thread_id;
|
||||
const commentId = metadata?.comment_id;
|
||||
|
||||
if (searchSpaceId && threadId) {
|
||||
const url = commentId
|
||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||
onClose?.();
|
||||
router.push(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
|
||||
|
|
@ -86,7 +112,7 @@ export function NotificationPopup({
|
|||
<div key={notification.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !notification.read && handleMarkAsRead(notification.id)}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={cn(
|
||||
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
|
||||
!notification.read && "bg-accent/50"
|
||||
|
|
@ -106,7 +132,7 @@ export function NotificationPopup({
|
|||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
|
||||
{notification.message}
|
||||
{convertRenderedToDisplay(notification.message)}
|
||||
</p>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
|
|
|
|||
142
surfsense_web/contracts/types/chat-comments.types.ts
Normal file
142
surfsense_web/contracts/types/chat-comments.types.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const author = z.object({
|
||||
id: z.string().uuid(),
|
||||
display_name: z.string().nullable(),
|
||||
avatar_url: z.string().nullable(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export const commentReply = z.object({
|
||||
id: z.number(),
|
||||
content: z.string(),
|
||||
content_rendered: z.string(),
|
||||
author: author.nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
is_edited: z.boolean(),
|
||||
can_edit: z.boolean(),
|
||||
can_delete: z.boolean(),
|
||||
});
|
||||
|
||||
export const comment = z.object({
|
||||
id: z.number(),
|
||||
message_id: z.number(),
|
||||
content: z.string(),
|
||||
content_rendered: z.string(),
|
||||
author: author.nullable(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
is_edited: z.boolean(),
|
||||
can_edit: z.boolean(),
|
||||
can_delete: z.boolean(),
|
||||
reply_count: z.number(),
|
||||
replies: z.array(commentReply),
|
||||
});
|
||||
|
||||
export const mentionContext = z.object({
|
||||
thread_id: z.number(),
|
||||
thread_title: z.string(),
|
||||
message_id: z.number(),
|
||||
search_space_id: z.number(),
|
||||
search_space_name: z.string(),
|
||||
});
|
||||
|
||||
export const mentionComment = z.object({
|
||||
id: z.number(),
|
||||
content_preview: z.string(),
|
||||
author: author.nullable(),
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
export const mention = z.object({
|
||||
id: z.number(),
|
||||
created_at: z.string(),
|
||||
comment: mentionComment,
|
||||
context: mentionContext,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get comments for a message
|
||||
*/
|
||||
export const getCommentsRequest = z.object({
|
||||
message_id: z.number(),
|
||||
});
|
||||
|
||||
export const getCommentsResponse = z.object({
|
||||
comments: z.array(comment),
|
||||
total_count: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create comment
|
||||
*/
|
||||
export const createCommentRequest = z.object({
|
||||
message_id: z.number(),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export const createCommentResponse = comment;
|
||||
|
||||
/**
|
||||
* Create reply
|
||||
*/
|
||||
export const createReplyRequest = z.object({
|
||||
comment_id: z.number(),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export const createReplyResponse = commentReply;
|
||||
|
||||
/**
|
||||
* Update comment
|
||||
*/
|
||||
export const updateCommentRequest = z.object({
|
||||
comment_id: z.number(),
|
||||
content: z.string().min(1).max(5000),
|
||||
});
|
||||
|
||||
export const updateCommentResponse = commentReply;
|
||||
|
||||
/**
|
||||
* Delete comment
|
||||
*/
|
||||
export const deleteCommentRequest = z.object({
|
||||
comment_id: z.number(),
|
||||
});
|
||||
|
||||
export const deleteCommentResponse = z.object({
|
||||
message: z.string(),
|
||||
comment_id: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get mentions
|
||||
*/
|
||||
export const getMentionsRequest = z.object({
|
||||
search_space_id: z.number().optional(),
|
||||
});
|
||||
|
||||
export const getMentionsResponse = z.object({
|
||||
mentions: z.array(mention),
|
||||
total_count: z.number(),
|
||||
});
|
||||
|
||||
export type Author = z.infer<typeof author>;
|
||||
export type CommentReply = z.infer<typeof commentReply>;
|
||||
export type Comment = z.infer<typeof comment>;
|
||||
export type MentionContext = z.infer<typeof mentionContext>;
|
||||
export type MentionComment = z.infer<typeof mentionComment>;
|
||||
export type Mention = z.infer<typeof mention>;
|
||||
export type GetCommentsRequest = z.infer<typeof getCommentsRequest>;
|
||||
export type GetCommentsResponse = z.infer<typeof getCommentsResponse>;
|
||||
export type CreateCommentRequest = z.infer<typeof createCommentRequest>;
|
||||
export type CreateCommentResponse = z.infer<typeof createCommentResponse>;
|
||||
export type CreateReplyRequest = z.infer<typeof createReplyRequest>;
|
||||
export type CreateReplyResponse = z.infer<typeof createReplyResponse>;
|
||||
export type UpdateCommentRequest = z.infer<typeof updateCommentRequest>;
|
||||
export type UpdateCommentResponse = z.infer<typeof updateCommentResponse>;
|
||||
export type DeleteCommentRequest = z.infer<typeof deleteCommentRequest>;
|
||||
export type DeleteCommentResponse = z.infer<typeof deleteCommentResponse>;
|
||||
export type GetMentionsRequest = z.infer<typeof getMentionsRequest>;
|
||||
export type GetMentionsResponse = z.infer<typeof getMentionsResponse>;
|
||||
|
|
@ -11,6 +11,8 @@ export const membership = z.object({
|
|||
created_at: z.string(),
|
||||
role: role.nullable().optional(),
|
||||
user_email: z.string().nullable().optional(),
|
||||
user_display_name: z.string().nullable().optional(),
|
||||
user_avatar_url: z.string().nullable().optional(),
|
||||
user_is_active: z.boolean().nullable().optional(),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { documentTypeEnum } from "./document.types";
|
|||
/**
|
||||
* Notification type enum - matches backend notification types
|
||||
*/
|
||||
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing"]);
|
||||
export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing", "new_mention"]);
|
||||
|
||||
/**
|
||||
* Notification status enum - used in metadata
|
||||
|
|
@ -68,6 +68,20 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
|||
error_message: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* New mention metadata schema
|
||||
*/
|
||||
export const newMentionMetadata = z.object({
|
||||
mention_id: z.number(),
|
||||
comment_id: z.number(),
|
||||
message_id: z.number(),
|
||||
thread_id: z.number(),
|
||||
thread_title: z.string(),
|
||||
author_id: z.string(),
|
||||
author_name: z.string(),
|
||||
content_preview: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Union of all notification metadata types
|
||||
* Use this when the notification type is unknown
|
||||
|
|
@ -75,6 +89,7 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
|||
export const notificationMetadata = z.union([
|
||||
connectorIndexingMetadata,
|
||||
documentProcessingMetadata,
|
||||
newMentionMetadata,
|
||||
baseNotificationMetadata,
|
||||
]);
|
||||
|
||||
|
|
@ -107,6 +122,11 @@ export const documentProcessingNotification = notification.extend({
|
|||
metadata: documentProcessingMetadata,
|
||||
});
|
||||
|
||||
export const newMentionNotification = notification.extend({
|
||||
type: z.literal("new_mention"),
|
||||
metadata: newMentionMetadata,
|
||||
});
|
||||
|
||||
// Inferred types
|
||||
export type NotificationTypeEnum = z.infer<typeof notificationTypeEnum>;
|
||||
export type NotificationStatusEnum = z.infer<typeof notificationStatusEnum>;
|
||||
|
|
@ -114,7 +134,9 @@ export type DocumentProcessingStageEnum = z.infer<typeof documentProcessingStage
|
|||
export type BaseNotificationMetadata = z.infer<typeof baseNotificationMetadata>;
|
||||
export type ConnectorIndexingMetadata = z.infer<typeof connectorIndexingMetadata>;
|
||||
export type DocumentProcessingMetadata = z.infer<typeof documentProcessingMetadata>;
|
||||
export type NewMentionMetadata = z.infer<typeof newMentionMetadata>;
|
||||
export type NotificationMetadata = z.infer<typeof notificationMetadata>;
|
||||
export type Notification = z.infer<typeof notification>;
|
||||
export type ConnectorIndexingNotification = z.infer<typeof connectorIndexingNotification>;
|
||||
export type DocumentProcessingNotification = z.infer<typeof documentProcessingNotification>;
|
||||
export type NewMentionNotification = z.infer<typeof newMentionNotification>;
|
||||
|
|
|
|||
18
surfsense_web/hooks/use-comments.ts
Normal file
18
surfsense_web/hooks/use-comments.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
interface UseCommentsOptions {
|
||||
messageId: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useComments({ messageId, enabled = true }: UseCommentsOptions) {
|
||||
return useQuery({
|
||||
queryKey: cacheKeys.comments.byMessage(messageId),
|
||||
queryFn: async () => {
|
||||
return chatCommentsApiService.getComments({ message_id: messageId });
|
||||
},
|
||||
enabled: enabled && !!messageId,
|
||||
});
|
||||
}
|
||||
134
surfsense_web/lib/apis/chat-comments-api.service.ts
Normal file
134
surfsense_web/lib/apis/chat-comments-api.service.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import {
|
||||
type CreateCommentRequest,
|
||||
type CreateReplyRequest,
|
||||
createCommentRequest,
|
||||
createCommentResponse,
|
||||
createReplyRequest,
|
||||
createReplyResponse,
|
||||
type DeleteCommentRequest,
|
||||
deleteCommentRequest,
|
||||
deleteCommentResponse,
|
||||
type GetCommentsRequest,
|
||||
type GetMentionsRequest,
|
||||
getCommentsRequest,
|
||||
getCommentsResponse,
|
||||
getMentionsRequest,
|
||||
getMentionsResponse,
|
||||
type UpdateCommentRequest,
|
||||
updateCommentRequest,
|
||||
updateCommentResponse,
|
||||
} from "@/contracts/types/chat-comments.types";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class ChatCommentsApiService {
|
||||
/**
|
||||
* Get comments for a message
|
||||
*/
|
||||
getComments = async (request: GetCommentsRequest) => {
|
||||
const parsed = getCommentsRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/messages/${parsed.data.message_id}/comments`,
|
||||
getCommentsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a top-level comment
|
||||
*/
|
||||
createComment = async (request: CreateCommentRequest) => {
|
||||
const parsed = createCommentRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/messages/${parsed.data.message_id}/comments`,
|
||||
createCommentResponse,
|
||||
{ body: { content: parsed.data.content } }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a reply to a comment
|
||||
*/
|
||||
createReply = async (request: CreateReplyRequest) => {
|
||||
const parsed = createReplyRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.post(
|
||||
`/api/v1/comments/${parsed.data.comment_id}/replies`,
|
||||
createReplyResponse,
|
||||
{ body: { content: parsed.data.content } }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a comment
|
||||
*/
|
||||
updateComment = async (request: UpdateCommentRequest) => {
|
||||
const parsed = updateCommentRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.put(`/api/v1/comments/${parsed.data.comment_id}`, updateCommentResponse, {
|
||||
body: { content: parsed.data.content },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a comment
|
||||
*/
|
||||
deleteComment = async (request: DeleteCommentRequest) => {
|
||||
const parsed = deleteCommentRequest.safeParse(request);
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.delete(
|
||||
`/api/v1/comments/${parsed.data.comment_id}`,
|
||||
deleteCommentResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get mentions for current user
|
||||
*/
|
||||
getMentions = async (request?: GetMentionsRequest) => {
|
||||
const parsed = getMentionsRequest.safeParse(request ?? {});
|
||||
|
||||
if (!parsed.success) {
|
||||
const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (parsed.data.search_space_id !== undefined) {
|
||||
params.set("search_space_id", String(parsed.data.search_space_id));
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions";
|
||||
|
||||
return baseApiService.get(url, getMentionsResponse);
|
||||
};
|
||||
}
|
||||
|
||||
export const chatCommentsApiService = new ChatCommentsApiService();
|
||||
|
|
@ -23,6 +23,7 @@ export interface ThreadRecord {
|
|||
search_space_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
has_comments?: boolean;
|
||||
}
|
||||
|
||||
export interface MessageRecord {
|
||||
|
|
|
|||
|
|
@ -224,6 +224,19 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
|
||||
`);
|
||||
|
||||
// Create the chat_comment_mentions table schema in PGlite
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_comment_mentions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
comment_id INTEGER NOT NULL,
|
||||
mentioned_user_id TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_id ON chat_comment_mentions(mentioned_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
|
||||
`);
|
||||
|
||||
const electricUrl = getElectricUrl();
|
||||
|
||||
// STEP 4: Create the client wrapper
|
||||
|
|
|
|||
|
|
@ -72,4 +72,7 @@ export const cacheKeys = {
|
|||
["connectors", "google-drive", connectorId, "folders", parentId] as const,
|
||||
},
|
||||
},
|
||||
comments: {
|
||||
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue