mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-28 18:36:23 +02:00
Merge pull request #656 from MODSetter/dev
feat(v0.0.10): new connector and improved connector UX
This commit is contained in:
commit
c79c4bb2fd
186 changed files with 18148 additions and 9458 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
20
surfsense_backend/app/connectors/google_drive/__init__.py
Normal file
20
surfsense_backend/app/connectors/google_drive/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
204
surfsense_backend/app/connectors/google_drive/change_tracker.py
Normal file
204
surfsense_backend/app/connectors/google_drive/change_tracker.py
Normal 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}"
|
||||||
181
surfsense_backend/app/connectors/google_drive/client.py
Normal file
181
surfsense_backend/app/connectors/google_drive/client.py
Normal 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}"
|
||||||
|
|
@ -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}")
|
||||||
95
surfsense_backend/app/connectors/google_drive/credentials.py
Normal file
95
surfsense_backend/app/connectors/google_drive/credentials.py
Normal 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,
|
||||||
|
]
|
||||||
|
)
|
||||||
28
surfsense_backend/app/connectors/google_drive/file_types.py
Normal file
28
surfsense_backend/app/connectors/google_drive/file_types.py
Normal 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)
|
||||||
263
surfsense_backend/app/connectors/google_drive/folder_manager.py
Normal file
263
surfsense_backend/app/connectors/google_drive/folder_manager.py
Normal 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}"
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
317
surfsense_backend/app/routes/circleback_webhook_route.py
Normal file
317
surfsense_backend/app/routes/circleback_webhook_route.py
Normal 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",
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
318
surfsense_backend/app/routes/google_drive_add_connector_route.py
Normal file
318
surfsense_backend/app/routes/google_drive_add_connector_route.py
Normal 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
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
41
surfsense_backend/app/schemas/google_drive.py
Normal file
41
surfsense_backend/app/schemas/google_drive.py
Normal 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]
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
2
surfsense_backend/uv.lock
generated
2
surfsense_backend/uv.lock
generated
|
|
@ -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" },
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 https://docs.example.com 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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 https://docs.example.com 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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: [],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
120
surfsense_web/components/assistant-ui/assistant-message.tsx
Normal file
120
surfsense_web/components/assistant-ui/assistant-message.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
32
surfsense_web/components/assistant-ui/branch-picker.tsx
Normal file
32
surfsense_web/components/assistant-ui/branch-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
300
surfsense_web/components/assistant-ui/composer-action.tsx
Normal file
300
surfsense_web/components/assistant-ui/composer-action.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
236
surfsense_web/components/assistant-ui/composer.tsx
Normal file
236
surfsense_web/components/assistant-ui/composer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
316
surfsense_web/components/assistant-ui/connector-popup.tsx
Normal file
316
surfsense_web/components/assistant-ui/connector-popup.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue