Merge pull request #656 from MODSetter/dev

feat(v0.0.10): new connector and improved connector UX
This commit is contained in:
Rohan Verma 2026-01-01 23:09:53 -08:00 committed by GitHub
commit c79c4bb2fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
186 changed files with 18148 additions and 9458 deletions

View file

@ -37,6 +37,8 @@ GOOGLE_OAUTH_CLIENT_SECRET=GOCSV
# Connector Specific Configs # Connector Specific Configs
GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback GOOGLE_CALENDAR_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/calendar/connector/callback
GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback GOOGLE_GMAIL_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/gmail/connector/callback
GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback
GOOGLE_DRIVE_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/drive/connector/callback
# Airtable OAuth for Aitable Connector # Airtable OAuth for Aitable Connector
AIRTABLE_CLIENT_ID=your_airtable_client_id AIRTABLE_CLIENT_ID=your_airtable_client_id

View file

@ -0,0 +1,73 @@
"""Add Google Drive connector enums
Revision ID: 54
Revises: 53
Create Date: 2025-12-28 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "54"
down_revision: str | None = "53"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Safely add 'GOOGLE_DRIVE_CONNECTOR' to enum types if missing."""
# Add to searchsourceconnectortype enum
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'searchsourceconnectortype' AND e.enumlabel = 'GOOGLE_DRIVE_CONNECTOR'
) THEN
ALTER TYPE searchsourceconnectortype ADD VALUE 'GOOGLE_DRIVE_CONNECTOR';
END IF;
END
$$;
"""
)
# Add to documenttype enum
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'documenttype' AND e.enumlabel = 'GOOGLE_DRIVE_CONNECTOR'
) THEN
ALTER TYPE documenttype ADD VALUE 'GOOGLE_DRIVE_CONNECTOR';
END IF;
END
$$;
"""
)
def downgrade() -> None:
"""Remove 'GOOGLE_DRIVE_CONNECTOR' from enum types.
Note: PostgreSQL doesn't support removing enum values directly.
This would require recreating the enum type, which is complex and risky.
For now, we'll leave the enum values in place.
In a production environment with strict downgrade requirements, you would need to:
1. Create new enum types without the value
2. Convert all columns to use the new type
3. Drop the old enum type
4. Rename the new type to the old name
This is left as pass to avoid accidental data loss.
"""
pass

View file

@ -0,0 +1,73 @@
"""Rename GOOGLE_DRIVE_CONNECTOR document type to GOOGLE_DRIVE_FILE
Revision ID: 55
Revises: 54
Create Date: 2025-12-29 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "55"
down_revision: str | None = "54"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
from sqlalchemy import text
connection = op.get_bind()
connection.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'documenttype' AND e.enumlabel = 'GOOGLE_DRIVE_FILE'
) THEN
ALTER TYPE documenttype ADD VALUE IF NOT EXISTS 'GOOGLE_DRIVE_FILE';
END IF;
END
$$;
"""
)
)
connection.commit()
connection.execute(
text(
"""
UPDATE documents
SET document_type = 'GOOGLE_DRIVE_FILE'
WHERE document_type = 'GOOGLE_DRIVE_CONNECTOR';
"""
)
)
connection.commit()
def downgrade() -> None:
from sqlalchemy import text
connection = op.get_bind()
connection.execute(
text(
"""
UPDATE documents
SET document_type = 'GOOGLE_DRIVE_CONNECTOR'
WHERE document_type = 'GOOGLE_DRIVE_FILE';
"""
)
)
connection.commit()

View file

@ -0,0 +1,56 @@
"""Add Circleback connector enums
Revision ID: 56
Revises: 55
Create Date: 2025-12-30 12:00:00.000000
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "56"
down_revision: str | None = "55"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Safely add 'CIRCLEBACK' to documenttype and 'CIRCLEBACK_CONNECTOR' to searchsourceconnectortype enums if missing."""
from sqlalchemy import text
# Get connection and commit current transaction to allow ALTER TYPE
connection = op.get_bind()
connection.execute(text("COMMIT"))
# Add to documenttype enum (must be outside transaction)
connection.execute(
text("ALTER TYPE documenttype ADD VALUE IF NOT EXISTS 'CIRCLEBACK'")
)
# Add to searchsourceconnectortype enum
connection.execute(
text(
"ALTER TYPE searchsourceconnectortype ADD VALUE IF NOT EXISTS 'CIRCLEBACK_CONNECTOR'"
)
)
def downgrade() -> None:
"""Remove 'CIRCLEBACK' and 'CIRCLEBACK_CONNECTOR' from enum types.
Note: PostgreSQL doesn't support removing enum values directly.
This would require recreating the enum type, which is complex and risky.
For now, we'll leave the enum values in place.
In a production environment with strict downgrade requirements, you would need to:
1. Create new enum types without the value
2. Convert all columns to use the new type
3. Drop the old enum type
4. Rename the new type to the old name
This is left as pass to avoid accidental data loss.
"""
pass

View file

@ -36,6 +36,7 @@ _ALL_CONNECTORS: list[str] = [
"CLICKUP_CONNECTOR", "CLICKUP_CONNECTOR",
"GOOGLE_CALENDAR_CONNECTOR", "GOOGLE_CALENDAR_CONNECTOR",
"GOOGLE_GMAIL_CONNECTOR", "GOOGLE_GMAIL_CONNECTOR",
"GOOGLE_DRIVE_FILE",
"DISCORD_CONNECTOR", "DISCORD_CONNECTOR",
"AIRTABLE_CONNECTOR", "AIRTABLE_CONNECTOR",
"TAVILY_API", "TAVILY_API",
@ -46,6 +47,7 @@ _ALL_CONNECTORS: list[str] = [
"NOTE", "NOTE",
"BOOKSTACK_CONNECTOR", "BOOKSTACK_CONNECTOR",
"CRAWLED_URL", "CRAWLED_URL",
"CIRCLEBACK",
] ]
@ -425,6 +427,16 @@ async def search_knowledge_base_async(
) )
all_documents.extend(chunks) all_documents.extend(chunks)
elif connector == "GOOGLE_DRIVE_FILE":
_, chunks = await connector_service.search_google_drive(
user_query=query,
search_space_id=search_space_id,
top_k=top_k,
start_date=resolved_start_date,
end_date=resolved_end_date,
)
all_documents.extend(chunks)
elif connector == "CONFLUENCE_CONNECTOR": elif connector == "CONFLUENCE_CONNECTOR":
_, chunks = await connector_service.search_confluence( _, chunks = await connector_service.search_confluence(
user_query=query, user_query=query,
@ -485,6 +497,16 @@ async def search_knowledge_base_async(
) )
all_documents.extend(chunks) all_documents.extend(chunks)
elif connector == "CIRCLEBACK":
_, chunks = await connector_service.search_circleback(
user_query=query,
search_space_id=search_space_id,
top_k=top_k,
start_date=resolved_start_date,
end_date=resolved_end_date,
)
all_documents.extend(chunks)
except Exception as e: except Exception as e:
print(f"Error searching connector {connector}: {e}") print(f"Error searching connector {connector}: {e}")
continue continue
@ -561,6 +583,7 @@ def create_search_knowledge_base_tool(
- CLICKUP_CONNECTOR: "ClickUp tasks and project data" (personal task management) - CLICKUP_CONNECTOR: "ClickUp tasks and project data" (personal task management)
- GOOGLE_CALENDAR_CONNECTOR: "Google Calendar events, meetings, and schedules" (personal calendar and time management) - GOOGLE_CALENDAR_CONNECTOR: "Google Calendar events, meetings, and schedules" (personal calendar and time management)
- GOOGLE_GMAIL_CONNECTOR: "Google Gmail emails and conversations" (personal emails and communications) - GOOGLE_GMAIL_CONNECTOR: "Google Gmail emails and conversations" (personal emails and communications)
- GOOGLE_DRIVE_FILE: "Google Drive files and documents" (personal cloud storage and file management)
- DISCORD_CONNECTOR: "Discord server conversations and shared content" (personal community communications) - DISCORD_CONNECTOR: "Discord server conversations and shared content" (personal community communications)
- AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization) - AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization)
- TAVILY_API: "Tavily search API results" (personalized search results) - TAVILY_API: "Tavily search API results" (personalized search results)
@ -570,6 +593,7 @@ def create_search_knowledge_base_tool(
- LUMA_CONNECTOR: "Luma events" - LUMA_CONNECTOR: "Luma events"
- WEBCRAWLER_CONNECTOR: "Webpages indexed by SurfSense" (personally selected websites) - WEBCRAWLER_CONNECTOR: "Webpages indexed by SurfSense" (personally selected websites)
- BOOKSTACK_CONNECTOR: "BookStack pages" (personal documentation) - BOOKSTACK_CONNECTOR: "BookStack pages" (personal documentation)
- CIRCLEBACK: "Circleback meeting notes, transcripts, and action items" (personal meeting records)
NOTE: `WEBCRAWLER_CONNECTOR` is mapped internally to the canonical document type `CRAWLED_URL`. NOTE: `WEBCRAWLER_CONNECTOR` is mapped internally to the canonical document type `CRAWLED_URL`.

View file

@ -82,6 +82,9 @@ class Config:
# Google Gmail redirect URI # Google Gmail redirect URI
GOOGLE_GMAIL_REDIRECT_URI = os.getenv("GOOGLE_GMAIL_REDIRECT_URI") GOOGLE_GMAIL_REDIRECT_URI = os.getenv("GOOGLE_GMAIL_REDIRECT_URI")
# Google Drive redirect URI
GOOGLE_DRIVE_REDIRECT_URI = os.getenv("GOOGLE_DRIVE_REDIRECT_URI")
# Airtable OAuth # Airtable OAuth
AIRTABLE_CLIENT_ID = os.getenv("AIRTABLE_CLIENT_ID") AIRTABLE_CLIENT_ID = os.getenv("AIRTABLE_CLIENT_ID")
AIRTABLE_CLIENT_SECRET = os.getenv("AIRTABLE_CLIENT_SECRET") AIRTABLE_CLIENT_SECRET = os.getenv("AIRTABLE_CLIENT_SECRET")

View file

@ -0,0 +1,20 @@
"""Google Drive Connector Module."""
from .change_tracker import categorize_change, fetch_all_changes, get_start_page_token
from .client import GoogleDriveClient
from .content_extractor import download_and_process_file
from .credentials import get_valid_credentials, validate_credentials
from .folder_manager import get_file_by_id, get_files_in_folder, list_folder_contents
__all__ = [
"GoogleDriveClient",
"categorize_change",
"download_and_process_file",
"fetch_all_changes",
"get_file_by_id",
"get_files_in_folder",
"get_start_page_token",
"get_valid_credentials",
"list_folder_contents",
"validate_credentials",
]

View file

@ -0,0 +1,204 @@
"""Change tracking for Google Drive delta sync."""
import logging
from datetime import datetime
from typing import Any
from .client import GoogleDriveClient
logger = logging.getLogger(__name__)
async def get_start_page_token(
client: GoogleDriveClient,
) -> tuple[str | None, str | None]:
"""
Get the starting page token for change tracking.
This token represents the current state and is used for future delta syncs.
Args:
client: GoogleDriveClient instance
Returns:
Tuple of (start_page_token, error message)
"""
try:
service = await client.get_service()
response = service.changes().getStartPageToken(supportsAllDrives=True).execute()
token = response.get("startPageToken")
logger.info(f"Got start page token: {token}")
return token, None
except Exception as e:
logger.error(f"Error getting start page token: {e!s}", exc_info=True)
return None, f"Error getting start page token: {e!s}"
async def get_changes(
client: GoogleDriveClient,
page_token: str,
folder_id: str | None = None,
) -> tuple[list[dict[str, Any]], str | None, str | None]:
"""
Get list of changes since the given page token.
Args:
client: GoogleDriveClient instance
page_token: Page token from previous sync
folder_id: Optional folder ID to filter changes
Returns:
Tuple of (changes list, new_page_token, error message)
"""
try:
service = await client.get_service()
params = {
"pageToken": page_token,
"pageSize": 100,
"fields": "nextPageToken, newStartPageToken, changes(fileId, removed, file(id, name, mimeType, modifiedTime, size, webViewLink, parents, trashed))",
"supportsAllDrives": True,
"includeItemsFromAllDrives": True,
}
response = service.changes().list(**params).execute()
changes = response.get("changes", [])
next_token = response.get("nextPageToken")
new_start_token = response.get("newStartPageToken")
# Use new start token if this is the last page
token_to_return = new_start_token if new_start_token else next_token
# Filter changes by folder if specified
if folder_id:
changes = await _filter_changes_by_folder(client, changes, folder_id)
logger.info(f"Got {len(changes)} changes, next token: {token_to_return}")
return changes, token_to_return, None
except Exception as e:
logger.error(f"Error getting changes: {e!s}", exc_info=True)
return [], None, f"Error getting changes: {e!s}"
async def _filter_changes_by_folder(
client: GoogleDriveClient,
changes: list[dict[str, Any]],
folder_id: str,
) -> list[dict[str, Any]]:
"""
Filter changes to only include files within the specified folder.
Args:
client: GoogleDriveClient instance
changes: List of changes from API
folder_id: Folder ID to filter by
Returns:
Filtered list of changes
"""
filtered = []
for change in changes:
file = change.get("file")
if not file:
filtered.append(change)
continue
# Check if file is in the folder (or subfolder)
parents = file.get("parents", [])
if folder_id in parents:
filtered.append(change)
else:
# Check if any parent is a descendant of folder_id
# This is a simplified check - full implementation would traverse hierarchy
# For now, we'll include it and let indexer validate
filtered.append(change)
return filtered
def categorize_change(change: dict[str, Any]) -> str:
"""
Categorize a change event.
Args:
change: Change event from Drive API
Returns:
Category: 'removed', 'trashed', 'modified', 'new'
"""
if change.get("removed"):
return "removed"
file = change.get("file")
if not file:
return "removed"
if file.get("trashed"):
return "trashed"
created_time = file.get("createdTime")
modified_time = file.get("modifiedTime")
if created_time and modified_time:
try:
created = datetime.fromisoformat(created_time.replace("Z", "+00:00"))
modified = datetime.fromisoformat(modified_time.replace("Z", "+00:00"))
# If created and modified times are very close, it's likely a new file
time_diff = abs((modified - created).total_seconds())
if time_diff < 60: # Within 1 minute
return "new"
except Exception:
pass
return "modified"
async def fetch_all_changes(
client: GoogleDriveClient,
start_token: str,
folder_id: str | None = None,
) -> tuple[list[dict[str, Any]], str | None, str | None]:
"""
Fetch all changes from start token, handling pagination.
Args:
client: GoogleDriveClient instance
start_token: Starting page token
folder_id: Optional folder ID to filter changes
Returns:
Tuple of (all changes, final_page_token, error message)
"""
all_changes = []
current_token = start_token
error = None
try:
while current_token:
changes, next_token, err = await get_changes(
client, current_token, folder_id
)
if err:
error = err
break
all_changes.extend(changes)
if not next_token or next_token == current_token:
break
current_token = next_token
logger.info(f"Fetched total of {len(all_changes)} changes")
return all_changes, current_token, error
except Exception as e:
logger.error(f"Error fetching all changes: {e!s}", exc_info=True)
return all_changes, current_token, f"Error fetching all changes: {e!s}"

View file

@ -0,0 +1,181 @@
"""Google Drive API client."""
from typing import Any
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from sqlalchemy.ext.asyncio import AsyncSession
from .credentials import get_valid_credentials
class GoogleDriveClient:
"""Client for Google Drive API operations."""
def __init__(self, session: AsyncSession, connector_id: int):
"""
Initialize Google Drive client.
Args:
session: Database session
connector_id: ID of the Drive connector
"""
self.session = session
self.connector_id = connector_id
self.service = None
async def get_service(self):
"""
Get or create the Drive service instance.
Returns:
Google Drive service instance
Raises:
Exception: If service creation fails
"""
if self.service:
return self.service
try:
credentials = await get_valid_credentials(self.session, self.connector_id)
self.service = build("drive", "v3", credentials=credentials)
return self.service
except Exception as e:
raise Exception(f"Failed to create Google Drive service: {e!s}") from e
async def list_files(
self,
query: str = "",
fields: str = "nextPageToken, files(id, name, mimeType, modifiedTime, size, webViewLink, parents, owners, createdTime, description)",
page_size: int = 100,
page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None, str | None]:
"""
List files from Google Drive with pagination.
Args:
query: Search query (e.g., "mimeType != 'application/vnd.google-apps.folder'")
fields: Fields to retrieve
page_size: Number of files per page (max 1000)
page_token: Token for next page
Returns:
Tuple of (files list, next_page_token, error message)
"""
try:
service = await self.get_service()
params = {
"pageSize": min(page_size, 1000),
"fields": fields,
"supportsAllDrives": True,
"includeItemsFromAllDrives": True,
}
if query:
params["q"] = query
if page_token:
params["pageToken"] = page_token
result = service.files().list(**params).execute()
files = result.get("files", [])
next_token = result.get("nextPageToken")
return files, next_token, None
except HttpError as e:
error_msg = f"HTTP error listing files: {e.resp.status} - {e.error_details}"
return [], None, error_msg
except Exception as e:
return [], None, f"Error listing files: {e!s}"
async def get_file_metadata(
self, file_id: str, fields: str = "*"
) -> tuple[dict[str, Any] | None, str | None]:
"""
Get metadata for a specific file.
Args:
file_id: ID of the file
fields: Fields to retrieve
Returns:
Tuple of (file metadata, error message)
"""
try:
service = await self.get_service()
file = (
service.files()
.get(fileId=file_id, fields=fields, supportsAllDrives=True)
.execute()
)
return file, None
except HttpError as e:
return None, f"HTTP error getting file metadata: {e.resp.status}"
except Exception as e:
return None, f"Error getting file metadata: {e!s}"
async def download_file(self, file_id: str) -> tuple[bytes | None, str | None]:
"""
Download binary file content.
Args:
file_id: ID of the file to download
Returns:
Tuple of (file content bytes, error message)
"""
try:
service = await self.get_service()
request = service.files().get_media(fileId=file_id)
import io
fh = io.BytesIO()
from googleapiclient.http import MediaIoBaseDownload
downloader = MediaIoBaseDownload(fh, request)
done = False
while not done:
_, done = downloader.next_chunk()
return fh.getvalue(), None
except HttpError as e:
return None, f"HTTP error downloading file: {e.resp.status}"
except Exception as e:
return None, f"Error downloading file: {e!s}"
async def export_google_file(
self, file_id: str, mime_type: str
) -> tuple[bytes | None, str | None]:
"""
Export Google Workspace file to specified format.
Args:
file_id: ID of the Google file
mime_type: Target MIME type (e.g., 'application/pdf', 'text/plain')
Returns:
Tuple of (exported content as bytes, error message)
"""
try:
service = await self.get_service()
content = (
service.files().export(fileId=file_id, mimeType=mime_type).execute()
)
# Content is already bytes from the API
# Keep as bytes to support both text and binary formats (like PDF)
if not isinstance(content, bytes):
content = content.encode("utf-8")
return content, None
except HttpError as e:
return None, f"HTTP error exporting file: {e.resp.status}"
except Exception as e:
return None, f"Error exporting file: {e!s}"

View file

@ -0,0 +1,136 @@
"""Content extraction for Google Drive files."""
import logging
import os
import tempfile
from pathlib import Path
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Log
from app.services.task_logging_service import TaskLoggingService
from .client import GoogleDriveClient
from .file_types import get_export_mime_type, is_google_workspace_file, should_skip_file
logger = logging.getLogger(__name__)
async def download_and_process_file(
client: GoogleDriveClient,
file: dict[str, Any],
search_space_id: int,
user_id: str,
session: AsyncSession,
task_logger: TaskLoggingService,
log_entry: Log,
) -> tuple[Any, str | None, dict[str, Any] | None]:
"""
Download Google Drive file and process using Surfsense file processors.
Args:
client: GoogleDriveClient instance
file: File metadata from Drive API
search_space_id: ID of the search space
user_id: ID of the user
session: Database session
task_logger: Task logging service
log_entry: Log entry for tracking
Returns:
Tuple of (Document object if successful, error message if failed, file metadata dict)
"""
file_id = file.get("id")
file_name = file.get("name", "Unknown")
mime_type = file.get("mimeType", "")
# Skip folders and shortcuts
if should_skip_file(mime_type):
return None, f"Skipping {mime_type}", None
logger.info(f"Downloading file: {file_name} ({mime_type})")
temp_file_path = None
try:
# Step 1: Download or export the file
if is_google_workspace_file(mime_type):
# Google Workspace files need export (as PDF to preserve formatting & images)
export_mime = get_export_mime_type(mime_type)
if not export_mime:
return None, f"Cannot export Google Workspace type: {mime_type}"
logger.info(f"Exporting Google Workspace file as {export_mime}")
content_bytes, error = await client.export_google_file(file_id, export_mime)
if error:
return None, error
extension = ".pdf" if export_mime == "application/pdf" else ".txt"
else:
content_bytes, error = await client.download_file(file_id)
if error:
return None, error
# Preserve original file extension
extension = Path(file_name).suffix or ".bin"
with tempfile.NamedTemporaryFile(delete=False, suffix=extension) as tmp_file:
tmp_file.write(content_bytes)
temp_file_path = tmp_file.name
from app.db import DocumentType
from app.tasks.document_processors.file_processors import (
process_file_in_background,
)
connector_info = {
"type": DocumentType.GOOGLE_DRIVE_FILE,
"metadata": {
"google_drive_file_id": file_id,
"google_drive_file_name": file_name,
"google_drive_mime_type": mime_type,
"source_connector": "google_drive",
},
}
# Add additional Drive metadata if available
if "modifiedTime" in file:
connector_info["metadata"]["modified_time"] = file["modifiedTime"]
if "createdTime" in file:
connector_info["metadata"]["created_time"] = file["createdTime"]
if "size" in file:
connector_info["metadata"]["file_size"] = file["size"]
if "webViewLink" in file:
connector_info["metadata"]["web_view_link"] = file["webViewLink"]
if is_google_workspace_file(mime_type):
connector_info["metadata"]["exported_as"] = "pdf"
connector_info["metadata"]["original_workspace_type"] = mime_type.split(
"."
)[-1]
logger.info(f"Processing {file_name} with Surfsense's file processor")
await process_file_in_background(
file_path=temp_file_path,
filename=file_name,
search_space_id=search_space_id,
user_id=user_id,
session=session,
task_logger=task_logger,
log_entry=log_entry,
connector=connector_info,
)
return None, None, connector_info["metadata"]
except Exception as e:
logger.warning(f"Failed to process {file_name}: {e!s}")
return None, str(e), None
finally:
# Cleanup temp file (if process_file_in_background didn't already delete it)
if temp_file_path and os.path.exists(temp_file_path):
try:
os.unlink(temp_file_path)
except Exception as e:
logger.debug(f"Could not delete temp file {temp_file_path}: {e}")

View file

@ -0,0 +1,95 @@
"""Google Drive OAuth credential management."""
import json
from datetime import datetime
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm.attributes import flag_modified
from app.db import SearchSourceConnector
async def get_valid_credentials(
session: AsyncSession,
connector_id: int,
) -> Credentials:
"""
Get valid Google OAuth credentials, refreshing if needed.
Args:
session: Database session
connector_id: Connector ID
Returns:
Valid Google OAuth credentials
Raises:
ValueError: If credentials are missing or invalid
Exception: If token refresh fails
"""
result = await session.execute(
select(SearchSourceConnector).filter(SearchSourceConnector.id == connector_id)
)
connector = result.scalars().first()
if not connector:
raise ValueError(f"Connector {connector_id} not found")
config_data = connector.config
exp = config_data.get("expiry", "").replace("Z", "")
if not all(
[
config_data.get("client_id"),
config_data.get("client_secret"),
config_data.get("refresh_token"),
]
):
raise ValueError(
"Google OAuth credentials (client_id, client_secret, refresh_token) must be set"
)
credentials = Credentials(
token=config_data.get("token"),
refresh_token=config_data.get("refresh_token"),
token_uri=config_data.get("token_uri"),
client_id=config_data.get("client_id"),
client_secret=config_data.get("client_secret"),
scopes=config_data.get("scopes", []),
expiry=datetime.fromisoformat(exp) if exp else None,
)
if credentials.expired or not credentials.valid:
try:
credentials.refresh(Request())
connector.config = json.loads(credentials.to_json())
flag_modified(connector, "config")
await session.commit()
except Exception as e:
raise Exception(f"Failed to refresh Google OAuth credentials: {e!s}") from e
return credentials
def validate_credentials(credentials: Credentials) -> bool:
"""
Validate that credentials have required fields.
Args:
credentials: Google OAuth credentials
Returns:
True if valid, False otherwise
"""
return all(
[
credentials.client_id,
credentials.client_secret,
credentials.refresh_token,
]
)

View file

@ -0,0 +1,28 @@
"""File type handlers for Google Drive."""
GOOGLE_DOC = "application/vnd.google-apps.document"
GOOGLE_SHEET = "application/vnd.google-apps.spreadsheet"
GOOGLE_SLIDE = "application/vnd.google-apps.presentation"
GOOGLE_FOLDER = "application/vnd.google-apps.folder"
GOOGLE_SHORTCUT = "application/vnd.google-apps.shortcut"
EXPORT_FORMATS = {
GOOGLE_DOC: "application/pdf",
GOOGLE_SHEET: "application/pdf",
GOOGLE_SLIDE: "application/pdf",
}
def is_google_workspace_file(mime_type: str) -> bool:
"""Check if file is a Google Workspace file that needs export."""
return mime_type.startswith("application/vnd.google-apps")
def should_skip_file(mime_type: str) -> bool:
"""Check if file should be skipped (folders, shortcuts, etc)."""
return mime_type in [GOOGLE_FOLDER, GOOGLE_SHORTCUT]
def get_export_mime_type(mime_type: str) -> str | None:
"""Get export MIME type for Google Workspace files."""
return EXPORT_FORMATS.get(mime_type)

View file

@ -0,0 +1,263 @@
"""Folder management for Google Drive."""
import logging
from typing import Any
from .client import GoogleDriveClient
logger = logging.getLogger(__name__)
async def list_folders(
client: GoogleDriveClient,
parent_id: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""
List folders in Google Drive.
Args:
client: GoogleDriveClient instance
parent_id: Parent folder ID (None for root)
Returns:
Tuple of (folders list, error message)
"""
try:
# Build query to get only folders
query_parts = [
"mimeType = 'application/vnd.google-apps.folder'",
"trashed = false",
]
if parent_id:
query_parts.append(f"'{parent_id}' in parents")
query = " and ".join(query_parts)
folders, _, error = await client.list_files(
query=query,
fields="files(id, name, parents, createdTime, modifiedTime)",
page_size=100,
)
if error:
return [], error
return folders, None
except Exception as e:
logger.error(f"Error listing folders: {e!s}", exc_info=True)
return [], f"Error listing folders: {e!s}"
async def get_folder_hierarchy(
client: GoogleDriveClient,
folder_id: str,
) -> tuple[list[dict[str, str]], str | None]:
"""
Get the full path hierarchy for a folder.
Args:
client: GoogleDriveClient instance
folder_id: Folder ID to get hierarchy for
Returns:
Tuple of (hierarchy list [{'id': ..., 'name': ...}], error message)
"""
try:
hierarchy = []
current_id = folder_id
# Traverse up to root
while current_id:
file, error = await client.get_file_metadata(
current_id, fields="id, name, parents, mimeType"
)
if error:
return [], error
if not file:
break
hierarchy.insert(0, {"id": file["id"], "name": file["name"]})
# Get parent
parents = file.get("parents", [])
current_id = parents[0] if parents else None
return hierarchy, None
except Exception as e:
logger.error(f"Error getting folder hierarchy: {e!s}", exc_info=True)
return [], f"Error getting folder hierarchy: {e!s}"
async def get_files_in_folder(
client: GoogleDriveClient,
folder_id: str,
include_subfolders: bool = True,
page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None, str | None]:
"""
Get all indexable files in a folder.
Args:
client: GoogleDriveClient instance
folder_id: Folder ID to search in
include_subfolders: Whether to include subfolders
page_token: Pagination token
Returns:
Tuple of (files list, next_page_token, error message)
"""
try:
# Build query
query_parts = [
f"'{folder_id}' in parents",
"trashed = false",
"mimeType != 'application/vnd.google-apps.shortcut'", # Skip shortcuts
]
if not include_subfolders:
query_parts.append("mimeType != 'application/vnd.google-apps.folder'")
query = " and ".join(query_parts)
files, next_token, error = await client.list_files(
query=query,
page_size=100,
page_token=page_token,
)
if error:
return [], None, error
return files, next_token, None
except Exception as e:
logger.error(f"Error getting files in folder: {e!s}", exc_info=True)
return [], None, f"Error getting files in folder: {e!s}"
async def get_file_by_id(
client: GoogleDriveClient,
file_id: str,
) -> tuple[dict[str, Any] | None, str | None]:
"""
Get file metadata by ID.
Args:
client: GoogleDriveClient instance
file_id: File ID to fetch
Returns:
Tuple of (file metadata dict, error message)
"""
try:
file, error = await client.get_file_metadata(
file_id,
fields="id, name, mimeType, parents, createdTime, modifiedTime, size, webViewLink, iconLink",
)
if error:
return None, error
if not file:
return None, f"File not found: {file_id}"
return file, None
except Exception as e:
logger.error(f"Error getting file by ID: {e!s}", exc_info=True)
return None, f"Error getting file by ID: {e!s}"
def format_folder_path(hierarchy: list[dict[str, str]]) -> str:
"""
Format folder hierarchy as a path string.
Args:
hierarchy: List of folder dicts with 'id' and 'name'
Returns:
Formatted path (e.g., "My Drive / Projects / Documents")
"""
if not hierarchy:
return "My Drive"
folder_names = [folder["name"] for folder in hierarchy]
return " / ".join(folder_names)
async def list_folder_contents(
client: GoogleDriveClient,
parent_id: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
"""
List folders and files in a Google Drive folder with pagination support.
Args:
client: GoogleDriveClient instance
parent_id: Parent folder ID (None for root)
Returns:
Tuple of (items list with folders and files, error message)
"""
try:
# Build query to get folders and files (exclude shortcuts)
query_parts = [
"trashed = false",
"mimeType != 'application/vnd.google-apps.shortcut'",
]
# For root, we need to explicitly query for items in 'root'
# For subfolders, query for items with that parent
if parent_id:
query_parts.append(f"'{parent_id}' in parents")
else:
# Query for root-level items
query_parts.append("'root' in parents")
query = " and ".join(query_parts)
# Fetch all items with pagination (max 1000 per page)
all_items = []
page_token = None
while True:
items, next_token, error = await client.list_files(
query=query,
fields="files(id, name, mimeType, parents, createdTime, modifiedTime, size, webViewLink, iconLink)",
page_size=1000, # Max allowed by Google Drive API
page_token=page_token,
)
if error:
return [], error
all_items.extend(items)
if not next_token:
break
page_token = next_token
for item in all_items:
item["isFolder"] = item["mimeType"] == "application/vnd.google-apps.folder"
all_items.sort(key=lambda x: (not x["isFolder"], x["name"].lower()))
folder_count = sum(1 for item in all_items if item["isFolder"])
file_count = len(all_items) - folder_count
logger.info(
f"Listed {len(all_items)} items ({folder_count} folders, {file_count} files) "
+ (f"in folder {parent_id}" if parent_id else "in root (My Drive)")
)
return all_items, None
except Exception as e:
logger.error(f"Error listing folder contents: {e!s}", exc_info=True)
return [], f"Error listing folder contents: {e!s}"

View file

@ -46,10 +46,12 @@ class DocumentType(str, Enum):
CLICKUP_CONNECTOR = "CLICKUP_CONNECTOR" CLICKUP_CONNECTOR = "CLICKUP_CONNECTOR"
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR" GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR"
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR" GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
GOOGLE_DRIVE_FILE = "GOOGLE_DRIVE_FILE"
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR" AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
LUMA_CONNECTOR = "LUMA_CONNECTOR" LUMA_CONNECTOR = "LUMA_CONNECTOR"
ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR" ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR"
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR" BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
CIRCLEBACK = "CIRCLEBACK"
NOTE = "NOTE" NOTE = "NOTE"
@ -69,11 +71,13 @@ class SearchSourceConnectorType(str, Enum):
CLICKUP_CONNECTOR = "CLICKUP_CONNECTOR" CLICKUP_CONNECTOR = "CLICKUP_CONNECTOR"
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR" GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR"
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR" GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
GOOGLE_DRIVE_CONNECTOR = "GOOGLE_DRIVE_CONNECTOR"
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR" AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
LUMA_CONNECTOR = "LUMA_CONNECTOR" LUMA_CONNECTOR = "LUMA_CONNECTOR"
ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR" ELASTICSEARCH_CONNECTOR = "ELASTICSEARCH_CONNECTOR"
WEBCRAWLER_CONNECTOR = "WEBCRAWLER_CONNECTOR" WEBCRAWLER_CONNECTOR = "WEBCRAWLER_CONNECTOR"
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR" BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR"
class LiteLLMProvider(str, Enum): class LiteLLMProvider(str, Enum):

View file

@ -3,11 +3,15 @@ from fastapi import APIRouter
from .airtable_add_connector_route import ( from .airtable_add_connector_route import (
router as airtable_add_connector_router, router as airtable_add_connector_router,
) )
from .circleback_webhook_route import router as circleback_webhook_router
from .documents_routes import router as documents_router from .documents_routes import router as documents_router
from .editor_routes import router as editor_router from .editor_routes import router as editor_router
from .google_calendar_add_connector_route import ( from .google_calendar_add_connector_route import (
router as google_calendar_add_connector_router, router as google_calendar_add_connector_router,
) )
from .google_drive_add_connector_route import (
router as google_drive_add_connector_router,
)
from .google_gmail_add_connector_route import ( from .google_gmail_add_connector_route import (
router as google_gmail_add_connector_router, router as google_gmail_add_connector_router,
) )
@ -33,7 +37,9 @@ router.include_router(podcasts_router) # Podcast task status and audio
router.include_router(search_source_connectors_router) router.include_router(search_source_connectors_router)
router.include_router(google_calendar_add_connector_router) router.include_router(google_calendar_add_connector_router)
router.include_router(google_gmail_add_connector_router) router.include_router(google_gmail_add_connector_router)
router.include_router(google_drive_add_connector_router)
router.include_router(airtable_add_connector_router) router.include_router(airtable_add_connector_router)
router.include_router(luma_add_connector_router) router.include_router(luma_add_connector_router)
router.include_router(new_llm_config_router) # LLM configs with prompt configuration router.include_router(new_llm_config_router) # LLM configs with prompt configuration
router.include_router(logs_router) router.include_router(logs_router)
router.include_router(circleback_webhook_router) # Circleback meeting webhooks

View file

@ -255,9 +255,10 @@ async def airtable_callback(
await session.commit() await session.commit()
logger.info(f"Successfully saved Airtable connector for user {user_id}") logger.info(f"Successfully saved Airtable connector for user {user_id}")
# Redirect to the frontend success page # Redirect to the frontend with success params for indexing config
# Using query params to auto-open the popup with config view on new-chat page
return RedirectResponse( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/airtable-connector?success=true" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=airtable-connector"
) )
except ValidationError as e: except ValidationError as e:

View file

@ -0,0 +1,317 @@
"""
Circleback Webhook Route
This module provides a webhook endpoint for receiving meeting data from Circleback.
It processes the incoming webhook payload and saves it as a document in the specified search space.
"""
import logging
from datetime import datetime
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter()
# Pydantic models for Circleback webhook payload
class CirclebackAttendee(BaseModel):
"""Attendee model for Circleback meeting."""
name: str | None = None
email: str | None = None
class CirclebackActionItemAssignee(BaseModel):
"""Assignee model for action items."""
name: str | None = None
email: str | None = None
class CirclebackActionItem(BaseModel):
"""Action item model for Circleback meeting."""
id: int
title: str
description: str = ""
assignee: CirclebackActionItemAssignee | None = None
status: str = "PENDING"
class CirclebackTranscriptSegment(BaseModel):
"""Transcript segment model for Circleback meeting."""
speaker: str
text: str
timestamp: float
class CirclebackInsightItem(BaseModel):
"""Individual insight item."""
insight: str | dict[str, Any]
speaker: str | None = None
timestamp: float | None = None
class CirclebackWebhookPayload(BaseModel):
"""
Circleback webhook payload model.
This model represents the data sent by Circleback when a meeting is processed.
"""
model_config = {"populate_by_name": True}
id: int = Field(..., description="Circleback meeting ID")
name: str = Field(..., description="Meeting name")
created_at: str = Field(
..., alias="createdAt", description="Meeting creation date in ISO format"
)
duration: float = Field(..., description="Meeting duration in seconds")
url: str | None = Field(None, description="URL of the virtual meeting")
recording_url: str | None = Field(
None,
alias="recordingUrl",
description="URL of the meeting recording (valid for 24 hours)",
)
tags: list[str] = Field(default_factory=list, description="Meeting tags")
ical_uid: str | None = Field(
None, alias="icalUid", description="Unique identifier of the calendar event"
)
attendees: list[CirclebackAttendee] = Field(
default_factory=list, description="Meeting attendees"
)
notes: str = Field("", description="Meeting notes in Markdown format")
action_items: list[CirclebackActionItem] = Field(
default_factory=list,
alias="actionItems",
description="Action items from the meeting",
)
transcript: list[CirclebackTranscriptSegment] = Field(
default_factory=list, description="Meeting transcript segments"
)
insights: dict[str, list[CirclebackInsightItem]] = Field(
default_factory=dict, description="Custom insights from the meeting"
)
def format_circleback_meeting_to_markdown(payload: CirclebackWebhookPayload) -> str:
"""
Convert Circleback webhook payload to a well-formatted Markdown document.
Args:
payload: The Circleback webhook payload
Returns:
Markdown string representation of the meeting
"""
lines = []
# Title
lines.append(f"# {payload.name}")
lines.append("")
# Meeting metadata
lines.append("## Meeting Details")
lines.append("")
# Parse and format date
try:
created_dt = datetime.fromisoformat(payload.created_at.replace("Z", "+00:00"))
formatted_date = created_dt.strftime("%Y-%m-%d %H:%M:%S UTC")
except (ValueError, AttributeError):
formatted_date = payload.created_at
lines.append(f"- **Date:** {formatted_date}")
lines.append(f"- **Duration:** {int(payload.duration // 60)} minutes")
if payload.url:
lines.append(f"- **Meeting URL:** {payload.url}")
if payload.tags:
lines.append(f"- **Tags:** {', '.join(payload.tags)}")
lines.append(
f"- **Circleback Link:** [View on Circleback](https://app.circleback.ai/meetings/{payload.id})"
)
lines.append("")
# Attendees
if payload.attendees:
lines.append("## Attendees")
lines.append("")
for attendee in payload.attendees:
name = attendee.name or "Unknown"
if attendee.email:
lines.append(f"- **{name}** ({attendee.email})")
else:
lines.append(f"- **{name}**")
lines.append("")
# Notes (if provided)
if payload.notes:
lines.append("## Meeting Notes")
lines.append("")
lines.append(payload.notes)
lines.append("")
# Action Items
if payload.action_items:
lines.append("## Action Items")
lines.append("")
for item in payload.action_items:
status_emoji = "" if item.status == "DONE" else ""
assignee_text = ""
if item.assignee and item.assignee.name:
assignee_text = f" (Assigned to: {item.assignee.name})"
lines.append(f"{status_emoji} **{item.title}**{assignee_text}")
if item.description:
lines.append(f" {item.description}")
lines.append("")
# Insights
if payload.insights:
lines.append("## Insights")
lines.append("")
for insight_name, insight_items in payload.insights.items():
lines.append(f"### {insight_name}")
lines.append("")
for insight_item in insight_items:
if isinstance(insight_item.insight, dict):
for key, value in insight_item.insight.items():
lines.append(f"- **{key}:** {value}")
else:
speaker_info = (
f" _{insight_item.speaker}_" if insight_item.speaker else ""
)
lines.append(f"- {insight_item.insight}{speaker_info}")
lines.append("")
# Transcript
if payload.transcript:
lines.append("## Transcript")
lines.append("")
for segment in payload.transcript:
# Format timestamp as MM:SS
minutes = int(segment.timestamp // 60)
seconds = int(segment.timestamp % 60)
timestamp_str = f"[{minutes:02d}:{seconds:02d}]"
lines.append(f"**{segment.speaker}** {timestamp_str}: {segment.text}")
lines.append("")
return "\n".join(lines)
@router.post("/webhooks/circleback/{search_space_id}")
async def receive_circleback_webhook(
search_space_id: int,
payload: CirclebackWebhookPayload,
):
"""
Receive and process a Circleback webhook.
This endpoint receives meeting data from Circleback and saves it as a document
in the specified search space. The meeting data is converted to Markdown format
and processed asynchronously.
Args:
search_space_id: The ID of the search space to save the document to
payload: The Circleback webhook payload containing meeting data
Returns:
Success message with document details
Note:
This endpoint does not require authentication as it's designed to receive
webhooks from Circleback. Signature verification can be added later for security.
"""
try:
logger.info(
f"Received Circleback webhook for meeting {payload.id} in search space {search_space_id}"
)
# Convert to markdown
markdown_content = format_circleback_meeting_to_markdown(payload)
# Trigger async document processing
from app.tasks.celery_tasks.document_tasks import (
process_circleback_meeting_task,
)
# Prepare meeting metadata for the task
meeting_metadata = {
"circleback_meeting_id": payload.id,
"meeting_name": payload.name,
"meeting_date": payload.created_at,
"duration_seconds": payload.duration,
"meeting_url": payload.url,
"tags": payload.tags,
"attendees_count": len(payload.attendees),
"action_items_count": len(payload.action_items),
"has_transcript": len(payload.transcript) > 0,
}
# Queue the processing task
process_circleback_meeting_task.delay(
meeting_id=payload.id,
meeting_name=payload.name,
markdown_content=markdown_content,
metadata=meeting_metadata,
search_space_id=search_space_id,
)
logger.info(
f"Queued Circleback meeting {payload.id} for processing in search space {search_space_id}"
)
return {
"status": "accepted",
"message": f"Meeting '{payload.name}' queued for processing",
"meeting_id": payload.id,
"search_space_id": search_space_id,
}
except Exception as e:
logger.error(f"Error processing Circleback webhook: {e!s}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to process Circleback webhook: {e!s}",
) from e
@router.get("/webhooks/circleback/{search_space_id}/info")
async def get_circleback_webhook_info(
search_space_id: int,
):
"""
Get information about the Circleback webhook endpoint.
This endpoint provides information about how to configure the Circleback
webhook integration.
Args:
search_space_id: The ID of the search space
Returns:
Webhook configuration information
"""
from app.config import config
# Construct the webhook URL
base_url = getattr(config, "API_BASE_URL", "http://localhost:8000")
webhook_url = f"{base_url}/api/v1/webhooks/circleback/{search_space_id}"
return {
"webhook_url": webhook_url,
"search_space_id": search_space_id,
"method": "POST",
"content_type": "application/json",
"description": "Use this URL in your Circleback automation to send meeting data to SurfSense",
"note": "Configure this URL in Circleback Settings → Automations → Create automation → Send webhook request",
}

View file

@ -131,8 +131,10 @@ async def calendar_callback(
session.add(db_connector) session.add(db_connector)
await session.commit() await session.commit()
await session.refresh(db_connector) await session.refresh(db_connector)
# Redirect to the frontend with success params for indexing config
# Using query params to auto-open the popup with config view on new-chat page
return RedirectResponse( return RedirectResponse(
f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/google-calendar-connector?success=true" f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-calendar-connector"
) )
except ValidationError as e: except ValidationError as e:
await session.rollback() await session.rollback()

View file

@ -0,0 +1,318 @@
"""
Google Drive Connector OAuth Routes.
Handles OAuth 2.0 authentication flow for Google Drive connector.
Folder selection happens at index time on the manage connector page.
Endpoints:
- GET /auth/google/drive/connector/add - Initiate OAuth
- GET /auth/google/drive/connector/callback - Handle OAuth callback
- GET /connectors/{connector_id}/google-drive/folders - List user's folders (for index-time selection)
"""
import base64
import json
import logging
import os
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse
from google_auth_oauthlib.flow import Flow
from pydantic import ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.config import config
from app.connectors.google_drive import (
GoogleDriveClient,
get_start_page_token,
list_folder_contents,
)
from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
User,
get_async_session,
)
from app.users import current_active_user
# Relax token scope validation for Google OAuth
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
logger = logging.getLogger(__name__)
router = APIRouter()
# Google Drive OAuth scopes
SCOPES = [
"https://www.googleapis.com/auth/drive.readonly", # Read-only access to Drive
"https://www.googleapis.com/auth/userinfo.email", # User email
"https://www.googleapis.com/auth/userinfo.profile", # User profile
"openid",
]
def get_google_flow():
"""Create and return a Google OAuth flow for Drive API."""
try:
return Flow.from_client_config(
{
"web": {
"client_id": config.GOOGLE_OAUTH_CLIENT_ID,
"client_secret": config.GOOGLE_OAUTH_CLIENT_SECRET,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"redirect_uris": [config.GOOGLE_DRIVE_REDIRECT_URI],
}
},
scopes=SCOPES,
redirect_uri=config.GOOGLE_DRIVE_REDIRECT_URI,
)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to create Google OAuth flow: {e!s}"
) from e
@router.get("/auth/google/drive/connector/add")
async def connect_drive(space_id: int, user: User = Depends(current_active_user)):
"""
Initiate Google Drive OAuth flow.
Query params:
space_id: Search space ID to add connector to
Returns:
JSON with auth_url to redirect user to Google authorization
"""
try:
if not space_id:
raise HTTPException(status_code=400, detail="space_id is required")
flow = get_google_flow()
# Encode space_id and user_id in state parameter
state_payload = json.dumps(
{
"space_id": space_id,
"user_id": str(user.id),
}
)
state_encoded = base64.urlsafe_b64encode(state_payload.encode()).decode()
# Generate authorization URL
auth_url, _ = flow.authorization_url(
access_type="offline", # Get refresh token
prompt="consent", # Force consent screen to get refresh token
include_granted_scopes="true",
state=state_encoded,
)
logger.info(
f"Initiating Google Drive OAuth for user {user.id}, space {space_id}"
)
return {"auth_url": auth_url}
except Exception as e:
logger.error(f"Failed to initiate Google Drive OAuth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate Google OAuth: {e!s}"
) from e
@router.get("/auth/google/drive/connector/callback")
async def drive_callback(
request: Request,
code: str,
state: str,
session: AsyncSession = Depends(get_async_session),
):
"""
Handle Google Drive OAuth callback.
Query params:
code: Authorization code from Google
state: Encoded state with space_id and user_id
Returns:
Redirect to frontend success page
"""
try:
# Decode and parse state
decoded_state = base64.urlsafe_b64decode(state.encode()).decode()
data = json.loads(decoded_state)
user_id = UUID(data["user_id"])
space_id = data["space_id"]
logger.info(
f"Processing Google Drive callback for user {user_id}, space {space_id}"
)
# Exchange authorization code for tokens
flow = get_google_flow()
flow.fetch_token(code=code)
creds = flow.credentials
creds_dict = json.loads(creds.to_json())
# Check if connector already exists for this space/user
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
)
)
existing_connector = result.scalars().first()
if existing_connector:
raise HTTPException(
status_code=409,
detail="A GOOGLE_DRIVE_CONNECTOR already exists in this search space. Each search space can have only one connector of each type per user.",
)
# Create new connector (NO folder selection here - happens at index time)
db_connector = SearchSourceConnector(
name="Google Drive Connector",
connector_type=SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
config={
**creds_dict,
"start_page_token": None, # Will be set on first index
},
search_space_id=space_id,
user_id=user_id,
is_indexable=True,
)
session.add(db_connector)
await session.commit()
await session.refresh(db_connector)
# Get initial start page token for delta sync
try:
drive_client = GoogleDriveClient(session, db_connector.id)
start_token, token_error = await get_start_page_token(drive_client)
if start_token and not token_error:
db_connector.config["start_page_token"] = start_token
from sqlalchemy.orm.attributes import flag_modified
flag_modified(db_connector, "config")
await session.commit()
logger.info(
f"Set initial start page token for connector {db_connector.id}"
)
except Exception as e:
logger.warning(f"Failed to get initial start page token: {e!s}")
logger.info(
f"Successfully created Google Drive connector {db_connector.id} for user {user_id}"
)
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-drive-connector"
)
except HTTPException:
await session.rollback()
raise
except ValidationError as e:
await session.rollback()
logger.error(f"Validation error: {e!s}", exc_info=True)
raise HTTPException(
status_code=400, detail=f"Invalid connector configuration: {e!s}"
) from e
except IntegrityError as e:
await session.rollback()
logger.error(f"Database integrity error: {e!s}", exc_info=True)
raise HTTPException(
status_code=409,
detail="A connector with this configuration already exists.",
) from e
except Exception as e:
await session.rollback()
logger.error(f"Unexpected error in Drive callback: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to complete Google OAuth: {e!s}"
) from e
@router.get("/connectors/{connector_id}/google-drive/folders")
async def list_google_drive_folders(
connector_id: int,
parent_id: str | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List folders AND files in user's Google Drive with hierarchical support.
This is called at index time from the manage connector page to display
the complete file system (folders and files). Only folders are selectable.
Args:
connector_id: ID of the Google Drive connector
parent_id: Optional parent folder ID to list contents (None for root)
Returns:
JSON with list of items: {
"items": [
{"id": str, "name": str, "mimeType": str, "isFolder": bool, ...},
...
]
}
"""
try:
# Get connector and verify ownership
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(
status_code=404,
detail="Google Drive connector not found or access denied",
)
# Initialize Drive client (credentials will be loaded on first API call)
drive_client = GoogleDriveClient(session, connector_id)
# List both folders and files (sorted: folders first)
items, error = await list_folder_contents(drive_client, parent_id=parent_id)
if error:
raise HTTPException(
status_code=500, detail=f"Failed to list folder contents: {error}"
)
# Count folders and files for better logging
folder_count = sum(1 for item in items if item.get("isFolder", False))
file_count = len(items) - folder_count
logger.info(
f"✅ Listed {len(items)} total items ({folder_count} folders, {file_count} files) for connector {connector_id}"
+ (f" in folder {parent_id}" if parent_id else " in ROOT")
)
# Log first few items for debugging
if items:
logger.info(f"First 3 items: {[item.get('name') for item in items[:3]]}")
return {"items": items}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing Drive contents: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to list Drive contents: {e!s}"
) from e

View file

@ -135,9 +135,10 @@ async def gmail_callback(
f"Successfully created Gmail connector for user {user_id} with ID {db_connector.id}" f"Successfully created Gmail connector for user {user_id} with ID {db_connector.id}"
) )
# Redirect to the frontend success page # Redirect to the frontend with success params for indexing config
# Using query params to auto-open the popup with config view on new-chat page
return RedirectResponse( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/add/google-gmail-connector?success=true" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=google-gmail-connector"
) )
except IntegrityError as e: except IntegrityError as e:

View file

@ -322,6 +322,9 @@ async def get_logs_summary(
document_id = ( document_id = (
log.log_metadata.get("document_id") if log.log_metadata else None log.log_metadata.get("document_id") if log.log_metadata else None
) )
connector_id = (
log.log_metadata.get("connector_id") if log.log_metadata else None
)
summary["active_tasks"].append( summary["active_tasks"].append(
{ {
"id": log.id, "id": log.id,
@ -330,6 +333,7 @@ async def get_logs_summary(
"started_at": log.created_at, "started_at": log.created_at,
"source": log.source, "source": log.source,
"document_id": document_id, "document_id": document_id,
"connector_id": connector_id,
} }
) )

View file

@ -14,7 +14,7 @@ import logging
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -30,6 +30,7 @@ from app.db import (
get_async_session, get_async_session,
) )
from app.schemas import ( from app.schemas import (
GoogleDriveIndexRequest,
SearchSourceConnectorBase, SearchSourceConnectorBase,
SearchSourceConnectorCreate, SearchSourceConnectorCreate,
SearchSourceConnectorRead, SearchSourceConnectorRead,
@ -542,6 +543,10 @@ async def index_connector_content(
None, None,
description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date", description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date",
), ),
drive_items: GoogleDriveIndexRequest | None = Body(
None,
description="[Google Drive only] Structured request with folders and files to index",
),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
@ -747,6 +752,33 @@ async def index_connector_content(
) )
response_message = "Google Gmail indexing started in the background." response_message = "Google Gmail indexing started in the background."
elif (
connector.connector_type == SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR
):
from app.tasks.celery_tasks.connector_tasks import (
index_google_drive_files_task,
)
if not drive_items or not drive_items.has_items():
raise HTTPException(
status_code=400,
detail="Google Drive indexing requires drive_items body parameter with folders or files",
)
logger.info(
f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id}, "
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
)
# Pass structured data to Celery task
index_google_drive_files_task.delay(
connector_id,
search_space_id,
str(user.id),
drive_items.model_dump(), # Convert to dict for JSON serialization
)
response_message = "Google Drive indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR: elif connector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR:
from app.tasks.celery_tasks.connector_tasks import ( from app.tasks.celery_tasks.connector_tasks import (
index_discord_messages_task, index_discord_messages_task,
@ -1515,6 +1547,90 @@ async def run_google_gmail_indexing(
# Optionally update status in DB to indicate failure # Optionally update status in DB to indicate failure
async def run_google_drive_indexing(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
items_dict: dict, # Dictionary with 'folders' and 'files' lists
):
"""Runs the Google Drive indexing task for folders and files and updates the timestamp."""
try:
from app.tasks.connector_indexers.google_drive_indexer import (
index_google_drive_files,
index_google_drive_single_file,
)
# Parse the structured data
items = GoogleDriveIndexRequest(**items_dict)
total_indexed = 0
errors = []
# Index each folder
for folder in items.folders:
try:
indexed_count, error_message = await index_google_drive_files(
session,
connector_id,
search_space_id,
user_id,
folder_id=folder.id,
folder_name=folder.name,
use_delta_sync=True,
update_last_indexed=False,
)
if error_message:
errors.append(f"Folder '{folder.name}': {error_message}")
else:
total_indexed += indexed_count
except Exception as e:
errors.append(f"Folder '{folder.name}': {e!s}")
logger.error(
f"Error indexing folder {folder.name} ({folder.id}): {e}",
exc_info=True,
)
# Index each individual file
for file in items.files:
try:
indexed_count, error_message = await index_google_drive_single_file(
session,
connector_id,
search_space_id,
user_id,
file_id=file.id,
file_name=file.name,
)
if error_message:
errors.append(f"File '{file.name}': {error_message}")
else:
total_indexed += indexed_count
except Exception as e:
errors.append(f"File '{file.name}': {e!s}")
logger.error(
f"Error indexing file {file.name} ({file.id}): {e}",
exc_info=True,
)
if errors:
logger.error(
f"Google Drive indexing completed with errors for connector {connector_id}: {'; '.join(errors)}"
)
else:
logger.info(
f"Google Drive indexing successful for connector {connector_id}. Indexed {total_indexed} documents from {len(items.folders)} folder(s) and {len(items.files)} file(s)."
)
# Update the last indexed timestamp only on full success
await update_connector_last_indexed(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
f"Critical error in run_google_drive_indexing for connector {connector_id}: {e}",
exc_info=True,
)
# Optionally update status in DB to indicate failure
# Add new helper functions for luma indexing # Add new helper functions for luma indexing
async def run_luma_indexing_with_new_session( async def run_luma_indexing_with_new_session(
connector_id: int, connector_id: int,

View file

@ -10,6 +10,7 @@ from .documents import (
ExtensionDocumentMetadata, ExtensionDocumentMetadata,
PaginatedResponse, PaginatedResponse,
) )
from .google_drive import DriveItem, GoogleDriveIndexRequest
from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate
from .new_chat import ( from .new_chat import (
ChatMessage, ChatMessage,
@ -83,9 +84,12 @@ __all__ = [
"DocumentUpdate", "DocumentUpdate",
"DocumentWithChunksRead", "DocumentWithChunksRead",
"DocumentsCreate", "DocumentsCreate",
# Google Drive schemas
"DriveItem",
"ExtensionDocumentContent", "ExtensionDocumentContent",
"ExtensionDocumentMetadata", "ExtensionDocumentMetadata",
"GlobalNewLLMConfigRead", "GlobalNewLLMConfigRead",
"GoogleDriveIndexRequest",
# Base schemas # Base schemas
"IDModel", "IDModel",
# RBAC schemas # RBAC schemas

View file

@ -0,0 +1,41 @@
"""Schemas for Google Drive connector."""
from pydantic import BaseModel, Field
class DriveItem(BaseModel):
"""Represents a Google Drive file or folder."""
id: str = Field(..., description="Google Drive item ID")
name: str = Field(..., description="Item display name")
class GoogleDriveIndexRequest(BaseModel):
"""Request body for indexing Google Drive content."""
folders: list[DriveItem] = Field(
default_factory=list, description="List of folders to index"
)
files: list[DriveItem] = Field(
default_factory=list, description="List of specific files to index"
)
def has_items(self) -> bool:
"""Check if any items are selected."""
return len(self.folders) > 0 or len(self.files) > 0
def get_folder_ids(self) -> list[str]:
"""Get list of folder IDs."""
return [folder.id for folder in self.folders]
def get_folder_names(self) -> list[str]:
"""Get list of folder names."""
return [folder.name for folder in self.folders]
def get_file_ids(self) -> list[str]:
"""Get list of file IDs."""
return [file.id for file in self.files]
def get_file_names(self) -> list[str]:
"""Get list of file names."""
return [file.name for file in self.files]

View file

@ -1808,6 +1808,106 @@ class ConnectorService:
return result_object, gmail_docs return result_object, gmail_docs
async def search_google_drive(
self,
user_query: str,
search_space_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> tuple:
"""
Search for Google Drive files and return both the source information and langchain documents.
Uses combined chunk-level and document-level hybrid search with RRF fusion.
Args:
user_query: The user's query
search_space_id: The search space ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
Returns:
tuple: (sources_info, langchain_documents)
"""
drive_docs = await self._combined_rrf_search(
query_text=user_query,
search_space_id=search_space_id,
document_type="GOOGLE_DRIVE_FILE",
top_k=top_k,
start_date=start_date,
end_date=end_date,
)
# Early return if no results
if not drive_docs:
return {
"id": 33,
"name": "Google Drive Files",
"type": "GOOGLE_DRIVE_FILE",
"sources": [],
}, []
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
return (
doc_info.get("title")
or metadata.get("google_drive_file_name")
or metadata.get("FILE_NAME")
or "Untitled File"
)
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
file_id = metadata.get("google_drive_file_id", "")
return f"https://drive.google.com/file/d/{file_id}/view" if file_id else ""
def _description_fn(
chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
) -> str:
description = self._chunk_preview(chunk.get("content", ""))
info_parts = []
mime_type = metadata.get("google_drive_mime_type", "")
modified_time = metadata.get("modified_time", "")
if mime_type:
# Simplify mime type for display
if "google-apps" in mime_type:
file_type = mime_type.split(".")[-1].title()
else:
file_type = mime_type.split("/")[-1].upper()
info_parts.append(f"Type: {file_type}")
if modified_time:
info_parts.append(f"Modified: {modified_time}")
if info_parts:
description = (description + " | " + " | ".join(info_parts)).strip(" |")
return description
def _extra_fields_fn(
_chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
) -> dict[str, Any]:
return {
"google_drive_file_id": metadata.get("google_drive_file_id", ""),
"google_drive_mime_type": metadata.get("google_drive_mime_type", ""),
"modified_time": metadata.get("modified_time", ""),
}
sources_list = self._build_chunk_sources_from_documents(
drive_docs,
title_fn=_title_fn,
url_fn=_url_fn,
description_fn=_description_fn,
extra_fields_fn=_extra_fields_fn,
)
# Create result object
result_object = {
"id": 33, # Assign a unique ID for the Google Drive connector
"name": "Google Drive Files",
"type": "GOOGLE_DRIVE_FILE",
"sources": sources_list,
}
return result_object, drive_docs
async def search_confluence( async def search_confluence(
self, self,
user_query: str, user_query: str,
@ -2506,3 +2606,103 @@ class ConnectorService:
} }
return result_object, bookstack_docs return result_object, bookstack_docs
async def search_circleback(
self,
user_query: str,
search_space_id: int,
top_k: int = 20,
start_date: datetime | None = None,
end_date: datetime | None = None,
) -> tuple:
"""
Search for Circleback meeting notes and return both the source information and langchain documents.
Uses combined chunk-level and document-level hybrid search with RRF fusion.
Args:
user_query: The user's query
search_space_id: The search space ID to search in
top_k: Maximum number of results to return
start_date: Optional start date for filtering documents by updated_at
end_date: Optional end date for filtering documents by updated_at
Returns:
tuple: (sources_info, langchain_documents)
"""
circleback_docs = await self._combined_rrf_search(
query_text=user_query,
search_space_id=search_space_id,
document_type="CIRCLEBACK",
top_k=top_k,
start_date=start_date,
end_date=end_date,
)
# Early return if no results
if not circleback_docs:
return {
"id": 52,
"name": "Circleback Meetings",
"type": "CIRCLEBACK",
"sources": [],
}, []
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
meeting_name = metadata.get("meeting_name", "")
meeting_date = metadata.get("meeting_date", "")
title = doc_info.get("title") or meeting_name or "Circleback Meeting"
if meeting_date:
title += f" ({meeting_date})"
return title
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
meeting_id = metadata.get("circleback_meeting_id", "")
return (
f"https://app.circleback.ai/meetings/{meeting_id}" if meeting_id else ""
)
def _description_fn(
chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
) -> str:
description = self._chunk_preview(chunk.get("content", ""), limit=200)
info_parts = []
duration = metadata.get("duration_seconds")
attendee_count = metadata.get("attendee_count")
if duration:
minutes = int(duration) // 60
info_parts.append(f"Duration: {minutes} min")
if attendee_count:
info_parts.append(f"Attendees: {attendee_count}")
if info_parts:
description = (description + " | " + " | ".join(info_parts)).strip(" |")
return description
def _extra_fields_fn(
_chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
) -> dict[str, Any]:
return {
"circleback_meeting_id": metadata.get("circleback_meeting_id", ""),
"meeting_name": metadata.get("meeting_name", ""),
"meeting_date": metadata.get("meeting_date", ""),
"duration_seconds": metadata.get("duration_seconds", 0),
"attendee_count": metadata.get("attendee_count", 0),
}
sources_list = self._build_chunk_sources_from_documents(
circleback_docs,
title_fn=_title_fn,
url_fn=_url_fn,
description_fn=_description_fn,
extra_fields_fn=_extra_fields_fn,
)
# Create result object
result_object = {
"id": 52,
"name": "Circleback Meetings",
"type": "CIRCLEBACK",
"sources": sources_list,
}
return result_object, circleback_docs

View file

@ -473,6 +473,54 @@ async def _index_google_gmail_messages(
) )
@celery_app.task(name="index_google_drive_files", bind=True)
def index_google_drive_files_task(
self,
connector_id: int,
search_space_id: int,
user_id: str,
items_dict: dict, # Dictionary with 'folders' and 'files' lists
):
"""Celery task to index Google Drive folders and files."""
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_index_google_drive_files(
connector_id,
search_space_id,
user_id,
items_dict,
)
)
finally:
loop.close()
async def _index_google_drive_files(
connector_id: int,
search_space_id: int,
user_id: str,
items_dict: dict, # Dictionary with 'folders' and 'files' lists
):
"""Index Google Drive folders and files with new session."""
from app.routes.search_source_connectors_routes import (
run_google_drive_indexing,
)
async with get_celery_session_maker()() as session:
await run_google_drive_indexing(
session,
connector_id,
search_space_id,
user_id,
items_dict,
)
@celery_app.task(name="index_discord_messages", bind=True) @celery_app.task(name="index_discord_messages", bind=True)
def index_discord_messages_task( def index_discord_messages_task(
self, self,

View file

@ -268,3 +268,105 @@ async def _process_file_upload(
) )
logger.error(error_message) logger.error(error_message)
raise raise
@celery_app.task(name="process_circleback_meeting", bind=True)
def process_circleback_meeting_task(
self,
meeting_id: int,
meeting_name: str,
markdown_content: str,
metadata: dict,
search_space_id: int,
):
"""
Celery task to process Circleback meeting webhook data.
Args:
meeting_id: Circleback meeting ID
meeting_name: Name of the meeting
markdown_content: Meeting content formatted as markdown
metadata: Meeting metadata dictionary
search_space_id: ID of the search space
"""
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(
_process_circleback_meeting(
meeting_id,
meeting_name,
markdown_content,
metadata,
search_space_id,
)
)
finally:
loop.close()
async def _process_circleback_meeting(
meeting_id: int,
meeting_name: str,
markdown_content: str,
metadata: dict,
search_space_id: int,
):
"""Process Circleback meeting with new session."""
from app.tasks.document_processors.circleback_processor import (
add_circleback_meeting_document,
)
async with get_celery_session_maker()() as session:
task_logger = TaskLoggingService(session, search_space_id)
log_entry = await task_logger.log_task_start(
task_name="process_circleback_meeting",
source="circleback_webhook",
message=f"Starting Circleback meeting processing: {meeting_name}",
metadata={
"document_type": "CIRCLEBACK",
"meeting_id": meeting_id,
"meeting_name": meeting_name,
**metadata,
},
)
try:
result = await add_circleback_meeting_document(
session=session,
meeting_id=meeting_id,
meeting_name=meeting_name,
markdown_content=markdown_content,
metadata=metadata,
search_space_id=search_space_id,
)
if result:
await task_logger.log_task_success(
log_entry,
f"Successfully processed Circleback meeting: {meeting_name}",
{
"document_id": result.id,
"meeting_id": meeting_id,
"content_hash": result.content_hash,
},
)
else:
await task_logger.log_task_success(
log_entry,
f"Circleback meeting document already exists (duplicate): {meeting_name}",
{"duplicate_detected": True, "meeting_id": meeting_id},
)
except Exception as e:
await task_logger.log_task_failure(
log_entry,
f"Failed to process Circleback meeting: {meeting_name}",
str(e),
{"error_type": type(e).__name__, "meeting_id": meeting_id},
)
logger.error(f"Error processing Circleback meeting: {e!s}")
raise

View file

@ -34,6 +34,7 @@ from .discord_indexer import index_discord_messages
from .elasticsearch_indexer import index_elasticsearch_documents from .elasticsearch_indexer import index_elasticsearch_documents
from .github_indexer import index_github_repos from .github_indexer import index_github_repos
from .google_calendar_indexer import index_google_calendar_events from .google_calendar_indexer import index_google_calendar_events
from .google_drive_indexer import index_google_drive_files
from .google_gmail_indexer import index_google_gmail_messages from .google_gmail_indexer import index_google_gmail_messages
from .jira_indexer import index_jira_issues from .jira_indexer import index_jira_issues
@ -57,6 +58,7 @@ __all__ = [ # noqa: RUF022
"index_github_repos", "index_github_repos",
# Calendar and scheduling # Calendar and scheduling
"index_google_calendar_events", "index_google_calendar_events",
"index_google_drive_files",
"index_luma_events", "index_luma_events",
"index_jira_issues", "index_jira_issues",
# Issue tracking and project management # Issue tracking and project management

View file

@ -0,0 +1,537 @@
"""Google Drive indexer using Surfsense file processors."""
import logging
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.connectors.google_drive import (
GoogleDriveClient,
categorize_change,
download_and_process_file,
fetch_all_changes,
get_file_by_id,
get_files_in_folder,
get_start_page_token,
)
from app.db import DocumentType, SearchSourceConnectorType
from app.services.task_logging_service import TaskLoggingService
from app.tasks.connector_indexers.base import (
check_document_by_unique_identifier,
get_connector_by_id,
update_connector_last_indexed,
)
from app.utils.document_converters import generate_unique_identifier_hash
logger = logging.getLogger(__name__)
async def index_google_drive_files(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
folder_id: str | None = None,
folder_name: str | None = None,
use_delta_sync: bool = True,
update_last_indexed: bool = True,
max_files: int = 500,
) -> tuple[int, str | None]:
"""
Index Google Drive files for a specific connector.
Args:
session: Database session
connector_id: ID of the Drive connector
search_space_id: ID of the search space
user_id: ID of the user
folder_id: Specific folder to index (from UI/request, takes precedence)
folder_name: Folder name for display (from UI/request)
use_delta_sync: Whether to use change tracking for incremental sync
update_last_indexed: Whether to update last_indexed_at timestamp
max_files: Maximum number of files to index
Returns:
Tuple of (number_of_indexed_files, error_message)
"""
task_logger = TaskLoggingService(session, search_space_id)
log_entry = await task_logger.log_task_start(
task_name="google_drive_files_indexing",
source="connector_indexing_task",
message=f"Starting Google Drive indexing for connector {connector_id}",
metadata={
"connector_id": connector_id,
"user_id": str(user_id),
"folder_id": folder_id,
"use_delta_sync": use_delta_sync,
"max_files": max_files,
},
)
try:
connector = await get_connector_by_id(
session, connector_id, SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR
)
if not connector:
error_msg = f"Google Drive connector with ID {connector_id} not found"
await task_logger.log_task_failure(
log_entry, error_msg, {"error_type": "ConnectorNotFound"}
)
return 0, error_msg
await task_logger.log_task_progress(
log_entry,
f"Initializing Google Drive client for connector {connector_id}",
{"stage": "client_initialization"},
)
drive_client = GoogleDriveClient(session, connector_id)
if not folder_id:
error_msg = "folder_id is required for Google Drive indexing"
await task_logger.log_task_failure(
log_entry, error_msg, {"error_type": "MissingParameter"}
)
return 0, error_msg
target_folder_id = folder_id
target_folder_name = folder_name or "Selected Folder"
logger.info(
f"Indexing Google Drive folder: {target_folder_name} ({target_folder_id})"
)
folder_tokens = connector.config.get("folder_tokens", {})
start_page_token = folder_tokens.get(target_folder_id)
can_use_delta_sync = (
use_delta_sync and start_page_token and connector.last_indexed_at
)
if can_use_delta_sync:
logger.info(f"Using delta sync for connector {connector_id}")
result = await _index_with_delta_sync(
drive_client=drive_client,
session=session,
connector=connector,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
folder_id=target_folder_id,
start_page_token=start_page_token,
task_logger=task_logger,
log_entry=log_entry,
max_files=max_files,
)
else:
logger.info(f"Using full scan for connector {connector_id}")
result = await _index_full_scan(
drive_client=drive_client,
session=session,
connector=connector,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
folder_id=target_folder_id,
folder_name=target_folder_name,
task_logger=task_logger,
log_entry=log_entry,
max_files=max_files,
)
documents_indexed, documents_skipped = result
if documents_indexed > 0 or can_use_delta_sync:
new_token, token_error = await get_start_page_token(drive_client)
if new_token and not token_error:
from sqlalchemy.orm.attributes import flag_modified
if "folder_tokens" not in connector.config:
connector.config["folder_tokens"] = {}
connector.config["folder_tokens"][target_folder_id] = new_token
flag_modified(connector, "config")
await update_connector_last_indexed(session, connector, update_last_indexed)
await session.commit()
logger.info("Successfully committed Google Drive indexing changes to database")
await task_logger.log_task_success(
log_entry,
f"Successfully completed Google Drive indexing for connector {connector_id}",
{
"files_processed": documents_indexed,
"files_skipped": documents_skipped,
"sync_type": "delta" if can_use_delta_sync else "full",
"folder": target_folder_name,
},
)
logger.info(
f"Google Drive indexing completed: {documents_indexed} files indexed, {documents_skipped} skipped"
)
return documents_indexed, None
except SQLAlchemyError as db_error:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Database error during Google Drive indexing for connector {connector_id}",
str(db_error),
{"error_type": "SQLAlchemyError"},
)
logger.error(f"Database error: {db_error!s}", exc_info=True)
return 0, f"Database error: {db_error!s}"
except Exception as e:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
f"Failed to index Google Drive files for connector {connector_id}",
str(e),
{"error_type": type(e).__name__},
)
logger.error(f"Failed to index Google Drive files: {e!s}", exc_info=True)
return 0, f"Failed to index Google Drive files: {e!s}"
async def index_google_drive_single_file(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
file_id: str,
file_name: str | None = None,
) -> tuple[int, str | None]:
"""
Index a single Google Drive file by its ID.
Args:
session: Database session
connector_id: ID of the Drive connector
search_space_id: ID of the search space
user_id: ID of the user
file_id: Specific file ID to index
file_name: File name for display (optional)
Returns:
Tuple of (number_of_indexed_files, error_message)
"""
task_logger = TaskLoggingService(session, search_space_id)
log_entry = await task_logger.log_task_start(
task_name="google_drive_single_file_indexing",
source="connector_indexing_task",
message=f"Starting Google Drive single file indexing for file {file_id}",
metadata={
"connector_id": connector_id,
"user_id": str(user_id),
"file_id": file_id,
"file_name": file_name,
},
)
try:
connector = await get_connector_by_id(
session, connector_id, SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR
)
if not connector:
error_msg = f"Google Drive connector with ID {connector_id} not found"
await task_logger.log_task_failure(
log_entry, error_msg, {"error_type": "ConnectorNotFound"}
)
return 0, error_msg
await task_logger.log_task_progress(
log_entry,
f"Initializing Google Drive client for connector {connector_id}",
{"stage": "client_initialization"},
)
drive_client = GoogleDriveClient(session, connector_id)
# Fetch the file metadata
file, error = await get_file_by_id(drive_client, file_id)
if error or not file:
error_msg = f"Failed to fetch file {file_id}: {error or 'File not found'}"
await task_logger.log_task_failure(
log_entry, error_msg, {"error_type": "FileNotFound"}
)
return 0, error_msg
display_name = file_name or file.get("name", "Unknown")
logger.info(f"Indexing Google Drive file: {display_name} ({file_id})")
# Process the file
indexed, skipped = await _process_single_file(
drive_client=drive_client,
session=session,
file=file,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
task_logger=task_logger,
log_entry=log_entry,
)
await session.commit()
logger.info(
"Successfully committed Google Drive file indexing changes to database"
)
if indexed > 0:
await task_logger.log_task_success(
log_entry,
f"Successfully indexed file {display_name}",
{
"file_name": display_name,
"file_id": file_id,
},
)
logger.info(f"Google Drive file indexing completed: {display_name}")
return 1, None
else:
await task_logger.log_task_progress(
log_entry,
f"File {display_name} was skipped",
{"status": "skipped"},
)
return 0, None
except SQLAlchemyError as db_error:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
"Database error during file indexing",
str(db_error),
{"error_type": "SQLAlchemyError"},
)
logger.error(f"Database error: {db_error!s}", exc_info=True)
return 0, f"Database error: {db_error!s}"
except Exception as e:
await session.rollback()
await task_logger.log_task_failure(
log_entry,
"Failed to index Google Drive file",
str(e),
{"error_type": type(e).__name__},
)
logger.error(f"Failed to index Google Drive file: {e!s}", exc_info=True)
return 0, f"Failed to index Google Drive file: {e!s}"
async def _index_full_scan(
drive_client: GoogleDriveClient,
session: AsyncSession,
connector: any,
connector_id: int,
search_space_id: int,
user_id: str,
folder_id: str | None,
folder_name: str,
task_logger: TaskLoggingService,
log_entry: any,
max_files: int,
) -> tuple[int, int]:
"""Perform full scan indexing of a folder."""
await task_logger.log_task_progress(
log_entry,
f"Starting full scan of folder: {folder_name}",
{"stage": "full_scan", "folder_id": folder_id},
)
documents_indexed = 0
documents_skipped = 0
page_token = None
files_processed = 0
while files_processed < max_files:
files, next_token, error = await get_files_in_folder(
drive_client, folder_id, include_subfolders=False, page_token=page_token
)
if error:
logger.error(f"Error listing files: {error}")
break
if not files:
break
for file in files:
if files_processed >= max_files:
break
files_processed += 1
indexed, skipped = await _process_single_file(
drive_client=drive_client,
session=session,
file=file,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
task_logger=task_logger,
log_entry=log_entry,
)
documents_indexed += indexed
documents_skipped += skipped
if documents_indexed % 10 == 0 and documents_indexed > 0:
await session.commit()
logger.info(
f"Committed batch: {documents_indexed} files indexed so far"
)
page_token = next_token
if not page_token:
break
logger.info(
f"Full scan complete: {documents_indexed} indexed, {documents_skipped} skipped"
)
return documents_indexed, documents_skipped
async def _index_with_delta_sync(
drive_client: GoogleDriveClient,
session: AsyncSession,
connector: any,
connector_id: int,
search_space_id: int,
user_id: str,
folder_id: str | None,
start_page_token: str,
task_logger: TaskLoggingService,
log_entry: any,
max_files: int,
) -> tuple[int, int]:
"""Perform delta sync indexing using change tracking."""
await task_logger.log_task_progress(
log_entry,
f"Starting delta sync from token: {start_page_token[:20]}...",
{"stage": "delta_sync", "start_token": start_page_token},
)
changes, final_token, error = await fetch_all_changes(
drive_client, start_page_token, folder_id
)
if error:
logger.error(f"Error fetching changes: {error}")
return 0, 0
if not changes:
logger.info("No changes detected since last sync")
return 0, 0
logger.info(f"Processing {len(changes)} changes")
documents_indexed = 0
documents_skipped = 0
files_processed = 0
for change in changes:
if files_processed >= max_files:
break
files_processed += 1
change_type = categorize_change(change)
if change_type in ["removed", "trashed"]:
file_id = change.get("fileId")
if file_id:
await _remove_document(session, file_id, search_space_id)
continue
file = change.get("file")
if not file:
continue
indexed, skipped = await _process_single_file(
drive_client=drive_client,
session=session,
file=file,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
task_logger=task_logger,
log_entry=log_entry,
)
documents_indexed += indexed
documents_skipped += skipped
if documents_indexed % 10 == 0 and documents_indexed > 0:
await session.commit()
logger.info(f"Committed batch: {documents_indexed} changes processed")
logger.info(
f"Delta sync complete: {documents_indexed} indexed, {documents_skipped} skipped"
)
return documents_indexed, documents_skipped
async def _process_single_file(
drive_client: GoogleDriveClient,
session: AsyncSession,
file: dict,
connector_id: int,
search_space_id: int,
user_id: str,
task_logger: TaskLoggingService,
log_entry: any,
) -> tuple[int, int]:
"""
Process a single file by downloading and using Surfsense's file processor.
Returns:
Tuple of (indexed_count, skipped_count)
"""
file_name = file.get("name", "Unknown")
mime_type = file.get("mimeType", "")
try:
logger.info(f"Processing file: {file_name} ({mime_type})")
_, error, _ = await download_and_process_file(
client=drive_client,
file=file,
search_space_id=search_space_id,
user_id=user_id,
session=session,
task_logger=task_logger,
log_entry=log_entry,
)
if error:
await task_logger.log_task_progress(
log_entry,
f"Skipped {file_name}: {error}",
{"status": "skipped", "reason": error},
)
return 0, 1
logger.info(f"Successfully indexed Google Drive file: {file_name}")
return 1, 0
except Exception as e:
logger.error(f"Error processing file {file_name}: {e!s}", exc_info=True)
return 0, 1
async def _remove_document(session: AsyncSession, file_id: str, search_space_id: int):
"""Remove a document that was deleted in Drive."""
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.GOOGLE_DRIVE_FILE, file_id, search_space_id
)
existing_document = await check_document_by_unique_identifier(
session, unique_identifier_hash
)
if existing_document:
await session.delete(existing_document)
logger.info(f"Removed deleted file document: {file_id}")

View file

@ -0,0 +1,183 @@
"""
Circleback meeting document processor.
This module processes meeting data received from Circleback webhooks
and stores it as searchable documents in the database.
"""
import logging
from typing import Any
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import Document, DocumentType
from app.services.llm_service import get_document_summary_llm
from app.utils.document_converters import (
create_document_chunks,
generate_content_hash,
generate_document_summary,
generate_unique_identifier_hash,
)
from .base import (
check_document_by_unique_identifier,
get_current_timestamp,
)
logger = logging.getLogger(__name__)
async def add_circleback_meeting_document(
session: AsyncSession,
meeting_id: int,
meeting_name: str,
markdown_content: str,
metadata: dict[str, Any],
search_space_id: int,
) -> Document | None:
"""
Process and store a Circleback meeting document.
Args:
session: Database session
meeting_id: Circleback meeting ID
meeting_name: Name of the meeting
markdown_content: Meeting content formatted as markdown
metadata: Meeting metadata dictionary
search_space_id: ID of the search space
Returns:
Document object if successful, None if failed or duplicate
"""
try:
# Generate unique identifier hash using Circleback meeting ID
unique_identifier = f"circleback_{meeting_id}"
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.CIRCLEBACK, unique_identifier, search_space_id
)
# Generate content hash
content_hash = generate_content_hash(markdown_content, search_space_id)
# Check if document with this unique identifier already exists
existing_document = await check_document_by_unique_identifier(
session, unique_identifier_hash
)
if existing_document:
# Document exists - check if content has changed
if existing_document.content_hash == content_hash:
logger.info(f"Circleback meeting {meeting_id} unchanged. Skipping.")
return existing_document
else:
# Content has changed - update the existing document
logger.info(
f"Content changed for Circleback meeting {meeting_id}. Updating document."
)
# Get LLM for generating summary
llm = await get_document_summary_llm(session, search_space_id)
if not llm:
logger.warning(
f"No LLM configured for search space {search_space_id}. Using content as summary."
)
# Use first 1000 chars as summary if no LLM available
summary_content = (
markdown_content[:1000] + "..."
if len(markdown_content) > 1000
else markdown_content
)
summary_embedding = None
else:
# Generate summary with metadata
document_metadata = {
"meeting_name": meeting_name,
"meeting_id": meeting_id,
"document_type": "Circleback Meeting",
**{
k: v
for k, v in metadata.items()
if isinstance(v, str | int | float | bool)
},
}
summary_content, summary_embedding = await generate_document_summary(
markdown_content, llm, document_metadata
)
# Process chunks
chunks = await create_document_chunks(markdown_content)
# Convert to BlockNote JSON for editing capability
from app.utils.blocknote_converter import convert_markdown_to_blocknote
blocknote_json = await convert_markdown_to_blocknote(markdown_content)
if not blocknote_json:
logger.warning(
f"Failed to convert Circleback meeting {meeting_id} to BlockNote JSON, document will not be editable"
)
# Prepare document metadata
document_metadata = {
"CIRCLEBACK_MEETING_ID": meeting_id,
"MEETING_NAME": meeting_name,
"SOURCE": "CIRCLEBACK_WEBHOOK",
**metadata,
}
# Update or create document
if existing_document:
# Update existing document
existing_document.title = meeting_name
existing_document.content = summary_content
existing_document.content_hash = content_hash
if summary_embedding is not None:
existing_document.embedding = summary_embedding
existing_document.document_metadata = document_metadata
existing_document.chunks = chunks
existing_document.blocknote_document = blocknote_json
existing_document.content_needs_reindexing = False
existing_document.updated_at = get_current_timestamp()
await session.commit()
await session.refresh(existing_document)
document = existing_document
logger.info(
f"Updated Circleback meeting document {meeting_id} in search space {search_space_id}"
)
else:
# Create new document
document = Document(
search_space_id=search_space_id,
title=meeting_name,
document_type=DocumentType.CIRCLEBACK,
document_metadata=document_metadata,
content=summary_content,
embedding=summary_embedding,
chunks=chunks,
content_hash=content_hash,
unique_identifier_hash=unique_identifier_hash,
blocknote_document=blocknote_json,
content_needs_reindexing=False,
updated_at=get_current_timestamp(),
)
session.add(document)
await session.commit()
await session.refresh(document)
logger.info(
f"Created new Circleback meeting document {meeting_id} in search space {search_space_id}"
)
return document
except SQLAlchemyError as db_error:
await session.rollback()
logger.error(
f"Database error processing Circleback meeting {meeting_id}: {db_error}"
)
raise db_error
except Exception as e:
await session.rollback()
logger.error(f"Failed to process Circleback meeting {meeting_id}: {e!s}")
raise RuntimeError(f"Failed to process Circleback meeting: {e!s}") from e

View file

@ -447,6 +447,24 @@ async def add_received_file_document_using_docling(
) from e ) from e
async def _update_document_from_connector(
document: Document | None, connector: dict | None, session: AsyncSession
) -> None:
"""Helper to update document type and metadata from connector info."""
if document and connector:
if "type" in connector:
document.document_type = connector["type"]
if "metadata" in connector:
# Merge with existing document_metadata (the actual column name)
if not document.document_metadata:
document.document_metadata = connector["metadata"]
else:
# Expand existing metadata with connector metadata
merged = {**document.document_metadata, **connector["metadata"]}
document.document_metadata = merged
await session.commit()
async def process_file_in_background( async def process_file_in_background(
file_path: str, file_path: str,
filename: str, filename: str,
@ -455,6 +473,8 @@ async def process_file_in_background(
session: AsyncSession, session: AsyncSession,
task_logger: TaskLoggingService, task_logger: TaskLoggingService,
log_entry: Log, log_entry: Log,
connector: dict
| None = None, # Optional: {"type": "GOOGLE_DRIVE_FILE", "metadata": {...}}
): ):
try: try:
# Check if the file is a markdown or text file # Check if the file is a markdown or text file
@ -492,6 +512,9 @@ async def process_file_in_background(
session, filename, markdown_content, search_space_id, user_id session, filename, markdown_content, search_space_id, user_id
) )
if connector:
await _update_document_from_connector(result, connector, session)
if result: if result:
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
@ -608,6 +631,9 @@ async def process_file_in_background(
session, filename, transcribed_text, search_space_id, user_id session, filename, transcribed_text, search_space_id, user_id
) )
if connector:
await _update_document_from_connector(result, connector, session)
if result: if result:
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
@ -753,6 +779,9 @@ async def process_file_in_background(
session, filename, docs, search_space_id, user_id session, filename, docs, search_space_id, user_id
) )
if connector:
await _update_document_from_connector(result, connector, session)
if result: if result:
# Update page usage after successful processing # Update page usage after successful processing
# allow_exceed=True because document was already created after passing initial check # allow_exceed=True because document was already created after passing initial check
@ -897,6 +926,11 @@ async def process_file_in_background(
user_id, final_page_count, allow_exceed=True user_id, final_page_count, allow_exceed=True
) )
if connector:
await _update_document_from_connector(
last_created_doc, connector, session
)
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
f"Successfully processed file with LlamaCloud: {filename}", f"Successfully processed file with LlamaCloud: {filename}",
@ -1021,6 +1055,11 @@ async def process_file_in_background(
user_id, final_page_count, allow_exceed=True user_id, final_page_count, allow_exceed=True
) )
if connector:
await _update_document_from_connector(
doc_result, connector, session
)
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
f"Successfully processed file with Docling: {filename}", f"Successfully processed file with Docling: {filename}",

View file

@ -1,6 +1,6 @@
[project] [project]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.9" version = "0.0.10"
description = "SurfSense Backend" description = "SurfSense Backend"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View file

@ -6409,7 +6409,7 @@ wheels = [
[[package]] [[package]]
name = "surf-new-backend" name = "surf-new-backend"
version = "0.0.9" version = "0.0.10"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "alembic" }, { name = "alembic" },

View file

@ -1,7 +1,7 @@
{ {
"name": "surfsense_browser_extension", "name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension", "displayName": "Surfsense Browser Extension",
"version": "0.0.9", "version": "0.0.10",
"description": "Extension to collect Browsing History for SurfSense.", "description": "Extension to collect Browsing History for SurfSense.",
"author": "https://github.com/MODSetter", "author": "https://github.com/MODSetter",
"engines": { "engines": {

View file

@ -14,6 +14,7 @@ import {
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
@ -240,36 +241,34 @@ export function DashboardClientLayout({
} }
return ( return (
<SidebarProvider <DocumentUploadDialogProvider>
className="h-full overflow-hidden" <SidebarProvider className="h-full overflow-hidden" open={open} onOpenChange={setOpen}>
open={open} {/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
onOpenChange={setOpen} <AppSidebarProvider
> searchSpaceId={searchSpaceId}
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */} navSecondary={translatedNavSecondary}
<AppSidebarProvider navMain={translatedNavMain}
searchSpaceId={searchSpaceId} />
navSecondary={translatedNavSecondary} <SidebarInset className="h-full ">
navMain={translatedNavMain} <main className="flex flex-col h-full">
/> <header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
<SidebarInset className="h-full "> <div className="flex items-center justify-between w-full gap-2 px-4">
<main className="flex flex-col h-full"> <div className="flex items-center gap-2">
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b"> <SidebarTrigger className="-ml-1" />
<div className="flex items-center justify-between w-full gap-2 px-4"> <div className="hidden md:flex items-center gap-2">
<div className="flex items-center gap-2"> <Separator orientation="vertical" className="h-6" />
<SidebarTrigger className="-ml-1" /> <DashboardBreadcrumb />
<div className="hidden md:flex items-center gap-2"> </div>
<Separator orientation="vertical" className="h-6" /> </div>
<DashboardBreadcrumb /> <div className="flex items-center gap-2">
<LanguageSwitcher />
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> </header>
<LanguageSwitcher /> <div className="flex-1 overflow-hidden">{children}</div>
</div> </main>
</div> </SidebarInset>
</header> </SidebarProvider>
<div className="flex-1 overflow-hidden">{children}</div> </DocumentUploadDialogProvider>
</main>
</SidebarInset>
</SidebarProvider>
); );
} }

View file

@ -1,715 +0,0 @@
"use client";
import { format } from "date-fns";
import { useAtomValue } from "jotai";
import {
Calendar as CalendarIcon,
Clock,
Edit,
Loader2,
Plus,
RefreshCw,
Trash2,
} from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
deleteConnectorMutationAtom,
indexConnectorMutationAtom,
updateConnectorMutationAtom,
} from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils";
export default function ConnectorsPage() {
const t = useTranslations("connectors");
const tCommon = useTranslations("common");
// Helper function to format date with time
const formatDateTime = (dateString: string | null): string => {
if (!dateString) return t("never");
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const today = new Date();
const { data: connectors = [], isLoading, error } = useAtomValue(connectorsAtom);
const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
const { mutateAsync: indexConnector } = useAtomValue(indexConnectorMutationAtom);
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
const [connectorToDelete, setConnectorToDelete] = useState<number | null>(null);
const [indexingConnectorId, setIndexingConnectorId] = useState<number | null>(null);
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [selectedConnectorForIndexing, setSelectedConnectorForIndexing] = useState<number | null>(
null
);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
// Periodic indexing state
const [periodicDialogOpen, setPeriodicDialogOpen] = useState(false);
const [selectedConnectorForPeriodic, setSelectedConnectorForPeriodic] = useState<number | null>(
null
);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState<string>("1440");
const [customFrequency, setCustomFrequency] = useState<string>("");
const [isSavingPeriodic, setIsSavingPeriodic] = useState(false);
useEffect(() => {
if (error) {
toast.error(t("failed_load"));
console.error("Error fetching connectors:", error);
}
}, [error, t]);
// Handle connector deletion
const handleDeleteConnector = async () => {
if (connectorToDelete === null) return;
try {
await deleteConnector({ id: connectorToDelete });
} catch (error) {
console.error("Error deleting connector:", error);
} finally {
setConnectorToDelete(null);
}
};
// Handle opening date picker for indexing
const handleOpenDatePicker = (connectorId: number) => {
setSelectedConnectorForIndexing(connectorId);
setDatePickerOpen(true);
};
// Handle connector indexing with dates
const handleIndexConnector = async () => {
if (selectedConnectorForIndexing === null) return;
setDatePickerOpen(false);
try {
setIndexingConnectorId(selectedConnectorForIndexing);
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
await indexConnector({
connector_id: selectedConnectorForIndexing,
queryParams: {
search_space_id: searchSpaceId,
start_date: startDateStr,
end_date: endDateStr,
},
});
toast.success(t("indexing_started"));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : t("indexing_failed"));
} finally {
setIndexingConnectorId(null);
setSelectedConnectorForIndexing(null);
setStartDate(undefined);
setEndDate(undefined);
}
};
// Handle indexing without date picker (for quick indexing)
const handleQuickIndexConnector = async (connectorId: number) => {
setIndexingConnectorId(connectorId);
try {
await indexConnector({
connector_id: connectorId,
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success(t("indexing_started"));
} catch (error) {
console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : t("indexing_failed"));
} finally {
setIndexingConnectorId(null);
}
};
// Handle opening periodic indexing dialog
const handleOpenPeriodicDialog = (connectorId: number) => {
const connector = connectors.find((c) => c.id === connectorId);
if (!connector) return;
setSelectedConnectorForPeriodic(connectorId);
setPeriodicEnabled(connector.periodic_indexing_enabled);
if (connector.indexing_frequency_minutes) {
// Check if it's a preset value
const presetValues = ["15", "60", "360", "720", "1440", "10080"];
if (presetValues.includes(connector.indexing_frequency_minutes.toString())) {
setFrequencyMinutes(connector.indexing_frequency_minutes.toString());
setCustomFrequency("");
} else {
setFrequencyMinutes("custom");
setCustomFrequency(connector.indexing_frequency_minutes.toString());
}
} else {
setFrequencyMinutes("1440");
setCustomFrequency("");
}
setPeriodicDialogOpen(true);
};
// Handle saving periodic indexing configuration
const handleSavePeriodicIndexing = async () => {
if (selectedConnectorForPeriodic === null) return;
const connector = connectors.find((c) => c.id === selectedConnectorForPeriodic);
if (!connector) return;
setIsSavingPeriodic(true);
try {
// Determine the frequency value
let frequency: number | null = null;
if (periodicEnabled) {
if (frequencyMinutes === "custom") {
frequency = parseInt(customFrequency, 10);
if (isNaN(frequency) || frequency <= 0) {
toast.error("Please enter a valid frequency in minutes");
setIsSavingPeriodic(false);
return;
}
} else {
frequency = parseInt(frequencyMinutes, 10);
}
}
await updateConnector({
id: selectedConnectorForPeriodic,
data: {
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: frequency,
},
});
toast.success(
periodicEnabled
? "Periodic indexing enabled successfully"
: "Periodic indexing disabled successfully"
);
setPeriodicDialogOpen(false);
} catch (error) {
console.error("Error updating periodic indexing:", error);
toast.error(error instanceof Error ? error.message : "Failed to update periodic indexing");
} finally {
setIsSavingPeriodic(false);
setSelectedConnectorForPeriodic(null);
}
};
// Format frequency for display
const formatFrequency = (minutes: number): string => {
if (minutes < 60) return `${minutes}m`;
if (minutes < 1440) return `${Math.floor(minutes / 60)}h`;
if (minutes < 10080) return `${Math.floor(minutes / 1440)}d`;
return `${Math.floor(minutes / 10080)}w`;
};
return (
<div className="container mx-auto py-8 px-4 max-w-6xl min-h-[calc(100vh-64px)]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-8 flex items-center justify-between gap-2"
>
<div>
<h1 className="text-xl md:text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-xs md:text-base text-muted-foreground mt-2">{t("subtitle")}</p>
</div>
<Button
className="h-8 text-xs px-3 md:h-10 md:text-sm md:px-4"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<Plus className="mr-2 h-3 w-3 md:h-4 md:w-4" />
{t("add_connector")}
</Button>
</motion.div>
<Card>
<CardHeader className="pb-3">
<CardTitle>{t("your_connectors")}</CardTitle>
<CardDescription>{t("view_manage")}</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<div className="animate-pulse text-center">
<div className="h-6 w-32 bg-muted rounded mx-auto mb-2"></div>
<div className="h-4 w-48 bg-muted rounded mx-auto"></div>
</div>
</div>
) : connectors.length === 0 ? (
<div className="text-center py-12">
<h3 className="text-lg font-medium mb-2">{t("no_connectors")}</h3>
<p className="text-muted-foreground mb-6">{t("no_connectors_desc")}</p>
<Button onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}>
<Plus className="mr-2 h-4 w-4" />
{t("add_first")}
</Button>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("last_indexed")}</TableHead>
<TableHead>{t("periodic")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connectors.map((connector) => (
<TableRow key={connector.id}>
<TableCell className="font-medium">{connector.name}</TableCell>
<TableCell>{getConnectorIcon(connector.connector_type)}</TableCell>
<TableCell>
{connector.is_indexable
? formatDateTime(connector.last_indexed_at)
: t("not_indexable")}
</TableCell>
<TableCell>
{connector.is_indexable ? (
connector.periodic_indexing_enabled ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-green-600 dark:text-green-400">
<Clock className="h-4 w-4" />
<span className="text-sm font-medium">
{connector.indexing_frequency_minutes
? formatFrequency(connector.indexing_frequency_minutes)
: "Enabled"}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Runs every {connector.indexing_frequency_minutes} minutes
{connector.next_scheduled_at && (
<>
<br />
Next: {formatDateTime(connector.next_scheduled_at)}
</>
)}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="text-sm text-muted-foreground">Disabled</span>
)
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{connector.is_indexable && (
<div className="flex gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenDatePicker(connector.id)}
disabled={indexingConnectorId === connector.id}
>
{indexingConnectorId === connector.id ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<CalendarIcon className="h-4 w-4" />
)}
<span className="sr-only">{t("index_date_range")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("index_date_range")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleQuickIndexConnector(connector.id)}
disabled={indexingConnectorId === connector.id}
>
{indexingConnectorId === connector.id ? (
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span className="sr-only">{t("quick_index")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("quick_index_auto")}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{connector.is_indexable && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenPeriodicDialog(connector.id)}
>
<Clock className="h-4 w-4" />
<span className="sr-only">Configure Periodic Indexing</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Configure Periodic Indexing</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Button
variant="outline"
size="sm"
onClick={() =>
router.push(
`/dashboard/${searchSpaceId}/connectors/${connector.id}/edit`
)
}
>
<Edit className="h-4 w-4" />
<span className="sr-only">{tCommon("edit")}</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive-foreground hover:bg-destructive/10"
onClick={() => setConnectorToDelete(connector.id)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">{tCommon("delete")}</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("delete_connector")}</AlertDialogTitle>
<AlertDialogDescription>
{t("delete_confirm")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConnectorToDelete(null)}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDeleteConnector}
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
{/* Date Picker Dialog */}
<Dialog open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{t("select_date_range")}</DialogTitle>
<DialogDescription>{t("select_date_range_desc")}</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="start-date">{t("start_date")}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
id="start-date"
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "PPP") : t("pick_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={setStartDate}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">{t("end_date")}</Label>
<Popover>
<PopoverTrigger asChild>
<Button
id="end-date"
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!endDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "PPP") : t("pick_date")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar mode="single" selected={endDate} onSelect={setEndDate} initialFocus />
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setStartDate(undefined);
setEndDate(undefined);
}}
>
{t("clear_dates")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
setStartDate(thirtyDaysAgo);
setEndDate(today);
}}
>
{t("last_30_days")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const yearAgo = new Date(today);
yearAgo.setFullYear(today.getFullYear() - 1);
setStartDate(yearAgo);
setEndDate(today);
}}
>
{t("last_year")}
</Button>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDatePickerOpen(false);
setSelectedConnectorForIndexing(null);
setStartDate(undefined);
setEndDate(undefined);
}}
>
{tCommon("cancel")}
</Button>
<Button onClick={handleIndexConnector}>{t("start_indexing")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Periodic Indexing Configuration Dialog */}
<Dialog open={periodicDialogOpen} onOpenChange={setPeriodicDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Configure Periodic Indexing</DialogTitle>
<DialogDescription>
Set up automatic indexing at regular intervals for this connector.
</DialogDescription>
</DialogHeader>
<div className="grid gap-6 py-4">
<div className="flex items-center justify-between space-x-2">
<div className="space-y-0.5">
<Label htmlFor="periodic-enabled" className="text-base">
Enable Periodic Indexing
</Label>
<p className="text-sm text-muted-foreground">
Automatically index this connector at regular intervals
</p>
</div>
<Switch
id="periodic-enabled"
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
/>
</div>
{periodicEnabled && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="frequency">Indexing Frequency</Label>
<Select value={frequencyMinutes} onValueChange={setFrequencyMinutes}>
<SelectTrigger id="frequency">
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="15">Every 15 minutes</SelectItem>
<SelectItem value="60">Every hour</SelectItem>
<SelectItem value="360">Every 6 hours</SelectItem>
<SelectItem value="720">Every 12 hours</SelectItem>
<SelectItem value="1440">Daily (24 hours)</SelectItem>
<SelectItem value="10080">Weekly (7 days)</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
{frequencyMinutes === "custom" && (
<div className="space-y-2">
<Label htmlFor="custom-frequency">Custom Frequency (minutes)</Label>
<Input
id="custom-frequency"
type="number"
min="1"
placeholder="Enter minutes"
value={customFrequency}
onChange={(e) => setCustomFrequency(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Enter the number of minutes between each indexing run
</p>
</div>
)}
<div className="rounded-lg bg-muted p-3 text-sm">
<p className="font-medium mb-1">Preview:</p>
<p className="text-muted-foreground">
{frequencyMinutes === "custom" && customFrequency
? `Will run every ${customFrequency} minutes`
: frequencyMinutes === "15"
? "Will run every 15 minutes"
: frequencyMinutes === "60"
? "Will run every hour"
: frequencyMinutes === "360"
? "Will run every 6 hours"
: frequencyMinutes === "720"
? "Will run every 12 hours"
: frequencyMinutes === "1440"
? "Will run daily (every 24 hours)"
: frequencyMinutes === "10080"
? "Will run weekly (every 7 days)"
: "Select a frequency above"}
</p>
</div>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setPeriodicDialogOpen(false);
setSelectedConnectorForPeriodic(null);
}}
>
Cancel
</Button>
<Button onClick={handleSavePeriodicIndexing} disabled={isSavingPeriodic}>
{isSavingPeriodic && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save Configuration
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,336 +0,0 @@
"use client";
import { ArrowLeft, Check, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
import { EditConnectorLoadingSkeleton } from "@/components/editConnector/EditConnectorLoadingSkeleton";
import { EditConnectorNameForm } from "@/components/editConnector/EditConnectorNameForm";
import { EditGitHubConnectorConfig } from "@/components/editConnector/EditGitHubConnectorConfig";
import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenForm";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useConnectorEditPage } from "@/hooks/use-connector-edit-page";
// Import Utils, Types, Hook, and Components
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
export default function EditConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
// Ensure connectorId is parsed safely
const connectorIdParam = params.connector_id as string;
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
// Use the custom hook to manage state and logic
const {
connectorsLoading,
connector,
isSaving,
editForm,
patForm, // Needed for GitHub child component
handleSaveChanges,
// GitHub specific props for the child component
editMode,
setEditMode, // Pass down if needed by GitHub component
originalPat,
currentSelectedRepos,
fetchedRepos,
setFetchedRepos,
newSelectedRepos,
setNewSelectedRepos,
isFetchingRepos,
handleFetchRepositories,
handleRepoSelectionChange,
} = useConnectorEditPage(connectorId, searchSpaceId);
// Redirect if connectorId is not a valid number after parsing
useEffect(() => {
if (Number.isNaN(connectorId)) {
toast.error("Invalid Connector ID.");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}, [connectorId, router, searchSpaceId]);
// Loading State
if (connectorsLoading || !connector) {
// Handle NaN case before showing skeleton
if (Number.isNaN(connectorId)) return null;
return <EditConnectorLoadingSkeleton />;
}
// Main Render using data/handlers from the hook
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2">
{getConnectorIcon(connector.connector_type)}
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
</CardTitle>
<CardDescription>Modify connector name and configuration.</CardDescription>
</CardHeader>
<Form {...editForm}>
{/* Pass hook's handleSaveChanges */}
<form onSubmit={editForm.handleSubmit(handleSaveChanges)} className="space-y-6">
<CardContent className="space-y-6">
{/* Pass form control from hook */}
<EditConnectorNameForm control={editForm.control} />
<hr />
<h3 className="text-lg font-semibold">Configuration</h3>
{/* == GitHub == */}
{connector.connector_type === "GITHUB_CONNECTOR" && (
<EditGitHubConnectorConfig
// Pass relevant state and handlers from hook
editMode={editMode}
setEditMode={setEditMode} // Pass setter if child manages mode
originalPat={originalPat}
currentSelectedRepos={currentSelectedRepos}
fetchedRepos={fetchedRepos}
newSelectedRepos={newSelectedRepos}
isFetchingRepos={isFetchingRepos}
patForm={patForm}
handleFetchRepositories={handleFetchRepositories}
handleRepoSelectionChange={handleRepoSelectionChange}
setNewSelectedRepos={setNewSelectedRepos}
setFetchedRepos={setFetchedRepos}
/>
)}
{/* == Slack == */}
{connector.connector_type === "SLACK_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="SLACK_BOT_TOKEN"
fieldLabel="Slack Bot Token"
fieldDescription="Update the Slack Bot Token if needed."
placeholder="Begins with xoxb-..."
/>
)}
{/* == Notion == */}
{connector.connector_type === "NOTION_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="NOTION_INTEGRATION_TOKEN"
fieldLabel="Notion Integration Token"
fieldDescription="Update the Notion Integration Token if needed."
placeholder="Begins with secret_..."
/>
)}
{/* == Tavily == */}
{connector.connector_type === "TAVILY_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="TAVILY_API_KEY"
fieldLabel="Tavily API Key"
fieldDescription="Update the Tavily API Key if needed."
/>
)}
{/* == Linear == */}
{connector.connector_type === "LINEAR_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LINEAR_API_KEY"
fieldLabel="Linear API Key"
fieldDescription="Update your Linear API Key if needed."
placeholder="Begins with lin_api_..."
/>
)}
{/* == Jira == */}
{connector.connector_type === "JIRA_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_BASE_URL"
fieldLabel="Jira Base URL"
fieldDescription="Update your Jira instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_EMAIL"
fieldLabel="Jira Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_API_TOKEN"
fieldLabel="Jira API Token"
fieldDescription="Update your Jira API Token if needed."
placeholder="Your Jira API Token"
/>
</div>
)}
{/* == Confluence == */}
{connector.connector_type === "CONFLUENCE_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_BASE_URL"
fieldLabel="Confluence Base URL"
fieldDescription="Update your Confluence instance URL if needed."
placeholder="https://yourcompany.atlassian.net"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_EMAIL"
fieldLabel="Confluence Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="CONFLUENCE_API_TOKEN"
fieldLabel="Confluence API Token"
fieldDescription="Update your Confluence API Token if needed."
placeholder="Your Confluence API Token"
/>
</div>
)}
{/* == ClickUp == */}
{connector.connector_type === "CLICKUP_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="CLICKUP_API_TOKEN"
fieldLabel="ClickUp API Token"
fieldDescription="Update your ClickUp API Token if needed."
placeholder="pk_..."
/>
)}
{/* == Linkup == */}
{connector.connector_type === "LINKUP_API" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LINKUP_API_KEY"
fieldLabel="Linkup API Key"
fieldDescription="Update your Linkup API Key if needed."
placeholder="Begins with linkup_..."
/>
)}
{/* == Discord == */}
{connector.connector_type === "DISCORD_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="DISCORD_BOT_TOKEN"
fieldLabel="Discord Bot Token"
fieldDescription="Update the Discord Bot Token if needed."
placeholder="Bot token..."
/>
)}
{/* == Luma == */}
{connector.connector_type === "LUMA_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="LUMA_API_KEY"
fieldLabel="Luma API Key"
fieldDescription="Update the Luma API Key if needed."
placeholder="API Key..."
/>
)}
{/* == Elasticsearch == */}
{connector.connector_type === "ELASTICSEARCH_CONNECTOR" && (
<EditSimpleTokenForm
control={editForm.control}
fieldName="ELASTICSEARCH_API_KEY"
fieldLabel="Elasticsearch API Key"
fieldDescription="Update your Elasticsearch API Key if needed."
placeholder="Your Elasticsearch API Key"
/>
)}
{/* == Webcrawler == */}
{connector.connector_type === "WEBCRAWLER_CONNECTOR" && (
<div className="space-y-4">
<EditSimpleTokenForm
control={editForm.control}
fieldName="FIRECRAWL_API_KEY"
fieldLabel="Firecrawl API Key (Optional)"
fieldDescription="Add a Firecrawl API key for enhanced crawling capabilities. If not provided, will use AsyncChromiumLoader as fallback."
placeholder="fc-xxxxxxxxxxxxx"
/>
<FormField
control={editForm.control}
name="INITIAL_URLS"
render={({ field }) => (
<FormItem>
<FormLabel>URLs to Crawl</FormLabel>
<FormControl>
<Textarea
placeholder="https://example.com&#10;https://docs.example.com&#10;https://blog.example.com"
className="min-h-[150px] font-mono text-sm"
{...field}
/>
</FormControl>
<FormDescription>
Enter URLs to crawl (one per line). These URLs will be indexed when you
trigger indexing.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</CardContent>
<CardFooter className="border-t pt-6">
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Check className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</CardFooter>
</form>
</Form>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,301 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { updateConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import type { EnumConnectorName } from "@/contracts/enums/connector";
import type { SearchSourceConnector } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const apiConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
// Helper function to get connector type display name
const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = {
TAVILY_API: "Tavily API",
SLACK_CONNECTOR: "Slack Connector",
NOTION_CONNECTOR: "Notion Connector",
GITHUB_CONNECTOR: "GitHub Connector",
LINEAR_CONNECTOR: "Linear Connector",
JIRA_CONNECTOR: "Jira Connector",
DISCORD_CONNECTOR: "Discord Connector",
LINKUP_API: "Linkup",
CONFLUENCE_CONNECTOR: "Confluence Connector",
CLICKUP_CONNECTOR: "ClickUp Connector",
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Connector",
GOOGLE_GMAIL_CONNECTOR: "Google Gmail Connector",
AIRTABLE_CONNECTOR: "Airtable Connector",
LUMA_CONNECTOR: "Luma Connector",
ELASTICSEARCH_CONNECTOR: "Elasticsearch Connector",
WEBCRAWLER_CONNECTOR: "Web Page Connector",
// Add other connector types here as needed
};
return typeMap[type] || type;
};
// Define the type for the form values
type ApiConnectorFormValues = z.infer<typeof apiConnectorFormSchema>;
// Get API key field name based on connector type
const getApiKeyFieldName = (connectorType: string): string => {
const fieldMap: Record<string, string> = {
TAVILY_API: "TAVILY_API_KEY",
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
GITHUB_CONNECTOR: "GITHUB_PAT",
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
LINKUP_API: "LINKUP_API_KEY",
LUMA_CONNECTOR: "LUMA_API_KEY",
ELASTICSEARCH_CONNECTOR: "ELASTICSEARCH_API_KEY",
WEBCRAWLER_CONNECTOR: "FIRECRAWL_API_KEY",
};
return fieldMap[connectorType] || "";
};
export default function EditConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const connectorId = parseInt(params.connector_id as string, 10);
const { data: connectors = [] } = useAtomValue(connectorsAtom);
const { mutateAsync: updateConnector } = useAtomValue(updateConnectorMutationAtom);
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
// console.log("connector", connector);
// Initialize the form
const form = useForm<ApiConnectorFormValues>({
resolver: zodResolver(apiConnectorFormSchema),
defaultValues: {
name: "",
api_key: "",
},
});
useEffect(() => {
const currentConnector = connectors.find((c) => c.id === connectorId);
if (currentConnector) {
setConnector(currentConnector);
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
if (apiKeyField) {
form.reset({
name: currentConnector.name,
api_key: currentConnector.config[apiKeyField] || "",
});
} else {
toast.error("This connector type is not supported for editing");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
setIsLoading(false);
} else if (!isLoading && connectors.length > 0) {
toast.error("Connector not found");
router.push(`/dashboard/${searchSpaceId}/connectors`);
}
}, [connectors, connectorId, form, router, searchSpaceId, isLoading]);
// Handle form submission
const onSubmit = async (values: ApiConnectorFormValues) => {
if (!connector) return;
setIsSubmitting(true);
try {
const apiKeyField = getApiKeyFieldName(connector.connector_type);
const updatedConfig = { ...connector.config };
if (values.api_key) {
updatedConfig[apiKeyField] = values.api_key;
}
await updateConnector({
id: connectorId,
data: {
name: values.name,
connector_type: connector.connector_type as EnumConnectorName,
config: updatedConfig,
is_indexable: connector.is_indexable,
last_indexed_at: connector.last_indexed_at,
},
});
toast.success("Connector updated successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error updating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to update connector");
} finally {
setIsSubmitting(false);
}
};
if (isLoading) {
return (
<div className="container mx-auto py-8 max-w-3xl flex justify-center items-center min-h-[60vh]">
<div className="animate-pulse text-center">
<div className="h-8 w-48 bg-muted rounded mx-auto mb-4"></div>
<div className="h-4 w-64 bg-muted rounded mx-auto"></div>
</div>
</div>
);
}
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
</CardTitle>
<CardDescription>Update your connector settings.</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>API Key Security</AlertTitle>
<AlertDescription>
Your API key is stored securely. For security reasons, we don't display your
existing API key. If you don't update the API key field, your existing key will be
preserved.
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My API Connector" {...field} />
</FormControl>
<FormDescription>A friendly name to identify this connector.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>
{connector?.connector_type === "SLACK_CONNECTOR"
? "Slack Bot Token"
: connector?.connector_type === "NOTION_CONNECTOR"
? "Notion Integration Token"
: connector?.connector_type === "GITHUB_CONNECTOR"
? "GitHub Personal Access Token (PAT)"
: connector?.connector_type === "LINKUP_API"
? "Linkup API Key"
: connector?.connector_type === "ELASTICSEARCH_CONNECTOR"
? "Elasticsearch API Key"
: "API Key"}
</FormLabel>
<FormControl>
<Input
type="password"
placeholder={
connector?.connector_type === "SLACK_CONNECTOR"
? "Enter new Slack Bot Token (optional)"
: connector?.connector_type === "NOTION_CONNECTOR"
? "Enter new Notion Token (optional)"
: connector?.connector_type === "GITHUB_CONNECTOR"
? "Enter new GitHub PAT (optional)"
: connector?.connector_type === "LINKUP_API"
? "Enter new Linkup API Key (optional)"
: connector?.connector_type === "ELASTICSEARCH_CONNECTOR"
? "Enter new Elasticsearch API Key (optional)"
: "Enter new API key (optional)"
}
{...field}
/>
</FormControl>
<FormDescription>
{connector?.connector_type === "SLACK_CONNECTOR"
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
: connector?.connector_type === "NOTION_CONNECTOR"
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
: connector?.connector_type === "GITHUB_CONNECTOR"
? "Enter a new GitHub PAT or leave blank to keep your existing token."
: connector?.connector_type === "LINKUP_API"
? "Enter a new Linkup API Key or leave blank to keep your existing key."
: connector?.connector_type === "ELASTICSEARCH_CONNECTOR"
? "Enter a new Elasticsearch API Key or leave blank to keep your existing key."
: "Enter a new API key or leave blank to keep your existing key."}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Update Connector
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,181 +0,0 @@
"use client";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EnumConnectorName } from "@/contracts/enums/connector";
// import { IconBrandAirtable } from "@tabler/icons-react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
export default function AirtableConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isConnecting, setIsConnecting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {
const connectors = data.data || [];
const connector = connectors.find(
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.AIRTABLE_CONNECTOR
);
if (connector) {
setDoesConnectorExist(true);
}
});
}, []);
const handleConnectAirtable = async () => {
setIsConnecting(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/airtable/connector/add/?space_id=${searchSpaceId}`,
{ method: "GET" }
);
if (!response.ok) {
throw new Error("Failed to initiate Airtable OAuth");
}
const data = await response.json();
// Redirect to Airtable for authentication
window.location.href = data.auth_url;
} catch (error) {
console.error("Error connecting to Airtable:", error);
toast.error("Failed to connect to Airtable");
} finally {
setIsConnecting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="mb-8">
<Link
href={`/dashboard/${searchSpaceId}/connectors/add`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to connectors
</Link>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Airtable</h1>
<p className="text-muted-foreground">Connect your Airtable to search records.</p>
</div>
</div>
</div>
{/* OAuth Connection Card */}
{!doesConnectorExist ? (
<Card>
<CardHeader>
<CardTitle>Connect Your Airtable Account</CardTitle>
<CardDescription>
Connect your Airtable account to access your records. We'll only request read-only
access to your records.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Read-only access to your records</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Access works even when you're offline</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>You can disconnect anytime</span>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
Cancel
</Button>
<Button onClick={handleConnectAirtable} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<ExternalLink className="mr-2 h-4 w-4" />
Connect Your Airtable Account
</>
)}
</Button>
</CardFooter>
</Card>
) : (
/* Configuration Form Card */
<Card>
<CardHeader>
<CardTitle> Your Airtable is successfully connected!</CardTitle>
</CardHeader>
</Card>
)}
{/* Help Section */}
{!doesConnectorExist && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">How It Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">1. Connect Your Account</h4>
<p className="text-sm text-muted-foreground">
Click "Connect Your Airtable Account" to start the secure OAuth process. You'll be
redirected to Airtable to sign in.
</p>
</div>
<div>
<h4 className="font-medium mb-2">2. Grant Permissions</h4>
<p className="text-sm text-muted-foreground">
Airtable will ask for permission to read your records. We only request read-only
access to keep your data safe.
</p>
</div>
</CardContent>
</Card>
)}
</motion.div>
</div>
);
}

View file

@ -1,323 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const baiduSearchApiFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
model: z.string().optional(),
search_source: z.enum(["baidu_search_v1", "baidu_search_v2"]).optional(),
enable_deep_search: z.boolean().default(false),
});
// Define the type for the form values
type BaiduSearchApiFormValues = z.infer<typeof baiduSearchApiFormSchema>;
export default function BaiduSearchApiPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<BaiduSearchApiFormValues>({
resolver: zodResolver(baiduSearchApiFormSchema),
defaultValues: {
name: "Baidu Search Connector",
api_key: "",
model: "ernie-3.5-8k",
search_source: "baidu_search_v2",
enable_deep_search: false,
},
});
// Handle form submission
const onSubmit = async (values: BaiduSearchApiFormValues) => {
setIsSubmitting(true);
try {
// Build config object
const config: Record<string, unknown> = {
BAIDU_API_KEY: values.api_key,
};
// Add optional parameters if provided
if (values.model) {
config.BAIDU_MODEL = values.model;
}
if (values.search_source) {
config.BAIDU_SEARCH_SOURCE = values.search_source;
}
if (values.enable_deep_search !== undefined) {
config.BAIDU_ENABLE_DEEP_SEARCH = values.enable_deep_search;
}
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.BAIDU_SEARCH_API,
config,
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Baidu Search connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.BAIDU_SEARCH_API, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Baidu Search</h1>
<p className="text-muted-foreground">
Connect Baidu AI Search for intelligent Chinese web search capabilities.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Baidu Search</CardTitle>
<CardDescription>
Integrate with Baidu AI Search to enhance your search capabilities with intelligent
Chinese web search results.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
You'll need a Baidu AppBuilder API key to use this connector. You can get one by
signing up at{" "}
<a
href="https://qianfan.cloud.baidu.com/"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
qianfan.cloud.baidu.com
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Baidu Search Connector" {...field} />
</FormControl>
<FormDescription>A friendly name to identify this connector.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Baidu AppBuilder API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter your Baidu API key" {...field} />
</FormControl>
<FormDescription>
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model (Optional)</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="ernie-3.5-8k">ERNIE 3.5 8K</SelectItem>
<SelectItem value="ernie-4.5-turbo-32k">ERNIE 4.5 Turbo 32K</SelectItem>
<SelectItem value="ernie-4.5-turbo-128k">ERNIE 4.5 Turbo 128K</SelectItem>
<SelectItem value="deepseek-v3">DeepSeek V3</SelectItem>
<SelectItem value="qwen3-235b-a22b-instruct-2507">Qwen3 235B</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The language model used for search summarization. Default: ERNIE 3.5 8K.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="search_source"
render={({ field }) => (
<FormItem>
<FormLabel>Search Source (Optional)</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select search source" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="baidu_search_v1">Baidu Search V1</SelectItem>
<SelectItem value="baidu_search_v2">
Baidu Search V2 (Recommended)
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
V2 provides better performance and richer content. Default: V2.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enable_deep_search"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Enable Deep Search</FormLabel>
<FormDescription>
Deep search retrieves up to 100 results per type (may incur additional
costs).
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Baidu Search
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Baidu Search:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Intelligent search tailored for Chinese web content</li>
<li>Real-time information from Baidu's search index</li>
<li>AI-powered summarization with source references</li>
<li>Support for web, video, and image search results</li>
</ul>
</CardFooter>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,306 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const bookstackConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z.string().url({
message: "Please enter a valid BookStack URL (e.g., https://docs.example.com)",
}),
token_id: z.string().min(10, {
message: "BookStack Token ID is required.",
}),
token_secret: z.string().min(10, {
message: "BookStack Token Secret is required.",
}),
});
// Define the type for the form values
type BookStackConnectorFormValues = z.infer<typeof bookstackConnectorFormSchema>;
export default function BookStackConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<BookStackConnectorFormValues>({
resolver: zodResolver(bookstackConnectorFormSchema),
defaultValues: {
name: "BookStack Connector",
base_url: "",
token_id: "",
token_secret: "",
},
});
// Handle form submission
const onSubmit = async (values: BookStackConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.BOOKSTACK_CONNECTOR,
config: {
BOOKSTACK_BASE_URL: values.base_url,
BOOKSTACK_TOKEN_ID: values.token_id,
BOOKSTACK_TOKEN_SECRET: values.token_secret,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("BookStack connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.BOOKSTACK_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect BookStack</h1>
<p className="text-muted-foreground">
Connect your BookStack instance to search wiki pages.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card>
<CardHeader>
<CardTitle>Connect to BookStack</CardTitle>
<CardDescription>
Connect your BookStack instance to index pages from your wiki.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
You'll need to create an API token from your BookStack instance. Go to{" "}
<strong>Edit Profile API Tokens Create Token</strong>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My BookStack Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>BookStack Instance URL</FormLabel>
<FormControl>
<Input placeholder="https://docs.example.com" {...field} />
</FormControl>
<FormDescription>
Your BookStack instance URL (e.g., https://wiki.yourcompany.com)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token_id"
render={({ field }) => (
<FormItem>
<FormLabel>Token ID</FormLabel>
<FormControl>
<Input placeholder="Your BookStack Token ID" {...field} />
</FormControl>
<FormDescription>
The Token ID from your BookStack API token.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token_secret"
render={({ field }) => (
<FormItem>
<FormLabel>Token Secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your BookStack Token Secret"
{...field}
/>
</FormControl>
<FormDescription>
Your Token Secret will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect BookStack
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card>
<CardHeader>
<CardTitle>BookStack Integration Guide</CardTitle>
<CardDescription>
Learn how to set up and use the BookStack connector.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>All pages from your BookStack instance</li>
<li>Page content in Markdown format</li>
<li>Page titles and metadata</li>
<li>Book and chapter hierarchy information</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>Log in to your BookStack instance</li>
<li>Click on your profile icon Edit Profile</li>
<li>Navigate to the "API Tokens" tab</li>
<li>Click "Create Token" and give it a name</li>
<li>Copy both the Token ID and Token Secret</li>
<li>Paste them in the form above</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Your user account must have "Access System API" permission</li>
<li>Read access to books and pages you want to index</li>
<li>The connector will only index content your account can view</li>
</ul>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
BookStack API has a rate limit of 180 requests per minute. The connector
automatically handles rate limiting to ensure reliable indexing.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,241 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, ExternalLink, Eye, EyeOff } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const clickupConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_token: z.string().min(10, {
message: "ClickUp API Token is required and must be valid.",
}),
});
// Define the type for the form values
type ClickUpConnectorFormValues = z.infer<typeof clickupConnectorFormSchema>;
export default function ClickUpConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
const [isLoading, setIsLoading] = useState(false);
const [showApiToken, setShowApiToken] = useState(false);
// Initialize the form with react-hook-form and zod validation
const form = useForm<ClickUpConnectorFormValues>({
resolver: zodResolver(clickupConnectorFormSchema),
defaultValues: {
name: "ClickUp Connector",
api_token: "",
},
});
// Handle form submission
async function onSubmit(values: ClickUpConnectorFormValues) {
setIsLoading(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
is_indexable: true,
config: {
CLICKUP_API_TOKEN: values.api_token,
},
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("ClickUp connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating ClickUp connector:", error);
toast.error("Failed to create ClickUp connector. Please try again.");
} finally {
setIsLoading(false);
}
}
return (
<div className="container mx-auto py-6 max-w-2xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.CLICKUP_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect ClickUp</h1>
<p className="text-muted-foreground">
Connect your ClickUp workspace to search tasks and projects.
</p>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>ClickUp Configuration</CardTitle>
<CardDescription>
Enter your ClickUp API token to connect your workspace. You can generate a personal API
token from your ClickUp settings.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="ClickUp Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this ClickUp connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>ClickUp API Token</FormLabel>
<FormControl>
<div className="relative">
<Input
type={showApiToken ? "text" : "password"}
placeholder="pk_..."
{...field}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowApiToken(!showApiToken)}
>
{showApiToken ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
</FormControl>
<FormDescription>
Your ClickUp personal API token. You can generate one in your{" "}
<Link
href="https://app.clickup.com/settings/apps"
target="_blank"
className="text-primary hover:underline inline-flex items-center"
>
ClickUp settings
<ExternalLink className="ml-1 h-3 w-3" />
</Link>
.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isLoading}
>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Connector"}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">How to get your ClickUp API Token</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">1. Log in to your ClickUp account</p>
<p className="text-sm text-muted-foreground">
2. Click your avatar in the upper-right corner and select "Settings"
</p>
<p className="text-sm text-muted-foreground">3. In the sidebar, click "Apps"</p>
<p className="text-sm text-muted-foreground">
4. Under "API Token", click "Generate" or "Regenerate"
</p>
<p className="text-sm text-muted-foreground">
5. Copy the generated token and paste it above
</p>
</div>
<div className="mt-4">
<Link
href="https://app.clickup.com/settings/apps"
target="_blank"
className="inline-flex items-center text-sm text-primary hover:underline"
>
Go to ClickUp API Settings
<ExternalLink className="ml-1 h-3 w-3" />
</Link>
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -1,322 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const confluenceConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message: "Please enter a valid Confluence URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("confluence");
},
{
message: "Please enter a valid Confluence instance URL",
}
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Confluence API Token is required and must be valid.",
}),
});
// Define the type for the form values
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
export default function ConfluenceConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<ConfluenceConnectorFormValues>({
resolver: zodResolver(confluenceConnectorFormSchema),
defaultValues: {
name: "Confluence Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Handle form submission
const onSubmit = async (values: ConfluenceConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.CONFLUENCE_CONNECTOR,
config: {
CONFLUENCE_BASE_URL: values.base_url,
CONFLUENCE_EMAIL: values.email,
CONFLUENCE_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Confluence connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.CONFLUENCE_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Confluence</h1>
<p className="text-muted-foreground">
Connect your Confluence instance to search pages and spaces.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card>
<CardHeader>
<CardTitle>Connect to Confluence</CardTitle>
<CardDescription>
Connect your Confluence instance to index pages and comments from your spaces.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
You'll need to create an API token from your{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Confluence Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Confluence Instance URL</FormLabel>
<FormControl>
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
</FormControl>
<FormDescription>
Your Confluence instance URL. For Atlassian Cloud, this is typically
https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" placeholder="your.email@company.com" {...field} />
</FormControl>
<FormDescription>Your Atlassian account email address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Confluence API Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Confluence API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Confluence
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card>
<CardHeader>
<CardTitle>Confluence Integration Guide</CardTitle>
<CardDescription>
Learn how to set up and use the Confluence connector.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">What gets indexed?</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>All pages from accessible spaces</li>
<li>Page content and metadata</li>
<li>Comments on pages (both footer and inline comments)</li>
<li>Page titles and descriptions</li>
</ul>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Setup Instructions</h3>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>Go to your Atlassian Account Settings</li>
<li>Navigate to Security API tokens</li>
<li>Create a new API token with appropriate permissions</li>
<li>Copy the token and paste it in the form above</li>
<li>Ensure your account has read access to the spaces you want to index</li>
</ol>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Permissions Required</h3>
<ul className="list-disc list-inside space-y-2 text-sm text-muted-foreground">
<li>Read access to Confluence spaces</li>
<li>View pages and comments</li>
<li>Access to space metadata</li>
</ul>
</div>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
The connector will only index content that your account has permission to view.
Make sure your API token has the necessary permissions for the spaces you want
to index.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,371 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const discordConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z
.string()
.min(50, { message: "Discord Bot Token appears to be too short." })
.regex(/^[A-Za-z0-9._-]+$/, { message: "Discord Bot Token contains invalid characters." }),
});
// Define the type for the form values
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
export default function DiscordConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<DiscordConnectorFormValues>({
resolver: zodResolver(discordConnectorFormSchema),
defaultValues: {
name: "Discord Connector",
bot_token: "",
},
});
// Handle form submission
const onSubmit = async (values: DiscordConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.DISCORD_CONNECTOR,
config: {
DISCORD_BOT_TOKEN: values.bot_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Discord connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg ">
{getConnectorIcon(EnumConnectorName.DISCORD_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Discord</h1>
<p className="text-muted-foreground">
Connect your Discord server to search messages and channels.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Discord Server</CardTitle>
<CardDescription>
Integrate with Discord to search and retrieve information from your servers and
channels. This connector can index your Discord messages for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Bot Token Required</AlertTitle>
<AlertDescription>
You'll need a Discord Bot Token to use this connector. You can create a Discord
bot and get the token from the{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Discord Developer Portal
</a>
.
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Discord Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>Discord Bot Token</FormLabel>
<FormControl>
<Input type="password" placeholder="Bot Token..." {...field} />
</FormControl>
<FormDescription>
Your Discord Bot Token will be encrypted and stored securely. You can
find it in the Bot section of your application in the Discord Developer
Portal.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Discord
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Discord integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through your Discord servers and channels</li>
<li>Access historical messages and shared files</li>
<li>Connect your team's knowledge directly to your search space</li>
<li>Keep your search results up-to-date with latest communications</li>
<li>Index your Discord messages for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Discord Connector Documentation
</CardTitle>
<CardDescription>
Learn how to set up and use the Discord connector to index your server data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Discord connector indexes all accessible channels for a given bot in your
servers.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>Upcoming: Support for private channels by granting the bot access.</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Bot Setup Required</AlertTitle>
<AlertDescription>
You must create a Discord bot and add it to your server with the correct
permissions.
</AlertDescription>
</Alert>
<ol className="list-decimal pl-5 space-y-3">
<li>
Go to{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://discord.com/developers/applications
</a>
.
</li>
<li>Create a new application and add a bot to it.</li>
<li>Copy the Bot Token from the Bot section.</li>
<li>
Invite the bot to your server with the following OAuth2 scopes and
permissions:
<ul className="list-disc pl-5 mt-1">
<li>
Scopes: <code>bot</code>
</li>
<li>
Bot Permissions: <code>Read Messages/View Channels</code>,{" "}
<code>Read Message History</code>, <code>Send Messages</code>
</li>
</ul>
</li>
<li>Paste the Bot Token above to connect.</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the{" "}
<strong>Discord</strong> Connector.
</li>
<li>
Place the <strong>Bot Token</strong> under{" "}
<strong>Step 1 Provide Credentials</strong>.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Important: Bot Channel Access</AlertTitle>
<AlertDescription>
After connecting, ensure the bot has access to all channels you want to
index. You may need to adjust channel permissions in Discord.
</AlertDescription>
</Alert>
<Alert className="bg-muted mt-4">
<Info className="h-4 w-4" />
<AlertTitle>First Indexing</AlertTitle>
<AlertDescription>
The first indexing pulls all accessible channels and may take longer than
future updates. Only channels where the bot has access will be indexed.
</AlertDescription>
</Alert>
<div className="mt-4">
<h4 className="font-medium mb-2">Troubleshooting:</h4>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<li>
<strong>Missing messages:</strong> If you don't see messages from a
channel, check the bot's permissions for that channel.
</li>
<li>
<strong>Bot not responding:</strong> Make sure the bot is online and the
token is correct.
</li>
<li>
<strong>Private channels:</strong> The bot must be explicitly granted
access to private channels.
</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,755 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const elasticsearchConnectorFormSchema = z
.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
endpoint_url: z.string().url({ message: "Please enter a valid Elasticsearch endpoint URL." }),
auth_method: z.enum(["basic", "api_key"]).default("api_key"),
username: z.string().optional(),
password: z.string().optional(),
ELASTICSEARCH_API_KEY: z.string().optional(),
indices: z.string().optional(),
query: z.string().default("*"),
search_fields: z.string().optional(),
max_documents: z.number().min(1).max(10000).optional(),
})
.refine(
(data) => {
if (data.auth_method === "basic") {
return Boolean(data.username?.trim() && data.password?.trim());
}
if (data.auth_method === "api_key") {
return Boolean(data.ELASTICSEARCH_API_KEY?.trim());
}
return true;
},
{
message: "Authentication credentials are required for the selected method.",
path: ["auth_method"],
}
);
// Define the type for the form values
type ElasticsearchConnectorFormValues = z.infer<typeof elasticsearchConnectorFormSchema>;
export default function ElasticsearchConnectorPage() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
// match pattern used in other connector pages: prefer route param, fallback to query param
const searchSpaceId = (params.search_space_id ?? searchParams?.get("search_space_id")) as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const authBasicId = useId();
const authApiKeyId = useId();
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<ElasticsearchConnectorFormValues>({
resolver: zodResolver(elasticsearchConnectorFormSchema),
defaultValues: {
name: "Elasticsearch Connector",
endpoint_url: "",
auth_method: "api_key",
username: "",
password: "",
ELASTICSEARCH_API_KEY: "",
indices: "",
query: "*",
search_fields: "",
max_documents: undefined,
},
});
const stringToArray = (str: string): string[] => {
const items = str
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
return Array.from(new Set(items));
};
// Handle form submission
const onSubmit = async (values: ElasticsearchConnectorFormValues) => {
setIsSubmitting(true);
if (!searchSpaceId) {
toast.error(
"Missing search_space_id (route or ?search_space_id=). Provide it in the URL or pick a search space."
);
setIsSubmitting(false);
return;
}
const searchSpaceIdNum = Number(searchSpaceId);
if (!Number.isInteger(searchSpaceIdNum) || searchSpaceIdNum <= 0) {
toast.error("Invalid search_space_id. It must be a positive integer.");
setIsSubmitting(false);
return;
}
try {
// Send full URL to backend (backend expects ELASTICSEARCH_URL)
const config: Record<string, string | number | boolean | string[]> = {
ELASTICSEARCH_URL: values.endpoint_url,
// default to verifying certs; expose fields for CA/verify if UI added later
ELASTICSEARCH_VERIFY_CERTS: true,
};
if (values.auth_method === "basic") {
if (values.username) config.ELASTICSEARCH_USERNAME = values.username;
if (values.password) config.ELASTICSEARCH_PASSWORD = values.password;
} else if (values.auth_method === "api_key") {
if (values.ELASTICSEARCH_API_KEY)
config.ELASTICSEARCH_API_KEY = values.ELASTICSEARCH_API_KEY;
}
const indicesInput = values.indices?.trim() ?? "";
const indicesArr = stringToArray(indicesInput);
config.ELASTICSEARCH_INDEX =
indicesArr.length === 0 ? "*" : indicesArr.length === 1 ? indicesArr[0] : indicesArr;
if (values.query && values.query !== "*") {
config.ELASTICSEARCH_QUERY = values.query;
}
if (values.search_fields?.trim()) {
// config.ELASTICSEARCH_FIELDS = stringToArray(values.search_fields);
const fields = stringToArray(values.search_fields);
config.ELASTICSEARCH_FIELDS = fields;
config.ELASTICSEARCH_CONTENT_FIELDS = fields;
if (fields.includes("title")) {
config.ELASTICSEARCH_TITLE_FIELD = "title";
}
}
if (values.max_documents !== undefined && values.max_documents > 0) {
config.ELASTICSEARCH_MAX_DOCUMENTS = values.max_documents;
}
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
config,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Elasticsearch connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.ELASTICSEARCH_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Elasticsearch</h1>
<p className="text-muted-foreground">
Connect to your Elasticsearch cluster to search and index documents.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Elasticsearch Cluster</CardTitle>
<CardDescription>
Connect to your Elasticsearch instance to search and index documents for enhanced
search capabilities.
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Connector Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Elasticsearch Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Connection Details */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Connection Details</h3>
<FormField
control={form.control}
name="endpoint_url"
render={({ field }) => (
<FormItem>
<FormLabel>Elasticsearch Endpoint URL</FormLabel>
<FormControl>
<Input
type="url"
autoComplete="off"
placeholder="https://your-cluster.es.region.aws.com:443"
{...field}
/>
</FormControl>
<FormDescription>
Enter the complete Elasticsearch endpoint URL. We'll automatically
extract the hostname, port, and SSL settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed URL details */}
{form.watch("endpoint_url") && (
<div className="rounded-lg border bg-muted/50 p-3">
<h4 className="text-sm font-medium mb-2">Parsed Connection Details:</h4>
<div className="text-sm text-muted-foreground space-y-1">
{(() => {
try {
const url = new URL(form.watch("endpoint_url"));
return (
<>
<div>
<strong>Hostname:</strong> {url.hostname}
</div>
<div>
<strong>Port:</strong>{" "}
{url.port || (url.protocol === "https:" ? "443" : "80")}
</div>
<div>
<strong>SSL/TLS:</strong>{" "}
{url.protocol === "https:" ? "Enabled" : "Disabled"}
</div>
</>
);
} catch {
return <div className="text-destructive">Invalid URL format</div>;
}
})()}
</div>
</div>
)}
</div>
{/* Authentication */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Authentication</h3>
<FormField
control={form.control}
name="auth_method"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<RadioGroup.Root
onValueChange={(value) => {
field.onChange(value);
// Clear auth fields when method changes
if (value !== "basic") {
form.setValue("username", "");
form.setValue("password", "");
}
if (value !== "api_key") {
form.setValue("ELASTICSEARCH_API_KEY", "");
}
}}
value={field.value}
className="flex flex-col space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="api_key"
id={authApiKeyId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authApiKeyId}>API Key</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="basic"
id={authBasicId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authBasicId}>Username & Password</Label>
</div>
</RadioGroup.Root>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Basic Auth Fields */}
{form.watch("auth_method") === "basic" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="elastic" autoComplete="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
autoComplete="current-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* API Key Field */}
{form.watch("auth_method") === "api_key" && (
<FormField
control={form.control}
name="ELASTICSEARCH_API_KEY"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your API Key Here"
autoComplete="off"
{...field}
/>
</FormControl>
<FormDescription>
Enter your Elasticsearch API key (base64 encoded). This will be
stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Index Selection */}
<FormField
control={form.control}
name="indices"
render={({ field }) => (
<FormItem>
<FormLabel>Index Selection </FormLabel>
<FormControl>
<Input placeholder="logs-*, documents-*, app-logs" {...field} />
</FormControl>
<FormDescription>
Comma-separated indices to search (e.g., "logs-*, documents-*").
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed indices as badges */}
{form.watch("indices")?.trim() && (
<div className="rounded-lg border bg-muted/50 p-3">
<h4 className="text-sm font-medium mb-2">Selected Indices:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(form.watch("indices") ?? "").map((index) => (
<Badge key={index} variant="secondary" className="text-xs">
{index}
</Badge>
))}
</div>
</div>
)}
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Index Selection Tips</AlertTitle>
<AlertDescription className="mt-2">
<ul className="list-disc pl-4 space-y-1 text-sm">
<li>Use wildcards like "logs-*" to match multiple indices</li>
<li>Separate multiple indices with commas</li>
<li>
Leave empty to search all accessible indices including internal ones
</li>
<li>Choosing specific indices improves search performance</li>
</ul>
</AlertDescription>
</Alert>
</div>
{/* Advanced Configuration */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced">
<AccordionTrigger>Advanced Configuration</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Default Search Query */}
<FormField
control={form.control}
name="query"
render={({ field }) => (
<FormItem>
<FormLabel>
Default Search Query{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="*" {...field} />
</FormControl>
<FormDescription>
Default Elasticsearch query to use for searches. Use "*" to match
all documents.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Form Fields */}
<FormField
control={form.control}
name="search_fields"
render={({ field }) => (
<FormItem>
<FormLabel>
Search Fields{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input placeholder="title, content, description" {...field} />
</FormControl>
<FormDescription>
Comma-separated list of specific fields to search in (e.g.,
"title, content, description"). Leave empty to search all fields.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed search fields as badges */}
{form.watch("search_fields")?.trim() && (
<div className="rounded-lg border bg-muted/50 p-3">
<h4 className="text-sm font-medium mb-2">Search Fields:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(form.watch("search_fields") ?? "").map((field) => (
<Badge key={field} variant="outline" className="text-xs">
{field}
</Badge>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="max_documents"
render={({ field }) => (
<FormItem>
<FormLabel>
Maximum Documents{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1000"
min="1"
max="10000"
{...field}
onChange={(e) =>
field.onChange(
e.target.value === ""
? undefined
: parseInt(e.target.value, 10)
)
}
/>
</FormControl>
<FormDescription>
Maximum number of documents to retrieve per search (1-10,000).
Leave empty to use Elasticsearch's default limit.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
<Separator />
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Elasticsearch
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">
What you get with Elasticsearch integration:
</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search across your indexed documents and logs</li>
<li>Access structured and unstructured data from your cluster</li>
<li>Leverage existing Elasticsearch indices for enhanced search</li>
<li>Real-time search capabilities with powerful query features</li>
<li>Integration with your existing Elasticsearch infrastructure</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Elasticsearch Connector Documentation
</CardTitle>
<CardDescription>
Learn how to set up and use the Elasticsearch connector to search your data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Elasticsearch connector allows you to search and retrieve documents from
your Elasticsearch cluster. Configure connection details, select specific
indices, and set search parameters to make your existing data searchable within
SurfSense.
</p>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="connection">
<AccordionTrigger className="text-lg font-medium">
Connection Setup
</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
<strong>Endpoint URL:</strong> Enter the complete Elasticsearch endpoint
URL (e.g., https://your-cluster.es.region.aws.com:443). We'll
automatically extract hostname, port, and SSL settings.
</li>
<li>
<strong>Authentication:</strong> Choose the appropriate method:
<ul className="list-disc pl-5 mt-1">
<li>
<strong>API Key:</strong> Base64 encoded API key (recommended for
security)
</li>
<li>
<strong>Username/Password:</strong> Basic authentication credentials
</li>
</ul>
</li>
<li>
<strong>Index Selection:</strong> Specify which indices to search using
comma-separated patterns (e.g., "logs-*, documents-*")
</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem value="advanced">
<AccordionTrigger className="text-lg font-medium">
Advanced Configuration
</AccordionTrigger>
<AccordionContent className="space-y-4">
<p className="text-muted-foreground">
Fine-tune your Elasticsearch connector with these optional settings:
</p>
<ul className="list-disc pl-5 space-y-2">
<li>
<strong>Search Fields:</strong> Limit searches to specific fields (e.g.,
"title, content") for better relevance
</li>
<li>
<strong>Default Query:</strong> Set a default Elasticsearch query pattern
</li>
<li>
<strong>Max Documents:</strong> Limit the number of documents returned per
search (1-10,000)
</li>
</ul>
</AccordionContent>
</AccordionItem>
<AccordionItem value="troubleshooting">
<AccordionTrigger className="text-lg font-medium">
Troubleshooting
</AccordionTrigger>
<AccordionContent className="space-y-4">
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">Common Connection Issues:</h4>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<li>
<strong>Connection Refused:</strong> Check hostname and port. Ensure
Elasticsearch is running.
</li>
<li>
<strong>Authentication Failed:</strong> Verify credentials. For API
keys, ensure they have proper permissions.
</li>
<li>
<strong>SSL Errors:</strong> Try disabling SSL for local development
or check certificate validity.
</li>
<li>
<strong>No Indices Found:</strong> Ensure your credentials have
permission to list and read indices.
</li>
</ul>
</div>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Security Note</AlertTitle>
<AlertDescription>
For production environments, use API keys with minimal required
permissions: cluster monitoring and read access to specific indices.
</AlertDescription>
</Alert>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,531 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { authenticatedFetch, redirectToLogin } from "@/lib/auth-utils";
// Define the form schema with Zod for GitHub PAT entry step
const githubPatFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
github_pat: z
.string()
.min(20, {
// Apply min length first
message: "GitHub Personal Access Token seems too short.",
})
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
// Then refine the pattern
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
}),
});
// Define the type for the form values
type GithubPatFormValues = z.infer<typeof githubPatFormSchema>;
// Type for fetched GitHub repositories
interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
url: string;
description: string | null;
last_updated: string | null;
}
export default function GithubConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [step, setStep] = useState<"enter_pat" | "select_repos">("enter_pat");
const [isFetchingRepos, setIsFetchingRepos] = useState(false);
const [isCreatingConnector, setIsCreatingConnector] = useState(false);
const [repositories, setRepositories] = useState<GithubRepo[]>([]);
const [selectedRepos, setSelectedRepos] = useState<string[]>([]);
const [connectorName, setConnectorName] = useState<string>("GitHub Connector");
const [validatedPat, setValidatedPat] = useState<string>(""); // Store the validated PAT
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form for PAT entry
const form = useForm<GithubPatFormValues>({
resolver: zodResolver(githubPatFormSchema),
defaultValues: {
name: connectorName,
github_pat: "",
},
});
// Function to fetch repositories using the new backend endpoint
const fetchRepositories = async (values: GithubPatFormValues) => {
setIsFetchingRepos(true);
setConnectorName(values.name); // Store the name
setValidatedPat(values.github_pat); // Store the PAT temporarily
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/github/repositories`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ github_pat: values.github_pat }),
}
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || `Failed to fetch repositories: ${response.statusText}`);
}
const data: GithubRepo[] = await response.json();
setRepositories(data);
setStep("select_repos"); // Move to the next step
toast.success(`Found ${data.length} repositories.`);
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to fetch repositories. Please check the PAT and try again.";
toast.error(errorMessage);
} finally {
setIsFetchingRepos(false);
}
};
// Handle final connector creation
const handleCreateConnector = async () => {
if (selectedRepos.length === 0) {
toast.warning("Please select at least one repository to index.");
return;
}
setIsCreatingConnector(true);
try {
await createConnector({
data: {
name: connectorName, // Use the stored name
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
config: {
GITHUB_PAT: validatedPat, // Use the stored validated PAT
repo_full_names: selectedRepos, // Add the selected repo names
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("GitHub connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating GitHub connector:", error);
const errorMessage =
error instanceof Error ? error.message : "Failed to create GitHub connector.";
toast.error(errorMessage);
} finally {
setIsCreatingConnector(false);
}
};
// Handle checkbox changes
const handleRepoSelection = (repoFullName: string, checked: boolean) => {
setSelectedRepos((prev) =>
checked ? [...prev, repoFullName] : prev.filter((name) => name !== repoFullName)
);
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => {
if (step === "select_repos") {
// Go back to PAT entry, clear sensitive/fetched data
setStep("enter_pat");
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
// Reset form PAT field, keep name
form.reset({ name: connectorName, github_pat: "" });
} else {
router.push(`/dashboard/${searchSpaceId}/connectors/add`);
}
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
{step === "select_repos" ? "Back to PAT Entry" : "Back to Add Connectors"}
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect GitHub</TabsTrigger>
<TabsTrigger value="documentation">Setup Guide</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2">
{step === "enter_pat" ? (
getConnectorIcon(EnumConnectorName.GITHUB_CONNECTOR, "h-6 w-6")
) : (
<ListChecks className="h-6 w-6" />
)}
{step === "enter_pat" ? "Connect GitHub Account" : "Select Repositories to Index"}
</CardTitle>
<CardDescription>
{step === "enter_pat"
? "Provide a name and GitHub Personal Access Token (PAT) to fetch accessible repositories."
: `Select which repositories you want SurfSense to index for search. Found ${repositories.length} repositories accessible via your PAT.`}
</CardDescription>
</CardHeader>
<Form {...form}>
{step === "enter_pat" && (
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>GitHub Personal Access Token (PAT) Required</AlertTitle>
<AlertDescription>
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
repositories. You can create one from your{" "}
<a
href="https://github.com/settings/personal-access-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub Developer Settings
</a>
. The PAT will be used to fetch repositories and then stored securely to
enable indexing.
</AlertDescription>
</Alert>
<form onSubmit={form.handleSubmit(fetchRepositories)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My GitHub Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this GitHub connection.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="github_pat"
render={({ field }) => (
<FormItem>
<FormLabel>GitHub Personal Access Token (PAT)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ghp_... or github_pat_..."
{...field}
/>
</FormControl>
<FormDescription>
Enter your GitHub PAT here to fetch your repositories. It will be
stored encrypted later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isFetchingRepos}
className="w-full sm:w-auto"
>
{isFetchingRepos ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Fetching Repositories...
</>
) : (
"Fetch Repositories"
)}
</Button>
</div>
</form>
</CardContent>
)}
{step === "select_repos" && (
<CardContent>
{repositories.length === 0 ? (
<Alert variant="destructive">
<CircleAlert className="h-4 w-4" />
<AlertTitle>No Repositories Found</AlertTitle>
<AlertDescription>
No repositories were found or accessible with the provided PAT. Please
check the token and its permissions, then go back and try again.
</AlertDescription>
</Alert>
) : (
<div className="space-y-4">
<FormLabel>Repositories ({selectedRepos.length} selected)</FormLabel>
<div className="h-64 w-full rounded-md border p-4 overflow-y-auto">
{repositories.map((repo) => (
<div key={repo.id} className="flex items-center space-x-2 mb-2 py-1">
<Checkbox
id={`repo-${repo.id}`}
checked={selectedRepos.includes(repo.full_name)}
onCheckedChange={(checked) =>
handleRepoSelection(repo.full_name, !!checked)
}
/>
<label
htmlFor={`repo-${repo.id}`}
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{repo.full_name} {repo.private && "(Private)"}
</label>
</div>
))}
</div>
<FormDescription>
Select the repositories you wish to index. Only checked repositories will
be processed.
</FormDescription>
<div className="flex justify-between items-center pt-4">
<Button
variant="outline"
onClick={() => {
setStep("enter_pat");
setRepositories([]);
setSelectedRepos([]);
setValidatedPat("");
form.reset({ name: connectorName, github_pat: "" });
}}
>
Back
</Button>
<Button
onClick={handleCreateConnector}
disabled={isCreatingConnector || selectedRepos.length === 0}
className="w-full sm:w-auto"
>
{isCreatingConnector ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Connector...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Create Connector
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
)}
</Form>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with GitHub integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through code and documentation in your selected repositories</li>
<li>Access READMEs, Markdown files, and common code files</li>
<li>Connect your project knowledge directly to your search space</li>
<li>Index your selected repositories for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">GitHub Connector Setup Guide</CardTitle>
<CardDescription>
Learn how to generate a Personal Access Token (PAT) and connect your GitHub
account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the
GitHub API. First, it fetches a list of repositories accessible to the token.
You then select which repositories you want to index. The connector indexes
relevant files (code, markdown, text) from only the selected repositories.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
The connector indexes files based on common code and documentation extensions.
</li>
<li>Large files (over 1MB) are skipped during indexing.</li>
<li>Only selected repositories are indexed.</li>
<li>
Indexing runs periodically (check connector settings for frequency) to keep
content up-to-date.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="create_pat">
<AccordionTrigger className="text-lg font-medium">
Step 1: Generate GitHub PAT
</AccordionTrigger>
<AccordionContent>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Generating a Token:</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>
Go to your GitHub{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Developer settings
</a>
.
</li>
<li>
Click on <strong>Personal access tokens</strong>, then choose{" "}
<strong>Tokens (classic)</strong> or{" "}
<strong>Fine-grained tokens</strong> (recommended if available and
suitable).
</li>
<li>
Click <strong>Generate new token</strong> (and choose the appropriate
type).
</li>
<li>
Give your token a descriptive name (e.g., "SurfSense Connector").
</li>
<li>
Set an expiration date for the token (recommended for security).
</li>
<li>
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
<strong>Repository access</strong> (for fine-grained), grant the
necessary permissions. At minimum, the <strong>`repo`</strong> scope
(or equivalent read access to repositories for fine-grained tokens) is
required to read repository content.
</li>
<li>
Click <strong>Generate token</strong>.
</li>
<li>
<strong>Important:</strong> Copy your new PAT immediately. You won't
be able to see it again after leaving the page.
</li>
</ol>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="connect_app">
<AccordionTrigger className="text-lg font-medium">
Step 2: Connect in SurfSense
</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>Navigate to the "Connect GitHub" tab.</li>
<li>Enter a name for your connector.</li>
<li>
Paste the copied GitHub PAT into the "GitHub Personal Access Token (PAT)"
field.
</li>
<li>
Click <strong>Fetch Repositories</strong>.
</li>
<li>
If the PAT is valid, you'll see a list of your accessible repositories.
</li>
<li>
Select the repositories you want SurfSense to index using the checkboxes.
</li>
<li>
Click the <strong>Create Connector</strong> button.
</li>
<li>
If the connection is successful, you will be redirected and can start
indexing from the Connectors page.
</li>
</ol>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,188 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
export default function GoogleCalendarConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isConnecting, setIsConnecting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {
const connectors = data.data || [];
const connector = connectors.find(
(c: SearchSourceConnector) =>
c.connector_type === EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR
);
if (connector) {
setDoesConnectorExist(true);
}
});
}, []);
// Handle Google OAuth connection
const handleConnectGoogle = async () => {
try {
setIsConnecting(true);
// Call backend to initiate authorization flow
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/calendar/connector/add/?space_id=${searchSpaceId}`,
{ method: "GET" }
);
if (!response.ok) {
throw new Error("Failed to initiate Google OAuth");
}
const data = await response.json();
// Redirect to Google for authentication
window.location.href = data.auth_url;
} catch (error) {
console.error("Error connecting to Google:", error);
toast.error("Failed to connect to Google Calendar");
} finally {
setIsConnecting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="mb-8">
<Link
href={`/dashboard/${searchSpaceId}/connectors/add`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to connectors
</Link>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Google Calendar</h1>
<p className="text-muted-foreground">
Connect your Google Calendar to search events.
</p>
</div>
</div>
</div>
{/* OAuth Connection Card */}
{!doesConnectorExist ? (
<Card>
<CardHeader>
<CardTitle>Connect Your Google Account</CardTitle>
<CardDescription>
Connect your Google account to access your calendar events. We'll only request
read-only access to your calendars.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Read-only access to your calendar events</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Access works even when you're offline</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>You can disconnect anytime</span>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
Cancel
</Button>
<Button onClick={handleConnectGoogle} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<ExternalLink className="mr-2 h-4 w-4" />
Connect Your Google Account
</>
)}
</Button>
</CardFooter>
</Card>
) : (
/* Configuration Form Card */
<Card>
<CardHeader>
<CardTitle> Your Google calendar is successfully connected!</CardTitle>
</CardHeader>
</Card>
)}
{/* Help Section */}
{!doesConnectorExist && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">How It Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">1. Connect Your Account</h4>
<p className="text-sm text-muted-foreground">
Click "Connect Your Google Account" to start the secure OAuth process. You'll be
redirected to Google to sign in.
</p>
</div>
<div>
<h4 className="font-medium mb-2">2. Grant Permissions</h4>
<p className="text-sm text-muted-foreground">
Google will ask for permission to read your calendar events. We only request
read-only access to keep your data safe.
</p>
</div>
</CardContent>
</Card>
)}
</motion.div>
</div>
);
}

View file

@ -1,196 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
export default function GoogleGmailConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isConnecting, setIsConnecting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {
const connectors = data.data || [];
const connector = connectors.find(
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.GOOGLE_GMAIL_CONNECTOR
);
if (connector) {
setDoesConnectorExist(true);
}
});
}, []);
// Handle Google OAuth connection
const handleConnectGoogle = async () => {
try {
setIsConnecting(true);
// Call backend to initiate authorization flow
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/google/gmail/connector/add/?space_id=${searchSpaceId}`,
{ method: "GET" }
);
if (!response.ok) {
throw new Error("Failed to initiate Google OAuth");
}
const data = await response.json();
// Redirect to Google for authentication
window.location.href = data.auth_url;
} catch (error) {
console.error("Error connecting to Google:", error);
toast.error("Failed to connect to Google Gmail");
} finally {
setIsConnecting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="mb-8">
<Link
href={`/dashboard/${searchSpaceId}/connectors/add`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to connectors
</Link>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.GOOGLE_GMAIL_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Google Gmail</h1>
<p className="text-muted-foreground">
Connect your Gmail account to search through your emails
</p>
</div>
</div>
</div>
{/* Connection Card */}
{!doesConnectorExist ? (
<Card>
<CardHeader>
<CardTitle>Connect Your Gmail Account</CardTitle>
<CardDescription>
Securely connect your Gmail account to enable email search within SurfSense. We'll
only access your emails with read-only permissions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Read-only access to your emails</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Search through email content and metadata</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Secure OAuth 2.0 authentication</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>You can disconnect anytime</span>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
Cancel
</Button>
<Button onClick={handleConnectGoogle} disabled={isConnecting}>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<ExternalLink className="mr-2 h-4 w-4" />
Connect Your Google Account
</>
)}
</Button>
</CardFooter>
</Card>
) : (
/* Configuration Form Card */
<Card>
<CardHeader>
<CardTitle> Your Gmail is successfully connected!</CardTitle>
</CardHeader>
</Card>
)}
{/* Information Card */}
<Card className="mt-6">
<CardHeader>
<CardTitle>What data will be indexed?</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">Email Content</h4>
<p className="text-sm text-muted-foreground">
We'll index the content of your emails including subject lines, sender information,
and message body text to make them searchable.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium">Email Metadata</h4>
<p className="text-sm text-muted-foreground">
Information like sender, recipient, date, and labels will be indexed to provide
better search context and filtering options.
</p>
</div>
<div className="space-y-2">
<h4 className="font-medium">Privacy & Security</h4>
<p className="text-sm text-muted-foreground">
Your emails are processed securely and stored with encryption. We only access emails
with read-only permissions and never modify or send emails on your behalf.
</p>
</div>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,427 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const jiraConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message: "Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("jira");
},
{
message: "Please enter a valid Jira instance URL",
}
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Jira API Token is required and must be valid.",
}),
});
// Define the type for the form values
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
export default function JiraConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<JiraConnectorFormValues>({
resolver: zodResolver(jiraConnectorFormSchema),
defaultValues: {
name: "Jira Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Handle form submission
const onSubmit = async (values: JiraConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.JIRA_CONNECTOR,
config: {
JIRA_BASE_URL: values.base_url,
JIRA_EMAIL: values.email,
JIRA_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Jira connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.JIRA_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Jira</h1>
<p className="text-muted-foreground">
Connect your Jira instance to search issues and tickets.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Jira Instance</CardTitle>
<CardDescription>
Integrate with Jira to search and retrieve information from your issues, tickets,
and comments. This connector can index your Jira content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
<AlertDescription>
You'll need a Jira Personal Access Token to use this connector. You can create
one from{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Jira Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Jira Instance URL</FormLabel>
<FormControl>
<Input placeholder="https://yourcompany.atlassian.net" {...field} />
</FormControl>
<FormDescription>
Your Jira instance URL. For Atlassian Cloud, this is typically
https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input type="email" placeholder="your.email@company.com" {...field} />
</FormControl>
<FormDescription>Your Atlassian account email address.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input type="password" placeholder="Your Jira API Token" {...field} />
</FormControl>
<FormDescription>
Your Jira API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Jira
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Jira integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Jira issues and tickets</li>
<li>Access issue descriptions, comments, and full discussion threads</li>
<li>Connect your team's project management directly to your search space</li>
<li>Keep your search results up-to-date with latest Jira content</li>
<li>Index your Jira issues for enhanced search capabilities</li>
<li>Search by issue keys, status, priority, and assignee information</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Jira Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Jira connector to index your project management
data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Jira connector uses the Jira REST API with Basic Authentication to fetch all
issues and comments that your account has access to within your Jira instance.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector retrieves issues and comments that
have been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need read access for this connector to work. The API Token will
only be used to read your Jira data.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Step 1: Create an API Token</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Atlassian account</li>
<li>
Navigate to{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://id.atlassian.com/manage-profile/security/api-tokens
</a>
</li>
<li>
Click <strong>Create API token</strong>
</li>
<li>Enter a label for your token (like "SurfSense Connector")</li>
<li>
Click <strong>Create</strong>
</li>
<li>Copy the generated token as it will only be shown once</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
<p className="text-muted-foreground mb-3">
The API Token will have access to all projects and issues that your user
account can see. Make sure your account has appropriate permissions for
the projects you want to index.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues, comments, and basic metadata will be indexed. Jira
attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the <strong>Jira</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>Jira Instance URL</strong> (e.g.,
https://yourcompany.atlassian.net)
</li>
<li>
Place your <strong>Personal Access Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Jira issues will be indexed automatically.</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">The Jira connector indexes the following data:</p>
<ul className="list-disc pl-5">
<li>Issue keys and summaries (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments and discussion threads</li>
<li>Issue status, priority, and type information</li>
<li>Assignee and reporter information</li>
<li>Project information</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,379 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const linearConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z
.string()
.min(10, {
message: "Linear API Key is required and must be valid.",
})
.regex(/^lin_api_/, {
message: "Linear API Key should start with 'lin_api_'",
}),
});
// Define the type for the form values
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
export default function LinearConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<LinearConnectorFormValues>({
resolver: zodResolver(linearConnectorFormSchema),
defaultValues: {
name: "Linear Connector",
api_key: "",
},
});
// Handle form submission
const onSubmit = async (values: LinearConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.LINEAR_CONNECTOR,
config: {
LINEAR_API_KEY: values.api_key,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Linear connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.LINEAR_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Linear</h1>
<p className="text-muted-foreground">
Connect your Linear workspace to search issues and projects.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Linear Workspace</CardTitle>
<CardDescription>
Integrate with Linear to search and retrieve information from your issues and
comments. This connector can index your Linear content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Linear API Key Required</AlertTitle>
<AlertDescription>
You'll need a Linear API Key to use this connector. You can create a Linear API
key from{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Linear API Settings
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Linear Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Linear API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="lin_api_..." {...field} />
</FormControl>
<FormDescription>
Your Linear API Key will be encrypted and stored securely. It typically
starts with "lin_api_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Linear
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Linear integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Linear issues and comments</li>
<li>Access issue titles, descriptions, and full discussion threads</li>
<li>Connect your team's project management directly to your search space</li>
<li>Keep your search results up-to-date with latest Linear content</li>
<li>Index your Linear issues for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Linear Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Linear connector to index your project management
data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Linear connector uses the Linear GraphQL API to fetch all issues and
comments that the API key has access to within a workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector retrieves issues and comments that
have been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need a read-only API key for this connector to work. This limits
the permissions to just reading your Linear data.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Step 1: Create an API key</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Linear account</li>
<li>
Navigate to{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://linear.app/settings/api
</a>{" "}
in your browser.
</li>
<li>Alternatively, click on your profile picture Settings API</li>
<li>
Click the <strong>+ New API key</strong> button.
</li>
<li>Enter a description for your key (like "Search Connector").</li>
<li>Select "Read-only" as the permission.</li>
<li>
Click <strong>Create</strong> to generate the API key.
</li>
<li>
Copy the generated API key that starts with 'lin_api_' as it will only
be shown once.
</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">Step 2: Grant necessary access</h4>
<p className="text-muted-foreground mb-3">
The API key will have access to all issues and comments that your user
account can see. If you're creating the key as an admin, it will have
access to all issues in the workspace.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues and comments will be indexed. Linear attachments and
linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the <strong>Linear</strong>{" "}
Connector.
</li>
<li>
Place the <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Linear issues will be indexed automatically.</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">The Linear connector indexes the following data:</p>
<ul className="list-disc pl-5">
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments</li>
<li>Issue status and metadata</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,219 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const linkupApiFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
// Define the type for the form values
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
export default function LinkupApiPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<LinkupApiFormValues>({
resolver: zodResolver(linkupApiFormSchema),
defaultValues: {
name: "Linkup API Connector",
api_key: "",
},
});
// Handle form submission
const onSubmit = async (values: LinkupApiFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.LINKUP_API,
config: {
LINKUP_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Linkup API connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.LINKUP_API, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Linkup API</h1>
<p className="text-muted-foreground">
Connect Linkup API for enhanced search capabilities.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Linkup API</CardTitle>
<CardDescription>
Integrate with Linkup API to enhance your search capabilities with AI-powered search
results.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
<a
href="https://linkup.so"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
linkup.so
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Linkup API Connector" {...field} />
</FormControl>
<FormDescription>A friendly name to identify this connector.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Linkup API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter your Linkup API key" {...field} />
</FormControl>
<FormDescription>
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Linkup API
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Linkup API:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>AI-powered search results tailored to your queries</li>
<li>Real-time information from the web</li>
<li>Enhanced search capabilities for your projects</li>
</ul>
</CardFooter>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,268 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Key, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
// Define the form schema with Zod
const lumaConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
// Define the type for the form values
type LumaConnectorFormValues = z.infer<typeof lumaConnectorFormSchema>;
export default function LumaConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { data: connectors } = useAtomValue(connectorsAtom);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<LumaConnectorFormValues>({
resolver: zodResolver(lumaConnectorFormSchema),
defaultValues: {
name: "Luma Events",
api_key: "",
},
});
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
useEffect(() => {
fetchConnectors().then((data) => {
const connectors = data.data || [];
const connector = connectors.find(
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.LUMA_CONNECTOR
);
if (connector) {
setDoesConnectorExist(true);
}
});
}, []);
// Handle form submission
const onSubmit = async (values: LumaConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.LUMA_CONNECTOR,
config: {
LUMA_API_KEY: values.api_key,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Luma connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="mb-8">
<Link
href={`/dashboard/${searchSpaceId}/connectors/add`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to connectors
</Link>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Luma</h1>
<p className="text-muted-foreground">Connect your Luma account to search events.</p>
</div>
</div>
</div>
{/* Connection Card */}
{!doesConnectorExist ? (
<Card>
<CardHeader>
<CardTitle>Connect Your Luma Account</CardTitle>
<CardDescription>
Enter your Luma API key to connect your account. We'll use this to access your
events in read-only mode.
</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Luma Events" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter your Luma API key" {...field} />
</FormControl>
<FormDescription>
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2 pt-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Read-only access to your Luma events</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Access works even when you're offline</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>You can disconnect anytime</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Key className="mr-2 h-4 w-4" />
Connect Luma
</>
)}
</Button>
</CardFooter>
</form>
</Form>
</Card>
) : (
/* Success Card */
<Card>
<CardHeader>
<CardTitle> Your Luma account is successfully connected!</CardTitle>
</CardHeader>
</Card>
)}
{/* Help Section */}
{!doesConnectorExist && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">How It Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">1. Get Your API Key</h4>
<p className="text-sm text-muted-foreground">
Log into your Luma account and navigate to your account settings to generate an
API key.
</p>
</div>
<div>
<h4 className="font-medium mb-2">2. Enter Your API Key</h4>
<p className="text-sm text-muted-foreground">
Paste your API key in the field above. We'll use this to securely access your
events with read-only permissions.
</p>
</div>
</CardContent>
</Card>
)}
</motion.div>
</div>
);
}

View file

@ -1,390 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const notionConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
integration_token: z.string().min(10, {
message: "Notion Integration Token is required and must be valid.",
}),
});
// Define the type for the form values
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
export default function NotionConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<NotionConnectorFormValues>({
resolver: zodResolver(notionConnectorFormSchema),
defaultValues: {
name: "Notion Connector",
integration_token: "",
},
});
// Handle form submission
const onSubmit = async (values: NotionConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.NOTION_CONNECTOR,
config: {
NOTION_INTEGRATION_TOKEN: values.integration_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Notion connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.NOTION_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Notion</h1>
<p className="text-muted-foreground">
Connect your Notion workspace to search pages and databases.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Notion Workspace</CardTitle>
<CardDescription>
Integrate with Notion to search and retrieve information from your workspace pages
and databases. This connector can index your Notion content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Notion Integration Token Required</AlertTitle>
<AlertDescription>
You'll need a Notion Integration Token to use this connector. You can create a
Notion integration and get the token from{" "}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Notion Integrations Dashboard
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Notion Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="integration_token"
render={({ field }) => (
<FormItem>
<FormLabel>Notion Integration Token</FormLabel>
<FormControl>
<Input type="password" placeholder="ntn_.." {...field} />
</FormControl>
<FormDescription>
Your Notion Integration Token will be encrypted and stored securely. It
typically starts with "ntn_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Notion
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Notion integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through your Notion pages and databases</li>
<li>Access documents, wikis, and knowledge bases</li>
<li>Connect your team's knowledge directly to your search space</li>
<li>Keep your search results up-to-date with latest Notion content</li>
<li>Index your Notion documents for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Notion Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Notion connector to index your workspace data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Notion connector uses the Notion search API to fetch all pages that the
connector has access to within a workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector only retrieves pages that have been
updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run every <strong>10 minutes</strong>, so page
updates should appear within 10 minutes.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>No Admin Access Required</AlertTitle>
<AlertDescription>
There's no requirement to be an Admin to share information with an
integration. Any member can share pages and databases with it.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">Step 1: Create an integration</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>
Visit{" "}
<a
href="https://www.notion.com/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://www.notion.com/my-integrations
</a>{" "}
in your browser.
</li>
<li>
Click the <strong>+ New integration</strong> button.
</li>
<li>
Name the integration (something like "Search Connector" could work).
</li>
<li>Select "Read content" as the only capability required.</li>
<li>
Click <strong>Submit</strong> to create the integration.
</li>
<li>
On the next page, you'll find your Notion integration token. Make a
copy of it as you'll need it to configure the connector.
</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">
Step 2: Share pages/databases with your integration
</h4>
<p className="text-muted-foreground mb-3">
To keep your information secure, integrations don't have access to any
pages or databases in the workspace at first. You must share specific
pages with an integration in order for the connector to access those
pages.
</p>
<ol className="list-decimal pl-5 space-y-3">
<li>Go to the page/database in your workspace.</li>
<li>
Click the <code></code> on the top right corner of the page.
</li>
<li>
Scroll to the bottom of the pop-up and click{" "}
<strong>Add connections</strong>.
</li>
<li>
Search for and select the new integration in the{" "}
<code>Search for connections...</code> menu.
</li>
<li>
<strong>Important:</strong>
<ul className="list-disc pl-5 mt-1">
<li>
If you've added a page, all child pages also become accessible.
</li>
<li>
If you've added a database, all rows (and their children) become
accessible.
</li>
</ul>
</li>
</ol>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Integration Token</strong> under{" "}
<strong>Step 1 Provide Credentials</strong>.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Indexing Behavior</AlertTitle>
<AlertDescription>
The Notion connector currently indexes everything it has access to. If you
want to limit specific content being indexed, simply unshare the database
from Notion with the integration.
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,16 +0,0 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function AddConnectorRedirect() {
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
useEffect(() => {
router.replace(`/dashboard/${search_space_id}/sources/add?tab=connectors`);
}, [search_space_id, router]);
return null;
}

View file

@ -1,370 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
const searxngFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
host: z
.string({ required_error: "Host is required." })
.url({ message: "Enter a valid SearxNG host URL (e.g. https://searxng.example.org)." }),
api_key: z.string().optional(),
engines: z.string().optional(),
categories: z.string().optional(),
language: z.string().optional(),
safesearch: z
.string()
.regex(/^[0-2]?$/, { message: "SafeSearch must be 0, 1, or 2." })
.optional(),
verify_ssl: z.boolean().default(true),
});
type SearxngFormValues = z.infer<typeof searxngFormSchema>;
const parseCommaSeparated = (value?: string | null) => {
if (!value) return undefined;
const items = value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
return items.length > 0 ? items : undefined;
};
export default function SearxngConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
const form = useForm<SearxngFormValues>({
resolver: zodResolver(searxngFormSchema),
defaultValues: {
name: "SearxNG Connector",
host: "",
api_key: "",
engines: "",
categories: "",
language: "",
safesearch: "",
verify_ssl: true,
},
});
const onSubmit = async (values: SearxngFormValues) => {
setIsSubmitting(true);
try {
const config: Record<string, unknown> = {
SEARXNG_HOST: values.host.trim(),
};
const apiKey = values.api_key?.trim();
if (apiKey) config.SEARXNG_API_KEY = apiKey;
const engines = parseCommaSeparated(values.engines);
if (engines) config.SEARXNG_ENGINES = engines;
const categories = parseCommaSeparated(values.categories);
if (categories) config.SEARXNG_CATEGORIES = categories;
const language = values.language?.trim();
if (language) config.SEARXNG_LANGUAGE = language;
const safesearch = values.safesearch?.trim();
if (safesearch) {
const parsed = Number(safesearch);
if (!Number.isNaN(parsed)) {
config.SEARXNG_SAFESEARCH = parsed;
}
}
// Include verify flag only when disabled to keep config minimal
if (values.verify_ssl === false) {
config.SEARXNG_VERIFY_SSL = false;
}
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.SEARXNG_API,
config,
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("SearxNG connector created successfully!");
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating SearxNG connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.SEARXNG_API, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect SearxNG</h1>
<p className="text-muted-foreground">
Bring your self-hosted SearxNG meta-search engine into SurfSense.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect SearxNG</CardTitle>
<CardDescription>
Integrate SurfSense with any SearxNG instance to broaden your search coverage while
preserving privacy and control.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>SearxNG Instance Required</AlertTitle>
<AlertDescription>
You need access to a running SearxNG instance. Refer to the{" "}
<a
href="https://docs.searxng.org/admin/installation-docker.html"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
SearxNG installation guide
</a>{" "}
for setup instructions. If your instance requires an API key, include it below.
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My SearxNG Connector" {...field} />
</FormControl>
<FormDescription>A friendly name to identify this connector.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel>SearxNG Host</FormLabel>
<FormControl>
<Input placeholder="https://searxng.example.org" {...field} />
</FormControl>
<FormDescription>
Provide the full base URL to your SearxNG instance. Include the protocol
(http/https).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>API Key (optional)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter API key if your instance requires one"
{...field}
/>
</FormControl>
<FormDescription>
Leave empty if your SearxNG instance does not enforce API keys.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="engines"
render={({ field }) => (
<FormItem>
<FormLabel>Engines (optional)</FormLabel>
<FormControl>
<Input placeholder="google,bing,duckduckgo" {...field} />
</FormControl>
<FormDescription>
Comma-separated list to target specific engines.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categories"
render={({ field }) => (
<FormItem>
<FormLabel>Categories (optional)</FormLabel>
<FormControl>
<Input placeholder="general,it,science" {...field} />
</FormControl>
<FormDescription>
Comma-separated list of SearxNG categories.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel>Preferred Language (optional)</FormLabel>
<FormControl>
<Input placeholder="en-US" {...field} />
</FormControl>
<FormDescription>
IETF language tag (e.g. en, en-US). Leave blank to inherit defaults.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="safesearch"
render={({ field }) => (
<FormItem>
<FormLabel>SafeSearch Level (optional)</FormLabel>
<FormControl>
<Input placeholder="0 (off), 1 (moderate), 2 (strict)" {...field} />
</FormControl>
<FormDescription>
Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the
instance default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="verify_ssl"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div>
<FormLabel>Verify SSL Certificates</FormLabel>
<FormDescription>
Disable only when connecting to instances with self-signed certificates.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)}
/>
<CardFooter className="flex justify-end px-0">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect SearxNG
</>
)}
</Button>
</CardFooter>
</form>
</Form>
</CardContent>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,421 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const slackConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z.string().min(10, {
message: "Bot User OAuth Token is required and must be valid.",
}),
});
// Define the type for the form values
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
export default function SlackConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<SlackConnectorFormValues>({
resolver: zodResolver(slackConnectorFormSchema),
defaultValues: {
name: "Slack Connector",
bot_token: "",
},
});
// Handle form submission
const onSubmit = async (values: SlackConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.SLACK_CONNECTOR,
config: {
SLACK_BOT_TOKEN: values.bot_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Slack connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.SLACK_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Slack</h1>
<p className="text-muted-foreground">
Connect your Slack workspace to search messages and channels.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Slack Workspace</CardTitle>
<CardDescription>
Integrate with Slack to search and retrieve information from your workspace
channels and conversations. This connector can index your Slack messages for
search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Bot User OAuth Token Required</AlertTitle>
<AlertDescription>
You'll need a Slack Bot User OAuth Token to use this connector. You can create a
Slack app and get the token from{" "}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Slack API Dashboard
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Slack Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel>Slack Bot User OAuth Token</FormLabel>
<FormControl>
<Input type="password" placeholder="xoxb-..." {...field} />
</FormControl>
<FormDescription>
Your Bot User OAuth Token will be encrypted and stored securely. It
typically starts with "xoxb-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Slack
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Slack integration:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through your Slack channels and conversations</li>
<li>Access historical messages and shared files</li>
<li>Connect your team's knowledge directly to your search space</li>
<li>Keep your search results up-to-date with latest communications</li>
<li>Index your Slack messages for enhanced search capabilities</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Slack Connector Documentation</CardTitle>
<CardDescription>
Learn how to set up and use the Slack connector to index your workspace data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Slack connector indexes all public channels for a given workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
Upcoming: Support for private channels by tagging/adding the Slack Bot to
private channels.
</li>
</ul>
</div>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Admin Access Required</AlertTitle>
<AlertDescription>
You must be an admin of the Slack workspace to set up the connector.
</AlertDescription>
</Alert>
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate and sign in to{" "}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://api.slack.com/apps
</a>
.
</li>
<li>
Create a new Slack app:
<ul className="list-disc pl-5 mt-1">
<li>
Click the <strong>Create New App</strong> button in the top right.
</li>
<li>
Select <strong>From an app manifest</strong> option.
</li>
<li>
Select the relevant workspace from the dropdown and click{" "}
<strong>Next</strong>.
</li>
</ul>
</li>
<li>
Select the "YAML" tab, paste the following manifest into the text box, and
click <strong>Next</strong>:
<div className="bg-muted p-4 rounded-md mt-2 overflow-x-auto">
<pre className="text-xs">
{`display_information:
name: SlackConnector
description: ReadOnly Connector for indexing
features:
bot_user:
display_name: SlackConnector
always_online: false
oauth_config:
scopes:
bot:
- channels:history
- channels:read
- groups:history
- groups:read
- channels:join
- im:history
- users:read
- users:read.email
- usergroups:read
settings:
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false`}
</pre>
</div>
</li>
<li>
Click the <strong>Create</strong> button.
</li>
<li>
In the app page, navigate to the <strong>OAuth & Permissions</strong> tab
under the <strong>Features</strong> header.
</li>
<li>
Copy the <strong>Bot User OAuth Token</strong>, this will be used to
access Slack.
</li>
</ol>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">Indexing</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Bot User OAuth Token</strong> under{" "}
<strong>Step 1 Provide Credentials</strong>.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Important: Invite Bot to Channels</AlertTitle>
<AlertDescription>
After connecting, you must invite the bot to each channel you want to
index. In each Slack channel, type:
<pre className="mt-2 bg-background p-2 rounded-md text-xs">
/invite @YourBotName
</pre>
<p className="mt-2">
Without this step, you'll get a "not_in_channel" error when the
connector tries to access channel messages.
</p>
</AlertDescription>
</Alert>
<Alert className="bg-muted mt-4">
<Info className="h-4 w-4" />
<AlertTitle>First Indexing</AlertTitle>
<AlertDescription>
The first indexing pulls all of the public channels and takes longer than
future updates. Only channels where the bot has been invited will be fully
indexed.
</AlertDescription>
</Alert>
<div className="mt-4">
<h4 className="font-medium mb-2">Troubleshooting:</h4>
<ul className="list-disc pl-5 space-y-2 text-muted-foreground">
<li>
<strong>not_in_channel error:</strong> If you see this error in logs, it
means the bot hasn't been invited to a channel it's trying to access.
Use the <code>/invite @YourBotName</code> command in that channel.
</li>
<li>
<strong>Alternative approach:</strong> You can add the{" "}
<code>chat:write.public</code> scope to your Slack app to allow it to
access public channels without an explicit invitation.
</li>
<li>
<strong>For private channels:</strong> The bot must always be invited
using the <code>/invite</code> command.
</li>
</ul>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,219 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Define the form schema with Zod
const tavilyApiFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
// Define the type for the form values
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
export default function TavilyApiPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<TavilyApiFormValues>({
resolver: zodResolver(tavilyApiFormSchema),
defaultValues: {
name: "Tavily API Connector",
api_key: "",
},
});
// Handle form submission
const onSubmit = async (values: TavilyApiFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.TAVILY_API,
config: {
TAVILY_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Tavily API connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
{/* Header */}
<div className="mb-8">
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.TAVILY_API, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Tavily API</h1>
<p className="text-muted-foreground">
Connect Tavily API for AI-powered search capabilities.
</p>
</div>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">Connect Tavily API</CardTitle>
<CardDescription>
Integrate with Tavily API to enhance your search capabilities with AI-powered search
results.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>API Key Required</AlertTitle>
<AlertDescription>
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
<a
href="https://tavily.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
tavily.com
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Tavily API Connector" {...field} />
</FormControl>
<FormDescription>A friendly name to identify this connector.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Tavily API Key</FormLabel>
<FormControl>
<Input type="password" placeholder="Enter your Tavily API key" {...field} />
</FormControl>
<FormDescription>
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Tavily API
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">What you get with Tavily API:</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>AI-powered search results tailored to your queries</li>
<li>Real-time information from the web</li>
<li>Enhanced search capabilities for your projects</li>
</ul>
</CardFooter>
</Card>
</motion.div>
</div>
);
}

View file

@ -1,325 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useAtomValue } from "jotai";
import { ArrowLeft, Check, Globe, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod";
import { createConnectorMutationAtom } from "@/atoms/connectors/connector-mutation.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
// Define the form schema with Zod
const webcrawlerConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().optional(),
initial_urls: z.string().optional(),
});
// Define the type for the form values
type WebcrawlerConnectorFormValues = z.infer<typeof webcrawlerConnectorFormSchema>;
export default function WebcrawlerConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
const { refetch: fetchConnectors } = useAtomValue(connectorsAtom);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
// Initialize the form
const form = useForm<WebcrawlerConnectorFormValues>({
resolver: zodResolver(webcrawlerConnectorFormSchema),
defaultValues: {
name: "Web Pages",
api_key: "",
initial_urls: "",
},
});
useEffect(() => {
fetchConnectors().then((data) => {
const connectors = data.data || [];
const connector = connectors.find(
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.WEBCRAWLER_CONNECTOR
);
if (connector) {
setDoesConnectorExist(true);
}
});
}, []);
// Handle form submission
const onSubmit = async (values: WebcrawlerConnectorFormValues) => {
setIsSubmitting(true);
try {
const config: Record<string, string> = {};
// Only add API key to config if provided
if (values.api_key && values.api_key.trim()) {
config.FIRECRAWL_API_KEY = values.api_key;
}
// Parse initial URLs if provided
if (values.initial_urls && values.initial_urls.trim()) {
config.INITIAL_URLS = values.initial_urls;
}
await createConnector({
data: {
name: values.name,
connector_type: EnumConnectorName.WEBCRAWLER_CONNECTOR,
config: config,
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
},
queryParams: {
search_space_id: searchSpaceId,
},
});
toast.success("Webcrawler connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to create connector");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-2xl">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="mb-8">
<Link
href={`/dashboard/${searchSpaceId}/connectors/add`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to connectors
</Link>
<div className="flex items-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
{getConnectorIcon(EnumConnectorName.WEBCRAWLER_CONNECTOR, "h-6 w-6")}
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight">Connect Web Pages</h1>
<p className="text-muted-foreground">Crawl and index web pages for search.</p>
</div>
</div>
</div>
{/* Connection Card */}
{!doesConnectorExist ? (
<Card>
<CardHeader>
<CardTitle>Set Up Web Page crawler</CardTitle>
<CardDescription>
Configure your web page crawler to index web pages. Optionally add a Firecrawl API
key for enhanced crawling capabilities.
</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<CardContent className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Web Crawler" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel>Firecrawl API Key (Optional)</FormLabel>
<FormControl>
<Input type="password" placeholder="fc-xxxxxxxxxxxxx" {...field} />
</FormControl>
<FormDescription>
Add a Firecrawl API key for enhanced crawling. If not provided, will use
AsyncChromiumLoader as fallback.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="initial_urls"
render={({ field }) => (
<FormItem>
<FormLabel>Initial URLs (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="https://example.com&#10;https://docs.example.com&#10;https://blog.example.com"
className="min-h-[100px] font-mono text-sm"
{...field}
/>
</FormControl>
<FormDescription>
Enter URLs to crawl (one per line). You can add more URLs later.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2 pt-2">
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Crawl any public web page</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Extract markdown content automatically</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Detect content changes and update documents</span>
</div>
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 text-green-500" />
<span>Works with or without Firecrawl API key</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Setting up...
</>
) : (
<>
<Globe className="mr-2 h-4 w-4" />
Create Crawler
</>
)}
</Button>
</CardFooter>
</form>
</Form>
</Card>
) : (
/* Success Card */
<Card>
<CardHeader>
<CardTitle> Your web page crawler is successfully set up!</CardTitle>
<CardDescription>
You can now add URLs to crawl from the connector management page.
</CardDescription>
</CardHeader>
</Card>
)}
{/* Help Section */}
{!doesConnectorExist && (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-lg">How It Works</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="font-medium mb-2">1. Choose Your Crawler Method</h4>
<p className="text-sm text-muted-foreground">
<strong>With Firecrawl (Recommended):</strong> Get your API key from{" "}
<a
href="https://firecrawl.dev"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
firecrawl.dev
</a>{" "}
for faster, more reliable crawling with better content extraction.
</p>
<p className="text-sm text-muted-foreground mt-2">
<strong>Without Firecrawl:</strong> The crawler will use AsyncChromiumLoader as a
free fallback option. This works well for most websites but may be slower.
</p>
</div>
<div>
<h4 className="font-medium mb-2">2. Add URLs to Crawl (Optional)</h4>
<p className="text-sm text-muted-foreground">
You can add initial URLs now or add them later from the connector management page.
Enter one URL per line.
</p>
</div>
<div>
<h4 className="font-medium mb-2">3. Manage Your Crawler</h4>
<p className="text-sm text-muted-foreground">
After setup, you can add more URLs, trigger manual crawls, or set up periodic
indexing to keep your content up-to-date.
</p>
</div>
</CardContent>
</Card>
)}
</motion.div>
</div>
);
}

View file

@ -3,8 +3,6 @@
import type React from "react"; import type React from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
export function getDocumentTypeIcon(type: string): React.ReactNode { export function getDocumentTypeIcon(type: string): React.ReactNode {
return getConnectorIcon(type); return getConnectorIcon(type);
} }

View file

@ -2,9 +2,10 @@
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react"; import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React from "react"; import React from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { DocumentViewer } from "@/components/document-viewer"; import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@ -69,9 +70,9 @@ export function DocumentsTableShell({
onSortChange: (key: SortKey) => void; onSortChange: (key: SortKey) => void;
}) { }) {
const t = useTranslations("documents"); const t = useTranslations("documents");
const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id; const searchSpaceId = params.search_space_id;
const { openDialog } = useDocumentUploadDialog();
const sorted = React.useMemo( const sorted = React.useMemo(
() => sortDocuments(documents, sortKey, sortDesc), () => sortDocuments(documents, sortKey, sortDesc),
@ -140,15 +141,12 @@ export function DocumentsTableShell({
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-semibold">{t("no_documents")}</h3> <h3 className="text-lg font-semibold">{t("no_documents")}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Get started by adding your first data source. Get started by uploading your first document.
</p> </p>
</div> </div>
<Button <Button onClick={openDialog} className="mt-2">
onClick={() => router.push(`/dashboard/${searchSpaceId}/sources/add`)}
className="mt-2"
>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Sources Upload Documents
</Button> </Button>
</motion.div> </motion.div>
</div> </div>

View file

@ -125,14 +125,22 @@ export default function DocumentsTable() {
setColumnVisibility((prev) => ({ ...prev, [id]: checked })); setColumnVisibility((prev) => ({ ...prev, [id]: checked }));
}; };
const [isRefreshing, setIsRefreshing] = useState(false);
const refreshCurrentView = useCallback(async () => { const refreshCurrentView = useCallback(async () => {
if (debouncedSearch.trim()) { if (isRefreshing) return;
await refetchSearch(); setIsRefreshing(true);
} else { try {
await refetchDocuments(); if (debouncedSearch.trim()) {
await refetchSearch();
} else {
await refetchDocuments();
}
toast.success(t("refresh_success") || "Documents refreshed");
} finally {
setIsRefreshing(false);
} }
toast.success(t("refresh_success") || "Documents refreshed"); }, [debouncedSearch, refetchSearch, refetchDocuments, t, isRefreshing]);
}, [debouncedSearch, refetchSearch, refetchDocuments, t]);
// Set up smart polling for active tasks - only polls when tasks are in progress // Set up smart polling for active tasks - only polls when tasks are in progress
const { summary } = useLogsSummary(searchSpaceId, 24, { const { summary } = useLogsSummary(searchSpaceId, 24, {
@ -146,7 +154,6 @@ export default function DocumentsTable() {
summary?.active_tasks.filter((task) => task.source === "document_processor") || []; summary?.active_tasks.filter((task) => task.source === "document_processor") || [];
const documentProcessorTasksCount = documentProcessorTasks.length; const documentProcessorTasksCount = documentProcessorTasks.length;
const activeTasksCount = summary?.active_tasks.length || 0; const activeTasksCount = summary?.active_tasks.length || 0;
const prevActiveTasksCount = useRef(activeTasksCount); const prevActiveTasksCount = useRef(activeTasksCount);
@ -231,8 +238,8 @@ export default function DocumentsTable() {
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2> <h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p> <p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div> </div>
<Button onClick={refreshCurrentView} variant="outline" size="sm"> <Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")} {t("refresh")}
</Button> </Button>
</motion.div> </motion.div>

View file

@ -1,16 +0,0 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function UploadDocumentsRedirect() {
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
useEffect(() => {
router.replace(`/dashboard/${search_space_id}/sources/add?tab=documents`);
}, [search_space_id, router]);
return null;
}

View file

@ -1,16 +0,0 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
export default function YouTubeRedirect() {
const params = useParams();
const router = useRouter();
const search_space_id = params.search_space_id as string;
useEffect(() => {
router.replace(`/dashboard/${search_space_id}/sources/add?tab=youtube`);
}, [search_space_id, router]);
return null;
}

View file

@ -30,32 +30,19 @@ export default function DashboardLayout({
{ {
title: "Chat", title: "Chat",
url: `/dashboard/${search_space_id}/new-chat`, url: `/dashboard/${search_space_id}/new-chat`,
icon: "SquareTerminal", icon: "MessageCircle",
items: [], items: [],
}, },
{ {
title: "Sources", title: "Documents",
url: "#", url: `/dashboard/${search_space_id}/documents`,
icon: "Database", icon: "SquareLibrary",
items: [ items: [],
{
title: "Add Sources",
url: `/dashboard/${search_space_id}/sources/add`,
},
{
title: "Manage Documents",
url: `/dashboard/${search_space_id}/documents`,
},
{
title: "Manage Connectors",
url: `/dashboard/${search_space_id}/connectors`,
},
],
}, },
{ {
title: "Logs", title: "Logs",
url: `/dashboard/${search_space_id}/logs`, url: `/dashboard/${search_space_id}/logs`,
icon: "FileText", icon: "Logs",
items: [], items: [],
}, },
]; ];

View file

@ -472,9 +472,18 @@ export default function LogsManagePage() {
} }
}; };
const [isRefreshing, setIsRefreshing] = useState(false);
const [isSummaryRefreshing, setIsSummaryRefreshing] = useState(false);
const handleRefresh = async () => { const handleRefresh = async () => {
await Promise.all([refreshLogs(), refreshSummary()]); if (isRefreshing) return;
toast.success("Logs refreshed"); setIsRefreshing(true);
try {
await Promise.all([refreshLogs(), refreshSummary()]);
toast.success("Logs refreshed");
} finally {
setIsRefreshing(false);
}
}; };
return ( return (
@ -494,8 +503,17 @@ export default function LogsManagePage() {
<LogsSummaryDashboard <LogsSummaryDashboard
summary={summary} summary={summary}
loading={summaryLoading} loading={summaryLoading}
error={summaryError} error={summaryError?.message ?? null}
onRefresh={refreshSummary} onRefresh={async () => {
if (isSummaryRefreshing) return;
setIsSummaryRefreshing(true);
try {
await refreshSummary();
} finally {
setIsSummaryRefreshing(false);
}
}}
isRefreshing={isSummaryRefreshing}
/> />
{/* Logs Table Header */} {/* Logs Table Header */}
@ -509,8 +527,8 @@ export default function LogsManagePage() {
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2> <h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p> <p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div> </div>
<Button onClick={handleRefresh} variant="outline" size="sm"> <Button onClick={handleRefresh} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className="w-4 h-4 mr-2" /> <RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")} {t("refresh")}
</Button> </Button>
</motion.div> </motion.div>
@ -546,11 +564,13 @@ function LogsSummaryDashboard({
loading, loading,
error, error,
onRefresh, onRefresh,
isRefreshing = false,
}: { }: {
summary: any; summary: any;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
onRefresh: () => void; onRefresh: () => void | Promise<void>;
isRefreshing?: boolean;
}) { }) {
const t = useTranslations("logs"); const t = useTranslations("logs");
if (loading) { if (loading) {
@ -581,7 +601,8 @@ function LogsSummaryDashboard({
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<AlertCircle className="h-8 w-8 text-destructive" /> <AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">{t("failed_load_summary")}</p> <p className="text-sm text-destructive">{t("failed_load_summary")}</p>
<Button variant="outline" size="sm" onClick={onRefresh}> <Button variant="outline" size="sm" onClick={onRefresh} disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("retry")} {t("retry")}
</Button> </Button>
</div> </div>

View file

@ -1,104 +0,0 @@
"use client";
import { IconBrandYoutube } from "@tabler/icons-react";
import { Cable, Database, Globe, Upload } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { ConnectorsTab } from "@/components/sources/ConnectorsTab";
import { DocumentUploadTab } from "@/components/sources/DocumentUploadTab";
import { YouTubeTab } from "@/components/sources/YouTubeTab";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { trackSourcesTabViewed } from "@/lib/posthog/events";
export default function AddSourcesPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
const search_space_id = params.search_space_id as string;
const [activeTab, setActiveTab] = useState("documents");
// Handle tab from query parameter
useEffect(() => {
const tabParam = searchParams.get("tab");
if (tabParam && ["documents", "youtube", "connectors"].includes(tabParam)) {
setActiveTab(tabParam);
}
}, [searchParams]);
const handleTabChange = (value: string) => {
if (value === "webpages") {
router.push(`/dashboard/${search_space_id}/connectors/add/webcrawler-connector`);
} else {
setActiveTab(value);
// Track tab view
trackSourcesTabViewed(Number(search_space_id), value);
}
};
// Track initial tab view
useEffect(() => {
trackSourcesTabViewed(Number(search_space_id), activeTab);
}, []);
return (
<div className="container mx-auto py-8 px-4 min-h-[calc(100vh-64px)]">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="space-y-6"
>
{/* Header */}
<div className="text-center space-y-2">
<h1 className="text-2xl sm:text-4xl font-bold tracking-tight flex items-center justify-center gap-3">
<Database className="h-6 w-6 sm:h-8 sm:w-8" />
Add Sources
</h1>
<p className="text-muted-foreground text-sm sm:text-lg">
Add your sources to your search space
</p>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full max-w-3xl mx-auto grid-cols-4 h-12">
<TabsTrigger value="documents" className="flex items-center gap-2">
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">Documents</span>
<span className="sm:hidden">Docs</span>
</TabsTrigger>
<TabsTrigger value="youtube" className="flex items-center gap-2">
<IconBrandYoutube className="h-4 w-4" />
YouTube
</TabsTrigger>
<TabsTrigger value="webpages" className="flex items-center gap-2">
<Globe className="h-4 w-4" />
<span className="hidden sm:inline">Web Pages</span>
<span className="sm:hidden">Web</span>
</TabsTrigger>
<TabsTrigger value="connectors" className="flex items-center gap-2">
<Cable className="h-4 w-4" />
<span className="hidden sm:inline">Connectors</span>
<span className="sm:hidden">More</span>
</TabsTrigger>
</TabsList>
<div className="mt-8">
<TabsContent value="documents" className="space-y-6">
<DocumentUploadTab searchSpaceId={search_space_id} />
</TabsContent>
<TabsContent value="youtube" className="space-y-6">
<YouTubeTab searchSpaceId={search_space_id} />
</TabsContent>
<TabsContent value="connectors" className="space-y-6">
<ConnectorsTab searchSpaceId={search_space_id} />
</TabsContent>
</div>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -159,4 +159,3 @@ button {
@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'; @source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}';
@source '../node_modules/streamdown/dist/*.js'; @source '../node_modules/streamdown/dist/*.js';

View file

@ -0,0 +1,120 @@
import {
ActionBarPrimitive,
AssistantIf,
ErrorPrimitive,
MessagePrimitive,
useAssistantState,
} from "@assistant-ui/react";
import { CheckIcon, CopyIcon, DownloadIcon, RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { useContext } from "react";
import { BranchPicker } from "@/components/assistant-ui/branch-picker";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import {
ThinkingStepsContext,
ThinkingStepsDisplay,
} from "@/components/assistant-ui/thinking-steps";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
export const MessageError: FC = () => {
return (
<MessagePrimitive.Error>
<ErrorPrimitive.Root className="aui-message-error-root mt-2 rounded-md border border-destructive bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2" />
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
);
};
/**
* Custom component to render thinking steps from Context
*/
const ThinkingStepsPart: FC = () => {
const thinkingStepsMap = useContext(ThinkingStepsContext);
// Get the current message ID to look up thinking steps
const messageId = useAssistantState(({ message }) => message?.id);
const thinkingSteps = thinkingStepsMap.get(messageId) || [];
// Check if this specific message is currently streaming
// A message is streaming if: thread is running AND this is the last assistant message
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
if (thinkingSteps.length === 0) return null;
return (
<div className="mb-3">
<ThinkingStepsDisplay steps={thinkingSteps} isThreadRunning={isMessageStreaming} />
</div>
);
};
const AssistantMessageInner: FC = () => {
return (
<>
{/* Render thinking steps from message content - this ensures proper scroll tracking */}
<ThinkingStepsPart />
<div className="aui-assistant-message-content wrap-break-word px-2 text-foreground leading-relaxed">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
tools: { Fallback: ToolFallback },
}}
/>
<MessageError />
</div>
<div className="aui-assistant-message-footer mt-1 mb-5 ml-2 flex">
<BranchPicker />
<AssistantActionBar />
</div>
</>
);
};
export 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
hideWhenRunning
autohide="not-last"
autohideFloat="single-branch"
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
>
<ActionBarPrimitive.Copy asChild>
<TooltipIconButton tooltip="Copy">
<AssistantIf condition={({ message }) => message.isCopied}>
<CheckIcon />
</AssistantIf>
<AssistantIf condition={({ message }) => !message.isCopied}>
<CopyIcon />
</AssistantIf>
</TooltipIconButton>
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.ExportMarkdown asChild>
<TooltipIconButton tooltip="Export as Markdown">
<DownloadIcon />
</TooltipIconButton>
</ActionBarPrimitive.ExportMarkdown>
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton tooltip="Refresh">
<RefreshCwIcon />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
</ActionBarPrimitive.Root>
);
};

View file

@ -7,15 +7,22 @@ import {
useAssistantApi, useAssistantApi,
useAssistantState, useAssistantState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { FileText, Loader2, PlusIcon, XIcon } from "lucide-react"; import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { type FC, type PropsWithChildren, useEffect, useState } from "react"; import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDocumentUploadDialog } from "./document-upload-popup";
const useFileSrc = (file: File | undefined) => { const useFileSrc = (file: File | undefined) => {
const [src, setSrc] = useState<string | undefined>(undefined); const [src, setSrc] = useState<string | undefined>(undefined);
@ -184,23 +191,26 @@ const AttachmentUI: FC = () => {
> >
<AttachmentPreviewDialog> <AttachmentPreviewDialog>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div <button
type="button"
className={cn( className={cn(
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75", "aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
isComposer && "aui-attachment-tile-composer border-foreground/20", isComposer && "aui-attachment-tile-composer border-foreground/20",
isProcessing && "animate-pulse" isProcessing && "animate-pulse"
)} )}
role="button"
id="attachment-tile" id="attachment-tile"
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`} aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
> >
<AttachmentThumb /> <AttachmentThumb />
</div> </button>
</TooltipTrigger> </TooltipTrigger>
</AttachmentPreviewDialog> </AttachmentPreviewDialog>
{isComposer && !isProcessing && <AttachmentRemove />} {isComposer && !isProcessing && <AttachmentRemove />}
</AttachmentPrimitive.Root> </AttachmentPrimitive.Root>
<TooltipContent side="top"> <TooltipContent
side="top"
className="bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none"
>
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
@ -309,18 +319,58 @@ export const ComposerAttachments: FC = () => {
}; };
export const ComposerAddAttachment: FC = () => { export const ComposerAddAttachment: FC = () => {
const chatAttachmentInputRef = useRef<HTMLInputElement>(null);
const { openDialog } = useDocumentUploadDialog();
const handleFileUpload = () => {
openDialog();
};
const handleChatAttachment = () => {
chatAttachmentInputRef.current?.click();
};
// Prevent event bubbling when file input is clicked
const handleFileInputClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.stopPropagation();
};
return ( return (
<ComposerPrimitive.AddAttachment asChild> <>
<TooltipIconButton <DropdownMenu>
tooltip="Add Attachment" <DropdownMenuTrigger asChild>
side="bottom" <TooltipIconButton
variant="ghost" tooltip="Upload"
size="icon" side="bottom"
className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" variant="ghost"
aria-label="Add Attachment" size="icon"
> className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" /> aria-label="Upload"
</TooltipIconButton> >
</ComposerPrimitive.AddAttachment> <PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
</TooltipIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-48 bg-background border-border">
<DropdownMenuItem onSelect={handleChatAttachment} className="cursor-pointer">
<Paperclip className="size-4" />
<span>Add attachment</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
<Upload className="size-4" />
<span>Upload Files</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ComposerPrimitive.AddAttachment asChild>
<input
ref={chatAttachmentInputRef}
type="file"
multiple
className="hidden"
accept="image/*,application/pdf,.doc,.docx,.txt"
onClick={handleFileInputClick}
/>
</ComposerPrimitive.AddAttachment>
</>
); );
}; };

View file

@ -0,0 +1,32 @@
import { BranchPickerPrimitive } from "@assistant-ui/react";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { cn } from "@/lib/utils";
export const BranchPicker: FC<BranchPickerPrimitive.Root.Props> = ({ className, ...rest }) => {
return (
<BranchPickerPrimitive.Root
hideWhenSingleBranch
className={cn(
"aui-branch-picker-root -ml-2 mr-2 inline-flex items-center text-muted-foreground text-xs",
className
)}
{...rest}
>
<BranchPickerPrimitive.Previous asChild>
<TooltipIconButton tooltip="Previous">
<ChevronLeftIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Previous>
<span className="aui-branch-picker-state font-medium">
<BranchPickerPrimitive.Number /> / <BranchPickerPrimitive.Count />
</span>
<BranchPickerPrimitive.Next asChild>
<TooltipIconButton tooltip="Next">
<ChevronRightIcon />
</TooltipIconButton>
</BranchPickerPrimitive.Next>
</BranchPickerPrimitive.Root>
);
};

View file

@ -0,0 +1,300 @@
import { AssistantIf, ComposerPrimitive, useAssistantState } from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import {
AlertCircle,
ArrowUpIcon,
ChevronRightIcon,
Loader2,
Plug2,
Plus,
SquareIcon,
} from "lucide-react";
import type { FC } from "react";
import { useCallback, useMemo, useRef, useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import {
globalNewLLMConfigsAtom,
llmPreferencesAtom,
newLLMConfigsAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ComposerAddAttachment } from "@/components/assistant-ui/attachment";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
import { cn } from "@/lib/utils";
const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const { connectors, isLoading: connectorsLoading } = useSearchSourceConnectors(
false,
searchSpaceId ? Number(searchSpaceId) : undefined
);
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
const [isOpen, setIsOpen] = useState(false);
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isLoading = connectorsLoading || documentTypesLoading;
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([_, count]) => count > 0)
: [];
// Count only active connectors (matching what's shown in the Active tab)
const activeConnectorsCount = connectors.length;
const hasConnectors = activeConnectorsCount > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const handleMouseEnter = useCallback(() => {
// Clear any pending close timeout
if (closeTimeoutRef.current) {
clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
}
setIsOpen(true);
}, []);
const handleMouseLeave = useCallback(() => {
// Delay closing by 150ms for better UX
closeTimeoutRef.current = setTimeout(() => {
setIsOpen(false);
}, 150);
}, []);
if (!searchSpaceId) return null;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
"outline-none focus:outline-none focus-visible:outline-none",
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none",
"data-[state=open]:bg-transparent data-[state=open]:shadow-none data-[state=open]:ring-0",
"text-muted-foreground"
)}
aria-label={
hasConnectors
? `View ${activeConnectorsCount} active connectors`
: "Add your first connector"
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Plug2 className="size-4" />
{activeConnectorsCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
</span>
)}
</>
)}
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-64 p-3"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{hasSources ? (
<div className="space-y-3">
{activeConnectorsCount > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">Active Connectors</p>
<span className="text-xs font-medium bg-muted px-1.5 py-0.5 rounded">
{activeConnectorsCount}
</span>
</div>
)}
{activeConnectorsCount > 0 && (
<div className="flex flex-wrap gap-2">
{connectors.map((connector) => (
<div
key={`connector-${connector.id}`}
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
>
{getConnectorIcon(connector.connector_type, "size-3.5")}
<span className="truncate max-w-[100px]">{connector.name}</span>
</div>
))}
</div>
)}
{activeDocumentTypes.length > 0 && (
<>
{activeConnectorsCount > 0 && (
<div className="pt-2 border-t border-border/50">
<p className="text-xs font-medium text-muted-foreground mb-2">Documents</p>
</div>
)}
<div className="flex flex-wrap gap-2">
{activeDocumentTypes.map(([docType, count]) => (
<div
key={docType}
className="flex items-center gap-1.5 rounded-md bg-muted/80 px-2.5 py-1.5 text-xs border border-border/50"
>
{getConnectorIcon(docType, "size-3.5")}
<span className="truncate max-w-[100px]">
{getDocumentTypeLabel(docType)}
</span>
<span className="flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-medium rounded-full bg-primary/10 text-primary">
{count > 999 ? "999+" : count}
</span>
</div>
))}
</div>
</>
)}
<div className="pt-1 border-t border-border/50">
<button
type="button"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => {
/* Connector popup should be opened via the connector indicator button */
}}
>
<Plus className="size-3" />
Add more sources
<ChevronRightIcon className="size-3" />
</button>
</div>
</div>
) : (
<div className="space-y-2">
<p className="text-sm font-medium">No sources yet</p>
<p className="text-xs text-muted-foreground">
Add documents or connect data sources to enhance search results.
</p>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors mt-1"
onClick={() => {
/* Connector popup should be opened via the connector indicator button */
}}
>
<Plus className="size-3" />
Add Connector
</button>
</div>
)}
</PopoverContent>
</Popover>
);
};
export const ComposerAction: FC = () => {
// Check if any attachments are still being processed (running AND progress < 100)
// When progress is 100, processing is done but waiting for send()
const hasProcessingAttachments = useAssistantState(({ composer }) =>
composer.attachments?.some((att) => {
const status = att.status;
if (status?.type !== "running") return false;
const progress = (status as { type: "running"; progress?: number }).progress;
return progress === undefined || progress < 100;
})
);
// Check if composer text is empty
const isComposerEmpty = useAssistantState(({ composer }) => {
const text = composer.text?.trim() || "";
return text.length === 0;
});
// Check if a model is configured
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom);
const { data: preferences } = useAtomValue(llmPreferencesAtom);
const hasModelConfigured = useMemo(() => {
if (!preferences) return false;
const agentLlmId = preferences.agent_llm_id;
if (agentLlmId === null || agentLlmId === undefined) return false;
// Check if the configured model actually exists
if (agentLlmId < 0) {
return globalConfigs?.some((c) => c.id === agentLlmId) ?? false;
}
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
}, [preferences, globalConfigs, userConfigs]);
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
return (
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
<div className="flex items-center gap-1">
<ComposerAddAttachment />
<ConnectorIndicator />
</div>
{/* Show processing indicator when attachments are being processed */}
{hasProcessingAttachments && (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<Loader2 className="size-3 animate-spin" />
<span>Processing...</span>
</div>
)}
{/* Show warning when no model is configured */}
{!hasModelConfigured && !hasProcessingAttachments && (
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
<AlertCircle className="size-3" />
<span>Select a model</span>
</div>
)}
<AssistantIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
!hasModelConfigured
? "Please select a model from the header to start chatting"
: hasProcessingAttachments
? "Wait for attachments to process"
: isComposerEmpty
? "Enter a message to send"
: "Send message"
}
side="bottom"
type="submit"
variant="default"
size="icon"
className={cn(
"aui-composer-send size-8 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
disabled={isSendDisabled}
>
<ArrowUpIcon className="aui-composer-send-icon size-4" />
</TooltipIconButton>
</ComposerPrimitive.Send>
</AssistantIf>
<AssistantIf condition={({ thread }) => thread.isRunning}>
<ComposerPrimitive.Cancel asChild>
<Button
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-8 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3 fill-current" />
</Button>
</ComposerPrimitive.Cancel>
</AssistantIf>
</div>
);
};

View file

@ -0,0 +1,236 @@
import { ComposerPrimitive, useAssistantState, useComposerRuntime } from "@assistant-ui/react";
import { useAtom, useSetAtom } from "jotai";
import { useParams } from "next/navigation";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
mentionedDocumentIdsAtom,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
import { ComposerAction } from "@/components/assistant-ui/composer-action";
import {
InlineMentionEditor,
type InlineMentionEditorRef,
} from "@/components/assistant-ui/inline-mention-editor";
import {
DocumentMentionPicker,
type DocumentMentionPickerRef,
} from "@/components/new-chat/document-mention-picker";
import type { Document } from "@/contracts/types/document.types";
export const Composer: FC = () => {
// ---- State for document mentions (using atoms to persist across remounts) ----
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const { search_space_id } = useParams();
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
const composerRuntime = useComposerRuntime();
const hasAutoFocusedRef = useRef(false);
// Check if thread is empty (new chat)
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
// Check if thread is currently running (streaming response)
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
// Auto-focus editor when on new chat page
useEffect(() => {
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
// Small delay to ensure the editor is fully mounted
const timeoutId = setTimeout(() => {
editorRef.current?.focus();
hasAutoFocusedRef.current = true;
}, 100);
return () => clearTimeout(timeoutId);
}
}, [isThreadEmpty]);
// Sync mentioned document IDs to atom for use in chat request
useEffect(() => {
setMentionedDocumentIds(mentionedDocuments.map((doc) => doc.id));
}, [mentionedDocuments, setMentionedDocumentIds]);
// Handle text change from inline editor - sync with assistant-ui composer
const handleEditorChange = useCallback(
(text: string) => {
composerRuntime.setText(text);
},
[composerRuntime]
);
// Handle @ mention trigger from inline editor
const handleMentionTrigger = useCallback((query: string) => {
setShowDocumentPopover(true);
setMentionQuery(query);
}, []);
// Handle mention close
const handleMentionClose = useCallback(() => {
if (showDocumentPopover) {
setShowDocumentPopover(false);
setMentionQuery("");
}
}, [showDocumentPopover]);
// Handle keyboard navigation when popover is open
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (showDocumentPopover) {
if (e.key === "ArrowDown") {
e.preventDefault();
documentPickerRef.current?.moveDown();
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
documentPickerRef.current?.moveUp();
return;
}
if (e.key === "Enter") {
e.preventDefault();
documentPickerRef.current?.selectHighlighted();
return;
}
if (e.key === "Escape") {
e.preventDefault();
setShowDocumentPopover(false);
setMentionQuery("");
return;
}
}
},
[showDocumentPopover]
);
// Handle submit from inline editor (Enter key)
const handleSubmit = useCallback(() => {
// Prevent sending while a response is still streaming
if (isThreadRunning) {
return;
}
if (!showDocumentPopover) {
composerRuntime.send();
// Clear the editor after sending
editorRef.current?.clear();
setMentionedDocuments([]);
setMentionedDocumentIds([]);
}
}, [
showDocumentPopover,
isThreadRunning,
composerRuntime,
setMentionedDocuments,
setMentionedDocumentIds,
]);
// Handle document removal from inline editor
const handleDocumentRemove = useCallback(
(docId: number) => {
setMentionedDocuments((prev) => {
const updated = prev.filter((doc) => doc.id !== docId);
// Immediately sync document IDs to avoid race conditions
setMentionedDocumentIds(updated.map((doc) => doc.id));
return updated;
});
},
[setMentionedDocuments, setMentionedDocumentIds]
);
// Handle document selection from picker
const handleDocumentsMention = useCallback(
(documents: Document[]) => {
// Insert chips into the inline editor for each new document
const existingIds = new Set(mentionedDocuments.map((d) => d.id));
const newDocs = documents.filter((doc) => !existingIds.has(doc.id));
for (const doc of newDocs) {
editorRef.current?.insertDocumentChip(doc);
}
// Update mentioned documents state
setMentionedDocuments((prev) => {
const existingIdSet = new Set(prev.map((d) => d.id));
const uniqueNewDocs = documents.filter((doc) => !existingIdSet.has(doc.id));
const updated = [...prev, ...uniqueNewDocs];
// Immediately sync document IDs to avoid race conditions
setMentionedDocumentIds(updated.map((doc) => doc.id));
return updated;
});
// Reset mention query but keep popover open for more selections
setMentionQuery("");
},
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
);
return (
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
<ComposerAttachments />
{/* -------- Inline Mention Editor -------- */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
placeholder="Ask SurfSense or @mention docs"
onMentionTrigger={handleMentionTrigger}
onMentionClose={handleMentionClose}
onChange={handleEditorChange}
onDocumentRemove={handleDocumentRemove}
onSubmit={handleSubmit}
onKeyDown={handleKeyDown}
className="min-h-[24px]"
/>
</div>
{/* -------- Document mention popover (rendered via portal) -------- */}
{showDocumentPopover &&
typeof document !== "undefined" &&
createPortal(
<>
{/* Backdrop */}
<button
type="button"
className="fixed inset-0 cursor-default"
style={{ zIndex: 9998 }}
onClick={() => setShowDocumentPopover(false)}
aria-label="Close document picker"
/>
{/* Popover positioned above input */}
<div
className="fixed shadow-2xl rounded-lg border border-border overflow-hidden bg-popover"
style={{
zIndex: 9999,
bottom: editorContainerRef.current
? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px`
: "200px",
left: editorContainerRef.current
? `${editorContainerRef.current.getBoundingClientRect().left}px`
: "50%",
}}
>
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
</>,
document.body
)}
<ComposerAction />
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Root>
);
};

View file

@ -0,0 +1,316 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Cable, Loader2 } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { type FC, useEffect, useMemo } from "react";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useLogsSummary } from "@/hooks/use-logs";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams();
const { data: documentTypeCounts, isLoading: documentTypesLoading } =
useAtomValue(documentTypeCountsAtom);
// Check if YouTube view is active
const isYouTubeView = searchParams.get("view") === "youtube";
// Track active indexing tasks
const { summary: logsSummary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
enablePolling: true,
refetchInterval: 5000,
});
// Use the custom hook for dialog state management
const {
isOpen,
activeTab,
connectingId,
isScrolled,
searchQuery,
indexingConfig,
indexingConnector,
indexingConnectorConfig,
editingConnector,
connectingConnectorType,
isCreatingConnector,
startDate,
endDate,
isStartingIndexing,
isSaving,
isDisconnecting,
periodicEnabled,
frequencyMinutes,
allConnectors,
setSearchQuery,
setStartDate,
setEndDate,
setPeriodicEnabled,
setFrequencyMinutes,
handleOpenChange,
handleTabChange,
handleScroll,
handleConnectOAuth,
handleConnectNonOAuth,
handleCreateWebcrawler,
handleCreateYouTubeCrawler,
handleSubmitConnectForm,
handleStartIndexing,
handleSkipIndexing,
handleStartEdit,
handleSaveConnector,
handleDisconnectConnector,
handleBackFromEdit,
handleBackFromConnect,
handleBackFromYouTube,
handleQuickIndexConnector,
connectorConfig,
setConnectorConfig,
setIndexingConnectorConfig,
setConnectorName,
} = useConnectorDialog();
// Fetch connectors using React Query with conditional refetchInterval
// This automatically refetches when mutations invalidate the cache (event-driven)
// and also polls when dialog is open to catch external changes
const {
data: connectors = [],
isLoading: connectorsLoading,
refetch: refreshConnectors,
} = useQuery({
queryKey: cacheKeys.connectors.all(searchSpaceId || ""),
queryFn: () =>
connectorsApiService.getConnectors({
queryParams: {
search_space_id: searchSpaceId ? Number(searchSpaceId) : undefined,
},
}),
enabled: !!searchSpaceId,
staleTime: 5 * 60 * 1000, // 5 minutes (same as connectorsAtom)
// Poll when dialog is open to catch external changes
refetchInterval: isOpen ? 5000 : false, // 5 seconds when open, no polling when closed
});
const queryClient = useQueryClient();
// Also refresh document type counts when dialog is open
useEffect(() => {
if (!isOpen || !searchSpaceId) return;
const POLL_INTERVAL = 5000; // 5 seconds, same as connectors
const intervalId = setInterval(() => {
// Invalidate document type counts to refresh active document types
queryClient.invalidateQueries({
queryKey: cacheKeys.documents.typeCounts(searchSpaceId),
});
}, POLL_INTERVAL);
// Cleanup interval on unmount or when dialog closes
return () => {
clearInterval(intervalId);
};
}, [isOpen, searchSpaceId, queryClient]);
// Get connector IDs that are currently being indexed
const indexingConnectorIds = useMemo(() => {
if (!logsSummary?.active_tasks) return new Set<number>();
return new Set(
logsSummary.active_tasks
.filter((task) => task.source?.includes("connector_indexing") && task.connector_id != null)
.map((task) => task.connector_id as number)
);
}, [logsSummary?.active_tasks]);
const isLoading = connectorsLoading || documentTypesLoading;
// Get document types that have documents in the search space
const activeDocumentTypes = documentTypeCounts
? Object.entries(documentTypeCounts).filter(([, count]) => count > 0)
: [];
const hasConnectors = connectors.length > 0;
const hasSources = hasConnectors || activeDocumentTypes.length > 0;
const totalSourceCount = connectors.length + activeDocumentTypes.length;
const activeConnectorsCount = connectors.length; // Only actual connectors, not document types
// Check which connectors are already connected
const connectedTypes = new Set(
(allConnectors || []).map((c: SearchSourceConnector) => c.connector_type)
);
if (!searchSpaceId) return null;
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<TooltipIconButton
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
side="bottom"
className={cn(
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
)}
aria-label={
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
}
onClick={() => handleOpenChange(true)}
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<>
<Cable className="size-4 stroke-[1.5px]" />
{activeConnectorsCount > 0 && (
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
</span>
)}
</>
)}
</TooltipIconButton>
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[90vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-6 sm:[&>button]:right-12 [&>button]:top-8 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
{/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : connectingConnectorType ? (
<ConnectorConnectView
connectorType={connectingConnectorType}
onSubmit={handleSubmitConnectForm}
onBack={handleBackFromConnect}
isSubmitting={isCreatingConnector}
/>
) : editingConnector ? (
<ConnectorEditView
connector={{
...editingConnector,
config: connectorConfig || editingConnector.config,
name: editingConnector.name,
}}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
isSaving={isSaving}
isDisconnecting={isDisconnecting}
isIndexing={indexingConnectorIds.has(editingConnector.id)}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onSave={() => handleSaveConnector(() => refreshConnectors())}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit}
onQuickIndex={
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => handleQuickIndexConnector(editingConnector.id)
: undefined
}
onConfigChange={setConnectorConfig}
onNameChange={setConnectorName}
/>
) : indexingConfig ? (
<IndexingConfigurationView
config={indexingConfig}
connector={
indexingConnector
? {
...indexingConnector,
config: indexingConnectorConfig || indexingConnector.config,
}
: undefined
}
startDate={startDate}
endDate={endDate}
periodicEnabled={periodicEnabled}
frequencyMinutes={frequencyMinutes}
isStartingIndexing={isStartingIndexing}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
onPeriodicEnabledChange={setPeriodicEnabled}
onFrequencyChange={setFrequencyMinutes}
onConfigChange={setIndexingConnectorConfig}
onStartIndexing={() => handleStartIndexing(() => refreshConnectors())}
onSkip={handleSkipIndexing}
/>
) : (
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col min-h-0"
>
{/* Header */}
<ConnectorDialogHeader
activeTab={activeTab}
totalSourceCount={activeConnectorsCount}
searchQuery={searchQuery}
onTabChange={handleTabChange}
onSearchChange={setSearchQuery}
isScrolled={isScrolled}
/>
{/* Content */}
<div className="flex-1 min-h-0 relative overflow-hidden">
<div className="h-full overflow-y-auto" onScroll={handleScroll}>
<div className="px-6 sm:px-12 py-6 sm:py-8 pb-16 sm:pb-16">
<TabsContent value="all" className="m-0">
<AllConnectorsTab
searchQuery={searchQuery}
searchSpaceId={searchSpaceId}
connectedTypes={connectedTypes}
connectingId={connectingId}
allConnectors={allConnectors}
documentTypeCounts={documentTypeCounts}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
onConnectOAuth={handleConnectOAuth}
onConnectNonOAuth={handleConnectNonOAuth}
onCreateWebcrawler={handleCreateWebcrawler}
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit}
/>
</TabsContent>
<ActiveConnectorsTab
searchQuery={searchQuery}
hasSources={hasSources}
totalSourceCount={totalSourceCount}
activeDocumentTypes={activeDocumentTypes}
connectors={connectors as SearchSourceConnector[]}
indexingConnectorIds={indexingConnectorIds}
logsSummary={logsSummary}
searchSpaceId={searchSpaceId}
onTabChange={handleTabChange}
onManage={handleStartEdit}
/>
</div>
</div>
{/* Bottom fade shadow */}
<div className="absolute bottom-0 left-0 right-0 h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
</div>
</Tabs>
)}
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,151 @@
"use client";
import { IconBrandYoutube } from "@tabler/icons-react";
import { format } from "date-fns";
import { FileText, Loader2 } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { LogActiveTask } from "@/contracts/types/log.types";
import { cn } from "@/lib/utils";
interface ConnectorCardProps {
id: string;
title: string;
description: string;
connectorType?: string;
isConnected?: boolean;
isConnecting?: boolean;
documentCount?: number;
lastIndexedAt?: string | null;
isIndexing?: boolean;
activeTask?: LogActiveTask;
onConnect?: () => void;
onManage?: () => void;
}
/**
* Extract a number from the active task message for display
* Looks for patterns like "45 indexed", "Processing 123", etc.
*/
function extractIndexedCount(message: string | undefined): number | null {
if (!message) return null;
// Try to find a number in the message
const match = message.match(/(\d+)/);
return match ? parseInt(match[1], 10) : null;
}
/**
* Format document count (e.g., "1.2k docs", "500 docs", "1.5M docs")
*/
function formatDocumentCount(count: number | undefined): string {
if (count === undefined || count === 0) return "0 docs";
if (count < 1000) return `${count} docs`;
if (count < 1000000) {
const k = (count / 1000).toFixed(1);
return `${k.replace(/\.0$/, "")}k docs`;
}
const m = (count / 1000000).toFixed(1);
return `${m.replace(/\.0$/, "")}M docs`;
}
export const ConnectorCard: FC<ConnectorCardProps> = ({
id,
title,
description,
connectorType,
isConnected = false,
isConnecting = false,
documentCount,
lastIndexedAt,
isIndexing = false,
activeTask,
onConnect,
onManage,
}) => {
// Extract count from active task message during indexing
const indexingCount = extractIndexedCount(activeTask?.message);
// Determine the status content to display
const getStatusContent = () => {
if (isIndexing) {
return (
<div className="flex items-center gap-2 w-full max-w-[200px]">
<span className="text-[11px] text-primary font-medium whitespace-nowrap">
{indexingCount !== null ? <>{indexingCount.toLocaleString()} indexed</> : "Syncing..."}
</span>
{/* Indeterminate progress bar with animation */}
<div className="relative flex-1 h-1 overflow-hidden rounded-full bg-primary/20">
<div className="absolute h-full bg-primary rounded-full animate-progress-indeterminate" />
</div>
</div>
);
}
if (isConnected) {
// Show last indexed date for connected connectors
if (lastIndexedAt) {
return (
<span className="whitespace-nowrap">
Last indexed: {format(new Date(lastIndexedAt), "MMM d, yyyy")}
</span>
);
}
// Fallback for connected but never indexed
return <span className="whitespace-nowrap">Never indexed</span>;
}
return description;
};
return (
<div className="group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10">
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors flex-shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
{connectorType ? (
getConnectorIcon(connectorType, "size-6")
) : id === "youtube-crawler" ? (
<IconBrandYoutube className="size-6" />
) : (
<FileText className="size-6" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[14px] font-semibold leading-tight">{title}</span>
</div>
<div className="text-[11px] text-muted-foreground mt-1">{getStatusContent()}</div>
{isConnected && documentCount !== undefined && (
<p className="text-[11px] text-muted-foreground mt-0.5">
{formatDocumentCount(documentCount)}
</p>
)}
</div>
<Button
size="sm"
variant={isConnected ? "secondary" : "default"}
className={cn(
"h-8 text-[11px] px-3 rounded-lg flex-shrink-0 font-medium",
isConnected &&
"bg-white text-slate-700 hover:bg-slate-50 border-0 shadow-xs dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80",
!isConnected && "shadow-xs"
)}
onClick={isConnected ? onManage : onConnect}
disabled={isConnecting || isIndexing}
>
{isConnecting ? (
<Loader2 className="size-3 animate-spin" />
) : isIndexing ? (
"Syncing..."
) : isConnected ? (
"Manage"
) : id === "youtube-crawler" ? (
"Add"
) : connectorType ? (
"Connect"
) : (
"Add"
)}
</Button>
</div>
);
};

View file

@ -0,0 +1,92 @@
"use client";
import { Search, X } from "lucide-react";
import type { FC } from "react";
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
interface ConnectorDialogHeaderProps {
activeTab: string;
totalSourceCount: number;
searchQuery: string;
onTabChange: (value: string) => void;
onSearchChange: (query: string) => void;
isScrolled: boolean;
}
export const ConnectorDialogHeader: FC<ConnectorDialogHeaderProps> = ({
totalSourceCount,
searchQuery,
onSearchChange,
isScrolled,
}) => {
return (
<div
className={cn(
"flex-shrink-0 px-6 sm:px-12 pt-8 sm:pt-10 transition-shadow duration-200 relative z-10",
isScrolled && "shadow-xl bg-muted/50 backdrop-blur-md"
)}
>
<DialogHeader>
<DialogTitle className="text-2xl sm:text-3xl font-semibold tracking-tight">
Connectors
</DialogTitle>
<DialogDescription className="text-sm sm:text-base text-muted-foreground/80 mt-1 sm:mt-1.5">
Search across all your apps and data in one place.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col-reverse sm:flex-row sm:items-end justify-between gap-6 sm:gap-8 mt-6 sm:mt-8 border-b border-border/80 dark:border-white/5">
<TabsList className="bg-transparent p-0 gap-4 sm:gap-8 h-auto w-full sm:w-auto justify-center sm:justify-start">
<TabsTrigger
value="all"
className="px-0 pb-3 bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none rounded-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white transition-all text-base font-medium text-muted-foreground data-[state=active]:text-foreground"
>
All Connectors
</TabsTrigger>
<TabsTrigger
value="active"
className="group px-0 pb-3 bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none rounded-none border-b-[1.5px] border-transparent transition-all text-base font-medium flex items-center gap-2 text-muted-foreground data-[state=active]:text-foreground relative"
>
<span className="relative">
Active
<span className="absolute bottom-[-13.5px] left-1/2 -translate-x-1/2 w-12 h-[1.5px] bg-foreground dark:bg-white opacity-0 group-data-[state=active]:opacity-100 transition-all duration-200" />
</span>
{totalSourceCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full bg-muted-foreground/15 text-[10px] font-bold">
{totalSourceCount}
</span>
)}
</TabsTrigger>
</TabsList>
<div className="w-full sm:w-72 sm:pb-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground/60" />
<input
type="text"
placeholder="Search"
className={cn(
"w-full bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10 focus:bg-slate-400/10 dark:focus:bg-white/10 border border-border rounded-xl pl-9 py-2 text-sm transition-all outline-none placeholder:text-muted-foreground/50",
searchQuery ? "pr-9" : "pr-4"
)}
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchQuery && (
<button
type="button"
onClick={() => onSearchChange("")}
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
aria-label="Clear search"
>
<X className="size-4" />
</button>
)}
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,144 @@
"use client";
import { format, subDays, subYears } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface DateRangeSelectorProps {
startDate: Date | undefined;
endDate: Date | undefined;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
}
export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
startDate,
endDate,
onStartDateChange,
onEndDateChange,
}) => {
const handleLast30Days = () => {
const today = new Date();
onStartDateChange(subDays(today, 30));
onEndDateChange(today);
};
const handleLastYear = () => {
const today = new Date();
onStartDateChange(subYears(today, 1));
onEndDateChange(today);
};
const handleClearDates = () => {
onStartDateChange(undefined);
onEndDateChange(undefined);
};
return (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<h3 className="font-medium text-sm sm:text-base mb-4">Select Date Range</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-6">
Choose how far back you want to sync your data. You can always re-index later with different
dates.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Start Date */}
<div className="space-y-2">
<Label htmlFor="start-date" className="text-xs sm:text-sm">
Start Date
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
id="start-date"
variant="outline"
className={cn(
"w-full justify-start text-left font-normal bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm",
!startDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{startDate ? format(startDate, "PPP") : "Default (1 year ago)"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 z-[100]" align="start">
<Calendar
mode="single"
selected={startDate}
onSelect={onStartDateChange}
disabled={(date) => date > new Date()}
/>
</PopoverContent>
</Popover>
</div>
{/* End Date */}
<div className="space-y-2">
<Label htmlFor="end-date" className="text-xs sm:text-sm">
End Date
</Label>
<Popover>
<PopoverTrigger asChild>
<Button
id="end-date"
variant="outline"
className={cn(
"w-full justify-start text-left font-normal bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm",
!endDate && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{endDate ? format(endDate, "PPP") : "Default (Today)"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0 z-[100]" align="start">
<Calendar
mode="single"
selected={endDate}
onSelect={onEndDateChange}
disabled={(date) => date > new Date() || (startDate ? date < startDate : false)}
/>
</PopoverContent>
</Popover>
</div>
</div>
{/* Quick date range buttons */}
<div className="flex flex-wrap gap-2 mt-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleClearDates}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
>
Clear Dates
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLast30Days}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
>
Last 30 Days
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleLastYear}
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
>
Last Year
</Button>
</div>
</div>
);
};

View file

@ -0,0 +1,78 @@
"use client";
import type { FC } from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
interface PeriodicSyncConfigProps {
enabled: boolean;
frequencyMinutes: string;
onEnabledChange: (enabled: boolean) => void;
onFrequencyChange: (frequency: string) => void;
}
export const PeriodicSyncConfig: FC<PeriodicSyncConfigProps> = ({
enabled,
frequencyMinutes,
onEnabledChange,
onFrequencyChange,
}) => {
return (
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch checked={enabled} onCheckedChange={onEnabledChange} />
</div>
{enabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select value={frequencyMinutes} onValueChange={onFrequencyChange}>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,159 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const baiduSearchApiFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
type BaiduSearchApiFormValues = z.infer<typeof baiduSearchApiFormSchema>;
export const BaiduSearchApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const form = useForm<BaiduSearchApiFormValues>({
resolver: zodResolver(baiduSearchApiFormSchema),
defaultValues: {
name: "Baidu Search Connector",
api_key: "",
},
});
const handleSubmit = async (values: BaiduSearchApiFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.BAIDU_SEARCH_API,
config: {
BAIDU_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Baidu AppBuilder API key to use this connector. You can get one by signing
up at{" "}
<a
href="https://qianfan.cloud.baidu.com/"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
qianfan.cloud.baidu.com
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="baidu-search-api-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Baidu Search Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Baidu AppBuilder API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Baidu API key"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.BAIDU_SEARCH_API) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Baidu Search:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.BAIDU_SEARCH_API)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,421 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const bookstackConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z.string().url({ message: "Please enter a valid BookStack base URL." }),
token_id: z.string().min(1, {
message: "BookStack Token ID is required.",
}),
token_secret: z.string().min(1, {
message: "BookStack Token Secret is required.",
}),
});
type BookStackConnectorFormValues = z.infer<typeof bookstackConnectorFormSchema>;
export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<BookStackConnectorFormValues>({
resolver: zodResolver(bookstackConnectorFormSchema),
defaultValues: {
name: "BookStack Connector",
base_url: "",
token_id: "",
token_secret: "",
},
});
const handleSubmit = async (values: BookStackConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.BOOKSTACK_CONNECTOR,
config: {
BOOKSTACK_BASE_URL: values.base_url,
BOOKSTACK_TOKEN_ID: values.token_id,
BOOKSTACK_TOKEN_SECRET: values.token_secret,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a BookStack API Token to use this connector. You can create one from your
BookStack instance settings.
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="bookstack-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My BookStack Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">BookStack Base URL</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://your-bookstack-instance.com"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
The base URL of your BookStack instance (e.g.,
https://your-bookstack-instance.com).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token_id"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Token ID</FormLabel>
<FormControl>
<Input
placeholder="Your Token ID"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your BookStack API Token ID.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="token_secret"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Token Secret</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Token Secret"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your BookStack API Token Secret will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.BOOKSTACK_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">
What you get with BookStack integration:
</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.BOOKSTACK_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The BookStack connector uses the BookStack REST API to fetch all pages from your
BookStack instance that your account has access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves pages that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create an API token from your BookStack instance. The token requires
"Access System API" permission.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your BookStack instance</li>
<li>Click on your profile icon Edit Profile</li>
<li>Navigate to the "API Tokens" tab</li>
<li>Click "Create Token" and give it a name</li>
<li>Copy both the Token ID and Token Secret</li>
<li>Paste them in the form above</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Your user account must have "Access System API" permission. The connector will
only index content your account can view.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Rate Limiting</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
BookStack API has a rate limit of 180 requests per minute. The connector
automatically handles rate limiting to ensure reliable indexing.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>BookStack</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>BookStack Instance URL</strong> (e.g.,
https://docs.example.com)
</li>
<li>
Enter your <strong>Token ID</strong> and <strong>Token Secret</strong> from your
BookStack API token.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your BookStack pages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The BookStack connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All pages from your BookStack instance</li>
<li>Page content in Markdown format</li>
<li>Page titles and metadata</li>
<li>Book and chapter hierarchy information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,122 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info, Webhook } from "lucide-react";
import type { FC } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const circlebackFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
});
type CirclebackFormValues = z.infer<typeof circlebackFormSchema>;
export const CirclebackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const form = useForm<CirclebackFormValues>({
resolver: zodResolver(circlebackFormSchema),
defaultValues: {
name: "Circleback Connector",
},
});
const handleSubmit = async (values: CirclebackFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.CIRCLEBACK_CONNECTOR,
config: {},
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Webhook className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Webhook-Based Integration</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
Circleback uses webhooks to automatically send meeting data. After connecting, you'll
receive a webhook URL to configure in your Circleback settings.
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="circleback-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Circleback Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.CIRCLEBACK_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Circleback:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.CIRCLEBACK_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,382 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const clickupConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_token: z.string().min(10, {
message: "ClickUp API Token is required and must be valid.",
}),
});
type ClickUpConnectorFormValues = z.infer<typeof clickupConnectorFormSchema>;
export const ClickUpConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<ClickUpConnectorFormValues>({
resolver: zodResolver(clickupConnectorFormSchema),
defaultValues: {
name: "ClickUp Connector",
api_token: "",
},
});
const handleSubmit = async (values: ClickUpConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.CLICKUP_CONNECTOR,
config: {
CLICKUP_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a ClickUp API Token to use this connector. You can create one from{" "}
<a
href="https://app.clickup.com/settings/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
ClickUp Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="clickup-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My ClickUp Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">ClickUp API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="pk_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your ClickUp API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with ClickUp integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.CLICKUP_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The ClickUp connector uses the ClickUp API to fetch all tasks and projects that your
API token has access to within your workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves tasks that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need a ClickUp personal API token to use this connector. The token will be
used to read your ClickUp data.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get Your API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your ClickUp account</li>
<li>Click your avatar in the upper-right corner and select "Settings"</li>
<li>In the sidebar, click "Apps"</li>
<li>
Under "API Token", click <strong>Generate</strong> or{" "}
<strong>Regenerate</strong>
</li>
<li>Copy the generated token (it typically starts with "pk_")</li>
<li>
Paste it in the form above. You can also visit{" "}
<a
href="https://app.clickup.com/settings/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
ClickUp API Settings
</a>{" "}
directly.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API Token will have access to all tasks and projects that your user
account can see. Make sure your account has appropriate permissions for the
workspaces you want to index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only tasks, comments, and basic metadata will be indexed. ClickUp
attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>ClickUp</strong>{" "}
Connector.
</li>
<li>
Place your <strong>API Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your ClickUp tasks will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The ClickUp connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Task names and descriptions</li>
<li>Task comments and discussion threads</li>
<li>Task status, priority, and assignee information</li>
<li>Project and workspace information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,448 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const confluenceConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z.string().url({ message: "Please enter a valid Confluence base URL." }),
email: z.string().email({ message: "Please enter a valid email address." }),
api_token: z.string().min(10, {
message: "Confluence API Token is required and must be valid.",
}),
});
type ConfluenceConnectorFormValues = z.infer<typeof confluenceConnectorFormSchema>;
export const ConfluenceConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<ConfluenceConnectorFormValues>({
resolver: zodResolver(confluenceConnectorFormSchema),
defaultValues: {
name: "Confluence Connector",
base_url: "",
email: "",
api_token: "",
},
});
const handleSubmit = async (values: ConfluenceConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.CONFLUENCE_CONNECTOR,
config: {
CONFLUENCE_BASE_URL: values.base_url,
CONFLUENCE_EMAIL: values.email,
CONFLUENCE_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Confluence API Token to use this connector. You can create one from{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="confluence-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Confluence Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Confluence Base URL</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://your-domain.atlassian.net"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
The base URL of your Confluence instance (e.g.,
https://your-domain.atlassian.net).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Email Address</FormLabel>
<FormControl>
<Input
type="email"
placeholder="your-email@example.com"
autoComplete="email"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
The email address associated with your Atlassian account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your API Token"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Confluence API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.CONFLUENCE_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">
What you get with Confluence integration:
</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.CONFLUENCE_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Confluence connector uses the Confluence REST API to fetch all pages and
comments that your account has access to within your Confluence instance.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves pages and comments that have
been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Read-Only Access is Sufficient
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You only need read access for this connector to work. The API Token will only be
used to read your Confluence data.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your Atlassian account</li>
<li>
Navigate to{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://id.atlassian.com/manage-profile/security/api-tokens
</a>{" "}
in your browser.
</li>
<li>
Click <strong>Create API token</strong>
</li>
<li>Enter a label for your token (like "SurfSense Connector")</li>
<li>
Click <strong>Create</strong>
</li>
<li>Copy the generated token as it will only be shown once</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API Token will have access to all spaces and pages that your user account
can see. Make sure your account has appropriate permissions for the spaces you
want to index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only pages, comments, and basic metadata will be indexed. Confluence
attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Confluence</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>Confluence Instance URL</strong> (e.g.,
https://yourcompany.atlassian.net)
</li>
<li>
Enter your <strong>Email Address</strong> associated with your Atlassian account
</li>
<li>
Place your <strong>API Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Confluence pages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Confluence connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All pages from accessible spaces</li>
<li>Page content and metadata</li>
<li>Comments on pages (both footer and inline comments)</li>
<li>Page titles and descriptions</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,406 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const discordConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z.string().min(10, {
message: "Discord Bot Token is required and must be valid.",
}),
});
type DiscordConnectorFormValues = z.infer<typeof discordConnectorFormSchema>;
export const DiscordConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<DiscordConnectorFormValues>({
resolver: zodResolver(discordConnectorFormSchema),
defaultValues: {
name: "Discord Connector",
bot_token: "",
},
});
const handleSubmit = async (values: DiscordConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.DISCORD_CONNECTOR,
config: {
DISCORD_BOT_TOKEN: values.bot_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Bot Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Discord Bot Token to use this connector. You can create one from{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Discord Developer Portal
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="discord-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Discord Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Discord Bot Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Bot Token"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Discord Bot Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.DISCORD_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Discord integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.DISCORD_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Discord connector uses the Discord API to fetch messages from all accessible
channels that the bot token has access to within a server.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves messages that have been
updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Bot Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create a Discord application and bot to get a bot token. The bot
needs read access to channels and messages.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create a Discord Application
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to{" "}
<a
href="https://discord.com/developers/applications"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://discord.com/developers/applications
</a>
</li>
<li>
Click <strong>New Application</strong>
</li>
<li>
Enter an application name and click <strong>Create</strong>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Create a Bot
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Navigate to <strong>Bot</strong> in the sidebar
</li>
<li>
Click <strong>Add Bot</strong> and confirm
</li>
<li>
Under <strong>Privileged Gateway Intents</strong>, enable:
<ul className="list-disc pl-5 mt-1 space-y-1">
<li>
<code className="bg-muted px-1 py-0.5 rounded">
MESSAGE CONTENT INTENT
</code>{" "}
- Required to read message content
</li>
</ul>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Get Bot Token and Invite Bot
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Under <strong>Token</strong>, click <strong>Reset Token</strong> and copy
the token
</li>
<li>
Navigate to <strong>OAuth2 URL Generator</strong>
</li>
<li>
Select <strong>bot</strong> scope and <strong>Read Messages</strong>{" "}
permission
</li>
<li>Copy the generated URL and open it in your browser</li>
<li>Select your server and authorize the bot</li>
</ol>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Discord</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Bot Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Discord messages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Discord connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Messages from all accessible channels</li>
<li>Direct messages (if bot has access)</li>
<li>Message timestamps and metadata</li>
<li>Thread replies and conversations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,888 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import * as RadioGroup from "@radix-ui/react-radio-group";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useId, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const elasticsearchConnectorFormSchema = z
.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
endpoint_url: z.string().url({ message: "Please enter a valid Elasticsearch endpoint URL." }),
auth_method: z.enum(["basic", "api_key"]),
username: z.string().optional(),
password: z.string().optional(),
ELASTICSEARCH_API_KEY: z.string().optional(),
indices: z.string().optional(),
query: z.string(),
search_fields: z.string().optional(),
max_documents: z.number().min(1).max(10000).optional(),
})
.refine(
(data) => {
if (data.auth_method === "basic") {
return Boolean(data.username?.trim() && data.password?.trim());
}
if (data.auth_method === "api_key") {
return Boolean(data.ELASTICSEARCH_API_KEY?.trim());
}
return true;
},
{
message: "Authentication credentials are required for the selected method.",
path: ["auth_method"],
}
);
type ElasticsearchConnectorFormValues = z.infer<typeof elasticsearchConnectorFormSchema>;
export const ElasticsearchConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const authBasicId = useId();
const authApiKeyId = useId();
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<ElasticsearchConnectorFormValues>({
resolver: zodResolver(elasticsearchConnectorFormSchema),
defaultValues: {
name: "Elasticsearch Connector",
endpoint_url: "",
auth_method: "api_key",
username: "",
password: "",
ELASTICSEARCH_API_KEY: "",
indices: "",
query: "*",
search_fields: "",
max_documents: undefined,
},
});
const stringToArray = (str: string): string[] => {
const items = str
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
return Array.from(new Set(items));
};
const handleSubmit = async (values: ElasticsearchConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
// Send full URL to backend (backend expects ELASTICSEARCH_URL)
const config: Record<string, string | number | boolean | string[]> = {
ELASTICSEARCH_URL: values.endpoint_url,
// default to verifying certs; expose fields for CA/verify if UI added later
ELASTICSEARCH_VERIFY_CERTS: true,
};
if (values.auth_method === "basic") {
if (values.username) config.ELASTICSEARCH_USERNAME = values.username;
if (values.password) config.ELASTICSEARCH_PASSWORD = values.password;
} else if (values.auth_method === "api_key") {
if (values.ELASTICSEARCH_API_KEY)
config.ELASTICSEARCH_API_KEY = values.ELASTICSEARCH_API_KEY;
}
const indicesInput = values.indices?.trim() ?? "";
const indicesArr = stringToArray(indicesInput);
config.ELASTICSEARCH_INDEX =
indicesArr.length === 0 ? "*" : indicesArr.length === 1 ? indicesArr[0] : indicesArr;
if (values.query && values.query !== "*") {
config.ELASTICSEARCH_QUERY = values.query;
}
if (values.search_fields?.trim()) {
const fields = stringToArray(values.search_fields);
config.ELASTICSEARCH_FIELDS = fields;
config.ELASTICSEARCH_CONTENT_FIELDS = fields;
if (fields.includes("title")) {
config.ELASTICSEARCH_TITLE_FIELD = "title";
}
}
if (values.max_documents !== undefined && values.max_documents > 0) {
config.ELASTICSEARCH_MAX_DOCUMENTS = values.max_documents;
}
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.ELASTICSEARCH_CONNECTOR,
config,
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
Enter your Elasticsearch cluster endpoint URL and authentication credentials to connect.
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="elasticsearch-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Elasticsearch Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Connection Details */}
<div className="space-y-4">
<h3 className="text-sm sm:text-base font-medium">Connection Details</h3>
<FormField
control={form.control}
name="endpoint_url"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Elasticsearch Endpoint URL</FormLabel>
<FormControl>
<Input
type="url"
autoComplete="off"
placeholder="https://your-cluster.es.region.aws.com:443"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Enter the complete Elasticsearch endpoint URL. We'll automatically extract the
hostname, port, and SSL settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed URL details */}
{form.watch("endpoint_url") && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Parsed Connection Details:
</h4>
<div className="text-[10px] sm:text-xs text-muted-foreground space-y-1">
{(() => {
try {
const url = new URL(form.watch("endpoint_url"));
return (
<>
<div>
<strong>Hostname:</strong> {url.hostname}
</div>
<div>
<strong>Port:</strong>{" "}
{url.port || (url.protocol === "https:" ? "443" : "80")}
</div>
<div>
<strong>SSL/TLS:</strong>{" "}
{url.protocol === "https:" ? "Enabled" : "Disabled"}
</div>
</>
);
} catch {
return <div className="text-destructive">Invalid URL format</div>;
}
})()}
</div>
</div>
)}
</div>
{/* Authentication */}
<div className="space-y-4">
<h3 className="text-sm sm:text-base font-medium">Authentication</h3>
<FormField
control={form.control}
name="auth_method"
render={({ field }) => (
<FormItem className="space-y-3">
<FormControl>
<RadioGroup.Root
onValueChange={(value) => {
field.onChange(value);
// Clear auth fields when method changes
if (value !== "basic") {
form.setValue("username", "");
form.setValue("password", "");
}
if (value !== "api_key") {
form.setValue("ELASTICSEARCH_API_KEY", "");
}
}}
value={field.value}
className="flex flex-col space-y-2"
>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="api_key"
id={authApiKeyId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authApiKeyId} className="text-xs sm:text-sm">
API Key
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroup.Item
value="basic"
id={authBasicId}
className="aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<RadioGroup.Indicator className="flex items-center justify-center">
<div className="h-2.5 w-2.5 rounded-full bg-current" />
</RadioGroup.Indicator>
</RadioGroup.Item>
<Label htmlFor={authBasicId} className="text-xs sm:text-sm">
Username & Password
</Label>
</div>
</RadioGroup.Root>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Basic Auth Fields */}
{form.watch("auth_method") === "basic" && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Username</FormLabel>
<FormControl>
<Input
placeholder="elastic"
autoComplete="username"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Password"
autoComplete="current-password"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
{/* API Key Field */}
{form.watch("auth_method") === "api_key" && (
<FormField
control={form.control}
name="ELASTICSEARCH_API_KEY"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your API Key Here"
autoComplete="off"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Enter your Elasticsearch API key (base64 encoded). This will be stored
securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
{/* Index Selection */}
<FormField
control={form.control}
name="indices"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Index Selection</FormLabel>
<FormControl>
<Input
placeholder="logs-*, documents-*, app-logs"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated indices to search (e.g., "logs-*, documents-*").
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed indices as badges */}
{form.watch("indices")?.trim() && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Selected Indices:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(form.watch("indices") ?? "").map((index) => (
<Badge key={index} variant="secondary" className="text-[10px]">
{index}
</Badge>
))}
</div>
</div>
)}
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Index Selection Tips</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px] mt-2">
<ul className="list-disc pl-4 space-y-1">
<li>Use wildcards like "logs-*" to match multiple indices</li>
<li>Separate multiple indices with commas</li>
<li>Leave empty to search all accessible indices including internal ones</li>
<li>Choosing specific indices improves search performance</li>
</ul>
</AlertDescription>
</Alert>
{/* Advanced Configuration */}
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="advanced">
<AccordionTrigger className="text-xs sm:text-sm">
Advanced Configuration
</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Default Search Query */}
<FormField
control={form.control}
name="query"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
Default Search Query{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
placeholder="*"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Default Elasticsearch query to use for searches. Use "*" to match all
documents.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Form Fields */}
<FormField
control={form.control}
name="search_fields"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
Search Fields <span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
placeholder="title, content, description"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list of specific fields to search in (e.g., "title,
content, description"). Leave empty to search all fields.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed search fields as badges */}
{form.watch("search_fields")?.trim() && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(form.watch("search_fields") ?? "").map((field) => (
<Badge key={field} variant="outline" className="text-[10px]">
{field}
</Badge>
))}
</div>
</div>
)}
<FormField
control={form.control}
name="max_documents"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
Maximum Documents{" "}
<span className="text-muted-foreground">(Optional)</span>
</FormLabel>
<FormControl>
<Input
type="number"
placeholder="1000"
min="1"
max="10000"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
onChange={(e) =>
field.onChange(
e.target.value === "" ? undefined : parseInt(e.target.value, 10)
)
}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Maximum number of documents to retrieve per search (1-10,000). Leave empty
to use Elasticsearch's default limit.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">
What you get with Elasticsearch integration:
</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.ELASTICSEARCH_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Elasticsearch connector allows you to search and retrieve documents from your
Elasticsearch cluster. Configure connection details, select specific indices, and
set search parameters to make your existing data searchable within SurfSense.
</p>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Connection Setup</h3>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get your Elasticsearch endpoint
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
You'll need the endpoint URL for your Elasticsearch cluster. This typically
looks like:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Cloud:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
https://your-cluster.es.region.aws.com:443
</code>
</li>
<li>
Self-hosted:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
https://elasticsearch.example.com:9200
</code>
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Configure authentication
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Elasticsearch requires authentication. You can use either:
</p>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
<strong>API Key:</strong> A base64-encoded API key. You can create one in
Elasticsearch by running:
<pre className="bg-muted p-2 rounded mt-1 text-[9px] overflow-x-auto">
<code>POST /_security/api_key</code>
</pre>
</li>
<li>
<strong>Username & Password:</strong> Basic authentication using your
Elasticsearch username and password.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Select indices
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Specify which indices to search. You can:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
Use wildcards: <code className="bg-muted px-1 py-0.5 rounded">logs-*</code>{" "}
to match multiple indices
</li>
<li>
List specific indices:{" "}
<code className="bg-muted px-1 py-0.5 rounded">
logs-2024, documents-2024
</code>
</li>
<li>
Leave empty to search all accessible indices (not recommended for
performance)
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Advanced Configuration</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Query</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
The default query used for searches. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded">*</code> to match all
documents, or specify a more complex Elasticsearch query.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Fields</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
Limit searches to specific fields for better performance. Common fields
include:
</p>
<ul className="list-disc pl-5 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
<code className="bg-muted px-1 py-0.5 rounded">title</code> - Document
titles
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">content</code> - Main content
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">description</code> -
Descriptions
</li>
</ul>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Leave empty to search all fields in your documents.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Maximum Documents</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Set a limit on the number of documents retrieved per search (1-10,000). This
helps control response times and resource usage. Leave empty to use
Elasticsearch's default limit.
</p>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Troubleshooting</h3>
<div className="space-y-4">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Connection Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid URL:</strong> Ensure your endpoint URL includes the protocol
(https://) and port number if required.
</li>
<li>
<strong>SSL/TLS Errors:</strong> Verify that your cluster uses HTTPS and the
certificate is valid. Self-signed certificates may require additional
configuration.
</li>
<li>
<strong>Connection Timeout:</strong> Check your network connectivity and
firewall settings. Ensure the Elasticsearch cluster is accessible from
SurfSense servers.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Authentication Issues
</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>Invalid Credentials:</strong> Double-check your username/password or
API key. API keys must be base64-encoded.
</li>
<li>
<strong>Permission Denied:</strong> Ensure your API key or user account has
read permissions for the indices you want to search.
</li>
<li>
<strong>API Key Format:</strong> Elasticsearch API keys are typically
base64-encoded strings. Make sure you're using the full key value.
</li>
</ul>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Search Issues</h4>
<ul className="list-disc pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>No Results:</strong> Verify that your index selection matches
existing indices. Use wildcards carefully.
</li>
<li>
<strong>Slow Searches:</strong> Limit the number of indices or use specific
index names instead of wildcards. Reduce the maximum documents limit.
</li>
<li>
<strong>Field Not Found:</strong> Ensure the search fields you specify
actually exist in your Elasticsearch documents.
</li>
</ul>
</div>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Need More Help?</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
If you continue to experience issues, check your Elasticsearch cluster logs
and ensure your cluster version is compatible. For Elasticsearch Cloud
deployments, verify your access policies and IP allowlists.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,466 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const githubConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
github_pat: z
.string()
.min(20, {
message: "GitHub Personal Access Token seems too short.",
})
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
}),
repo_full_names: z.string().min(1, {
message: "At least one repository is required.",
}),
});
type GithubConnectorFormValues = z.infer<typeof githubConnectorFormSchema>;
export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<GithubConnectorFormValues>({
resolver: zodResolver(githubConnectorFormSchema),
defaultValues: {
name: "GitHub Connector",
github_pat: "",
repo_full_names: "",
},
});
const stringToArray = (str: string): string[] => {
const items = str
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
return Array.from(new Set(items));
};
const handleSubmit = async (values: GithubConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
const repoList = stringToArray(values.repo_full_names);
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
config: {
GITHUB_PAT: values.github_pat,
repo_full_names: repoList,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Personal Access Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a GitHub Personal Access Token to use this connector. You can create one
from{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
GitHub Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="github-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My GitHub Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="github_pat"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">GitHub Personal Access Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ghp_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your GitHub PAT will be encrypted and stored securely. It typically starts with
"ghp_" or "github_pat_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="repo_full_names"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Repository Names</FormLabel>
<FormControl>
<Input
placeholder="owner/repo1, owner/repo2"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list of repository full names (e.g., "owner/repo1,
owner/repo2").
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show parsed repositories as badges */}
{form.watch("repo_full_names")?.trim() && (
<div className="rounded-lg border border-border bg-muted/50 p-3">
<h4 className="text-[10px] sm:text-xs font-medium mb-2">Selected Repositories:</h4>
<div className="flex flex-wrap gap-2">
{stringToArray(form.watch("repo_full_names") ?? "").map((repo) => (
<Badge key={repo} variant="secondary" className="text-[10px]">
{repo}
</Badge>
))}
</div>
</div>
)}
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with GitHub integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the
GitHub API. You provide a comma-separated list of repository full names (e.g.,
"owner/repo1, owner/repo2") that you want to index. The connector indexes relevant
files (code, markdown, text) from the selected repositories.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
The connector indexes files based on common code and documentation extensions.
</li>
<li>Large files (over 1MB) are skipped during indexing.</li>
<li>Only specified repositories are indexed.</li>
<li>
Indexing runs periodically (check connector settings for frequency) to keep
content up-to-date.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Personal Access Token Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
repositories. The PAT will be stored securely to enable indexing.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Generate GitHub PAT
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to your GitHub{" "}
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Developer settings
</a>
</li>
<li>
Click on <strong>Personal access tokens</strong>, then choose{" "}
<strong>Tokens (classic)</strong> or <strong>Fine-grained tokens</strong>{" "}
(recommended if available).
</li>
<li>
Click <strong>Generate new token</strong> (and choose the appropriate type).
</li>
<li>Give your token a descriptive name (e.g., "SurfSense Connector").</li>
<li>Set an expiration date for the token (recommended for security).</li>
<li>
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
<strong>Repository access</strong> (for fine-grained), grant the necessary
permissions. At minimum, the <strong>`repo`</strong> scope (or equivalent
read access to repositories for fine-grained tokens) is required to read
repository content.
</li>
<li>
Click <strong>Generate token</strong>.
</li>
<li>
<strong>Important:</strong> Copy your new PAT immediately. You won't be able
to see it again after leaving the page.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Specify repositories
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Enter a comma-separated list of repository full names in the format
"owner/repo1, owner/repo2". The connector will index files from only the
specified repositories.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Repository Access</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Make sure your PAT has access to all repositories you want to index. Private
repositories require appropriate permissions.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>GitHub</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>GitHub Personal Access Token</strong> in the form field.
</li>
<li>
Enter a comma-separated list of <strong>Repository Names</strong> (e.g.,
"owner/repo1, owner/repo2").
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your GitHub repositories will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The GitHub connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Code files from selected repositories</li>
<li>README files and Markdown documentation</li>
<li>Common text-based file formats</li>
<li>Repository metadata and structure</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,447 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const jiraConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z.string().url({ message: "Please enter a valid Jira base URL." }),
email: z.string().email({ message: "Please enter a valid email address." }),
api_token: z.string().min(10, {
message: "Jira API Token is required and must be valid.",
}),
});
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
export const JiraConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<JiraConnectorFormValues>({
resolver: zodResolver(jiraConnectorFormSchema),
defaultValues: {
name: "Jira Connector",
base_url: "",
email: "",
api_token: "",
},
});
const handleSubmit = async (values: JiraConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.JIRA_CONNECTOR,
config: {
JIRA_BASE_URL: values.base_url,
JIRA_EMAIL: values.email,
JIRA_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Jira API Token to use this connector. You can create one from{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="jira-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Jira Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Jira Base URL</FormLabel>
<FormControl>
<Input
type="url"
placeholder="https://your-domain.atlassian.net"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
The base URL of your Jira instance (e.g., https://your-domain.atlassian.net).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Email Address</FormLabel>
<FormControl>
<Input
type="email"
placeholder="your-email@example.com"
autoComplete="email"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
The email address associated with your Atlassian account.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your API Token"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Jira API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.JIRA_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Jira integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.JIRA_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Jira connector uses the Jira REST API with Basic Authentication to fetch all
issues and comments that your account has access to within your Jira instance.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves issues and comments that have
been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Read-Only Access is Sufficient
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You only need read access for this connector to work. The API Token will only be
used to read your Jira data.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your Atlassian account</li>
<li>
Navigate to{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://id.atlassian.com/manage-profile/security/api-tokens
</a>{" "}
in your browser.
</li>
<li>
Click <strong>Create API token</strong>
</li>
<li>Enter a label for your token (like "SurfSense Connector")</li>
<li>
Click <strong>Create</strong>
</li>
<li>Copy the generated token as it will only be shown once</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API Token will have access to all projects and issues that your user
account can see. Make sure your account has appropriate permissions for the
projects you want to index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only issues, comments, and basic metadata will be indexed. Jira attachments
and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Jira</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>Jira Instance URL</strong> (e.g.,
https://yourcompany.atlassian.net)
</li>
<li>
Enter your <strong>Email Address</strong> associated with your Atlassian account
</li>
<li>
Place your <strong>API Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Jira issues will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Jira connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Issue keys and summaries (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments and discussion threads</li>
<li>Issue status, priority, and type information</li>
<li>Assignee and reporter information</li>
<li>Project information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,396 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const linearConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z
.string()
.min(10, {
message: "Linear API Key is required and must be valid.",
})
.regex(/^lin_api_/, {
message: "Linear API Key should start with 'lin_api_'",
}),
});
type LinearConnectorFormValues = z.infer<typeof linearConnectorFormSchema>;
export const LinearConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<LinearConnectorFormValues>({
resolver: zodResolver(linearConnectorFormSchema),
defaultValues: {
name: "Linear Connector",
api_key: "",
},
});
const handleSubmit = async (values: LinearConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.LINEAR_CONNECTOR,
config: {
LINEAR_API_KEY: values.api_key,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Linear API Key to use this connector. You can create one from{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Linear API Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="linear-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Linear Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Linear API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="lin_api_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Linear API Key will be encrypted and stored securely. It typically starts
with "lin_api_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.LINEAR_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Linear integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.LINEAR_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Linear connector uses the Linear GraphQL API to fetch all issues and comments
that the API key has access to within a workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves issues and comments that have
been updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Read-Only Access is Sufficient
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You only need a read-only API key for this connector to work. This limits the
permissions to just reading your Linear data.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API key
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your Linear account</li>
<li>
Navigate to{" "}
<a
href="https://linear.app/settings/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://linear.app/settings/api
</a>{" "}
in your browser.
</li>
<li>Alternatively, click on your profile picture Settings API</li>
<li>
Click the <strong>+ New API key</strong> button.
</li>
<li>Enter a description for your key (like "Search Connector").</li>
<li>Select "Read-only" as the permission.</li>
<li>
Click <strong>Create</strong> to generate the API key.
</li>
<li>
Copy the generated API key that starts with 'lin_api_' as it will only be
shown once.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API key will have access to all issues and comments that your user account
can see. If you're creating the key as an admin, it will have access to all
issues in the workspace.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only issues and comments will be indexed. Linear attachments and linked
files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Linear</strong>{" "}
Connector.
</li>
<li>
Place the <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Linear issues will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Linear connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Issue titles and identifiers (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments</li>
<li>Issue status and metadata</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,158 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const linkupApiFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
type LinkupApiFormValues = z.infer<typeof linkupApiFormSchema>;
export const LinkupApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const form = useForm<LinkupApiFormValues>({
resolver: zodResolver(linkupApiFormSchema),
defaultValues: {
name: "Linkup API Connector",
api_key: "",
},
});
const handleSubmit = async (values: LinkupApiFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.LINKUP_API,
config: {
LINKUP_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Linkup API key to use this connector. You can get one by signing up at{" "}
<a
href="https://linkup.so"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
linkup.so
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="linkup-api-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Linkup API Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Linkup API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Linkup API key"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.LINKUP_API) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Linkup API:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.LINKUP_API)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,379 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const lumaConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "Luma API Key is required and must be valid.",
}),
});
type LumaConnectorFormValues = z.infer<typeof lumaConnectorFormSchema>;
export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<LumaConnectorFormValues>({
resolver: zodResolver(lumaConnectorFormSchema),
defaultValues: {
name: "Luma Connector",
api_key: "",
},
});
const handleSubmit = async (values: LumaConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.LUMA_CONNECTOR,
config: {
LUMA_API_KEY: values.api_key,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Luma API Key to use this connector. You can create one from{" "}
<a
href="https://lu.ma/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Luma API Settings
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="luma-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Luma Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Luma API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your API Key"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Luma API Key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.LUMA_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Luma integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.LUMA_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Luma connector uses the Luma API to fetch all events that your API key has
access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves events that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Key Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need a Luma API key to use this connector. The key will be used to read your
Luma events with read-only permissions.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get Your API Key
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log into your Luma account</li>
<li>Navigate to your account settings</li>
<li>Go to API settings or Developer settings</li>
<li>Generate a new API key</li>
<li>Copy the generated API key</li>
<li>
You can also visit{" "}
<a
href="https://lu.ma/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Luma API Settings
</a>{" "}
for more information.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API key will have access to all events that your user account can see.
Make sure your account has appropriate permissions for the events you want to
index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only event details, descriptions, and attendee information will be indexed.
Event attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Luma</strong>{" "}
Connector.
</li>
<li>
Place your <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Luma events will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Luma connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Event titles and descriptions</li>
<li>Event details and metadata</li>
<li>Attendee information</li>
<li>Event dates and locations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,396 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const notionConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
integration_token: z.string().min(10, {
message: "Notion Integration Token is required and must be valid.",
}),
});
type NotionConnectorFormValues = z.infer<typeof notionConnectorFormSchema>;
export const NotionConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<NotionConnectorFormValues>({
resolver: zodResolver(notionConnectorFormSchema),
defaultValues: {
name: "Notion Connector",
integration_token: "",
},
});
const handleSubmit = async (values: NotionConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.NOTION_CONNECTOR,
config: {
NOTION_INTEGRATION_TOKEN: values.integration_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Integration Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Notion Integration Token to use this connector. You can create one from{" "}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Notion Integrations
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="notion-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Notion Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="integration_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Notion Integration Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ntn_..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Notion Integration Token will be encrypted and stored securely. It
typically starts with "ntn_".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.NOTION_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Notion integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.NOTION_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Notion connector uses the Notion API to fetch pages from all accessible
workspaces that the integration token has access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves pages that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Integration Token Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create a Notion integration and share pages with it to get access.
The integration needs read access to pages.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create a Notion Integration
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to{" "}
<a
href="https://www.notion.so/my-integrations"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://www.notion.so/my-integrations
</a>
</li>
<li>
Click <strong>+ New integration</strong>
</li>
<li>Enter a name for your integration (e.g., "Search Connector")</li>
<li>Select your workspace</li>
<li>
Under <strong>Capabilities</strong>, enable <strong>Read content</strong>
</li>
<li>
Click <strong>Submit</strong> to create the integration
</li>
<li>
Copy the <strong>Internal Integration Token</strong> (starts with "ntn_")
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Share Pages with Integration
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Open the Notion pages or databases you want to index</li>
<li>
Click the <strong></strong> (three dots) menu in the top right
</li>
<li>
Select <strong>Add connections</strong> or <strong>Connections</strong>
</li>
<li>Search for and select your integration</li>
<li>Repeat for all pages you want to index</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mt-3">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Important</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
The integration can only access pages that have been explicitly shared with
it. Make sure to share all pages you want to index.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Notion</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Integration Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Notion pages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Notion connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Page titles and content</li>
<li>Database entries and properties</li>
<li>Page metadata and properties</li>
<li>Nested pages and sub-pages</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,354 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const searxngFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
host: z
.string()
.min(1, { message: "Host is required." })
.url({ message: "Enter a valid SearxNG host URL (e.g. https://searxng.example.org)." }),
api_key: z.string().optional(),
engines: z.string().optional(),
categories: z.string().optional(),
language: z.string().optional(),
safesearch: z
.string()
.regex(/^[0-2]?$/, { message: "SafeSearch must be 0, 1, or 2." })
.optional(),
verify_ssl: z.boolean(),
});
type SearxngFormValues = z.infer<typeof searxngFormSchema>;
const parseCommaSeparated = (value?: string | null) => {
if (!value) return undefined;
const items = value
.split(",")
.map((item) => item.trim())
.filter((item) => item.length > 0);
return items.length > 0 ? items : undefined;
};
export const SearxngConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const form = useForm<SearxngFormValues>({
resolver: zodResolver(searxngFormSchema),
defaultValues: {
name: "SearxNG Connector",
host: "",
api_key: "",
engines: "",
categories: "",
language: "",
safesearch: "",
verify_ssl: true,
},
});
const handleSubmit = async (values: SearxngFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
const config: Record<string, unknown> = {
SEARXNG_HOST: values.host.trim(),
};
const apiKey = values.api_key?.trim();
if (apiKey) config.SEARXNG_API_KEY = apiKey;
const engines = parseCommaSeparated(values.engines);
if (engines) config.SEARXNG_ENGINES = engines;
const categories = parseCommaSeparated(values.categories);
if (categories) config.SEARXNG_CATEGORIES = categories;
const language = values.language?.trim();
if (language) config.SEARXNG_LANGUAGE = language;
const safesearch = values.safesearch?.trim();
if (safesearch) {
const parsed = Number(safesearch);
if (!Number.isNaN(parsed)) {
config.SEARXNG_SAFESEARCH = parsed;
}
}
// Include verify flag only when disabled to keep config minimal
if (values.verify_ssl === false) {
config.SEARXNG_VERIFY_SSL = false;
}
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.SEARXNG_API,
config,
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">SearxNG Instance Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You need access to a running SearxNG instance. Refer to the{" "}
<a
href="https://docs.searxng.org/admin/installation-docker.html"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
SearxNG installation guide
</a>{" "}
for setup instructions. If your instance requires an API key, include it below.
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="searxng-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My SearxNG Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">SearxNG Host</FormLabel>
<FormControl>
<Input
placeholder="https://searxng.example.org"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Provide the full base URL to your SearxNG instance. Include the protocol
(http/https).
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">API Key (optional)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter API key if your instance requires one"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Leave empty if your SearxNG instance does not enforce API keys.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="engines"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Engines (optional)</FormLabel>
<FormControl>
<Input
placeholder="google,bing,duckduckgo"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list to target specific engines.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categories"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Categories (optional)</FormLabel>
<FormControl>
<Input
placeholder="general,it,science"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Comma-separated list of SearxNG categories.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="language"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
Preferred Language (optional)
</FormLabel>
<FormControl>
<Input
placeholder="en-US"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
IETF language tag (e.g. en, en-US). Leave blank to inherit defaults.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="safesearch"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">
SafeSearch Level (optional)
</FormLabel>
<FormControl>
<Input
placeholder="0 (off), 1 (moderate), 2 (strict)"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Set 0, 1, or 2 to adjust SafeSearch filtering. Leave blank to use the instance
default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="verify_ssl"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-slate-400/20 p-3 sm:p-4">
<div>
<FormLabel className="text-xs sm:text-sm">Verify SSL Certificates</FormLabel>
<FormDescription className="text-[10px] sm:text-xs">
Disable only when connecting to instances with self-signed certificates.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isSubmitting}
/>
</FormControl>
</FormItem>
)}
/>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.SEARXNG_API) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with SearxNG:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.SEARXNG_API)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,426 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const slackConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
bot_token: z.string().min(10, {
message: "Slack Bot Token is required and must be valid.",
}),
});
type SlackConnectorFormValues = z.infer<typeof slackConnectorFormSchema>;
export const SlackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<SlackConnectorFormValues>({
resolver: zodResolver(slackConnectorFormSchema),
defaultValues: {
name: "Slack Connector",
bot_token: "",
},
});
const handleSubmit = async (values: SlackConnectorFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.SLACK_CONNECTOR,
config: {
SLACK_BOT_TOKEN: values.bot_token,
},
is_indexable: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
periodicEnabled,
frequencyMinutes,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Bot User OAuth Token Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Slack Bot User OAuth Token to use this connector. You can create a Slack
app and get the token from{" "}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Slack API Dashboard
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="slack-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Slack Connector"
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="bot_token"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Slack Bot User OAuth Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="xoxb-..."
className="h-8 sm:h-10 px-2 sm:px-3 text-xs sm:text-sm border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your Bot User OAuth Token will be encrypted and stored securely. It typically
starts with "xoxb-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Indexing Configuration */}
<div className="space-y-4 pt-4 border-t border-slate-400/20">
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
{/* Date Range Selector */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Periodic Sync Config */}
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h3 className="font-medium text-sm sm:text-base">Enable Periodic Sync</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Automatically re-index at regular intervals
</p>
</div>
<Switch
checked={periodicEnabled}
onCheckedChange={setPeriodicEnabled}
disabled={isSubmitting}
/>
</div>
{periodicEnabled && (
<div className="mt-4 pt-4 border-t border-slate-400/20 space-y-3">
<div className="space-y-2">
<Label htmlFor="frequency" className="text-xs sm:text-sm">
Sync Frequency
</Label>
<Select
value={frequencyMinutes}
onValueChange={setFrequencyMinutes}
disabled={isSubmitting}
>
<SelectTrigger
id="frequency"
className="w-full bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
>
<SelectValue placeholder="Select frequency" />
</SelectTrigger>
<SelectContent className="z-[100]">
<SelectItem value="15" className="text-xs sm:text-sm">
Every 15 minutes
</SelectItem>
<SelectItem value="60" className="text-xs sm:text-sm">
Every hour
</SelectItem>
<SelectItem value="360" className="text-xs sm:text-sm">
Every 6 hours
</SelectItem>
<SelectItem value="720" className="text-xs sm:text-sm">
Every 12 hours
</SelectItem>
<SelectItem value="1440" className="text-xs sm:text-sm">
Daily
</SelectItem>
<SelectItem value="10080" className="text-xs sm:text-sm">
Weekly
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
</div>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.SLACK_CONNECTOR) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Slack integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.SLACK_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Slack connector uses the Slack Web API to fetch messages from all accessible
channels that the bot token has access to within a workspace.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves messages that have been
updated since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
Bot User OAuth Token Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create a Slack app and install it to your workspace to get a Bot
User OAuth Token. The bot needs read access to channels and messages.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create a Slack App
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to{" "}
<a
href="https://api.slack.com/apps"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://api.slack.com/apps
</a>
</li>
<li>
Click <strong>Create New App</strong> and choose "From scratch"
</li>
<li>Enter an app name and select your workspace</li>
<li>
Click <strong>Create App</strong>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Configure Bot Scopes
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Navigate to <strong>OAuth & Permissions</strong> in the sidebar
</li>
<li>
Under <strong>Bot Token Scopes</strong>, add the following scopes:
<ul className="list-disc pl-5 mt-1 space-y-1">
<li>
<code className="bg-muted px-1 py-0.5 rounded">channels:read</code> -
View basic information about public channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">channels:history</code> -
View messages in public channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">groups:read</code> - View
basic information about private channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">groups:history</code> -
View messages in private channels
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">im:read</code> - View
basic information about direct messages
</li>
<li>
<code className="bg-muted px-1 py-0.5 rounded">im:history</code> - View
messages in direct messages
</li>
</ul>
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Install App to Workspace
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
Go to <strong>Install App</strong> in the sidebar
</li>
<li>
Click <strong>Install to Workspace</strong>
</li>
<li>
Review the permissions and click <strong>Allow</strong>
</li>
<li>
Copy the <strong>Bot User OAuth Token</strong> from the "OAuth &
Permissions" page (starts with "xoxb-")
</li>
</ol>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Slack</strong>{" "}
Connector.
</li>
<li>
Place the <strong>Bot User OAuth Token</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Slack messages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Slack connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Messages from all accessible channels (public and private)</li>
<li>Direct messages (if bot has access)</li>
<li>Message timestamps and metadata</li>
<li>Thread replies and conversations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
};

View file

@ -0,0 +1,158 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { Info } from "lucide-react";
import type { FC } from "react";
import { useRef } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const tavilyApiFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
api_key: z.string().min(10, {
message: "API key is required and must be valid.",
}),
});
type TavilyApiFormValues = z.infer<typeof tavilyApiFormSchema>;
export const TavilyApiConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const form = useForm<TavilyApiFormValues>({
resolver: zodResolver(tavilyApiFormSchema),
defaultValues: {
name: "Tavily API Connector",
api_key: "",
},
});
const handleSubmit = async (values: TavilyApiFormValues) => {
// Prevent multiple submissions
if (isSubmittingRef.current || isSubmitting) {
return;
}
isSubmittingRef.current = true;
try {
await onSubmit({
name: values.name,
connector_type: EnumConnectorName.TAVILY_API,
config: {
TAVILY_API_KEY: values.api_key,
},
is_indexable: false,
last_indexed_at: null,
periodic_indexing_enabled: false,
indexing_frequency_minutes: null,
next_scheduled_at: null,
});
} finally {
isSubmittingRef.current = false;
}
};
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 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
<div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">API Key Required</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
You'll need a Tavily API key to use this connector. You can get one by signing up at{" "}
<a
href="https://tavily.com"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
tavily.com
</a>
</AlertDescription>
</div>
</Alert>
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<Form {...form}>
<form
id="tavily-connect-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4 sm:space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Connector Name</FormLabel>
<FormControl>
<Input
placeholder="My Tavily API Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_key"
render={({ field }) => (
<FormItem>
<FormLabel className="text-xs sm:text-sm">Tavily API Key</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Tavily API key"
className="border-slate-400/20 focus-visible:border-slate-400/40"
disabled={isSubmitting}
{...field}
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your API key will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.TAVILY_API) && (
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
<h4 className="text-xs sm:text-sm font-medium">What you get with Tavily API:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.TAVILY_API)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,114 @@
/**
* Helper function to get connector-specific benefits list
* Returns null if no benefits are defined for the connector
*/
export function getConnectorBenefits(connectorType: string): string[] | null {
const benefits: Record<string, string[]> = {
LINEAR_CONNECTOR: [
"Search through all your Linear issues and comments",
"Access issue titles, descriptions, and full discussion threads",
"Connect your team's project management directly to your search space",
"Keep your search results up-to-date with latest Linear content",
"Index your Linear issues for enhanced search capabilities",
],
ELASTICSEARCH_CONNECTOR: [
"Search across your indexed documents and logs",
"Access structured and unstructured data from your cluster",
"Leverage existing Elasticsearch indices for enhanced search",
"Real-time search capabilities with powerful query features",
"Integration with your existing Elasticsearch infrastructure",
],
TAVILY_API: [
"AI-powered search results tailored to your queries",
"Real-time information from the web",
"Enhanced search capabilities for your projects",
],
SEARXNG_API: [
"Privacy-focused meta-search across multiple engines",
"Self-hosted search instance for full control",
"Real-time web search results from multiple sources",
],
LINKUP_API: [
"AI-powered search results tailored to your queries",
"Real-time information from the web",
"Enhanced search capabilities for your projects",
],
BAIDU_SEARCH_API: [
"Intelligent search tailored for Chinese web content",
"Real-time information from Baidu's search index",
"AI-powered summarization with source references",
],
SLACK_CONNECTOR: [
"Search through all your Slack messages and conversations",
"Access messages from public and private channels",
"Connect your team's communications directly to your search space",
"Keep your search results up-to-date with latest Slack content",
"Index your Slack conversations for enhanced search capabilities",
],
DISCORD_CONNECTOR: [
"Search through all your Discord messages and conversations",
"Access messages from all accessible channels",
"Connect your community's communications directly to your search space",
"Keep your search results up-to-date with latest Discord content",
"Index your Discord conversations for enhanced search capabilities",
],
NOTION_CONNECTOR: [
"Search through all your Notion pages and databases",
"Access page content, properties, and metadata",
"Connect your knowledge base directly to your search space",
"Keep your search results up-to-date with latest Notion content",
"Index your Notion workspace for enhanced search capabilities",
],
CONFLUENCE_CONNECTOR: [
"Search through all your Confluence pages and spaces",
"Access page content, comments, and attachments",
"Connect your team's documentation directly to your search space",
"Keep your search results up-to-date with latest Confluence content",
"Index your Confluence workspace for enhanced search capabilities",
],
BOOKSTACK_CONNECTOR: [
"Search through all your BookStack pages and books",
"Access page content, chapters, and documentation",
"Connect your documentation directly to your search space",
"Keep your search results up-to-date with latest BookStack content",
"Index your BookStack instance for enhanced search capabilities",
],
GITHUB_CONNECTOR: [
"Search through code, issues, and documentation from GitHub repositories",
"Access repository content, pull requests, and discussions",
"Connect your codebase directly to your search space",
"Keep your search results up-to-date with latest GitHub content",
"Index your GitHub repositories for enhanced search capabilities",
],
JIRA_CONNECTOR: [
"Search through all your Jira issues and tickets",
"Access issue descriptions, comments, and project data",
"Connect your project management directly to your search space",
"Keep your search results up-to-date with latest Jira content",
"Index your Jira projects for enhanced search capabilities",
],
CLICKUP_CONNECTOR: [
"Search through all your ClickUp tasks and projects",
"Access task descriptions, comments, and project data",
"Connect your task management directly to your search space",
"Keep your search results up-to-date with latest ClickUp content",
"Index your ClickUp workspace for enhanced search capabilities",
],
LUMA_CONNECTOR: [
"Search through all your Luma events",
"Access event details, descriptions, and attendee information",
"Connect your events directly to your search space",
"Keep your search results up-to-date with latest Luma content",
"Index your Luma events for enhanced search capabilities",
],
CIRCLEBACK_CONNECTOR: [
"Automatically receive meeting notes, transcripts, and action items",
"Access meeting details, attendees, and insights",
"Search through all your Circleback meeting records",
"Real-time updates via webhook integration",
"No manual indexing required - meetings are added automatically",
],
};
return benefits[connectorType] || null;
}

View file

@ -0,0 +1,82 @@
import type { FC } from "react";
import { BaiduSearchApiConnectForm } from "./components/baidu-search-api-connect-form";
import { BookStackConnectForm } from "./components/bookstack-connect-form";
import { CirclebackConnectForm } from "./components/circleback-connect-form";
import { ClickUpConnectForm } from "./components/clickup-connect-form";
import { ConfluenceConnectForm } from "./components/confluence-connect-form";
import { DiscordConnectForm } from "./components/discord-connect-form";
import { ElasticsearchConnectForm } from "./components/elasticsearch-connect-form";
import { GithubConnectForm } from "./components/github-connect-form";
import { JiraConnectForm } from "./components/jira-connect-form";
import { LinearConnectForm } from "./components/linear-connect-form";
import { LinkupApiConnectForm } from "./components/linkup-api-connect-form";
import { LumaConnectForm } from "./components/luma-connect-form";
import { NotionConnectForm } from "./components/notion-connect-form";
import { SearxngConnectForm } from "./components/searxng-connect-form";
import { SlackConnectForm } from "./components/slack-connect-form";
import { TavilyApiConnectForm } from "./components/tavily-api-connect-form";
export interface ConnectFormProps {
onSubmit: (data: {
name: string;
connector_type: string;
config: Record<string, unknown>;
is_indexable: boolean;
last_indexed_at: null;
periodic_indexing_enabled: boolean;
indexing_frequency_minutes: number | null;
next_scheduled_at: null;
startDate?: Date;
endDate?: Date;
periodicEnabled?: boolean;
frequencyMinutes?: string;
}) => Promise<void>;
onBack: () => void;
isSubmitting: boolean;
onFormSubmit?: () => void;
}
export type ConnectFormComponent = FC<ConnectFormProps>;
/**
* Factory function to get the appropriate connect form component for a connector type
*/
export function getConnectFormComponent(connectorType: string): ConnectFormComponent | null {
switch (connectorType) {
case "TAVILY_API":
return TavilyApiConnectForm;
case "SEARXNG_API":
return SearxngConnectForm;
case "LINKUP_API":
return LinkupApiConnectForm;
case "BAIDU_SEARCH_API":
return BaiduSearchApiConnectForm;
case "LINEAR_CONNECTOR":
return LinearConnectForm;
case "ELASTICSEARCH_CONNECTOR":
return ElasticsearchConnectForm;
case "SLACK_CONNECTOR":
return SlackConnectForm;
case "DISCORD_CONNECTOR":
return DiscordConnectForm;
case "NOTION_CONNECTOR":
return NotionConnectForm;
case "CONFLUENCE_CONNECTOR":
return ConfluenceConnectForm;
case "BOOKSTACK_CONNECTOR":
return BookStackConnectForm;
case "GITHUB_CONNECTOR":
return GithubConnectForm;
case "JIRA_CONNECTOR":
return JiraConnectForm;
case "CLICKUP_CONNECTOR":
return ClickUpConnectForm;
case "LUMA_CONNECTOR":
return LumaConnectForm;
case "CIRCLEBACK_CONNECTOR":
return CirclebackConnectForm;
// Add other connector types here as needed
default:
return null;
}
}

View file

@ -0,0 +1,89 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
export interface BaiduSearchApiConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const BaiduSearchApiConfig: FC<BaiduSearchApiConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [apiKey, setApiKey] = useState<string>((connector.config?.BAIDU_API_KEY as string) || "");
const [name, setName] = useState<string>(connector.name || "");
// Update API key and name when connector changes
useEffect(() => {
const key = (connector.config?.BAIDU_API_KEY as string) || "";
setApiKey(key);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
BAIDU_API_KEY: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Baidu Search Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
Baidu AppBuilder API Key
</Label>
<Input
type="password"
value={apiKey}
onChange={(e) => handleApiKeyChange(e.target.value)}
placeholder="Enter your Baidu API key"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update the Baidu API Key if needed.
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,150 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
export interface BookStackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const BookStackConfig: FC<BookStackConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [baseUrl, setBaseUrl] = useState<string>(
(connector.config?.BOOKSTACK_BASE_URL as string) || ""
);
const [tokenId, setTokenId] = useState<string>(
(connector.config?.BOOKSTACK_TOKEN_ID as string) || ""
);
const [tokenSecret, setTokenSecret] = useState<string>(
(connector.config?.BOOKSTACK_TOKEN_SECRET as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update values when connector changes
useEffect(() => {
const url = (connector.config?.BOOKSTACK_BASE_URL as string) || "";
const id = (connector.config?.BOOKSTACK_TOKEN_ID as string) || "";
const secret = (connector.config?.BOOKSTACK_TOKEN_SECRET as string) || "";
setBaseUrl(url);
setTokenId(id);
setTokenSecret(secret);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
BOOKSTACK_BASE_URL: value,
});
}
};
const handleTokenIdChange = (value: string) => {
setTokenId(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
BOOKSTACK_TOKEN_ID: value,
});
}
};
const handleTokenSecretChange = (value: string) => {
setTokenSecret(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
BOOKSTACK_TOKEN_SECRET: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My BookStack Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">BookStack Base URL</Label>
<Input
type="url"
value={baseUrl}
onChange={(e) => handleBaseUrlChange(e.target.value)}
placeholder="https://your-bookstack-instance.com"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The base URL of your BookStack instance.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Token ID</Label>
<Input
value={tokenId}
onChange={(e) => handleTokenIdChange(e.target.value)}
placeholder="Your Token ID"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Your BookStack API Token ID.
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
Token Secret
</Label>
<Input
type="password"
value={tokenSecret}
onChange={(e) => handleTokenSecretChange(e.target.value)}
placeholder="Your Token Secret"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your BookStack Token Secret if needed.
</p>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,179 @@
"use client";
import { Check, Copy, Info, Webhook } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { z } from "zod";
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 { authenticatedFetch } from "@/lib/auth-utils";
import type { ConnectorConfigProps } from "../index";
export interface CirclebackConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
// Type-safe schema for webhook info response
const circlebackWebhookInfoSchema = z.object({
webhook_url: z.string(),
search_space_id: z.number(),
method: z.string(),
content_type: z.string(),
description: z.string(),
note: z.string(),
});
export type CirclebackWebhookInfo = z.infer<typeof circlebackWebhookInfoSchema>;
export const CirclebackConfig: FC<CirclebackConfigProps> = ({ connector, onNameChange }) => {
const [name, setName] = useState<string>(connector.name || "");
const [webhookUrl, setWebhookUrl] = useState<string>("");
const [webhookInfo, setWebhookInfo] = useState<CirclebackWebhookInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [copied, setCopied] = useState(false);
// Update name when connector changes
useEffect(() => {
setName(connector.name || "");
}, [connector.name]);
// Fetch webhook info
useEffect(() => {
const fetchWebhookInfo = async () => {
if (!connector.search_space_id) return;
const baseUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL;
if (!baseUrl) {
console.error("NEXT_PUBLIC_FASTAPI_BACKEND_URL is not configured");
setIsLoading(false);
return;
}
setIsLoading(true);
try {
const response = await authenticatedFetch(
`${baseUrl}/api/v1/webhooks/circleback/${connector.search_space_id}/info`
);
if (response.ok) {
const data: unknown = await response.json();
// Runtime validation with zod schema
const validatedData = circlebackWebhookInfoSchema.parse(data);
setWebhookInfo(validatedData);
setWebhookUrl(validatedData.webhook_url);
}
} catch (error) {
console.error("Failed to fetch webhook info:", error);
// Reset state on error
setWebhookInfo(null);
setWebhookUrl("");
} finally {
setIsLoading(false);
}
};
fetchWebhookInfo();
}, [connector.search_space_id]);
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
const handleCopyWebhookUrl = async () => {
if (webhookUrl) {
await navigator.clipboard.writeText(webhookUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Circleback Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Webhook Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base flex items-center gap-2">
<Webhook className="h-4 w-4" />
Webhook Configuration
</h3>
</div>
{isLoading ? (
<p className="text-[10px] sm:text-xs text-muted-foreground">
Loading webhook information...
</p>
) : webhookUrl ? (
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Webhook URL</Label>
<div className="flex gap-2">
<Input
value={webhookUrl}
readOnly
disabled
className="border-slate-400/20 focus-visible:border-slate-400/40 font-mono text-xs bg-muted/50 cursor-default select-all"
/>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleCopyWebhookUrl}
className="shrink-0"
>
{copied ? (
<>
<Check className="h-4 w-4 mr-2" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4 mr-2" />
Copy
</>
)}
</Button>
</div>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Use this URL in your Circleback automation settings to send meeting data to SurfSense.
</p>
</div>
) : (
<p className="text-[10px] sm:text-xs text-muted-foreground">
Unable to load webhook URL. Please try refreshing the page.
</p>
)}
{webhookInfo && (
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-xs sm:text-sm">Configuration Instructions</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs !pl-0 mt-1">
Configure this URL in Circleback Settings Automations Create automation Send
webhook request. The webhook will automatically send meeting notes, transcripts, and
action items to this search space.
</AlertDescription>
</Alert>
)}
</div>
</div>
);
};

View file

@ -0,0 +1,91 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
export interface ClickUpConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const ClickUpConfig: FC<ClickUpConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [apiToken, setApiToken] = useState<string>(
(connector.config?.CLICKUP_API_TOKEN as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update API token and name when connector changes
useEffect(() => {
const token = (connector.config?.CLICKUP_API_TOKEN as string) || "";
setApiToken(token);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleApiTokenChange = (value: string) => {
setApiToken(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
CLICKUP_API_TOKEN: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My ClickUp Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
ClickUp API Token
</Label>
<Input
type="password"
value={apiToken}
onChange={(e) => handleApiTokenChange(e.target.value)}
placeholder="pk_..."
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your ClickUp API Token if needed.
</p>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,149 @@
"use client";
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ConnectorConfigProps } from "../index";
export interface ConfluenceConfigProps extends ConnectorConfigProps {
onNameChange?: (name: string) => void;
}
export const ConfluenceConfig: FC<ConfluenceConfigProps> = ({
connector,
onConfigChange,
onNameChange,
}) => {
const [baseUrl, setBaseUrl] = useState<string>(
(connector.config?.CONFLUENCE_BASE_URL as string) || ""
);
const [email, setEmail] = useState<string>((connector.config?.CONFLUENCE_EMAIL as string) || "");
const [apiToken, setApiToken] = useState<string>(
(connector.config?.CONFLUENCE_API_TOKEN as string) || ""
);
const [name, setName] = useState<string>(connector.name || "");
// Update values when connector changes
useEffect(() => {
const url = (connector.config?.CONFLUENCE_BASE_URL as string) || "";
const emailVal = (connector.config?.CONFLUENCE_EMAIL as string) || "";
const token = (connector.config?.CONFLUENCE_API_TOKEN as string) || "";
setBaseUrl(url);
setEmail(emailVal);
setApiToken(token);
setName(connector.name || "");
}, [connector.config, connector.name]);
const handleBaseUrlChange = (value: string) => {
setBaseUrl(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
CONFLUENCE_BASE_URL: value,
});
}
};
const handleEmailChange = (value: string) => {
setEmail(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
CONFLUENCE_EMAIL: value,
});
}
};
const handleApiTokenChange = (value: string) => {
setApiToken(value);
if (onConfigChange) {
onConfigChange({
...connector.config,
CONFLUENCE_API_TOKEN: value,
});
}
};
const handleNameChange = (value: string) => {
setName(value);
if (onNameChange) {
onNameChange(value);
}
};
return (
<div className="space-y-6">
{/* Connector Name */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Connector Name</Label>
<Input
value={name}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="My Confluence Connector"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
A friendly name to identify this connector.
</p>
</div>
</div>
{/* Configuration */}
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Configuration</h3>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Confluence Base URL</Label>
<Input
type="url"
value={baseUrl}
onChange={(e) => handleBaseUrlChange(e.target.value)}
placeholder="https://your-domain.atlassian.net"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The base URL of your Confluence instance.
</p>
</div>
<div className="space-y-2">
<Label className="text-xs sm:text-sm">Email Address</Label>
<Input
type="email"
value={email}
onChange={(e) => handleEmailChange(e.target.value)}
placeholder="your-email@example.com"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The email address associated with your Atlassian account.
</p>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
API Token
</Label>
<Input
type="password"
value={apiToken}
onChange={(e) => handleApiTokenChange(e.target.value)}
placeholder="Your API Token"
className="border-slate-400/20 focus-visible:border-slate-400/40"
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Update your Confluence API Token if needed.
</p>
</div>
</div>
</div>
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more