mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
merge: upstream/dev with migration renumbering
This commit is contained in:
commit
a7145b2c63
176 changed files with 8791 additions and 3608 deletions
|
|
@ -228,7 +228,7 @@ COPY scripts/docker/init-postgres.sh /app/init-postgres.sh
|
||||||
RUN dos2unix /app/init-postgres.sh && chmod +x /app/init-postgres.sh
|
RUN dos2unix /app/init-postgres.sh && chmod +x /app/init-postgres.sh
|
||||||
|
|
||||||
# Clean up build dependencies to reduce image size
|
# Clean up build dependencies to reduce image size
|
||||||
RUN apt-get purge -y build-essential postgresql-server-dev-14 git \
|
RUN apt-get purge -y build-essential postgresql-server-dev-14 \
|
||||||
&& apt-get autoremove -y \
|
&& apt-get autoremove -y \
|
||||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -29,8 +29,7 @@ SurfSense is a highly customizable AI research agent, connected to external sour
|
||||||
|
|
||||||
# Video
|
# Video
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/42a29ea1-d4d8-4213-9c69-972b5b806d58
|
https://github.com/user-attachments/assets/cc0c84d3-1f2f-4f7a-b519-2ecce22310b1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Podcast Sample
|
## Podcast Sample
|
||||||
|
|
@ -52,8 +51,10 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
|
||||||
- Interact in Natural Language and get cited answers.
|
- Interact in Natural Language and get cited answers.
|
||||||
### 📄 **Cited Answers**
|
### 📄 **Cited Answers**
|
||||||
- Get Cited answers just like Perplexity.
|
- Get Cited answers just like Perplexity.
|
||||||
|
### 🧩 **Universal Compatibility**
|
||||||
|
- Connect virtually any inference provider via the OpenAI spec and LiteLLM.
|
||||||
### 🔔 **Privacy & Local LLM Support**
|
### 🔔 **Privacy & Local LLM Support**
|
||||||
- Works Flawlessly with Ollama local LLMs.
|
- Works Flawlessly with local LLMs like vLLM and Ollama.
|
||||||
### 🏠 **Self Hostable**
|
### 🏠 **Self Hostable**
|
||||||
- Open source and easy to deploy locally.
|
- Open source and easy to deploy locally.
|
||||||
### 👥 **Team Collaboration with RBAC**
|
### 👥 **Team Collaboration with RBAC**
|
||||||
|
|
@ -61,6 +62,7 @@ https://github.com/user-attachments/assets/a0a16566-6967-4374-ac51-9b3e07fbecd7
|
||||||
- Invite team members with customizable roles (Owner, Admin, Editor, Viewer)
|
- Invite team members with customizable roles (Owner, Admin, Editor, Viewer)
|
||||||
- Granular permissions for documents, chats, connectors, and settings
|
- Granular permissions for documents, chats, connectors, and settings
|
||||||
- Share knowledge bases securely within your organization
|
- Share knowledge bases securely within your organization
|
||||||
|
- Team chats update in real-time and "Chat about the chat" in comment threads
|
||||||
### 🎙️ Podcasts
|
### 🎙️ Podcasts
|
||||||
- Blazingly fast podcast generation agent. (Creates a 3-minute podcast in under 20 seconds.)
|
- Blazingly fast podcast generation agent. (Creates a 3-minute podcast in under 20 seconds.)
|
||||||
- Convert your chat conversations into engaging audio content
|
- Convert your chat conversations into engaging audio content
|
||||||
|
|
@ -237,6 +239,8 @@ Before self-hosting installation, make sure to complete the [prerequisite setup
|
||||||
|
|
||||||
### **BackEnd**
|
### **BackEnd**
|
||||||
|
|
||||||
|
- **LiteLLM**: Universal LLM integration supporting 100+ models (OpenAI, Anthropic, Ollama, etc.)
|
||||||
|
|
||||||
- **FastAPI**: Modern, fast web framework for building APIs with Python
|
- **FastAPI**: Modern, fast web framework for building APIs with Python
|
||||||
|
|
||||||
- **PostgreSQL with pgvector**: Database with vector search capabilities for similarity searches
|
- **PostgreSQL with pgvector**: Database with vector search capabilities for similarity searches
|
||||||
|
|
@ -253,8 +257,6 @@ Before self-hosting installation, make sure to complete the [prerequisite setup
|
||||||
|
|
||||||
- **LangChain**: Framework for developing AI-powered applications.
|
- **LangChain**: Framework for developing AI-powered applications.
|
||||||
|
|
||||||
- **LiteLLM**: Universal LLM integration supporting 100+ models (OpenAI, Anthropic, Ollama, etc.)
|
|
||||||
|
|
||||||
- **Rerankers**: Advanced result ranking for improved search relevance
|
- **Rerankers**: Advanced result ranking for improved search relevance
|
||||||
|
|
||||||
- **Hybrid Search**: Combines vector similarity and full-text search for optimal results using Reciprocal Rank Fusion (RRF)
|
- **Hybrid Search**: Combines vector similarity and full-text search for optimal results using Reciprocal Rank Fusion (RRF)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libxext6 \
|
libxext6 \
|
||||||
libxrender1 \
|
libxrender1 \
|
||||||
dos2unix \
|
dos2unix \
|
||||||
|
git \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Update certificates and install SSL tools
|
# Update certificates and install SSL tools
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""Add Composio connector types to SearchSourceConnectorType and DocumentType enums
|
||||||
|
|
||||||
|
Revision ID: 79
|
||||||
|
Revises: 78
|
||||||
|
|
||||||
|
This migration adds the Composio connector enum values to both:
|
||||||
|
- searchsourceconnectortype (for connector type tracking)
|
||||||
|
- documenttype (for document type tracking)
|
||||||
|
|
||||||
|
Composio is a managed OAuth integration service that allows connecting
|
||||||
|
to various third-party services (Google Drive, Gmail, Calendar, etc.)
|
||||||
|
without requiring separate OAuth app verification.
|
||||||
|
|
||||||
|
This migration adds three specific connector types:
|
||||||
|
- COMPOSIO_GOOGLE_DRIVE_CONNECTOR
|
||||||
|
- COMPOSIO_GMAIL_CONNECTOR
|
||||||
|
- COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "79"
|
||||||
|
down_revision: str | None = "78"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# Define the ENUM type names and the new values
|
||||||
|
CONNECTOR_ENUM = "searchsourceconnectortype"
|
||||||
|
CONNECTOR_NEW_VALUES = [
|
||||||
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
]
|
||||||
|
DOCUMENT_ENUM = "documenttype"
|
||||||
|
DOCUMENT_NEW_VALUES = [
|
||||||
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema - add Composio connector types to connector and document enums safely."""
|
||||||
|
# Add each Composio connector type to searchsourceconnectortype only if not exists
|
||||||
|
for value in CONNECTOR_NEW_VALUES:
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum e
|
||||||
|
JOIN pg_type t ON e.enumtypid = t.oid
|
||||||
|
WHERE t.typname = '{CONNECTOR_ENUM}' AND e.enumlabel = '{value}'
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{value}';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add each Composio connector type to documenttype only if not exists
|
||||||
|
for value in DOCUMENT_NEW_VALUES:
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_enum e
|
||||||
|
JOIN pg_type t ON e.enumtypid = t.oid
|
||||||
|
WHERE t.typname = '{DOCUMENT_ENUM}' AND e.enumlabel = '{value}'
|
||||||
|
) THEN
|
||||||
|
ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{value}';
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema - remove Composio connector types from connector and document enums.
|
||||||
|
|
||||||
|
Note: PostgreSQL does not support removing enum values directly.
|
||||||
|
To properly downgrade, you would need to:
|
||||||
|
1. Delete any rows using the Composio connector type values
|
||||||
|
2. Create new enums without the Composio connector types
|
||||||
|
3. Alter the columns to use the new enums
|
||||||
|
4. Drop the old enums
|
||||||
|
|
||||||
|
This is left as a no-op since removing enum values is complex
|
||||||
|
and typically not needed in practice.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
"""Add user incentive tasks table for earning free pages
|
||||||
|
|
||||||
|
Revision ID: 80
|
||||||
|
Revises: 79
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
1. Create incentive_task_type enum with GITHUB_STAR value
|
||||||
|
2. Create user_incentive_tasks table to track completed tasks
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "80"
|
||||||
|
down_revision: str | None = "79"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create incentive tasks infrastructure."""
|
||||||
|
|
||||||
|
# Check if enum already exists (handles partial migration recovery)
|
||||||
|
conn = op.get_bind()
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("SELECT 1 FROM pg_type WHERE typname = 'incentivetasktype'")
|
||||||
|
)
|
||||||
|
enum_exists = result.fetchone() is not None
|
||||||
|
|
||||||
|
# Create the enum type only if it doesn't exist
|
||||||
|
if not enum_exists:
|
||||||
|
incentive_task_type_enum = postgresql.ENUM(
|
||||||
|
"GITHUB_STAR",
|
||||||
|
name="incentivetasktype",
|
||||||
|
create_type=False,
|
||||||
|
)
|
||||||
|
incentive_task_type_enum.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
# Check if table already exists (handles partial migration recovery)
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM information_schema.tables WHERE table_name = 'user_incentive_tasks'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
table_exists = result.fetchone() is not None
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
# Create the user_incentive_tasks table
|
||||||
|
op.create_table(
|
||||||
|
"user_incentive_tasks",
|
||||||
|
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"task_type",
|
||||||
|
postgresql.ENUM(
|
||||||
|
"GITHUB_STAR", name="incentivetasktype", create_type=False
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.Column("pages_awarded", sa.Integer(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"completed_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
index=True,
|
||||||
|
),
|
||||||
|
sa.UniqueConstraint("user_id", "task_type", name="uq_user_incentive_task"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove incentive tasks infrastructure."""
|
||||||
|
|
||||||
|
# Drop the table
|
||||||
|
op.drop_table("user_incentive_tasks")
|
||||||
|
|
||||||
|
# Drop the enum type
|
||||||
|
postgresql.ENUM(name="incentivetasktype").drop(op.get_bind(), checkfirst=True)
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Add public sharing columns to new_chat_threads
|
"""Add public sharing columns to new_chat_threads
|
||||||
|
|
||||||
Revision ID: 79
|
Revision ID: 81
|
||||||
Revises: 78
|
Revises: 80
|
||||||
Create Date: 2026-01-23
|
Create Date: 2026-01-23
|
||||||
|
|
||||||
Adds public_share_token and public_share_enabled columns to enable
|
Adds public_share_token and public_share_enabled columns to enable
|
||||||
|
|
@ -13,8 +13,8 @@ from collections.abc import Sequence
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision: str = "79"
|
revision: str = "81"
|
||||||
down_revision: str | None = "78"
|
down_revision: str | None = "80"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Add thread_id to podcasts
|
"""Add thread_id to podcasts
|
||||||
|
|
||||||
Revision ID: 80
|
Revision ID: 82
|
||||||
Revises: 79
|
Revises: 81
|
||||||
Create Date: 2026-01-23
|
Create Date: 2026-01-23
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
@ -10,8 +10,8 @@ from collections.abc import Sequence
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
revision: str = "80"
|
revision: str = "82"
|
||||||
down_revision: str | None = "79"
|
down_revision: str | None = "81"
|
||||||
branch_labels: str | Sequence[str] | None = None
|
branch_labels: str | Sequence[str] | None = None
|
||||||
depends_on: str | Sequence[str] | None = None
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
@ -7,6 +7,7 @@ via NewLLMConfig.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from deepagents import create_deep_agent
|
from deepagents import create_deep_agent
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
|
|
@ -23,6 +24,90 @@ from app.agents.new_chat.system_prompt import (
|
||||||
from app.agents.new_chat.tools.registry import build_tools_async
|
from app.agents.new_chat.tools.registry import build_tools_async
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Connector Type Mapping
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Maps SearchSourceConnectorType enum values to the searchable document/connector types
|
||||||
|
# used by the knowledge_base tool. Some connectors map to different document types.
|
||||||
|
_CONNECTOR_TYPE_TO_SEARCHABLE: dict[str, str] = {
|
||||||
|
# Direct mappings (connector type == searchable type)
|
||||||
|
"TAVILY_API": "TAVILY_API",
|
||||||
|
"SEARXNG_API": "SEARXNG_API",
|
||||||
|
"LINKUP_API": "LINKUP_API",
|
||||||
|
"BAIDU_SEARCH_API": "BAIDU_SEARCH_API",
|
||||||
|
"SLACK_CONNECTOR": "SLACK_CONNECTOR",
|
||||||
|
"TEAMS_CONNECTOR": "TEAMS_CONNECTOR",
|
||||||
|
"NOTION_CONNECTOR": "NOTION_CONNECTOR",
|
||||||
|
"GITHUB_CONNECTOR": "GITHUB_CONNECTOR",
|
||||||
|
"LINEAR_CONNECTOR": "LINEAR_CONNECTOR",
|
||||||
|
"DISCORD_CONNECTOR": "DISCORD_CONNECTOR",
|
||||||
|
"JIRA_CONNECTOR": "JIRA_CONNECTOR",
|
||||||
|
"CONFLUENCE_CONNECTOR": "CONFLUENCE_CONNECTOR",
|
||||||
|
"CLICKUP_CONNECTOR": "CLICKUP_CONNECTOR",
|
||||||
|
"GOOGLE_CALENDAR_CONNECTOR": "GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
"GOOGLE_GMAIL_CONNECTOR": "GOOGLE_GMAIL_CONNECTOR",
|
||||||
|
"GOOGLE_DRIVE_CONNECTOR": "GOOGLE_DRIVE_FILE", # Connector type differs from document type
|
||||||
|
"AIRTABLE_CONNECTOR": "AIRTABLE_CONNECTOR",
|
||||||
|
"LUMA_CONNECTOR": "LUMA_CONNECTOR",
|
||||||
|
"ELASTICSEARCH_CONNECTOR": "ELASTICSEARCH_CONNECTOR",
|
||||||
|
"WEBCRAWLER_CONNECTOR": "CRAWLED_URL", # Maps to document type
|
||||||
|
"BOOKSTACK_CONNECTOR": "BOOKSTACK_CONNECTOR",
|
||||||
|
"CIRCLEBACK_CONNECTOR": "CIRCLEBACK", # Connector type differs from document type
|
||||||
|
"OBSIDIAN_CONNECTOR": "OBSIDIAN_CONNECTOR",
|
||||||
|
# Composio connectors
|
||||||
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR": "COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Document types that don't come from SearchSourceConnector but should always be searchable
|
||||||
|
_ALWAYS_AVAILABLE_DOC_TYPES: list[str] = [
|
||||||
|
"EXTENSION", # Browser extension data
|
||||||
|
"FILE", # Uploaded files
|
||||||
|
"NOTE", # User notes
|
||||||
|
"YOUTUBE_VIDEO", # YouTube videos
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _map_connectors_to_searchable_types(
|
||||||
|
connector_types: list[Any],
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Map SearchSourceConnectorType enums to searchable document/connector types.
|
||||||
|
|
||||||
|
This function:
|
||||||
|
1. Converts connector type enums to their searchable counterparts
|
||||||
|
2. Includes always-available document types (EXTENSION, FILE, NOTE, YOUTUBE_VIDEO)
|
||||||
|
3. Deduplicates while preserving order
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connector_types: List of SearchSourceConnectorType enum values
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of searchable connector/document type strings
|
||||||
|
"""
|
||||||
|
result_set: set[str] = set()
|
||||||
|
result_list: list[str] = []
|
||||||
|
|
||||||
|
# Add always-available document types first
|
||||||
|
for doc_type in _ALWAYS_AVAILABLE_DOC_TYPES:
|
||||||
|
if doc_type not in result_set:
|
||||||
|
result_set.add(doc_type)
|
||||||
|
result_list.append(doc_type)
|
||||||
|
|
||||||
|
# Map each connector type to its searchable equivalent
|
||||||
|
for ct in connector_types:
|
||||||
|
# Handle both enum and string types
|
||||||
|
ct_str = ct.value if hasattr(ct, "value") else str(ct)
|
||||||
|
searchable = _CONNECTOR_TYPE_TO_SEARCHABLE.get(ct_str)
|
||||||
|
if searchable and searchable not in result_set:
|
||||||
|
result_set.add(searchable)
|
||||||
|
result_list.append(searchable)
|
||||||
|
|
||||||
|
return result_list
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Deep Agent Factory
|
# Deep Agent Factory
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -117,6 +202,30 @@ async def create_surfsense_deep_agent(
|
||||||
additional_tools=[my_custom_tool]
|
additional_tools=[my_custom_tool]
|
||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
|
# Discover available connectors and document types for this search space
|
||||||
|
# This enables dynamic tool docstrings that inform the LLM about what's actually available
|
||||||
|
available_connectors: list[str] | None = None
|
||||||
|
available_document_types: list[str] | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get enabled search source connectors for this search space
|
||||||
|
connector_types = await connector_service.get_available_connectors(
|
||||||
|
search_space_id
|
||||||
|
)
|
||||||
|
if connector_types:
|
||||||
|
# Convert enum values to strings and also include mapped document types
|
||||||
|
available_connectors = _map_connectors_to_searchable_types(connector_types)
|
||||||
|
|
||||||
|
# Get document types that have at least one document indexed
|
||||||
|
available_document_types = await connector_service.get_available_document_types(
|
||||||
|
search_space_id
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Log but don't fail - fall back to all connectors if discovery fails
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.warning(f"Failed to discover available connectors/document types: {e}")
|
||||||
|
|
||||||
# Build dependencies dict for the tools registry
|
# Build dependencies dict for the tools registry
|
||||||
dependencies = {
|
dependencies = {
|
||||||
"search_space_id": search_space_id,
|
"search_space_id": search_space_id,
|
||||||
|
|
@ -125,6 +234,9 @@ async def create_surfsense_deep_agent(
|
||||||
"firecrawl_api_key": firecrawl_api_key,
|
"firecrawl_api_key": firecrawl_api_key,
|
||||||
"user_id": user_id, # Required for memory tools
|
"user_id": user_id, # Required for memory tools
|
||||||
"thread_id": thread_id, # For podcast tool
|
"thread_id": thread_id, # For podcast tool
|
||||||
|
# Dynamic connector/document type discovery for knowledge base tool
|
||||||
|
"available_connectors": available_connectors,
|
||||||
|
"available_document_types": available_document_types,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build tools using the async registry (includes MCP tools)
|
# Build tools using the async registry (includes MCP tools)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ Available tools:
|
||||||
# Tool factory exports (for direct use)
|
# Tool factory exports (for direct use)
|
||||||
from .display_image import create_display_image_tool
|
from .display_image import create_display_image_tool
|
||||||
from .knowledge_base import (
|
from .knowledge_base import (
|
||||||
|
CONNECTOR_DESCRIPTIONS,
|
||||||
create_search_knowledge_base_tool,
|
create_search_knowledge_base_tool,
|
||||||
format_documents_for_context,
|
format_documents_for_context,
|
||||||
search_knowledge_base_async,
|
search_knowledge_base_async,
|
||||||
|
|
@ -40,6 +41,8 @@ from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Registry
|
# Registry
|
||||||
"BUILTIN_TOOLS",
|
"BUILTIN_TOOLS",
|
||||||
|
# Knowledge base utilities
|
||||||
|
"CONNECTOR_DESCRIPTIONS",
|
||||||
"ToolDefinition",
|
"ToolDefinition",
|
||||||
"build_tools",
|
"build_tools",
|
||||||
# Tool factories
|
# Tool factories
|
||||||
|
|
@ -51,7 +54,6 @@ __all__ = [
|
||||||
"create_scrape_webpage_tool",
|
"create_scrape_webpage_tool",
|
||||||
"create_search_knowledge_base_tool",
|
"create_search_knowledge_base_tool",
|
||||||
"create_search_surfsense_docs_tool",
|
"create_search_surfsense_docs_tool",
|
||||||
# Knowledge base utilities
|
|
||||||
"format_documents_for_context",
|
"format_documents_for_context",
|
||||||
"get_all_tool_names",
|
"get_all_tool_names",
|
||||||
"get_default_enabled_tools",
|
"get_default_enabled_tools",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import StructuredTool
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
|
|
@ -22,6 +23,7 @@ from app.services.connector_service import ConnectorService
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
# Canonical connector values used internally by ConnectorService
|
# Canonical connector values used internally by ConnectorService
|
||||||
|
# Includes all document types and search source connectors
|
||||||
_ALL_CONNECTORS: list[str] = [
|
_ALL_CONNECTORS: list[str] = [
|
||||||
"EXTENSION",
|
"EXTENSION",
|
||||||
"FILE",
|
"FILE",
|
||||||
|
|
@ -50,41 +52,117 @@ _ALL_CONNECTORS: list[str] = [
|
||||||
"CRAWLED_URL",
|
"CRAWLED_URL",
|
||||||
"CIRCLEBACK",
|
"CIRCLEBACK",
|
||||||
"OBSIDIAN_CONNECTOR",
|
"OBSIDIAN_CONNECTOR",
|
||||||
|
# Composio connectors
|
||||||
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Human-readable descriptions for each connector type
|
||||||
|
# Used for generating dynamic docstrings and informing the LLM
|
||||||
|
CONNECTOR_DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"EXTENSION": "Web content saved via SurfSense browser extension (personal browsing history)",
|
||||||
|
"FILE": "User-uploaded documents (PDFs, Word, etc.) (personal files)",
|
||||||
|
"NOTE": "SurfSense Notes (notes created inside SurfSense)",
|
||||||
|
"SLACK_CONNECTOR": "Slack conversations and shared content (personal workspace communications)",
|
||||||
|
"TEAMS_CONNECTOR": "Microsoft Teams messages and conversations (personal Teams communications)",
|
||||||
|
"NOTION_CONNECTOR": "Notion workspace pages and databases (personal knowledge management)",
|
||||||
|
"YOUTUBE_VIDEO": "YouTube video transcripts and metadata (personally saved videos)",
|
||||||
|
"GITHUB_CONNECTOR": "GitHub repository content and issues (personal repositories and interactions)",
|
||||||
|
"ELASTICSEARCH_CONNECTOR": "Elasticsearch indexed documents and data (personal Elasticsearch instances)",
|
||||||
|
"LINEAR_CONNECTOR": "Linear project issues and discussions (personal project management)",
|
||||||
|
"JIRA_CONNECTOR": "Jira project issues, tickets, and comments (personal project tracking)",
|
||||||
|
"CONFLUENCE_CONNECTOR": "Confluence pages and comments (personal project documentation)",
|
||||||
|
"CLICKUP_CONNECTOR": "ClickUp tasks and project data (personal task management)",
|
||||||
|
"GOOGLE_CALENDAR_CONNECTOR": "Google Calendar events, meetings, and schedules (personal calendar)",
|
||||||
|
"GOOGLE_GMAIL_CONNECTOR": "Google Gmail emails and conversations (personal emails)",
|
||||||
|
"GOOGLE_DRIVE_FILE": "Google Drive files and documents (personal cloud storage)",
|
||||||
|
"DISCORD_CONNECTOR": "Discord server conversations and shared content (personal community)",
|
||||||
|
"AIRTABLE_CONNECTOR": "Airtable records, tables, and database content (personal data)",
|
||||||
|
"TAVILY_API": "Tavily web search API results (real-time web search)",
|
||||||
|
"SEARXNG_API": "SearxNG search API results (privacy-focused web search)",
|
||||||
|
"LINKUP_API": "Linkup search API results (web search)",
|
||||||
|
"BAIDU_SEARCH_API": "Baidu search API results (Chinese web search)",
|
||||||
|
"LUMA_CONNECTOR": "Luma events and meetings",
|
||||||
|
"WEBCRAWLER_CONNECTOR": "Webpages indexed by SurfSense (personally selected websites)",
|
||||||
|
"CRAWLED_URL": "Webpages indexed by SurfSense (personally selected websites)",
|
||||||
|
"BOOKSTACK_CONNECTOR": "BookStack pages (personal documentation)",
|
||||||
|
"CIRCLEBACK": "Circleback meeting notes, transcripts, and action items",
|
||||||
|
"OBSIDIAN_CONNECTOR": "Obsidian vault notes and markdown files (personal notes)",
|
||||||
|
# Composio connectors
|
||||||
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": "Google Drive files via Composio (personal cloud storage)",
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR": "Gmail emails via Composio (personal emails)",
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": "Google Calendar events via Composio (personal calendar)",
|
||||||
|
}
|
||||||
|
|
||||||
def _normalize_connectors(connectors_to_search: list[str] | None) -> list[str]:
|
|
||||||
|
def _normalize_connectors(
|
||||||
|
connectors_to_search: list[str] | None,
|
||||||
|
available_connectors: list[str] | None = None,
|
||||||
|
) -> list[str]:
|
||||||
"""
|
"""
|
||||||
Normalize connectors provided by the model.
|
Normalize connectors provided by the model.
|
||||||
|
|
||||||
- Accepts user-facing enums like WEBCRAWLER_CONNECTOR and maps them to canonical
|
- Accepts user-facing enums like WEBCRAWLER_CONNECTOR and maps them to canonical
|
||||||
ConnectorService types.
|
ConnectorService types.
|
||||||
- Drops unknown values.
|
- Drops unknown values.
|
||||||
- If None/empty, defaults to searching across all known connectors.
|
- If available_connectors is provided, only includes connectors from that list.
|
||||||
|
- If connectors_to_search is None/empty, defaults to available_connectors or all.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connectors_to_search: List of connectors requested by the model
|
||||||
|
available_connectors: List of connectors actually available in the search space
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of normalized connector strings to search
|
||||||
"""
|
"""
|
||||||
|
# Determine the set of valid connectors to consider
|
||||||
|
valid_set = (
|
||||||
|
set(available_connectors) if available_connectors else set(_ALL_CONNECTORS)
|
||||||
|
)
|
||||||
|
|
||||||
if not connectors_to_search:
|
if not connectors_to_search:
|
||||||
return list(_ALL_CONNECTORS)
|
# Search all available connectors if none specified
|
||||||
|
return (
|
||||||
|
list(available_connectors)
|
||||||
|
if available_connectors
|
||||||
|
else list(_ALL_CONNECTORS)
|
||||||
|
)
|
||||||
|
|
||||||
normalized: list[str] = []
|
normalized: list[str] = []
|
||||||
for raw in connectors_to_search:
|
for raw in connectors_to_search:
|
||||||
c = (raw or "").strip().upper()
|
c = (raw or "").strip().upper()
|
||||||
if not c:
|
if not c:
|
||||||
continue
|
continue
|
||||||
|
# Map user-facing aliases to canonical names
|
||||||
if c == "WEBCRAWLER_CONNECTOR":
|
if c == "WEBCRAWLER_CONNECTOR":
|
||||||
c = "CRAWLED_URL"
|
c = "CRAWLED_URL"
|
||||||
normalized.append(c)
|
normalized.append(c)
|
||||||
|
|
||||||
# de-dupe while preserving order + filter unknown
|
# de-dupe while preserving order + filter to valid connectors
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for c in normalized:
|
for c in normalized:
|
||||||
if c in seen:
|
if c in seen:
|
||||||
continue
|
continue
|
||||||
|
# Only include if it's a known connector AND available
|
||||||
if c not in _ALL_CONNECTORS:
|
if c not in _ALL_CONNECTORS:
|
||||||
continue
|
continue
|
||||||
|
if c not in valid_set:
|
||||||
|
continue
|
||||||
seen.add(c)
|
seen.add(c)
|
||||||
out.append(c)
|
out.append(c)
|
||||||
return out if out else list(_ALL_CONNECTORS)
|
|
||||||
|
# Fallback to all available if nothing matched
|
||||||
|
return (
|
||||||
|
out
|
||||||
|
if out
|
||||||
|
else (
|
||||||
|
list(available_connectors)
|
||||||
|
if available_connectors
|
||||||
|
else list(_ALL_CONNECTORS)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -233,6 +311,7 @@ async def search_knowledge_base_async(
|
||||||
top_k: int = 10,
|
top_k: int = 10,
|
||||||
start_date: datetime | None = None,
|
start_date: datetime | None = None,
|
||||||
end_date: datetime | None = None,
|
end_date: datetime | None = None,
|
||||||
|
available_connectors: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Search the user's knowledge base for relevant documents.
|
Search the user's knowledge base for relevant documents.
|
||||||
|
|
@ -248,6 +327,8 @@ async def search_knowledge_base_async(
|
||||||
top_k: Number of results per connector
|
top_k: Number of results per connector
|
||||||
start_date: Optional start datetime (UTC) for filtering documents
|
start_date: Optional start datetime (UTC) for filtering documents
|
||||||
end_date: Optional end datetime (UTC) for filtering documents
|
end_date: Optional end datetime (UTC) for filtering documents
|
||||||
|
available_connectors: Optional list of connectors actually available in the search space.
|
||||||
|
If provided, only these connectors will be searched.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Formatted string with search results
|
Formatted string with search results
|
||||||
|
|
@ -262,7 +343,7 @@ async def search_knowledge_base_async(
|
||||||
end_date=end_date,
|
end_date=end_date,
|
||||||
)
|
)
|
||||||
|
|
||||||
connectors = _normalize_connectors(connectors_to_search)
|
connectors = _normalize_connectors(connectors_to_search, available_connectors)
|
||||||
|
|
||||||
for connector in connectors:
|
for connector in connectors:
|
||||||
try:
|
try:
|
||||||
|
|
@ -316,6 +397,16 @@ async def search_knowledge_base_async(
|
||||||
)
|
)
|
||||||
all_documents.extend(chunks)
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
elif connector == "TEAMS_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_teams(
|
||||||
|
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 == "NOTION_CONNECTOR":
|
elif connector == "NOTION_CONNECTOR":
|
||||||
_, chunks = await connector_service.search_notion(
|
_, chunks = await connector_service.search_notion(
|
||||||
user_query=query,
|
user_query=query,
|
||||||
|
|
@ -519,6 +610,39 @@ async def search_knowledge_base_async(
|
||||||
)
|
)
|
||||||
all_documents.extend(chunks)
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
# =========================================================
|
||||||
|
# Composio Connectors
|
||||||
|
# =========================================================
|
||||||
|
elif connector == "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_composio_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 == "COMPOSIO_GMAIL_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_composio_gmail(
|
||||||
|
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 == "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
|
||||||
|
_, chunks = await connector_service.search_composio_google_calendar(
|
||||||
|
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
|
||||||
|
|
@ -543,11 +667,68 @@ async def search_knowledge_base_async(
|
||||||
return format_documents_for_context(deduplicated)
|
return format_documents_for_context(deduplicated)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_connector_docstring(available_connectors: list[str] | None) -> str:
|
||||||
|
"""
|
||||||
|
Build the connector documentation section for the tool docstring.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
available_connectors: List of available connector types, or None for all
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted docstring section listing available connectors
|
||||||
|
"""
|
||||||
|
connectors = available_connectors if available_connectors else list(_ALL_CONNECTORS)
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for connector in connectors:
|
||||||
|
# Skip internal names, prefer user-facing aliases
|
||||||
|
if connector == "CRAWLED_URL":
|
||||||
|
# Show as WEBCRAWLER_CONNECTOR for user-facing docs
|
||||||
|
description = CONNECTOR_DESCRIPTIONS.get(connector, connector)
|
||||||
|
lines.append(f"- WEBCRAWLER_CONNECTOR: {description}")
|
||||||
|
else:
|
||||||
|
description = CONNECTOR_DESCRIPTIONS.get(connector, connector)
|
||||||
|
lines.append(f"- {connector}: {description}")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tool Input Schema
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class SearchKnowledgeBaseInput(BaseModel):
|
||||||
|
"""Input schema for the search_knowledge_base tool."""
|
||||||
|
|
||||||
|
query: str = Field(
|
||||||
|
description="The search query - be specific and include key terms"
|
||||||
|
)
|
||||||
|
top_k: int = Field(
|
||||||
|
default=10,
|
||||||
|
description="Number of results to retrieve (default: 10)",
|
||||||
|
)
|
||||||
|
start_date: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional ISO date/datetime (e.g. '2025-12-12' or '2025-12-12T00:00:00+00:00')",
|
||||||
|
)
|
||||||
|
end_date: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional ISO date/datetime (e.g. '2025-12-19' or '2025-12-19T23:59:59+00:00')",
|
||||||
|
)
|
||||||
|
connectors_to_search: list[str] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Optional list of connector enums to search. If omitted, searches all available.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_search_knowledge_base_tool(
|
def create_search_knowledge_base_tool(
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
connector_service: ConnectorService,
|
connector_service: ConnectorService,
|
||||||
):
|
available_connectors: list[str] | None = None,
|
||||||
|
available_document_types: list[str] | None = None,
|
||||||
|
) -> StructuredTool:
|
||||||
"""
|
"""
|
||||||
Factory function to create the search_knowledge_base tool with injected dependencies.
|
Factory function to create the search_knowledge_base tool with injected dependencies.
|
||||||
|
|
||||||
|
|
@ -555,72 +736,57 @@ def create_search_knowledge_base_tool(
|
||||||
search_space_id: The user's search space ID
|
search_space_id: The user's search space ID
|
||||||
db_session: Database session
|
db_session: Database session
|
||||||
connector_service: Initialized connector service
|
connector_service: Initialized connector service
|
||||||
|
available_connectors: Optional list of connector types available in the search space.
|
||||||
|
Used to dynamically generate the tool docstring.
|
||||||
|
available_document_types: Optional list of document types that have data in the search space.
|
||||||
|
Used to inform the LLM about what data exists.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A configured tool function
|
A configured StructuredTool instance
|
||||||
"""
|
"""
|
||||||
|
# Build connector documentation dynamically
|
||||||
|
connector_docs = _build_connector_docstring(available_connectors)
|
||||||
|
|
||||||
@tool
|
# Build context about available document types
|
||||||
async def search_knowledge_base(
|
doc_types_info = ""
|
||||||
|
if available_document_types:
|
||||||
|
doc_types_info = f"""
|
||||||
|
|
||||||
|
## Document types with indexed content in this search space
|
||||||
|
|
||||||
|
The following document types have content available for search:
|
||||||
|
{", ".join(available_document_types)}
|
||||||
|
|
||||||
|
Focus searches on these types for best results."""
|
||||||
|
|
||||||
|
# Build the dynamic description for the tool
|
||||||
|
# This is what the LLM sees when deciding whether/how to use the tool
|
||||||
|
dynamic_description = f"""Search the user's personal knowledge base for relevant information.
|
||||||
|
|
||||||
|
Use this tool to find documents, notes, files, web pages, and other content that may help answer the user's question.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- If the user requests a specific source type (e.g. "my notes", "Slack messages"), pass `connectors_to_search=[...]` using the enums below.
|
||||||
|
- If `connectors_to_search` is omitted/empty, the system will search broadly.
|
||||||
|
- Only connectors that are enabled/configured for this search space are available.{doc_types_info}
|
||||||
|
|
||||||
|
## Available connector enums for `connectors_to_search`
|
||||||
|
|
||||||
|
{connector_docs}
|
||||||
|
|
||||||
|
NOTE: `WEBCRAWLER_CONNECTOR` is mapped internally to the canonical document type `CRAWLED_URL`."""
|
||||||
|
|
||||||
|
# Capture for closure
|
||||||
|
_available_connectors = available_connectors
|
||||||
|
|
||||||
|
async def _search_knowledge_base_impl(
|
||||||
query: str,
|
query: str,
|
||||||
top_k: int = 10,
|
top_k: int = 10,
|
||||||
start_date: str | None = None,
|
start_date: str | None = None,
|
||||||
end_date: str | None = None,
|
end_date: str | None = None,
|
||||||
connectors_to_search: list[str] | None = None,
|
connectors_to_search: list[str] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""Implementation function for knowledge base search."""
|
||||||
Search the user's personal knowledge base for relevant information.
|
|
||||||
|
|
||||||
Use this tool to find documents, notes, files, web pages, and other content
|
|
||||||
that may help answer the user's question.
|
|
||||||
|
|
||||||
IMPORTANT:
|
|
||||||
- If the user requests a specific source type (e.g. "my notes", "Slack messages"),
|
|
||||||
pass `connectors_to_search=[...]` using the enums below.
|
|
||||||
- If `connectors_to_search` is omitted/empty, the system will search broadly.
|
|
||||||
|
|
||||||
## Available connector enums for `connectors_to_search`
|
|
||||||
|
|
||||||
- EXTENSION: "Web content saved via SurfSense browser extension" (personal browsing history)
|
|
||||||
- FILE: "User-uploaded documents (PDFs, Word, etc.)" (personal files)
|
|
||||||
- NOTE: "SurfSense Notes" (notes created inside SurfSense)
|
|
||||||
- SLACK_CONNECTOR: "Slack conversations and shared content" (personal workspace communications)
|
|
||||||
- TEAMS_CONNECTOR: "Microsoft Teams messages and conversations" (personal Teams communications)
|
|
||||||
- NOTION_CONNECTOR: "Notion workspace pages and databases" (personal knowledge management)
|
|
||||||
- YOUTUBE_VIDEO: "YouTube video transcripts and metadata" (personally saved videos)
|
|
||||||
- GITHUB_CONNECTOR: "GitHub repository content and issues" (personal repositories and interactions)
|
|
||||||
- ELASTICSEARCH_CONNECTOR: "Elasticsearch indexed documents and data" (personal Elasticsearch instances and custom data sources)
|
|
||||||
- LINEAR_CONNECTOR: "Linear project issues and discussions" (personal project management)
|
|
||||||
- JIRA_CONNECTOR: "Jira project issues, tickets, and comments" (personal project tracking)
|
|
||||||
- CONFLUENCE_CONNECTOR: "Confluence pages and comments" (personal project documentation)
|
|
||||||
- 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_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)
|
|
||||||
- AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization)
|
|
||||||
- TAVILY_API: "Tavily search API results" (personalized search results)
|
|
||||||
- SEARXNG_API: "SearxNG search API results" (personalized search results)
|
|
||||||
- LINKUP_API: "Linkup search API results" (personalized search results)
|
|
||||||
- BAIDU_SEARCH_API: "Baidu search API results" (personalized search results)
|
|
||||||
- LUMA_CONNECTOR: "Luma events"
|
|
||||||
- WEBCRAWLER_CONNECTOR: "Webpages indexed by SurfSense" (personally selected websites)
|
|
||||||
- BOOKSTACK_CONNECTOR: "BookStack pages" (personal documentation)
|
|
||||||
- CIRCLEBACK: "Circleback meeting notes, transcripts, and action items" (personal meeting records)
|
|
||||||
- OBSIDIAN_CONNECTOR: "Obsidian vault notes and markdown files" (personal notes and knowledge management)
|
|
||||||
|
|
||||||
NOTE: `WEBCRAWLER_CONNECTOR` is mapped internally to the canonical document type `CRAWLED_URL`.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: The search query - be specific and include key terms
|
|
||||||
top_k: Number of results to retrieve (default: 10)
|
|
||||||
start_date: Optional ISO date/datetime (e.g. "2025-12-12" or "2025-12-12T00:00:00+00:00")
|
|
||||||
end_date: Optional ISO date/datetime (e.g. "2025-12-19" or "2025-12-19T23:59:59+00:00")
|
|
||||||
connectors_to_search: Optional list of connector enums to search. If omitted, searches all.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted string with relevant documents and their content
|
|
||||||
"""
|
|
||||||
from app.agents.new_chat.utils import parse_date_or_datetime
|
from app.agents.new_chat.utils import parse_date_or_datetime
|
||||||
|
|
||||||
parsed_start: datetime | None = None
|
parsed_start: datetime | None = None
|
||||||
|
|
@ -640,6 +806,16 @@ def create_search_knowledge_base_tool(
|
||||||
top_k=top_k,
|
top_k=top_k,
|
||||||
start_date=parsed_start,
|
start_date=parsed_start,
|
||||||
end_date=parsed_end,
|
end_date=parsed_end,
|
||||||
|
available_connectors=_available_connectors,
|
||||||
)
|
)
|
||||||
|
|
||||||
return search_knowledge_base
|
# Create StructuredTool with dynamic description
|
||||||
|
# This properly sets the description that the LLM sees
|
||||||
|
tool = StructuredTool(
|
||||||
|
name="search_knowledge_base",
|
||||||
|
description=dynamic_description,
|
||||||
|
coroutine=_search_knowledge_base_impl,
|
||||||
|
args_schema=SearchKnowledgeBaseInput,
|
||||||
|
)
|
||||||
|
|
||||||
|
return tool
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ class ToolDefinition:
|
||||||
# Contributors: Add your new tools here!
|
# Contributors: Add your new tools here!
|
||||||
BUILTIN_TOOLS: list[ToolDefinition] = [
|
BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||||
# Core tool - searches the user's knowledge base
|
# Core tool - searches the user's knowledge base
|
||||||
|
# Now supports dynamic connector/document type discovery
|
||||||
ToolDefinition(
|
ToolDefinition(
|
||||||
name="search_knowledge_base",
|
name="search_knowledge_base",
|
||||||
description="Search the user's personal knowledge base for relevant information",
|
description="Search the user's personal knowledge base for relevant information",
|
||||||
|
|
@ -92,8 +93,12 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||||
search_space_id=deps["search_space_id"],
|
search_space_id=deps["search_space_id"],
|
||||||
db_session=deps["db_session"],
|
db_session=deps["db_session"],
|
||||||
connector_service=deps["connector_service"],
|
connector_service=deps["connector_service"],
|
||||||
|
# Optional: dynamically discovered connectors/document types
|
||||||
|
available_connectors=deps.get("available_connectors"),
|
||||||
|
available_document_types=deps.get("available_document_types"),
|
||||||
),
|
),
|
||||||
requires=["search_space_id", "db_session", "connector_service"],
|
requires=["search_space_id", "db_session", "connector_service"],
|
||||||
|
# Note: available_connectors and available_document_types are optional
|
||||||
),
|
),
|
||||||
# Podcast generation tool
|
# Podcast generation tool
|
||||||
ToolDefinition(
|
ToolDefinition(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Composio Connector Module.
|
Composio Connector Base Module.
|
||||||
|
|
||||||
Provides a unified interface for interacting with various services via Composio,
|
Provides a base class for interacting with various services via Composio,
|
||||||
primarily used during indexing operations.
|
primarily used during indexing operations.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -19,10 +19,10 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class ComposioConnector:
|
class ComposioConnector:
|
||||||
"""
|
"""
|
||||||
Generic Composio connector for data retrieval.
|
Base Composio connector for data retrieval.
|
||||||
|
|
||||||
Wraps the ComposioService to provide toolkit-specific data access
|
Wraps the ComposioService to provide toolkit-specific data access
|
||||||
for indexing operations.
|
for indexing operations. Subclasses implement toolkit-specific methods.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -89,302 +89,12 @@ class ComposioConnector:
|
||||||
toolkit_id = await self.get_toolkit_id()
|
toolkit_id = await self.get_toolkit_id()
|
||||||
return toolkit_id in INDEXABLE_TOOLKITS
|
return toolkit_id in INDEXABLE_TOOLKITS
|
||||||
|
|
||||||
# ===== Google Drive Methods =====
|
@property
|
||||||
|
def session(self) -> AsyncSession:
|
||||||
|
"""Get the database session."""
|
||||||
|
return self._session
|
||||||
|
|
||||||
async def list_drive_files(
|
@property
|
||||||
self,
|
def connector_id(self) -> int:
|
||||||
folder_id: str | None = None,
|
"""Get the connector ID."""
|
||||||
page_token: str | None = None,
|
return self._connector_id
|
||||||
page_size: int = 100,
|
|
||||||
) -> tuple[list[dict[str, Any]], str | None, str | None]:
|
|
||||||
"""
|
|
||||||
List files from Google Drive via Composio.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
folder_id: Optional folder ID to list contents of.
|
|
||||||
page_token: Pagination token.
|
|
||||||
page_size: Number of files per page.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (files list, next_page_token, error message).
|
|
||||||
"""
|
|
||||||
connected_account_id = await self.get_connected_account_id()
|
|
||||||
if not connected_account_id:
|
|
||||||
return [], None, "No connected account ID found"
|
|
||||||
|
|
||||||
entity_id = await self.get_entity_id()
|
|
||||||
service = await self._get_service()
|
|
||||||
return await service.get_drive_files(
|
|
||||||
connected_account_id=connected_account_id,
|
|
||||||
entity_id=entity_id,
|
|
||||||
folder_id=folder_id,
|
|
||||||
page_token=page_token,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_drive_file_content(
|
|
||||||
self, file_id: str
|
|
||||||
) -> tuple[bytes | None, str | None]:
|
|
||||||
"""
|
|
||||||
Download file content from Google Drive via Composio.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_id: Google Drive file ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (file content bytes, error message).
|
|
||||||
"""
|
|
||||||
connected_account_id = await self.get_connected_account_id()
|
|
||||||
if not connected_account_id:
|
|
||||||
return None, "No connected account ID found"
|
|
||||||
|
|
||||||
entity_id = await self.get_entity_id()
|
|
||||||
service = await self._get_service()
|
|
||||||
return await service.get_drive_file_content(
|
|
||||||
connected_account_id=connected_account_id,
|
|
||||||
entity_id=entity_id,
|
|
||||||
file_id=file_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ===== Gmail Methods =====
|
|
||||||
|
|
||||||
async def list_gmail_messages(
|
|
||||||
self,
|
|
||||||
query: str = "",
|
|
||||||
max_results: int = 100,
|
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
|
||||||
"""
|
|
||||||
List Gmail messages via Composio.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Gmail search query.
|
|
||||||
max_results: Maximum number of messages.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (messages list, error message).
|
|
||||||
"""
|
|
||||||
connected_account_id = await self.get_connected_account_id()
|
|
||||||
if not connected_account_id:
|
|
||||||
return [], "No connected account ID found"
|
|
||||||
|
|
||||||
entity_id = await self.get_entity_id()
|
|
||||||
service = await self._get_service()
|
|
||||||
return await service.get_gmail_messages(
|
|
||||||
connected_account_id=connected_account_id,
|
|
||||||
entity_id=entity_id,
|
|
||||||
query=query,
|
|
||||||
max_results=max_results,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_gmail_message_detail(
|
|
||||||
self, message_id: str
|
|
||||||
) -> tuple[dict[str, Any] | None, str | None]:
|
|
||||||
"""
|
|
||||||
Get full details of a Gmail message via Composio.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message_id: Gmail message ID.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (message details, error message).
|
|
||||||
"""
|
|
||||||
connected_account_id = await self.get_connected_account_id()
|
|
||||||
if not connected_account_id:
|
|
||||||
return None, "No connected account ID found"
|
|
||||||
|
|
||||||
entity_id = await self.get_entity_id()
|
|
||||||
service = await self._get_service()
|
|
||||||
return await service.get_gmail_message_detail(
|
|
||||||
connected_account_id=connected_account_id,
|
|
||||||
entity_id=entity_id,
|
|
||||||
message_id=message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ===== Google Calendar Methods =====
|
|
||||||
|
|
||||||
async def list_calendar_events(
|
|
||||||
self,
|
|
||||||
time_min: str | None = None,
|
|
||||||
time_max: str | None = None,
|
|
||||||
max_results: int = 250,
|
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
|
||||||
"""
|
|
||||||
List Google Calendar events via Composio.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
time_min: Start time (RFC3339 format).
|
|
||||||
time_max: End time (RFC3339 format).
|
|
||||||
max_results: Maximum number of events.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (events list, error message).
|
|
||||||
"""
|
|
||||||
connected_account_id = await self.get_connected_account_id()
|
|
||||||
if not connected_account_id:
|
|
||||||
return [], "No connected account ID found"
|
|
||||||
|
|
||||||
entity_id = await self.get_entity_id()
|
|
||||||
service = await self._get_service()
|
|
||||||
return await service.get_calendar_events(
|
|
||||||
connected_account_id=connected_account_id,
|
|
||||||
entity_id=entity_id,
|
|
||||||
time_min=time_min,
|
|
||||||
time_max=time_max,
|
|
||||||
max_results=max_results,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ===== Utility Methods =====
|
|
||||||
|
|
||||||
def format_gmail_message_to_markdown(self, message: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
Format a Gmail message to markdown.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message: Message object from Composio's GMAIL_FETCH_EMAILS response.
|
|
||||||
Composio structure: messageId, messageText, messageTimestamp,
|
|
||||||
payload.headers, labelIds, attachmentList
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted markdown string.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Composio uses 'messageId' (camelCase)
|
|
||||||
message_id = message.get("messageId", "") or message.get("id", "")
|
|
||||||
label_ids = message.get("labelIds", [])
|
|
||||||
|
|
||||||
# Extract headers from payload
|
|
||||||
payload = message.get("payload", {})
|
|
||||||
headers = payload.get("headers", [])
|
|
||||||
|
|
||||||
# Parse headers into a dict
|
|
||||||
header_dict = {}
|
|
||||||
for header in headers:
|
|
||||||
name = header.get("name", "").lower()
|
|
||||||
value = header.get("value", "")
|
|
||||||
header_dict[name] = value
|
|
||||||
|
|
||||||
# Extract key information
|
|
||||||
subject = header_dict.get("subject", "No Subject")
|
|
||||||
from_email = header_dict.get("from", "Unknown Sender")
|
|
||||||
to_email = header_dict.get("to", "Unknown Recipient")
|
|
||||||
# Composio provides messageTimestamp directly
|
|
||||||
date_str = message.get("messageTimestamp", "") or header_dict.get(
|
|
||||||
"date", "Unknown Date"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build markdown content
|
|
||||||
markdown_content = f"# {subject}\n\n"
|
|
||||||
markdown_content += f"**From:** {from_email}\n"
|
|
||||||
markdown_content += f"**To:** {to_email}\n"
|
|
||||||
markdown_content += f"**Date:** {date_str}\n"
|
|
||||||
|
|
||||||
if label_ids:
|
|
||||||
markdown_content += f"**Labels:** {', '.join(label_ids)}\n"
|
|
||||||
|
|
||||||
markdown_content += "\n---\n\n"
|
|
||||||
|
|
||||||
# Composio provides full message text in 'messageText'
|
|
||||||
message_text = message.get("messageText", "")
|
|
||||||
if message_text:
|
|
||||||
markdown_content += f"## Content\n\n{message_text}\n\n"
|
|
||||||
else:
|
|
||||||
# Fallback to snippet if no messageText
|
|
||||||
snippet = message.get("snippet", "")
|
|
||||||
if snippet:
|
|
||||||
markdown_content += f"## Preview\n\n{snippet}\n\n"
|
|
||||||
|
|
||||||
# Add attachment info if present
|
|
||||||
attachments = message.get("attachmentList", [])
|
|
||||||
if attachments:
|
|
||||||
markdown_content += "## Attachments\n\n"
|
|
||||||
for att in attachments:
|
|
||||||
att_name = att.get("filename", att.get("name", "Unknown"))
|
|
||||||
markdown_content += f"- {att_name}\n"
|
|
||||||
markdown_content += "\n"
|
|
||||||
|
|
||||||
# Add message metadata
|
|
||||||
markdown_content += "## Message Details\n\n"
|
|
||||||
markdown_content += f"- **Message ID:** {message_id}\n"
|
|
||||||
|
|
||||||
return markdown_content
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error formatting message to markdown: {e!s}"
|
|
||||||
|
|
||||||
def format_calendar_event_to_markdown(self, event: dict[str, Any]) -> str:
|
|
||||||
"""
|
|
||||||
Format a Google Calendar event to markdown.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
event: Event object from Google Calendar API.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted markdown string.
|
|
||||||
"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Extract basic event information
|
|
||||||
summary = event.get("summary", "No Title")
|
|
||||||
description = event.get("description", "")
|
|
||||||
location = event.get("location", "")
|
|
||||||
|
|
||||||
# Extract start and end times
|
|
||||||
start = event.get("start", {})
|
|
||||||
end = event.get("end", {})
|
|
||||||
|
|
||||||
start_time = start.get("dateTime") or start.get("date", "")
|
|
||||||
end_time = end.get("dateTime") or end.get("date", "")
|
|
||||||
|
|
||||||
# Format times for display
|
|
||||||
def format_time(time_str: str) -> str:
|
|
||||||
if not time_str:
|
|
||||||
return "Unknown"
|
|
||||||
try:
|
|
||||||
if "T" in time_str:
|
|
||||||
dt = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
|
||||||
return dt.strftime("%Y-%m-%d %H:%M")
|
|
||||||
return time_str
|
|
||||||
except Exception:
|
|
||||||
return time_str
|
|
||||||
|
|
||||||
start_formatted = format_time(start_time)
|
|
||||||
end_formatted = format_time(end_time)
|
|
||||||
|
|
||||||
# Extract attendees
|
|
||||||
attendees = event.get("attendees", [])
|
|
||||||
attendee_list = []
|
|
||||||
for attendee in attendees:
|
|
||||||
email = attendee.get("email", "")
|
|
||||||
display_name = attendee.get("displayName", email)
|
|
||||||
response_status = attendee.get("responseStatus", "")
|
|
||||||
attendee_list.append(f"- {display_name} ({response_status})")
|
|
||||||
|
|
||||||
# Build markdown content
|
|
||||||
markdown_content = f"# {summary}\n\n"
|
|
||||||
markdown_content += f"**Start:** {start_formatted}\n"
|
|
||||||
markdown_content += f"**End:** {end_formatted}\n"
|
|
||||||
|
|
||||||
if location:
|
|
||||||
markdown_content += f"**Location:** {location}\n"
|
|
||||||
|
|
||||||
markdown_content += "\n"
|
|
||||||
|
|
||||||
if description:
|
|
||||||
markdown_content += f"## Description\n\n{description}\n\n"
|
|
||||||
|
|
||||||
if attendee_list:
|
|
||||||
markdown_content += "## Attendees\n\n"
|
|
||||||
markdown_content += "\n".join(attendee_list)
|
|
||||||
markdown_content += "\n\n"
|
|
||||||
|
|
||||||
# Add event metadata
|
|
||||||
markdown_content += "## Event Details\n\n"
|
|
||||||
markdown_content += f"- **Event ID:** {event.get('id', 'Unknown')}\n"
|
|
||||||
markdown_content += f"- **Created:** {event.get('created', 'Unknown')}\n"
|
|
||||||
markdown_content += f"- **Updated:** {event.get('updated', 'Unknown')}\n"
|
|
||||||
|
|
||||||
return markdown_content
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error formatting event to markdown: {e!s}"
|
|
||||||
|
|
|
||||||
613
surfsense_backend/app/connectors/composio_gmail_connector.py
Normal file
613
surfsense_backend/app/connectors/composio_gmail_connector.py
Normal file
|
|
@ -0,0 +1,613 @@
|
||||||
|
"""
|
||||||
|
Composio Gmail Connector Module.
|
||||||
|
|
||||||
|
Provides Gmail specific methods for data retrieval and indexing via Composio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.connectors.composio_connector import ComposioConnector
|
||||||
|
from app.db import Document, DocumentType
|
||||||
|
from app.services.composio_service import TOOLKIT_TO_DOCUMENT_TYPE
|
||||||
|
from app.services.llm_service import get_user_long_context_llm
|
||||||
|
from app.services.task_logging_service import TaskLoggingService
|
||||||
|
from app.tasks.connector_indexers.base import calculate_date_range
|
||||||
|
from app.utils.document_converters import (
|
||||||
|
create_document_chunks,
|
||||||
|
generate_content_hash,
|
||||||
|
generate_document_summary,
|
||||||
|
generate_unique_identifier_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_timestamp() -> datetime:
|
||||||
|
"""Get the current timestamp with timezone for updated_at field."""
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_document_by_unique_identifier(
|
||||||
|
session: AsyncSession, unique_identifier_hash: str
|
||||||
|
) -> Document | None:
|
||||||
|
"""Check if a document with the given unique identifier hash already exists."""
|
||||||
|
existing_doc_result = await session.execute(
|
||||||
|
select(Document)
|
||||||
|
.options(selectinload(Document.chunks))
|
||||||
|
.where(Document.unique_identifier_hash == unique_identifier_hash)
|
||||||
|
)
|
||||||
|
return existing_doc_result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_connector_last_indexed(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Update the last_indexed_at timestamp for a connector."""
|
||||||
|
if update_last_indexed:
|
||||||
|
connector.last_indexed_at = datetime.now(UTC)
|
||||||
|
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
||||||
|
|
||||||
|
|
||||||
|
class ComposioGmailConnector(ComposioConnector):
|
||||||
|
"""
|
||||||
|
Gmail specific Composio connector.
|
||||||
|
|
||||||
|
Provides methods for listing messages, getting message details, and formatting
|
||||||
|
Gmail messages from Gmail via Composio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def list_gmail_messages(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
max_results: int = 50,
|
||||||
|
page_token: str | None = None,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None, int | None, str | None]:
|
||||||
|
"""
|
||||||
|
List Gmail messages via Composio with pagination support.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Gmail search query.
|
||||||
|
max_results: Maximum number of messages per page (default: 50).
|
||||||
|
page_token: Optional pagination token for next page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (messages list, next_page_token, result_size_estimate, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return [], None, None, "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_gmail_messages(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
query=query,
|
||||||
|
max_results=max_results,
|
||||||
|
page_token=page_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_gmail_message_detail(
|
||||||
|
self, message_id: str
|
||||||
|
) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
|
"""
|
||||||
|
Get full details of a Gmail message via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Gmail message ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (message details, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return None, "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_gmail_message_detail(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_gmail_message_to_markdown(self, message: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Format a Gmail message to markdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Message object from Composio's GMAIL_FETCH_EMAILS response.
|
||||||
|
Composio structure: messageId, messageText, messageTimestamp,
|
||||||
|
payload.headers, labelIds, attachmentList
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted markdown string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Composio uses 'messageId' (camelCase)
|
||||||
|
message_id = message.get("messageId", "") or message.get("id", "")
|
||||||
|
label_ids = message.get("labelIds", [])
|
||||||
|
|
||||||
|
# Extract headers from payload
|
||||||
|
payload = message.get("payload", {})
|
||||||
|
headers = payload.get("headers", [])
|
||||||
|
|
||||||
|
# Parse headers into a dict
|
||||||
|
header_dict = {}
|
||||||
|
for header in headers:
|
||||||
|
name = header.get("name", "").lower()
|
||||||
|
value = header.get("value", "")
|
||||||
|
header_dict[name] = value
|
||||||
|
|
||||||
|
# Extract key information
|
||||||
|
subject = header_dict.get("subject", "No Subject")
|
||||||
|
from_email = header_dict.get("from", "Unknown Sender")
|
||||||
|
to_email = header_dict.get("to", "Unknown Recipient")
|
||||||
|
# Composio provides messageTimestamp directly
|
||||||
|
date_str = message.get("messageTimestamp", "") or header_dict.get(
|
||||||
|
"date", "Unknown Date"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build markdown content
|
||||||
|
markdown_content = f"# {subject}\n\n"
|
||||||
|
markdown_content += f"**From:** {from_email}\n"
|
||||||
|
markdown_content += f"**To:** {to_email}\n"
|
||||||
|
markdown_content += f"**Date:** {date_str}\n"
|
||||||
|
|
||||||
|
if label_ids:
|
||||||
|
markdown_content += f"**Labels:** {', '.join(label_ids)}\n"
|
||||||
|
|
||||||
|
markdown_content += "\n---\n\n"
|
||||||
|
|
||||||
|
# Composio provides full message text in 'messageText'
|
||||||
|
message_text = message.get("messageText", "")
|
||||||
|
if message_text:
|
||||||
|
markdown_content += f"## Content\n\n{message_text}\n\n"
|
||||||
|
else:
|
||||||
|
# Fallback to snippet if no messageText
|
||||||
|
snippet = message.get("snippet", "")
|
||||||
|
if snippet:
|
||||||
|
markdown_content += f"## Preview\n\n{snippet}\n\n"
|
||||||
|
|
||||||
|
# Add attachment info if present
|
||||||
|
attachments = message.get("attachmentList", [])
|
||||||
|
if attachments:
|
||||||
|
markdown_content += "## Attachments\n\n"
|
||||||
|
for att in attachments:
|
||||||
|
att_name = att.get("filename", att.get("name", "Unknown"))
|
||||||
|
markdown_content += f"- {att_name}\n"
|
||||||
|
markdown_content += "\n"
|
||||||
|
|
||||||
|
# Add message metadata
|
||||||
|
markdown_content += "## Message Details\n\n"
|
||||||
|
markdown_content += f"- **Message ID:** {message_id}\n"
|
||||||
|
|
||||||
|
return markdown_content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error formatting message to markdown: {e!s}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Indexer Functions ============
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_gmail_message_batch(
|
||||||
|
session: AsyncSession,
|
||||||
|
messages: list[dict[str, Any]],
|
||||||
|
composio_connector: ComposioGmailConnector,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
total_documents_indexed: int = 0,
|
||||||
|
) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
Process a batch of Gmail messages and index them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
total_documents_indexed: Running total of documents indexed so far (for batch commits).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (documents_indexed, documents_skipped)
|
||||||
|
"""
|
||||||
|
documents_indexed = 0
|
||||||
|
documents_skipped = 0
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
try:
|
||||||
|
# Composio uses 'messageId' (camelCase), not 'id'
|
||||||
|
message_id = message.get("messageId", "") or message.get("id", "")
|
||||||
|
if not message_id:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Composio's GMAIL_FETCH_EMAILS already returns full message content
|
||||||
|
# No need for a separate detail API call
|
||||||
|
|
||||||
|
# Extract message info from Composio response
|
||||||
|
# Composio structure: messageId, messageText, messageTimestamp, payload.headers, labelIds
|
||||||
|
payload = message.get("payload", {})
|
||||||
|
headers = payload.get("headers", [])
|
||||||
|
|
||||||
|
subject = "No Subject"
|
||||||
|
sender = "Unknown Sender"
|
||||||
|
date_str = message.get("messageTimestamp", "Unknown Date")
|
||||||
|
|
||||||
|
for header in headers:
|
||||||
|
name = header.get("name", "").lower()
|
||||||
|
value = header.get("value", "")
|
||||||
|
if name == "subject":
|
||||||
|
subject = value
|
||||||
|
elif name == "from":
|
||||||
|
sender = value
|
||||||
|
elif name == "date":
|
||||||
|
date_str = value
|
||||||
|
|
||||||
|
# Format to markdown using the full message data
|
||||||
|
markdown_content = composio_connector.format_gmail_message_to_markdown(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for empty content (defensive parsing per Composio best practices)
|
||||||
|
if not markdown_content.strip():
|
||||||
|
logger.warning(f"Skipping Gmail message with no content: {subject}")
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate unique identifier
|
||||||
|
document_type = DocumentType(TOOLKIT_TO_DOCUMENT_TYPE["gmail"])
|
||||||
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
|
document_type, f"gmail_{message_id}", search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||||
|
|
||||||
|
existing_document = await check_document_by_unique_identifier(
|
||||||
|
session, unique_identifier_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get label IDs from Composio response
|
||||||
|
label_ids = message.get("labelIds", [])
|
||||||
|
# Extract thread_id if available (for consistency with non-Composio implementation)
|
||||||
|
thread_id = message.get("threadId", "") or message.get("thread_id", "")
|
||||||
|
|
||||||
|
if existing_document:
|
||||||
|
if existing_document.content_hash == content_hash:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"document_type": "Gmail Message (Composio)",
|
||||||
|
}
|
||||||
|
(
|
||||||
|
summary_content,
|
||||||
|
summary_embedding,
|
||||||
|
) = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = (
|
||||||
|
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||||
|
)
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(
|
||||||
|
summary_content
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
existing_document.title = f"Gmail: {subject}"
|
||||||
|
existing_document.content = summary_content
|
||||||
|
existing_document.content_hash = content_hash
|
||||||
|
existing_document.embedding = summary_embedding
|
||||||
|
existing_document.document_metadata = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"date": date_str,
|
||||||
|
"labels": label_ids,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"source": "composio",
|
||||||
|
}
|
||||||
|
existing_document.chunks = chunks
|
||||||
|
existing_document.updated_at = get_current_timestamp()
|
||||||
|
|
||||||
|
documents_indexed += 1
|
||||||
|
|
||||||
|
# Batch commit every 10 documents
|
||||||
|
current_total = total_documents_indexed + documents_indexed
|
||||||
|
if current_total % 10 == 0:
|
||||||
|
logger.info(
|
||||||
|
f"Committing batch: {current_total} Gmail messages processed so far"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new document
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"document_type": "Gmail Message (Composio)",
|
||||||
|
}
|
||||||
|
summary_content, summary_embedding = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = (
|
||||||
|
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||||
|
)
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(
|
||||||
|
summary_content
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
title=f"Gmail: {subject}",
|
||||||
|
document_type=DocumentType(TOOLKIT_TO_DOCUMENT_TYPE["gmail"]),
|
||||||
|
document_metadata={
|
||||||
|
"message_id": message_id,
|
||||||
|
"thread_id": thread_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"date": date_str,
|
||||||
|
"labels": label_ids,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"toolkit_id": "gmail",
|
||||||
|
"source": "composio",
|
||||||
|
},
|
||||||
|
content=summary_content,
|
||||||
|
content_hash=content_hash,
|
||||||
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
embedding=summary_embedding,
|
||||||
|
chunks=chunks,
|
||||||
|
updated_at=get_current_timestamp(),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
documents_indexed += 1
|
||||||
|
|
||||||
|
# Batch commit every 10 documents
|
||||||
|
current_total = total_documents_indexed + documents_indexed
|
||||||
|
if current_total % 10 == 0:
|
||||||
|
logger.info(
|
||||||
|
f"Committing batch: {current_total} Gmail messages processed so far"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Gmail message: {e!s}", exc_info=True)
|
||||||
|
documents_skipped += 1
|
||||||
|
# Rollback on error to avoid partial state (per Composio best practices)
|
||||||
|
try:
|
||||||
|
await session.rollback()
|
||||||
|
except Exception as rollback_error:
|
||||||
|
logger.error(
|
||||||
|
f"Error during rollback: {rollback_error!s}", exc_info=True
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return documents_indexed, documents_skipped
|
||||||
|
|
||||||
|
|
||||||
|
async def index_composio_gmail(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str | None,
|
||||||
|
end_date: str | None,
|
||||||
|
task_logger: TaskLoggingService,
|
||||||
|
log_entry,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
max_items: int = 1000,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Index Gmail messages via Composio with pagination and incremental processing."""
|
||||||
|
try:
|
||||||
|
composio_connector = ComposioGmailConnector(session, connector_id)
|
||||||
|
|
||||||
|
# Normalize date values - handle "undefined" strings from frontend
|
||||||
|
if start_date == "undefined" or start_date == "":
|
||||||
|
start_date = None
|
||||||
|
if end_date == "undefined" or end_date == "":
|
||||||
|
end_date = None
|
||||||
|
|
||||||
|
# Use provided dates directly if both are provided, otherwise calculate from last_indexed_at
|
||||||
|
# This ensures user-selected dates are respected (matching non-Composio Gmail connector behavior)
|
||||||
|
if start_date is not None and end_date is not None:
|
||||||
|
# User provided both dates - use them directly
|
||||||
|
start_date_str = start_date
|
||||||
|
end_date_str = end_date
|
||||||
|
else:
|
||||||
|
# Calculate date range with defaults (uses last_indexed_at or 365 days back)
|
||||||
|
# This ensures indexing works even when user doesn't specify dates
|
||||||
|
start_date_str, end_date_str = calculate_date_range(
|
||||||
|
connector, start_date, end_date, default_days_back=365
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build query with date range
|
||||||
|
query_parts = []
|
||||||
|
if start_date_str:
|
||||||
|
query_parts.append(f"after:{start_date_str.replace('-', '/')}")
|
||||||
|
if end_date_str:
|
||||||
|
query_parts.append(f"before:{end_date_str.replace('-', '/')}")
|
||||||
|
query = " ".join(query_parts) if query_parts else ""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Gmail query for connector {connector_id}: '{query}' "
|
||||||
|
f"(start_date={start_date_str}, end_date={end_date_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use smaller batch size to avoid 413 payload too large errors
|
||||||
|
batch_size = 50
|
||||||
|
page_token = None
|
||||||
|
total_documents_indexed = 0
|
||||||
|
total_documents_skipped = 0
|
||||||
|
total_messages_fetched = 0
|
||||||
|
result_size_estimate = None # Will be set from first API response
|
||||||
|
|
||||||
|
while total_messages_fetched < max_items:
|
||||||
|
# Calculate how many messages to fetch in this batch
|
||||||
|
remaining = max_items - total_messages_fetched
|
||||||
|
current_batch_size = min(batch_size, remaining)
|
||||||
|
|
||||||
|
# Use result_size_estimate if available, otherwise fall back to max_items
|
||||||
|
estimated_total = (
|
||||||
|
result_size_estimate if result_size_estimate is not None else max_items
|
||||||
|
)
|
||||||
|
# Cap estimated_total at max_items to avoid showing misleading progress
|
||||||
|
estimated_total = min(estimated_total, max_items)
|
||||||
|
|
||||||
|
await task_logger.log_task_progress(
|
||||||
|
log_entry,
|
||||||
|
f"Fetching Gmail messages batch via Composio for connector {connector_id} "
|
||||||
|
f"({total_messages_fetched}/{estimated_total} fetched, {total_documents_indexed} indexed)",
|
||||||
|
{
|
||||||
|
"stage": "fetching_messages",
|
||||||
|
"batch_size": current_batch_size,
|
||||||
|
"total_fetched": total_messages_fetched,
|
||||||
|
"total_indexed": total_documents_indexed,
|
||||||
|
"estimated_total": estimated_total,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch batch of messages
|
||||||
|
(
|
||||||
|
messages,
|
||||||
|
next_token,
|
||||||
|
result_size_estimate_batch,
|
||||||
|
error,
|
||||||
|
) = await composio_connector.list_gmail_messages(
|
||||||
|
query=query,
|
||||||
|
max_results=current_batch_size,
|
||||||
|
page_token=page_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, f"Failed to fetch Gmail messages: {error}", {}
|
||||||
|
)
|
||||||
|
return 0, f"Failed to fetch Gmail messages: {error}"
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
# No more messages available
|
||||||
|
break
|
||||||
|
|
||||||
|
# Update result_size_estimate from first response (Gmail provides this estimate)
|
||||||
|
if result_size_estimate is None and result_size_estimate_batch is not None:
|
||||||
|
result_size_estimate = result_size_estimate_batch
|
||||||
|
logger.info(
|
||||||
|
f"Gmail API estimated {result_size_estimate} total messages for query: '{query}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
total_messages_fetched += len(messages)
|
||||||
|
# Recalculate estimated_total after potentially updating result_size_estimate
|
||||||
|
estimated_total = (
|
||||||
|
result_size_estimate if result_size_estimate is not None else max_items
|
||||||
|
)
|
||||||
|
estimated_total = min(estimated_total, max_items)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Fetched batch of {len(messages)} Gmail messages "
|
||||||
|
f"(total: {total_messages_fetched}/{estimated_total})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process batch incrementally
|
||||||
|
batch_indexed, batch_skipped = await _process_gmail_message_batch(
|
||||||
|
session=session,
|
||||||
|
messages=messages,
|
||||||
|
composio_connector=composio_connector,
|
||||||
|
connector_id=connector_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
total_documents_indexed=total_documents_indexed,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_documents_indexed += batch_indexed
|
||||||
|
total_documents_skipped += batch_skipped
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Processed batch: {batch_indexed} indexed, {batch_skipped} skipped "
|
||||||
|
f"(total: {total_documents_indexed} indexed, {total_documents_skipped} skipped)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Batch commits happen in _process_gmail_message_batch every 10 documents
|
||||||
|
# This ensures progress is saved incrementally, preventing data loss on crashes
|
||||||
|
|
||||||
|
# Check if we should continue
|
||||||
|
if not next_token:
|
||||||
|
# No more pages available
|
||||||
|
break
|
||||||
|
|
||||||
|
if len(messages) < current_batch_size:
|
||||||
|
# Last page had fewer items than requested, we're done
|
||||||
|
break
|
||||||
|
|
||||||
|
# Continue with next page
|
||||||
|
page_token = next_token
|
||||||
|
|
||||||
|
if total_messages_fetched == 0:
|
||||||
|
success_msg = "No Gmail messages found in the specified date range"
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry, success_msg, {"messages_count": 0}
|
||||||
|
)
|
||||||
|
# CRITICAL: Update timestamp even when no messages found so Electric SQL syncs and UI shows indexed status
|
||||||
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
await session.commit()
|
||||||
|
return 0, None # Return None (not error) when no items found
|
||||||
|
|
||||||
|
# CRITICAL: Always update timestamp (even if 0 documents indexed) so Electric SQL syncs
|
||||||
|
# This ensures the UI shows "Last indexed" instead of "Never indexed"
|
||||||
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
|
||||||
|
# Final commit to ensure all documents are persisted (safety net)
|
||||||
|
# This matches the pattern used in non-Composio Gmail indexer
|
||||||
|
logger.info(
|
||||||
|
f"Final commit: Total {total_documents_indexed} Gmail messages processed"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Successfully committed all Composio Gmail document changes to database"
|
||||||
|
)
|
||||||
|
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry,
|
||||||
|
f"Successfully completed Gmail indexing via Composio for connector {connector_id}",
|
||||||
|
{
|
||||||
|
"documents_indexed": total_documents_indexed,
|
||||||
|
"documents_skipped": total_documents_skipped,
|
||||||
|
"messages_fetched": total_messages_fetched,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return total_documents_indexed, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to index Gmail via Composio: {e!s}", exc_info=True)
|
||||||
|
return 0, f"Failed to index Gmail via Composio: {e!s}"
|
||||||
|
|
@ -0,0 +1,502 @@
|
||||||
|
"""
|
||||||
|
Composio Google Calendar Connector Module.
|
||||||
|
|
||||||
|
Provides Google Calendar specific methods for data retrieval and indexing via Composio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.connectors.composio_connector import ComposioConnector
|
||||||
|
from app.db import Document, DocumentType
|
||||||
|
from app.services.composio_service import TOOLKIT_TO_DOCUMENT_TYPE
|
||||||
|
from app.services.llm_service import get_user_long_context_llm
|
||||||
|
from app.services.task_logging_service import TaskLoggingService
|
||||||
|
from app.tasks.connector_indexers.base import (
|
||||||
|
calculate_date_range,
|
||||||
|
check_duplicate_document_by_hash,
|
||||||
|
)
|
||||||
|
from app.utils.document_converters import (
|
||||||
|
create_document_chunks,
|
||||||
|
generate_content_hash,
|
||||||
|
generate_document_summary,
|
||||||
|
generate_unique_identifier_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_timestamp() -> datetime:
|
||||||
|
"""Get the current timestamp with timezone for updated_at field."""
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_document_by_unique_identifier(
|
||||||
|
session: AsyncSession, unique_identifier_hash: str
|
||||||
|
) -> Document | None:
|
||||||
|
"""Check if a document with the given unique identifier hash already exists."""
|
||||||
|
existing_doc_result = await session.execute(
|
||||||
|
select(Document)
|
||||||
|
.options(selectinload(Document.chunks))
|
||||||
|
.where(Document.unique_identifier_hash == unique_identifier_hash)
|
||||||
|
)
|
||||||
|
return existing_doc_result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_connector_last_indexed(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Update the last_indexed_at timestamp for a connector."""
|
||||||
|
if update_last_indexed:
|
||||||
|
connector.last_indexed_at = datetime.now(UTC)
|
||||||
|
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
||||||
|
|
||||||
|
|
||||||
|
class ComposioGoogleCalendarConnector(ComposioConnector):
|
||||||
|
"""
|
||||||
|
Google Calendar specific Composio connector.
|
||||||
|
|
||||||
|
Provides methods for listing calendar events and formatting them from
|
||||||
|
Google Calendar via Composio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def list_calendar_events(
|
||||||
|
self,
|
||||||
|
time_min: str | None = None,
|
||||||
|
time_max: str | None = None,
|
||||||
|
max_results: int = 250,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
"""
|
||||||
|
List Google Calendar events via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_min: Start time (RFC3339 format).
|
||||||
|
time_max: End time (RFC3339 format).
|
||||||
|
max_results: Maximum number of events.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (events list, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return [], "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_calendar_events(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
time_min=time_min,
|
||||||
|
time_max=time_max,
|
||||||
|
max_results=max_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
def format_calendar_event_to_markdown(self, event: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Format a Google Calendar event to markdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event object from Google Calendar API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted markdown string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Extract basic event information
|
||||||
|
summary = event.get("summary", "No Title")
|
||||||
|
description = event.get("description", "")
|
||||||
|
location = event.get("location", "")
|
||||||
|
|
||||||
|
# Extract start and end times
|
||||||
|
start = event.get("start", {})
|
||||||
|
end = event.get("end", {})
|
||||||
|
|
||||||
|
start_time = start.get("dateTime") or start.get("date", "")
|
||||||
|
end_time = end.get("dateTime") or end.get("date", "")
|
||||||
|
|
||||||
|
# Format times for display
|
||||||
|
def format_time(time_str: str) -> str:
|
||||||
|
if not time_str:
|
||||||
|
return "Unknown"
|
||||||
|
try:
|
||||||
|
if "T" in time_str:
|
||||||
|
dt = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
return time_str
|
||||||
|
except Exception:
|
||||||
|
return time_str
|
||||||
|
|
||||||
|
start_formatted = format_time(start_time)
|
||||||
|
end_formatted = format_time(end_time)
|
||||||
|
|
||||||
|
# Extract attendees
|
||||||
|
attendees = event.get("attendees", [])
|
||||||
|
attendee_list = []
|
||||||
|
for attendee in attendees:
|
||||||
|
email = attendee.get("email", "")
|
||||||
|
display_name = attendee.get("displayName", email)
|
||||||
|
response_status = attendee.get("responseStatus", "")
|
||||||
|
attendee_list.append(f"- {display_name} ({response_status})")
|
||||||
|
|
||||||
|
# Build markdown content
|
||||||
|
markdown_content = f"# {summary}\n\n"
|
||||||
|
markdown_content += f"**Start:** {start_formatted}\n"
|
||||||
|
markdown_content += f"**End:** {end_formatted}\n"
|
||||||
|
|
||||||
|
if location:
|
||||||
|
markdown_content += f"**Location:** {location}\n"
|
||||||
|
|
||||||
|
markdown_content += "\n"
|
||||||
|
|
||||||
|
if description:
|
||||||
|
markdown_content += f"## Description\n\n{description}\n\n"
|
||||||
|
|
||||||
|
if attendee_list:
|
||||||
|
markdown_content += "## Attendees\n\n"
|
||||||
|
markdown_content += "\n".join(attendee_list)
|
||||||
|
markdown_content += "\n\n"
|
||||||
|
|
||||||
|
# Add event metadata
|
||||||
|
markdown_content += "## Event Details\n\n"
|
||||||
|
markdown_content += f"- **Event ID:** {event.get('id', 'Unknown')}\n"
|
||||||
|
markdown_content += f"- **Created:** {event.get('created', 'Unknown')}\n"
|
||||||
|
markdown_content += f"- **Updated:** {event.get('updated', 'Unknown')}\n"
|
||||||
|
|
||||||
|
return markdown_content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error formatting event to markdown: {e!s}"
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Indexer Functions ============
|
||||||
|
|
||||||
|
|
||||||
|
async def index_composio_google_calendar(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str | None,
|
||||||
|
end_date: str | None,
|
||||||
|
task_logger: TaskLoggingService,
|
||||||
|
log_entry,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
max_items: int = 2500,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Index Google Calendar events via Composio."""
|
||||||
|
try:
|
||||||
|
composio_connector = ComposioGoogleCalendarConnector(session, connector_id)
|
||||||
|
|
||||||
|
await task_logger.log_task_progress(
|
||||||
|
log_entry,
|
||||||
|
f"Fetching Google Calendar events via Composio for connector {connector_id}",
|
||||||
|
{"stage": "fetching_events"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Normalize date values - handle "undefined" strings from frontend
|
||||||
|
if start_date == "undefined" or start_date == "":
|
||||||
|
start_date = None
|
||||||
|
if end_date == "undefined" or end_date == "":
|
||||||
|
end_date = None
|
||||||
|
|
||||||
|
# Use provided dates directly if both are provided, otherwise calculate from last_indexed_at
|
||||||
|
# This ensures user-selected dates are respected (matching non-Composio Calendar connector behavior)
|
||||||
|
if start_date is not None and end_date is not None:
|
||||||
|
# User provided both dates - use them directly
|
||||||
|
start_date_str = start_date
|
||||||
|
end_date_str = end_date
|
||||||
|
else:
|
||||||
|
# Calculate date range with defaults (uses last_indexed_at or 365 days back)
|
||||||
|
# This ensures indexing works even when user doesn't specify dates
|
||||||
|
start_date_str, end_date_str = calculate_date_range(
|
||||||
|
connector, start_date, end_date, default_days_back=365
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build time range for API call
|
||||||
|
time_min = f"{start_date_str}T00:00:00Z"
|
||||||
|
time_max = f"{end_date_str}T23:59:59Z"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Google Calendar query for connector {connector_id}: "
|
||||||
|
f"(start_date={start_date_str}, end_date={end_date_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
events, error = await composio_connector.list_calendar_events(
|
||||||
|
time_min=time_min,
|
||||||
|
time_max=time_max,
|
||||||
|
max_results=max_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, f"Failed to fetch Calendar events: {error}", {}
|
||||||
|
)
|
||||||
|
return 0, f"Failed to fetch Calendar events: {error}"
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
success_msg = "No Google Calendar events found in the specified date range"
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry, success_msg, {"events_count": 0}
|
||||||
|
)
|
||||||
|
# CRITICAL: Update timestamp even when no events found so Electric SQL syncs and UI shows indexed status
|
||||||
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
await session.commit()
|
||||||
|
return (
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
) # Return None (not error) when no items found - this is success with 0 items
|
||||||
|
|
||||||
|
logger.info(f"Found {len(events)} Google Calendar events to index via Composio")
|
||||||
|
|
||||||
|
documents_indexed = 0
|
||||||
|
documents_skipped = 0
|
||||||
|
duplicate_content_count = (
|
||||||
|
0 # Track events skipped due to duplicate content_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
try:
|
||||||
|
# Handle both standard Google API and potential Composio variations
|
||||||
|
event_id = event.get("id", "") or event.get("eventId", "")
|
||||||
|
summary = (
|
||||||
|
event.get("summary", "") or event.get("title", "") or "No Title"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not event_id:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Format to markdown
|
||||||
|
markdown_content = composio_connector.format_calendar_event_to_markdown(
|
||||||
|
event
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique identifier
|
||||||
|
document_type = DocumentType(TOOLKIT_TO_DOCUMENT_TYPE["googlecalendar"])
|
||||||
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
|
document_type, f"calendar_{event_id}", search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||||
|
|
||||||
|
existing_document = await check_document_by_unique_identifier(
|
||||||
|
session, unique_identifier_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract event times
|
||||||
|
start = event.get("start", {})
|
||||||
|
end = event.get("end", {})
|
||||||
|
start_time = start.get("dateTime") or start.get("date", "")
|
||||||
|
end_time = end.get("dateTime") or end.get("date", "")
|
||||||
|
location = event.get("location", "")
|
||||||
|
|
||||||
|
if existing_document:
|
||||||
|
if existing_document.content_hash == content_hash:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"document_type": "Google Calendar Event (Composio)",
|
||||||
|
}
|
||||||
|
(
|
||||||
|
summary_content,
|
||||||
|
summary_embedding,
|
||||||
|
) = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||||
|
if location:
|
||||||
|
summary_content += f"\nLocation: {location}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(
|
||||||
|
summary_content
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
existing_document.title = f"Calendar: {summary}"
|
||||||
|
existing_document.content = summary_content
|
||||||
|
existing_document.content_hash = content_hash
|
||||||
|
existing_document.embedding = summary_embedding
|
||||||
|
existing_document.document_metadata = {
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"location": location,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"source": "composio",
|
||||||
|
}
|
||||||
|
existing_document.chunks = chunks
|
||||||
|
existing_document.updated_at = get_current_timestamp()
|
||||||
|
|
||||||
|
documents_indexed += 1
|
||||||
|
|
||||||
|
# Batch commit every 10 documents
|
||||||
|
if documents_indexed % 10 == 0:
|
||||||
|
logger.info(
|
||||||
|
f"Committing batch: {documents_indexed} Google Calendar events processed so far"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Document doesn't exist by unique_identifier_hash
|
||||||
|
# Check if a document with the same content_hash exists (from standard connector)
|
||||||
|
with session.no_autoflush:
|
||||||
|
duplicate_by_content = await check_duplicate_document_by_hash(
|
||||||
|
session, content_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
if duplicate_by_content:
|
||||||
|
# A document with the same content already exists (likely from standard connector)
|
||||||
|
logger.info(
|
||||||
|
f"Event {summary} already indexed by another connector "
|
||||||
|
f"(existing document ID: {duplicate_by_content.id}, "
|
||||||
|
f"type: {duplicate_by_content.document_type}). Skipping to avoid duplicate content."
|
||||||
|
)
|
||||||
|
duplicate_content_count += 1
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new document
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"document_type": "Google Calendar Event (Composio)",
|
||||||
|
}
|
||||||
|
(
|
||||||
|
summary_content,
|
||||||
|
summary_embedding,
|
||||||
|
) = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = (
|
||||||
|
f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||||
|
)
|
||||||
|
if location:
|
||||||
|
summary_content += f"\nLocation: {location}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(
|
||||||
|
summary_content
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
title=f"Calendar: {summary}",
|
||||||
|
document_type=DocumentType(
|
||||||
|
TOOLKIT_TO_DOCUMENT_TYPE["googlecalendar"]
|
||||||
|
),
|
||||||
|
document_metadata={
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"location": location,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"toolkit_id": "googlecalendar",
|
||||||
|
"source": "composio",
|
||||||
|
},
|
||||||
|
content=summary_content,
|
||||||
|
content_hash=content_hash,
|
||||||
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
embedding=summary_embedding,
|
||||||
|
chunks=chunks,
|
||||||
|
updated_at=get_current_timestamp(),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
documents_indexed += 1
|
||||||
|
|
||||||
|
# Batch commit every 10 documents
|
||||||
|
if documents_indexed % 10 == 0:
|
||||||
|
logger.info(
|
||||||
|
f"Committing batch: {documents_indexed} Google Calendar events processed so far"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Calendar event: {e!s}", exc_info=True)
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# CRITICAL: Always update timestamp (even if 0 documents indexed) so Electric SQL syncs
|
||||||
|
# This ensures the UI shows "Last indexed" instead of "Never indexed"
|
||||||
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
|
||||||
|
# Final commit to ensure all documents are persisted (safety net)
|
||||||
|
# This matches the pattern used in non-Composio Gmail indexer
|
||||||
|
logger.info(
|
||||||
|
f"Final commit: Total {documents_indexed} Google Calendar events processed"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
logger.info(
|
||||||
|
"Successfully committed all Composio Google Calendar document changes to database"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# Handle any remaining integrity errors gracefully (race conditions, etc.)
|
||||||
|
if (
|
||||||
|
"duplicate key value violates unique constraint" in str(e).lower()
|
||||||
|
or "uniqueviolationerror" in str(e).lower()
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"Duplicate content_hash detected during final commit. "
|
||||||
|
f"This may occur if the same event was indexed by multiple connectors. "
|
||||||
|
f"Rolling back and continuing. Error: {e!s}"
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
# Don't fail the entire task - some documents may have been successfully indexed
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Build warning message if duplicates were found
|
||||||
|
warning_message = None
|
||||||
|
if duplicate_content_count > 0:
|
||||||
|
warning_message = f"{duplicate_content_count} skipped (duplicate)"
|
||||||
|
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry,
|
||||||
|
f"Successfully completed Google Calendar indexing via Composio for connector {connector_id}",
|
||||||
|
{
|
||||||
|
"documents_indexed": documents_indexed,
|
||||||
|
"documents_skipped": documents_skipped,
|
||||||
|
"duplicate_content_count": duplicate_content_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Composio Google Calendar indexing completed: {documents_indexed} new events, {documents_skipped} skipped "
|
||||||
|
f"({duplicate_content_count} due to duplicate content from other connectors)"
|
||||||
|
)
|
||||||
|
return documents_indexed, warning_message
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True
|
||||||
|
)
|
||||||
|
return 0, f"Failed to index Google Calendar via Composio: {e!s}"
|
||||||
1167
surfsense_backend/app/connectors/composio_google_drive_connector.py
Normal file
1167
surfsense_backend/app/connectors/composio_google_drive_connector.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -142,6 +142,15 @@ class GoogleCalendarConnector:
|
||||||
flag_modified(connector, "config")
|
flag_modified(connector, "config")
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
# Check if this is an invalid_grant error (token expired/revoked)
|
||||||
|
if (
|
||||||
|
"invalid_grant" in error_str.lower()
|
||||||
|
or "token has been expired or revoked" in error_str.lower()
|
||||||
|
):
|
||||||
|
raise Exception(
|
||||||
|
"Google Calendar authentication failed. Please re-authenticate."
|
||||||
|
) from e
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Failed to refresh Google OAuth credentials: {e!s}"
|
f"Failed to refresh Google OAuth credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
@ -165,6 +174,14 @@ class GoogleCalendarConnector:
|
||||||
self.service = build("calendar", "v3", credentials=credentials)
|
self.service = build("calendar", "v3", credentials=credentials)
|
||||||
return self.service
|
return self.service
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
# If the error already contains a user-friendly re-authentication message, preserve it
|
||||||
|
if (
|
||||||
|
"re-authenticate" in error_str.lower()
|
||||||
|
or "expired or been revoked" in error_str.lower()
|
||||||
|
or "authentication failed" in error_str.lower()
|
||||||
|
):
|
||||||
|
raise Exception(error_str) from e
|
||||||
raise Exception(f"Failed to create Google Calendar service: {e!s}") from e
|
raise Exception(f"Failed to create Google Calendar service: {e!s}") from e
|
||||||
|
|
||||||
async def get_calendars(self) -> tuple[list[dict[str, Any]], str | None]:
|
async def get_calendars(self) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
|
@ -271,6 +288,14 @@ class GoogleCalendarConnector:
|
||||||
return events, None
|
return events, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
# If the error already contains a user-friendly re-authentication message, preserve it
|
||||||
|
if (
|
||||||
|
"re-authenticate" in error_str.lower()
|
||||||
|
or "expired or been revoked" in error_str.lower()
|
||||||
|
or "authentication failed" in error_str.lower()
|
||||||
|
):
|
||||||
|
return [], error_str
|
||||||
return [], f"Error fetching events: {e!s}"
|
return [], f"Error fetching events: {e!s}"
|
||||||
|
|
||||||
def format_event_to_markdown(self, event: dict[str, Any]) -> str:
|
def format_event_to_markdown(self, event: dict[str, Any]) -> str:
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,15 @@ class GoogleGmailConnector:
|
||||||
flag_modified(connector, "config")
|
flag_modified(connector, "config")
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
# Check if this is an invalid_grant error (token expired/revoked)
|
||||||
|
if (
|
||||||
|
"invalid_grant" in error_str.lower()
|
||||||
|
or "token has been expired or revoked" in error_str.lower()
|
||||||
|
):
|
||||||
|
raise Exception(
|
||||||
|
"Gmail authentication failed. Please re-authenticate."
|
||||||
|
) from e
|
||||||
raise Exception(
|
raise Exception(
|
||||||
f"Failed to refresh Google OAuth credentials: {e!s}"
|
f"Failed to refresh Google OAuth credentials: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
@ -164,6 +173,14 @@ class GoogleGmailConnector:
|
||||||
self.service = build("gmail", "v1", credentials=credentials)
|
self.service = build("gmail", "v1", credentials=credentials)
|
||||||
return self.service
|
return self.service
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
# If the error already contains a user-friendly re-authentication message, preserve it
|
||||||
|
if (
|
||||||
|
"re-authenticate" in error_str.lower()
|
||||||
|
or "expired or been revoked" in error_str.lower()
|
||||||
|
or "authentication failed" in error_str.lower()
|
||||||
|
):
|
||||||
|
raise Exception(error_str) from e
|
||||||
raise Exception(f"Failed to create Gmail service: {e!s}") from e
|
raise Exception(f"Failed to create Gmail service: {e!s}") from e
|
||||||
|
|
||||||
async def get_user_profile(self) -> tuple[dict[str, Any], str | None]:
|
async def get_user_profile(self) -> tuple[dict[str, Any], str | None]:
|
||||||
|
|
@ -225,6 +242,14 @@ class GoogleGmailConnector:
|
||||||
return messages, None
|
return messages, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
error_str = str(e)
|
||||||
|
# If the error already contains a user-friendly re-authentication message, preserve it
|
||||||
|
if (
|
||||||
|
"re-authenticate" in error_str.lower()
|
||||||
|
or "expired or been revoked" in error_str.lower()
|
||||||
|
or "authentication failed" in error_str.lower()
|
||||||
|
):
|
||||||
|
return [], error_str
|
||||||
return [], f"Error fetching messages list: {e!s}"
|
return [], f"Error fetching messages list: {e!s}"
|
||||||
|
|
||||||
async def get_message_details(
|
async def get_message_details(
|
||||||
|
|
@ -271,6 +296,13 @@ class GoogleGmailConnector:
|
||||||
try:
|
try:
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
# Normalize date values - handle "undefined" strings from frontend
|
||||||
|
# This prevents "time data 'undefined' does not match format" errors
|
||||||
|
if start_date == "undefined" or start_date == "":
|
||||||
|
start_date = None
|
||||||
|
if end_date == "undefined" or end_date == "":
|
||||||
|
end_date = None
|
||||||
|
|
||||||
# Build date query
|
# Build date query
|
||||||
query_parts = []
|
query_parts = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ class DocumentType(str, Enum):
|
||||||
CIRCLEBACK = "CIRCLEBACK"
|
CIRCLEBACK = "CIRCLEBACK"
|
||||||
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR"
|
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR"
|
||||||
NOTE = "NOTE"
|
NOTE = "NOTE"
|
||||||
COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR" # Generic Composio integration
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
|
||||||
|
COMPOSIO_GMAIL_CONNECTOR = "COMPOSIO_GMAIL_CONNECTOR"
|
||||||
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"
|
||||||
|
|
||||||
|
|
||||||
class SearchSourceConnectorType(str, Enum):
|
class SearchSourceConnectorType(str, Enum):
|
||||||
|
|
@ -86,9 +88,9 @@ class SearchSourceConnectorType(str, Enum):
|
||||||
"OBSIDIAN_CONNECTOR" # Self-hosted only - Local Obsidian vault indexing
|
"OBSIDIAN_CONNECTOR" # Self-hosted only - Local Obsidian vault indexing
|
||||||
)
|
)
|
||||||
MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools
|
MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools
|
||||||
COMPOSIO_CONNECTOR = (
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
|
||||||
"COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.)
|
COMPOSIO_GMAIL_CONNECTOR = "COMPOSIO_GMAIL_CONNECTOR"
|
||||||
)
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"
|
||||||
|
|
||||||
|
|
||||||
class LiteLLMProvider(str, Enum):
|
class LiteLLMProvider(str, Enum):
|
||||||
|
|
@ -142,6 +144,43 @@ class LogStatus(str, Enum):
|
||||||
FAILED = "FAILED"
|
FAILED = "FAILED"
|
||||||
|
|
||||||
|
|
||||||
|
class IncentiveTaskType(str, Enum):
|
||||||
|
"""
|
||||||
|
Enum for incentive task types that users can complete to earn free pages.
|
||||||
|
Each task can only be completed once per user.
|
||||||
|
|
||||||
|
When adding new tasks:
|
||||||
|
1. Add a new enum value here
|
||||||
|
2. Add the task configuration to INCENTIVE_TASKS_CONFIG below
|
||||||
|
3. Create an Alembic migration to add the enum value to PostgreSQL
|
||||||
|
"""
|
||||||
|
|
||||||
|
GITHUB_STAR = "GITHUB_STAR"
|
||||||
|
# Future tasks can be added here:
|
||||||
|
# GITHUB_ISSUE = "GITHUB_ISSUE"
|
||||||
|
# SOCIAL_SHARE = "SOCIAL_SHARE"
|
||||||
|
# REFER_FRIEND = "REFER_FRIEND"
|
||||||
|
|
||||||
|
|
||||||
|
# Centralized configuration for incentive tasks
|
||||||
|
# This makes it easy to add new tasks without changing code in multiple places
|
||||||
|
INCENTIVE_TASKS_CONFIG = {
|
||||||
|
IncentiveTaskType.GITHUB_STAR: {
|
||||||
|
"title": "Star our GitHub repository",
|
||||||
|
"description": "Show your support by starring SurfSense on GitHub",
|
||||||
|
"pages_reward": 100,
|
||||||
|
"action_url": "https://github.com/MODSetter/SurfSense",
|
||||||
|
},
|
||||||
|
# Future tasks can be configured here:
|
||||||
|
# IncentiveTaskType.GITHUB_ISSUE: {
|
||||||
|
# "title": "Create an issue",
|
||||||
|
# "description": "Help improve SurfSense by reporting bugs or suggesting features",
|
||||||
|
# "pages_reward": 50,
|
||||||
|
# "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose",
|
||||||
|
# },
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Permission(str, Enum):
|
class Permission(str, Enum):
|
||||||
"""
|
"""
|
||||||
Granular permissions for search space resources.
|
Granular permissions for search space resources.
|
||||||
|
|
@ -936,6 +975,39 @@ class Notification(BaseModel, TimestampMixin):
|
||||||
search_space = relationship("SearchSpace", back_populates="notifications")
|
search_space = relationship("SearchSpace", back_populates="notifications")
|
||||||
|
|
||||||
|
|
||||||
|
class UserIncentiveTask(BaseModel, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Tracks completed incentive tasks for users.
|
||||||
|
Each user can only complete each task type once.
|
||||||
|
When a task is completed, the user's pages_limit is increased.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_incentive_tasks"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_id",
|
||||||
|
"task_type",
|
||||||
|
name="uq_user_incentive_task",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True)
|
||||||
|
pages_awarded = Column(Integer, nullable=False)
|
||||||
|
completed_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="incentive_tasks")
|
||||||
|
|
||||||
|
|
||||||
class SearchSpaceRole(BaseModel, TimestampMixin):
|
class SearchSpaceRole(BaseModel, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Custom roles that can be defined per search space.
|
Custom roles that can be defined per search space.
|
||||||
|
|
@ -1114,6 +1186,13 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Incentive tasks completed by this user
|
||||||
|
incentive_tasks = relationship(
|
||||||
|
"UserIncentiveTask",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
@ -1165,6 +1244,13 @@ else:
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Incentive tasks completed by this user
|
||||||
|
incentive_tasks = relationship(
|
||||||
|
"UserIncentiveTask",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from .google_drive_add_connector_route import (
|
||||||
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,
|
||||||
)
|
)
|
||||||
|
from .incentive_tasks_routes import router as incentive_tasks_router
|
||||||
from .jira_add_connector_route import router as jira_add_connector_router
|
from .jira_add_connector_route import router as jira_add_connector_router
|
||||||
from .linear_add_connector_route import router as linear_add_connector_router
|
from .linear_add_connector_route import router as linear_add_connector_router
|
||||||
from .logs_routes import router as logs_router
|
from .logs_routes import router as logs_router
|
||||||
|
|
@ -69,3 +70,4 @@ router.include_router(surfsense_docs_router) # Surfsense documentation for cita
|
||||||
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
||||||
router.include_router(composio_router) # Composio OAuth and toolkit management
|
router.include_router(composio_router) # Composio OAuth and toolkit management
|
||||||
router.include_router(public_chat_router) # Public chat sharing and cloning
|
router.include_router(public_chat_router) # Public chat sharing and cloning
|
||||||
|
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,18 @@ Endpoints:
|
||||||
- GET /composio/toolkits - List available Composio toolkits
|
- GET /composio/toolkits - List available Composio toolkits
|
||||||
- GET /auth/composio/connector/add - Initiate OAuth for a specific toolkit
|
- GET /auth/composio/connector/add - Initiate OAuth for a specific toolkit
|
||||||
- GET /auth/composio/connector/callback - Handle OAuth callback
|
- GET /auth/composio/connector/callback - Handle OAuth callback
|
||||||
|
- GET /connectors/{connector_id}/composio-drive/folders - List folders/files for Composio Google Drive
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import (
|
from app.db import (
|
||||||
|
|
@ -29,19 +31,31 @@ from app.db import (
|
||||||
from app.services.composio_service import (
|
from app.services.composio_service import (
|
||||||
COMPOSIO_TOOLKIT_NAMES,
|
COMPOSIO_TOOLKIT_NAMES,
|
||||||
INDEXABLE_TOOLKITS,
|
INDEXABLE_TOOLKITS,
|
||||||
|
TOOLKIT_TO_CONNECTOR_TYPE,
|
||||||
ComposioService,
|
ComposioService,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.connector_naming import (
|
from app.utils.connector_naming import (
|
||||||
check_duplicate_connector,
|
count_connectors_of_type,
|
||||||
generate_unique_connector_name,
|
get_base_name_for_type,
|
||||||
)
|
)
|
||||||
from app.utils.oauth_security import OAuthStateManager
|
from app.utils.oauth_security import OAuthStateManager
|
||||||
|
|
||||||
|
# Note: We no longer use check_duplicate_connector for Composio connectors because
|
||||||
|
# Composio generates a new connected_account_id each time, even for the same Google account.
|
||||||
|
# Instead, we check for existing connectors by type/space/user and update them.
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Map toolkit_id to frontend connector ID
|
||||||
|
TOOLKIT_TO_FRONTEND_CONNECTOR_ID = {
|
||||||
|
"googledrive": "composio-googledrive",
|
||||||
|
"gmail": "composio-gmail",
|
||||||
|
"googlecalendar": "composio-googlecalendar",
|
||||||
|
}
|
||||||
|
|
||||||
# Initialize security utilities
|
# Initialize security utilities
|
||||||
_state_manager = None
|
_state_manager = None
|
||||||
|
|
||||||
|
|
@ -166,11 +180,8 @@ async def initiate_composio_auth(
|
||||||
|
|
||||||
@router.get("/auth/composio/connector/callback")
|
@router.get("/auth/composio/connector/callback")
|
||||||
async def composio_callback(
|
async def composio_callback(
|
||||||
|
request: Request,
|
||||||
state: str | None = None,
|
state: str | None = None,
|
||||||
composio_connected_account_id: str | None = Query(
|
|
||||||
None, alias="connectedAccountId"
|
|
||||||
), # Composio sends camelCase
|
|
||||||
connected_account_id: str | None = None, # Fallback snake_case
|
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
):
|
):
|
||||||
|
|
@ -236,16 +247,17 @@ async def composio_callback(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize Composio service
|
# Initialize Composio service
|
||||||
ComposioService()
|
service = ComposioService()
|
||||||
|
|
||||||
# Use camelCase param if provided (Composio's format), fallback to snake_case
|
# Extract connected_account_id from query params (accepts both camelCase and snake_case)
|
||||||
final_connected_account_id = (
|
query_params = request.query_params
|
||||||
composio_connected_account_id or connected_account_id
|
final_connected_account_id = query_params.get(
|
||||||
)
|
"connectedAccountId"
|
||||||
|
) or query_params.get("connected_account_id")
|
||||||
|
|
||||||
# DEBUG: Log all query parameters received
|
# DEBUG: Log query parameter received
|
||||||
logger.info(
|
logger.info(
|
||||||
f"DEBUG: Callback received - connectedAccountId: {composio_connected_account_id}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}"
|
f"DEBUG: Callback received - connectedAccountId: {query_params.get('connectedAccountId')}, connected_account_id: {query_params.get('connected_account_id')}, using: {final_connected_account_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# If we still don't have a connected_account_id, warn but continue
|
# If we still don't have a connected_account_id, warn but continue
|
||||||
|
|
@ -268,38 +280,89 @@ async def composio_callback(
|
||||||
"is_indexable": toolkit_id in INDEXABLE_TOOLKITS,
|
"is_indexable": toolkit_id in INDEXABLE_TOOLKITS,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check for duplicate connector
|
# Get the specific connector type for this toolkit
|
||||||
# For Composio, we use toolkit_id + connected_account_id as unique identifier
|
connector_type_str = TOOLKIT_TO_CONNECTOR_TYPE.get(toolkit_id)
|
||||||
identifier = final_connected_account_id or f"{toolkit_id}_{user_id}"
|
if not connector_type_str:
|
||||||
|
raise HTTPException(
|
||||||
is_duplicate = await check_duplicate_connector(
|
status_code=400,
|
||||||
session,
|
detail=f"Unknown toolkit: {toolkit_id}. Available: {list(TOOLKIT_TO_CONNECTOR_TYPE.keys())}",
|
||||||
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
|
||||||
space_id,
|
|
||||||
user_id,
|
|
||||||
identifier,
|
|
||||||
)
|
)
|
||||||
if is_duplicate:
|
connector_type = SearchSourceConnectorType(connector_type_str)
|
||||||
|
|
||||||
|
# Check for existing connector of the same type for this user/space
|
||||||
|
# When reconnecting, Composio gives a new connected_account_id, so we need to
|
||||||
|
# check by connector_type, user_id, and search_space_id instead of connected_account_id
|
||||||
|
existing_connector_result = await session.execute(
|
||||||
|
select(SearchSourceConnector).where(
|
||||||
|
SearchSourceConnector.connector_type == connector_type,
|
||||||
|
SearchSourceConnector.search_space_id == space_id,
|
||||||
|
SearchSourceConnector.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_connector = existing_connector_result.scalars().first()
|
||||||
|
|
||||||
|
if existing_connector:
|
||||||
|
# Delete the old Composio connected account before updating
|
||||||
|
old_connected_account_id = existing_connector.config.get(
|
||||||
|
"composio_connected_account_id"
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
old_connected_account_id
|
||||||
|
and old_connected_account_id != final_connected_account_id
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
deleted = await service.delete_connected_account(
|
||||||
|
old_connected_account_id
|
||||||
|
)
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
f"Deleted old Composio connected account {old_connected_account_id} "
|
||||||
|
f"before updating connector {existing_connector.id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Duplicate Composio connector detected for user {user_id} with toolkit {toolkit_id}"
|
f"Failed to delete old Composio connected account {old_connected_account_id}"
|
||||||
|
)
|
||||||
|
except Exception as delete_error:
|
||||||
|
# Log but don't fail - the old account may already be deleted
|
||||||
|
logger.warning(
|
||||||
|
f"Error deleting old Composio connected account {old_connected_account_id}: {delete_error!s}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update existing connector with new connected_account_id
|
||||||
|
logger.info(
|
||||||
|
f"Updating existing Composio connector {existing_connector.id} with new connected_account_id {final_connected_account_id}"
|
||||||
|
)
|
||||||
|
existing_connector.config = connector_config
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(existing_connector)
|
||||||
|
|
||||||
|
# Get the frontend connector ID based on toolkit_id
|
||||||
|
frontend_connector_id = TOOLKIT_TO_FRONTEND_CONNECTOR_ID.get(
|
||||||
|
toolkit_id, "composio-connector"
|
||||||
)
|
)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=composio-connector"
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector={frontend_connector_id}&connectorId={existing_connector.id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate a unique, user-friendly connector name
|
# Count existing connectors of this type to determine the number
|
||||||
connector_name = await generate_unique_connector_name(
|
count = await count_connectors_of_type(
|
||||||
session,
|
session, connector_type, space_id, user_id
|
||||||
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
|
||||||
space_id,
|
|
||||||
user_id,
|
|
||||||
f"{toolkit_name} (Composio)",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Generate base name (e.g., "Gmail", "Google Drive")
|
||||||
|
base_name = get_base_name_for_type(connector_type)
|
||||||
|
|
||||||
|
# Format: "Gmail (Composio) 1", "Gmail (Composio) 2", etc.
|
||||||
|
if count == 0:
|
||||||
|
connector_name = f"{base_name} (Composio) 1"
|
||||||
|
else:
|
||||||
|
connector_name = f"{base_name} (Composio) {count + 1}"
|
||||||
|
|
||||||
db_connector = SearchSourceConnector(
|
db_connector = SearchSourceConnector(
|
||||||
name=connector_name,
|
name=connector_name,
|
||||||
connector_type=SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
connector_type=connector_type,
|
||||||
config=connector_config,
|
config=connector_config,
|
||||||
search_space_id=space_id,
|
search_space_id=space_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|
@ -314,8 +377,12 @@ async def composio_callback(
|
||||||
f"Successfully created Composio connector {db_connector.id} for user {user_id}, toolkit {toolkit_id}"
|
f"Successfully created Composio connector {db_connector.id} for user {user_id}, toolkit {toolkit_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get the frontend connector ID based on toolkit_id
|
||||||
|
frontend_connector_id = TOOLKIT_TO_FRONTEND_CONNECTOR_ID.get(
|
||||||
|
toolkit_id, "composio-connector"
|
||||||
|
)
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=composio-connector&connectorId={db_connector.id}"
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector={frontend_connector_id}&connectorId={db_connector.id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
|
|
@ -339,3 +406,136 @@ async def composio_callback(
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500, detail=f"Failed to complete Composio OAuth: {e!s}"
|
status_code=500, detail=f"Failed to complete Composio OAuth: {e!s}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connectors/{connector_id}/composio-drive/folders")
|
||||||
|
async def list_composio_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 via Composio 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 Composio 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, ...},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not ComposioService.is_enabled():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Composio integration is not enabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
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.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connector = result.scalars().first()
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Composio Google Drive connector not found or access denied",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Composio connected account ID from config
|
||||||
|
composio_connected_account_id = connector.config.get(
|
||||||
|
"composio_connected_account_id"
|
||||||
|
)
|
||||||
|
if not composio_connected_account_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Composio connected account not found. Please reconnect the connector.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize Composio service and fetch files
|
||||||
|
service = ComposioService()
|
||||||
|
entity_id = f"surfsense_{user.id}"
|
||||||
|
|
||||||
|
# Fetch files/folders from Composio Google Drive
|
||||||
|
files, _next_token, error = await service.get_drive_files(
|
||||||
|
connected_account_id=composio_connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
folder_id=parent_id,
|
||||||
|
page_size=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
logger.error(f"Failed to list Composio Drive files: {error}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to list folder contents: {error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transform files to match the expected format with isFolder field
|
||||||
|
items = []
|
||||||
|
for file_info in files:
|
||||||
|
file_id = file_info.get("id", "") or file_info.get("fileId", "")
|
||||||
|
file_name = (
|
||||||
|
file_info.get("name", "") or file_info.get("fileName", "") or "Untitled"
|
||||||
|
)
|
||||||
|
mime_type = file_info.get("mimeType", "") or file_info.get("mime_type", "")
|
||||||
|
|
||||||
|
if not file_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_folder = mime_type == "application/vnd.google-apps.folder"
|
||||||
|
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"id": file_id,
|
||||||
|
"name": file_name,
|
||||||
|
"mimeType": mime_type,
|
||||||
|
"isFolder": is_folder,
|
||||||
|
"parents": file_info.get("parents", []),
|
||||||
|
"size": file_info.get("size"),
|
||||||
|
"iconLink": file_info.get("iconLink"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort: folders first, then files, both alphabetically
|
||||||
|
folders = sorted(
|
||||||
|
[item for item in items if item["isFolder"]],
|
||||||
|
key=lambda x: x["name"].lower(),
|
||||||
|
)
|
||||||
|
files_list = sorted(
|
||||||
|
[item for item in items if not item["isFolder"]],
|
||||||
|
key=lambda x: x["name"].lower(),
|
||||||
|
)
|
||||||
|
items = folders + files_list
|
||||||
|
|
||||||
|
folder_count = len(folders)
|
||||||
|
file_count = len(files_list)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Listed {len(items)} total items ({folder_count} folders, {file_count} files) for Composio connector {connector_id}"
|
||||||
|
+ (f" in folder {parent_id}" if parent_id else " in ROOT")
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error listing Composio Drive contents: {e!s}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to list Drive contents: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
|
||||||
|
|
@ -402,7 +402,7 @@ async def list_google_drive_folders(
|
||||||
file_count = len(items) - folder_count
|
file_count = len(items) - folder_count
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"✅ Listed {len(items)} total items ({folder_count} folders, {file_count} files) for connector {connector_id}"
|
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")
|
+ (f" in folder {parent_id}" if parent_id else " in ROOT")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
"""
|
||||||
|
Incentive Tasks API routes.
|
||||||
|
Allows users to complete tasks (like starring GitHub repo) to earn free pages.
|
||||||
|
Each task can only be completed once per user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import (
|
||||||
|
INCENTIVE_TASKS_CONFIG,
|
||||||
|
IncentiveTaskType,
|
||||||
|
User,
|
||||||
|
UserIncentiveTask,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
|
from app.schemas.incentive_tasks import (
|
||||||
|
CompleteTaskResponse,
|
||||||
|
IncentiveTaskInfo,
|
||||||
|
IncentiveTasksResponse,
|
||||||
|
TaskAlreadyCompletedResponse,
|
||||||
|
)
|
||||||
|
from app.users import current_active_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/incentive-tasks", tags=["incentive-tasks"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=IncentiveTasksResponse)
|
||||||
|
async def get_incentive_tasks(
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> IncentiveTasksResponse:
|
||||||
|
"""
|
||||||
|
Get all available incentive tasks with the user's completion status.
|
||||||
|
"""
|
||||||
|
# Get all completed tasks for this user
|
||||||
|
result = await session.execute(
|
||||||
|
select(UserIncentiveTask).where(UserIncentiveTask.user_id == user.id)
|
||||||
|
)
|
||||||
|
completed_tasks = {task.task_type: task for task in result.scalars().all()}
|
||||||
|
|
||||||
|
# Build task list with completion status
|
||||||
|
tasks = []
|
||||||
|
total_pages_earned = 0
|
||||||
|
|
||||||
|
for task_type, config in INCENTIVE_TASKS_CONFIG.items():
|
||||||
|
completed_task = completed_tasks.get(task_type)
|
||||||
|
is_completed = completed_task is not None
|
||||||
|
|
||||||
|
if is_completed:
|
||||||
|
total_pages_earned += completed_task.pages_awarded
|
||||||
|
|
||||||
|
tasks.append(
|
||||||
|
IncentiveTaskInfo(
|
||||||
|
task_type=task_type,
|
||||||
|
title=config["title"],
|
||||||
|
description=config["description"],
|
||||||
|
pages_reward=config["pages_reward"],
|
||||||
|
action_url=config["action_url"],
|
||||||
|
completed=is_completed,
|
||||||
|
completed_at=completed_task.completed_at if completed_task else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return IncentiveTasksResponse(
|
||||||
|
tasks=tasks,
|
||||||
|
total_pages_earned=total_pages_earned,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{task_type}/complete",
|
||||||
|
response_model=CompleteTaskResponse | TaskAlreadyCompletedResponse,
|
||||||
|
)
|
||||||
|
async def complete_task(
|
||||||
|
task_type: IncentiveTaskType,
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> CompleteTaskResponse | TaskAlreadyCompletedResponse:
|
||||||
|
"""
|
||||||
|
Mark an incentive task as completed and award pages to the user.
|
||||||
|
|
||||||
|
Each task can only be completed once. If the task was already completed,
|
||||||
|
returns the existing completion information without awarding additional pages.
|
||||||
|
"""
|
||||||
|
# Validate task type exists in config
|
||||||
|
task_config = INCENTIVE_TASKS_CONFIG.get(task_type)
|
||||||
|
if not task_config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Unknown task type: {task_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if task was already completed
|
||||||
|
existing_task = await session.execute(
|
||||||
|
select(UserIncentiveTask).where(
|
||||||
|
UserIncentiveTask.user_id == user.id,
|
||||||
|
UserIncentiveTask.task_type == task_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = existing_task.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return TaskAlreadyCompletedResponse(
|
||||||
|
success=False,
|
||||||
|
message="Task already completed",
|
||||||
|
completed_at=existing.completed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the task completion record
|
||||||
|
pages_reward = task_config["pages_reward"]
|
||||||
|
new_task = UserIncentiveTask(
|
||||||
|
user_id=user.id,
|
||||||
|
task_type=task_type,
|
||||||
|
pages_awarded=pages_reward,
|
||||||
|
)
|
||||||
|
session.add(new_task)
|
||||||
|
|
||||||
|
# Update user's pages_limit
|
||||||
|
user.pages_limit += pages_reward
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
return CompleteTaskResponse(
|
||||||
|
success=True,
|
||||||
|
message=f"Task completed! You earned {pages_reward} pages.",
|
||||||
|
pages_awarded=pages_reward,
|
||||||
|
new_pages_limit=user.pages_limit,
|
||||||
|
)
|
||||||
|
|
@ -59,6 +59,58 @@ router = APIRouter()
|
||||||
|
|
||||||
# ============ Permissions Endpoints ============
|
# ============ Permissions Endpoints ============
|
||||||
|
|
||||||
|
# Human-readable descriptions for each permission
|
||||||
|
PERMISSION_DESCRIPTIONS = {
|
||||||
|
# Documents
|
||||||
|
"documents:create": "Add new documents, files, and content to the search space",
|
||||||
|
"documents:read": "View and search documents in the search space",
|
||||||
|
"documents:update": "Edit existing documents and their metadata",
|
||||||
|
"documents:delete": "Remove documents from the search space",
|
||||||
|
# Chats
|
||||||
|
"chats:create": "Start new AI chat conversations",
|
||||||
|
"chats:read": "View chat history and conversations",
|
||||||
|
"chats:update": "Edit chat titles and settings",
|
||||||
|
"chats:delete": "Delete chat conversations",
|
||||||
|
# Comments
|
||||||
|
"comments:create": "Add comments and annotations to documents",
|
||||||
|
"comments:read": "View comments on documents",
|
||||||
|
"comments:delete": "Remove comments from documents",
|
||||||
|
# LLM Configs
|
||||||
|
"llm_configs:create": "Add new AI model configurations",
|
||||||
|
"llm_configs:read": "View AI model settings and configurations",
|
||||||
|
"llm_configs:update": "Modify AI model configurations",
|
||||||
|
"llm_configs:delete": "Remove AI model configurations",
|
||||||
|
# Podcasts
|
||||||
|
"podcasts:create": "Generate new AI podcasts from content",
|
||||||
|
"podcasts:read": "Listen to and view generated podcasts",
|
||||||
|
"podcasts:update": "Edit podcast settings and metadata",
|
||||||
|
"podcasts:delete": "Remove generated podcasts",
|
||||||
|
# Connectors
|
||||||
|
"connectors:create": "Set up new data source integrations",
|
||||||
|
"connectors:read": "View configured data sources and their status",
|
||||||
|
"connectors:update": "Modify data source configurations",
|
||||||
|
"connectors:delete": "Remove data source integrations",
|
||||||
|
# Logs
|
||||||
|
"logs:read": "View activity logs and audit trail",
|
||||||
|
"logs:delete": "Clear activity logs",
|
||||||
|
# Members
|
||||||
|
"members:invite": "Send invitations to new team members",
|
||||||
|
"members:view": "View the list of team members",
|
||||||
|
"members:remove": "Remove members from the search space",
|
||||||
|
"members:manage_roles": "Assign and change member roles",
|
||||||
|
# Roles
|
||||||
|
"roles:create": "Create new custom roles",
|
||||||
|
"roles:read": "View available roles and their permissions",
|
||||||
|
"roles:update": "Modify role permissions",
|
||||||
|
"roles:delete": "Remove custom roles",
|
||||||
|
# Settings
|
||||||
|
"settings:view": "View search space settings",
|
||||||
|
"settings:update": "Modify search space settings",
|
||||||
|
"settings:delete": "Delete the entire search space",
|
||||||
|
# Full access
|
||||||
|
"*": "Full access to all features and settings",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/permissions", response_model=PermissionsListResponse)
|
@router.get("/permissions", response_model=PermissionsListResponse)
|
||||||
async def list_all_permissions(
|
async def list_all_permissions(
|
||||||
|
|
@ -71,12 +123,14 @@ async def list_all_permissions(
|
||||||
for perm in Permission:
|
for perm in Permission:
|
||||||
# Extract category from permission value (e.g., "documents:read" -> "documents")
|
# Extract category from permission value (e.g., "documents:read" -> "documents")
|
||||||
category = perm.value.split(":")[0] if ":" in perm.value else "general"
|
category = perm.value.split(":")[0] if ":" in perm.value else "general"
|
||||||
|
description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}")
|
||||||
|
|
||||||
permissions.append(
|
permissions.append(
|
||||||
PermissionInfo(
|
PermissionInfo(
|
||||||
value=perm.value,
|
value=perm.value,
|
||||||
name=perm.name,
|
name=perm.name,
|
||||||
category=category,
|
category=category,
|
||||||
|
description=description,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ import logging
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from dateutil.parser import isoparse
|
||||||
from fastapi import APIRouter, Body, 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
|
||||||
|
|
@ -47,6 +49,7 @@ from app.schemas import (
|
||||||
SearchSourceConnectorRead,
|
SearchSourceConnectorRead,
|
||||||
SearchSourceConnectorUpdate,
|
SearchSourceConnectorUpdate,
|
||||||
)
|
)
|
||||||
|
from app.services.composio_service import ComposioService
|
||||||
from app.services.notification_service import NotificationService
|
from app.services.notification_service import NotificationService
|
||||||
from app.tasks.connector_indexers import (
|
from app.tasks.connector_indexers import (
|
||||||
index_airtable_records,
|
index_airtable_records,
|
||||||
|
|
@ -529,6 +532,38 @@ async def delete_search_source_connector(
|
||||||
f"Failed to delete periodic schedule for connector {connector_id}"
|
f"Failed to delete periodic schedule for connector {connector_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For Composio connectors, also delete the connected account in Composio
|
||||||
|
composio_connector_types = [
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
|
||||||
|
]
|
||||||
|
if db_connector.connector_type in composio_connector_types:
|
||||||
|
composio_connected_account_id = db_connector.config.get(
|
||||||
|
"composio_connected_account_id"
|
||||||
|
)
|
||||||
|
if composio_connected_account_id and ComposioService.is_enabled():
|
||||||
|
try:
|
||||||
|
service = ComposioService()
|
||||||
|
deleted = await service.delete_connected_account(
|
||||||
|
composio_connected_account_id
|
||||||
|
)
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
f"Successfully deleted Composio connected account {composio_connected_account_id} "
|
||||||
|
f"for connector {connector_id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to delete Composio connected account {composio_connected_account_id} "
|
||||||
|
f"for connector {connector_id}"
|
||||||
|
)
|
||||||
|
except Exception as composio_error:
|
||||||
|
# Log but don't fail the deletion - Composio account may already be deleted
|
||||||
|
logger.warning(
|
||||||
|
f"Error deleting Composio connected account {composio_connected_account_id}: {composio_error!s}"
|
||||||
|
)
|
||||||
|
|
||||||
await session.delete(db_connector)
|
await session.delete(db_connector)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"message": "Search source connector deleted successfully"}
|
return {"message": "Search source connector deleted successfully"}
|
||||||
|
|
@ -611,32 +646,59 @@ async def index_connector_content(
|
||||||
|
|
||||||
# Handle different connector types
|
# Handle different connector types
|
||||||
response_message = ""
|
response_message = ""
|
||||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
# Use UTC for consistency with last_indexed_at storage
|
||||||
|
today_str = datetime.now(UTC).strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# Determine the actual date range to use
|
# Determine the actual date range to use
|
||||||
if start_date is None:
|
if start_date is None:
|
||||||
# Use last_indexed_at or default to 365 days ago
|
# Use last_indexed_at or default to 365 days ago
|
||||||
if connector.last_indexed_at:
|
if connector.last_indexed_at:
|
||||||
today = datetime.now().date()
|
# Convert last_indexed_at to timezone-naive for comparison (like calculate_date_range does)
|
||||||
if connector.last_indexed_at.date() == today:
|
last_indexed_naive = (
|
||||||
# If last indexed today, go back 1 day to ensure we don't miss anything
|
connector.last_indexed_at.replace(tzinfo=None)
|
||||||
indexing_from = (today - timedelta(days=1)).strftime("%Y-%m-%d")
|
if connector.last_indexed_at.tzinfo
|
||||||
else:
|
else connector.last_indexed_at
|
||||||
indexing_from = connector.last_indexed_at.strftime("%Y-%m-%d")
|
|
||||||
else:
|
|
||||||
indexing_from = (datetime.now() - timedelta(days=365)).strftime(
|
|
||||||
"%Y-%m-%d"
|
|
||||||
)
|
)
|
||||||
|
# Use UTC for "today" to match how last_indexed_at is stored
|
||||||
|
today_utc = datetime.now(UTC).replace(tzinfo=None).date()
|
||||||
|
last_indexed_date = last_indexed_naive.date()
|
||||||
|
|
||||||
|
if last_indexed_date == today_utc:
|
||||||
|
# If last indexed today, go back 1 day to ensure we don't miss anything
|
||||||
|
indexing_from = (today_utc - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
indexing_from = last_indexed_naive.strftime("%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
indexing_from = (
|
||||||
|
datetime.now(UTC).replace(tzinfo=None) - timedelta(days=365)
|
||||||
|
).strftime("%Y-%m-%d")
|
||||||
else:
|
else:
|
||||||
indexing_from = start_date
|
indexing_from = start_date
|
||||||
|
|
||||||
# For calendar connectors, default to today but allow future dates if explicitly provided
|
# For calendar connectors, default to today but allow future dates if explicitly provided
|
||||||
if connector.connector_type in [
|
if connector.connector_type in [
|
||||||
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
|
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
|
||||||
SearchSourceConnectorType.LUMA_CONNECTOR,
|
SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||||
]:
|
]:
|
||||||
# Default to today if no end_date provided (users can manually select future dates)
|
# Default to today if no end_date provided (users can manually select future dates)
|
||||||
indexing_to = today_str if end_date is None else end_date
|
indexing_to = today_str if end_date is None else end_date
|
||||||
|
|
||||||
|
# If start_date and end_date are the same, adjust end_date to be one day later
|
||||||
|
# to ensure valid date range (start_date must be strictly before end_date)
|
||||||
|
if indexing_from == indexing_to:
|
||||||
|
dt = isoparse(indexing_to)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=pytz.UTC)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(pytz.UTC)
|
||||||
|
# Add one day to end_date to make it strictly after start_date
|
||||||
|
dt_end = dt + timedelta(days=1)
|
||||||
|
indexing_to = dt_end.strftime("%Y-%m-%d")
|
||||||
|
logger.info(
|
||||||
|
f"Adjusted end_date from {end_date} to {indexing_to} "
|
||||||
|
f"to ensure valid date range (start_date must be strictly before end_date)"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# For non-calendar connectors, cap at today
|
# For non-calendar connectors, cap at today
|
||||||
indexing_to = end_date if end_date else today_str
|
indexing_to = end_date if end_date else today_str
|
||||||
|
|
@ -887,11 +949,66 @@ async def index_connector_content(
|
||||||
)
|
)
|
||||||
response_message = "Obsidian vault indexing started in the background."
|
response_message = "Obsidian vault indexing started in the background."
|
||||||
|
|
||||||
elif connector.connector_type == SearchSourceConnectorType.COMPOSIO_CONNECTOR:
|
elif (
|
||||||
|
connector.connector_type
|
||||||
|
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
|
||||||
|
):
|
||||||
from app.tasks.celery_tasks.connector_tasks import (
|
from app.tasks.celery_tasks.connector_tasks import (
|
||||||
index_composio_connector_task,
|
index_composio_connector_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# For Composio Google Drive, if drive_items is provided, update connector config
|
||||||
|
# This allows the UI to pass folder/file selection like the regular Google Drive connector
|
||||||
|
if drive_items and drive_items.has_items():
|
||||||
|
# Update connector config with the selected folders/files
|
||||||
|
config = connector.config or {}
|
||||||
|
config["selected_folders"] = [
|
||||||
|
{"id": f.id, "name": f.name} for f in drive_items.folders
|
||||||
|
]
|
||||||
|
config["selected_files"] = [
|
||||||
|
{"id": f.id, "name": f.name} for f in drive_items.files
|
||||||
|
]
|
||||||
|
if drive_items.indexing_options:
|
||||||
|
config["indexing_options"] = {
|
||||||
|
"max_files_per_folder": drive_items.indexing_options.max_files_per_folder,
|
||||||
|
"incremental_sync": drive_items.indexing_options.incremental_sync,
|
||||||
|
"include_subfolders": drive_items.indexing_options.include_subfolders,
|
||||||
|
}
|
||||||
|
connector.config = config
|
||||||
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
|
flag_modified(connector, "config")
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(connector)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Triggering Composio Google Drive indexing for connector {connector_id} into search space {search_space_id}, "
|
||||||
|
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Triggering Composio Google Drive indexing for connector {connector_id} into search space {search_space_id} "
|
||||||
|
f"using existing config (from {indexing_from} to {indexing_to})"
|
||||||
|
)
|
||||||
|
|
||||||
|
index_composio_connector_task.delay(
|
||||||
|
connector_id, search_space_id, str(user.id), indexing_from, indexing_to
|
||||||
|
)
|
||||||
|
response_message = (
|
||||||
|
"Composio Google Drive indexing started in the background."
|
||||||
|
)
|
||||||
|
|
||||||
|
elif connector.connector_type in [
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
|
||||||
|
]:
|
||||||
|
from app.tasks.celery_tasks.connector_tasks import (
|
||||||
|
index_composio_connector_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
# For Composio Gmail and Calendar, use the same date calculation logic as normal connectors
|
||||||
|
# This ensures consistent behavior and uses last_indexed_at to reduce API calls
|
||||||
|
# (includes special case: if indexed today, go back 1 day to avoid missing data)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Triggering Composio connector indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
|
f"Triggering Composio connector indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
|
||||||
)
|
)
|
||||||
|
|
@ -943,7 +1060,9 @@ async def _update_connector_timestamp_by_id(session: AsyncSession, connector_id:
|
||||||
connector = result.scalars().first()
|
connector = result.scalars().first()
|
||||||
|
|
||||||
if connector:
|
if connector:
|
||||||
connector.last_indexed_at = datetime.now()
|
connector.last_indexed_at = datetime.now(
|
||||||
|
UTC
|
||||||
|
) # Use UTC for timezone consistency
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"Updated last_indexed_at for connector {connector_id}")
|
logger.info(f"Updated last_indexed_at for connector {connector_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1083,18 +1202,24 @@ async def _run_indexing_with_notifications(
|
||||||
)
|
)
|
||||||
|
|
||||||
await update_timestamp_func(session, connector_id)
|
await update_timestamp_func(session, connector_id)
|
||||||
|
await session.commit() # Commit timestamp update
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Indexing completed successfully: {documents_processed} documents processed"
|
f"Indexing completed successfully: {documents_processed} documents processed"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update notification on success
|
# Update notification on success (or partial success with errors)
|
||||||
if notification:
|
if notification:
|
||||||
|
# Refresh notification to ensure it's not stale after timestamp update commit
|
||||||
|
await session.refresh(notification)
|
||||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||||
session=session,
|
session=session,
|
||||||
notification=notification,
|
notification=notification,
|
||||||
indexed_count=documents_processed,
|
indexed_count=documents_processed,
|
||||||
error_message=None,
|
error_message=error_or_warning, # Show errors even if some documents were indexed
|
||||||
)
|
)
|
||||||
|
await (
|
||||||
|
session.commit()
|
||||||
|
) # Commit to ensure Electric SQL syncs the notification update
|
||||||
elif documents_processed > 0:
|
elif documents_processed > 0:
|
||||||
# Update notification to storing stage
|
# Update notification to storing stage
|
||||||
if notification:
|
if notification:
|
||||||
|
|
@ -1110,24 +1235,73 @@ async def _run_indexing_with_notifications(
|
||||||
f"Indexing completed successfully: {documents_processed} documents processed"
|
f"Indexing completed successfully: {documents_processed} documents processed"
|
||||||
)
|
)
|
||||||
if notification:
|
if notification:
|
||||||
|
# Refresh notification to ensure it's not stale after indexing function commits
|
||||||
|
await session.refresh(notification)
|
||||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||||
session=session,
|
session=session,
|
||||||
notification=notification,
|
notification=notification,
|
||||||
indexed_count=documents_processed,
|
indexed_count=documents_processed,
|
||||||
error_message=None,
|
error_message=error_or_warning, # Show errors even if some documents were indexed
|
||||||
)
|
)
|
||||||
|
await (
|
||||||
|
session.commit()
|
||||||
|
) # Commit to ensure Electric SQL syncs the notification update
|
||||||
else:
|
else:
|
||||||
# No new documents processed - check if this is an error or just no changes
|
# No new documents processed - check if this is an error or just no changes
|
||||||
if error_or_warning:
|
if error_or_warning:
|
||||||
|
# Check if this is a duplicate warning or empty result (success cases) or an actual error
|
||||||
|
# Handle both normal and Composio calendar connectors
|
||||||
|
error_or_warning_lower = (
|
||||||
|
str(error_or_warning).lower() if error_or_warning else ""
|
||||||
|
)
|
||||||
|
is_duplicate_warning = "skipped (duplicate)" in error_or_warning_lower
|
||||||
|
# "No X found" messages are success cases - sync worked, just found nothing in date range
|
||||||
|
is_empty_result = (
|
||||||
|
"no " in error_or_warning_lower
|
||||||
|
and "found" in error_or_warning_lower
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_duplicate_warning or is_empty_result:
|
||||||
|
# These are success cases - sync worked, just found nothing new
|
||||||
|
logger.info(f"Indexing completed successfully: {error_or_warning}")
|
||||||
|
# Still update timestamp so ElectricSQL syncs and clears "Syncing" UI
|
||||||
|
if update_timestamp_func:
|
||||||
|
await update_timestamp_func(session, connector_id)
|
||||||
|
await session.commit() # Commit timestamp update
|
||||||
|
if notification:
|
||||||
|
# Refresh notification to ensure it's not stale after timestamp update commit
|
||||||
|
await session.refresh(notification)
|
||||||
|
# For empty results, use a cleaner message
|
||||||
|
notification_message = (
|
||||||
|
"No new items found in date range"
|
||||||
|
if is_empty_result
|
||||||
|
else error_or_warning
|
||||||
|
)
|
||||||
|
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||||
|
session=session,
|
||||||
|
notification=notification,
|
||||||
|
indexed_count=0,
|
||||||
|
error_message=notification_message, # Pass as warning, not error
|
||||||
|
is_warning=True, # Flag to indicate this is a warning, not an error
|
||||||
|
)
|
||||||
|
await (
|
||||||
|
session.commit()
|
||||||
|
) # Commit to ensure Electric SQL syncs the notification update
|
||||||
|
else:
|
||||||
# Actual failure
|
# Actual failure
|
||||||
logger.error(f"Indexing failed: {error_or_warning}")
|
logger.error(f"Indexing failed: {error_or_warning}")
|
||||||
if notification:
|
if notification:
|
||||||
|
# Refresh notification to ensure it's not stale after indexing function commits
|
||||||
|
await session.refresh(notification)
|
||||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||||
session=session,
|
session=session,
|
||||||
notification=notification,
|
notification=notification,
|
||||||
indexed_count=0,
|
indexed_count=0,
|
||||||
error_message=error_or_warning,
|
error_message=error_or_warning,
|
||||||
)
|
)
|
||||||
|
await (
|
||||||
|
session.commit()
|
||||||
|
) # Commit to ensure Electric SQL syncs the notification update
|
||||||
else:
|
else:
|
||||||
# Success - just no new documents to index (all skipped/unchanged)
|
# Success - just no new documents to index (all skipped/unchanged)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -1136,13 +1310,19 @@ async def _run_indexing_with_notifications(
|
||||||
# Still update timestamp so ElectricSQL syncs and clears "Syncing" UI
|
# Still update timestamp so ElectricSQL syncs and clears "Syncing" UI
|
||||||
if update_timestamp_func:
|
if update_timestamp_func:
|
||||||
await update_timestamp_func(session, connector_id)
|
await update_timestamp_func(session, connector_id)
|
||||||
|
await session.commit() # Commit timestamp update
|
||||||
if notification:
|
if notification:
|
||||||
|
# Refresh notification to ensure it's not stale after timestamp update commit
|
||||||
|
await session.refresh(notification)
|
||||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||||
session=session,
|
session=session,
|
||||||
notification=notification,
|
notification=notification,
|
||||||
indexed_count=0,
|
indexed_count=0,
|
||||||
error_message=None, # No error - sync succeeded
|
error_message=None, # No error - sync succeeded
|
||||||
)
|
)
|
||||||
|
await (
|
||||||
|
session.commit()
|
||||||
|
) # Commit to ensure Electric SQL syncs the notification update
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in indexing task: {e!s}", exc_info=True)
|
logger.error(f"Error in indexing task: {e!s}", exc_info=True)
|
||||||
|
|
||||||
|
|
@ -2157,6 +2337,59 @@ async def run_obsidian_indexing(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_composio_indexing_with_new_session(
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new session and run the Composio indexing task.
|
||||||
|
This prevents session leaks by creating a dedicated session for the background task.
|
||||||
|
"""
|
||||||
|
async with async_session_maker() as session:
|
||||||
|
await run_composio_indexing(
|
||||||
|
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_composio_indexing(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str | None,
|
||||||
|
end_date: str | None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Run Composio connector indexing with real-time notifications.
|
||||||
|
|
||||||
|
This wraps the Composio indexer with the notification system so that
|
||||||
|
Electric SQL can sync indexing progress to the frontend in real-time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
connector_id: ID of the Composio connector
|
||||||
|
search_space_id: ID of the search space
|
||||||
|
user_id: ID of the user
|
||||||
|
start_date: Start date for indexing
|
||||||
|
end_date: End date for indexing
|
||||||
|
"""
|
||||||
|
from app.tasks.composio_indexer import index_composio_connector
|
||||||
|
|
||||||
|
await _run_indexing_with_notifications(
|
||||||
|
session=session,
|
||||||
|
connector_id=connector_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
indexing_function=index_composio_connector,
|
||||||
|
update_timestamp_func=_update_connector_timestamp_by_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# MCP Connector Routes
|
# MCP Connector Routes
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ async def read_search_spaces(
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(SearchSpace)
|
select(SearchSpace)
|
||||||
.filter(SearchSpace.user_id == user.id)
|
.filter(SearchSpace.user_id == user.id)
|
||||||
|
.order_by(SearchSpace.id.asc())
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
@ -138,6 +139,7 @@ async def read_search_spaces(
|
||||||
select(SearchSpace)
|
select(SearchSpace)
|
||||||
.join(SearchSpaceMembership)
|
.join(SearchSpaceMembership)
|
||||||
.filter(SearchSpaceMembership.user_id == user.id)
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
.order_by(SearchSpace.id.asc())
|
||||||
.offset(skip)
|
.offset(skip)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""
|
||||||
|
Schemas for incentive tasks API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.db import INCENTIVE_TASKS_CONFIG, IncentiveTaskType
|
||||||
|
|
||||||
|
|
||||||
|
class IncentiveTaskInfo(BaseModel):
|
||||||
|
"""Information about an available incentive task."""
|
||||||
|
|
||||||
|
task_type: IncentiveTaskType
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
pages_reward: int
|
||||||
|
action_url: str
|
||||||
|
completed: bool
|
||||||
|
completed_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class IncentiveTasksResponse(BaseModel):
|
||||||
|
"""Response containing all available incentive tasks with completion status."""
|
||||||
|
|
||||||
|
tasks: list[IncentiveTaskInfo]
|
||||||
|
total_pages_earned: int
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteTaskRequest(BaseModel):
|
||||||
|
"""Request to mark a task as completed."""
|
||||||
|
|
||||||
|
task_type: IncentiveTaskType
|
||||||
|
|
||||||
|
|
||||||
|
class CompleteTaskResponse(BaseModel):
|
||||||
|
"""Response after completing a task."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
pages_awarded: int
|
||||||
|
new_pages_limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class TaskAlreadyCompletedResponse(BaseModel):
|
||||||
|
"""Response when task was already completed."""
|
||||||
|
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
completed_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def get_task_info(task_type: IncentiveTaskType) -> dict | None:
|
||||||
|
"""Get task configuration by type."""
|
||||||
|
return INCENTIVE_TASKS_CONFIG.get(task_type)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_task_types() -> list[IncentiveTaskType]:
|
||||||
|
"""Get all configured task types."""
|
||||||
|
return list(INCENTIVE_TASKS_CONFIG.keys())
|
||||||
|
|
@ -167,6 +167,7 @@ class PermissionInfo(BaseModel):
|
||||||
value: str
|
value: str
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category: str
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
class PermissionsListResponse(BaseModel):
|
class PermissionsListResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -39,21 +39,73 @@ COMPOSIO_TOOLKIT_NAMES = {
|
||||||
# Toolkits that support indexing (Phase 1: Google services only)
|
# Toolkits that support indexing (Phase 1: Google services only)
|
||||||
INDEXABLE_TOOLKITS = {"googledrive", "gmail", "googlecalendar"}
|
INDEXABLE_TOOLKITS = {"googledrive", "gmail", "googlecalendar"}
|
||||||
|
|
||||||
|
# Mapping of toolkit IDs to connector types
|
||||||
|
TOOLKIT_TO_CONNECTOR_TYPE = {
|
||||||
|
"googledrive": "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"gmail": "COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"googlecalendar": "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping of toolkit IDs to document types
|
||||||
|
TOOLKIT_TO_DOCUMENT_TYPE = {
|
||||||
|
"googledrive": "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"gmail": "COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"googlecalendar": "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping of toolkit IDs to their indexer functions
|
||||||
|
# Format: toolkit_id -> (module_path, function_name, supports_date_filter)
|
||||||
|
# supports_date_filter: True if the indexer accepts start_date/end_date params
|
||||||
|
TOOLKIT_TO_INDEXER = {
|
||||||
|
"googledrive": (
|
||||||
|
"app.connectors.composio_google_drive_connector",
|
||||||
|
"index_composio_google_drive",
|
||||||
|
False, # Google Drive doesn't use date filtering
|
||||||
|
),
|
||||||
|
"gmail": (
|
||||||
|
"app.connectors.composio_gmail_connector",
|
||||||
|
"index_composio_gmail",
|
||||||
|
True, # Gmail uses date filtering
|
||||||
|
),
|
||||||
|
"googlecalendar": (
|
||||||
|
"app.connectors.composio_google_calendar_connector",
|
||||||
|
"index_composio_google_calendar",
|
||||||
|
True, # Calendar uses date filtering
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ComposioService:
|
class ComposioService:
|
||||||
"""Service for interacting with Composio API."""
|
"""Service for interacting with Composio API."""
|
||||||
|
|
||||||
def __init__(self, api_key: str | None = None):
|
# Default download directory for files from Composio
|
||||||
|
DEFAULT_DOWNLOAD_DIR = "/tmp/composio_downloads"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, api_key: str | None = None, file_download_dir: str | None = None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the Composio service.
|
Initialize the Composio service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Composio API key. If not provided, uses config.COMPOSIO_API_KEY.
|
api_key: Composio API key. If not provided, uses config.COMPOSIO_API_KEY.
|
||||||
|
file_download_dir: Directory for downloaded files. Defaults to /tmp/composio_downloads.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
self.api_key = api_key or config.COMPOSIO_API_KEY
|
self.api_key = api_key or config.COMPOSIO_API_KEY
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
raise ValueError("COMPOSIO_API_KEY is required but not configured")
|
raise ValueError("COMPOSIO_API_KEY is required but not configured")
|
||||||
self.client = Composio(api_key=self.api_key)
|
|
||||||
|
# Set up download directory
|
||||||
|
self.file_download_dir = file_download_dir or self.DEFAULT_DOWNLOAD_DIR
|
||||||
|
os.makedirs(self.file_download_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Initialize Composio client with download directory
|
||||||
|
# Per docs: file_download_dir configures where files are downloaded
|
||||||
|
self.client = Composio(
|
||||||
|
api_key=self.api_key, file_download_dir=self.file_download_dir
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_enabled() -> bool:
|
def is_enabled() -> bool:
|
||||||
|
|
@ -252,7 +304,6 @@ class ComposioService:
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"DEBUG: Found {len(result)} TOTAL connections in Composio")
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list all connections: {e!s}")
|
logger.error(f"Failed to list all connections: {e!s}")
|
||||||
|
|
@ -269,7 +320,6 @@ class ComposioService:
|
||||||
List of connected account details.
|
List of connected account details.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"DEBUG: Calling connected_accounts.list(user_id='{user_id}')")
|
|
||||||
accounts_response = self.client.connected_accounts.list(user_id=user_id)
|
accounts_response = self.client.connected_accounts.list(user_id=user_id)
|
||||||
|
|
||||||
# Handle paginated response (may have .items attribute) or direct list
|
# Handle paginated response (may have .items attribute) or direct list
|
||||||
|
|
@ -312,6 +362,30 @@ class ComposioService:
|
||||||
logger.error(f"Failed to list connections for user {user_id}: {e!s}")
|
logger.error(f"Failed to list connections for user {user_id}: {e!s}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def delete_connected_account(self, connected_account_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a connected account from Composio.
|
||||||
|
|
||||||
|
This permanently removes the connected account and revokes access tokens.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: The Composio connected account ID to delete.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deletion was successful, False otherwise.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.client.connected_accounts.delete(connected_account_id)
|
||||||
|
logger.info(
|
||||||
|
f"Successfully deleted Composio connected account: {connected_account_id}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to delete Composio connected account {connected_account_id}: {e!s}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
async def execute_tool(
|
async def execute_tool(
|
||||||
self,
|
self,
|
||||||
connected_account_id: str,
|
connected_account_id: str,
|
||||||
|
|
@ -338,7 +412,6 @@ class ComposioService:
|
||||||
# - connected_account_id: for authentication
|
# - connected_account_id: for authentication
|
||||||
# - user_id: user identifier (SDK uses user_id, not entity_id)
|
# - user_id: user identifier (SDK uses user_id, not entity_id)
|
||||||
# - dangerously_skip_version_check: skip version check for manual execution
|
# - dangerously_skip_version_check: skip version check for manual execution
|
||||||
logger.info(f"DEBUG: Executing tool {tool_name} with params: {params}")
|
|
||||||
result = self.client.tools.execute(
|
result = self.client.tools.execute(
|
||||||
slug=tool_name,
|
slug=tool_name,
|
||||||
connected_account_id=connected_account_id,
|
connected_account_id=connected_account_id,
|
||||||
|
|
@ -346,8 +419,6 @@ class ComposioService:
|
||||||
arguments=params or {},
|
arguments=params or {},
|
||||||
dangerously_skip_version_check=True,
|
dangerously_skip_version_check=True,
|
||||||
)
|
)
|
||||||
logger.info(f"DEBUG: Tool {tool_name} raw result type: {type(result)}")
|
|
||||||
logger.info(f"DEBUG: Tool {tool_name} raw result: {result}")
|
|
||||||
return {"success": True, "data": result}
|
return {"success": True, "data": result}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to execute tool {tool_name}: {e!s}")
|
logger.error(f"Failed to execute tool {tool_name}: {e!s}")
|
||||||
|
|
@ -382,7 +453,15 @@ class ComposioService:
|
||||||
"page_size": min(page_size, 100),
|
"page_size": min(page_size, 100),
|
||||||
}
|
}
|
||||||
if folder_id:
|
if folder_id:
|
||||||
params["folder_id"] = folder_id
|
# List contents of a specific folder (exclude shortcuts - we don't have access to them)
|
||||||
|
params["q"] = (
|
||||||
|
f"'{folder_id}' in parents and trashed = false and mimeType != 'application/vnd.google-apps.shortcut'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# List root-level items only (My Drive root), exclude shortcuts
|
||||||
|
params["q"] = (
|
||||||
|
"'root' in parents and trashed = false and mimeType != 'application/vnd.google-apps.shortcut'"
|
||||||
|
)
|
||||||
if page_token:
|
if page_token:
|
||||||
params["page_token"] = page_token
|
params["page_token"] = page_token
|
||||||
|
|
||||||
|
|
@ -397,9 +476,6 @@ class ComposioService:
|
||||||
return [], None, result.get("error", "Unknown error")
|
return [], None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
data = result.get("data", {})
|
data = result.get("data", {})
|
||||||
logger.info(
|
|
||||||
f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Handle nested response structure from Composio
|
# Handle nested response structure from Composio
|
||||||
files = []
|
files = []
|
||||||
|
|
@ -415,7 +491,6 @@ class ComposioService:
|
||||||
elif isinstance(data, list):
|
elif isinstance(data, list):
|
||||||
files = data
|
files = data
|
||||||
|
|
||||||
logger.info(f"DEBUG: Extracted {len(files)} drive files")
|
|
||||||
return files, next_token, None
|
return files, next_token, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -428,6 +503,10 @@ class ComposioService:
|
||||||
"""
|
"""
|
||||||
Download file content from Google Drive via Composio.
|
Download file content from Google Drive via Composio.
|
||||||
|
|
||||||
|
Per Composio docs: When tools return files, they are automatically downloaded
|
||||||
|
to a local directory, and the local file path is provided in the response.
|
||||||
|
Response includes: file_path, file_name, size fields.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
connected_account_id: Composio connected account ID.
|
connected_account_id: Composio connected account ID.
|
||||||
entity_id: The entity/user ID that owns the connected account.
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
|
@ -436,27 +515,264 @@ class ComposioService:
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (file content bytes, error message).
|
Tuple of (file content bytes, error message).
|
||||||
"""
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = await self.execute_tool(
|
result = await self.execute_tool(
|
||||||
connected_account_id=connected_account_id,
|
connected_account_id=connected_account_id,
|
||||||
tool_name="GOOGLEDRIVE_DOWNLOAD_FILE",
|
tool_name="GOOGLEDRIVE_DOWNLOAD_FILE",
|
||||||
params={"file_id": file_id}, # snake_case
|
params={"file_id": file_id},
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
return None, result.get("error", "Unknown error")
|
return None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
content = result.get("data")
|
data = result.get("data")
|
||||||
if isinstance(content, str):
|
if not data:
|
||||||
content = content.encode("utf-8")
|
return None, "No data returned from Composio"
|
||||||
|
|
||||||
|
# Per Composio docs, response includes file_path where file was downloaded
|
||||||
|
# Response structure: {data: {...}, error: ..., successful: ...}
|
||||||
|
# The actual file info is nested inside data["data"]
|
||||||
|
file_path = None
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Handle nested response structure: data contains {data, error, successful}
|
||||||
|
# The actual file info is in data["data"]
|
||||||
|
inner_data = data
|
||||||
|
if "data" in data and isinstance(data["data"], dict):
|
||||||
|
inner_data = data["data"]
|
||||||
|
logger.debug(
|
||||||
|
f"Found nested data structure. Inner keys: {list(inner_data.keys())}"
|
||||||
|
)
|
||||||
|
elif "successful" in data and "data" in data:
|
||||||
|
# Standard Composio response wrapper
|
||||||
|
inner_data = data["data"] if data["data"] else data
|
||||||
|
|
||||||
|
# Try documented fields: file_path, downloaded_file_content, path, uri
|
||||||
|
file_path = (
|
||||||
|
inner_data.get("file_path")
|
||||||
|
or inner_data.get("downloaded_file_content")
|
||||||
|
or inner_data.get("path")
|
||||||
|
or inner_data.get("uri")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle nested dict case where downloaded_file_content contains the path
|
||||||
|
if isinstance(file_path, dict):
|
||||||
|
file_path = (
|
||||||
|
file_path.get("file_path")
|
||||||
|
or file_path.get("downloaded_file_content")
|
||||||
|
or file_path.get("path")
|
||||||
|
or file_path.get("uri")
|
||||||
|
)
|
||||||
|
|
||||||
|
# If still no path, check if inner_data itself has the nested structure
|
||||||
|
if not file_path and isinstance(inner_data, dict):
|
||||||
|
for key in ["downloaded_file_content", "file_path", "path", "uri"]:
|
||||||
|
if key in inner_data:
|
||||||
|
val = inner_data[key]
|
||||||
|
if isinstance(val, str):
|
||||||
|
file_path = val
|
||||||
|
break
|
||||||
|
elif isinstance(val, dict):
|
||||||
|
# One more level of nesting
|
||||||
|
file_path = (
|
||||||
|
val.get("file_path")
|
||||||
|
or val.get("downloaded_file_content")
|
||||||
|
or val.get("path")
|
||||||
|
or val.get("uri")
|
||||||
|
)
|
||||||
|
if file_path:
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Composio response keys: {list(data.keys())}, inner keys: {list(inner_data.keys()) if isinstance(inner_data, dict) else 'N/A'}, extracted path: {file_path}"
|
||||||
|
)
|
||||||
|
elif isinstance(data, str):
|
||||||
|
# Direct string response (could be path or content)
|
||||||
|
file_path = data
|
||||||
|
elif isinstance(data, bytes):
|
||||||
|
# Direct bytes response
|
||||||
|
return data, None
|
||||||
|
|
||||||
|
# Read file from the path
|
||||||
|
if file_path and isinstance(file_path, str):
|
||||||
|
path_obj = Path(file_path)
|
||||||
|
|
||||||
|
# Check if it's a valid file path (absolute or in .composio directory)
|
||||||
|
if path_obj.is_absolute() or ".composio" in str(path_obj):
|
||||||
|
try:
|
||||||
|
if path_obj.exists():
|
||||||
|
content = path_obj.read_bytes()
|
||||||
|
logger.info(
|
||||||
|
f"Successfully read {len(content)} bytes from Composio file: {file_path}"
|
||||||
|
)
|
||||||
return content, None
|
return content, None
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"File path from Composio does not exist: {file_path}"
|
||||||
|
)
|
||||||
|
return None, f"File not found at path: {file_path}"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to read file from Composio path {file_path}: {e!s}"
|
||||||
|
)
|
||||||
|
return None, f"Failed to read file: {e!s}"
|
||||||
|
else:
|
||||||
|
# Not a file path - might be base64 encoded content
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
content = base64.b64decode(file_path)
|
||||||
|
return content, None
|
||||||
|
except Exception:
|
||||||
|
# Not base64, return as UTF-8 bytes
|
||||||
|
return file_path.encode("utf-8"), None
|
||||||
|
|
||||||
|
# If we got here, couldn't extract file path
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Log full structure for debugging
|
||||||
|
inner_data = data.get("data", {})
|
||||||
|
logger.warning(
|
||||||
|
f"Could not extract file path from Composio response. "
|
||||||
|
f"Top keys: {list(data.keys())}, "
|
||||||
|
f"Inner data keys: {list(inner_data.keys()) if isinstance(inner_data, dict) else type(inner_data).__name__}, "
|
||||||
|
f"Full inner data: {inner_data}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
None,
|
||||||
|
f"No file path in Composio response. Keys: {list(data.keys())}, inner: {list(inner_data.keys()) if isinstance(inner_data, dict) else 'N/A'}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return None, f"Unexpected data type from Composio: {type(data).__name__}"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get Drive file content: {e!s}")
|
logger.error(f"Failed to get Drive file content: {e!s}")
|
||||||
return None, str(e)
|
return None, str(e)
|
||||||
|
|
||||||
|
async def get_drive_start_page_token(
|
||||||
|
self, connected_account_id: str, entity_id: str
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""
|
||||||
|
Get the starting page token for Google Drive change tracking.
|
||||||
|
|
||||||
|
This token represents the current state and is used for future delta syncs.
|
||||||
|
Per Composio docs: Use GOOGLEDRIVE_GET_CHANGES_START_PAGE_TOKEN to get initial token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: Composio connected account ID.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (start_page_token, error message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await self.execute_tool(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
tool_name="GOOGLEDRIVE_GET_CHANGES_START_PAGE_TOKEN",
|
||||||
|
params={},
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
return None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
# Handle nested response: {data: {startPageToken: ...}, successful: ...}
|
||||||
|
if isinstance(data, dict):
|
||||||
|
inner_data = data.get("data", data)
|
||||||
|
token = (
|
||||||
|
inner_data.get("startPageToken")
|
||||||
|
or inner_data.get("start_page_token")
|
||||||
|
or data.get("startPageToken")
|
||||||
|
or data.get("start_page_token")
|
||||||
|
)
|
||||||
|
if token:
|
||||||
|
logger.info(f"Got Drive start page token: {token}")
|
||||||
|
return token, None
|
||||||
|
|
||||||
|
logger.warning(f"Could not extract start page token from response: {data}")
|
||||||
|
return None, "No start page token in response"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get Drive start page token: {e!s}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
async def list_drive_changes(
|
||||||
|
self,
|
||||||
|
connected_account_id: str,
|
||||||
|
entity_id: str,
|
||||||
|
page_token: str | None = None,
|
||||||
|
page_size: int = 100,
|
||||||
|
include_removed: bool = True,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None, str | None]:
|
||||||
|
"""
|
||||||
|
List changes in Google Drive since the given page token.
|
||||||
|
|
||||||
|
Per Composio docs: GOOGLEDRIVE_LIST_CHANGES tracks modifications to files/folders.
|
||||||
|
If pageToken is not provided, it auto-fetches the current start page token.
|
||||||
|
Response includes nextPageToken for pagination and newStartPageToken for future syncs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: Composio connected account ID.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
page_token: Page token from previous sync (optional - will auto-fetch if not provided).
|
||||||
|
page_size: Number of changes per page.
|
||||||
|
include_removed: Whether to include removed items in the response.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (changes list, new_start_page_token, error message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
params = {
|
||||||
|
"pageSize": min(page_size, 100),
|
||||||
|
"includeRemoved": include_removed,
|
||||||
|
}
|
||||||
|
if page_token:
|
||||||
|
params["pageToken"] = page_token
|
||||||
|
|
||||||
|
result = await self.execute_tool(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
tool_name="GOOGLEDRIVE_LIST_CHANGES",
|
||||||
|
params=params,
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
return [], None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
|
||||||
|
# Handle nested response structure
|
||||||
|
changes = []
|
||||||
|
new_start_token = None
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
inner_data = data.get("data", data)
|
||||||
|
changes = inner_data.get("changes", []) or data.get("changes", [])
|
||||||
|
|
||||||
|
# Get the token for next sync
|
||||||
|
# newStartPageToken is returned when all changes have been fetched
|
||||||
|
# nextPageToken is for pagination within the current fetch
|
||||||
|
new_start_token = (
|
||||||
|
inner_data.get("newStartPageToken")
|
||||||
|
or inner_data.get("new_start_page_token")
|
||||||
|
or inner_data.get("nextPageToken")
|
||||||
|
or inner_data.get("next_page_token")
|
||||||
|
or data.get("newStartPageToken")
|
||||||
|
or data.get("nextPageToken")
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Got {len(changes)} Drive changes, new token: {new_start_token[:20] if new_start_token else 'None'}..."
|
||||||
|
)
|
||||||
|
return changes, new_start_token, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list Drive changes: {e!s}")
|
||||||
|
return [], None, str(e)
|
||||||
|
|
||||||
# ===== Gmail specific methods =====
|
# ===== Gmail specific methods =====
|
||||||
|
|
||||||
async def get_gmail_messages(
|
async def get_gmail_messages(
|
||||||
|
|
@ -464,25 +780,30 @@ class ComposioService:
|
||||||
connected_account_id: str,
|
connected_account_id: str,
|
||||||
entity_id: str,
|
entity_id: str,
|
||||||
query: str = "",
|
query: str = "",
|
||||||
max_results: int = 100,
|
max_results: int = 50,
|
||||||
) -> tuple[list[dict[str, Any]], str | None]:
|
page_token: str | None = None,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None, int | None, str | None]:
|
||||||
"""
|
"""
|
||||||
List Gmail messages via Composio.
|
List Gmail messages via Composio with pagination support.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
connected_account_id: Composio connected account ID.
|
connected_account_id: Composio connected account ID.
|
||||||
entity_id: The entity/user ID that owns the connected account.
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
query: Gmail search query.
|
query: Gmail search query.
|
||||||
max_results: Maximum number of messages to return.
|
max_results: Maximum number of messages to return per page (default: 50 to avoid payload size issues).
|
||||||
|
page_token: Optional pagination token for next page.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (messages list, error message).
|
Tuple of (messages list, next_page_token, result_size_estimate, error message).
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Composio uses snake_case for parameters, max is 500
|
# Use smaller batch size to avoid 413 payload too large errors
|
||||||
params = {"max_results": min(max_results, 500)}
|
# Composio uses snake_case for parameters
|
||||||
|
params = {"max_results": min(max_results, 50)} # Reduced from 500 to 50
|
||||||
if query:
|
if query:
|
||||||
params["query"] = query # Composio uses 'query' not 'q'
|
params["query"] = query # Composio uses 'query' not 'q'
|
||||||
|
if page_token:
|
||||||
|
params["page_token"] = page_token
|
||||||
|
|
||||||
result = await self.execute_tool(
|
result = await self.execute_tool(
|
||||||
connected_account_id=connected_account_id,
|
connected_account_id=connected_account_id,
|
||||||
|
|
@ -492,31 +813,42 @@ class ComposioService:
|
||||||
)
|
)
|
||||||
|
|
||||||
if not result.get("success"):
|
if not result.get("success"):
|
||||||
return [], result.get("error", "Unknown error")
|
return [], None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
data = result.get("data", {})
|
data = result.get("data", {})
|
||||||
logger.info(
|
|
||||||
f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
|
|
||||||
)
|
|
||||||
logger.info(f"DEBUG: Gmail full data: {data}")
|
|
||||||
|
|
||||||
# Try different possible response structures
|
# Try different possible response structures
|
||||||
messages = []
|
messages = []
|
||||||
|
next_token = None
|
||||||
|
result_size_estimate = None
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
messages = (
|
messages = (
|
||||||
data.get("messages", [])
|
data.get("messages", [])
|
||||||
or data.get("data", {}).get("messages", [])
|
or data.get("data", {}).get("messages", [])
|
||||||
or data.get("emails", [])
|
or data.get("emails", [])
|
||||||
)
|
)
|
||||||
|
# Check for pagination token in various possible locations
|
||||||
|
next_token = (
|
||||||
|
data.get("nextPageToken")
|
||||||
|
or data.get("next_page_token")
|
||||||
|
or data.get("data", {}).get("nextPageToken")
|
||||||
|
or data.get("data", {}).get("next_page_token")
|
||||||
|
)
|
||||||
|
# Extract resultSizeEstimate if available (Gmail API provides this)
|
||||||
|
result_size_estimate = (
|
||||||
|
data.get("resultSizeEstimate")
|
||||||
|
or data.get("result_size_estimate")
|
||||||
|
or data.get("data", {}).get("resultSizeEstimate")
|
||||||
|
or data.get("data", {}).get("result_size_estimate")
|
||||||
|
)
|
||||||
elif isinstance(data, list):
|
elif isinstance(data, list):
|
||||||
messages = data
|
messages = data
|
||||||
|
|
||||||
logger.info(f"DEBUG: Extracted {len(messages)} messages")
|
return messages, next_token, result_size_estimate, None
|
||||||
return messages, None
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to list Gmail messages: {e!s}")
|
logger.error(f"Failed to list Gmail messages: {e!s}")
|
||||||
return [], str(e)
|
return [], None, str(e)
|
||||||
|
|
||||||
async def get_gmail_message_detail(
|
async def get_gmail_message_detail(
|
||||||
self, connected_account_id: str, entity_id: str, message_id: str
|
self, connected_account_id: str, entity_id: str, message_id: str
|
||||||
|
|
@ -595,10 +927,6 @@ class ComposioService:
|
||||||
return [], result.get("error", "Unknown error")
|
return [], result.get("error", "Unknown error")
|
||||||
|
|
||||||
data = result.get("data", {})
|
data = result.get("data", {})
|
||||||
logger.info(
|
|
||||||
f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
|
|
||||||
)
|
|
||||||
logger.info(f"DEBUG: Calendar full data: {data}")
|
|
||||||
|
|
||||||
# Try different possible response structures
|
# Try different possible response structures
|
||||||
events = []
|
events = []
|
||||||
|
|
@ -611,7 +939,6 @@ class ComposioService:
|
||||||
elif isinstance(data, list):
|
elif isinstance(data, list):
|
||||||
events = data
|
events = data
|
||||||
|
|
||||||
logger.info(f"DEBUG: Extracted {len(events)} calendar events")
|
|
||||||
return events, None
|
return events, None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -2871,3 +2871,350 @@ class ConnectorService:
|
||||||
}
|
}
|
||||||
|
|
||||||
return result_object, obsidian_docs
|
return result_object, obsidian_docs
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Composio Connector Search Methods
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def search_composio_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 Composio 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)
|
||||||
|
"""
|
||||||
|
composio_drive_docs = await self._combined_rrf_search(
|
||||||
|
query_text=user_query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
document_type="COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Early return if no results
|
||||||
|
if not composio_drive_docs:
|
||||||
|
return {
|
||||||
|
"id": 54,
|
||||||
|
"name": "Google Drive (Composio)",
|
||||||
|
"type": "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"sources": [],
|
||||||
|
}, []
|
||||||
|
|
||||||
|
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||||
|
return (
|
||||||
|
doc_info.get("title")
|
||||||
|
or metadata.get("title")
|
||||||
|
or metadata.get("file_name")
|
||||||
|
or "Untitled Document"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||||
|
return metadata.get("url") or metadata.get("web_view_link") or ""
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
mime_type = metadata.get("mime_type")
|
||||||
|
modified_time = metadata.get("modified_time")
|
||||||
|
if mime_type:
|
||||||
|
info_parts.append(f"Type: {mime_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 {
|
||||||
|
"mime_type": metadata.get("mime_type", ""),
|
||||||
|
"file_id": metadata.get("file_id", ""),
|
||||||
|
"modified_time": metadata.get("modified_time", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
sources_list = self._build_chunk_sources_from_documents(
|
||||||
|
composio_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": 54,
|
||||||
|
"name": "Google Drive (Composio)",
|
||||||
|
"type": "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"sources": sources_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result_object, composio_drive_docs
|
||||||
|
|
||||||
|
async def search_composio_gmail(
|
||||||
|
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 Composio Gmail messages 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)
|
||||||
|
"""
|
||||||
|
composio_gmail_docs = await self._combined_rrf_search(
|
||||||
|
query_text=user_query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
document_type="COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Early return if no results
|
||||||
|
if not composio_gmail_docs:
|
||||||
|
return {
|
||||||
|
"id": 55,
|
||||||
|
"name": "Gmail (Composio)",
|
||||||
|
"type": "COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"sources": [],
|
||||||
|
}, []
|
||||||
|
|
||||||
|
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||||
|
return (
|
||||||
|
doc_info.get("title")
|
||||||
|
or metadata.get("subject")
|
||||||
|
or metadata.get("title")
|
||||||
|
or "Untitled Email"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||||
|
return metadata.get("url") or ""
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
sender = metadata.get("from") or metadata.get("sender")
|
||||||
|
date = metadata.get("date") or metadata.get("received_at")
|
||||||
|
if sender:
|
||||||
|
info_parts.append(f"From: {sender}")
|
||||||
|
if date:
|
||||||
|
info_parts.append(f"Date: {date}")
|
||||||
|
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 {
|
||||||
|
"message_id": metadata.get("message_id", ""),
|
||||||
|
"thread_id": metadata.get("thread_id", ""),
|
||||||
|
"from": metadata.get("from", ""),
|
||||||
|
"to": metadata.get("to", ""),
|
||||||
|
"date": metadata.get("date", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
sources_list = self._build_chunk_sources_from_documents(
|
||||||
|
composio_gmail_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": 55,
|
||||||
|
"name": "Gmail (Composio)",
|
||||||
|
"type": "COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"sources": sources_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result_object, composio_gmail_docs
|
||||||
|
|
||||||
|
async def search_composio_google_calendar(
|
||||||
|
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 Composio Google Calendar events 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)
|
||||||
|
"""
|
||||||
|
composio_calendar_docs = await self._combined_rrf_search(
|
||||||
|
query_text=user_query,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
document_type="COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
top_k=top_k,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Early return if no results
|
||||||
|
if not composio_calendar_docs:
|
||||||
|
return {
|
||||||
|
"id": 56,
|
||||||
|
"name": "Google Calendar (Composio)",
|
||||||
|
"type": "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
"sources": [],
|
||||||
|
}, []
|
||||||
|
|
||||||
|
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||||
|
return (
|
||||||
|
doc_info.get("title")
|
||||||
|
or metadata.get("summary")
|
||||||
|
or metadata.get("title")
|
||||||
|
or "Untitled Event"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||||
|
return metadata.get("url") or metadata.get("html_link") or ""
|
||||||
|
|
||||||
|
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 = []
|
||||||
|
start_time = metadata.get("start_time") or metadata.get("start")
|
||||||
|
end_time = metadata.get("end_time") or metadata.get("end")
|
||||||
|
if start_time:
|
||||||
|
info_parts.append(f"Start: {start_time}")
|
||||||
|
if end_time:
|
||||||
|
info_parts.append(f"End: {end_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 {
|
||||||
|
"event_id": metadata.get("event_id", ""),
|
||||||
|
"calendar_id": metadata.get("calendar_id", ""),
|
||||||
|
"start_time": metadata.get("start_time", ""),
|
||||||
|
"end_time": metadata.get("end_time", ""),
|
||||||
|
"location": metadata.get("location", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
sources_list = self._build_chunk_sources_from_documents(
|
||||||
|
composio_calendar_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": 56,
|
||||||
|
"name": "Google Calendar (Composio)",
|
||||||
|
"type": "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
|
"sources": sources_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result_object, composio_calendar_docs
|
||||||
|
|
||||||
|
# =========================================================================
|
||||||
|
# Utility Methods for Connector Discovery
|
||||||
|
# =========================================================================
|
||||||
|
|
||||||
|
async def get_available_connectors(
|
||||||
|
self,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> list[SearchSourceConnectorType]:
|
||||||
|
"""
|
||||||
|
Get all available (enabled) connector types for a search space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_space_id: The search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of SearchSourceConnectorType enums for enabled connectors
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
select(SearchSourceConnector.connector_type)
|
||||||
|
.filter(
|
||||||
|
SearchSourceConnector.search_space_id == search_space_id,
|
||||||
|
)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
connector_types = result.scalars().all()
|
||||||
|
return list(connector_types)
|
||||||
|
|
||||||
|
async def get_available_document_types(
|
||||||
|
self,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get all document types that have at least one document in the search space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_space_id: The search space ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of document type strings that have documents indexed
|
||||||
|
"""
|
||||||
|
from sqlalchemy import distinct
|
||||||
|
|
||||||
|
from app.db import Document
|
||||||
|
|
||||||
|
query = select(distinct(Document.document_type)).filter(
|
||||||
|
Document.search_space_id == search_space_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.session.execute(query)
|
||||||
|
doc_types = result.scalars().all()
|
||||||
|
return [str(dt) for dt in doc_types]
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,7 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
||||||
notification: Notification,
|
notification: Notification,
|
||||||
indexed_count: int,
|
indexed_count: int,
|
||||||
error_message: str | None = None,
|
error_message: str | None = None,
|
||||||
|
is_warning: bool = False,
|
||||||
) -> Notification:
|
) -> Notification:
|
||||||
"""
|
"""
|
||||||
Update notification when connector indexing completes.
|
Update notification when connector indexing completes.
|
||||||
|
|
@ -343,7 +344,8 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
||||||
session: Database session
|
session: Database session
|
||||||
notification: Notification to update
|
notification: Notification to update
|
||||||
indexed_count: Total number of items indexed
|
indexed_count: Total number of items indexed
|
||||||
error_message: Error message if indexing failed (optional)
|
error_message: Error message if indexing failed, or warning message (optional)
|
||||||
|
is_warning: If True, treat error_message as a warning (success case) rather than an error
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated notification
|
Updated notification
|
||||||
|
|
@ -352,7 +354,23 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
||||||
"connector_name", "Connector"
|
"connector_name", "Connector"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# If there's an error message but items were indexed, treat it as a warning (partial success)
|
||||||
|
# If is_warning is True, treat it as success even with 0 items (e.g., duplicates found)
|
||||||
|
# Otherwise, treat it as a failure
|
||||||
if error_message:
|
if error_message:
|
||||||
|
if indexed_count > 0:
|
||||||
|
# Partial success with warnings (e.g., duplicate content from other connectors)
|
||||||
|
title = f"Ready: {connector_name}"
|
||||||
|
item_text = "item" if indexed_count == 1 else "items"
|
||||||
|
message = f"Now searchable! {indexed_count} {item_text} synced. Note: {error_message}"
|
||||||
|
status = "completed"
|
||||||
|
elif is_warning:
|
||||||
|
# Warning case (e.g., duplicates found) - treat as success
|
||||||
|
title = f"Ready: {connector_name}"
|
||||||
|
message = f"Sync completed. {error_message}"
|
||||||
|
status = "completed"
|
||||||
|
else:
|
||||||
|
# Complete failure
|
||||||
title = f"Failed: {connector_name}"
|
title = f"Failed: {connector_name}"
|
||||||
message = f"Sync failed: {error_message}"
|
message = f"Sync failed: {error_message}"
|
||||||
status = "failed"
|
status = "failed"
|
||||||
|
|
@ -367,7 +385,9 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
||||||
|
|
||||||
metadata_updates = {
|
metadata_updates = {
|
||||||
"indexed_count": indexed_count,
|
"indexed_count": indexed_count,
|
||||||
"sync_stage": "completed" if not error_message else "failed",
|
"sync_stage": "completed"
|
||||||
|
if (not error_message or is_warning or indexed_count > 0)
|
||||||
|
else "failed",
|
||||||
"error_message": error_message,
|
"error_message": error_message,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -810,8 +810,8 @@ def index_composio_connector_task(
|
||||||
connector_id: int,
|
connector_id: int,
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
start_date: str,
|
start_date: str | None,
|
||||||
end_date: str,
|
end_date: str | None,
|
||||||
):
|
):
|
||||||
"""Celery task to index Composio connector content (Google Drive, Gmail, Calendar via Composio)."""
|
"""Celery task to index Composio connector content (Google Drive, Gmail, Calendar via Composio)."""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -833,14 +833,16 @@ async def _index_composio_connector(
|
||||||
connector_id: int,
|
connector_id: int,
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
start_date: str,
|
start_date: str | None,
|
||||||
end_date: str,
|
end_date: str | None,
|
||||||
):
|
):
|
||||||
"""Index Composio connector content with new session."""
|
"""Index Composio connector content with new session and real-time notifications."""
|
||||||
# Import from tasks folder (not connector_indexers) to avoid circular import
|
# Import from routes to use the notification-wrapped version
|
||||||
from app.tasks.composio_indexer import index_composio_connector
|
from app.routes.search_source_connectors_routes import (
|
||||||
|
run_composio_indexing,
|
||||||
|
)
|
||||||
|
|
||||||
async with get_celery_session_maker()() as session:
|
async with get_celery_session_maker()() as session:
|
||||||
await index_composio_connector(
|
await run_composio_indexing(
|
||||||
session, connector_id, search_space_id, user_id, start_date, end_date
|
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ async def _check_and_trigger_schedules():
|
||||||
from app.tasks.celery_tasks.connector_tasks import (
|
from app.tasks.celery_tasks.connector_tasks import (
|
||||||
index_airtable_records_task,
|
index_airtable_records_task,
|
||||||
index_clickup_tasks_task,
|
index_clickup_tasks_task,
|
||||||
|
index_composio_connector_task,
|
||||||
index_confluence_pages_task,
|
index_confluence_pages_task,
|
||||||
index_crawled_urls_task,
|
index_crawled_urls_task,
|
||||||
index_discord_messages_task,
|
index_discord_messages_task,
|
||||||
|
|
@ -98,6 +99,10 @@ async def _check_and_trigger_schedules():
|
||||||
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: index_elasticsearch_documents_task,
|
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: index_elasticsearch_documents_task,
|
||||||
SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: index_crawled_urls_task,
|
SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: index_crawled_urls_task,
|
||||||
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: index_google_drive_files_task,
|
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: index_google_drive_files_task,
|
||||||
|
# Composio connector types
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: index_composio_connector_task,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: index_composio_connector_task,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: index_composio_connector_task,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Trigger indexing for each due connector
|
# Trigger indexing for each due connector
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,12 @@ def format_attachments_as_context(attachments: list[ChatAttachment]) -> str:
|
||||||
|
|
||||||
|
|
||||||
def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
||||||
"""Format mentioned documents as context for the agent."""
|
"""
|
||||||
|
Format mentioned documents as context for the agent.
|
||||||
|
|
||||||
|
Uses the same XML structure as knowledge_base.format_documents_for_context
|
||||||
|
to ensure citations work properly with chunk IDs.
|
||||||
|
"""
|
||||||
if not documents:
|
if not documents:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
@ -62,13 +67,55 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
||||||
context_parts.append(
|
context_parts.append(
|
||||||
"The user has explicitly mentioned the following documents from their knowledge base. "
|
"The user has explicitly mentioned the following documents from their knowledge base. "
|
||||||
"These documents are directly relevant to the query and should be prioritized as primary sources. "
|
"These documents are directly relevant to the query and should be prioritized as primary sources. "
|
||||||
|
"Use [citation:CHUNK_ID] format for citations (e.g., [citation:123])."
|
||||||
)
|
)
|
||||||
for i, doc in enumerate(documents, 1):
|
context_parts.append("")
|
||||||
|
|
||||||
|
for doc in documents:
|
||||||
|
# Build metadata JSON
|
||||||
|
metadata = doc.document_metadata or {}
|
||||||
|
metadata_json = json.dumps(metadata, ensure_ascii=False)
|
||||||
|
|
||||||
|
# Get URL from metadata
|
||||||
|
url = (
|
||||||
|
metadata.get("url")
|
||||||
|
or metadata.get("source")
|
||||||
|
or metadata.get("page_url")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
context_parts.append("<document>")
|
||||||
|
context_parts.append("<document_metadata>")
|
||||||
|
context_parts.append(f" <document_id>{doc.id}</document_id>")
|
||||||
context_parts.append(
|
context_parts.append(
|
||||||
f"<document index='{i}' id='{doc.id}' title='{doc.title}' type='{doc.document_type.value}'>"
|
f" <document_type>{doc.document_type.value}</document_type>"
|
||||||
)
|
)
|
||||||
context_parts.append(f"<![CDATA[{doc.content}]]>")
|
context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
|
||||||
|
context_parts.append(f" <url><![CDATA[{url}]]></url>")
|
||||||
|
context_parts.append(
|
||||||
|
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
|
||||||
|
)
|
||||||
|
context_parts.append("</document_metadata>")
|
||||||
|
context_parts.append("")
|
||||||
|
context_parts.append("<document_content>")
|
||||||
|
|
||||||
|
# Use chunks if available (preferred for proper citations)
|
||||||
|
if hasattr(doc, "chunks") and doc.chunks:
|
||||||
|
for chunk in doc.chunks:
|
||||||
|
context_parts.append(
|
||||||
|
f" <chunk id='{chunk.id}'><![CDATA[{chunk.content}]]></chunk>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to document content if chunks not loaded
|
||||||
|
# Use document ID as chunk ID prefix for consistency
|
||||||
|
context_parts.append(
|
||||||
|
f" <chunk id='{doc.id}'><![CDATA[{doc.content}]]></chunk>"
|
||||||
|
)
|
||||||
|
|
||||||
|
context_parts.append("</document_content>")
|
||||||
context_parts.append("</document>")
|
context_parts.append("</document>")
|
||||||
|
context_parts.append("")
|
||||||
|
|
||||||
context_parts.append("</mentioned_documents>")
|
context_parts.append("</mentioned_documents>")
|
||||||
|
|
||||||
return "\n".join(context_parts)
|
return "\n".join(context_parts)
|
||||||
|
|
@ -81,8 +128,6 @@ def format_mentioned_surfsense_docs_as_context(
|
||||||
if not documents:
|
if not documents:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
context_parts = ["<mentioned_surfsense_docs>"]
|
context_parts = ["<mentioned_surfsense_docs>"]
|
||||||
context_parts.append(
|
context_parts.append(
|
||||||
"The user has explicitly mentioned the following SurfSense documentation pages. "
|
"The user has explicitly mentioned the following SurfSense documentation pages. "
|
||||||
|
|
@ -263,11 +308,15 @@ async def stream_new_chat(
|
||||||
# Build input with message history from frontend
|
# Build input with message history from frontend
|
||||||
langchain_messages = []
|
langchain_messages = []
|
||||||
|
|
||||||
# Fetch mentioned documents if any
|
# Fetch mentioned documents if any (with chunks for proper citations)
|
||||||
mentioned_documents: list[Document] = []
|
mentioned_documents: list[Document] = []
|
||||||
if mentioned_document_ids:
|
if mentioned_document_ids:
|
||||||
|
from sqlalchemy.orm import selectinload as doc_selectinload
|
||||||
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Document).filter(
|
select(Document)
|
||||||
|
.options(doc_selectinload(Document.chunks))
|
||||||
|
.filter(
|
||||||
Document.id.in_(mentioned_document_ids),
|
Document.id.in_(mentioned_document_ids),
|
||||||
Document.search_space_id == search_space_id,
|
Document.search_space_id == search_space_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,83 +2,76 @@
|
||||||
Composio connector indexer.
|
Composio connector indexer.
|
||||||
|
|
||||||
Routes indexing requests to toolkit-specific handlers (Google Drive, Gmail, Calendar).
|
Routes indexing requests to toolkit-specific handlers (Google Drive, Gmail, Calendar).
|
||||||
|
Uses a registry pattern for clean, extensible connector routing.
|
||||||
|
|
||||||
Note: This module is intentionally placed in app/tasks/ (not in connector_indexers/)
|
Note: This module is intentionally placed in app/tasks/ (not in connector_indexers/)
|
||||||
to avoid circular import issues with the connector_indexers package.
|
to avoid circular import issues with the connector_indexers package.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import UTC, datetime
|
from importlib import import_module
|
||||||
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from app.config import config
|
|
||||||
from app.connectors.composio_connector import ComposioConnector
|
|
||||||
from app.db import (
|
from app.db import (
|
||||||
Document,
|
|
||||||
DocumentType,
|
|
||||||
SearchSourceConnector,
|
SearchSourceConnector,
|
||||||
SearchSourceConnectorType,
|
SearchSourceConnectorType,
|
||||||
)
|
)
|
||||||
from app.services.composio_service import INDEXABLE_TOOLKITS
|
from app.services.composio_service import INDEXABLE_TOOLKITS, TOOLKIT_TO_INDEXER
|
||||||
from app.services.llm_service import get_user_long_context_llm
|
|
||||||
from app.services.task_logging_service import TaskLoggingService
|
from app.services.task_logging_service import TaskLoggingService
|
||||||
from app.utils.document_converters import (
|
|
||||||
create_document_chunks,
|
|
||||||
generate_content_hash,
|
|
||||||
generate_document_summary,
|
|
||||||
generate_unique_identifier_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ============ Utility functions (copied from connector_indexers.base to avoid circular imports) ============
|
# Valid Composio connector types
|
||||||
|
COMPOSIO_CONNECTOR_TYPES = {
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_current_timestamp() -> datetime:
|
# ============ Utility functions ============
|
||||||
"""Get the current timestamp with timezone for updated_at field."""
|
|
||||||
return datetime.now(UTC)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_document_by_unique_identifier(
|
|
||||||
session: AsyncSession, unique_identifier_hash: str
|
|
||||||
) -> Document | None:
|
|
||||||
"""Check if a document with the given unique identifier hash already exists."""
|
|
||||||
existing_doc_result = await session.execute(
|
|
||||||
select(Document)
|
|
||||||
.options(selectinload(Document.chunks))
|
|
||||||
.where(Document.unique_identifier_hash == unique_identifier_hash)
|
|
||||||
)
|
|
||||||
return existing_doc_result.scalars().first()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_connector_by_id(
|
async def get_connector_by_id(
|
||||||
session: AsyncSession, connector_id: int, connector_type: SearchSourceConnectorType
|
session: AsyncSession,
|
||||||
|
connector_id: int,
|
||||||
|
connector_type: SearchSourceConnectorType | None,
|
||||||
) -> SearchSourceConnector | None:
|
) -> SearchSourceConnector | None:
|
||||||
"""Get a connector by ID and type from the database."""
|
"""Get a connector by ID and optionally by type from the database."""
|
||||||
result = await session.execute(
|
query = select(SearchSourceConnector).filter(
|
||||||
select(SearchSourceConnector).filter(
|
SearchSourceConnector.id == connector_id
|
||||||
SearchSourceConnector.id == connector_id,
|
|
||||||
SearchSourceConnector.connector_type == connector_type,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
if connector_type is not None:
|
||||||
|
query = query.filter(SearchSourceConnector.connector_type == connector_type)
|
||||||
|
result = await session.execute(query)
|
||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
async def update_connector_last_indexed(
|
def get_indexer_function(toolkit_id: str):
|
||||||
session: AsyncSession,
|
"""
|
||||||
connector: SearchSourceConnector,
|
Dynamically import and return the indexer function for a toolkit.
|
||||||
update_last_indexed: bool = True,
|
|
||||||
) -> None:
|
Args:
|
||||||
"""Update the last_indexed_at timestamp for a connector."""
|
toolkit_id: The toolkit ID (e.g., "googledrive", "gmail")
|
||||||
if update_last_indexed:
|
|
||||||
connector.last_indexed_at = datetime.now()
|
Returns:
|
||||||
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
Tuple of (indexer_function, supports_date_filter)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If toolkit not found in registry
|
||||||
|
"""
|
||||||
|
if toolkit_id not in TOOLKIT_TO_INDEXER:
|
||||||
|
raise ValueError(f"No indexer registered for toolkit: {toolkit_id}")
|
||||||
|
|
||||||
|
module_path, function_name, supports_date_filter = TOOLKIT_TO_INDEXER[toolkit_id]
|
||||||
|
module = import_module(module_path)
|
||||||
|
indexer_func = getattr(module, function_name)
|
||||||
|
return indexer_func, supports_date_filter
|
||||||
|
|
||||||
|
|
||||||
# ============ Main indexer function ============
|
# ============ Main indexer function ============
|
||||||
|
|
@ -98,6 +91,7 @@ async def index_composio_connector(
|
||||||
Index content from a Composio connector.
|
Index content from a Composio connector.
|
||||||
|
|
||||||
Routes to toolkit-specific indexing based on the connector's toolkit_id.
|
Routes to toolkit-specific indexing based on the connector's toolkit_id.
|
||||||
|
Uses a registry pattern for clean, extensible connector routing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
|
|
@ -129,10 +123,16 @@ async def index_composio_connector(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get connector by id
|
# Get connector by id - accept any Composio connector type
|
||||||
connector = await get_connector_by_id(
|
connector = await get_connector_by_id(session, connector_id, None)
|
||||||
session, connector_id, SearchSourceConnectorType.COMPOSIO_CONNECTOR
|
|
||||||
|
# Validate it's a Composio connector
|
||||||
|
if connector and connector.connector_type not in COMPOSIO_CONNECTOR_TYPES:
|
||||||
|
error_msg = f"Connector {connector_id} is not a Composio connector"
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, error_msg, {"error_type": "InvalidConnectorType"}
|
||||||
)
|
)
|
||||||
|
return 0, error_msg
|
||||||
|
|
||||||
if not connector:
|
if not connector:
|
||||||
error_msg = f"Composio connector with ID {connector_id} not found"
|
error_msg = f"Composio connector with ID {connector_id} not found"
|
||||||
|
|
@ -160,53 +160,35 @@ async def index_composio_connector(
|
||||||
)
|
)
|
||||||
return 0, error_msg
|
return 0, error_msg
|
||||||
|
|
||||||
# Route to toolkit-specific indexer
|
# Get indexer function from registry
|
||||||
if toolkit_id == "googledrive":
|
try:
|
||||||
return await _index_composio_google_drive(
|
indexer_func, supports_date_filter = get_indexer_function(toolkit_id)
|
||||||
session=session,
|
except ValueError as e:
|
||||||
connector=connector,
|
|
||||||
connector_id=connector_id,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
user_id=user_id,
|
|
||||||
task_logger=task_logger,
|
|
||||||
log_entry=log_entry,
|
|
||||||
update_last_indexed=update_last_indexed,
|
|
||||||
max_items=max_items,
|
|
||||||
)
|
|
||||||
elif toolkit_id == "gmail":
|
|
||||||
return await _index_composio_gmail(
|
|
||||||
session=session,
|
|
||||||
connector=connector,
|
|
||||||
connector_id=connector_id,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
user_id=user_id,
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
task_logger=task_logger,
|
|
||||||
log_entry=log_entry,
|
|
||||||
update_last_indexed=update_last_indexed,
|
|
||||||
max_items=max_items,
|
|
||||||
)
|
|
||||||
elif toolkit_id == "googlecalendar":
|
|
||||||
return await _index_composio_google_calendar(
|
|
||||||
session=session,
|
|
||||||
connector=connector,
|
|
||||||
connector_id=connector_id,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
user_id=user_id,
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
task_logger=task_logger,
|
|
||||||
log_entry=log_entry,
|
|
||||||
update_last_indexed=update_last_indexed,
|
|
||||||
max_items=max_items,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
error_msg = f"No indexer implemented for toolkit: {toolkit_id}"
|
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry, error_msg, {"error_type": "NoIndexerImplemented"}
|
log_entry, str(e), {"error_type": "NoIndexerImplemented"}
|
||||||
)
|
)
|
||||||
return 0, error_msg
|
return 0, str(e)
|
||||||
|
|
||||||
|
# Build kwargs for the indexer function
|
||||||
|
kwargs = {
|
||||||
|
"session": session,
|
||||||
|
"connector": connector,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"search_space_id": search_space_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"task_logger": task_logger,
|
||||||
|
"log_entry": log_entry,
|
||||||
|
"update_last_indexed": update_last_indexed,
|
||||||
|
"max_items": max_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add date params for toolkits that support them
|
||||||
|
if supports_date_filter:
|
||||||
|
kwargs["start_date"] = start_date
|
||||||
|
kwargs["end_date"] = end_date
|
||||||
|
|
||||||
|
# Call the toolkit-specific indexer
|
||||||
|
return await indexer_func(**kwargs)
|
||||||
|
|
||||||
except SQLAlchemyError as db_error:
|
except SQLAlchemyError as db_error:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
@ -228,714 +210,3 @@ async def index_composio_connector(
|
||||||
)
|
)
|
||||||
logger.error(f"Failed to index Composio connector: {e!s}", exc_info=True)
|
logger.error(f"Failed to index Composio connector: {e!s}", exc_info=True)
|
||||||
return 0, f"Failed to index Composio connector: {e!s}"
|
return 0, f"Failed to index Composio connector: {e!s}"
|
||||||
|
|
||||||
|
|
||||||
async def _index_composio_google_drive(
|
|
||||||
session: AsyncSession,
|
|
||||||
connector,
|
|
||||||
connector_id: int,
|
|
||||||
search_space_id: int,
|
|
||||||
user_id: str,
|
|
||||||
task_logger: TaskLoggingService,
|
|
||||||
log_entry,
|
|
||||||
update_last_indexed: bool = True,
|
|
||||||
max_items: int = 1000,
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
"""Index Google Drive files via Composio."""
|
|
||||||
try:
|
|
||||||
composio_connector = ComposioConnector(session, connector_id)
|
|
||||||
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Fetching Google Drive files via Composio for connector {connector_id}",
|
|
||||||
{"stage": "fetching_files"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Fetch files
|
|
||||||
all_files = []
|
|
||||||
page_token = None
|
|
||||||
|
|
||||||
while len(all_files) < max_items:
|
|
||||||
files, next_token, error = await composio_connector.list_drive_files(
|
|
||||||
page_token=page_token,
|
|
||||||
page_size=min(100, max_items - len(all_files)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry, f"Failed to fetch Drive files: {error}", {}
|
|
||||||
)
|
|
||||||
return 0, f"Failed to fetch Drive files: {error}"
|
|
||||||
|
|
||||||
all_files.extend(files)
|
|
||||||
|
|
||||||
if not next_token:
|
|
||||||
break
|
|
||||||
page_token = next_token
|
|
||||||
|
|
||||||
if not all_files:
|
|
||||||
success_msg = "No Google Drive files found"
|
|
||||||
await task_logger.log_task_success(
|
|
||||||
log_entry, success_msg, {"files_count": 0}
|
|
||||||
)
|
|
||||||
return 0, success_msg
|
|
||||||
|
|
||||||
logger.info(f"Found {len(all_files)} Google Drive files to index via Composio")
|
|
||||||
|
|
||||||
documents_indexed = 0
|
|
||||||
documents_skipped = 0
|
|
||||||
|
|
||||||
for file_info in all_files:
|
|
||||||
try:
|
|
||||||
# Handle both standard Google API and potential Composio variations
|
|
||||||
file_id = file_info.get("id", "") or file_info.get("fileId", "")
|
|
||||||
file_name = (
|
|
||||||
file_info.get("name", "")
|
|
||||||
or file_info.get("fileName", "")
|
|
||||||
or "Untitled"
|
|
||||||
)
|
|
||||||
mime_type = file_info.get("mimeType", "") or file_info.get(
|
|
||||||
"mime_type", ""
|
|
||||||
)
|
|
||||||
|
|
||||||
if not file_id:
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Skip folders
|
|
||||||
if mime_type == "application/vnd.google-apps.folder":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Generate unique identifier hash
|
|
||||||
unique_identifier_hash = generate_unique_identifier_hash(
|
|
||||||
DocumentType.COMPOSIO_CONNECTOR, f"drive_{file_id}", search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if document exists
|
|
||||||
existing_document = await check_document_by_unique_identifier(
|
|
||||||
session, unique_identifier_hash
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get file content
|
|
||||||
(
|
|
||||||
content,
|
|
||||||
content_error,
|
|
||||||
) = await composio_connector.get_drive_file_content(file_id)
|
|
||||||
|
|
||||||
if content_error or not content:
|
|
||||||
logger.warning(
|
|
||||||
f"Could not get content for file {file_name}: {content_error}"
|
|
||||||
)
|
|
||||||
# Use metadata as content fallback
|
|
||||||
markdown_content = f"# {file_name}\n\n"
|
|
||||||
markdown_content += f"**File ID:** {file_id}\n"
|
|
||||||
markdown_content += f"**Type:** {mime_type}\n"
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
markdown_content = content.decode("utf-8")
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
markdown_content = f"# {file_name}\n\n[Binary file content]\n"
|
|
||||||
|
|
||||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
|
||||||
|
|
||||||
if existing_document:
|
|
||||||
if existing_document.content_hash == content_hash:
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update existing document
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if user_llm:
|
|
||||||
document_metadata = {
|
|
||||||
"file_id": file_id,
|
|
||||||
"file_name": file_name,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"document_type": "Google Drive File (Composio)",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
markdown_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
summary_content = (
|
|
||||||
f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
|
||||||
)
|
|
||||||
summary_embedding = config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = await create_document_chunks(markdown_content)
|
|
||||||
|
|
||||||
existing_document.title = f"Drive: {file_name}"
|
|
||||||
existing_document.content = summary_content
|
|
||||||
existing_document.content_hash = content_hash
|
|
||||||
existing_document.embedding = summary_embedding
|
|
||||||
existing_document.document_metadata = {
|
|
||||||
"file_id": file_id,
|
|
||||||
"file_name": file_name,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"connector_id": connector_id,
|
|
||||||
"source": "composio",
|
|
||||||
}
|
|
||||||
existing_document.chunks = chunks
|
|
||||||
existing_document.updated_at = get_current_timestamp()
|
|
||||||
|
|
||||||
documents_indexed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create new document
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if user_llm:
|
|
||||||
document_metadata = {
|
|
||||||
"file_id": file_id,
|
|
||||||
"file_name": file_name,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"document_type": "Google Drive File (Composio)",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
markdown_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
summary_content = (
|
|
||||||
f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
|
||||||
)
|
|
||||||
summary_embedding = config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = await create_document_chunks(markdown_content)
|
|
||||||
|
|
||||||
document = Document(
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
title=f"Drive: {file_name}",
|
|
||||||
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
|
||||||
document_metadata={
|
|
||||||
"file_id": file_id,
|
|
||||||
"file_name": file_name,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"connector_id": connector_id,
|
|
||||||
"toolkit_id": "googledrive",
|
|
||||||
"source": "composio",
|
|
||||||
},
|
|
||||||
content=summary_content,
|
|
||||||
content_hash=content_hash,
|
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
|
||||||
embedding=summary_embedding,
|
|
||||||
chunks=chunks,
|
|
||||||
updated_at=get_current_timestamp(),
|
|
||||||
)
|
|
||||||
session.add(document)
|
|
||||||
documents_indexed += 1
|
|
||||||
|
|
||||||
if documents_indexed % 10 == 0:
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing Drive file: {e!s}", exc_info=True)
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if documents_indexed > 0:
|
|
||||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await task_logger.log_task_success(
|
|
||||||
log_entry,
|
|
||||||
f"Successfully completed Google Drive indexing via Composio for connector {connector_id}",
|
|
||||||
{
|
|
||||||
"documents_indexed": documents_indexed,
|
|
||||||
"documents_skipped": documents_skipped,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return documents_indexed, None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to index Google Drive via Composio: {e!s}", exc_info=True)
|
|
||||||
return 0, f"Failed to index Google Drive via Composio: {e!s}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _index_composio_gmail(
|
|
||||||
session: AsyncSession,
|
|
||||||
connector,
|
|
||||||
connector_id: int,
|
|
||||||
search_space_id: int,
|
|
||||||
user_id: str,
|
|
||||||
start_date: str | None,
|
|
||||||
end_date: str | None,
|
|
||||||
task_logger: TaskLoggingService,
|
|
||||||
log_entry,
|
|
||||||
update_last_indexed: bool = True,
|
|
||||||
max_items: int = 1000,
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
"""Index Gmail messages via Composio."""
|
|
||||||
try:
|
|
||||||
composio_connector = ComposioConnector(session, connector_id)
|
|
||||||
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Fetching Gmail messages via Composio for connector {connector_id}",
|
|
||||||
{"stage": "fetching_messages"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build query with date range
|
|
||||||
query_parts = []
|
|
||||||
if start_date:
|
|
||||||
query_parts.append(f"after:{start_date.replace('-', '/')}")
|
|
||||||
if end_date:
|
|
||||||
query_parts.append(f"before:{end_date.replace('-', '/')}")
|
|
||||||
query = " ".join(query_parts)
|
|
||||||
|
|
||||||
messages, error = await composio_connector.list_gmail_messages(
|
|
||||||
query=query,
|
|
||||||
max_results=max_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry, f"Failed to fetch Gmail messages: {error}", {}
|
|
||||||
)
|
|
||||||
return 0, f"Failed to fetch Gmail messages: {error}"
|
|
||||||
|
|
||||||
if not messages:
|
|
||||||
success_msg = "No Gmail messages found in the specified date range"
|
|
||||||
await task_logger.log_task_success(
|
|
||||||
log_entry, success_msg, {"messages_count": 0}
|
|
||||||
)
|
|
||||||
return 0, success_msg
|
|
||||||
|
|
||||||
logger.info(f"Found {len(messages)} Gmail messages to index via Composio")
|
|
||||||
|
|
||||||
documents_indexed = 0
|
|
||||||
documents_skipped = 0
|
|
||||||
|
|
||||||
for message in messages:
|
|
||||||
try:
|
|
||||||
# Composio uses 'messageId' (camelCase), not 'id'
|
|
||||||
message_id = message.get("messageId", "") or message.get("id", "")
|
|
||||||
if not message_id:
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Composio's GMAIL_FETCH_EMAILS already returns full message content
|
|
||||||
# No need for a separate detail API call
|
|
||||||
|
|
||||||
# Extract message info from Composio response
|
|
||||||
# Composio structure: messageId, messageText, messageTimestamp, payload.headers, labelIds
|
|
||||||
payload = message.get("payload", {})
|
|
||||||
headers = payload.get("headers", [])
|
|
||||||
|
|
||||||
subject = "No Subject"
|
|
||||||
sender = "Unknown Sender"
|
|
||||||
date_str = message.get("messageTimestamp", "Unknown Date")
|
|
||||||
|
|
||||||
for header in headers:
|
|
||||||
name = header.get("name", "").lower()
|
|
||||||
value = header.get("value", "")
|
|
||||||
if name == "subject":
|
|
||||||
subject = value
|
|
||||||
elif name == "from":
|
|
||||||
sender = value
|
|
||||||
elif name == "date":
|
|
||||||
date_str = value
|
|
||||||
|
|
||||||
# Format to markdown using the full message data
|
|
||||||
markdown_content = composio_connector.format_gmail_message_to_markdown(
|
|
||||||
message
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate unique identifier
|
|
||||||
unique_identifier_hash = generate_unique_identifier_hash(
|
|
||||||
DocumentType.COMPOSIO_CONNECTOR,
|
|
||||||
f"gmail_{message_id}",
|
|
||||||
search_space_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
|
||||||
|
|
||||||
existing_document = await check_document_by_unique_identifier(
|
|
||||||
session, unique_identifier_hash
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get label IDs from Composio response
|
|
||||||
label_ids = message.get("labelIds", [])
|
|
||||||
|
|
||||||
if existing_document:
|
|
||||||
if existing_document.content_hash == content_hash:
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update existing
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if user_llm:
|
|
||||||
document_metadata = {
|
|
||||||
"message_id": message_id,
|
|
||||||
"subject": subject,
|
|
||||||
"sender": sender,
|
|
||||||
"document_type": "Gmail Message (Composio)",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
markdown_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
summary_content = (
|
|
||||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
|
||||||
)
|
|
||||||
summary_embedding = config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = await create_document_chunks(markdown_content)
|
|
||||||
|
|
||||||
existing_document.title = f"Gmail: {subject}"
|
|
||||||
existing_document.content = summary_content
|
|
||||||
existing_document.content_hash = content_hash
|
|
||||||
existing_document.embedding = summary_embedding
|
|
||||||
existing_document.document_metadata = {
|
|
||||||
"message_id": message_id,
|
|
||||||
"subject": subject,
|
|
||||||
"sender": sender,
|
|
||||||
"date": date_str,
|
|
||||||
"labels": label_ids,
|
|
||||||
"connector_id": connector_id,
|
|
||||||
"source": "composio",
|
|
||||||
}
|
|
||||||
existing_document.chunks = chunks
|
|
||||||
existing_document.updated_at = get_current_timestamp()
|
|
||||||
|
|
||||||
documents_indexed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create new document
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if user_llm:
|
|
||||||
document_metadata = {
|
|
||||||
"message_id": message_id,
|
|
||||||
"subject": subject,
|
|
||||||
"sender": sender,
|
|
||||||
"document_type": "Gmail Message (Composio)",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
markdown_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
summary_content = (
|
|
||||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
|
||||||
)
|
|
||||||
summary_embedding = config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = await create_document_chunks(markdown_content)
|
|
||||||
|
|
||||||
document = Document(
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
title=f"Gmail: {subject}",
|
|
||||||
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
|
||||||
document_metadata={
|
|
||||||
"message_id": message_id,
|
|
||||||
"subject": subject,
|
|
||||||
"sender": sender,
|
|
||||||
"date": date_str,
|
|
||||||
"labels": label_ids,
|
|
||||||
"connector_id": connector_id,
|
|
||||||
"toolkit_id": "gmail",
|
|
||||||
"source": "composio",
|
|
||||||
},
|
|
||||||
content=summary_content,
|
|
||||||
content_hash=content_hash,
|
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
|
||||||
embedding=summary_embedding,
|
|
||||||
chunks=chunks,
|
|
||||||
updated_at=get_current_timestamp(),
|
|
||||||
)
|
|
||||||
session.add(document)
|
|
||||||
documents_indexed += 1
|
|
||||||
|
|
||||||
if documents_indexed % 10 == 0:
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing Gmail message: {e!s}", exc_info=True)
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if documents_indexed > 0:
|
|
||||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await task_logger.log_task_success(
|
|
||||||
log_entry,
|
|
||||||
f"Successfully completed Gmail indexing via Composio for connector {connector_id}",
|
|
||||||
{
|
|
||||||
"documents_indexed": documents_indexed,
|
|
||||||
"documents_skipped": documents_skipped,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return documents_indexed, None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to index Gmail via Composio: {e!s}", exc_info=True)
|
|
||||||
return 0, f"Failed to index Gmail via Composio: {e!s}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _index_composio_google_calendar(
|
|
||||||
session: AsyncSession,
|
|
||||||
connector,
|
|
||||||
connector_id: int,
|
|
||||||
search_space_id: int,
|
|
||||||
user_id: str,
|
|
||||||
start_date: str | None,
|
|
||||||
end_date: str | None,
|
|
||||||
task_logger: TaskLoggingService,
|
|
||||||
log_entry,
|
|
||||||
update_last_indexed: bool = True,
|
|
||||||
max_items: int = 2500,
|
|
||||||
) -> tuple[int, str]:
|
|
||||||
"""Index Google Calendar events via Composio."""
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
try:
|
|
||||||
composio_connector = ComposioConnector(session, connector_id)
|
|
||||||
|
|
||||||
await task_logger.log_task_progress(
|
|
||||||
log_entry,
|
|
||||||
f"Fetching Google Calendar events via Composio for connector {connector_id}",
|
|
||||||
{"stage": "fetching_events"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build time range
|
|
||||||
if start_date:
|
|
||||||
time_min = f"{start_date}T00:00:00Z"
|
|
||||||
else:
|
|
||||||
# Default to 365 days ago
|
|
||||||
default_start = datetime.now() - timedelta(days=365)
|
|
||||||
time_min = default_start.strftime("%Y-%m-%dT00:00:00Z")
|
|
||||||
|
|
||||||
if end_date:
|
|
||||||
time_max = f"{end_date}T23:59:59Z"
|
|
||||||
else:
|
|
||||||
time_max = datetime.now().strftime("%Y-%m-%dT23:59:59Z")
|
|
||||||
|
|
||||||
events, error = await composio_connector.list_calendar_events(
|
|
||||||
time_min=time_min,
|
|
||||||
time_max=time_max,
|
|
||||||
max_results=max_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
if error:
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry, f"Failed to fetch Calendar events: {error}", {}
|
|
||||||
)
|
|
||||||
return 0, f"Failed to fetch Calendar events: {error}"
|
|
||||||
|
|
||||||
if not events:
|
|
||||||
success_msg = "No Google Calendar events found in the specified date range"
|
|
||||||
await task_logger.log_task_success(
|
|
||||||
log_entry, success_msg, {"events_count": 0}
|
|
||||||
)
|
|
||||||
return 0, success_msg
|
|
||||||
|
|
||||||
logger.info(f"Found {len(events)} Google Calendar events to index via Composio")
|
|
||||||
|
|
||||||
documents_indexed = 0
|
|
||||||
documents_skipped = 0
|
|
||||||
|
|
||||||
for event in events:
|
|
||||||
try:
|
|
||||||
# Handle both standard Google API and potential Composio variations
|
|
||||||
event_id = event.get("id", "") or event.get("eventId", "")
|
|
||||||
summary = (
|
|
||||||
event.get("summary", "") or event.get("title", "") or "No Title"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not event_id:
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Format to markdown
|
|
||||||
markdown_content = composio_connector.format_calendar_event_to_markdown(
|
|
||||||
event
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate unique identifier
|
|
||||||
unique_identifier_hash = generate_unique_identifier_hash(
|
|
||||||
DocumentType.COMPOSIO_CONNECTOR,
|
|
||||||
f"calendar_{event_id}",
|
|
||||||
search_space_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
|
||||||
|
|
||||||
existing_document = await check_document_by_unique_identifier(
|
|
||||||
session, unique_identifier_hash
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract event times
|
|
||||||
start = event.get("start", {})
|
|
||||||
end = event.get("end", {})
|
|
||||||
start_time = start.get("dateTime") or start.get("date", "")
|
|
||||||
end_time = end.get("dateTime") or end.get("date", "")
|
|
||||||
location = event.get("location", "")
|
|
||||||
|
|
||||||
if existing_document:
|
|
||||||
if existing_document.content_hash == content_hash:
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update existing
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if user_llm:
|
|
||||||
document_metadata = {
|
|
||||||
"event_id": event_id,
|
|
||||||
"summary": summary,
|
|
||||||
"start_time": start_time,
|
|
||||||
"document_type": "Google Calendar Event (Composio)",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
markdown_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
|
||||||
if location:
|
|
||||||
summary_content += f"\nLocation: {location}"
|
|
||||||
summary_embedding = config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = await create_document_chunks(markdown_content)
|
|
||||||
|
|
||||||
existing_document.title = f"Calendar: {summary}"
|
|
||||||
existing_document.content = summary_content
|
|
||||||
existing_document.content_hash = content_hash
|
|
||||||
existing_document.embedding = summary_embedding
|
|
||||||
existing_document.document_metadata = {
|
|
||||||
"event_id": event_id,
|
|
||||||
"summary": summary,
|
|
||||||
"start_time": start_time,
|
|
||||||
"end_time": end_time,
|
|
||||||
"location": location,
|
|
||||||
"connector_id": connector_id,
|
|
||||||
"source": "composio",
|
|
||||||
}
|
|
||||||
existing_document.chunks = chunks
|
|
||||||
existing_document.updated_at = get_current_timestamp()
|
|
||||||
|
|
||||||
documents_indexed += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Create new document
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if user_llm:
|
|
||||||
document_metadata = {
|
|
||||||
"event_id": event_id,
|
|
||||||
"summary": summary,
|
|
||||||
"start_time": start_time,
|
|
||||||
"document_type": "Google Calendar Event (Composio)",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
markdown_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
summary_content = (
|
|
||||||
f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
|
||||||
)
|
|
||||||
if location:
|
|
||||||
summary_content += f"\nLocation: {location}"
|
|
||||||
summary_embedding = config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
|
|
||||||
chunks = await create_document_chunks(markdown_content)
|
|
||||||
|
|
||||||
document = Document(
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
title=f"Calendar: {summary}",
|
|
||||||
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
|
||||||
document_metadata={
|
|
||||||
"event_id": event_id,
|
|
||||||
"summary": summary,
|
|
||||||
"start_time": start_time,
|
|
||||||
"end_time": end_time,
|
|
||||||
"location": location,
|
|
||||||
"connector_id": connector_id,
|
|
||||||
"toolkit_id": "googlecalendar",
|
|
||||||
"source": "composio",
|
|
||||||
},
|
|
||||||
content=summary_content,
|
|
||||||
content_hash=content_hash,
|
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
|
||||||
embedding=summary_embedding,
|
|
||||||
chunks=chunks,
|
|
||||||
updated_at=get_current_timestamp(),
|
|
||||||
)
|
|
||||||
session.add(document)
|
|
||||||
documents_indexed += 1
|
|
||||||
|
|
||||||
if documents_indexed % 10 == 0:
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error processing Calendar event: {e!s}", exc_info=True)
|
|
||||||
documents_skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if documents_indexed > 0:
|
|
||||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
await task_logger.log_task_success(
|
|
||||||
log_entry,
|
|
||||||
f"Successfully completed Google Calendar indexing via Composio for connector {connector_id}",
|
|
||||||
{
|
|
||||||
"documents_indexed": documents_indexed,
|
|
||||||
"documents_skipped": documents_skipped,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return documents_indexed, None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True
|
|
||||||
)
|
|
||||||
return 0, f"Failed to index Google Calendar via Composio: {e!s}"
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,13 @@ def calculate_date_range(
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (start_date_str, end_date_str)
|
Tuple of (start_date_str, end_date_str)
|
||||||
"""
|
"""
|
||||||
|
# Normalize "undefined" strings to None (from frontend)
|
||||||
|
# This prevents parsing errors and ensures consistent behavior across all indexers
|
||||||
|
if start_date == "undefined" or start_date == "":
|
||||||
|
start_date = None
|
||||||
|
if end_date == "undefined" or end_date == "":
|
||||||
|
end_date = None
|
||||||
|
|
||||||
if start_date is not None and end_date is not None:
|
if start_date is not None and end_date is not None:
|
||||||
return start_date, end_date
|
return start_date, end_date
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,10 +136,9 @@ async def index_bookstack_pages(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.error(f"Failed to get BookStack pages: {error}")
|
|
||||||
|
|
||||||
# Don't treat "No pages found" as an error that should stop indexing
|
# Don't treat "No pages found" as an error that should stop indexing
|
||||||
if "No pages found" in error:
|
if "No pages found" in error:
|
||||||
|
logger.info(f"No BookStack pages found: {error}")
|
||||||
logger.info(
|
logger.info(
|
||||||
"No pages found is not a critical error, continuing with update"
|
"No pages found is not a critical error, continuing with update"
|
||||||
)
|
)
|
||||||
|
|
@ -159,6 +158,7 @@ async def index_bookstack_pages(
|
||||||
)
|
)
|
||||||
return 0, None
|
return 0, None
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"Failed to get BookStack pages: {error}")
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Failed to get BookStack pages: {error}",
|
f"Failed to get BookStack pages: {error}",
|
||||||
|
|
|
||||||
|
|
@ -120,10 +120,9 @@ async def index_confluence_pages(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.error(f"Failed to get Confluence pages: {error}")
|
|
||||||
|
|
||||||
# Don't treat "No pages found" as an error that should stop indexing
|
# Don't treat "No pages found" as an error that should stop indexing
|
||||||
if "No pages found" in error:
|
if "No pages found" in error:
|
||||||
|
logger.info(f"No Confluence pages found: {error}")
|
||||||
logger.info(
|
logger.info(
|
||||||
"No pages found is not a critical error, continuing with update"
|
"No pages found is not a critical error, continuing with update"
|
||||||
)
|
)
|
||||||
|
|
@ -147,6 +146,7 @@ async def index_confluence_pages(
|
||||||
await confluence_client.close()
|
await confluence_client.close()
|
||||||
return 0, None
|
return 0, None
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"Failed to get Confluence pages: {error}")
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Failed to get Confluence pages: {error}",
|
f"Failed to get Confluence pages: {error}",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ Google Calendar connector indexer.
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from dateutil.parser import isoparse
|
||||||
from google.oauth2.credentials import Credentials
|
from google.oauth2.credentials import Credentials
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -21,6 +23,7 @@ from app.utils.document_converters import (
|
||||||
|
|
||||||
from .base import (
|
from .base import (
|
||||||
check_document_by_unique_identifier,
|
check_document_by_unique_identifier,
|
||||||
|
check_duplicate_document_by_hash,
|
||||||
get_connector_by_id,
|
get_connector_by_id,
|
||||||
get_current_timestamp,
|
get_current_timestamp,
|
||||||
logger,
|
logger,
|
||||||
|
|
@ -206,6 +209,23 @@ async def index_google_calendar_events(
|
||||||
start_date_str = start_date
|
start_date_str = start_date
|
||||||
end_date_str = end_date
|
end_date_str = end_date
|
||||||
|
|
||||||
|
# If start_date and end_date are the same, adjust end_date to be one day later
|
||||||
|
# to ensure valid date range (start_date must be strictly before end_date)
|
||||||
|
if start_date_str == end_date_str:
|
||||||
|
# Parse the date and add one day to ensure valid range
|
||||||
|
dt = isoparse(end_date_str)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=pytz.UTC)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(pytz.UTC)
|
||||||
|
# Add one day to end_date to make it strictly after start_date
|
||||||
|
dt_end = dt + timedelta(days=1)
|
||||||
|
end_date_str = dt_end.strftime("%Y-%m-%d")
|
||||||
|
logger.info(
|
||||||
|
f"Adjusted end_date from {end_date} to {end_date_str} "
|
||||||
|
f"to ensure valid date range (start_date must be strictly before end_date)"
|
||||||
|
)
|
||||||
|
|
||||||
await task_logger.log_task_progress(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Fetching Google Calendar events from {start_date_str} to {end_date_str}",
|
f"Fetching Google Calendar events from {start_date_str} to {end_date_str}",
|
||||||
|
|
@ -223,10 +243,9 @@ async def index_google_calendar_events(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.error(f"Failed to get Google Calendar events: {error}")
|
|
||||||
|
|
||||||
# Don't treat "No events found" as an error that should stop indexing
|
# Don't treat "No events found" as an error that should stop indexing
|
||||||
if "No events found" in error:
|
if "No events found" in error:
|
||||||
|
logger.info(f"No Google Calendar events found: {error}")
|
||||||
logger.info(
|
logger.info(
|
||||||
"No events found is not a critical error, continuing with update"
|
"No events found is not a critical error, continuing with update"
|
||||||
)
|
)
|
||||||
|
|
@ -246,13 +265,25 @@ async def index_google_calendar_events(
|
||||||
)
|
)
|
||||||
return 0, None
|
return 0, None
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"Failed to get Google Calendar events: {error}")
|
||||||
|
# Check if this is an authentication error that requires re-authentication
|
||||||
|
error_message = error
|
||||||
|
error_type = "APIError"
|
||||||
|
if (
|
||||||
|
"re-authenticate" in error.lower()
|
||||||
|
or "expired or been revoked" in error.lower()
|
||||||
|
or "authentication failed" in error.lower()
|
||||||
|
):
|
||||||
|
error_message = "Google Calendar authentication failed. Please re-authenticate."
|
||||||
|
error_type = "AuthenticationError"
|
||||||
|
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Failed to get Google Calendar events: {error}",
|
error_message,
|
||||||
"API Error",
|
error,
|
||||||
{"error_type": "APIError"},
|
{"error_type": error_type},
|
||||||
)
|
)
|
||||||
return 0, f"Failed to get Google Calendar events: {error}"
|
return 0, error_message
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(events)} events from Google Calendar API")
|
logger.info(f"Retrieved {len(events)} events from Google Calendar API")
|
||||||
|
|
||||||
|
|
@ -263,6 +294,9 @@ async def index_google_calendar_events(
|
||||||
documents_indexed = 0
|
documents_indexed = 0
|
||||||
documents_skipped = 0
|
documents_skipped = 0
|
||||||
skipped_events = []
|
skipped_events = []
|
||||||
|
duplicate_content_count = (
|
||||||
|
0 # Track events skipped due to duplicate content_hash
|
||||||
|
)
|
||||||
|
|
||||||
for event in events:
|
for event in events:
|
||||||
try:
|
try:
|
||||||
|
|
@ -383,6 +417,27 @@ async def index_google_calendar_events(
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Document doesn't exist by unique_identifier_hash
|
||||||
|
# Check if a document with the same content_hash exists (from another connector)
|
||||||
|
with session.no_autoflush:
|
||||||
|
duplicate_by_content = await check_duplicate_document_by_hash(
|
||||||
|
session, content_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
if duplicate_by_content:
|
||||||
|
# A document with the same content already exists (likely from Composio connector)
|
||||||
|
logger.info(
|
||||||
|
f"Event {event_summary} already indexed by another connector "
|
||||||
|
f"(existing document ID: {duplicate_by_content.id}, "
|
||||||
|
f"type: {duplicate_by_content.document_type}). Skipping to avoid duplicate content."
|
||||||
|
)
|
||||||
|
duplicate_content_count += 1
|
||||||
|
documents_skipped += 1
|
||||||
|
skipped_events.append(
|
||||||
|
f"{event_summary} (already indexed by another connector)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
# Document doesn't exist - create new one
|
# Document doesn't exist - create new one
|
||||||
# Generate summary with metadata
|
# Generate summary with metadata
|
||||||
user_llm = await get_user_long_context_llm(
|
user_llm = await get_user_long_context_llm(
|
||||||
|
|
@ -475,7 +530,28 @@ async def index_google_calendar_events(
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Final commit: Total {documents_indexed} Google Calendar events processed"
|
f"Final commit: Total {documents_indexed} Google Calendar events processed"
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# Handle any remaining integrity errors gracefully (race conditions, etc.)
|
||||||
|
if (
|
||||||
|
"duplicate key value violates unique constraint" in str(e).lower()
|
||||||
|
or "uniqueviolationerror" in str(e).lower()
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"Duplicate content_hash detected during final commit. "
|
||||||
|
f"This may occur if the same event was indexed by multiple connectors. "
|
||||||
|
f"Rolling back and continuing. Error: {e!s}"
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
# Don't fail the entire task - some documents may have been successfully indexed
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Build warning message if duplicates were found
|
||||||
|
warning_message = None
|
||||||
|
if duplicate_content_count > 0:
|
||||||
|
warning_message = f"{duplicate_content_count} skipped (duplicate)"
|
||||||
|
|
||||||
await task_logger.log_task_success(
|
await task_logger.log_task_success(
|
||||||
log_entry,
|
log_entry,
|
||||||
|
|
@ -484,14 +560,16 @@ async def index_google_calendar_events(
|
||||||
"events_processed": total_processed,
|
"events_processed": total_processed,
|
||||||
"documents_indexed": documents_indexed,
|
"documents_indexed": documents_indexed,
|
||||||
"documents_skipped": documents_skipped,
|
"documents_skipped": documents_skipped,
|
||||||
|
"duplicate_content_count": duplicate_content_count,
|
||||||
"skipped_events_count": len(skipped_events),
|
"skipped_events_count": len(skipped_events),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Google Calendar indexing completed: {documents_indexed} new events, {documents_skipped} skipped "
|
f"Google Calendar indexing completed: {documents_indexed} new events, {documents_skipped} skipped "
|
||||||
|
f"({duplicate_content_count} due to duplicate content from other connectors)"
|
||||||
)
|
)
|
||||||
return total_processed, None
|
return total_processed, warning_message
|
||||||
|
|
||||||
except SQLAlchemyError as db_error:
|
except SQLAlchemyError as db_error:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|
|
||||||
|
|
@ -578,7 +578,7 @@ async def _check_rename_only_update(
|
||||||
- (True, message): Only filename changed, document was updated
|
- (True, message): Only filename changed, document was updated
|
||||||
- (False, None): Content changed or new file, needs full processing
|
- (False, None): Content changed or new file, needs full processing
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import String, cast, select
|
||||||
from sqlalchemy.orm.attributes import flag_modified
|
from sqlalchemy.orm.attributes import flag_modified
|
||||||
|
|
||||||
from app.db import Document
|
from app.db import Document
|
||||||
|
|
@ -603,7 +603,8 @@ async def _check_rename_only_update(
|
||||||
select(Document).where(
|
select(Document).where(
|
||||||
Document.search_space_id == search_space_id,
|
Document.search_space_id == search_space_id,
|
||||||
Document.document_type == DocumentType.GOOGLE_DRIVE_FILE,
|
Document.document_type == DocumentType.GOOGLE_DRIVE_FILE,
|
||||||
Document.document_metadata["google_drive_file_id"].astext == file_id,
|
cast(Document.document_metadata["google_drive_file_id"], String)
|
||||||
|
== file_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_document = result.scalar_one_or_none()
|
existing_document = result.scalar_one_or_none()
|
||||||
|
|
@ -755,7 +756,7 @@ async def _remove_document(session: AsyncSession, file_id: str, search_space_id:
|
||||||
|
|
||||||
Handles both new (file_id-based) and legacy (filename-based) hash schemes.
|
Handles both new (file_id-based) and legacy (filename-based) hash schemes.
|
||||||
"""
|
"""
|
||||||
from sqlalchemy import select
|
from sqlalchemy import String, cast, select
|
||||||
|
|
||||||
from app.db import Document
|
from app.db import Document
|
||||||
|
|
||||||
|
|
@ -774,7 +775,8 @@ async def _remove_document(session: AsyncSession, file_id: str, search_space_id:
|
||||||
select(Document).where(
|
select(Document).where(
|
||||||
Document.search_space_id == search_space_id,
|
Document.search_space_id == search_space_id,
|
||||||
Document.document_type == DocumentType.GOOGLE_DRIVE_FILE,
|
Document.document_type == DocumentType.GOOGLE_DRIVE_FILE,
|
||||||
Document.document_metadata["google_drive_file_id"].astext == file_id,
|
cast(Document.document_metadata["google_drive_file_id"], String)
|
||||||
|
== file_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
existing_document = result.scalar_one_or_none()
|
existing_document = result.scalar_one_or_none()
|
||||||
|
|
|
||||||
|
|
@ -170,10 +170,21 @@ async def index_google_gmail_messages(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
|
# Check if this is an authentication error that requires re-authentication
|
||||||
|
error_message = error
|
||||||
|
error_type = "APIError"
|
||||||
|
if (
|
||||||
|
"re-authenticate" in error.lower()
|
||||||
|
or "expired or been revoked" in error.lower()
|
||||||
|
or "authentication failed" in error.lower()
|
||||||
|
):
|
||||||
|
error_message = "Gmail authentication failed. Please re-authenticate."
|
||||||
|
error_type = "AuthenticationError"
|
||||||
|
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry, f"Failed to fetch messages: {error}", {}
|
log_entry, error_message, error, {"error_type": error_type}
|
||||||
)
|
)
|
||||||
return 0, f"Failed to fetch Gmail messages: {error}"
|
return 0, error_message
|
||||||
|
|
||||||
if not messages:
|
if not messages:
|
||||||
success_msg = "No Google gmail messages found in the specified date range"
|
success_msg = "No Google gmail messages found in the specified date range"
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,9 @@ async def index_jira_issues(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.error(f"Failed to get Jira issues: {error}")
|
|
||||||
|
|
||||||
# Don't treat "No issues found" as an error that should stop indexing
|
# Don't treat "No issues found" as an error that should stop indexing
|
||||||
if "No issues found" in error:
|
if "No issues found" in error:
|
||||||
|
logger.info(f"No Jira issues found: {error}")
|
||||||
logger.info(
|
logger.info(
|
||||||
"No issues found is not a critical error, continuing with update"
|
"No issues found is not a critical error, continuing with update"
|
||||||
)
|
)
|
||||||
|
|
@ -149,6 +148,7 @@ async def index_jira_issues(
|
||||||
)
|
)
|
||||||
return 0, None
|
return 0, None
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"Failed to get Jira issues: {error}")
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Failed to get Jira issues: {error}",
|
f"Failed to get Jira issues: {error}",
|
||||||
|
|
|
||||||
|
|
@ -145,10 +145,9 @@ async def index_linear_issues(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.error(f"Failed to get Linear issues: {error}")
|
|
||||||
|
|
||||||
# Don't treat "No issues found" as an error that should stop indexing
|
# Don't treat "No issues found" as an error that should stop indexing
|
||||||
if "No issues found" in error:
|
if "No issues found" in error:
|
||||||
|
logger.info(f"No Linear issues found: {error}")
|
||||||
logger.info(
|
logger.info(
|
||||||
"No issues found is not a critical error, continuing with update"
|
"No issues found is not a critical error, continuing with update"
|
||||||
)
|
)
|
||||||
|
|
@ -162,6 +161,7 @@ async def index_linear_issues(
|
||||||
)
|
)
|
||||||
return 0, None
|
return 0, None
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"Failed to get Linear issues: {error}")
|
||||||
return 0, f"Failed to get Linear issues: {error}"
|
return 0, f"Failed to get Linear issues: {error}"
|
||||||
|
|
||||||
logger.info(f"Retrieved {len(issues)} issues from Linear API")
|
logger.info(f"Retrieved {len(issues)} issues from Linear API")
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,13 @@ async def index_luma_events(
|
||||||
|
|
||||||
luma_client = LumaConnector(api_key=api_key)
|
luma_client = LumaConnector(api_key=api_key)
|
||||||
|
|
||||||
|
# Handle 'undefined' string from frontend (treat as None)
|
||||||
|
# This prevents "time data 'undefined' does not match format" errors
|
||||||
|
if start_date == "undefined" or start_date == "":
|
||||||
|
start_date = None
|
||||||
|
if end_date == "undefined" or end_date == "":
|
||||||
|
end_date = None
|
||||||
|
|
||||||
# Calculate date range
|
# Calculate date range
|
||||||
# For calendar connectors, allow future dates to index upcoming events
|
# For calendar connectors, allow future dates to index upcoming events
|
||||||
if start_date is None or end_date is None:
|
if start_date is None or end_date is None:
|
||||||
|
|
@ -172,10 +179,9 @@ async def index_luma_events(
|
||||||
)
|
)
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
logger.error(f"Failed to get Luma events: {error}")
|
|
||||||
|
|
||||||
# Don't treat "No events found" as an error that should stop indexing
|
# Don't treat "No events found" as an error that should stop indexing
|
||||||
if "No events found" in error or "no events" in error.lower():
|
if "No events found" in error or "no events" in error.lower():
|
||||||
|
logger.info(f"No Luma events found: {error}")
|
||||||
logger.info(
|
logger.info(
|
||||||
"No events found is not a critical error, continuing with update"
|
"No events found is not a critical error, continuing with update"
|
||||||
)
|
)
|
||||||
|
|
@ -195,6 +201,7 @@ async def index_luma_events(
|
||||||
)
|
)
|
||||||
return 0, None
|
return 0, None
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"Failed to get Luma events: {error}")
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Failed to get Luma events: {error}",
|
f"Failed to get Luma events: {error}",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ BASE_NAME_FOR_TYPE = {
|
||||||
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence",
|
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence",
|
||||||
SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable",
|
SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable",
|
||||||
SearchSourceConnectorType.MCP_CONNECTOR: "Model Context Protocol (MCP)",
|
SearchSourceConnectorType.MCP_CONNECTOR: "Model Context Protocol (MCP)",
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: "Gmail",
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||||
|
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
description = "SurfSense Backend"
|
description = "SurfSense Backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
2
surfsense_backend/uv.lock
generated
2
surfsense_backend/uv.lock
generated
|
|
@ -6545,7 +6545,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.11"
|
version = "0.0.12"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
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.11",
|
"version": "0.0.12",
|
||||||
"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": {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { FooterNew } from "@/components/homepage/footer-new";
|
import { FooterNew } from "@/components/homepage/footer-new";
|
||||||
import { Navbar } from "@/components/homepage/navbar";
|
import { Navbar } from "@/components/homepage/navbar";
|
||||||
|
|
||||||
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
|
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
{children}
|
{children}
|
||||||
<FooterNew />
|
{!isAuthPage && <FooterNew />}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { AUTH_TYPE } from "@/lib/env-config";
|
import { AUTH_TYPE } from "@/lib/env-config";
|
||||||
import { ValidationError } from "@/lib/error";
|
import { ValidationError } from "@/lib/error";
|
||||||
|
|
@ -42,9 +43,6 @@ export function LocalLoginForm() {
|
||||||
// Track login attempt
|
// Track login attempt
|
||||||
trackLoginAttempt("local");
|
trackLoginAttempt("local");
|
||||||
|
|
||||||
// Show loading toast
|
|
||||||
const loadingToast = toast.loading(tCommon("loading"));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await login({
|
const data = await login({
|
||||||
username,
|
username,
|
||||||
|
|
@ -62,8 +60,7 @@ export function LocalLoginForm() {
|
||||||
|
|
||||||
// Success toast
|
// Success toast
|
||||||
toast.success(t("login_success"), {
|
toast.success(t("login_success"), {
|
||||||
id: loadingToast,
|
description: "Redirecting to dashboard",
|
||||||
description: "Redirecting to dashboard...",
|
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -76,7 +73,6 @@ export function LocalLoginForm() {
|
||||||
trackLoginFailure("local", err.message);
|
trackLoginFailure("local", err.message);
|
||||||
setError({ title: err.name, message: err.message });
|
setError({ title: err.name, message: err.message });
|
||||||
toast.error(err.name, {
|
toast.error(err.name, {
|
||||||
id: loadingToast,
|
|
||||||
description: err.message,
|
description: err.message,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
|
|
@ -106,7 +102,6 @@ export function LocalLoginForm() {
|
||||||
|
|
||||||
// Show error toast with conditional retry action
|
// Show error toast with conditional retry action
|
||||||
const toastOptions: any = {
|
const toastOptions: any = {
|
||||||
id: loadingToast,
|
|
||||||
description: errorDetails.description,
|
description: errorDetails.description,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
};
|
};
|
||||||
|
|
@ -244,9 +239,16 @@ export function LocalLoginForm() {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoggingIn}
|
disabled={isLoggingIn}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base"
|
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{isLoggingIn ? tCommon("loading") : t("sign_in")}
|
{isLoggingIn ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" className="text-white" />
|
||||||
|
<span>{t("signing_in")}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("sign_in")
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
import { Suspense, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { AUTH_TYPE } from "@/lib/env-config";
|
import { AUTH_TYPE } from "@/lib/env-config";
|
||||||
import { AmbientBackground } from "./AmbientBackground";
|
import { AmbientBackground } from "./AmbientBackground";
|
||||||
|
|
@ -66,7 +66,11 @@ function LoginContent() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show toast with conditional retry action
|
// Show toast with conditional retry action
|
||||||
const toastOptions: any = {
|
const toastOptions: {
|
||||||
|
description: string;
|
||||||
|
duration: number;
|
||||||
|
action?: { label: string; onClick: () => void };
|
||||||
|
} = {
|
||||||
description: errorDescription,
|
description: errorDescription,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
};
|
};
|
||||||
|
|
@ -95,20 +99,12 @@ function LoginContent() {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}, [searchParams, t, tCommon]);
|
}, [searchParams, t, tCommon]);
|
||||||
|
|
||||||
// Show loading state while determining auth type
|
// Use global loading screen for auth type determination - spinner animation won't reset
|
||||||
|
useGlobalLoadingEffect(isLoading, tCommon("loading"), "login");
|
||||||
|
|
||||||
|
// Show nothing while loading - the GlobalLoadingProvider handles the loading UI
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return null;
|
||||||
<div className="relative w-full overflow-hidden">
|
|
||||||
<AmbientBackground />
|
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
|
||||||
<Logo className="rounded-md" />
|
|
||||||
<div className="mt-8 flex items-center space-x-2">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">{tCommon("loading")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authType === "GOOGLE") {
|
if (authType === "GOOGLE") {
|
||||||
|
|
@ -189,23 +185,10 @@ function LoginContent() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading fallback for Suspense
|
|
||||||
const LoadingFallback = () => (
|
|
||||||
<div className="relative w-full overflow-hidden">
|
|
||||||
<AmbientBackground />
|
|
||||||
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
|
|
||||||
<Logo className="rounded-md" />
|
|
||||||
<div className="mt-8 flex items-center space-x-2">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={null}>
|
||||||
<LoginContent />
|
<LoginContent />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||||
import { AUTH_TYPE } from "@/lib/env-config";
|
import { AUTH_TYPE } from "@/lib/env-config";
|
||||||
import { AppError, ValidationError } from "@/lib/error";
|
import { AppError, ValidationError } from "@/lib/error";
|
||||||
|
|
@ -60,9 +61,6 @@ export default function RegisterPage() {
|
||||||
// Track registration attempt
|
// Track registration attempt
|
||||||
trackRegistrationAttempt();
|
trackRegistrationAttempt();
|
||||||
|
|
||||||
// Show loading toast
|
|
||||||
const loadingToast = toast.loading(t("creating_account"));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({
|
await register({
|
||||||
email,
|
email,
|
||||||
|
|
@ -77,7 +75,6 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
// Success toast
|
// Success toast
|
||||||
toast.success(t("register_success"), {
|
toast.success(t("register_success"), {
|
||||||
id: loadingToast,
|
|
||||||
description: t("redirecting_login"),
|
description: t("redirecting_login"),
|
||||||
duration: 2000,
|
duration: 2000,
|
||||||
});
|
});
|
||||||
|
|
@ -95,7 +92,6 @@ export default function RegisterPage() {
|
||||||
trackRegistrationFailure("Registration disabled");
|
trackRegistrationFailure("Registration disabled");
|
||||||
setError({ title: "Registration is disabled", message: friendlyMessage });
|
setError({ title: "Registration is disabled", message: friendlyMessage });
|
||||||
toast.error("Registration is disabled", {
|
toast.error("Registration is disabled", {
|
||||||
id: loadingToast,
|
|
||||||
description: friendlyMessage,
|
description: friendlyMessage,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
|
|
@ -109,7 +105,6 @@ export default function RegisterPage() {
|
||||||
trackRegistrationFailure(err.message);
|
trackRegistrationFailure(err.message);
|
||||||
setError({ title: err.name, message: err.message });
|
setError({ title: err.name, message: err.message });
|
||||||
toast.error(err.name, {
|
toast.error(err.name, {
|
||||||
id: loadingToast,
|
|
||||||
description: err.message,
|
description: err.message,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
});
|
});
|
||||||
|
|
@ -137,7 +132,6 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
// Show error toast with conditional retry action
|
// Show error toast with conditional retry action
|
||||||
const toastOptions: any = {
|
const toastOptions: any = {
|
||||||
id: loadingToast,
|
|
||||||
description: errorDetails.description,
|
description: errorDetails.description,
|
||||||
duration: 6000,
|
duration: 6000,
|
||||||
};
|
};
|
||||||
|
|
@ -295,9 +289,16 @@ export default function RegisterPage() {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isRegistering}
|
disabled={isRegistering}
|
||||||
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base"
|
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{isRegistering ? t("creating_account_btn") : t("register")}
|
{isRegistering ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" className="text-white" />
|
||||||
|
<span>{t("creating_account_btn")}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("register")
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|
|
||||||
14
surfsense_web/app/auth/callback/loading.tsx
Normal file
14
surfsense_web/app/auth/callback/loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
|
|
||||||
|
export default function AuthCallbackLoading() {
|
||||||
|
const t = useTranslations("auth");
|
||||||
|
|
||||||
|
// Use global loading - spinner animation won't reset when page transitions
|
||||||
|
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
|
||||||
|
|
||||||
|
// Return null - the GlobalLoadingProvider handles the loading UI
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,18 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import TokenHandler from "@/components/TokenHandler";
|
import TokenHandler from "@/components/TokenHandler";
|
||||||
|
|
||||||
export default function AuthCallbackPage() {
|
export default function AuthCallbackPage() {
|
||||||
|
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
|
||||||
|
// TokenHandler uses useGlobalLoadingEffect to show the loading screen
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-4">
|
<Suspense fallback={null}>
|
||||||
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center min-h-[200px]">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TokenHandler
|
<TokenHandler
|
||||||
redirectPath="/dashboard"
|
redirectPath="/dashboard"
|
||||||
tokenParamName="token"
|
tokenParamName="token"
|
||||||
storageKey="surfsense_bearer_token"
|
storageKey="surfsense_bearer_token"
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
@ -19,6 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||||
import { LayoutDataProvider } from "@/components/layout";
|
import { LayoutDataProvider } from "@/components/layout";
|
||||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
|
|
||||||
export function DashboardClientLayout({
|
export function DashboardClientLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -146,31 +146,22 @@ export function DashboardClientLayout({
|
||||||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||||
|
|
||||||
if (
|
// Determine if we should show loading
|
||||||
|
const shouldShowLoading =
|
||||||
(!hasCheckedOnboarding &&
|
(!hasCheckedOnboarding &&
|
||||||
(loading || accessLoading || globalConfigsLoading) &&
|
(loading || accessLoading || globalConfigsLoading) &&
|
||||||
!isOnboardingPage) ||
|
!isOnboardingPage) ||
|
||||||
isAutoConfiguring
|
isAutoConfiguring;
|
||||||
) {
|
|
||||||
return (
|
// Use global loading screen - spinner animation won't reset
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
useGlobalLoadingEffect(
|
||||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
shouldShowLoading,
|
||||||
<CardHeader className="pb-2">
|
isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"),
|
||||||
<CardTitle className="text-xl font-medium">
|
"default"
|
||||||
{isAutoConfiguring ? "Setting up AI..." : t("loading_config")}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{isAutoConfiguring
|
|
||||||
? "Auto-configuring with available settings"
|
|
||||||
: t("checking_llm_prefs")}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex justify-center py-6">
|
|
||||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (shouldShowLoading) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronDown, ChevronUp, FileX, Loader2, Plus } from "lucide-react";
|
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
@ -9,6 +9,7 @@ import { useDocumentUploadDialog } from "@/components/assistant-ui/document-uplo
|
||||||
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";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -114,7 +115,7 @@ export function DocumentsTableShell({
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-[400px] w-full items-center justify-center">
|
<div className="flex h-[400px] w-full items-center justify-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
<Spinner size="lg" className="text-primary" />
|
||||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ export function RowActions({
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
>
|
>
|
||||||
{isDeleting ? "Deleting..." : "Delete"}
|
{isDeleting ? "Deleting" : "Delete"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
|
import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
@ -21,6 +20,7 @@ import {
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||||
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
|
@ -78,7 +78,6 @@ function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string
|
||||||
export default function EditorPage() {
|
export default function EditorPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const documentId = params.documentId as string;
|
const documentId = params.documentId as string;
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const isNewNote = documentId === "new";
|
const isNewNote = documentId === "new";
|
||||||
|
|
@ -349,8 +348,8 @@ export default function EditorPage() {
|
||||||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||||
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" />
|
<Spinner size="xl" className="text-primary mb-4" />
|
||||||
<p className="text-muted-foreground">Loading editor...</p>
|
<p className="text-muted-foreground">Loading editor</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -437,7 +436,7 @@ export default function EditorPage() {
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
|
<Spinner size="sm" className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
|
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,210 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||||
|
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||||
|
import {
|
||||||
|
trackIncentiveContactOpened,
|
||||||
|
trackIncentivePageViewed,
|
||||||
|
trackIncentiveTaskClicked,
|
||||||
|
trackIncentiveTaskCompleted,
|
||||||
|
} from "@/lib/posthog/events";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function MorePagesPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Track page view on mount
|
||||||
|
useEffect(() => {
|
||||||
|
trackIncentivePageViewed();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch tasks from API
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["incentive-tasks"],
|
||||||
|
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutation to complete a task
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: incentiveTasksApiService.completeTask,
|
||||||
|
onSuccess: (response, taskType) => {
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(response.message);
|
||||||
|
// Track task completion
|
||||||
|
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||||
|
if (task) {
|
||||||
|
trackIncentiveTaskCompleted(taskType, task.pages_reward);
|
||||||
|
}
|
||||||
|
// Invalidate queries to refresh data
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error("Failed to complete task. Please try again.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTaskClick = (task: IncentiveTaskInfo) => {
|
||||||
|
if (!task.completed) {
|
||||||
|
trackIncentiveTaskClicked(task.task_type);
|
||||||
|
completeMutation.mutate(task.task_type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className="w-full max-w-md"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||||
|
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Complete tasks to earn additional pages</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tasks */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
|
<Skeleton className="h-9 w-9 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-3 w-1/4" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data?.tasks.map((task) => (
|
||||||
|
<Card
|
||||||
|
key={task.task_type}
|
||||||
|
className={cn("transition-colors", task.completed && "bg-muted/50")}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||||
|
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-sm font-medium",
|
||||||
|
task.completed && "text-muted-foreground line-through"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={task.completed ? "ghost" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
disabled={task.completed || completeMutation.isPending}
|
||||||
|
onClick={() => handleTaskClick(task)}
|
||||||
|
asChild={!task.completed}
|
||||||
|
>
|
||||||
|
{task.completed ? (
|
||||||
|
<span>Done</span>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={task.action_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
{completeMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Go
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<Separator className="my-6" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground">
|
||||||
|
{allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"}
|
||||||
|
</p>
|
||||||
|
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
Contact Us
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Contact Us</DialogTitle>
|
||||||
|
<DialogDescription>Schedule a meeting or send us an email.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
<Link
|
||||||
|
href="https://calendly.com/eric-surfsense/surfsense-meeting"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
<IconCalendar className="h-4 w-4" />
|
||||||
|
Schedule a Meeting
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<span className="h-px w-8 bg-border" />
|
||||||
|
<span className="text-xs">or</span>
|
||||||
|
<span className="h-px w-8 bg-border" />
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="mailto:eric@surfsense.com"
|
||||||
|
className="flex items-center gap-2 text-sm text-muted-foreground transition hover:text-foreground"
|
||||||
|
>
|
||||||
|
<IconMailFilled className="h-4 w-4" />
|
||||||
|
eric@surfsense.com
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue, useSetAtom } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useParams, useSearchParams } from "next/navigation";
|
import { useParams, useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
@ -34,6 +35,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||||
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||||
|
|
@ -132,6 +134,7 @@ interface ThinkingStepData {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewChatPage() {
|
export default function NewChatPage() {
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
|
|
@ -1379,8 +1382,9 @@ export default function NewChatPage() {
|
||||||
// Show loading state only when loading an existing thread
|
// Show loading state only when loading an existing thread
|
||||||
if (isInitializing) {
|
if (isInitializing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-64px)] items-center justify-center">
|
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
|
||||||
<div className="text-muted-foreground">Loading chat...</div>
|
<Spinner size="lg" />
|
||||||
|
<div className="text-sm text-muted-foreground">{t("loading_chat")}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
@ -17,6 +16,7 @@ import {
|
||||||
import { Logo } from "@/components/Logo";
|
import { Logo } from "@/components/Logo";
|
||||||
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
|
|
||||||
export default function OnboardPage() {
|
export default function OnboardPage() {
|
||||||
|
|
@ -156,7 +156,7 @@ export default function OnboardPage() {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" />
|
<div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" />
|
||||||
<div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25">
|
<div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25">
|
||||||
<Loader2 className="h-12 w-12 text-white animate-spin" />
|
<Spinner size="xl" className="text-white" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Bot,
|
Bot,
|
||||||
Brain,
|
Brain,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
FileText,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Menu,
|
Menu,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
|
@ -15,6 +16,7 @@ import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||||
|
|
@ -30,6 +32,12 @@ interface SettingsNavItem {
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingsNavItems: SettingsNavItem[] = [
|
const settingsNavItems: SettingsNavItem[] = [
|
||||||
|
{
|
||||||
|
id: "general",
|
||||||
|
labelKey: "nav_general",
|
||||||
|
descriptionKey: "nav_general_desc",
|
||||||
|
icon: FileText,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "models",
|
id: "models",
|
||||||
labelKey: "nav_agent_configs",
|
labelKey: "nav_agent_configs",
|
||||||
|
|
@ -262,6 +270,9 @@ function SettingsContent({
|
||||||
ease: [0.4, 0, 0.2, 1],
|
ease: [0.4, 0, 0.2, 1],
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{activeSection === "general" && (
|
||||||
|
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
|
||||||
|
)}
|
||||||
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
||||||
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
||||||
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
||||||
|
|
@ -277,7 +288,7 @@ export default function SettingsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const searchSpaceId = Number(params.search_space_id);
|
const searchSpaceId = Number(params.search_space_id);
|
||||||
const [activeSection, setActiveSection] = useState("models");
|
const [activeSection, setActiveSection] = useState("general");
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Track settings section view
|
// Track settings section view
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import {
|
||||||
Hash,
|
Hash,
|
||||||
Link2,
|
Link2,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
Loader2,
|
|
||||||
Logs,
|
Logs,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
|
|
@ -96,6 +95,7 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -105,7 +105,6 @@ import {
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import type {
|
import type {
|
||||||
CreateInviteRequest,
|
CreateInviteRequest,
|
||||||
DeleteInviteRequest,
|
DeleteInviteRequest,
|
||||||
|
|
@ -122,6 +121,7 @@ import type {
|
||||||
Role,
|
Role,
|
||||||
UpdateRoleRequest,
|
UpdateRoleRequest,
|
||||||
} from "@/contracts/types/roles.types";
|
} from "@/contracts/types/roles.types";
|
||||||
|
import type { PermissionInfo } from "@/contracts/types/permissions.types";
|
||||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
import { rolesApiService } from "@/lib/apis/roles-api.service";
|
||||||
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
|
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
|
||||||
|
|
@ -321,7 +321,7 @@ export default function TeamManagementPage() {
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
className="flex flex-col items-center gap-4"
|
className="flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<Loader2 className="h-10 w-10 text-primary animate-spin" />
|
<Spinner size="lg" className="text-primary" />
|
||||||
<p className="text-muted-foreground">Loading team data...</p>
|
<p className="text-muted-foreground">Loading team data...</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -471,13 +471,6 @@ export default function TeamManagementPage() {
|
||||||
className="w-full md:w-auto"
|
className="w-full md:w-auto"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === "roles" && hasPermission("roles:create") && (
|
|
||||||
<CreateRoleDialog
|
|
||||||
groupedPermissions={groupedPermissions}
|
|
||||||
onCreateRole={handleCreateRole}
|
|
||||||
className="w-full md:w-auto"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TabsContent value="members" className="space-y-4">
|
<TabsContent value="members" className="space-y-4">
|
||||||
|
|
@ -499,8 +492,10 @@ export default function TeamManagementPage() {
|
||||||
loading={rolesLoading}
|
loading={rolesLoading}
|
||||||
onUpdateRole={handleUpdateRole}
|
onUpdateRole={handleUpdateRole}
|
||||||
onDeleteRole={handleDeleteRole}
|
onDeleteRole={handleDeleteRole}
|
||||||
|
onCreateRole={handleCreateRole}
|
||||||
canUpdate={hasPermission("roles:update")}
|
canUpdate={hasPermission("roles:update")}
|
||||||
canDelete={hasPermission("roles:delete")}
|
canDelete={hasPermission("roles:delete")}
|
||||||
|
canCreate={hasPermission("roles:create")}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -571,7 +566,7 @@ function MembersTab({
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
<Spinner size="md" className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -767,17 +762,71 @@ function MembersTab({
|
||||||
|
|
||||||
// ============ Role Permissions Display ============
|
// ============ Role Permissions Display ============
|
||||||
|
|
||||||
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = {
|
// Unified category configuration used across all role-related components
|
||||||
documents: { label: "Documents", icon: FileText, order: 1 },
|
const CATEGORY_CONFIG: Record<
|
||||||
chats: { label: "Chats", icon: MessageSquare, order: 2 },
|
string,
|
||||||
comments: { label: "Comments", icon: MessageCircle, order: 3 },
|
{ label: string; icon: LucideIcon; description: string; order: number }
|
||||||
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
|
> = {
|
||||||
podcasts: { label: "Podcasts", icon: Mic, order: 5 },
|
documents: {
|
||||||
connectors: { label: "Connectors", icon: Plug, order: 6 },
|
label: "Documents",
|
||||||
logs: { label: "Logs", icon: Logs, order: 7 },
|
icon: FileText,
|
||||||
members: { label: "Members", icon: Users, order: 8 },
|
description: "Manage files, notes, and content",
|
||||||
roles: { label: "Roles", icon: Shield, order: 9 },
|
order: 1,
|
||||||
settings: { label: "Settings", icon: Settings, order: 10 },
|
},
|
||||||
|
chats: {
|
||||||
|
label: "AI Chats",
|
||||||
|
icon: MessageSquare,
|
||||||
|
description: "Create and manage AI conversations",
|
||||||
|
order: 2,
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
label: "Comments",
|
||||||
|
icon: MessageCircle,
|
||||||
|
description: "Add annotations to documents",
|
||||||
|
order: 3,
|
||||||
|
},
|
||||||
|
llm_configs: {
|
||||||
|
label: "AI Models",
|
||||||
|
icon: Bot,
|
||||||
|
description: "Configure AI model settings",
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
podcasts: {
|
||||||
|
label: "Podcasts",
|
||||||
|
icon: Mic,
|
||||||
|
description: "Generate AI podcasts from content",
|
||||||
|
order: 5,
|
||||||
|
},
|
||||||
|
connectors: {
|
||||||
|
label: "Integrations",
|
||||||
|
icon: Plug,
|
||||||
|
description: "Connect external data sources",
|
||||||
|
order: 6,
|
||||||
|
},
|
||||||
|
logs: {
|
||||||
|
label: "Activity Logs",
|
||||||
|
icon: Logs,
|
||||||
|
description: "View and manage audit trail",
|
||||||
|
order: 7,
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
label: "Team Members",
|
||||||
|
icon: Users,
|
||||||
|
description: "Manage team membership",
|
||||||
|
order: 8,
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
label: "Roles",
|
||||||
|
icon: Shield,
|
||||||
|
description: "Configure role permissions",
|
||||||
|
order: 9,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
label: "Settings",
|
||||||
|
icon: Settings,
|
||||||
|
description: "Manage search space settings",
|
||||||
|
order: 10,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const ACTION_LABELS: Record<string, string> = {
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
|
@ -893,25 +942,31 @@ function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
|
||||||
|
|
||||||
function RolesTab({
|
function RolesTab({
|
||||||
roles,
|
roles,
|
||||||
groupedPermissions: _groupedPermissions,
|
groupedPermissions,
|
||||||
loading,
|
loading,
|
||||||
onUpdateRole: _onUpdateRole,
|
onUpdateRole: _onUpdateRole,
|
||||||
onDeleteRole,
|
onDeleteRole,
|
||||||
|
onCreateRole,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
canCreate,
|
||||||
}: {
|
}: {
|
||||||
roles: Role[];
|
roles: Role[];
|
||||||
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
|
groupedPermissions: Record<string, PermissionWithDescription[]>;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
|
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
|
||||||
onDeleteRole: (roleId: number) => Promise<boolean>;
|
onDeleteRole: (roleId: number) => Promise<boolean>;
|
||||||
|
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
||||||
canUpdate: boolean;
|
canUpdate: boolean;
|
||||||
canDelete: boolean;
|
canDelete: boolean;
|
||||||
|
canCreate: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const [showCreateRole, setShowCreateRole] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
<Spinner size="md" className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -921,8 +976,33 @@ function RolesTab({
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
|
{/* Create Role Button / Section */}
|
||||||
|
{canCreate && !showCreateRole && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex justify-end"
|
||||||
|
>
|
||||||
|
<Button onClick={() => setShowCreateRole(true)} className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Create Custom Role
|
||||||
|
</Button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Role Form */}
|
||||||
|
{showCreateRole && (
|
||||||
|
<CreateRoleSection
|
||||||
|
groupedPermissions={groupedPermissions}
|
||||||
|
onCreateRole={onCreateRole}
|
||||||
|
onCancel={() => setShowCreateRole(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Roles Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{roles.map((role, index) => (
|
{roles.map((role, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={role.id}
|
key={role.id}
|
||||||
|
|
@ -1007,8 +1087,8 @@ function RolesTab({
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete role?</AlertDialogTitle>
|
<AlertDialogTitle>Delete role?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will permanently delete the "{role.name}" role. Members with
|
This will permanently delete the "{role.name}" role. Members
|
||||||
this role will lose their permissions.
|
with this role will lose their permissions.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
@ -1038,6 +1118,7 @@ function RolesTab({
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1068,7 +1149,7 @@ function InvitesTab({
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 text-primary animate-spin" />
|
<Spinner size="md" className="text-primary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1446,7 +1527,7 @@ function CreateInviteDialog({
|
||||||
<Button onClick={handleCreate} disabled={creating}>
|
<Button onClick={handleCreate} disabled={creating}>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Creating
|
Creating
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -1461,13 +1542,14 @@ function CreateInviteDialog({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Create Role Dialog ============
|
// ============ Create Role Section ============
|
||||||
|
|
||||||
// Preset permission sets for quick role creation
|
// Preset permission sets for quick role creation
|
||||||
// Editor: can create/read/update content, but cannot manage roles, remove members, or change settings
|
const ROLE_PRESETS = {
|
||||||
// Viewer: read-only access with ability to create comments
|
editor: {
|
||||||
const PRESET_PERMISSIONS = {
|
name: "Editor",
|
||||||
editor: [
|
description: "Can create, read, and update content, but cannot delete or manage team settings",
|
||||||
|
permissions: [
|
||||||
"documents:create",
|
"documents:create",
|
||||||
"documents:read",
|
"documents:read",
|
||||||
"documents:update",
|
"documents:update",
|
||||||
|
|
@ -1491,7 +1573,11 @@ const PRESET_PERMISSIONS = {
|
||||||
"roles:read",
|
"roles:read",
|
||||||
"settings:view",
|
"settings:view",
|
||||||
],
|
],
|
||||||
viewer: [
|
},
|
||||||
|
viewer: {
|
||||||
|
name: "Viewer",
|
||||||
|
description: "Read-only access with ability to add comments",
|
||||||
|
permissions: [
|
||||||
"documents:read",
|
"documents:read",
|
||||||
"chats:read",
|
"chats:read",
|
||||||
"comments:create",
|
"comments:create",
|
||||||
|
|
@ -1504,23 +1590,68 @@ const PRESET_PERMISSIONS = {
|
||||||
"roles:read",
|
"roles:read",
|
||||||
"settings:view",
|
"settings:view",
|
||||||
],
|
],
|
||||||
|
},
|
||||||
|
contributor: {
|
||||||
|
name: "Contributor",
|
||||||
|
description: "Can add and manage their own content",
|
||||||
|
permissions: [
|
||||||
|
"documents:create",
|
||||||
|
"documents:read",
|
||||||
|
"documents:update",
|
||||||
|
"chats:create",
|
||||||
|
"chats:read",
|
||||||
|
"comments:create",
|
||||||
|
"comments:read",
|
||||||
|
"llm_configs:read",
|
||||||
|
"podcasts:read",
|
||||||
|
"connectors:read",
|
||||||
|
"logs:read",
|
||||||
|
"members:view",
|
||||||
|
"roles:read",
|
||||||
|
"settings:view",
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function CreateRoleDialog({
|
// Action display labels
|
||||||
|
const ACTION_DISPLAY: Record<string, { label: string; color: string }> = {
|
||||||
|
create: { label: "Create", color: "text-emerald-600 bg-emerald-500/10" },
|
||||||
|
read: { label: "View", color: "text-blue-600 bg-blue-500/10" },
|
||||||
|
update: { label: "Edit", color: "text-amber-600 bg-amber-500/10" },
|
||||||
|
delete: { label: "Delete", color: "text-red-600 bg-red-500/10" },
|
||||||
|
invite: { label: "Invite", color: "text-violet-600 bg-violet-500/10" },
|
||||||
|
view: { label: "View", color: "text-blue-600 bg-blue-500/10" },
|
||||||
|
remove: { label: "Remove", color: "text-red-600 bg-red-500/10" },
|
||||||
|
manage_roles: { label: "Manage Roles", color: "text-violet-600 bg-violet-500/10" },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use the imported PermissionInfo type which now includes description
|
||||||
|
type PermissionWithDescription = PermissionInfo;
|
||||||
|
|
||||||
|
function CreateRoleSection({
|
||||||
groupedPermissions,
|
groupedPermissions,
|
||||||
onCreateRole,
|
onCreateRole,
|
||||||
className,
|
onCancel,
|
||||||
}: {
|
}: {
|
||||||
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>;
|
groupedPermissions: Record<string, PermissionWithDescription[]>;
|
||||||
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
|
||||||
className?: string;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
||||||
const [isDefault, setIsDefault] = useState(false);
|
const [isDefault, setIsDefault] = useState(false);
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Sort categories by order
|
||||||
|
const sortedCategories = useMemo(() => {
|
||||||
|
return Object.keys(groupedPermissions).sort((a, b) => {
|
||||||
|
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
|
||||||
|
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
}, [groupedPermissions]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
|
|
@ -1536,11 +1667,7 @@ function CreateRoleDialog({
|
||||||
permissions: selectedPermissions,
|
permissions: selectedPermissions,
|
||||||
is_default: isDefault,
|
is_default: isDefault,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
onCancel();
|
||||||
setName("");
|
|
||||||
setDescription("");
|
|
||||||
setSelectedPermissions([]);
|
|
||||||
setIsDefault(false);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create role:", error);
|
console.error("Failed to create role:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1548,13 +1675,14 @@ function CreateRoleDialog({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePermission = (perm: string) => {
|
const togglePermission = useCallback((perm: string) => {
|
||||||
setSelectedPermissions((prev) =>
|
setSelectedPermissions((prev) =>
|
||||||
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
|
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
|
||||||
);
|
);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const toggleCategory = (category: string) => {
|
const toggleCategory = useCallback(
|
||||||
|
(category: string) => {
|
||||||
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
|
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
|
||||||
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
|
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
|
||||||
|
|
||||||
|
|
@ -1563,151 +1691,342 @@ function CreateRoleDialog({
|
||||||
} else {
|
} else {
|
||||||
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
|
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[groupedPermissions, selectedPermissions]
|
||||||
|
);
|
||||||
|
|
||||||
const applyPreset = (preset: "editor" | "viewer") => {
|
const toggleCategoryExpanded = useCallback((category: string) => {
|
||||||
setSelectedPermissions(PRESET_PERMISSIONS[preset]);
|
setExpandedCategories((prev) =>
|
||||||
toast.success(`Applied ${preset === "editor" ? "Editor" : "Viewer"} preset permissions`);
|
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
|
||||||
};
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyPreset = useCallback((presetKey: keyof typeof ROLE_PRESETS) => {
|
||||||
|
const preset = ROLE_PRESETS[presetKey];
|
||||||
|
setSelectedPermissions(preset.permissions);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setName(preset.name);
|
||||||
|
setDescription(preset.description);
|
||||||
|
}
|
||||||
|
toast.success(`Applied ${preset.name} preset`);
|
||||||
|
}, [name]);
|
||||||
|
|
||||||
|
const getCategoryStats = useCallback(
|
||||||
|
(category: string) => {
|
||||||
|
const perms = groupedPermissions[category] || [];
|
||||||
|
const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length;
|
||||||
|
return { selected, total: perms.length, allSelected: selected === perms.length };
|
||||||
|
},
|
||||||
|
[groupedPermissions, selectedPermissions]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<motion.div
|
||||||
<DialogTrigger asChild>
|
initial={{ opacity: 0, y: -10 }}
|
||||||
<Button className={cn("gap-2", className)}>
|
animate={{ opacity: 1, y: 0 }}
|
||||||
<Plus className="h-4 w-4" />
|
exit={{ opacity: 0, y: -10 }}
|
||||||
Create Role
|
className="mb-6"
|
||||||
|
>
|
||||||
|
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||||
|
<Plus className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">Create Custom Role</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Define permissions for a new role in this search space
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={onCancel}>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</div>
|
||||||
<DialogContent className="w-[92vw] max-w-[92vw] sm:max-w-xl p-4 md:p-6">
|
</CardHeader>
|
||||||
<DialogHeader>
|
<CardContent className="space-y-6">
|
||||||
<DialogTitle>Create Custom Role</DialogTitle>
|
{/* Quick Start with Presets */}
|
||||||
<DialogDescription className="text-xs md:text-sm">
|
<div className="space-y-3">
|
||||||
Define a new role with specific permissions for this search space.
|
<Label className="text-sm font-medium">Quick Start with a Template</Label>
|
||||||
</DialogDescription>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||||
</DialogHeader>
|
{Object.entries(ROLE_PRESETS).map(([key, preset]) => (
|
||||||
<div className="space-y-3 py-2 md:py-4">
|
<button
|
||||||
<div className="flex flex-col md:grid md:grid-cols-2 gap-3 md:gap-4">
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(key as keyof typeof ROLE_PRESETS)}
|
||||||
|
className={cn(
|
||||||
|
"p-4 rounded-lg border-2 text-left transition-all hover:border-primary/50 hover:bg-primary/5",
|
||||||
|
selectedPermissions.length > 0 &&
|
||||||
|
preset.permissions.every((p) => selectedPermissions.includes(p))
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<ShieldCheck
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
key === "editor" && "text-blue-600",
|
||||||
|
key === "viewer" && "text-gray-600",
|
||||||
|
key === "contributor" && "text-emerald-600"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-sm">{preset.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{preset.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Role Details */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="role-name">Role Name *</Label>
|
<Label htmlFor="role-name">Role Name *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="role-name"
|
id="role-name"
|
||||||
placeholder="e.g., Contributor"
|
placeholder="e.g., Content Manager"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2">
|
<Label htmlFor="role-description">Description</Label>
|
||||||
<Checkbox checked={isDefault} onCheckedChange={(v) => setIsDefault(!!v)} />
|
<Input
|
||||||
|
id="role-description"
|
||||||
|
placeholder="Brief description of this role"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Default Role Checkbox */}
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
|
||||||
|
<Checkbox
|
||||||
|
id="is-default"
|
||||||
|
checked={isDefault}
|
||||||
|
onCheckedChange={(checked) => setIsDefault(checked === true)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="is-default" className="cursor-pointer font-medium">
|
||||||
Set as default role
|
Set as default role
|
||||||
</Label>
|
</Label>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
New invites without a role will use this
|
New members without a specific role will be assigned this role
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="role-description">Description</Label>
|
{/* Permissions Section */}
|
||||||
<Textarea
|
<div className="space-y-3">
|
||||||
id="role-description"
|
|
||||||
placeholder="Describe what this role can do..."
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Permissions ({selectedPermissions.length} selected)</Label>
|
<Label className="text-sm font-medium">
|
||||||
<div className="flex gap-2">
|
Permissions ({selectedPermissions.length} selected)
|
||||||
|
</Label>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs gap-1"
|
className="text-xs h-7"
|
||||||
onClick={() => applyPreset("editor")}
|
onClick={() =>
|
||||||
|
setExpandedCategories(
|
||||||
|
expandedCategories.length === sortedCategories.length ? [] : sortedCategories
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ShieldCheck className="h-3 w-3 text-blue-600" />
|
{expandedCategories.length === sortedCategories.length
|
||||||
Editor Preset
|
? "Collapse All"
|
||||||
</Button>
|
: "Expand All"}
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs gap-1"
|
|
||||||
onClick={() => applyPreset("viewer")}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="h-3 w-3 text-gray-600" />
|
|
||||||
Viewer Preset
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<div className="space-y-2">
|
||||||
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only)
|
{sortedCategories.map((category) => {
|
||||||
permissions
|
const config = CATEGORY_CONFIG[category] || {
|
||||||
</p>
|
label: category,
|
||||||
<ScrollArea className="h-64 rounded-lg border p-4">
|
icon: FileText,
|
||||||
<div className="space-y-4">
|
description: "",
|
||||||
{Object.entries(groupedPermissions).map(([category, perms]) => {
|
order: 99,
|
||||||
const categorySelected = perms.filter((p) =>
|
};
|
||||||
selectedPermissions.includes(p.value)
|
const IconComponent = config.icon;
|
||||||
).length;
|
const stats = getCategoryStats(category);
|
||||||
const allSelected = categorySelected === perms.length;
|
const isExpanded = expandedCategories.includes(category);
|
||||||
|
const perms = groupedPermissions[category] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category} className="space-y-2">
|
<div
|
||||||
<button
|
key={category}
|
||||||
type="button"
|
className="rounded-lg border bg-card overflow-hidden"
|
||||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
|
|
||||||
onClick={() => toggleCategory(category)}
|
|
||||||
>
|
>
|
||||||
|
{/* Category Header */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors",
|
||||||
|
stats.allSelected && "bg-primary/5"
|
||||||
|
)}
|
||||||
|
onClick={() => toggleCategoryExpanded(category)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleCategoryExpanded(category);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||||
|
stats.selected > 0 ? "bg-primary/10" : "bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<IconComponent
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
stats.selected > 0 ? "text-primary" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-sm">{config.label}</span>
|
||||||
|
<Badge
|
||||||
|
variant={stats.selected > 0 ? "default" : "secondary"}
|
||||||
|
className="text-xs h-5"
|
||||||
|
>
|
||||||
|
{stats.selected}/{stats.total}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground hidden md:block">
|
||||||
|
{config.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelected}
|
checked={stats.allSelected}
|
||||||
onCheckedChange={() => toggleCategory(category)}
|
onCheckedChange={() => toggleCategory(category)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
aria-label={`Select all ${config.label} permissions`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm font-medium capitalize">
|
<motion.div
|
||||||
{category} ({categorySelected}/{perms.length})
|
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||||
</span>
|
transition={{ duration: 0.2 }}
|
||||||
</button>
|
|
||||||
<div className="grid grid-cols-2 gap-2 ml-6">
|
|
||||||
{perms.map((perm) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={perm.value}
|
|
||||||
className="flex items-center gap-2 cursor-pointer text-left"
|
|
||||||
onClick={() => togglePermission(perm.value)}
|
|
||||||
>
|
>
|
||||||
<Checkbox
|
<svg
|
||||||
checked={selectedPermissions.includes(perm.value)}
|
className="h-4 w-4 text-muted-foreground"
|
||||||
onCheckedChange={() => togglePermission(perm.value)}
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">{perm.value.split(":")[1]}</span>
|
</svg>
|
||||||
</button>
|
</motion.div>
|
||||||
))}
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Permissions List */}
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: "auto", opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="border-t"
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-1">
|
||||||
|
{perms.map((perm) => {
|
||||||
|
const action = perm.value.split(":")[1];
|
||||||
|
const actionConfig = ACTION_DISPLAY[action] || {
|
||||||
|
label: action,
|
||||||
|
color: "text-gray-600 bg-gray-500/10",
|
||||||
|
};
|
||||||
|
const isSelected = selectedPermissions.includes(perm.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={perm.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-primary/10 hover:bg-primary/15"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
)}
|
||||||
|
onClick={() => togglePermission(perm.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePermission(perm.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={isSelected}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => togglePermission(perm.value)}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium px-2 py-0.5 rounded",
|
||||||
|
actionConfig.color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actionConfig.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">
|
||||||
|
{perm.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setOpen(false)}>
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-3 pt-4 border-t">
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
<Button onClick={handleCreate} disabled={creating || !name.trim()}>
|
||||||
{creating ? (
|
{creating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Creating
|
Creating...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Create Role"
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-2" />
|
||||||
|
Create Role
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
</DialogContent>
|
</CardContent>
|
||||||
</Dialog>
|
</Card>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||||
|
|
||||||
interface DashboardLayoutProps {
|
interface DashboardLayoutProps {
|
||||||
|
|
@ -10,8 +10,12 @@ interface DashboardLayoutProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||||
|
|
||||||
|
// Use the global loading screen - spinner animation won't reset
|
||||||
|
useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Check if user is authenticated
|
// Check if user is authenticated
|
||||||
const token = getBearerToken();
|
const token = getBearerToken();
|
||||||
|
|
@ -23,21 +27,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||||
setIsCheckingAuth(false);
|
setIsCheckingAuth(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Show loading screen while checking authentication
|
// Return null while loading - the global provider handles the loading UI
|
||||||
if (isCheckingAuth) {
|
if (isCheckingAuth) {
|
||||||
return (
|
return null;
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
|
||||||
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
|
|
||||||
<CardDescription>Checking authentication...</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex justify-center py-6">
|
|
||||||
<Loader2 className="h-12 w-12 text-primary animate-spin" />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
14
surfsense_web/app/dashboard/loading.tsx
Normal file
14
surfsense_web/app/dashboard/loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
|
|
||||||
|
export default function DashboardLoading() {
|
||||||
|
const t = useTranslations("common");
|
||||||
|
|
||||||
|
// Use global loading - spinner animation won't reset when page transitions
|
||||||
|
useGlobalLoadingEffect(true, t("loading"), "default");
|
||||||
|
|
||||||
|
// Return null - the GlobalLoadingProvider handles the loading UI
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { AlertCircle, Loader2, Plus, Search } from "lucide-react";
|
import { AlertCircle, Plus, Search } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
@ -18,37 +18,7 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
function LoadingScreen() {
|
|
||||||
const t = useTranslations("dashboard");
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
transition={{ duration: 0.5 }}
|
|
||||||
>
|
|
||||||
<Card className="w-full max-w-[350px] bg-background/60 backdrop-blur-sm">
|
|
||||||
<CardHeader className="pb-2">
|
|
||||||
<CardTitle className="text-xl font-medium">{t("loading")}</CardTitle>
|
|
||||||
<CardDescription>{t("fetching_spaces")}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex justify-center py-6">
|
|
||||||
<motion.div
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
|
||||||
>
|
|
||||||
<Loader2 className="h-12 w-12 text-primary" />
|
|
||||||
</motion.div>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
|
|
||||||
{t("may_take_moment")}
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ErrorScreen({ message }: { message: string }) {
|
function ErrorScreen({ message }: { message: string }) {
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
|
|
@ -121,6 +91,7 @@ export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||||
|
|
||||||
|
const t = useTranslations("dashboard");
|
||||||
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
|
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -131,11 +102,16 @@ export default function DashboardPage() {
|
||||||
}
|
}
|
||||||
}, [isLoading, searchSpaces, router]);
|
}, [isLoading, searchSpaces, router]);
|
||||||
|
|
||||||
if (isLoading) return <LoadingScreen />;
|
// Show loading while fetching or while we have spaces and are about to redirect
|
||||||
|
const shouldShowLoading = isLoading || searchSpaces.length > 0;
|
||||||
|
|
||||||
|
// Use global loading screen - spinner animation won't reset
|
||||||
|
useGlobalLoadingEffect(shouldShowLoading, t("fetching_spaces"), "default");
|
||||||
|
|
||||||
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
|
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
|
||||||
|
|
||||||
if (searchSpaces.length > 0) {
|
if (shouldShowLoading) {
|
||||||
return <LoadingScreen />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Loader2, Menu, User } from "lucide-react";
|
import { Menu, User } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
@ -11,6 +11,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
|
||||||
interface ProfileContentProps {
|
interface ProfileContentProps {
|
||||||
onMenuClick: () => void;
|
onMenuClick: () => void;
|
||||||
|
|
@ -129,7 +130,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
|
||||||
>
|
>
|
||||||
{isUserLoading ? (
|
{isUserLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Spinner size="md" className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
|
@ -166,7 +167,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button type="submit" disabled={isPending || !hasChanges}>
|
<Button type="submit" disabled={isPending || !hasChanges}>
|
||||||
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isPending && <Spinner size="sm" className="mr-2" />}
|
||||||
{t("profile_save")}
|
{t("profile_save")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,6 @@ import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock,
|
|
||||||
Loader2,
|
|
||||||
LogIn,
|
LogIn,
|
||||||
Shield,
|
Shield,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
|
@ -30,6 +28,7 @@ import {
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
|
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
|
||||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
@ -164,7 +163,7 @@ export default function InviteAcceptPage() {
|
||||||
animate={{ rotate: 360 }}
|
animate={{ rotate: 360 }}
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
>
|
>
|
||||||
<Loader2 className="h-12 w-12 text-primary" />
|
<Spinner size="xl" className="text-primary" />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
|
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -353,7 +352,7 @@ export default function InviteAcceptPage() {
|
||||||
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
|
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
|
||||||
{accepting ? (
|
{accepting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Spinner size="sm" />
|
||||||
Accepting...
|
Accepting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import "./globals.css";
|
||||||
import { RootProvider } from "fumadocs-ui/provider/next";
|
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||||
import { Roboto } from "next/font/google";
|
import { Roboto } from "next/font/google";
|
||||||
import { ElectricProvider } from "@/components/providers/ElectricProvider";
|
import { ElectricProvider } from "@/components/providers/ElectricProvider";
|
||||||
|
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
|
||||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||||
import { PostHogProvider } from "@/components/providers/PostHogProvider";
|
import { PostHogProvider } from "@/components/providers/PostHogProvider";
|
||||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||||
|
|
@ -104,7 +105,9 @@ export default function RootLayout({
|
||||||
>
|
>
|
||||||
<RootProvider>
|
<RootProvider>
|
||||||
<ReactQueryClientProvider>
|
<ReactQueryClientProvider>
|
||||||
<ElectricProvider>{children}</ElectricProvider>
|
<ElectricProvider>
|
||||||
|
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||||
|
</ElectricProvider>
|
||||||
</ReactQueryClientProvider>
|
</ReactQueryClientProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</RootProvider>
|
</RootProvider>
|
||||||
|
|
|
||||||
|
|
@ -175,5 +175,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
changeFrequency: "daily",
|
changeFrequency: "daily",
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
},
|
},
|
||||||
|
// How-to documentation
|
||||||
|
{
|
||||||
|
url: "https://www.surfsense.com/docs/how-to/electric-sql",
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: "daily",
|
||||||
|
priority: 0.8,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
surfsense_web/atoms/ui/loading.atoms.ts
Normal file
30
surfsense_web/atoms/ui/loading.atoms.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
interface GlobalLoadingState {
|
||||||
|
isLoading: boolean;
|
||||||
|
message?: string;
|
||||||
|
variant: "login" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const globalLoadingAtom = atom<GlobalLoadingState>({
|
||||||
|
isLoading: false,
|
||||||
|
message: undefined,
|
||||||
|
variant: "default",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper atom for showing global loading
|
||||||
|
export const showGlobalLoadingAtom = atom(
|
||||||
|
null,
|
||||||
|
(
|
||||||
|
get,
|
||||||
|
set,
|
||||||
|
{ message, variant = "default" }: { message?: string; variant?: "login" | "default" }
|
||||||
|
) => {
|
||||||
|
set(globalLoadingAtom, { isLoading: true, message, variant });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Helper atom for hiding global loading
|
||||||
|
export const hideGlobalLoadingAtom = atom(null, (get, set) => {
|
||||||
|
set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" });
|
||||||
|
});
|
||||||
88
surfsense_web/changelog/content/2026-01-26.mdx
Normal file
88
surfsense_web/changelog/content/2026-01-26.mdx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
---
|
||||||
|
title: "SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments"
|
||||||
|
description: "SurfSense v0.0.12 transforms the platform with a redesigned chat-first interface, real-time collaboration features, comment threads with @mentions, and instant notifications powered by ElectricSQL + PGlite."
|
||||||
|
date: "2026-01-26"
|
||||||
|
tags: ["UX", "UI", "Real-time chat", "Collaboration", "Comments"]
|
||||||
|
version: "0.0.12"
|
||||||
|
---
|
||||||
|
|
||||||
|
<img src="/changelog/0.0.12/header.gif" alt="SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments" className="rounded-lg w-full" />
|
||||||
|
|
||||||
|
## What's New in v0.0.12
|
||||||
|
|
||||||
|
This release brings major improvements to **collaboration and user experience**. We've completely redesigned the main interface to be chat-first, introduced real-time notifications and live collaboration features, and added a powerful commenting system with @mentions. These changes make SurfSense faster, more intuitive, and better for team collaboration.
|
||||||
|
|
||||||
|
### Major UX/UI Overhaul
|
||||||
|
|
||||||
|
#### New Chat-First Interface
|
||||||
|
|
||||||
|
- **Dashboard Removed**: Users now land directly in a chat for faster access
|
||||||
|
- **Redesigned Search Spaces**: Moved to a left column with color-coded icons and hover tooltips
|
||||||
|
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
|
||||||
|
- **Streamlined Settings**: Accessible through intuitive dropdown menus
|
||||||
|
- **Mobile-Responsive Design**: Better experience on all devices
|
||||||
|
- **Single-Click Google Login**: Replaces the old two-step process
|
||||||
|
|
||||||
|
### Real-Time Collaboration Features
|
||||||
|
|
||||||
|
#### Live Shared Chats
|
||||||
|
|
||||||
|
- **Multi-User Collaboration**: Multiple users can now collaborate in the same chat in real-time
|
||||||
|
- **Status Indicators**: See when the AI is responding to another team member
|
||||||
|
- **Instant Sync**: Changes sync instantly across all open tabs and users
|
||||||
|
|
||||||
|
#### Chat Comments with @Mentions
|
||||||
|
|
||||||
|
- **Comment on AI Responses**: Discuss responses directly with your team
|
||||||
|
- **Single-Level Threading**: Reply to comments with organized threads
|
||||||
|
- **@Mentions**: Tag teammates to get their attention
|
||||||
|
- **Real-Time Notifications**: Receive instant alerts when someone mentions you
|
||||||
|
|
||||||
|
#### Real-Time Notifications
|
||||||
|
|
||||||
|
- **Instant Updates**: Replaced slow polling with instant notifications using ElectricSQL + PGlite
|
||||||
|
- **New Inbox**: See connector indexing, document processing, and system events immediately
|
||||||
|
- **Cross-Tab Sync**: Syncs across all your browser tabs in real-time
|
||||||
|
|
||||||
|
### Connector Enhancements
|
||||||
|
|
||||||
|
#### OAuth Migration for Better Security
|
||||||
|
|
||||||
|
- **New OAuth Connectors**: Migrated Linear, Slack, Notion, Discord, Confluence, and Jira to OAuth-based authentication
|
||||||
|
- **Circleback Integration**: Connect your AI meeting notes from Circleback
|
||||||
|
- **Future Date Indexing**: Index future dates for calendar-based connectors to plan ahead
|
||||||
|
- **5-Minute Periodic Syncing**: Near-real-time updates option
|
||||||
|
- **Real-Time UI Updates**: See connector indexing progress without page refreshes
|
||||||
|
|
||||||
|
<Accordion type="multiple" className="w-full not-prose">
|
||||||
|
<AccordionItem value="item-1">
|
||||||
|
<AccordionTrigger>Bug Fixes</AccordionTrigger>
|
||||||
|
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||||
|
<ul className="list-disc space-y-2 pl-4">
|
||||||
|
<li>Syncs with no new content now show "Already up to date!" instead of falsely reporting failures</li>
|
||||||
|
<li>Restored missing indexing options page for Google Drive connector</li>
|
||||||
|
<li>File mention picker now handles large document counts with server-side search and pagination</li>
|
||||||
|
<li>Reasoning steps no longer overlap with chat input field</li>
|
||||||
|
<li>File upload modal is now scrollable when adding many files</li>
|
||||||
|
<li>OAuth connectors now display properly on mobile devices</li>
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">
|
||||||
|
<AccordionTrigger>Technical Improvements</AccordionTrigger>
|
||||||
|
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||||
|
<ul className="list-disc space-y-2 pl-4">
|
||||||
|
<li>Made Alembic migrations idempotent for safer deployments</li>
|
||||||
|
<li>Migrations now work on fresh databases from scratch</li>
|
||||||
|
<li>Major refactoring of chat components for better maintainability</li>
|
||||||
|
<li>Streamlined sidebar and connector page code</li>
|
||||||
|
<li>Fixed legacy route handling</li>
|
||||||
|
<li>Fixed hardcoded Docker values for complex deployments</li>
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
This release transforms SurfSense into a truly collaborative, real-time platform with a redesigned interface that puts chat front and center. The addition of comments, @mentions, and live collaboration features makes it easier than ever for teams to work together without leaving the app.
|
||||||
|
|
||||||
|
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||||
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
||||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||||
|
|
||||||
|
|
@ -25,8 +27,12 @@ const TokenHandler = ({
|
||||||
tokenParamName = "token",
|
tokenParamName = "token",
|
||||||
storageKey = "surfsense_bearer_token",
|
storageKey = "surfsense_bearer_token",
|
||||||
}: TokenHandlerProps) => {
|
}: TokenHandlerProps) => {
|
||||||
|
const t = useTranslations("auth");
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Always show loading for this component - spinner animation won't reset
|
||||||
|
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only run on client-side
|
// Only run on client-side
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
@ -66,11 +72,8 @@ const TokenHandler = ({
|
||||||
}
|
}
|
||||||
}, [searchParams, tokenParamName, storageKey, redirectPath]);
|
}, [searchParams, tokenParamName, storageKey, redirectPath]);
|
||||||
|
|
||||||
return (
|
// Return null - the global provider handles the loading UI
|
||||||
<div className="flex items-center justify-center min-h-[200px]">
|
return null;
|
||||||
<p className="text-gray-500">Processing authentication...</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TokenHandler;
|
export default TokenHandler;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
useAssistantApi,
|
useAssistantApi,
|
||||||
useAssistantState,
|
useAssistantState,
|
||||||
} from "@assistant-ui/react";
|
} from "@assistant-ui/react";
|
||||||
import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
|
import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
|
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
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";
|
import { useDocumentUploadDialog } from "./document-upload-popup";
|
||||||
|
|
@ -135,7 +136,7 @@ const AttachmentThumb: FC = () => {
|
||||||
if (isProcessing) {
|
if (isProcessing) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
<div className="flex h-full w-full items-center justify-center bg-muted">
|
||||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
<Spinner size="md" className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -213,7 +214,7 @@ const AttachmentUI: FC = () => {
|
||||||
>
|
>
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Spinner size="xs" />
|
||||||
Processing...
|
Processing...
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ChatSessionStatusProps {
|
interface ChatSessionStatusProps {
|
||||||
|
|
@ -43,7 +43,7 @@ export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Spinner size="xs" />
|
||||||
<span>Currently responding to {displayName}</span>
|
<span>Currently responding to {displayName}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,50 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { Cable, Loader2 } from "lucide-react";
|
import { Cable } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
||||||
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
|
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
|
||||||
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||||
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
||||||
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
||||||
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants";
|
import {
|
||||||
|
COMPOSIO_CONNECTORS,
|
||||||
|
OAUTH_CONNECTORS,
|
||||||
|
} from "./connector-popup/constants/connector-constants";
|
||||||
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
|
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
|
||||||
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
||||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||||
import { ComposioToolkitView } from "./connector-popup/views/composio-toolkit-view";
|
|
||||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||||
|
|
||||||
export const ConnectorIndicator: FC = () => {
|
export const ConnectorIndicator: FC = () => {
|
||||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
||||||
// Fetch document type counts using Electric SQL + PGlite for real-time updates
|
// Fetch document type counts using Electric SQL + PGlite for real-time updates
|
||||||
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
|
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
|
||||||
|
|
||||||
|
// Fetch notifications to detect indexing failures
|
||||||
|
const { inboxItems = [] } = useInbox(
|
||||||
|
currentUser?.id ?? null,
|
||||||
|
searchSpaceId ? Number(searchSpaceId) : null,
|
||||||
|
"connector_indexing"
|
||||||
|
);
|
||||||
|
|
||||||
// Check if YouTube view is active
|
// Check if YouTube view is active
|
||||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
const isYouTubeView = searchParams.get("view") === "youtube";
|
||||||
|
|
||||||
|
|
@ -88,12 +101,6 @@ export const ConnectorIndicator: FC = () => {
|
||||||
setConnectorConfig,
|
setConnectorConfig,
|
||||||
setIndexingConnectorConfig,
|
setIndexingConnectorConfig,
|
||||||
setConnectorName,
|
setConnectorName,
|
||||||
// Composio
|
|
||||||
viewingComposio,
|
|
||||||
connectingComposioToolkit,
|
|
||||||
handleOpenComposio,
|
|
||||||
handleBackFromComposio,
|
|
||||||
handleConnectComposioToolkit,
|
|
||||||
} = useConnectorDialog();
|
} = useConnectorDialog();
|
||||||
|
|
||||||
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
||||||
|
|
@ -123,8 +130,10 @@ export const ConnectorIndicator: FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
|
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
|
||||||
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
|
// Also clears when failed notifications are detected
|
||||||
connectors as SearchSourceConnector[]
|
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||||
|
connectors as SearchSourceConnector[],
|
||||||
|
inboxItems
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLoading = connectorsLoading || documentTypesLoading;
|
const isLoading = connectorsLoading || documentTypesLoading;
|
||||||
|
|
@ -142,7 +151,7 @@ export const ConnectorIndicator: FC = () => {
|
||||||
|
|
||||||
// Check which connectors are already connected
|
// Check which connectors are already connected
|
||||||
// Using Electric SQL + PGlite for real-time connector updates
|
// Using Electric SQL + PGlite for real-time connector updates
|
||||||
const connectedTypes = new Set(
|
const connectedTypes = new Set<string>(
|
||||||
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
(connectors || []).map((c: SearchSourceConnector) => c.connector_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -166,7 +175,7 @@ export const ConnectorIndicator: FC = () => {
|
||||||
onClick={() => handleOpenChange(true)}
|
onClick={() => handleOpenChange(true)}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 className="size-4 animate-spin" />
|
<Spinner size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Cable className="size-4 stroke-[1.5px]" />
|
<Cable className="size-4 stroke-[1.5px]" />
|
||||||
|
|
@ -179,22 +188,11 @@ export const ConnectorIndicator: FC = () => {
|
||||||
)}
|
)}
|
||||||
</TooltipIconButton>
|
</TooltipIconButton>
|
||||||
|
|
||||||
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
|
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
|
||||||
|
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||||
{isYouTubeView && searchSpaceId ? (
|
{isYouTubeView && searchSpaceId ? (
|
||||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||||
) : viewingComposio && searchSpaceId ? (
|
|
||||||
<ComposioToolkitView
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
connectedToolkits={(connectors || [])
|
|
||||||
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
|
|
||||||
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
|
|
||||||
.filter(Boolean)}
|
|
||||||
onBack={handleBackFromComposio}
|
|
||||||
onConnectToolkit={handleConnectComposioToolkit}
|
|
||||||
isConnecting={connectingComposioToolkit !== null}
|
|
||||||
connectingToolkitId={connectingComposioToolkit}
|
|
||||||
/>
|
|
||||||
) : viewingMCPList ? (
|
) : viewingMCPList ? (
|
||||||
<ConnectorAccountsListView
|
<ConnectorAccountsListView
|
||||||
connectorType="MCP_CONNECTOR"
|
connectorType="MCP_CONNECTOR"
|
||||||
|
|
@ -215,7 +213,12 @@ export const ConnectorIndicator: FC = () => {
|
||||||
onBack={handleBackFromAccountsList}
|
onBack={handleBackFromAccountsList}
|
||||||
onManage={handleStartEdit}
|
onManage={handleStartEdit}
|
||||||
onAddAccount={() => {
|
onAddAccount={() => {
|
||||||
const oauthConnector = OAUTH_CONNECTORS.find(
|
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||||
|
const oauthConnector =
|
||||||
|
OAUTH_CONNECTORS.find(
|
||||||
|
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||||
|
) ||
|
||||||
|
COMPOSIO_CONNECTORS.find(
|
||||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||||
);
|
);
|
||||||
if (oauthConnector) {
|
if (oauthConnector) {
|
||||||
|
|
@ -260,7 +263,13 @@ export const ConnectorIndicator: FC = () => {
|
||||||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||||
? () => {
|
? () => {
|
||||||
startIndexing(editingConnector.id);
|
startIndexing(editingConnector.id);
|
||||||
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
|
handleQuickIndexConnector(
|
||||||
|
editingConnector.id,
|
||||||
|
editingConnector.connector_type,
|
||||||
|
stopIndexing,
|
||||||
|
startDate,
|
||||||
|
endDate
|
||||||
|
);
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -331,7 +340,6 @@ export const ConnectorIndicator: FC = () => {
|
||||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||||
onManage={handleStartEdit}
|
onManage={handleStartEdit}
|
||||||
onViewAccountsList={handleViewAccountsList}
|
onViewAccountsList={handleViewAccountsList}
|
||||||
onOpenComposio={handleOpenComposio}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { Zap } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ComposioConnectorCardProps {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
connectorCount?: number;
|
|
||||||
onConnect: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ComposioConnectorCard: FC<ComposioConnectorCardProps> = ({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
connectorCount = 0,
|
|
||||||
onConnect,
|
|
||||||
}) => {
|
|
||||||
const hasConnections = connectorCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
|
|
||||||
"border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5",
|
|
||||||
"hover:border-violet-500/40 hover:from-violet-500/10 hover:to-purple-500/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
|
|
||||||
"bg-gradient-to-br from-violet-500/10 to-purple-500/10 border-violet-500/20"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/connectors/composio.svg"
|
|
||||||
alt="Composio"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
|
|
||||||
<Zap className="size-3.5 text-violet-500" />
|
|
||||||
</div>
|
|
||||||
{hasConnections ? (
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
|
|
||||||
<span>
|
|
||||||
{connectorCount} {connectorCount === 1 ? "connection" : "connections"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-[10px] text-muted-foreground mt-1">{description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={hasConnections ? "secondary" : "default"}
|
|
||||||
className={cn(
|
|
||||||
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium shadow-xs",
|
|
||||||
!hasConnections && "bg-violet-600 hover:bg-violet-700 text-white",
|
|
||||||
hasConnections &&
|
|
||||||
"bg-white text-slate-700 hover:bg-slate-50 border-0 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
|
||||||
)}
|
|
||||||
onClick={onConnect}
|
|
||||||
>
|
|
||||||
{hasConnections ? "Manage" : "Browse"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||||
import { FileText, Loader2 } from "lucide-react";
|
import { FileText } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -111,7 +112,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
</div>
|
</div>
|
||||||
{isIndexing ? (
|
{isIndexing ? (
|
||||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Spinner size="xs" />
|
||||||
Syncing
|
Syncing
|
||||||
</p>
|
</p>
|
||||||
) : isConnected ? (
|
) : isConnected ? (
|
||||||
|
|
@ -151,7 +152,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
||||||
disabled={isConnecting || !isEnabled}
|
disabled={isConnecting || !isEnabled}
|
||||||
>
|
>
|
||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Spinner size="xs" />
|
||||||
) : !isEnabled ? (
|
) : !isEnabled ? (
|
||||||
"Unavailable"
|
"Unavailable"
|
||||||
) : isConnected ? (
|
) : isConnected ? (
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,11 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"status": "warning",
|
"status": "warning",
|
||||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||||
|
},
|
||||||
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": {
|
||||||
|
"enabled": false,
|
||||||
|
"status": "disabled",
|
||||||
|
"statusMessage": "Not available yet."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,6 @@ import type { FC } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -85,6 +79,7 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
||||||
BOOKSTACK_TOKEN_SECRET: values.token_secret,
|
BOOKSTACK_TOKEN_SECRET: values.token_secret,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
|
is_active: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
periodic_indexing_enabled: periodicEnabled,
|
periodic_indexing_enabled: periodicEnabled,
|
||||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||||
|
|
@ -301,124 +296,6 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,6 @@ import type { FC } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -253,131 +247,6 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { FolderOpen, Info } from "lucide-react";
|
import { Info } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
|
|
@ -109,7 +103,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-6">
|
<div className="space-y-6 pb-6">
|
||||||
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||||
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
|
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
|
||||||
<div className="-ml-1">
|
<div className="-ml-1">
|
||||||
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
|
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
|
||||||
|
|
@ -320,145 +314,6 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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 Obsidian connector scans your local Obsidian vault directory and indexes all
|
|
||||||
Markdown files. It preserves your note structure and extracts metadata from YAML
|
|
||||||
frontmatter.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
|
||||||
<li>
|
|
||||||
The connector parses frontmatter metadata (title, tags, aliases, dates, etc.)
|
|
||||||
</li>
|
|
||||||
<li>Wiki-style links ([[note]]) are extracted and preserved</li>
|
|
||||||
<li>Inline tags (#tag) are recognized and indexed</li>
|
|
||||||
<li>Content is chunked intelligently for optimal search results</li>
|
|
||||||
<li>
|
|
||||||
Subsequent indexing runs use content hashing to skip unchanged files for faster
|
|
||||||
sync
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Setup</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">
|
|
||||||
File System Access Required
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
|
||||||
The SurfSense backend must have read access to your Obsidian vault directory.
|
|
||||||
For Docker deployments, mount your vault as a volume.
|
|
||||||
</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: Locate your vault
|
|
||||||
</h4>
|
|
||||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
<strong>macOS/Linux:</strong> Right-click any note in Obsidian → "Reveal in
|
|
||||||
Finder" to see the vault folder
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Windows:</strong> Right-click any note → "Show in system explorer"
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Or:</strong> Click the vault switcher (bottom-left icon) → "Open
|
|
||||||
folder" next to your vault name
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
|
||||||
Step 2: Enter the path
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
|
||||||
<strong>Running locally (no Docker):</strong> Use the direct path to your
|
|
||||||
vault:
|
|
||||||
</p>
|
|
||||||
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto mb-2">
|
|
||||||
{`/Users/yourname/Documents/MyObsidianVault`}
|
|
||||||
</pre>
|
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
|
|
||||||
<strong>Running in Docker:</strong> Mount your vault as a volume in
|
|
||||||
docker-compose.yml:
|
|
||||||
</p>
|
|
||||||
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto">
|
|
||||||
{`volumes:
|
|
||||||
- /path/to/your/vault:/app/obsidian_vaults/my-vault:ro`}
|
|
||||||
</pre>
|
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
|
|
||||||
Then use <code>/app/obsidian_vaults/my-vault</code> as your vault path.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
|
||||||
Step 3: Configure exclusions
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
|
||||||
Common folders to exclude:
|
|
||||||
</p>
|
|
||||||
<ul className="list-disc pl-5 mt-1 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
<code>.obsidian</code> - Obsidian config (always recommended)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>.trash</code> - Obsidian's trash folder
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>templates</code> - If you have a templates folder
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<code>daily-notes</code> - If you want to exclude daily notes
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm sm:text-base font-semibold mb-2">What Gets Indexed</h3>
|
|
||||||
<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">Indexed Content</AlertTitle>
|
|
||||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
|
||||||
<p className="mb-2">The Obsidian connector indexes:</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>All Markdown files (.md) in your vault</li>
|
|
||||||
<li>YAML frontmatter metadata (title, tags, aliases, dates)</li>
|
|
||||||
<li>Wiki-style links between notes</li>
|
|
||||||
<li>Inline tags throughout your notes</li>
|
|
||||||
<li>Full note content with proper chunking</li>
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FC } from "react";
|
||||||
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
|
||||||
|
interface ComposioCalendarConfigProps {
|
||||||
|
connector: SearchSourceConnector;
|
||||||
|
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||||
|
onNameChange?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
|
||||||
|
return <div className="space-y-6" />;
|
||||||
|
};
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { ExternalLink, Info, Zap } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ComposioConfigProps {
|
|
||||||
connector: SearchSourceConnector;
|
|
||||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
|
||||||
onNameChange?: (name: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get toolkit display info
|
|
||||||
const getToolkitInfo = (toolkitId: string): { name: string; icon: string; description: string } => {
|
|
||||||
switch (toolkitId) {
|
|
||||||
case "googledrive":
|
|
||||||
return {
|
|
||||||
name: "Google Drive",
|
|
||||||
icon: "/connectors/google-drive.svg",
|
|
||||||
description: "Files and documents from Google Drive",
|
|
||||||
};
|
|
||||||
case "gmail":
|
|
||||||
return {
|
|
||||||
name: "Gmail",
|
|
||||||
icon: "/connectors/google-gmail.svg",
|
|
||||||
description: "Emails from Gmail",
|
|
||||||
};
|
|
||||||
case "googlecalendar":
|
|
||||||
return {
|
|
||||||
name: "Google Calendar",
|
|
||||||
icon: "/connectors/google-calendar.svg",
|
|
||||||
description: "Events from Google Calendar",
|
|
||||||
};
|
|
||||||
case "slack":
|
|
||||||
return {
|
|
||||||
name: "Slack",
|
|
||||||
icon: "/connectors/slack.svg",
|
|
||||||
description: "Messages from Slack",
|
|
||||||
};
|
|
||||||
case "notion":
|
|
||||||
return {
|
|
||||||
name: "Notion",
|
|
||||||
icon: "/connectors/notion.svg",
|
|
||||||
description: "Pages from Notion",
|
|
||||||
};
|
|
||||||
case "github":
|
|
||||||
return {
|
|
||||||
name: "GitHub",
|
|
||||||
icon: "/connectors/github.svg",
|
|
||||||
description: "Repositories from GitHub",
|
|
||||||
};
|
|
||||||
default:
|
|
||||||
return {
|
|
||||||
name: toolkitId,
|
|
||||||
icon: "/connectors/composio.svg",
|
|
||||||
description: "Connected via Composio",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComposioConfig: FC<ComposioConfigProps> = ({ connector }) => {
|
|
||||||
const toolkitId = connector.config?.toolkit_id as string;
|
|
||||||
const toolkitName = connector.config?.toolkit_name as string;
|
|
||||||
const isIndexable = connector.config?.is_indexable as boolean;
|
|
||||||
const composioAccountId = connector.config?.composio_connected_account_id as string;
|
|
||||||
|
|
||||||
const toolkitInfo = getToolkitInfo(toolkitId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Toolkit Info Card */}
|
|
||||||
<div className="rounded-xl border border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5 p-4">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/10 to-purple-500/10 border border-violet-500/20 shrink-0">
|
|
||||||
<Image
|
|
||||||
src={toolkitInfo.icon}
|
|
||||||
alt={toolkitInfo.name}
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<h3 className="text-sm font-semibold">{toolkitName || toolkitInfo.name}</h3>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-5 bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
|
||||||
>
|
|
||||||
<Zap className="size-3 mr-0.5" />
|
|
||||||
Composio
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{toolkitInfo.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Connection Details */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
||||||
Connection Details
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
|
||||||
<span className="text-xs text-muted-foreground">Toolkit</span>
|
|
||||||
<span className="text-xs font-medium">{toolkitId}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
|
||||||
<span className="text-xs text-muted-foreground">Indexing Supported</span>
|
|
||||||
<Badge
|
|
||||||
variant={isIndexable ? "default" : "secondary"}
|
|
||||||
className={cn(
|
|
||||||
"text-[10px] px-1.5 py-0 h-5",
|
|
||||||
isIndexable
|
|
||||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
|
||||||
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isIndexable ? "Yes" : "Coming Soon"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{composioAccountId && (
|
|
||||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
|
||||||
<span className="text-xs text-muted-foreground">Account ID</span>
|
|
||||||
<span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]">
|
|
||||||
{composioAccountId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info Banner */}
|
|
||||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-3">
|
|
||||||
<div className="flex items-start gap-2.5">
|
|
||||||
<Info className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
This connection uses Composio's managed OAuth, which means you don't need to
|
|
||||||
wait for app verification. Your data is securely accessed through Composio.
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://composio.dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400 hover:underline"
|
|
||||||
>
|
|
||||||
Learn more about Composio
|
|
||||||
<ExternalLink className="size-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
File,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FolderClosed,
|
||||||
|
Image,
|
||||||
|
Presentation,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
|
||||||
|
interface ComposioDriveConfigProps {
|
||||||
|
connector: SearchSourceConnector;
|
||||||
|
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||||
|
onNameChange?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IndexingOptions {
|
||||||
|
max_files_per_folder: number;
|
||||||
|
incremental_sync: boolean;
|
||||||
|
include_subfolders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
|
||||||
|
max_files_per_folder: 100,
|
||||||
|
incremental_sync: true,
|
||||||
|
include_subfolders: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to get appropriate icon for file type based on file name
|
||||||
|
function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
|
||||||
|
const lowerName = fileName.toLowerCase();
|
||||||
|
// Spreadsheets
|
||||||
|
if (
|
||||||
|
lowerName.endsWith(".xlsx") ||
|
||||||
|
lowerName.endsWith(".xls") ||
|
||||||
|
lowerName.endsWith(".csv") ||
|
||||||
|
lowerName.includes("spreadsheet")
|
||||||
|
) {
|
||||||
|
return <FileSpreadsheet className={`${className} text-green-500`} />;
|
||||||
|
}
|
||||||
|
// Presentations
|
||||||
|
if (
|
||||||
|
lowerName.endsWith(".pptx") ||
|
||||||
|
lowerName.endsWith(".ppt") ||
|
||||||
|
lowerName.includes("presentation")
|
||||||
|
) {
|
||||||
|
return <Presentation className={`${className} text-orange-500`} />;
|
||||||
|
}
|
||||||
|
// Documents (word, text only - not PDF)
|
||||||
|
if (
|
||||||
|
lowerName.endsWith(".docx") ||
|
||||||
|
lowerName.endsWith(".doc") ||
|
||||||
|
lowerName.endsWith(".txt") ||
|
||||||
|
lowerName.includes("document") ||
|
||||||
|
lowerName.includes("word") ||
|
||||||
|
lowerName.includes("text")
|
||||||
|
) {
|
||||||
|
return <FileText className={`${className} text-gray-500`} />;
|
||||||
|
}
|
||||||
|
// Images
|
||||||
|
if (
|
||||||
|
lowerName.endsWith(".png") ||
|
||||||
|
lowerName.endsWith(".jpg") ||
|
||||||
|
lowerName.endsWith(".jpeg") ||
|
||||||
|
lowerName.endsWith(".gif") ||
|
||||||
|
lowerName.endsWith(".webp") ||
|
||||||
|
lowerName.endsWith(".svg")
|
||||||
|
) {
|
||||||
|
return <Image className={`${className} text-purple-500`} />;
|
||||||
|
}
|
||||||
|
// Default (including PDF)
|
||||||
|
return <File className={`${className} text-gray-500`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
||||||
|
connector,
|
||||||
|
onConfigChange,
|
||||||
|
}) => {
|
||||||
|
const isIndexable = connector.config?.is_indexable as boolean;
|
||||||
|
|
||||||
|
// Initialize with existing selected folders and files from connector config
|
||||||
|
const existingFolders =
|
||||||
|
(connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||||
|
const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
|
||||||
|
const existingIndexingOptions =
|
||||||
|
(connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
|
||||||
|
|
||||||
|
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
|
||||||
|
const [showFolderSelector, setShowFolderSelector] = useState(false);
|
||||||
|
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
|
||||||
|
|
||||||
|
// Update selected folders and files when connector config changes
|
||||||
|
useEffect(() => {
|
||||||
|
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||||
|
const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
|
||||||
|
const options =
|
||||||
|
(connector.config?.indexing_options as IndexingOptions | undefined) ||
|
||||||
|
DEFAULT_INDEXING_OPTIONS;
|
||||||
|
setSelectedFolders(folders);
|
||||||
|
setSelectedFiles(files);
|
||||||
|
setIndexingOptions(options);
|
||||||
|
}, [connector.config]);
|
||||||
|
|
||||||
|
const updateConfig = (
|
||||||
|
folders: SelectedFolder[],
|
||||||
|
files: SelectedFolder[],
|
||||||
|
options: IndexingOptions
|
||||||
|
) => {
|
||||||
|
if (onConfigChange) {
|
||||||
|
onConfigChange({
|
||||||
|
...connector.config,
|
||||||
|
selected_folders: folders,
|
||||||
|
selected_files: files,
|
||||||
|
indexing_options: options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFolders = (folders: SelectedFolder[]) => {
|
||||||
|
setSelectedFolders(folders);
|
||||||
|
updateConfig(folders, selectedFiles, indexingOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectFiles = (files: SelectedFolder[]) => {
|
||||||
|
setSelectedFiles(files);
|
||||||
|
updateConfig(selectedFolders, files, indexingOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
|
||||||
|
const newOptions = { ...indexingOptions, [key]: value };
|
||||||
|
setIndexingOptions(newOptions);
|
||||||
|
updateConfig(selectedFolders, selectedFiles, newOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFolder = (folderId: string) => {
|
||||||
|
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
|
||||||
|
setSelectedFolders(newFolders);
|
||||||
|
updateConfig(newFolders, selectedFiles, indexingOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (fileId: string) => {
|
||||||
|
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
|
||||||
|
setSelectedFiles(newFiles);
|
||||||
|
updateConfig(selectedFolders, newFiles, indexingOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalSelected = selectedFolders.length + selectedFiles.length;
|
||||||
|
|
||||||
|
// Only show configuration if the connector is indexable
|
||||||
|
if (!isIndexable) {
|
||||||
|
return <div className="space-y-6" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Folder & File Selection */}
|
||||||
|
<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">Folder & File Selection</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
|
Select specific folders and/or individual files to index from your Google Drive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{totalSelected > 0 && (
|
||||||
|
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
|
||||||
|
<p className="font-medium">
|
||||||
|
Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (selectedFolders.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectedFiles.length > 0) {
|
||||||
|
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
|
||||||
|
}
|
||||||
|
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
|
||||||
|
{selectedFolders.map((folder) => (
|
||||||
|
<div
|
||||||
|
key={folder.id}
|
||||||
|
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||||
|
title={folder.name}
|
||||||
|
>
|
||||||
|
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
||||||
|
<span className="flex-1 truncate">{folder.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveFolder(folder.id)}
|
||||||
|
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||||
|
aria-label={`Remove ${folder.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selectedFiles.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{getFileIconFromName(file.name)}
|
||||||
|
<span className="flex-1 truncate">{file.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveFile(file.id)}
|
||||||
|
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFolderSelector ? (
|
||||||
|
<div className="space-y-2 sm:space-y-3">
|
||||||
|
<ComposioDriveFolderTree
|
||||||
|
connectorId={connector.id}
|
||||||
|
selectedFolders={selectedFolders}
|
||||||
|
onSelectFolders={handleSelectFolders}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onSelectFiles={handleSelectFiles}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowFolderSelector(false)}
|
||||||
|
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||||
|
>
|
||||||
|
Done Selecting
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowFolderSelector(true)}
|
||||||
|
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||||
|
>
|
||||||
|
{totalSelected > 0 ? "Change Selection" : "Select Folders & Files"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Indexing Options */}
|
||||||
|
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
|
||||||
|
<div className="space-y-1 sm:space-y-2">
|
||||||
|
<h3 className="font-medium text-sm sm:text-base">Indexing Options</h3>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||||
|
Configure how files are indexed from your Google Drive.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max files per folder */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="max-files" className="text-sm font-medium">
|
||||||
|
Max files per folder
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Maximum number of files to index from each folder
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={indexingOptions.max_files_per_folder.toString()}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleIndexingOptionChange("max_files_per_folder", parseInt(value, 10))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="max-files"
|
||||||
|
className="w-[140px] bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select limit" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="z-[100]">
|
||||||
|
<SelectItem value="50" className="text-xs sm:text-sm">
|
||||||
|
50 files
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="100" className="text-xs sm:text-sm">
|
||||||
|
100 files
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="250" className="text-xs sm:text-sm">
|
||||||
|
250 files
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="500" className="text-xs sm:text-sm">
|
||||||
|
500 files
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="1000" className="text-xs sm:text-sm">
|
||||||
|
1000 files
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Include subfolders toggle */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="include-subfolders" className="text-sm font-medium">
|
||||||
|
Include subfolders
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Recursively index files in subfolders of selected folders
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="include-subfolders"
|
||||||
|
checked={indexingOptions.include_subfolders}
|
||||||
|
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FC } from "react";
|
||||||
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
|
||||||
|
interface ComposioGmailConfigProps {
|
||||||
|
connector: SearchSourceConnector;
|
||||||
|
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||||
|
onNameChange?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
|
||||||
|
return <div className="space-y-6" />;
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { File, FileSpreadsheet, FileText, FolderClosed, Image, Presentation } from "lucide-react";
|
import {
|
||||||
|
File,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FolderClosed,
|
||||||
|
Image,
|
||||||
|
Presentation,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
|
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
|
||||||
|
|
@ -135,6 +143,18 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
||||||
updateConfig(selectedFolders, selectedFiles, newOptions);
|
updateConfig(selectedFolders, selectedFiles, newOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveFolder = (folderId: string) => {
|
||||||
|
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
|
||||||
|
setSelectedFolders(newFolders);
|
||||||
|
updateConfig(newFolders, selectedFiles, indexingOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = (fileId: string) => {
|
||||||
|
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
|
||||||
|
setSelectedFiles(newFiles);
|
||||||
|
updateConfig(selectedFolders, newFiles, indexingOptions);
|
||||||
|
};
|
||||||
|
|
||||||
const totalSelected = selectedFolders.length + selectedFiles.length;
|
const totalSelected = selectedFolders.length + selectedFiles.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -161,29 +181,45 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
||||||
if (selectedFiles.length > 0) {
|
if (selectedFiles.length > 0) {
|
||||||
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
|
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
|
||||||
}
|
}
|
||||||
return parts.length > 0 ? `(${parts.join(" ")})` : "";
|
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
||||||
})()}
|
})()}
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
|
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
|
||||||
{selectedFolders.map((folder) => (
|
{selectedFolders.map((folder) => (
|
||||||
<p
|
<div
|
||||||
key={folder.id}
|
key={folder.id}
|
||||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||||
title={folder.name}
|
title={folder.name}
|
||||||
>
|
>
|
||||||
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
||||||
{folder.name}
|
<span className="flex-1 truncate">{folder.name}</span>
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveFolder(folder.id)}
|
||||||
|
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||||
|
aria-label={`Remove ${folder.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
{selectedFiles.map((file) => (
|
{selectedFiles.map((file) => (
|
||||||
<p
|
<div
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||||
title={file.name}
|
title={file.name}
|
||||||
>
|
>
|
||||||
{getFileIconFromName(file.name)}
|
{getFileIconFromName(file.name)}
|
||||||
{file.name}
|
<span className="flex-1 truncate">{file.name}</span>
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveFile(file.id)}
|
||||||
|
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||||
|
aria-label={`Remove ${file.name}`}
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
||||||
import { BookStackConfig } from "./components/bookstack-config";
|
import { BookStackConfig } from "./components/bookstack-config";
|
||||||
import { CirclebackConfig } from "./components/circleback-config";
|
import { CirclebackConfig } from "./components/circleback-config";
|
||||||
import { ClickUpConfig } from "./components/clickup-config";
|
import { ClickUpConfig } from "./components/clickup-config";
|
||||||
import { ComposioConfig } from "./components/composio-config";
|
import { ComposioCalendarConfig } from "./components/composio-calendar-config";
|
||||||
|
import { ComposioDriveConfig } from "./components/composio-drive-config";
|
||||||
|
import { ComposioGmailConfig } from "./components/composio-gmail-config";
|
||||||
import { ConfluenceConfig } from "./components/confluence-config";
|
import { ConfluenceConfig } from "./components/confluence-config";
|
||||||
import { DiscordConfig } from "./components/discord-config";
|
import { DiscordConfig } from "./components/discord-config";
|
||||||
import { ElasticsearchConfig } from "./components/elasticsearch-config";
|
import { ElasticsearchConfig } from "./components/elasticsearch-config";
|
||||||
|
|
@ -77,8 +79,12 @@ export function getConnectorConfigComponent(
|
||||||
return MCPConfig;
|
return MCPConfig;
|
||||||
case "OBSIDIAN_CONNECTOR":
|
case "OBSIDIAN_CONNECTOR":
|
||||||
return ObsidianConfig;
|
return ObsidianConfig;
|
||||||
case "COMPOSIO_CONNECTOR":
|
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
|
||||||
return ComposioConfig;
|
return ComposioDriveConfig;
|
||||||
|
case "COMPOSIO_GMAIL_CONNECTOR":
|
||||||
|
return ComposioGmailConfig;
|
||||||
|
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
|
||||||
|
return ComposioCalendarConfig;
|
||||||
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
|
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { type FC, useMemo } from "react";
|
import { type FC, useMemo } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type { EnumConnectorName } from "@/contracts/enums/connector";
|
import type { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||||
|
|
@ -139,7 +140,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Connecting
|
Connecting
|
||||||
</>
|
</>
|
||||||
) : connectorType === "MCP_CONNECTOR" ? (
|
) : connectorType === "MCP_CONNECTOR" ? (
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Info, Loader2, RefreshCw, Trash2 } from "lucide-react";
|
import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||||
|
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||||
import { getConnectorConfigComponent } from "../index";
|
import { getConnectorConfigComponent } from "../index";
|
||||||
|
|
||||||
interface ConnectorEditViewProps {
|
interface ConnectorEditViewProps {
|
||||||
|
|
@ -97,12 +99,16 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
};
|
};
|
||||||
}, [checkScrollState]);
|
}, [checkScrollState]);
|
||||||
|
|
||||||
// Reset local quick indexing state when indexing completes
|
// Reset local quick indexing state when indexing completes or fails
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isIndexing) {
|
if (!isIndexing && isQuickIndexing) {
|
||||||
|
// Small delay to ensure smooth transition
|
||||||
|
const timer = setTimeout(() => {
|
||||||
setIsQuickIndexing(false);
|
setIsQuickIndexing(false);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}, [isIndexing]);
|
}, [isIndexing, isQuickIndexing]);
|
||||||
|
|
||||||
const handleDisconnectClick = () => {
|
const handleDisconnectClick = () => {
|
||||||
setShowDisconnectConfirm(true);
|
setShowDisconnectConfirm(true);
|
||||||
|
|
@ -118,11 +124,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickIndex = useCallback(() => {
|
const handleQuickIndex = useCallback(() => {
|
||||||
if (onQuickIndex) {
|
if (onQuickIndex && !isQuickIndexing && !isIndexing) {
|
||||||
setIsQuickIndexing(true);
|
setIsQuickIndexing(true);
|
||||||
onQuickIndex();
|
onQuickIndex();
|
||||||
}
|
}
|
||||||
}, [onQuickIndex]);
|
}, [onQuickIndex, isQuickIndexing, isIndexing]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
|
|
@ -151,7 +157,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
|
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
|
||||||
{connector.name}
|
{getConnectorDisplayName(connector.name)}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||||
Manage your connector settings and sync configuration
|
Manage your connector settings and sync configuration
|
||||||
|
|
@ -206,8 +212,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||||
{connector.is_indexable && (
|
{connector.is_indexable && (
|
||||||
<>
|
<>
|
||||||
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
|
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||||
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
|
|
@ -217,6 +224,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
onEndDateChange={onEndDateChange}
|
onEndDateChange={onEndDateChange}
|
||||||
allowFutureDates={
|
allowFutureDates={
|
||||||
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
|
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||||
|
connector.connector_type === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||||
connector.connector_type === "LUMA_CONNECTOR"
|
connector.connector_type === "LUMA_CONNECTOR"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -224,8 +232,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
|
|
||||||
{/* Periodic sync - shown for all indexable connectors */}
|
{/* Periodic sync - shown for all indexable connectors */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Check if Google Drive has folders/files selected
|
// Check if Google Drive (regular or Composio) has folders/files selected
|
||||||
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
|
||||||
|
const isComposioGoogleDrive =
|
||||||
|
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||||
|
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||||
const selectedFolders =
|
const selectedFolders =
|
||||||
(connector.config?.selected_folders as
|
(connector.config?.selected_folders as
|
||||||
| Array<{ id: string; name: string }>
|
| Array<{ id: string; name: string }>
|
||||||
|
|
@ -235,7 +246,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
| Array<{ id: string; name: string }>
|
| Array<{ id: string; name: string }>
|
||||||
| undefined) || [];
|
| undefined) || [];
|
||||||
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||||
const isDisabled = isGoogleDrive && !hasItemsSelected;
|
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PeriodicSyncConfig
|
<PeriodicSyncConfig
|
||||||
|
|
@ -266,8 +277,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
Re-indexing runs in the background
|
Re-indexing runs in the background
|
||||||
</p>
|
</p>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||||
You can continue using SurfSense while we sync your data. Check the Active tab
|
You can continue using SurfSense while we sync your data. Check inbox for
|
||||||
to see progress.
|
updates.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -301,7 +312,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
>
|
>
|
||||||
{isDisconnecting ? (
|
{isDisconnecting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Disconnecting
|
Disconnecting
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -337,8 +348,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Saving...
|
Saving
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Save Changes"
|
"Save Changes"
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,16 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
import { ArrowLeft, Check, Info } from "lucide-react";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||||
import { type IndexingConfigState, OAUTH_CONNECTORS } from "../../constants/connector-constants";
|
import type { IndexingConfigState } from "../../constants/connector-constants";
|
||||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||||
import { getConnectorConfigComponent } from "../index";
|
import { getConnectorConfigComponent } from "../index";
|
||||||
|
|
||||||
|
|
@ -91,8 +92,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
};
|
};
|
||||||
}, [checkScrollState]);
|
}, [checkScrollState]);
|
||||||
|
|
||||||
const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
|
|
@ -151,8 +150,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||||
{connector?.is_indexable && (
|
{connector?.is_indexable && (
|
||||||
<>
|
<>
|
||||||
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
{/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
|
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
|
|
@ -162,13 +162,15 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
onEndDateChange={onEndDateChange}
|
onEndDateChange={onEndDateChange}
|
||||||
allowFutureDates={
|
allowFutureDates={
|
||||||
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
|
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||||
|
config.connectorType === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||||
config.connectorType === "LUMA_CONNECTOR"
|
config.connectorType === "LUMA_CONNECTOR"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Periodic sync - not shown for Google Drive */}
|
{/* Periodic sync - not shown for Google Drive (regular and Composio) */}
|
||||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && (
|
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
|
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
|
||||||
<PeriodicSyncConfig
|
<PeriodicSyncConfig
|
||||||
enabled={periodicEnabled}
|
enabled={periodicEnabled}
|
||||||
frequencyMinutes={frequencyMinutes}
|
frequencyMinutes={frequencyMinutes}
|
||||||
|
|
@ -188,8 +190,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
<div className="text-xs sm:text-sm">
|
<div className="text-xs sm:text-sm">
|
||||||
<p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p>
|
<p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p>
|
||||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||||
You can continue using SurfSense while we sync your data. Check the Active tab
|
You can continue using SurfSense while we sync your data. Check inbox for
|
||||||
to see progress.
|
updates.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -215,7 +217,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
>
|
>
|
||||||
{isStartingIndexing ? (
|
{isStartingIndexing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
Starting...
|
Starting...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -175,14 +175,28 @@ export const OTHER_CONNECTORS = [
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Composio Connector (Single entry that opens toolkit selector)
|
// Composio Connectors - Individual entries for each supported toolkit
|
||||||
export const COMPOSIO_CONNECTORS = [
|
export const COMPOSIO_CONNECTORS = [
|
||||||
{
|
{
|
||||||
id: "composio-connector",
|
id: "composio-googledrive",
|
||||||
title: "Composio",
|
title: "Google Drive",
|
||||||
description: "Connect 100+ apps via Composio (Google, Slack, Notion, etc.)",
|
description: "Search your Drive files via Composio",
|
||||||
connectorType: EnumConnectorName.COMPOSIO_CONNECTOR,
|
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||||
// No authEndpoint - handled via toolkit selector view
|
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=googledrive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "composio-gmail",
|
||||||
|
title: "Gmail",
|
||||||
|
description: "Search through your emails via Composio",
|
||||||
|
connectorType: EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR,
|
||||||
|
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=gmail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "composio-googlecalendar",
|
||||||
|
title: "Google Calendar",
|
||||||
|
description: "Search through your events via Composio",
|
||||||
|
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
|
||||||
|
authEndpoint: "/api/v1/auth/composio/connector/add/?toolkit_id=googlecalendar",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
|
||||||
export const connectorPopupQueryParamsSchema = z.object({
|
export const connectorPopupQueryParamsSchema = z.object({
|
||||||
modal: z.enum(["connectors"]).optional(),
|
modal: z.enum(["connectors"]).optional(),
|
||||||
tab: z.enum(["all", "active"]).optional(),
|
tab: z.enum(["all", "active"]).optional(),
|
||||||
view: z.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list"]).optional(),
|
view: z
|
||||||
|
.enum(["configure", "edit", "connect", "youtube", "accounts", "mcp-list", "composio"])
|
||||||
|
.optional(),
|
||||||
connector: z.string().optional(),
|
connector: z.string().optional(),
|
||||||
connectorId: z.string().optional(),
|
connectorId: z.string().optional(),
|
||||||
connectorType: z.string().optional(),
|
connectorType: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,11 @@ import {
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { queryClient } from "@/lib/query-client/client";
|
import { queryClient } from "@/lib/query-client/client";
|
||||||
import type { IndexingConfigState } from "../constants/connector-constants";
|
import type { IndexingConfigState } from "../constants/connector-constants";
|
||||||
import { OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
import {
|
||||||
|
COMPOSIO_CONNECTORS,
|
||||||
|
OAUTH_CONNECTORS,
|
||||||
|
OTHER_CONNECTORS,
|
||||||
|
} from "../constants/connector-constants";
|
||||||
import {
|
import {
|
||||||
dateRangeSchema,
|
dateRangeSchema,
|
||||||
frequencyMinutesSchema,
|
frequencyMinutesSchema,
|
||||||
|
|
@ -83,10 +87,6 @@ export const useConnectorDialog = () => {
|
||||||
// MCP list view state (for managing multiple MCP connectors)
|
// MCP list view state (for managing multiple MCP connectors)
|
||||||
const [viewingMCPList, setViewingMCPList] = useState(false);
|
const [viewingMCPList, setViewingMCPList] = useState(false);
|
||||||
|
|
||||||
// Composio toolkit view state
|
|
||||||
const [viewingComposio, setViewingComposio] = useState(false);
|
|
||||||
const [connectingComposioToolkit, setConnectingComposioToolkit] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Track if we came from accounts list when entering edit mode
|
// Track if we came from accounts list when entering edit mode
|
||||||
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
||||||
connectorType: string;
|
connectorType: string;
|
||||||
|
|
@ -159,27 +159,22 @@ export const useConnectorDialog = () => {
|
||||||
setViewingMCPList(true);
|
setViewingMCPList(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear Composio view if view is not "composio" anymore
|
|
||||||
if (params.view !== "composio" && viewingComposio) {
|
|
||||||
setViewingComposio(false);
|
|
||||||
setConnectingComposioToolkit(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Composio view
|
|
||||||
if (params.view === "composio" && !viewingComposio) {
|
|
||||||
setViewingComposio(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle connect view
|
// Handle connect view
|
||||||
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
||||||
setConnectingConnectorType(params.connectorType);
|
setConnectingConnectorType(params.connectorType);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle accounts view
|
// Handle accounts view
|
||||||
if (params.view === "accounts" && params.connectorType && !viewingAccountsType) {
|
if (params.view === "accounts" && params.connectorType) {
|
||||||
const oauthConnector = OAUTH_CONNECTORS.find(
|
// Update state if not set, or if connectorType has changed
|
||||||
(c) => c.connectorType === params.connectorType
|
const needsUpdate =
|
||||||
);
|
!viewingAccountsType || viewingAccountsType.connectorType !== params.connectorType;
|
||||||
|
|
||||||
|
if (needsUpdate) {
|
||||||
|
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||||
|
const oauthConnector =
|
||||||
|
OAUTH_CONNECTORS.find((c) => c.connectorType === params.connectorType) ||
|
||||||
|
COMPOSIO_CONNECTORS.find((c) => c.connectorType === params.connectorType);
|
||||||
if (oauthConnector) {
|
if (oauthConnector) {
|
||||||
setViewingAccountsType({
|
setViewingAccountsType({
|
||||||
connectorType: oauthConnector.connectorType,
|
connectorType: oauthConnector.connectorType,
|
||||||
|
|
@ -187,6 +182,7 @@ export const useConnectorDialog = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle YouTube view
|
// Handle YouTube view
|
||||||
if (params.view === "youtube") {
|
if (params.view === "youtube") {
|
||||||
|
|
@ -195,7 +191,10 @@ export const useConnectorDialog = () => {
|
||||||
|
|
||||||
// Handle configure view (for page refresh support)
|
// Handle configure view (for page refresh support)
|
||||||
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
|
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
|
||||||
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
|
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||||
|
const oauthConnector =
|
||||||
|
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||||
|
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
|
||||||
if (oauthConnector) {
|
if (oauthConnector) {
|
||||||
let existingConnector: SearchSourceConnector | undefined;
|
let existingConnector: SearchSourceConnector | undefined;
|
||||||
if (params.connectorId) {
|
if (params.connectorId) {
|
||||||
|
|
@ -293,6 +292,7 @@ export const useConnectorDialog = () => {
|
||||||
indexingConfig,
|
indexingConfig,
|
||||||
connectingConnectorType,
|
connectingConnectorType,
|
||||||
viewingAccountsType,
|
viewingAccountsType,
|
||||||
|
viewingMCPList,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Detect OAuth success / Failure and transition to config view
|
// Detect OAuth success / Failure and transition to config view
|
||||||
|
|
@ -328,31 +328,46 @@ export const useConnectorDialog = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
|
||||||
params.success === "true" &&
|
|
||||||
params.connector &&
|
|
||||||
searchSpaceId &&
|
|
||||||
params.modal === "connectors"
|
|
||||||
) {
|
|
||||||
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
|
|
||||||
if (oauthConnector) {
|
|
||||||
refetchAllConnectors().then((result) => {
|
refetchAllConnectors().then((result) => {
|
||||||
if (!result.data) return;
|
if (!result.data) return;
|
||||||
|
|
||||||
let newConnector: SearchSourceConnector | undefined;
|
let newConnector: SearchSourceConnector | undefined;
|
||||||
|
let oauthConnector:
|
||||||
|
| (typeof OAUTH_CONNECTORS)[number]
|
||||||
|
| (typeof COMPOSIO_CONNECTORS)[number]
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
// First, try to find connector by connectorId if provided
|
||||||
if (params.connectorId) {
|
if (params.connectorId) {
|
||||||
const connectorId = parseInt(params.connectorId, 10);
|
const connectorId = parseInt(params.connectorId, 10);
|
||||||
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||||
} else {
|
|
||||||
newConnector = result.data.find(
|
// If we found the connector, find the matching OAuth/Composio connector by type
|
||||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
|
if (newConnector) {
|
||||||
);
|
oauthConnector =
|
||||||
|
OAUTH_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type) ||
|
||||||
|
COMPOSIO_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newConnector) {
|
// If we don't have a connector yet, try to find by connector param
|
||||||
|
if (!newConnector && params.connector) {
|
||||||
|
oauthConnector =
|
||||||
|
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||||
|
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
|
||||||
|
|
||||||
|
if (oauthConnector) {
|
||||||
|
newConnector = result.data.find(
|
||||||
|
(c: SearchSourceConnector) => c.connector_type === oauthConnector!.connectorType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newConnector && oauthConnector) {
|
||||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||||
if (connectorValidation.success) {
|
if (connectorValidation.success) {
|
||||||
// Track connector connected event for OAuth connectors
|
// Track connector connected event for OAuth/Composio connectors
|
||||||
trackConnectorConnected(
|
trackConnectorConnected(
|
||||||
Number(searchSpaceId),
|
Number(searchSpaceId),
|
||||||
oauthConnector.connectorType,
|
oauthConnector.connectorType,
|
||||||
|
|
@ -380,7 +395,6 @@ export const useConnectorDialog = () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Invalid query params - log but don't crash
|
// Invalid query params - log but don't crash
|
||||||
console.warn("Invalid connector popup query params in OAuth success handler:", error);
|
console.warn("Invalid connector popup query params in OAuth success handler:", error);
|
||||||
|
|
@ -389,17 +403,18 @@ export const useConnectorDialog = () => {
|
||||||
|
|
||||||
// Handle OAuth connection
|
// Handle OAuth connection
|
||||||
const handleConnectOAuth = useCallback(
|
const handleConnectOAuth = useCallback(
|
||||||
async (connector: (typeof OAUTH_CONNECTORS)[number]) => {
|
async (connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]) => {
|
||||||
if (!searchSpaceId || !connector.authEndpoint) return;
|
if (!searchSpaceId || !connector.authEndpoint) return;
|
||||||
|
|
||||||
// Set connecting state immediately to disable button and show spinner
|
// Set connecting state immediately to disable button and show spinner
|
||||||
setConnectingId(connector.id);
|
setConnectingId(connector.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
// Check if authEndpoint already has query parameters
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`,
|
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
||||||
{ method: "GET" }
|
const url = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
|
||||||
);
|
|
||||||
|
const response = await authenticatedFetch(url, { method: "GET" });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to initiate ${connector.title} OAuth`);
|
throw new Error(`Failed to initiate ${connector.title} OAuth`);
|
||||||
|
|
@ -799,23 +814,19 @@ export const useConnectorDialog = () => {
|
||||||
|
|
||||||
// Handle viewing accounts list for OAuth connector type
|
// Handle viewing accounts list for OAuth connector type
|
||||||
const handleViewAccountsList = useCallback(
|
const handleViewAccountsList = useCallback(
|
||||||
(connectorType: string, connectorTitle: string) => {
|
(connectorType: string, _connectorTitle?: string) => {
|
||||||
if (!searchSpaceId) return;
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
setViewingAccountsType({
|
|
||||||
connectorType,
|
|
||||||
connectorTitle,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update URL to show accounts view, preserving current tab
|
// Update URL to show accounts view, preserving current tab
|
||||||
|
// The useEffect will handle setting viewingAccountsType based on URL params
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("modal", "connectors");
|
url.searchParams.set("modal", "connectors");
|
||||||
url.searchParams.set("view", "accounts");
|
url.searchParams.set("view", "accounts");
|
||||||
url.searchParams.set("connectorType", connectorType);
|
url.searchParams.set("connectorType", connectorType);
|
||||||
// Keep the current tab in URL so we can go back to it
|
// Keep the current tab in URL so we can go back to it
|
||||||
window.history.pushState({ modal: true }, "", url.toString());
|
router.replace(url.pathname + url.search, { scroll: false });
|
||||||
},
|
},
|
||||||
[searchSpaceId]
|
[searchSpaceId, router]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle going back from accounts list view
|
// Handle going back from accounts list view
|
||||||
|
|
@ -839,8 +850,8 @@ export const useConnectorDialog = () => {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set("modal", "connectors");
|
url.searchParams.set("modal", "connectors");
|
||||||
url.searchParams.set("view", "mcp-list");
|
url.searchParams.set("view", "mcp-list");
|
||||||
window.history.pushState({ modal: true }, "", url.toString());
|
router.replace(url.pathname + url.search, { scroll: false });
|
||||||
}, [searchSpaceId]);
|
}, [searchSpaceId, router]);
|
||||||
|
|
||||||
// Handle going back from MCP list view
|
// Handle going back from MCP list view
|
||||||
const handleBackFromMCPList = useCallback(() => {
|
const handleBackFromMCPList = useCallback(() => {
|
||||||
|
|
@ -861,71 +872,15 @@ export const useConnectorDialog = () => {
|
||||||
router.replace(url.pathname + url.search, { scroll: false });
|
router.replace(url.pathname + url.search, { scroll: false });
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
// Handle opening Composio toolkit view
|
|
||||||
const handleOpenComposio = useCallback(() => {
|
|
||||||
if (!searchSpaceId) return;
|
|
||||||
|
|
||||||
setViewingComposio(true);
|
|
||||||
|
|
||||||
// Update URL to show Composio view
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set("modal", "connectors");
|
|
||||||
url.searchParams.set("view", "composio");
|
|
||||||
window.history.pushState({ modal: true }, "", url.toString());
|
|
||||||
}, [searchSpaceId]);
|
|
||||||
|
|
||||||
// Handle going back from Composio view
|
|
||||||
const handleBackFromComposio = useCallback(() => {
|
|
||||||
setViewingComposio(false);
|
|
||||||
setConnectingComposioToolkit(null);
|
|
||||||
const url = new URL(window.location.href);
|
|
||||||
url.searchParams.set("modal", "connectors");
|
|
||||||
url.searchParams.delete("view");
|
|
||||||
router.replace(url.pathname + url.search, { scroll: false });
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// Handle connecting a Composio toolkit
|
|
||||||
const handleConnectComposioToolkit = useCallback(
|
|
||||||
async (toolkitId: string) => {
|
|
||||||
if (!searchSpaceId) return;
|
|
||||||
|
|
||||||
setConnectingComposioToolkit(toolkitId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await authenticatedFetch(
|
|
||||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/composio/connector/add?space_id=${searchSpaceId}&toolkit_id=${toolkitId}`,
|
|
||||||
{ method: "GET" }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to initiate Composio OAuth for ${toolkitId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.auth_url) {
|
|
||||||
// Redirect to Composio OAuth
|
|
||||||
window.location.href = data.auth_url;
|
|
||||||
} else {
|
|
||||||
throw new Error("No authorization URL received from Composio");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error connecting Composio toolkit:", error);
|
|
||||||
toast.error(`Failed to connect ${toolkitId}. Please try again.`);
|
|
||||||
setConnectingComposioToolkit(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[searchSpaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle starting indexing
|
// Handle starting indexing
|
||||||
const handleStartIndexing = useCallback(
|
const handleStartIndexing = useCallback(
|
||||||
async (refreshConnectors: () => void) => {
|
async (refreshConnectors: () => void) => {
|
||||||
if (!indexingConfig || !searchSpaceId) return;
|
if (!indexingConfig || !searchSpaceId) return;
|
||||||
|
|
||||||
// Validate date range (skip for Google Drive and Webcrawler)
|
// Validate date range (skip for Google Drive, Composio Drive, and Webcrawler)
|
||||||
if (
|
if (
|
||||||
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
|
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
|
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
|
||||||
) {
|
) {
|
||||||
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
||||||
|
|
@ -970,8 +925,12 @@ export const useConnectorDialog = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Google Drive folder selection
|
// Handle Google Drive folder selection (regular and Composio)
|
||||||
if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) {
|
if (
|
||||||
|
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||||
|
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR") &&
|
||||||
|
indexingConnectorConfig
|
||||||
|
) {
|
||||||
const selectedFolders = indexingConnectorConfig.selected_folders as
|
const selectedFolders = indexingConnectorConfig.selected_folders as
|
||||||
| Array<{ id: string; name: string }>
|
| Array<{ id: string; name: string }>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
@ -1191,8 +1150,12 @@ export const useConnectorDialog = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent periodic indexing for Google Drive without folders/files selected
|
// Prevent periodic indexing for Google Drive (regular or Composio) without folders/files selected
|
||||||
if (periodicEnabled && editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
|
if (
|
||||||
|
periodicEnabled &&
|
||||||
|
(editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||||
|
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR")
|
||||||
|
) {
|
||||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||||
| Array<{ id: string; name: string }>
|
| Array<{ id: string; name: string }>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
@ -1241,8 +1204,11 @@ export const useConnectorDialog = () => {
|
||||||
if (!editingConnector.is_indexable) {
|
if (!editingConnector.is_indexable) {
|
||||||
// Non-indexable connectors (like Tavily API) don't need re-indexing
|
// Non-indexable connectors (like Tavily API) don't need re-indexing
|
||||||
indexingDescription = "Settings saved.";
|
indexingDescription = "Settings saved.";
|
||||||
} else if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
|
} else if (
|
||||||
// Google Drive uses folder selection from config, not date ranges
|
editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||||
|
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
|
||||||
|
) {
|
||||||
|
// Google Drive (both regular and Composio) uses folder selection from config, not date ranges
|
||||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||||
| Array<{ id: string; name: string }>
|
| Array<{ id: string; name: string }>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
@ -1423,13 +1389,24 @@ export const useConnectorDialog = () => {
|
||||||
setIsDisconnecting(false);
|
setIsDisconnecting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editingConnector, searchSpaceId, deleteConnector, router]
|
[editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle quick index (index without date picker, uses backend defaults)
|
// Handle quick index (index with selected date range, or backend defaults if none selected)
|
||||||
const handleQuickIndexConnector = useCallback(
|
const handleQuickIndexConnector = useCallback(
|
||||||
async (connectorId: number, connectorType?: string) => {
|
async (
|
||||||
if (!searchSpaceId) return;
|
connectorId: number,
|
||||||
|
connectorType?: string,
|
||||||
|
stopIndexing?: (id: number) => void,
|
||||||
|
startDate?: Date,
|
||||||
|
endDate?: Date
|
||||||
|
) => {
|
||||||
|
if (!searchSpaceId) {
|
||||||
|
if (stopIndexing) {
|
||||||
|
stopIndexing(connectorId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Track quick index clicked event
|
// Track quick index clicked event
|
||||||
if (connectorType) {
|
if (connectorType) {
|
||||||
|
|
@ -1437,10 +1414,16 @@ export const useConnectorDialog = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Format dates if provided, otherwise pass undefined (backend will use defaults)
|
||||||
|
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
|
||||||
|
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
|
||||||
|
|
||||||
await indexConnector({
|
await indexConnector({
|
||||||
connector_id: connectorId,
|
connector_id: connectorId,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
|
start_date: startDateStr,
|
||||||
|
end_date: endDateStr,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.success("Indexing started", {
|
toast.success("Indexing started", {
|
||||||
|
|
@ -1451,12 +1434,18 @@ export const useConnectorDialog = () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
|
||||||
});
|
});
|
||||||
|
// Note: Don't call stopIndexing here - let useIndexingConnectors hook
|
||||||
|
// detect when last_indexed_at changes via Electric SQL
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error indexing connector content:", error);
|
console.error("Error indexing connector content:", error);
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to start indexing");
|
toast.error(error instanceof Error ? error.message : "Failed to start indexing");
|
||||||
|
// Stop indexing state on error
|
||||||
|
if (stopIndexing) {
|
||||||
|
stopIndexing(connectorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[searchSpaceId, indexConnector]
|
[searchSpaceId, indexConnector, queryClient]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle going back from edit view
|
// Handle going back from edit view
|
||||||
|
|
@ -1578,7 +1567,6 @@ export const useConnectorDialog = () => {
|
||||||
allConnectors,
|
allConnectors,
|
||||||
viewingAccountsType,
|
viewingAccountsType,
|
||||||
viewingMCPList,
|
viewingMCPList,
|
||||||
viewingComposio,
|
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
|
@ -1614,12 +1602,5 @@ export const useConnectorDialog = () => {
|
||||||
connectorConfig,
|
connectorConfig,
|
||||||
setConnectorConfig,
|
setConnectorConfig,
|
||||||
setIndexingConnectorConfig,
|
setIndexingConnectorConfig,
|
||||||
|
|
||||||
// Composio
|
|
||||||
viewingComposio,
|
|
||||||
connectingComposioToolkit,
|
|
||||||
handleOpenComposio,
|
|
||||||
handleBackFromComposio,
|
|
||||||
handleConnectComposioToolkit,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,24 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
import type { InboxItem } from "@/contracts/types/inbox.types";
|
||||||
|
import { isConnectorIndexingMetadata } from "@/contracts/types/inbox.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to track which connectors are currently indexing using local state.
|
* Hook to track which connectors are currently indexing using local state.
|
||||||
*
|
*
|
||||||
* This provides a better UX than polling by:
|
* This provides a better UX than polling by:
|
||||||
* 1. Setting indexing state immediately when user triggers indexing (optimistic)
|
* 1. Setting indexing state immediately when user triggers indexing (optimistic)
|
||||||
* 2. Clearing indexing state when Electric SQL detects last_indexed_at changed
|
* 2. Detecting in_progress notifications from Electric SQL to restore state after remounts
|
||||||
|
* 3. Clearing indexing state when notifications become completed or failed
|
||||||
|
* 4. Clearing indexing state when Electric SQL detects last_indexed_at changed
|
||||||
*
|
*
|
||||||
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
|
* The actual `last_indexed_at` value comes from Electric SQL/PGlite, not local state.
|
||||||
*/
|
*/
|
||||||
export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
|
export function useIndexingConnectors(
|
||||||
|
connectors: SearchSourceConnector[],
|
||||||
|
inboxItems?: InboxItem[]
|
||||||
|
) {
|
||||||
// Set of connector IDs that are currently indexing
|
// Set of connector IDs that are currently indexing
|
||||||
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
|
const [indexingConnectorIds, setIndexingConnectorIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
|
@ -22,31 +29,71 @@ export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
|
||||||
// Detect when last_indexed_at changes (indexing completed) via Electric SQL
|
// Detect when last_indexed_at changes (indexing completed) via Electric SQL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const previousValues = previousLastIndexedAtRef.current;
|
const previousValues = previousLastIndexedAtRef.current;
|
||||||
const newIndexingIds = new Set(indexingConnectorIds);
|
|
||||||
let hasChanges = false;
|
|
||||||
|
|
||||||
for (const connector of connectors) {
|
for (const connector of connectors) {
|
||||||
const previousValue = previousValues.get(connector.id);
|
const previousValue = previousValues.get(connector.id);
|
||||||
const currentValue = connector.last_indexed_at;
|
const currentValue = connector.last_indexed_at;
|
||||||
|
|
||||||
// If last_indexed_at changed and connector was in indexing state, clear it
|
// If last_indexed_at changed, clear it from indexing state
|
||||||
if (
|
if (
|
||||||
previousValue !== undefined && // We've seen this connector before
|
previousValue !== undefined && // We've seen this connector before
|
||||||
previousValue !== currentValue && // Value changed
|
previousValue !== currentValue // Value changed
|
||||||
indexingConnectorIds.has(connector.id) // It was marked as indexing
|
|
||||||
) {
|
) {
|
||||||
newIndexingIds.delete(connector.id);
|
// Use functional update to access current state
|
||||||
hasChanges = true;
|
setIndexingConnectorIds((prev) => {
|
||||||
|
if (prev.has(connector.id)) {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(connector.id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update previous value tracking
|
// Update previous value tracking
|
||||||
previousValues.set(connector.id, currentValue);
|
previousValues.set(connector.id, currentValue);
|
||||||
}
|
}
|
||||||
|
}, [connectors]);
|
||||||
|
|
||||||
if (hasChanges) {
|
// Detect notification status changes and update indexing state accordingly
|
||||||
setIndexingConnectorIds(newIndexingIds);
|
// This restores spinner state after component remounts and handles all status transitions
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inboxItems || inboxItems.length === 0) return;
|
||||||
|
|
||||||
|
setIndexingConnectorIds((prev) => {
|
||||||
|
const newIndexingIds = new Set(prev);
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
for (const item of inboxItems) {
|
||||||
|
// Only check connector_indexing notifications
|
||||||
|
if (item.type !== "connector_indexing") continue;
|
||||||
|
|
||||||
|
const metadata = isConnectorIndexingMetadata(item.metadata) ? item.metadata : null;
|
||||||
|
if (!metadata) continue;
|
||||||
|
|
||||||
|
// If status is "in_progress", add connector to indexing set
|
||||||
|
if (metadata.status === "in_progress") {
|
||||||
|
if (!newIndexingIds.has(metadata.connector_id)) {
|
||||||
|
newIndexingIds.add(metadata.connector_id);
|
||||||
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
}, [connectors, indexingConnectorIds]);
|
}
|
||||||
|
// If status is "completed" or "failed", remove connector from indexing set
|
||||||
|
else if (
|
||||||
|
metadata.status === "completed" ||
|
||||||
|
metadata.status === "failed" ||
|
||||||
|
(metadata.error_message && metadata.error_message.trim().length > 0)
|
||||||
|
) {
|
||||||
|
if (newIndexingIds.has(metadata.connector_id)) {
|
||||||
|
newIndexingIds.delete(metadata.connector_id);
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasChanges ? newIndexingIds : prev;
|
||||||
|
});
|
||||||
|
}, [inboxItems]);
|
||||||
|
|
||||||
// Add a connector to the indexing set (called when indexing starts)
|
// Add a connector to the indexing set (called when indexing starts)
|
||||||
const startIndexing = useCallback((connectorId: number) => {
|
const startIndexing = useCallback((connectorId: number) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowRight, Cable, Loader2 } from "lucide-react";
|
import { ArrowRight, Cable } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { TabsContent } from "@/components/ui/tabs";
|
import { TabsContent } from "@/components/ui/tabs";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
|
@ -13,8 +14,9 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
||||||
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { OAUTH_CONNECTORS } from "../constants/connector-constants";
|
import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
|
||||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||||
|
import { getConnectorDisplayName } from "./all-connectors-tab";
|
||||||
|
|
||||||
interface ActiveConnectorsTabProps {
|
interface ActiveConnectorsTabProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
|
|
@ -113,7 +115,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
|
|
||||||
// Get display info for OAuth connector type
|
// Get display info for OAuth connector type
|
||||||
const getOAuthConnectorTypeInfo = (connectorType: string) => {
|
const getOAuthConnectorTypeInfo = (connectorType: string) => {
|
||||||
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType);
|
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
|
||||||
|
const oauthConnector =
|
||||||
|
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
|
||||||
|
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
|
||||||
return {
|
return {
|
||||||
title:
|
title:
|
||||||
oauthConnector?.title ||
|
oauthConnector?.title ||
|
||||||
|
|
@ -205,7 +210,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
|
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
|
||||||
{isAnyIndexing ? (
|
{isAnyIndexing ? (
|
||||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Spinner size="xs" />
|
||||||
Syncing
|
Syncing
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -260,13 +265,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-[14px] font-semibold leading-tight">
|
<p className="text-[14px] font-semibold leading-tight truncate">
|
||||||
{connector.name}
|
{getConnectorDisplayName(connector.name)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{isIndexing ? (
|
{isIndexing ? (
|
||||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Spinner size="xs" />
|
||||||
Syncing
|
Syncing
|
||||||
</p>
|
</p>
|
||||||
) : !isMCPConnector ? (
|
) : !isMCPConnector ? (
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import type { FC } from "react";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { isSelfHosted } from "@/lib/env-config";
|
import { isSelfHosted } from "@/lib/env-config";
|
||||||
import { ComposioConnectorCard } from "../components/composio-connector-card";
|
|
||||||
import { ConnectorCard } from "../components/connector-card";
|
import { ConnectorCard } from "../components/connector-card";
|
||||||
import {
|
import {
|
||||||
COMPOSIO_CONNECTORS,
|
COMPOSIO_CONNECTORS,
|
||||||
|
|
@ -35,13 +34,14 @@ interface AllConnectorsTabProps {
|
||||||
allConnectors: SearchSourceConnector[] | undefined;
|
allConnectors: SearchSourceConnector[] | undefined;
|
||||||
documentTypeCounts?: Record<string, number>;
|
documentTypeCounts?: Record<string, number>;
|
||||||
indexingConnectorIds?: Set<number>;
|
indexingConnectorIds?: Set<number>;
|
||||||
onConnectOAuth: (connector: (typeof OAUTH_CONNECTORS)[number]) => void;
|
onConnectOAuth: (
|
||||||
|
connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]
|
||||||
|
) => void;
|
||||||
onConnectNonOAuth?: (connectorType: string) => void;
|
onConnectNonOAuth?: (connectorType: string) => void;
|
||||||
onCreateWebcrawler?: () => void;
|
onCreateWebcrawler?: () => void;
|
||||||
onCreateYouTubeCrawler?: () => void;
|
onCreateYouTubeCrawler?: () => void;
|
||||||
onManage?: (connector: SearchSourceConnector) => void;
|
onManage?: (connector: SearchSourceConnector) => void;
|
||||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||||
onOpenComposio?: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
|
|
@ -57,7 +57,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
onCreateYouTubeCrawler,
|
onCreateYouTubeCrawler,
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
onViewAccountsList,
|
||||||
onOpenComposio,
|
|
||||||
}) => {
|
}) => {
|
||||||
// Check if self-hosted mode (for showing self-hosted only connectors)
|
// Check if self-hosted mode (for showing self-hosted only connectors)
|
||||||
const selfHosted = isSelfHosted();
|
const selfHosted = isSelfHosted();
|
||||||
|
|
@ -93,23 +92,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Count Composio connectors
|
|
||||||
const composioConnectorCount = allConnectors
|
|
||||||
? allConnectors.filter(
|
|
||||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.COMPOSIO_CONNECTOR
|
|
||||||
).length
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Quick Connect */}
|
{/* Managed OAuth (Composio Integrations) */}
|
||||||
{filteredOAuth.length > 0 && (
|
{filteredComposio.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground">Quick Connect</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||||
|
Managed OAuth (Composio)
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{filteredOAuth.map((connector) => {
|
{filteredComposio.map((connector) => {
|
||||||
const isConnected = connectedTypes.has(connector.connectorType);
|
const isConnected = connectedTypes.has(connector.connectorType);
|
||||||
const isConnecting = connectingId === connector.id;
|
const isConnecting = connectingId === connector.id;
|
||||||
|
|
||||||
|
|
@ -123,18 +117,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
|
|
||||||
const accountCount = typeConnectors.length;
|
const accountCount = typeConnectors.length;
|
||||||
|
|
||||||
// Get the most recent last_indexed_at across all accounts
|
|
||||||
const mostRecentLastIndexed = typeConnectors.reduce<string | undefined>(
|
|
||||||
(latest, c) => {
|
|
||||||
if (!c.last_indexed_at) return latest;
|
|
||||||
if (!latest) return c.last_indexed_at;
|
|
||||||
return new Date(c.last_indexed_at) > new Date(latest)
|
|
||||||
? c.last_indexed_at
|
|
||||||
: latest;
|
|
||||||
},
|
|
||||||
undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const documentCount = getDocumentCountForConnector(
|
const documentCount = getDocumentCountForConnector(
|
||||||
connector.connectorType,
|
connector.connectorType,
|
||||||
documentTypeCounts
|
documentTypeCounts
|
||||||
|
|
@ -168,29 +150,59 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Composio Integrations */}
|
{/* Quick Connect */}
|
||||||
{/* {filteredComposio.length > 0 && onOpenComposio && (
|
{filteredOAuth.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground">Quick Connect</h3>
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 font-medium">
|
|
||||||
No verification needed
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{filteredComposio.map((connector) => (
|
{filteredOAuth.map((connector) => {
|
||||||
<ComposioConnectorCard
|
const isConnected = connectedTypes.has(connector.connectorType);
|
||||||
|
const isConnecting = connectingId === connector.id;
|
||||||
|
|
||||||
|
// Find all connectors of this type
|
||||||
|
const typeConnectors =
|
||||||
|
isConnected && allConnectors
|
||||||
|
? allConnectors.filter(
|
||||||
|
(c: SearchSourceConnector) => c.connector_type === connector.connectorType
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const accountCount = typeConnectors.length;
|
||||||
|
|
||||||
|
const documentCount = getDocumentCountForConnector(
|
||||||
|
connector.connectorType,
|
||||||
|
documentTypeCounts
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if any account is currently indexing
|
||||||
|
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConnectorCard
|
||||||
key={connector.id}
|
key={connector.id}
|
||||||
id={connector.id}
|
id={connector.id}
|
||||||
title={connector.title}
|
title={connector.title}
|
||||||
description={connector.description}
|
description={connector.description}
|
||||||
connectorCount={composioConnectorCount}
|
connectorType={connector.connectorType}
|
||||||
onConnect={onOpenComposio}
|
isConnected={isConnected}
|
||||||
|
isConnecting={isConnecting}
|
||||||
|
documentCount={documentCount}
|
||||||
|
accountCount={accountCount}
|
||||||
|
isIndexing={isIndexing}
|
||||||
|
onConnect={() => onConnectOAuth(connector)}
|
||||||
|
onManage={
|
||||||
|
isConnected && onViewAccountsList
|
||||||
|
? () => onViewAccountsList(connector.connectorType, connector.title)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)} */}
|
)}
|
||||||
|
|
||||||
{/* More Integrations */}
|
{/* More Integrations */}
|
||||||
{filteredOther.length > 0 && (
|
{filteredOther.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
||||||
// Special mappings (connector type differs from document type)
|
// Special mappings (connector type differs from document type)
|
||||||
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||||
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
||||||
COMPOSIO_CONNECTOR: "COMPOSIO_CONNECTOR",
|
// Composio connectors map to their own document types
|
||||||
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
COMPOSIO_GMAIL_CONNECTOR: "COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,355 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
Calendar,
|
|
||||||
Check,
|
|
||||||
ExternalLink,
|
|
||||||
FileText,
|
|
||||||
Github,
|
|
||||||
HardDrive,
|
|
||||||
Loader2,
|
|
||||||
Mail,
|
|
||||||
MessageSquare,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import type { FC } from "react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface ComposioToolkit {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
isIndexable: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ComposioToolkitViewProps {
|
|
||||||
searchSpaceId: string;
|
|
||||||
connectedToolkits: string[];
|
|
||||||
onBack: () => void;
|
|
||||||
onConnectToolkit: (toolkitId: string) => void;
|
|
||||||
isConnecting: boolean;
|
|
||||||
connectingToolkitId: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available Composio toolkits
|
|
||||||
const COMPOSIO_TOOLKITS: ComposioToolkit[] = [
|
|
||||||
{
|
|
||||||
id: "googledrive",
|
|
||||||
name: "Google Drive",
|
|
||||||
description: "Search your Drive files and documents",
|
|
||||||
isIndexable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gmail",
|
|
||||||
name: "Gmail",
|
|
||||||
description: "Search through your emails",
|
|
||||||
isIndexable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "googlecalendar",
|
|
||||||
name: "Google Calendar",
|
|
||||||
description: "Search through your events",
|
|
||||||
isIndexable: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "slack",
|
|
||||||
name: "Slack",
|
|
||||||
description: "Search Slack messages",
|
|
||||||
isIndexable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notion",
|
|
||||||
name: "Notion",
|
|
||||||
description: "Search Notion pages",
|
|
||||||
isIndexable: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "github",
|
|
||||||
name: "GitHub",
|
|
||||||
description: "Search repositories and code",
|
|
||||||
isIndexable: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Get icon for toolkit
|
|
||||||
const getToolkitIcon = (toolkitId: string, className?: string) => {
|
|
||||||
const iconClass = className || "size-5";
|
|
||||||
|
|
||||||
switch (toolkitId) {
|
|
||||||
case "googledrive":
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src="/connectors/google-drive.svg"
|
|
||||||
alt="Google Drive"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={iconClass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "gmail":
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src="/connectors/google-gmail.svg"
|
|
||||||
alt="Gmail"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={iconClass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "googlecalendar":
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src="/connectors/google-calendar.svg"
|
|
||||||
alt="Google Calendar"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={iconClass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "slack":
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src="/connectors/slack.svg"
|
|
||||||
alt="Slack"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={iconClass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "notion":
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src="/connectors/notion.svg"
|
|
||||||
alt="Notion"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={iconClass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "github":
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src="/connectors/github.svg"
|
|
||||||
alt="GitHub"
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
className={iconClass}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return <Zap className={iconClass} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
|
||||||
searchSpaceId,
|
|
||||||
connectedToolkits,
|
|
||||||
onBack,
|
|
||||||
onConnectToolkit,
|
|
||||||
isConnecting,
|
|
||||||
connectingToolkitId,
|
|
||||||
}) => {
|
|
||||||
const [hoveredToolkit, setHoveredToolkit] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Separate indexable and non-indexable toolkits
|
|
||||||
const indexableToolkits = COMPOSIO_TOOLKITS.filter((t) => t.isIndexable);
|
|
||||||
const nonIndexableToolkits = COMPOSIO_TOOLKITS.filter((t) => !t.isIndexable);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 sm:pb-6 border-b border-border/50 bg-muted">
|
|
||||||
{/* Back button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onBack}
|
|
||||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit transition-colors"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="size-4" />
|
|
||||||
Back to connectors
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Header content */}
|
|
||||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
|
||||||
<div className="flex gap-4 flex-1 w-full sm:w-auto">
|
|
||||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-500/20 border border-violet-500/30 shrink-0">
|
|
||||||
<Image
|
|
||||||
src="/connectors/composio.svg"
|
|
||||||
alt="Composio"
|
|
||||||
width={28}
|
|
||||||
height={28}
|
|
||||||
className="size-7"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">Composio</h2>
|
|
||||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
|
||||||
Connect 100+ apps with managed OAuth - no verification needed
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
href="https://composio.dev"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
<span>Powered by Composio</span>
|
|
||||||
<ExternalLink className="size-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
|
|
||||||
{/* Indexable Toolkits (Google Services) */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
|
||||||
>
|
|
||||||
Indexable
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
|
||||||
Connect Google services via Composio's verified OAuth app. Your data will be
|
|
||||||
indexed and searchable.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{indexableToolkits.map((toolkit) => {
|
|
||||||
const isConnected = connectedToolkits.includes(toolkit.id);
|
|
||||||
const isThisConnecting = connectingToolkitId === toolkit.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={toolkit.id}
|
|
||||||
onMouseEnter={() => setHoveredToolkit(toolkit.id)}
|
|
||||||
onMouseLeave={() => setHoveredToolkit(null)}
|
|
||||||
className={cn(
|
|
||||||
"group relative flex flex-col p-4 rounded-xl border transition-all duration-200",
|
|
||||||
isConnected
|
|
||||||
? "border-emerald-500/30 bg-emerald-500/5"
|
|
||||||
: "border-border bg-card hover:border-violet-500/30 hover:bg-violet-500/5"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex h-10 w-10 items-center justify-center rounded-lg border transition-colors",
|
|
||||||
isConnected
|
|
||||||
? "bg-emerald-500/10 border-emerald-500/20"
|
|
||||||
: "bg-muted border-border group-hover:border-violet-500/20 group-hover:bg-violet-500/10"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{getToolkitIcon(toolkit.id, "size-5")}
|
|
||||||
</div>
|
|
||||||
{isConnected && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
|
||||||
>
|
|
||||||
<Check className="size-3 mr-0.5" />
|
|
||||||
Connected
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={isConnected ? "secondary" : "default"}
|
|
||||||
className={cn(
|
|
||||||
"w-full h-8 text-xs font-medium",
|
|
||||||
!isConnected && "bg-violet-600 hover:bg-violet-700 text-white"
|
|
||||||
)}
|
|
||||||
onClick={() => onConnectToolkit(toolkit.id)}
|
|
||||||
disabled={isConnecting || isConnected}
|
|
||||||
>
|
|
||||||
{isThisConnecting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="size-3 mr-1.5 animate-spin" />
|
|
||||||
Connecting...
|
|
||||||
</>
|
|
||||||
) : isConnected ? (
|
|
||||||
"Connected"
|
|
||||||
) : (
|
|
||||||
"Connect"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Non-Indexable Toolkits (Coming Soon) */}
|
|
||||||
<section>
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-4">
|
|
||||||
Connect these services for future indexing support. Currently available for connection
|
|
||||||
only.
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
|
|
||||||
{nonIndexableToolkits.map((toolkit) => (
|
|
||||||
<div
|
|
||||||
key={toolkit.id}
|
|
||||||
className="group relative flex flex-col p-4 rounded-xl border border-border bg-card/50"
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border bg-muted border-border">
|
|
||||||
{getToolkitIcon(toolkit.id, "size-5")}
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
|
||||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full h-8 text-xs font-medium"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Info footer */}
|
|
||||||
<div className="mt-8 p-4 rounded-xl bg-muted/50 border border-border/50">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 border border-violet-500/20 shrink-0">
|
|
||||||
<Zap className="size-4 text-violet-500" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
Composio provides pre-verified OAuth apps, so you don't need to wait for Google
|
|
||||||
app verification. Your data is securely processed through Composio's managed
|
|
||||||
authentication.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
|
||||||
import { ArrowLeft, Loader2, Plus, Server } from "lucide-react";
|
import { ArrowLeft, Plus, Server } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
|
@ -143,7 +144,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
>
|
>
|
||||||
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/10 shrink-0">
|
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/10 shrink-0">
|
||||||
{isConnecting ? (
|
{isConnecting ? (
|
||||||
<Loader2 className="size-3 animate-spin text-primary" />
|
<Spinner size="xs" className="text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="size-3 text-primary" />
|
<Plus className="size-3 text-primary" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -207,7 +208,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
||||||
</p>
|
</p>
|
||||||
{isIndexing ? (
|
{isIndexing ? (
|
||||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
<Spinner size="xs" />
|
||||||
Syncing
|
Syncing
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { TagInput, type Tag as TagType } from "emblor";
|
import { TagInput, type Tag as TagType } from "emblor";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
|
|
@ -10,6 +10,7 @@ import { toast } from "sonner";
|
||||||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
|
||||||
|
|
@ -222,7 +223,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
{t("processing")}
|
{t("processing")}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
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