mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/ui-mcp
This commit is contained in:
commit
a536ad1590
64 changed files with 4297 additions and 401 deletions
87
.github/workflows/docker_build.yaml
vendored
87
.github/workflows/docker_build.yaml
vendored
|
|
@ -3,17 +3,8 @@ name: Build and Push Docker Image
|
|||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
bump_type:
|
||||
description: 'Version bump type (patch, minor, major)'
|
||||
required: true
|
||||
default: 'patch'
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
branch:
|
||||
description: 'Branch to tag (leave empty for default branch)'
|
||||
description: 'Branch to build from (leave empty for default branch)'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
|
|
@ -34,55 +25,37 @@ jobs:
|
|||
ref: ${{ github.event.inputs.branch }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get latest SemVer tag and calculate next version
|
||||
- name: Read app version and calculate next Docker build version
|
||||
id: tag_version
|
||||
run: |
|
||||
git fetch --tags
|
||||
LATEST_TAG=$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort='v:refname' | tail -n 1)
|
||||
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
echo "No previous SemVer tag found. Starting with v0.1.0"
|
||||
case "${{ github.event.inputs.bump_type }}" in
|
||||
patch|minor)
|
||||
NEXT_VERSION="v0.1.0"
|
||||
;;
|
||||
major)
|
||||
NEXT_VERSION="v1.0.0"
|
||||
;;
|
||||
*)
|
||||
echo "Invalid bump type: ${{ github.event.inputs.bump_type }}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
echo "Latest tag found: $LATEST_TAG"
|
||||
VERSION=${LATEST_TAG#v}
|
||||
MAJOR=$(echo $VERSION | cut -d. -f1)
|
||||
MINOR=$(echo $VERSION | cut -d. -f2)
|
||||
PATCH=$(echo $VERSION | cut -d. -f3)
|
||||
|
||||
case "${{ github.event.inputs.bump_type }}" in
|
||||
patch)
|
||||
PATCH=$((PATCH + 1))
|
||||
;;
|
||||
minor)
|
||||
MINOR=$((MINOR + 1))
|
||||
PATCH=0
|
||||
;;
|
||||
major)
|
||||
MAJOR=$((MAJOR + 1))
|
||||
MINOR=0
|
||||
PATCH=0
|
||||
;;
|
||||
*)
|
||||
echo "Invalid bump type: ${{ github.event.inputs.bump_type }}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
NEXT_VERSION="v${MAJOR}.${MINOR}.${PATCH}"
|
||||
# Read version from pyproject.toml
|
||||
APP_VERSION=$(grep -E '^version = ' surfsense_backend/pyproject.toml | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "App version from pyproject.toml: $APP_VERSION"
|
||||
|
||||
if [ -z "$APP_VERSION" ]; then
|
||||
echo "Error: Could not read version from surfsense_backend/pyproject.toml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Calculated next version: $NEXT_VERSION"
|
||||
|
||||
# Fetch all tags
|
||||
git fetch --tags
|
||||
|
||||
# Find the latest docker build tag for this app version (format: APP_VERSION.BUILD_NUMBER)
|
||||
# Tags follow pattern: 0.0.11.1, 0.0.11.2, etc.
|
||||
LATEST_BUILD_TAG=$(git tag --list "${APP_VERSION}.*" --sort='-v:refname' | head -n 1)
|
||||
|
||||
if [ -z "$LATEST_BUILD_TAG" ]; then
|
||||
echo "No previous Docker build tag found for version ${APP_VERSION}. Starting with ${APP_VERSION}.1"
|
||||
NEXT_VERSION="${APP_VERSION}.1"
|
||||
else
|
||||
echo "Latest Docker build tag found: $LATEST_BUILD_TAG"
|
||||
# Extract the build number (4th component)
|
||||
BUILD_NUMBER=$(echo "$LATEST_BUILD_TAG" | rev | cut -d. -f1 | rev)
|
||||
NEXT_BUILD=$((BUILD_NUMBER + 1))
|
||||
NEXT_VERSION="${APP_VERSION}.${NEXT_BUILD}"
|
||||
fi
|
||||
|
||||
echo "Calculated next Docker version: $NEXT_VERSION"
|
||||
echo "next_version=$NEXT_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create and Push Tag
|
||||
|
|
@ -94,7 +67,7 @@ jobs:
|
|||
COMMIT_SHA=$(git rev-parse HEAD)
|
||||
echo "Tagging commit $COMMIT_SHA with $NEXT_TAG"
|
||||
|
||||
git tag -a "$NEXT_TAG" -m "Release $NEXT_TAG"
|
||||
git tag -a "$NEXT_TAG" -m "Docker build $NEXT_TAG"
|
||||
echo "Pushing tag $NEXT_TAG to origin"
|
||||
git push origin "$NEXT_TAG"
|
||||
|
||||
|
|
|
|||
|
|
@ -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;")
|
||||
|
|
@ -116,47 +116,6 @@ You have access to the following tools:
|
|||
* This makes your response more visual and engaging.
|
||||
* Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content.
|
||||
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
||||
|
||||
6. write_todos: Create and update a planning/todo list.
|
||||
- Args:
|
||||
- todos: List of todo items, each with:
|
||||
* content: Description of the task (required)
|
||||
* status: "pending", "in_progress", "completed", or "cancelled" (required)
|
||||
|
||||
STRICT MODE SELECTION - CHOOSE ONE:
|
||||
|
||||
[MODE A] AGENT PLAN (you will work through it)
|
||||
Use when: User asks you to explain, teach, plan, or break down a concept.
|
||||
Examples: "Explain how to set up Python", "Plan my trip", "Break down machine learning"
|
||||
Rules:
|
||||
- Create plan with first item "in_progress", rest "pending"
|
||||
- After explaining each step, call write_todos again to update progress
|
||||
- Only ONE item "in_progress" at a time
|
||||
- Mark items "completed" as you finish explaining them
|
||||
- Final call: all items "completed"
|
||||
|
||||
[MODE B] EXTERNAL TASK DISPLAY (from connectors - you CANNOT complete these)
|
||||
Use when: User asks to show/list/display tasks from Linear, Jira, ClickUp, GitHub, Airtable, Notion, or any connector.
|
||||
Examples: "Show my Linear tasks", "List Jira tickets", "Create todos from ClickUp", "Show GitHub issues"
|
||||
STRICT RULES:
|
||||
1. You CANNOT complete these tasks - only the user can in the actual tool
|
||||
2. PRESERVE original status from source - DO NOT use agent workflow
|
||||
3. Call write_todos ONCE with all tasks and their REAL statuses
|
||||
4. Provide insights/summary as TEXT after the todo list, NOT as todo items
|
||||
5. NO INTERNAL REASONING - Never expose your process. Do NOT say "Let me map...", "Converting statuses...", "Here's how I'll organize...", or explain mapping logic. Just call write_todos silently and provide insights.
|
||||
|
||||
STATUS MAPPING (apply strictly):
|
||||
- "completed" ← Done, Completed, Complete, Closed, Resolved, Fixed, Merged, Shipped, Released
|
||||
- "in_progress" ← In Progress, In Review, Testing, QA, Active, Doing, Started, Review, Working
|
||||
- "pending" ← Todo, To Do, Backlog, Open, New, Pending, Triage, Reopened, Unstarted
|
||||
- "cancelled" ← Cancelled, Canceled, Won't Fix, Duplicate, Invalid, Rejected, Archived, Obsolete
|
||||
|
||||
CONNECTOR-SPECIFIC:
|
||||
- Linear: state.name = "Done", "In Progress", "Todo", "Backlog", "Cancelled"
|
||||
- Jira: statusCategory.name = "To Do", "In Progress", "Done"
|
||||
- ClickUp: status = "complete", "in progress", "open", "closed"
|
||||
- GitHub: state = "open", "closed"; PRs also "merged"
|
||||
- Airtable/Notion: Check field values, apply mapping above
|
||||
</tools>
|
||||
<tool_call_examples>
|
||||
- User: "How do I install SurfSense?"
|
||||
|
|
@ -189,12 +148,24 @@ You have access to the following tools:
|
|||
|
||||
- User: "Check out https://dev.to/some-article"
|
||||
- Call: `link_preview(url="https://dev.to/some-article")`
|
||||
- Call: `scrape_webpage(url="https://dev.to/some-article")`
|
||||
- After getting the content, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then provide your analysis, referencing the displayed image
|
||||
|
||||
- User: "What's this blog post about? https://example.com/blog/post"
|
||||
- Call: `link_preview(url="https://example.com/blog/post")`
|
||||
- Call: `scrape_webpage(url="https://example.com/blog/post")`
|
||||
- After getting the content, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then provide your analysis, referencing the displayed image
|
||||
|
||||
- User: "https://github.com/some/repo"
|
||||
- Call: `link_preview(url="https://github.com/some/repo")`
|
||||
- Call: `scrape_webpage(url="https://github.com/some/repo")`
|
||||
- After getting the content, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then provide your analysis, referencing the displayed image
|
||||
|
||||
- User: "Show me this image: https://example.com/image.png"
|
||||
- Call: `display_image(src="https://example.com/image.png", alt="User shared image")`
|
||||
|
|
@ -210,86 +181,31 @@ You have access to the following tools:
|
|||
- The user can already see their screenshot - they don't need you to display it again.
|
||||
|
||||
- User: "Read this article and summarize it for me: https://example.com/blog/ai-trends"
|
||||
- Call: `link_preview(url="https://example.com/blog/ai-trends")`
|
||||
- Call: `scrape_webpage(url="https://example.com/blog/ai-trends")`
|
||||
- After getting the content, provide a summary based on the scraped text
|
||||
- After getting the content, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then provide a summary based on the scraped text
|
||||
|
||||
- User: "What does this page say about machine learning? https://docs.example.com/ml-guide"
|
||||
- Call: `link_preview(url="https://docs.example.com/ml-guide")`
|
||||
- Call: `scrape_webpage(url="https://docs.example.com/ml-guide")`
|
||||
- After getting the content, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then answer the question using the extracted content
|
||||
|
||||
- User: "Summarize this blog post: https://medium.com/some-article"
|
||||
- Call: `link_preview(url="https://medium.com/some-article")`
|
||||
- Call: `scrape_webpage(url="https://medium.com/some-article")`
|
||||
- Provide a comprehensive summary of the article content
|
||||
- After getting the content, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then provide a comprehensive summary of the article content
|
||||
|
||||
- User: "Read this tutorial and explain it: https://example.com/ml-tutorial"
|
||||
- First: `scrape_webpage(url="https://example.com/ml-tutorial")`
|
||||
- Then, if the content contains useful diagrams/images like ``:
|
||||
- Call: `display_image(src="https://example.com/nn-diagram.png", alt="Neural Network Diagram", title="Neural Network Architecture")`
|
||||
- Then provide your explanation, referencing the displayed image
|
||||
|
||||
[MODE A EXAMPLES] Agent Plan - you work through it:
|
||||
|
||||
- User: "Create a plan for building a user authentication system"
|
||||
- Call: `write_todos(todos=[{"content": "Design database schema for users and sessions", "status": "in_progress"}, {"content": "Implement registration and login endpoints", "status": "pending"}, {"content": "Add password reset functionality", "status": "pending"}])`
|
||||
- Then explain each step in detail as you work through them
|
||||
|
||||
- User: "Break down how to build a REST API into steps"
|
||||
- Call: `write_todos(todos=[{"content": "Design API endpoints and data models", "status": "in_progress"}, {"content": "Set up server framework and routing", "status": "pending"}, {"content": "Implement CRUD operations", "status": "pending"}, {"content": "Add authentication and error handling", "status": "pending"}])`
|
||||
- Then provide detailed explanations for each step
|
||||
|
||||
- User: "Help me plan my trip to Japan"
|
||||
- Call: `write_todos(todos=[{"content": "Research best time to visit and book flights", "status": "in_progress"}, {"content": "Plan itinerary for cities to visit", "status": "pending"}, {"content": "Book accommodations", "status": "pending"}, {"content": "Prepare travel documents and currency", "status": "pending"}])`
|
||||
- Then provide travel preparation guidance
|
||||
|
||||
- COMPLETE WORKFLOW EXAMPLE - User: "Explain how to set up a Python project"
|
||||
- STEP 1 (Create initial plan):
|
||||
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "in_progress"}, {"content": "Create project structure", "status": "pending"}, {"content": "Configure dependencies", "status": "pending"}])`
|
||||
Then explain virtual environment setup in detail...
|
||||
- STEP 2 (After explaining virtual environments, update progress):
|
||||
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "completed"}, {"content": "Create project structure", "status": "in_progress"}, {"content": "Configure dependencies", "status": "pending"}])`
|
||||
Then explain project structure in detail...
|
||||
- STEP 3 (After explaining project structure, update progress):
|
||||
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "completed"}, {"content": "Create project structure", "status": "completed"}, {"content": "Configure dependencies", "status": "in_progress"}])`
|
||||
Then explain dependency configuration in detail...
|
||||
- STEP 4 (After completing all explanations, mark all done):
|
||||
Call: `write_todos(todos=[{"content": "Set up virtual environment", "status": "completed"}, {"content": "Create project structure", "status": "completed"}, {"content": "Configure dependencies", "status": "completed"}])`
|
||||
Provide final summary
|
||||
|
||||
[MODE B EXAMPLES] External Tasks - preserve original status, you CANNOT complete:
|
||||
|
||||
- User: "Show my Linear tasks" or "Create todos for Linear tasks"
|
||||
- First search: `search_knowledge_base(query="Linear tasks issues", connectors_to_search=["LINEAR_CONNECTOR"])`
|
||||
- Then call write_todos ONCE with ORIGINAL statuses preserved:
|
||||
Call: `write_todos(todos=[
|
||||
{"content": "SUR-21: Add refresh button in manage documents page", "status": "completed"},
|
||||
{"content": "SUR-22: Logs page not accessible in docker", "status": "completed"},
|
||||
{"content": "SUR-27: Add Google Drive connector", "status": "in_progress"},
|
||||
{"content": "SUR-28: Logs page should show all logs", "status": "pending"}
|
||||
])`
|
||||
- Then provide INSIGHTS as text (NOT as todos):
|
||||
"You have 2 completed, 1 in progress, and 1 pending task. SUR-27 (Google Drive connector) is currently active. Consider prioritizing SUR-28 next."
|
||||
|
||||
- User: "List my Jira tickets"
|
||||
- First search: `search_knowledge_base(query="Jira tickets issues", connectors_to_search=["JIRA_CONNECTOR"])`
|
||||
- Map Jira statuses: "Done" → completed, "In Progress"/"In Review" → in_progress, "To Do" → pending
|
||||
- Call write_todos ONCE with mapped statuses
|
||||
- Provide summary as text after
|
||||
|
||||
- User: "Show ClickUp tasks"
|
||||
- First search: `search_knowledge_base(query="ClickUp tasks", connectors_to_search=["CLICKUP_CONNECTOR"])`
|
||||
- Map: "complete"/"closed" → completed, "in progress" → in_progress, "open" → pending
|
||||
- Call write_todos ONCE, then provide insights as text
|
||||
|
||||
- User: "Show my GitHub issues"
|
||||
- First search: `search_knowledge_base(query="GitHub issues", connectors_to_search=["GITHUB_CONNECTOR"])`
|
||||
- Map: "closed"/"merged" → completed, "open" → pending
|
||||
- Call write_todos ONCE, then summarize as text
|
||||
|
||||
CRITICAL FOR MODE B:
|
||||
- NEVER use the "first item in_progress, rest pending" pattern for external tasks
|
||||
- NEVER pretend you will complete external tasks - be honest that only the user can
|
||||
- ALWAYS preserve the actual status from the source system
|
||||
- ALWAYS provide insights/summaries as regular text, not as todo items
|
||||
</tool_call_examples>
|
||||
"""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""MCP Client Wrapper.
|
||||
|
||||
This module provides a client for communicating with MCP servers via stdio transport.
|
||||
This module provides a client for communicating with MCP servers via stdio and HTTP transports.
|
||||
It handles server lifecycle management, tool discovery, and tool execution.
|
||||
"""
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ from typing import Any
|
|||
|
||||
from mcp import ClientSession
|
||||
from mcp.client.stdio import StdioServerParameters, stdio_client
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -222,7 +223,7 @@ class MCPClient:
|
|||
async def test_mcp_connection(
|
||||
command: str, args: list[str], env: dict[str, str] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Test connection to an MCP server and fetch available tools.
|
||||
"""Test connection to an MCP server via stdio and fetch available tools.
|
||||
|
||||
Args:
|
||||
command: Command to spawn the MCP server
|
||||
|
|
@ -249,3 +250,51 @@ async def test_mcp_connection(
|
|||
"message": f"Failed to connect: {e!s}",
|
||||
"tools": [],
|
||||
}
|
||||
|
||||
|
||||
async def test_mcp_http_connection(
|
||||
url: str, headers: dict[str, str] | None = None, transport: str = "streamable-http"
|
||||
) -> dict[str, Any]:
|
||||
"""Test connection to an MCP server via HTTP and fetch available tools.
|
||||
|
||||
Args:
|
||||
url: URL of the MCP server
|
||||
headers: Optional HTTP headers for authentication
|
||||
transport: Transport type ("streamable-http", "http", or "sse")
|
||||
|
||||
Returns:
|
||||
Dict with connection status and available tools
|
||||
|
||||
"""
|
||||
try:
|
||||
logger.info("Testing HTTP MCP connection to: %s (transport: %s)", url, transport)
|
||||
|
||||
# Use streamable HTTP client for all HTTP-based transports
|
||||
async with streamablehttp_client(url, headers=headers or {}) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List available tools
|
||||
response = await session.list_tools()
|
||||
tools = []
|
||||
for tool in response.tools:
|
||||
tools.append({
|
||||
"name": tool.name,
|
||||
"description": tool.description or "",
|
||||
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {},
|
||||
})
|
||||
|
||||
logger.info("HTTP MCP connection successful. Found %d tools.", len(tools))
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Connected successfully. Found {len(tools)} tools.",
|
||||
"tools": tools,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to connect to HTTP MCP server: %s", e, exc_info=True)
|
||||
return {
|
||||
"status": "error",
|
||||
"message": f"Failed to connect: {e!s}",
|
||||
"tools": [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
This module creates LangChain tools from MCP servers using the Model Context Protocol.
|
||||
Tools are dynamically discovered from MCP servers - no manual configuration needed.
|
||||
|
||||
Supports both transport types:
|
||||
- stdio: Local process-based MCP servers (command, args, env)
|
||||
- streamable-http/http/sse: Remote HTTP-based MCP servers (url, headers)
|
||||
|
||||
This implements real MCP protocol support similar to Cursor's implementation.
|
||||
"""
|
||||
|
||||
|
|
@ -10,6 +14,8 @@ import logging
|
|||
from typing import Any
|
||||
|
||||
from langchain_core.tools import StructuredTool
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from pydantic import BaseModel, create_model
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -65,11 +71,11 @@ def _create_dynamic_input_model_from_schema(
|
|||
return create_model(model_name, **field_definitions)
|
||||
|
||||
|
||||
async def _create_mcp_tool_from_definition(
|
||||
async def _create_mcp_tool_from_definition_stdio(
|
||||
tool_def: dict[str, Any],
|
||||
mcp_client: MCPClient,
|
||||
) -> StructuredTool:
|
||||
"""Create a LangChain tool from an MCP tool definition.
|
||||
"""Create a LangChain tool from an MCP tool definition (stdio transport).
|
||||
|
||||
Args:
|
||||
tool_def: Tool definition from MCP server with name, description, input_schema
|
||||
|
|
@ -116,13 +122,223 @@ async def _create_mcp_tool_from_definition(
|
|||
coroutine=mcp_tool_call,
|
||||
args_schema=input_model,
|
||||
# Store the original MCP schema as metadata so we can access it later
|
||||
metadata={"mcp_input_schema": input_schema},
|
||||
metadata={"mcp_input_schema": input_schema, "mcp_transport": "stdio"},
|
||||
)
|
||||
|
||||
logger.info(f"Created MCP tool: '{tool_name}'")
|
||||
logger.info(f"Created MCP tool (stdio): '{tool_name}'")
|
||||
return tool
|
||||
|
||||
|
||||
async def _create_mcp_tool_from_definition_http(
|
||||
tool_def: dict[str, Any],
|
||||
url: str,
|
||||
headers: dict[str, str],
|
||||
) -> StructuredTool:
|
||||
"""Create a LangChain tool from an MCP tool definition (HTTP transport).
|
||||
|
||||
Args:
|
||||
tool_def: Tool definition from MCP server with name, description, input_schema
|
||||
url: URL of the MCP server
|
||||
headers: HTTP headers for authentication
|
||||
|
||||
Returns:
|
||||
LangChain StructuredTool instance
|
||||
|
||||
"""
|
||||
tool_name = tool_def.get("name", "unnamed_tool")
|
||||
tool_description = tool_def.get("description", "No description provided")
|
||||
input_schema = tool_def.get("input_schema", {"type": "object", "properties": {}})
|
||||
|
||||
# Log the actual schema for debugging
|
||||
logger.info(f"MCP HTTP tool '{tool_name}' input schema: {input_schema}")
|
||||
|
||||
# Create dynamic input model from schema
|
||||
input_model = _create_dynamic_input_model_from_schema(tool_name, input_schema)
|
||||
|
||||
async def mcp_http_tool_call(**kwargs) -> str:
|
||||
"""Execute the MCP tool call via HTTP transport."""
|
||||
logger.info(f"MCP HTTP tool '{tool_name}' called with params: {kwargs}")
|
||||
|
||||
try:
|
||||
async with streamablehttp_client(url, headers=headers) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Call the tool
|
||||
response = await session.call_tool(tool_name, arguments=kwargs)
|
||||
|
||||
# Extract content from response
|
||||
result = []
|
||||
for content in response.content:
|
||||
if hasattr(content, "text"):
|
||||
result.append(content.text)
|
||||
elif hasattr(content, "data"):
|
||||
result.append(str(content.data))
|
||||
else:
|
||||
result.append(str(content))
|
||||
|
||||
result_str = "\n".join(result) if result else ""
|
||||
logger.info(f"MCP HTTP tool '{tool_name}' succeeded: {result_str[:200]}")
|
||||
return result_str
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"MCP HTTP tool '{tool_name}' execution failed: {e!s}"
|
||||
logger.exception(error_msg)
|
||||
return f"Error: {error_msg}"
|
||||
|
||||
# Create StructuredTool
|
||||
tool = StructuredTool(
|
||||
name=tool_name,
|
||||
description=tool_description,
|
||||
coroutine=mcp_http_tool_call,
|
||||
args_schema=input_model,
|
||||
metadata={"mcp_input_schema": input_schema, "mcp_transport": "http", "mcp_url": url},
|
||||
)
|
||||
|
||||
logger.info(f"Created MCP tool (HTTP): '{tool_name}'")
|
||||
return tool
|
||||
|
||||
|
||||
async def _load_stdio_mcp_tools(
|
||||
connector_id: int,
|
||||
connector_name: str,
|
||||
server_config: dict[str, Any],
|
||||
) -> list[StructuredTool]:
|
||||
"""Load tools from a stdio-based MCP server.
|
||||
|
||||
Args:
|
||||
connector_id: Connector ID for logging
|
||||
connector_name: Connector name for logging
|
||||
server_config: Server configuration with command, args, env
|
||||
|
||||
Returns:
|
||||
List of tools from the MCP server
|
||||
"""
|
||||
tools: list[StructuredTool] = []
|
||||
|
||||
# Validate required command field
|
||||
command = server_config.get("command")
|
||||
if not command or not isinstance(command, str):
|
||||
logger.warning(
|
||||
f"MCP connector {connector_id} (name: '{connector_name}') missing or invalid command field, skipping"
|
||||
)
|
||||
return tools
|
||||
|
||||
# Validate args field (must be list if present)
|
||||
args = server_config.get("args", [])
|
||||
if not isinstance(args, list):
|
||||
logger.warning(
|
||||
f"MCP connector {connector_id} (name: '{connector_name}') has invalid args field (must be list), skipping"
|
||||
)
|
||||
return tools
|
||||
|
||||
# Validate env field (must be dict if present)
|
||||
env = server_config.get("env", {})
|
||||
if not isinstance(env, dict):
|
||||
logger.warning(
|
||||
f"MCP connector {connector_id} (name: '{connector_name}') has invalid env field (must be dict), skipping"
|
||||
)
|
||||
return tools
|
||||
|
||||
# Create MCP client
|
||||
mcp_client = MCPClient(command, args, env)
|
||||
|
||||
# Connect and discover tools
|
||||
async with mcp_client.connect():
|
||||
tool_definitions = await mcp_client.list_tools()
|
||||
|
||||
logger.info(
|
||||
f"Discovered {len(tool_definitions)} tools from stdio MCP server "
|
||||
f"'{command}' (connector {connector_id})"
|
||||
)
|
||||
|
||||
# Create LangChain tools from definitions
|
||||
for tool_def in tool_definitions:
|
||||
try:
|
||||
tool = await _create_mcp_tool_from_definition_stdio(tool_def, mcp_client)
|
||||
tools.append(tool)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to create tool '{tool_def.get('name')}' "
|
||||
f"from connector {connector_id}: {e!s}"
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
async def _load_http_mcp_tools(
|
||||
connector_id: int,
|
||||
connector_name: str,
|
||||
server_config: dict[str, Any],
|
||||
) -> list[StructuredTool]:
|
||||
"""Load tools from an HTTP-based MCP server.
|
||||
|
||||
Args:
|
||||
connector_id: Connector ID for logging
|
||||
connector_name: Connector name for logging
|
||||
server_config: Server configuration with url, headers
|
||||
|
||||
Returns:
|
||||
List of tools from the MCP server
|
||||
"""
|
||||
tools: list[StructuredTool] = []
|
||||
|
||||
# Validate required url field
|
||||
url = server_config.get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
logger.warning(
|
||||
f"MCP connector {connector_id} (name: '{connector_name}') missing or invalid url field, skipping"
|
||||
)
|
||||
return tools
|
||||
|
||||
# Validate headers field (must be dict if present)
|
||||
headers = server_config.get("headers", {})
|
||||
if not isinstance(headers, dict):
|
||||
logger.warning(
|
||||
f"MCP connector {connector_id} (name: '{connector_name}') has invalid headers field (must be dict), skipping"
|
||||
)
|
||||
return tools
|
||||
|
||||
# Connect and discover tools via HTTP
|
||||
try:
|
||||
async with streamablehttp_client(url, headers=headers) as (read, write, _):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
||||
# List available tools
|
||||
response = await session.list_tools()
|
||||
tool_definitions = []
|
||||
for tool in response.tools:
|
||||
tool_definitions.append({
|
||||
"name": tool.name,
|
||||
"description": tool.description or "",
|
||||
"input_schema": tool.inputSchema if hasattr(tool, "inputSchema") else {},
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"Discovered {len(tool_definitions)} tools from HTTP MCP server "
|
||||
f"'{url}' (connector {connector_id})"
|
||||
)
|
||||
|
||||
# Create LangChain tools from definitions
|
||||
for tool_def in tool_definitions:
|
||||
try:
|
||||
tool = await _create_mcp_tool_from_definition_http(tool_def, url, headers)
|
||||
tools.append(tool)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to create HTTP tool '{tool_def.get('name')}' "
|
||||
f"from connector {connector_id}: {e!s}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to connect to HTTP MCP server at '{url}' (connector {connector_id}): {e!s}"
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
async def load_mcp_tools(
|
||||
session: AsyncSession,
|
||||
search_space_id: int,
|
||||
|
|
@ -130,6 +346,7 @@ async def load_mcp_tools(
|
|||
"""Load all MCP tools from user's active MCP server connectors.
|
||||
|
||||
This discovers tools dynamically from MCP servers using the protocol.
|
||||
Supports both stdio (local process) and HTTP (remote server) transports.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
|
|
@ -163,54 +380,22 @@ async def load_mcp_tools(
|
|||
)
|
||||
continue
|
||||
|
||||
# Validate required command field
|
||||
command = server_config.get("command")
|
||||
if not command or not isinstance(command, str):
|
||||
logger.warning(
|
||||
f"MCP connector {connector.id} (name: '{connector.name}') missing or invalid command field, skipping"
|
||||
# Determine transport type
|
||||
transport = server_config.get("transport", "stdio")
|
||||
|
||||
if transport in ("streamable-http", "http", "sse"):
|
||||
# HTTP-based MCP server
|
||||
connector_tools = await _load_http_mcp_tools(
|
||||
connector.id, connector.name, server_config
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate args field (must be list if present)
|
||||
args = server_config.get("args", [])
|
||||
if not isinstance(args, list):
|
||||
logger.warning(
|
||||
f"MCP connector {connector.id} (name: '{connector.name}') has invalid args field (must be list), skipping"
|
||||
else:
|
||||
# stdio-based MCP server (default)
|
||||
connector_tools = await _load_stdio_mcp_tools(
|
||||
connector.id, connector.name, server_config
|
||||
)
|
||||
continue
|
||||
|
||||
# Validate env field (must be dict if present)
|
||||
env = server_config.get("env", {})
|
||||
if not isinstance(env, dict):
|
||||
logger.warning(
|
||||
f"MCP connector {connector.id} (name: '{connector.name}') has invalid env field (must be dict), skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
# Create MCP client
|
||||
mcp_client = MCPClient(command, args, env)
|
||||
|
||||
# Connect and discover tools
|
||||
async with mcp_client.connect():
|
||||
tool_definitions = await mcp_client.list_tools()
|
||||
|
||||
logger.info(
|
||||
f"Discovered {len(tool_definitions)} tools from MCP server "
|
||||
f"'{command}' (connector {connector.id})"
|
||||
)
|
||||
|
||||
# Create LangChain tools from definitions
|
||||
for tool_def in tool_definitions:
|
||||
try:
|
||||
tool = await _create_mcp_tool_from_definition(
|
||||
tool_def, mcp_client
|
||||
)
|
||||
tools.append(tool)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to create tool '{tool_def.get('name')}' "
|
||||
f"from connector {connector.id}: {e!s}"
|
||||
)
|
||||
|
||||
tools.extend(connector_tools)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to load tools from MCP connector {connector.id}: {e!s}"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -2385,22 +2385,43 @@ async def test_mcp_server_connection(
|
|||
This endpoint allows users to test their MCP server configuration
|
||||
before saving it, similar to Cursor's flow.
|
||||
|
||||
Supports two transport types:
|
||||
- stdio: Local process with command, args, env
|
||||
- streamable-http/http/sse: Remote HTTP server with url, headers
|
||||
|
||||
Args:
|
||||
server_config: Server configuration with command, args, env
|
||||
server_config: Server configuration
|
||||
user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Connection status and list of available tools
|
||||
"""
|
||||
try:
|
||||
from app.agents.new_chat.tools.mcp_client import test_mcp_connection
|
||||
from app.agents.new_chat.tools.mcp_client import (
|
||||
test_mcp_connection,
|
||||
test_mcp_http_connection,
|
||||
)
|
||||
|
||||
transport = server_config.get("transport", "stdio")
|
||||
|
||||
# HTTP transport (streamable-http, http, sse)
|
||||
if transport in ("streamable-http", "http", "sse"):
|
||||
url = server_config.get("url")
|
||||
headers = server_config.get("headers", {})
|
||||
|
||||
if not url:
|
||||
raise HTTPException(status_code=400, detail="Server URL is required for HTTP transport")
|
||||
|
||||
result = await test_mcp_http_connection(url, headers, transport)
|
||||
return result
|
||||
|
||||
# stdio transport (default)
|
||||
command = server_config.get("command")
|
||||
args = server_config.get("args", [])
|
||||
env = server_config.get("env", {})
|
||||
|
||||
if not command:
|
||||
raise HTTPException(status_code=400, detail="Server command is required")
|
||||
raise HTTPException(status_code=400, detail="Server command is required for stdio transport")
|
||||
|
||||
# Test the connection
|
||||
result = await test_mcp_connection(command, args, env)
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
|
|
@ -83,12 +83,27 @@ class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampMod
|
|||
|
||||
|
||||
class MCPServerConfig(BaseModel):
|
||||
"""Configuration for an MCP server connection (similar to Cursor's config)."""
|
||||
"""Configuration for an MCP server connection.
|
||||
|
||||
Supports two transport types:
|
||||
- stdio: Local process (command, args, env)
|
||||
- streamable-http/http/sse: Remote HTTP server (url, headers)
|
||||
"""
|
||||
|
||||
command: str # e.g., "uvx", "node", "python"
|
||||
# stdio transport fields
|
||||
command: str | None = None # e.g., "uvx", "node", "python"
|
||||
args: list[str] = [] # e.g., ["mcp-server-git", "--repository", "/path"]
|
||||
env: dict[str, str] = {} # Environment variables for the server process
|
||||
transport: str = "stdio" # "stdio" | "sse" | "http" (stdio is most common)
|
||||
|
||||
# HTTP transport fields
|
||||
url: str | None = None # e.g., "https://mcp-server.com/mcp"
|
||||
headers: dict[str, str] = {} # HTTP headers for authentication
|
||||
|
||||
transport: str = "stdio" # "stdio" | "streamable-http" | "http" | "sse"
|
||||
|
||||
def is_http_transport(self) -> bool:
|
||||
"""Check if this config uses HTTP transport."""
|
||||
return self.transport in ("streamable-http", "http", "sse")
|
||||
|
||||
|
||||
class MCPConnectorCreate(BaseModel):
|
||||
|
|
|
|||
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 { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, 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,12 @@ 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 { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
|
||||
import { CommentTrigger } from "@/components/chat-comments/comment-trigger/comment-trigger";
|
||||
import { useComments } from "@/hooks/use-comments";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const MessageError: FC = () => {
|
||||
return (
|
||||
|
|
@ -76,13 +88,140 @@ 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 [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
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
|
||||
);
|
||||
|
||||
// Screen size detection for responsive comment UI
|
||||
// Mobile: < 768px (bottom sheet), Medium: 768px - 1024px (right sheet), Desktop: >= 1024px (inline panel)
|
||||
const isMediumScreen = useMediaQuery("(min-width: 768px) and (max-width: 1023px)");
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
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 commentCount = commentsData?.total_count ?? 0;
|
||||
const hasComments = commentCount > 0;
|
||||
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
|
||||
const showCommentPanel = hasComments || isAddingComment;
|
||||
|
||||
const handleToggleAddComment = () => {
|
||||
if (!dbMessageId) return;
|
||||
setAddingCommentToMessageId(isAddingComment ? null : dbMessageId);
|
||||
};
|
||||
|
||||
const handleCommentTriggerClick = () => {
|
||||
setIsSheetOpen(true);
|
||||
};
|
||||
|
||||
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();
|
||||
}, []);
|
||||
|
||||
const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
|
||||
|
||||
// Determine sheet side based on screen size
|
||||
const sheetSide = isMediumScreen ? "right" : "bottom";
|
||||
|
||||
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 />
|
||||
|
||||
{/* Desktop comment panel - only on lg screens and above */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
|
||||
{showCommentTrigger && !isDesktop && (
|
||||
<div className="mt-2 flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCommentTriggerClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-full px-3 py-1.5 text-sm transition-colors",
|
||||
hasComments
|
||||
? "border border-primary/50 bg-primary/5 text-primary hover:bg-primary/10"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className={cn("size-4", hasComments && "fill-current")} />
|
||||
{hasComments ? (
|
||||
<span>{commentCount} {commentCount === 1 ? "comment" : "comments"}</span>
|
||||
) : (
|
||||
<span>Add comment</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Comment sheet - bottom for mobile, right for medium screens */}
|
||||
{showCommentTrigger && !isDesktop && (
|
||||
<CommentSheet
|
||||
messageId={dbMessageId}
|
||||
isOpen={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
commentCount={commentCount}
|
||||
side={sheetSide}
|
||||
/>
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
|
||||
|
||||
const DEFAULT_CONFIG = JSON.stringify(
|
||||
// Default config for stdio transport (local process)
|
||||
const DEFAULT_STDIO_CONFIG = JSON.stringify(
|
||||
{
|
||||
name: "My MCP Server",
|
||||
command: "npx",
|
||||
|
|
@ -37,6 +38,22 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
2
|
||||
);
|
||||
|
||||
// Default config for HTTP transport (remote server)
|
||||
const DEFAULT_HTTP_CONFIG = JSON.stringify(
|
||||
{
|
||||
name: "My Remote MCP Server",
|
||||
url: "https://your-mcp-server.com/mcp",
|
||||
headers: {
|
||||
"API_KEY": "your_api_key_here",
|
||||
},
|
||||
transport: "streamable-http",
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
const DEFAULT_CONFIG = DEFAULT_STDIO_CONFIG;
|
||||
|
||||
const parseConfig = () => {
|
||||
const result = parseMCPConfig(configJson);
|
||||
if (result.error) {
|
||||
|
|
@ -120,19 +137,40 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
return (
|
||||
<div className="space-y-6 pb-6">
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 [&>svg]:top-2 sm:[&>svg]:top-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a
|
||||
separate connector.
|
||||
</AlertDescription>
|
||||
</div>
|
||||
</Alert>
|
||||
<Server className="h-4 w-4 shrink-0" />
|
||||
<AlertDescription className="text-[10px] sm:text-xs">
|
||||
Connect to an MCP (Model Context Protocol) server. Each MCP server is added as a separate connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<form id="mcp-connect-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-4 sm:p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="config">MCP Server Configuration (JSON)</Label>
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<Label htmlFor="config">MCP Server Configuration (JSON)</Label>
|
||||
{!configJson && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleConfigChange(DEFAULT_STDIO_CONFIG)}
|
||||
>
|
||||
Local Example
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleConfigChange(DEFAULT_HTTP_CONFIG)}
|
||||
>
|
||||
Remote Example
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
id="config"
|
||||
value={configJson}
|
||||
|
|
@ -141,10 +179,11 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
rows={16}
|
||||
className={`font-mono text-xs ${jsonError ? "border-red-500" : ""}`}
|
||||
/>
|
||||
{jsonError && <p className="text-xs text-red-500">{jsonError}</p>}
|
||||
{jsonError && (
|
||||
<p className="text-xs text-red-500">JSON Error: {jsonError}</p>
|
||||
)}
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Paste a single MCP server configuration. Must include: name, command, args (optional),
|
||||
env (optional), transport (optional).
|
||||
Paste a single MCP server configuration. Must include: name, command, args (optional), env (optional), transport (optional).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -176,11 +215,9 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
|
||||
<AlertTitle className="text-xs sm:text-sm">
|
||||
{testResult.status === "success"
|
||||
? "Connection Successful"
|
||||
: "Connection Failed"}
|
||||
<div className="flex items-center justify-between">
|
||||
<AlertTitle className="text-sm">
|
||||
{testResult.status === "success" ? "Connection Successful" : "Connection Failed"}
|
||||
</AlertTitle>
|
||||
{testResult.tools.length > 0 && (
|
||||
<Button
|
||||
|
|
@ -214,10 +251,12 @@ export const MCPConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting })
|
|||
{testResult.message}
|
||||
{showDetails && testResult.tools.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-500/20">
|
||||
<p className="font-semibold mb-2 text-[10px] sm:text-xs">Available tools:</p>
|
||||
<ul className="list-disc list-inside text-[10px] sm:text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool) => (
|
||||
<li key={tool.name}>{tool.name}</li>
|
||||
<p className="font-semibold mb-2">
|
||||
Available tools:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-xs space-y-0.5">
|
||||
{testResult.tools.map((tool, i) => (
|
||||
<li key={i}>{tool.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Server, XCircle } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { MCPServerConfig, MCPToolDefinition } from "@/contracts/types/mcp.types";
|
||||
import type { MCPServerConfig } from "@/contracts/types/mcp.types";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
import {
|
||||
parseMCPConfig,
|
||||
|
|
@ -28,46 +28,61 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [testResult, setTestResult] = useState<MCPConnectionTestResult | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Check if this is a valid MCP connector
|
||||
const isValidConnector = connector.connector_type === EnumConnectorName.MCP_CONNECTOR;
|
||||
|
||||
// Initialize form from connector config (only on mount)
|
||||
// We intentionally only read connector.name and connector.config on initial mount
|
||||
// to preserve user edits during the session
|
||||
useEffect(() => {
|
||||
if (!isValidConnector || initializedRef.current) return;
|
||||
initializedRef.current = true;
|
||||
|
||||
if (connector.name) {
|
||||
setName(connector.name);
|
||||
}
|
||||
|
||||
const serverConfig = connector.config?.server_config as MCPServerConfig | undefined;
|
||||
if (serverConfig) {
|
||||
// Convert server config to JSON string for editing (name is in separate field)
|
||||
const configObj = {
|
||||
command: serverConfig.command || "",
|
||||
args: serverConfig.args || [],
|
||||
env: serverConfig.env || {},
|
||||
transport: serverConfig.transport || "stdio",
|
||||
};
|
||||
const transport = serverConfig.transport || "stdio";
|
||||
|
||||
// Build config object based on transport type
|
||||
let configObj: Record<string, unknown>;
|
||||
|
||||
if (transport === "streamable-http" || transport === "http" || transport === "sse") {
|
||||
// HTTP transport - use url and headers
|
||||
configObj = {
|
||||
url: (serverConfig as any).url || "",
|
||||
headers: (serverConfig as any).headers || {},
|
||||
transport: transport,
|
||||
};
|
||||
} else {
|
||||
// stdio transport (default) - use command, args, env
|
||||
configObj = {
|
||||
command: (serverConfig as any).command || "",
|
||||
args: (serverConfig as any).args || [],
|
||||
env: (serverConfig as any).env || {},
|
||||
transport: transport,
|
||||
};
|
||||
}
|
||||
|
||||
setConfigJson(JSON.stringify(configObj, null, 2));
|
||||
}
|
||||
}, []);
|
||||
}, [isValidConnector, connector.name, connector.config?.server_config]);
|
||||
|
||||
// Validate that this is an MCP connector (after hooks)
|
||||
if (connector.connector_type !== EnumConnectorName.MCP_CONNECTOR) {
|
||||
console.error("MCPConfig received non-MCP connector:", connector.connector_type);
|
||||
return (
|
||||
<Alert className="border-red-500/50 bg-red-500/10">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<AlertTitle>Invalid Connector Type</AlertTitle>
|
||||
<AlertDescription>This component can only be used with MCP connectors.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
const handleNameChange = useCallback(
|
||||
(value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
},
|
||||
[onNameChange]
|
||||
);
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (onNameChange) {
|
||||
onNameChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
const parseConfig = () => {
|
||||
const parseConfig = useCallback(() => {
|
||||
const result = parseMCPConfig(configJson);
|
||||
if (result.error) {
|
||||
setJsonError(result.error);
|
||||
|
|
@ -75,25 +90,26 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
setJsonError(null);
|
||||
}
|
||||
return result.config;
|
||||
};
|
||||
}, [configJson]);
|
||||
|
||||
const handleConfigChange = (value: string) => {
|
||||
setConfigJson(value);
|
||||
if (jsonError) {
|
||||
const handleConfigChange = useCallback(
|
||||
(value: string) => {
|
||||
setConfigJson(value);
|
||||
setJsonError(null);
|
||||
}
|
||||
|
||||
// Use shared utility for validation and parsing (with caching)
|
||||
const result = parseMCPConfig(value);
|
||||
// Use shared utility for validation and parsing (with caching)
|
||||
const result = parseMCPConfig(value);
|
||||
|
||||
if (result.config && onConfigChange) {
|
||||
// Valid config - update parent immediately
|
||||
onConfigChange({ server_config: result.config });
|
||||
}
|
||||
// Ignore errors while typing - only show errors when user tests or saves
|
||||
};
|
||||
if (result.config && onConfigChange) {
|
||||
// Valid config - update parent immediately
|
||||
onConfigChange({ server_config: result.config });
|
||||
}
|
||||
// Ignore errors while typing - only show errors when user tests or saves
|
||||
},
|
||||
[onConfigChange]
|
||||
);
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
const handleTestConnection = useCallback(async () => {
|
||||
const serverConfig = parseConfig();
|
||||
if (!serverConfig) {
|
||||
setTestResult({
|
||||
|
|
@ -115,7 +131,19 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
const result = await testMCPConnection(serverConfig);
|
||||
setTestResult(result);
|
||||
setIsTesting(false);
|
||||
};
|
||||
}, [parseConfig, jsonError, onConfigChange]);
|
||||
|
||||
// Validate that this is an MCP connector - must be after all hooks
|
||||
if (!isValidConnector) {
|
||||
console.error("MCPConfig received non-MCP connector:", connector.connector_type);
|
||||
return (
|
||||
<Alert className="border-red-500/50 bg-red-500/10">
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<AlertTitle>Invalid Connector Type</AlertTitle>
|
||||
<AlertDescription>This component can only be used with MCP connectors.</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
|
@ -158,8 +186,8 @@ export const MCPConfig: FC<MCPConfigProps> = ({ connector, onConfigChange, onNam
|
|||
/>
|
||||
{jsonError && <p className="text-xs text-red-500">JSON Error: {jsonError}</p>}
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
Edit your MCP server configuration. Must include: name, command, args (optional), env
|
||||
(optional), transport (optional).
|
||||
<strong>Local (stdio):</strong> command, args, env, transport: "stdio"<br />
|
||||
<strong>Remote (HTTP):</strong> url, headers, transport: "streamable-http"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -35,20 +35,27 @@ import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
|||
|
||||
/**
|
||||
* Zod schema for MCP server configuration
|
||||
* Provides compile-time and runtime type safety
|
||||
* Supports both stdio (local process) and HTTP (remote server) transports
|
||||
*
|
||||
* Exported for advanced use cases (e.g., form builders)
|
||||
*/
|
||||
export const MCPServerConfigSchema = z.object({
|
||||
const StdioConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
command: z
|
||||
.string({ required_error: "Command field is required" })
|
||||
.min(1, "Command cannot be empty"),
|
||||
command: z.string().min(1, "Command cannot be empty"),
|
||||
args: z.array(z.string()).optional().default([]),
|
||||
env: z.record(z.string(), z.string()).optional().default({}),
|
||||
transport: z.enum(["stdio", "sse"]).optional().default("stdio"),
|
||||
transport: z.enum(["stdio"]).optional().default("stdio"),
|
||||
});
|
||||
|
||||
const HttpConfigSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
url: z.string().url("URL must be a valid URL"),
|
||||
headers: z.record(z.string(), z.string()).optional().default({}),
|
||||
transport: z.enum(["streamable-http", "http", "sse"]),
|
||||
});
|
||||
|
||||
export const MCPServerConfigSchema = z.union([StdioConfigSchema, HttpConfigSchema]);
|
||||
|
||||
/**
|
||||
* Shared MCP configuration validation result
|
||||
*/
|
||||
|
|
@ -148,12 +155,19 @@ export const parseMCPConfig = (configJson: string): MCPConfigValidationResult =>
|
|||
};
|
||||
}
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
command: result.data.command,
|
||||
args: result.data.args,
|
||||
env: result.data.env,
|
||||
transport: result.data.transport,
|
||||
};
|
||||
// Build config based on transport type
|
||||
const config: MCPServerConfig = result.data.transport === "stdio" || !result.data.transport
|
||||
? {
|
||||
command: (result.data as z.infer<typeof StdioConfigSchema>).command,
|
||||
args: (result.data as z.infer<typeof StdioConfigSchema>).args,
|
||||
env: (result.data as z.infer<typeof StdioConfigSchema>).env,
|
||||
transport: "stdio" as const,
|
||||
}
|
||||
: {
|
||||
url: (result.data as z.infer<typeof HttpConfigSchema>).url,
|
||||
headers: (result.data as z.infer<typeof HttpConfigSchema>).headers,
|
||||
transport: result.data.transport as "streamable-http" | "http" | "sse",
|
||||
};
|
||||
|
||||
// Cache the successfully parsed config
|
||||
configCache.set(configJson, {
|
||||
|
|
|
|||
|
|
@ -108,7 +108,10 @@ export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?:
|
|||
{/* Step dot - on top of line */}
|
||||
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
|
||||
{effectiveStatus === "in_progress" ? (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
<span className="relative flex size-2">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
|
||||
<span className="relative inline-flex size-2 rounded-full bg-primary" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-2 rounded-full bg-muted-foreground/30" />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 z-20 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
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export const UserMessage: FC = () => {
|
|||
</div>
|
||||
{/* User avatar - only shown in shared chats */}
|
||||
{author && (
|
||||
<div className="shrink-0">
|
||||
<div className="shrink-0 mb-1.5">
|
||||
<UserAvatar displayName={author.displayName} avatarUrl={author.avatarUrl} />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,87 @@
|
|||
"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,
|
||||
variant = "desktop",
|
||||
}: 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}
|
||||
variant={variant}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export interface CommentPanelContainerProps {
|
||||
messageId: number;
|
||||
isOpen: boolean;
|
||||
maxHeight?: number;
|
||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
|
||||
variant?: "desktop" | "mobile";
|
||||
}
|
||||
|
|
@ -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,124 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
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,
|
||||
variant = "desktop",
|
||||
}: CommentPanelProps) {
|
||||
const [isComposerOpen, setIsComposerOpen] = useState(false);
|
||||
|
||||
const handleCommentSubmit = (content: string) => {
|
||||
onCreateComment(content);
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
const handleComposerCancel = () => {
|
||||
setIsComposerOpen(false);
|
||||
};
|
||||
|
||||
const isMobile = variant === "mobile";
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex min-h-[120px] items-center justify-center p-4",
|
||||
!isMobile && "w-96 rounded-lg border bg-card"
|
||||
)}>
|
||||
<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={cn(
|
||||
"flex flex-col",
|
||||
isMobile ? "w-full" : "w-85 rounded-lg border bg-card"
|
||||
)}
|
||||
style={!isMobile && 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={cn(
|
||||
"p-3",
|
||||
showEmptyState && !isMobile && "border-t",
|
||||
isMobile && "border-t"
|
||||
)}>
|
||||
{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,17 @@
|
|||
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;
|
||||
/** Variant for responsive styling - desktop shows border/bg, mobile is plain */
|
||||
variant?: "desktop" | "mobile";
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
|
||||
import type { CommentSheetProps } from "./types";
|
||||
|
||||
export function CommentSheet({
|
||||
messageId,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
commentCount = 0,
|
||||
side = "bottom",
|
||||
}: CommentSheetProps) {
|
||||
const isBottomSheet = side === "bottom";
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side={side}
|
||||
className={cn(
|
||||
"flex flex-col p-0",
|
||||
isBottomSheet
|
||||
? "h-[85vh] max-h-[85vh] rounded-t-xl"
|
||||
: "h-full w-full max-w-md"
|
||||
)}
|
||||
>
|
||||
{/* Drag handle indicator - only for bottom sheet */}
|
||||
{isBottomSheet && (
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
<SheetHeader className={cn(
|
||||
"flex-shrink-0 border-b px-4",
|
||||
isBottomSheet ? "pb-3" : "py-4"
|
||||
)}>
|
||||
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<MessageSquare className="size-5" />
|
||||
Comments
|
||||
{commentCount > 0 && (
|
||||
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{commentCount}
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<CommentPanelContainer
|
||||
messageId={messageId}
|
||||
isOpen={true}
|
||||
variant="mobile"
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export interface CommentSheetProps {
|
||||
messageId: number;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
commentCount?: number;
|
||||
/** Side to open the sheet from - bottom for mobile, right for medium screens */
|
||||
side?: "bottom" | "right";
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -6,8 +6,75 @@ import { useEffect, useState } from "react";
|
|||
import { Logo } from "@/components/Logo";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { useGithubStars } from "@/hooks/use-github-stars";
|
||||
import { AUTH_TYPE, BACKEND_URL } from "@/lib/env-config";
|
||||
import { trackLoginAttempt } from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Official Google "G" logo with brand colors
|
||||
const GoogleLogo = ({ className }: { className?: string }) => (
|
||||
<svg className={className} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// Sign in button component that handles both Google OAuth and local auth
|
||||
const SignInButton = ({ variant = "desktop" }: { variant?: "desktop" | "mobile" }) => {
|
||||
const isGoogleAuth = AUTH_TYPE === "GOOGLE";
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
trackLoginAttempt("google");
|
||||
window.location.href = `${BACKEND_URL}/auth/google/authorize-redirect`;
|
||||
};
|
||||
|
||||
if (isGoogleAuth) {
|
||||
return (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 font-semibold transition-all duration-200",
|
||||
variant === "desktop"
|
||||
? "hidden rounded-full bg-white px-5 py-2 text-sm text-neutral-700 shadow-md ring-1 ring-neutral-200/50 hover:shadow-lg md:flex dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50"
|
||||
: "w-full rounded-lg bg-white px-8 py-2.5 text-neutral-700 shadow-md ring-1 ring-neutral-200/50 dark:bg-neutral-900 dark:text-neutral-200 dark:ring-neutral-700/50 touch-manipulation"
|
||||
)}
|
||||
>
|
||||
<GoogleLogo className="h-4 w-4" />
|
||||
<span>Sign In</span>
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href="/login"
|
||||
className={cn(
|
||||
variant === "desktop"
|
||||
? "hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"
|
||||
: "w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation"
|
||||
)}
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
|
|
@ -102,12 +169,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
)}
|
||||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
<Link
|
||||
href="/login"
|
||||
className="hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<SignInButton variant="desktop" />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
|
@ -191,12 +253,7 @@ const MobileNav = ({ navItems, isScrolled }: any) => {
|
|||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black text-center touch-manipulation"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<SignInButton variant="mobile" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -1,15 +1,24 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* MCP Server Configuration Schema (similar to Cursor's config)
|
||||
* MCP Server Configuration Schema
|
||||
* Supports both stdio (local process) and HTTP (remote server) transports
|
||||
*/
|
||||
export const mcpServerConfig = z.object({
|
||||
const stdioConfigSchema = z.object({
|
||||
command: z.string().min(1, "Command is required"),
|
||||
args: z.array(z.string()).default([]),
|
||||
env: z.record(z.string(), z.string()).default({}),
|
||||
transport: z.enum(["stdio", "sse", "http"]).default("stdio"),
|
||||
transport: z.enum(["stdio"]).default("stdio"),
|
||||
});
|
||||
|
||||
const httpConfigSchema = z.object({
|
||||
url: z.string().url("URL must be a valid URL"),
|
||||
headers: z.record(z.string(), z.string()).default({}),
|
||||
transport: z.enum(["streamable-http", "http", "sse"]),
|
||||
});
|
||||
|
||||
export const mcpServerConfig = z.union([stdioConfigSchema, httpConfigSchema]);
|
||||
|
||||
/**
|
||||
* MCP Connector Schemas
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,11 @@ 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 +72,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 +93,7 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
|
|||
export const notificationMetadata = z.union([
|
||||
connectorIndexingMetadata,
|
||||
documentProcessingMetadata,
|
||||
newMentionMetadata,
|
||||
baseNotificationMetadata,
|
||||
]);
|
||||
|
||||
|
|
@ -107,6 +126,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 +138,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 {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,10 @@ let initPromise: Promise<ElectricClient> | null = null;
|
|||
// Cache for sync handles to prevent duplicate subscriptions (memory optimization)
|
||||
const activeSyncHandles = new Map<string, SyncHandle>();
|
||||
|
||||
// Track pending sync operations to prevent race conditions
|
||||
// If a sync is in progress, subsequent calls will wait for it instead of starting a new one
|
||||
const pendingSyncs = new Map<string, Promise<SyncHandle>>();
|
||||
|
||||
// Version for sync state - increment this to force fresh sync when Electric config changes
|
||||
// Set to v2 for user-specific database architecture
|
||||
const SYNC_VERSION = 2;
|
||||
|
|
@ -224,6 +228,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
|
||||
|
|
@ -243,7 +260,16 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
return existingHandle;
|
||||
}
|
||||
|
||||
// Build params for the shape request
|
||||
// Check if there's already a pending sync for this shape (prevent race condition)
|
||||
const pendingSync = pendingSyncs.get(cacheKey);
|
||||
if (pendingSync) {
|
||||
console.log(`[Electric] Waiting for pending sync to complete: ${cacheKey}`);
|
||||
return pendingSync;
|
||||
}
|
||||
|
||||
// Create and track the sync promise to prevent race conditions
|
||||
const syncPromise = (async (): Promise<SyncHandle> => {
|
||||
// Build params for the shape request
|
||||
// Electric SQL expects params as URL query parameters
|
||||
const params: Record<string, string> = { table };
|
||||
|
||||
|
|
@ -394,7 +420,55 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>;
|
||||
};
|
||||
};
|
||||
const shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
|
||||
|
||||
let shape: { unsubscribe: () => void; isUpToDate: boolean; stream: unknown };
|
||||
try {
|
||||
shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
|
||||
} catch (syncError) {
|
||||
// Handle "Already syncing" error - pglite-sync might not have fully cleaned up yet
|
||||
const errorMessage = syncError instanceof Error ? syncError.message : String(syncError);
|
||||
if (errorMessage.includes("Already syncing")) {
|
||||
console.warn(`[Electric] Already syncing ${table}, waiting for existing sync to settle...`);
|
||||
|
||||
// Wait a short time for pglite-sync to settle
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Check if an active handle now exists (another sync might have completed)
|
||||
const existingHandle = activeSyncHandles.get(cacheKey);
|
||||
if (existingHandle) {
|
||||
console.log(`[Electric] Found existing handle after waiting: ${cacheKey}`);
|
||||
return existingHandle;
|
||||
}
|
||||
|
||||
// Retry once after waiting
|
||||
console.log(`[Electric] Retrying sync for ${table}...`);
|
||||
try {
|
||||
shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
|
||||
} catch (retryError) {
|
||||
const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
|
||||
if (retryMessage.includes("Already syncing")) {
|
||||
// Still syncing - create a placeholder handle that indicates the table is being synced
|
||||
console.warn(`[Electric] ${table} still syncing, creating placeholder handle`);
|
||||
const placeholderHandle: SyncHandle = {
|
||||
unsubscribe: () => {
|
||||
console.log(`[Electric] Placeholder unsubscribe for: ${cacheKey}`);
|
||||
activeSyncHandles.delete(cacheKey);
|
||||
},
|
||||
get isUpToDate() {
|
||||
return false; // We don't know the real state
|
||||
},
|
||||
stream: undefined,
|
||||
initialSyncPromise: Promise.resolve(), // Already syncing means data should be coming
|
||||
};
|
||||
activeSyncHandles.set(cacheKey, placeholderHandle);
|
||||
return placeholderHandle;
|
||||
}
|
||||
throw retryError;
|
||||
}
|
||||
} else {
|
||||
throw syncError;
|
||||
}
|
||||
}
|
||||
|
||||
if (!shape) {
|
||||
throw new Error("syncShapeToTable returned undefined");
|
||||
|
|
@ -555,6 +629,18 @@ export async function initElectric(userId: string): Promise<ElectricClient> {
|
|||
}
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
// Track the sync promise to prevent concurrent syncs for the same shape
|
||||
pendingSyncs.set(cacheKey, syncPromise);
|
||||
|
||||
// Clean up the pending sync when done (whether success or failure)
|
||||
syncPromise.finally(() => {
|
||||
pendingSyncs.delete(cacheKey);
|
||||
console.log(`[Electric] Pending sync removed for: ${cacheKey}`);
|
||||
});
|
||||
|
||||
return syncPromise;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -600,8 +686,9 @@ export async function cleanupElectric(): Promise<void> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Ensure cache is empty
|
||||
// Ensure caches are empty
|
||||
activeSyncHandles.clear();
|
||||
pendingSyncs.clear();
|
||||
|
||||
try {
|
||||
// Close the PGlite database connection
|
||||
|
|
|
|||
|
|
@ -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