mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-09 15:52:40 +02:00
Merge pull request #740 from MODSetter/dev
feat(0.0.12): bumped version, added composio connectors and various fixes
This commit is contained in:
commit
b87a8af036
77 changed files with 5672 additions and 2277 deletions
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -116,6 +201,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,
|
||||||
|
|
@ -123,6 +232,9 @@ async def create_surfsense_deep_agent(
|
||||||
"connector_service": connector_service,
|
"connector_service": connector_service,
|
||||||
"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
|
||||||
|
# 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):
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -5,33 +5,45 @@ import { Cable, Loader2 } 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 { 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 +100,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 +129,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 +150,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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -179,22 +187,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 +212,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 +262,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 +339,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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -24,6 +24,16 @@
|
||||||
"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."
|
||||||
|
},
|
||||||
|
"GITHUB_CONNECTOR": {
|
||||||
|
"enabled": false,
|
||||||
|
"status": "warning",
|
||||||
|
"statusMessage": "Some issues with indexing repositories."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"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";
|
||||||
|
|
@ -109,7 +109,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!">
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ 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 { getConnectorConfigComponent } from "../index";
|
import { getConnectorConfigComponent } from "../index";
|
||||||
|
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||||
|
|
||||||
interface ConnectorEditViewProps {
|
interface ConnectorEditViewProps {
|
||||||
connector: SearchSourceConnector;
|
connector: SearchSourceConnector;
|
||||||
|
|
@ -97,12 +98,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 +123,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 +156,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 +211,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 +223,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 +231,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 +245,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 +276,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>
|
||||||
|
|
@ -338,7 +348,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Saving...
|
Saving
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
"Save Changes"
|
"Save Changes"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ 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 +91,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 +149,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 +161,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 +189,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>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,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 +114,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 ||
|
||||||
|
|
@ -260,8 +264,8 @@ 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 ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -0,0 +1,364 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
File,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
FolderClosed,
|
||||||
|
FolderOpen,
|
||||||
|
HardDrive,
|
||||||
|
Image,
|
||||||
|
Loader2,
|
||||||
|
Presentation,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { useComposioDriveFolders } from "@/hooks/use-composio-drive-folders";
|
||||||
|
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface DriveItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
mimeType: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
parents?: string[];
|
||||||
|
size?: number;
|
||||||
|
iconLink?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemTreeNode {
|
||||||
|
item: DriveItem;
|
||||||
|
children: DriveItem[] | null; // null = not loaded, [] = loaded but empty
|
||||||
|
isExpanded: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectedFolder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposioDriveFolderTreeProps {
|
||||||
|
connectorId: number;
|
||||||
|
selectedFolders: SelectedFolder[];
|
||||||
|
onSelectFolders: (folders: SelectedFolder[]) => void;
|
||||||
|
selectedFiles?: SelectedFolder[];
|
||||||
|
onSelectFiles?: (files: SelectedFolder[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get appropriate icon for file type
|
||||||
|
function getFileIcon(mimeType: string, className: string = "h-4 w-4") {
|
||||||
|
if (mimeType.includes("spreadsheet") || mimeType.includes("excel")) {
|
||||||
|
return <FileSpreadsheet className={`${className} text-green-500`} />;
|
||||||
|
}
|
||||||
|
if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) {
|
||||||
|
return <Presentation className={`${className} text-orange-500`} />;
|
||||||
|
}
|
||||||
|
if (mimeType.includes("document") || mimeType.includes("word") || mimeType.includes("text")) {
|
||||||
|
return <FileText className={`${className} text-gray-500`} />;
|
||||||
|
}
|
||||||
|
if (mimeType.includes("image")) {
|
||||||
|
return <Image className={`${className} text-purple-500`} />;
|
||||||
|
}
|
||||||
|
return <File className={`${className} text-gray-500`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComposioDriveFolderTree({
|
||||||
|
connectorId,
|
||||||
|
selectedFolders,
|
||||||
|
onSelectFolders,
|
||||||
|
selectedFiles = [],
|
||||||
|
onSelectFiles = () => {},
|
||||||
|
}: ComposioDriveFolderTreeProps) {
|
||||||
|
const [itemStates, setItemStates] = useState<Map<string, ItemTreeNode>>(new Map());
|
||||||
|
|
||||||
|
const { data: rootData, isLoading: isLoadingRoot } = useComposioDriveFolders({
|
||||||
|
connectorId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootItems = rootData?.items || [];
|
||||||
|
|
||||||
|
const isFolderSelected = (folderId: string): boolean => {
|
||||||
|
return selectedFolders.some((f) => f.id === folderId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFileSelected = (fileId: string): boolean => {
|
||||||
|
return selectedFiles.some((f) => f.id === fileId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFolderSelection = (folderId: string, folderName: string) => {
|
||||||
|
if (isFolderSelected(folderId)) {
|
||||||
|
onSelectFolders(selectedFolders.filter((f) => f.id !== folderId));
|
||||||
|
} else {
|
||||||
|
onSelectFolders([...selectedFolders, { id: folderId, name: folderName }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFileSelection = (fileId: string, fileName: string) => {
|
||||||
|
if (isFileSelected(fileId)) {
|
||||||
|
onSelectFiles(selectedFiles.filter((f) => f.id !== fileId));
|
||||||
|
} else {
|
||||||
|
onSelectFiles([...selectedFiles, { id: fileId, name: fileName }]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an item by ID across all loaded items (root and nested).
|
||||||
|
*/
|
||||||
|
const findItem = (itemId: string): DriveItem | undefined => {
|
||||||
|
const state = itemStates.get(itemId);
|
||||||
|
if (state?.item) return state.item;
|
||||||
|
|
||||||
|
const rootItem = rootItems.find((item) => item.id === itemId);
|
||||||
|
if (rootItem) return rootItem;
|
||||||
|
|
||||||
|
for (const [, nodeState] of itemStates) {
|
||||||
|
if (nodeState.children) {
|
||||||
|
const found = nodeState.children.find((child) => child.id === itemId);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and display contents of a specific folder.
|
||||||
|
*/
|
||||||
|
const loadFolderContents = async (folderId: string) => {
|
||||||
|
try {
|
||||||
|
setItemStates((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const existing = newMap.get(folderId);
|
||||||
|
if (existing) {
|
||||||
|
newMap.set(folderId, { ...existing, isLoading: true });
|
||||||
|
} else {
|
||||||
|
const item = findItem(folderId);
|
||||||
|
if (item) {
|
||||||
|
newMap.set(folderId, {
|
||||||
|
item,
|
||||||
|
children: null,
|
||||||
|
isExpanded: false,
|
||||||
|
isLoading: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await connectorsApiService.listComposioDriveFolders({
|
||||||
|
connector_id: connectorId,
|
||||||
|
parent_id: folderId,
|
||||||
|
});
|
||||||
|
const items = data.items || [];
|
||||||
|
|
||||||
|
setItemStates((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const existing = newMap.get(folderId);
|
||||||
|
const item = existing?.item || findItem(folderId);
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
newMap.set(folderId, {
|
||||||
|
item,
|
||||||
|
children: items,
|
||||||
|
isExpanded: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(`Could not find item for folderId: ${folderId}`);
|
||||||
|
}
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading folder contents:", error);
|
||||||
|
setItemStates((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
const existing = newMap.get(folderId);
|
||||||
|
if (existing) {
|
||||||
|
newMap.set(folderId, { ...existing, isLoading: false });
|
||||||
|
}
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle folder expand/collapse state.
|
||||||
|
*/
|
||||||
|
const toggleFolder = async (item: DriveItem) => {
|
||||||
|
if (!item.isFolder) return;
|
||||||
|
|
||||||
|
const state = itemStates.get(item.id);
|
||||||
|
|
||||||
|
if (!state || state.children === null) {
|
||||||
|
await loadFolderContents(item.id);
|
||||||
|
} else {
|
||||||
|
setItemStates((prev) => {
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(item.id, {
|
||||||
|
...state,
|
||||||
|
isExpanded: !state.isExpanded,
|
||||||
|
});
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a single item (folder or file) with its children.
|
||||||
|
*/
|
||||||
|
const renderItem = (item: DriveItem, level: number = 0) => {
|
||||||
|
const state = itemStates.get(item.id);
|
||||||
|
const isExpanded = state?.isExpanded || false;
|
||||||
|
const isLoading = state?.isLoading || false;
|
||||||
|
const children = state?.children;
|
||||||
|
const isFolder = item.isFolder;
|
||||||
|
const isSelected = isFolder ? isFolderSelected(item.id) : isFileSelected(item.id);
|
||||||
|
|
||||||
|
const childFolders = children?.filter((c) => c.isFolder) || [];
|
||||||
|
const childFiles = children?.filter((c) => !c.isFolder) || [];
|
||||||
|
|
||||||
|
const indentSize = 0.75; // Smaller indent for mobile
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="w-full sm:ml-[calc(var(--level)*1.25rem)]"
|
||||||
|
style={
|
||||||
|
{ marginLeft: `${level * indentSize}rem`, "--level": level } as React.CSSProperties & {
|
||||||
|
"--level"?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center group gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md",
|
||||||
|
isFolder && "hover:bg-accent cursor-pointer",
|
||||||
|
!isFolder && "cursor-default opacity-60",
|
||||||
|
isSelected && "bg-accent/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isFolder ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center justify-center w-3 h-3 sm:w-4 sm:h-4 shrink-0 bg-transparent border-0 p-0 cursor-pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleFolder(item);
|
||||||
|
}}
|
||||||
|
aria-label={isExpanded ? `Collapse ${item.name}` : `Expand ${item.name}`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-2.5 w-2.5 sm:h-3 sm:w-3 animate-spin" />
|
||||||
|
) : isExpanded ? (
|
||||||
|
<ChevronDown className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="w-3 h-3 sm:w-4 sm:h-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => {
|
||||||
|
if (isFolder) {
|
||||||
|
toggleFolderSelection(item.id, item.name);
|
||||||
|
} else {
|
||||||
|
toggleFileSelection(item.id, item.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="shrink-0">
|
||||||
|
{isFolder ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
|
||||||
|
) : (
|
||||||
|
<FolderClosed className="h-3 w-3 sm:h-4 sm:w-4 text-gray-500" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
getFileIcon(item.mimeType, "h-3 w-3 sm:h-4 sm:w-4")
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFolder ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="truncate flex-1 text-left text-xs sm:text-sm min-w-0 bg-transparent border-0 p-0 cursor-pointer"
|
||||||
|
onClick={() => toggleFolder(item)}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="truncate flex-1 text-left text-xs sm:text-sm min-w-0">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isExpanded && isFolder && children && (
|
||||||
|
<div className="w-full">
|
||||||
|
{childFolders.map((child) => renderItem(child, level + 1))}
|
||||||
|
{childFiles.map((child) => renderItem(child, level + 1))}
|
||||||
|
|
||||||
|
{children.length === 0 && (
|
||||||
|
<div className="text-[10px] sm:text-xs text-muted-foreground py-1 sm:py-2 pl-1 sm:pl-2">
|
||||||
|
Empty folder
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-slate-400/20 dark:border-white/20 rounded-md w-full overflow-hidden">
|
||||||
|
<ScrollArea className="h-[300px] sm:h-[450px] w-full">
|
||||||
|
<div className="p-1 sm:p-2 pr-2 sm:pr-4 w-full overflow-x-hidden">
|
||||||
|
<div className="mb-1 sm:mb-2 pb-1 sm:pb-2 border-b border-slate-400/20 dark:border-white/20">
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 h-auto py-1 sm:py-2 px-1 sm:px-2 rounded-md hover:bg-accent cursor-pointer">
|
||||||
|
<Checkbox
|
||||||
|
checked={isFolderSelected("root")}
|
||||||
|
onCheckedChange={() => toggleFolderSelection("root", "My Drive")}
|
||||||
|
className="shrink-0 h-3.5 w-3.5 sm:h-4 sm:w-4 border-slate-400/20 dark:border-white/20"
|
||||||
|
/>
|
||||||
|
<HardDrive className="h-3 w-3 sm:h-4 sm:w-4 text-primary shrink-0" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="font-semibold truncate text-xs sm:text-sm cursor-pointer bg-transparent border-0 p-0 text-left"
|
||||||
|
onClick={() => toggleFolderSelection("root", "My Drive")}
|
||||||
|
>
|
||||||
|
My Drive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoadingRoot && (
|
||||||
|
<div className="flex items-center justify-center py-4 sm:py-8">
|
||||||
|
<Loader2 className="h-4 w-4 sm:h-6 sm:w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="w-full overflow-x-hidden">
|
||||||
|
{!isLoadingRoot && rootItems.map((item) => renderItem(item, 0))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isLoadingRoot && rootItems.length === 0 && (
|
||||||
|
<div className="text-center text-xs sm:text-sm text-muted-foreground py-4 sm:py-8">
|
||||||
|
No files or folders found in your Google Drive
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
import { Inbox, LogOut, SquareLibrary, Trash2 } 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 { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
|
|
@ -38,6 +39,17 @@ interface LayoutDataProviderProps {
|
||||||
breadcrumb?: React.ReactNode;
|
breadcrumb?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||||
|
*/
|
||||||
|
function formatInboxCount(count: number): string {
|
||||||
|
if (count <= 999) {
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
const thousands = Math.floor(count / 1000);
|
||||||
|
return `${thousands}k+`;
|
||||||
|
}
|
||||||
|
|
||||||
export function LayoutDataProvider({
|
export function LayoutDataProvider({
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
children,
|
children,
|
||||||
|
|
@ -55,11 +67,16 @@ export function LayoutDataProvider({
|
||||||
const { data: user } = useAtomValue(currentUserAtom);
|
const { data: user } = useAtomValue(currentUserAtom);
|
||||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||||
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
|
const resetCurrentThread = useSetAtom(resetCurrentThreadAtom);
|
||||||
|
|
||||||
// Current IDs from URL
|
// State for handling new chat navigation when router is out of sync
|
||||||
|
const [pendingNewChat, setPendingNewChat] = useState(false);
|
||||||
|
|
||||||
|
// Current IDs from URL, with fallback to atom for replaceState updates
|
||||||
const currentChatId = params?.chat_id
|
const currentChatId = params?.chat_id
|
||||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||||
: null;
|
: currentThreadState.id;
|
||||||
|
|
||||||
// Fetch current search space (for caching purposes)
|
// Fetch current search space (for caching purposes)
|
||||||
useQuery({
|
useQuery({
|
||||||
|
|
@ -111,6 +128,17 @@ export function LayoutDataProvider({
|
||||||
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
const [isDeletingSearchSpace, setIsDeletingSearchSpace] = useState(false);
|
||||||
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
|
const [isLeavingSearchSpace, setIsLeavingSearchSpace] = useState(false);
|
||||||
|
|
||||||
|
// Effect to complete new chat navigation after router syncs
|
||||||
|
// This runs when handleNewChat detected an out-of-sync state and triggered a sync
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingNewChat && params?.chat_id) {
|
||||||
|
// Router is now synced (chat_id is in params), complete navigation to new-chat
|
||||||
|
resetCurrentThread();
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
|
setPendingNewChat(false);
|
||||||
|
}
|
||||||
|
}, [pendingNewChat, params?.chat_id, router, searchSpaceId, resetCurrentThread]);
|
||||||
|
|
||||||
const searchSpaces: SearchSpace[] = useMemo(() => {
|
const searchSpaces: SearchSpace[] = useMemo(() => {
|
||||||
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||||
return searchSpacesData.map((space) => ({
|
return searchSpacesData.map((space) => ({
|
||||||
|
|
@ -161,18 +189,18 @@ export function LayoutDataProvider({
|
||||||
// Navigation items
|
// Navigation items
|
||||||
const navItems: NavItem[] = useMemo(
|
const navItems: NavItem[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
|
||||||
title: "Documents",
|
|
||||||
url: `/dashboard/${searchSpaceId}/documents`,
|
|
||||||
icon: SquareLibrary,
|
|
||||||
isActive: pathname?.includes("/documents"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "Inbox",
|
title: "Inbox",
|
||||||
url: "#inbox", // Special URL to indicate this is handled differently
|
url: "#inbox", // Special URL to indicate this is handled differently
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
isActive: isInboxSidebarOpen,
|
isActive: isInboxSidebarOpen,
|
||||||
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
|
badge: unreadCount > 0 ? formatInboxCount(unreadCount) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Documents",
|
||||||
|
url: `/dashboard/${searchSpaceId}/documents`,
|
||||||
|
icon: SquareLibrary,
|
||||||
|
isActive: pathname?.includes("/documents"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||||
|
|
@ -278,8 +306,20 @@ export function LayoutDataProvider({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNewChat = useCallback(() => {
|
const handleNewChat = useCallback(() => {
|
||||||
|
// Check if router is out of sync (thread created via replaceState but params don't have chat_id)
|
||||||
|
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
|
||||||
|
|
||||||
|
if (isOutOfSync) {
|
||||||
|
// First sync Next.js router by navigating to the current chat's actual URL
|
||||||
|
// This updates the router's internal state to match the browser URL
|
||||||
|
router.replace(`/dashboard/${searchSpaceId}/new-chat/${currentThreadState.id}`);
|
||||||
|
// Set flag to trigger navigation to new-chat after params update
|
||||||
|
setPendingNewChat(true);
|
||||||
|
} else {
|
||||||
|
// Normal navigation - router is in sync
|
||||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||||
}, [router, searchSpaceId]);
|
}
|
||||||
|
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id]);
|
||||||
|
|
||||||
const handleChatSelect = useCallback(
|
const handleChatSelect = useCallback(
|
||||||
(chat: ChatItem) => {
|
(chat: ChatItem) => {
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ export function AllPrivateChatsSidebar({
|
||||||
initial={{ x: "-100%" }}
|
initial={{ x: "-100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
||||||
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ export function AllSharedChatsSidebar({
|
||||||
initial={{ x: "-100%" }}
|
initial={{ x: "-100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
||||||
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,17 @@ function getInitials(name: string | null | undefined, email: string | null | und
|
||||||
return "U";
|
return "U";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format count for display: shows numbers up to 999, then "1k+", "2k+", etc.
|
||||||
|
*/
|
||||||
|
function formatInboxCount(count: number): string {
|
||||||
|
if (count <= 999) {
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
const thousands = Math.floor(count / 1000);
|
||||||
|
return `${thousands}k+`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get display name for connector type
|
* Get display name for connector type
|
||||||
*/
|
*/
|
||||||
|
|
@ -79,6 +90,9 @@ function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||||
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||||
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||||
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
|
||||||
|
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
|
||||||
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar",
|
||||||
LINEAR_CONNECTOR: "Linear",
|
LINEAR_CONNECTOR: "Linear",
|
||||||
NOTION_CONNECTOR: "Notion",
|
NOTION_CONNECTOR: "Notion",
|
||||||
SLACK_CONNECTOR: "Slack",
|
SLACK_CONNECTOR: "Slack",
|
||||||
|
|
@ -446,7 +460,7 @@ export function InboxSidebar({
|
||||||
initial={{ x: "-100%" }}
|
initial={{ x: "-100%" }}
|
||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: "-100%" }}
|
exit={{ x: "-100%" }}
|
||||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
||||||
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
|
@ -729,7 +743,7 @@ export function InboxSidebar({
|
||||||
<AtSign className="h-4 w-4" />
|
<AtSign className="h-4 w-4" />
|
||||||
<span>{t("mentions") || "Mentions"}</span>
|
<span>{t("mentions") || "Mentions"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{unreadMentionsCount}
|
{formatInboxCount(unreadMentionsCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
@ -741,7 +755,7 @@ export function InboxSidebar({
|
||||||
<History className="h-4 w-4" />
|
<History className="h-4 w-4" />
|
||||||
<span>{t("status") || "Status"}</span>
|
<span>{t("status") || "Status"}</span>
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
{unreadStatusCount}
|
{formatInboxCount(unreadStatusCount)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-[14px] h-[14px] px-0.5 rounded-full bg-red-500 text-white text-[9px] font-medium">
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -70,7 +70,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
<span className="flex-1 truncate">{item.title}</span>
|
<span className="flex-1 truncate">{item.title}</span>
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
|
<span className="inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -398,7 +398,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||||
>
|
>
|
||||||
<Save className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
<Save className="w-3.5 h-3.5 md:w-4 md:h-4" />
|
||||||
{isSaving ? "Saving..." : "Save Changes"}
|
{isSaving ? "Saving" : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
import {
|
||||||
createNewLLMConfigMutationAtom,
|
createNewLLMConfigMutationAtom,
|
||||||
deleteNewLLMConfigMutationAtom,
|
deleteNewLLMConfigMutationAtom,
|
||||||
|
|
@ -50,7 +49,6 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
|
||||||
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -112,12 +110,10 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
async (formData: LLMConfigFormData) => {
|
async (formData: LLMConfigFormData) => {
|
||||||
try {
|
try {
|
||||||
if (editingConfig) {
|
if (editingConfig) {
|
||||||
|
const { search_space_id, ...updateData } = formData;
|
||||||
await updateConfig({
|
await updateConfig({
|
||||||
id: editingConfig.id,
|
id: editingConfig.id,
|
||||||
data: {
|
data: updateData,
|
||||||
...formData,
|
|
||||||
search_space_id: undefined, // Can't change search_space_id
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await createConfig(formData);
|
await createConfig(formData);
|
||||||
|
|
@ -156,9 +152,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
setEditingConfig(null);
|
setEditingConfig(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProviderInfo = (providerValue: string) =>
|
|
||||||
LLM_PROVIDERS.find((p) => p.value === providerValue);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 md:space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|
@ -180,9 +173,9 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
{/* Error Alerts */}
|
{/* Error Alerts */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{errors.length > 0 &&
|
{errors.length > 0 &&
|
||||||
errors.map((err, i) => (
|
errors.map((err) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`error-${i}`}
|
key={err?.message ?? `error-${Date.now()}-${Math.random()}`}
|
||||||
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 }}
|
||||||
|
|
@ -269,7 +262,6 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
<motion.div variants={container} initial="hidden" animate="show" className="grid gap-4">
|
||||||
<AnimatePresence mode="popLayout">
|
<AnimatePresence mode="popLayout">
|
||||||
{configs?.map((config) => {
|
{configs?.map((config) => {
|
||||||
const providerInfo = getProviderInfo(config.provider);
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={config.id}
|
key={config.id}
|
||||||
|
|
@ -493,7 +485,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
|
||||||
{isDeleting ? (
|
{isDeleting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
Deleting...
|
Deleting
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps)
|
||||||
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
className="flex items-center gap-2 text-xs md:text-sm h-9 md:h-10"
|
||||||
>
|
>
|
||||||
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
<Save className="h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
{saving ? "Saving..." : "Save Instructions"}
|
{saving ? "Saving" : "Save Instructions"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,7 @@ export enum EnumConnectorName {
|
||||||
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR",
|
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR",
|
||||||
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR",
|
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR",
|
||||||
MCP_CONNECTOR = "MCP_CONNECTOR",
|
MCP_CONNECTOR = "MCP_CONNECTOR",
|
||||||
COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR",
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
COMPOSIO_GMAIL_CONNECTOR = "COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,12 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
|
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
|
||||||
case EnumConnectorName.OBSIDIAN_CONNECTOR:
|
case EnumConnectorName.OBSIDIAN_CONNECTOR:
|
||||||
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
|
return <Image src="/connectors/obsidian.svg" alt="Obsidian" {...imgProps} />;
|
||||||
case EnumConnectorName.COMPOSIO_CONNECTOR:
|
case EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR:
|
||||||
return <Image src="/connectors/composio.svg" alt="Composio" {...imgProps} />;
|
return <Image src="/connectors/google-drive.svg" alt="Google Drive" {...imgProps} />;
|
||||||
|
case EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR:
|
||||||
|
return <Image src="/connectors/google-gmail.svg" alt="Gmail" {...imgProps} />;
|
||||||
|
case EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR:
|
||||||
|
return <Image src="/connectors/google-calendar.svg" alt="Google Calendar" {...imgProps} />;
|
||||||
// Additional cases for non-enum connector types
|
// Additional cases for non-enum connector types
|
||||||
case "YOUTUBE_CONNECTOR":
|
case "YOUTUBE_CONNECTOR":
|
||||||
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
|
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
|
||||||
|
|
@ -89,8 +93,12 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
return <File {...iconProps} />;
|
return <File {...iconProps} />;
|
||||||
case "GOOGLE_DRIVE_FILE":
|
case "GOOGLE_DRIVE_FILE":
|
||||||
return <File {...iconProps} />;
|
return <File {...iconProps} />;
|
||||||
case "COMPOSIO_CONNECTOR":
|
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
|
||||||
return <Image src="/connectors/composio.svg" alt="Composio" {...imgProps} />;
|
return <Image src="/connectors/google-drive.svg" alt="Google Drive" {...imgProps} />;
|
||||||
|
case "COMPOSIO_GMAIL_CONNECTOR":
|
||||||
|
return <Image src="/connectors/google-gmail.svg" alt="Gmail" {...imgProps} />;
|
||||||
|
case "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
|
||||||
|
return <Image src="/connectors/google-calendar.svg" alt="Google Calendar" {...imgProps} />;
|
||||||
case "NOTE":
|
case "NOTE":
|
||||||
return <FileText {...iconProps} />;
|
return <FileText {...iconProps} />;
|
||||||
case "EXTENSION":
|
case "EXTENSION":
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ export const searchSourceConnectorTypeEnum = z.enum([
|
||||||
"CIRCLEBACK_CONNECTOR",
|
"CIRCLEBACK_CONNECTOR",
|
||||||
"MCP_CONNECTOR",
|
"MCP_CONNECTOR",
|
||||||
"OBSIDIAN_CONNECTOR",
|
"OBSIDIAN_CONNECTOR",
|
||||||
"COMPOSIO_CONNECTOR",
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const searchSourceConnector = z.object({
|
export const searchSourceConnector = z.object({
|
||||||
|
|
@ -150,6 +152,13 @@ export const googleDriveIndexBody = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
indexing_options: z
|
||||||
|
.object({
|
||||||
|
max_files_per_folder: z.number().int().min(1).max(1000),
|
||||||
|
incremental_sync: z.boolean(),
|
||||||
|
include_subfolders: z.boolean(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,9 @@ export const documentTypeEnum = z.enum([
|
||||||
"CIRCLEBACK",
|
"CIRCLEBACK",
|
||||||
"SURFSENSE_DOCS",
|
"SURFSENSE_DOCS",
|
||||||
"NOTE",
|
"NOTE",
|
||||||
"COMPOSIO_CONNECTOR",
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR",
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const document = z.object({
|
export const document = z.object({
|
||||||
|
|
|
||||||
28
surfsense_web/hooks/use-composio-drive-folders.ts
Normal file
28
surfsense_web/hooks/use-composio-drive-folders.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||||
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
|
interface UseComposioDriveFoldersOptions {
|
||||||
|
connectorId: number;
|
||||||
|
parentId?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useComposioDriveFolders({
|
||||||
|
connectorId,
|
||||||
|
parentId,
|
||||||
|
enabled = true,
|
||||||
|
}: UseComposioDriveFoldersOptions) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: cacheKeys.connectors.composioDrive.folders(connectorId, parentId),
|
||||||
|
queryFn: async () => {
|
||||||
|
return connectorsApiService.listComposioDriveFolders({
|
||||||
|
connector_id: connectorId,
|
||||||
|
parent_id: parentId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
enabled: enabled && !!connectorId,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -233,6 +233,29 @@ class ConnectorsApiService {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Composio Google Drive folders and files
|
||||||
|
*/
|
||||||
|
listComposioDriveFolders = async (request: ListGoogleDriveFoldersRequest) => {
|
||||||
|
const parsedRequest = listGoogleDriveFoldersRequest.safeParse(request);
|
||||||
|
|
||||||
|
if (!parsedRequest.success) {
|
||||||
|
console.error("Invalid request:", parsedRequest.error);
|
||||||
|
|
||||||
|
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||||
|
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { connector_id, parent_id } = parsedRequest.data;
|
||||||
|
|
||||||
|
const queryParams = parent_id ? `?parent_id=${encodeURIComponent(parent_id)}` : "";
|
||||||
|
|
||||||
|
return baseApiService.get(
|
||||||
|
`/api/v1/connectors/${connector_id}/composio-drive/folders${queryParams}`,
|
||||||
|
listGoogleDriveFoldersResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MCP Connector Methods
|
// MCP Connector Methods
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,15 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
||||||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||||
GOOGLE_GMAIL_CONNECTOR: "Google Gmail",
|
GOOGLE_GMAIL_CONNECTOR: "Google Gmail",
|
||||||
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||||
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||||
|
COMPOSIO_GMAIL_CONNECTOR: "Gmail",
|
||||||
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||||
AIRTABLE_CONNECTOR: "Airtable",
|
AIRTABLE_CONNECTOR: "Airtable",
|
||||||
LUMA_CONNECTOR: "Luma",
|
LUMA_CONNECTOR: "Luma",
|
||||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||||
WEBCRAWLER_CONNECTOR: "Web Pages",
|
WEBCRAWLER_CONNECTOR: "Web Pages",
|
||||||
CIRCLEBACK_CONNECTOR: "Circleback",
|
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||||
|
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||||
};
|
};
|
||||||
return typeMap[type] || type;
|
return typeMap[type] || type;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ export const cacheKeys = {
|
||||||
folders: (connectorId: number, parentId?: string) =>
|
folders: (connectorId: number, parentId?: string) =>
|
||||||
["connectors", "google-drive", connectorId, "folders", parentId] as const,
|
["connectors", "google-drive", connectorId, "folders", parentId] as const,
|
||||||
},
|
},
|
||||||
|
composioDrive: {
|
||||||
|
folders: (connectorId: number, parentId?: string) =>
|
||||||
|
["connectors", "composio-drive", connectorId, "folders", parentId] as const,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
comments: {
|
comments: {
|
||||||
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
|
byMessage: (messageId: number) => ["comments", "message", messageId] as const,
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@
|
||||||
"delete_note": "Delete Note",
|
"delete_note": "Delete Note",
|
||||||
"delete_note_confirm": "Are you sure you want to delete",
|
"delete_note_confirm": "Are you sure you want to delete",
|
||||||
"action_cannot_undone": "This action cannot be undone.",
|
"action_cannot_undone": "This action cannot be undone.",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting",
|
||||||
"surfsense_dashboard": "SurfSense Dashboard",
|
"surfsense_dashboard": "SurfSense Dashboard",
|
||||||
"welcome_message": "Welcome to your SurfSense dashboard.",
|
"welcome_message": "Welcome to your SurfSense dashboard.",
|
||||||
"your_search_spaces": "Your Search Spaces",
|
"your_search_spaces": "Your Search Spaces",
|
||||||
|
|
@ -498,7 +498,7 @@
|
||||||
"base": "Base",
|
"base": "Base",
|
||||||
"all_roles_assigned": "All roles are assigned and ready to use! Your LLM configuration is complete.",
|
"all_roles_assigned": "All roles are assigned and ready to use! Your LLM configuration is complete.",
|
||||||
"save_changes": "Save Changes",
|
"save_changes": "Save Changes",
|
||||||
"saving": "Saving...",
|
"saving": "Saving",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"status_ready": "Ready",
|
"status_ready": "Ready",
|
||||||
|
|
@ -548,7 +548,7 @@
|
||||||
"log_deleted_error": "Failed to delete log",
|
"log_deleted_error": "Failed to delete log",
|
||||||
"confirm_delete_log_title": "Are you sure?",
|
"confirm_delete_log_title": "Are you sure?",
|
||||||
"confirm_delete_log_desc": "This action cannot be undone. This will permanently delete the log entry.",
|
"confirm_delete_log_desc": "This action cannot be undone. This will permanently delete the log entry.",
|
||||||
"deleting": "Deleting..."
|
"deleting": "Deleting"
|
||||||
},
|
},
|
||||||
"onboard": {
|
"onboard": {
|
||||||
"welcome_title": "Welcome to SurfSense",
|
"welcome_title": "Welcome to SurfSense",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_web",
|
"name": "surfsense_web",
|
||||||
"version": "0.0.11",
|
"version": "0.0.12",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "SurfSense Frontend",
|
"description": "SurfSense Frontend",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,46 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
<svg width="397" height="512" viewBox="0 0 397 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<defs>
|
<path d="M324.709 475.437C321.564 498.795 298.704 517.043 275.987 510.743C243.618 501.826 206.139 487.918 172.416 485.326C167.97 484.984 120.732 481.403 120.732 481.403C112.376 480.808 104.533 477.157 98.6965 471.148L9.63858 379.448C-0.0941202 369.428 -2.72861 354.484 2.99039 341.74C2.99039 341.74 58.0589 220.728 60.1042 214.435C62.1495 208.142 69.6555 153.256 74.1035 123.77C75.2825 115.956 79.1405 108.793 85.0165 103.508L190.369 8.74822C205.027 -4.43479 227.778 -2.4812 239.972 13.0074L328.473 125.419C333.48 131.781 336.047 139.688 336.084 147.783C336.185 169.08 337.943 212.805 349.719 240.968C361.175 268.361 382.2 297.946 393.189 312.504C397.405 318.091 398.052 325.646 394.489 331.673C386.734 344.801 371.412 370.009 349.719 402.351C334.764 424.651 327.834 452.218 324.709 475.437Z" fill="#6C31E3"/>
|
||||||
<linearGradient id="obsidian-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
<path d="M108.293 478.079C149.651 394.115 148.498 333.963 130.899 291.034C114.702 251.53 84.578 226.613 60.834 211.149C60.334 213.383 59.609 215.564 58.67 217.658L2.98893 341.74C-2.73008 354.484 -0.0955851 369.428 9.63712 379.448L98.695 471.148C101.489 474.024 104.743 476.361 108.293 478.079Z" fill="url(#paint0_radial_1_115)"/>
|
||||||
<stop offset="0%" style="stop-color:#7C3AED"/>
|
<path d="M275.998 510.731C298.71 517.031 321.567 498.783 324.712 475.424C327.42 455.314 332.979 431.94 344.136 411.554C318.538 356.452 287.582 327.877 253.647 315.212C217.726 301.805 178.467 306.225 138.689 315.885C147.581 356.353 142.258 409.204 108.34 478.072C112.202 479.942 116.413 481.081 120.765 481.391C120.765 481.391 145.24 483.452 174.348 485.512C203.456 487.572 246.772 502.626 275.998 510.731Z" fill="url(#paint1_radial_1_115)"/>
|
||||||
<stop offset="100%" style="stop-color:#4F46E5"/>
|
<path d="M220.844 307.659C232.021 308.826 242.974 311.235 253.636 315.213C287.577 327.879 318.539 356.455 344.142 411.553C345.863 408.408 347.719 405.333 349.719 402.351C371.411 370.009 386.733 344.801 394.488 331.673C398.051 325.646 397.404 318.091 393.187 312.504C382.199 297.946 361.174 268.361 349.719 240.968C337.942 212.805 336.184 169.08 336.084 147.783C336.046 139.688 333.479 131.781 328.472 125.419L239.971 13.0073C239.498 12.4062 239.008 11.8256 238.504 11.2654C244.998 32.5466 244.558 49.6659 240.552 65.2287C236.838 79.6557 230.058 92.7451 222.897 106.571L222.896 106.573C220.493 111.21 218.049 115.93 215.662 120.812C206.161 140.247 197.582 162.241 196.317 191.734C195.051 221.228 201.097 258.22 220.847 307.658L220.844 307.659Z" fill="url(#paint2_radial_1_115)"/>
|
||||||
</linearGradient>
|
<path d="M220.832 307.658C201.084 258.221 195.035 221.228 196.301 191.733C197.567 162.238 206.146 140.244 215.649 120.807C218.036 115.924 220.481 111.204 222.884 106.566C230.044 92.7399 236.824 79.6517 240.537 65.226C244.544 49.6597 244.984 32.5357 238.484 11.2456C225.999 -2.61128 204.446 -3.91716 190.365 8.74822L85.0122 103.508C79.1362 108.793 75.2782 115.956 74.0992 123.77L61.2742 208.786C61.1552 209.575 61.0082 210.359 60.8342 211.138C84.5792 226.601 114.708 251.521 130.906 291.03C134.07 298.748 136.702 307.019 138.652 315.888C166.627 309.094 194.347 304.893 220.832 307.658Z" fill="url(#paint3_radial_1_115)"/>
|
||||||
</defs>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M196.508 189.79C195.238 219.048 198.89 252.61 218.598 301.944L212.409 301.386C194.728 249.904 190.88 223.509 192.168 193.847C193.456 164.17 203.045 141.35 212.601 121.884C215.021 116.954 220.668 107.696 223.085 103.049C230.24 89.2908 235.002 82.0228 239.09 69.4468C244.804 51.8747 243.568 43.5519 242.917 35.2692C247.453 65.2105 230.234 91.2462 217.217 117.763C207.734 137.08 197.777 160.547 196.508 189.79Z" fill="url(#paint4_radial_1_115)"/>
|
||||||
<path d="M50 5 L90 35 L75 95 L25 95 L10 35 Z" fill="url(#obsidian-gradient)" stroke="#6D28D9" stroke-width="2"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M136.726 293.21C139.063 298.615 141.271 302.979 142.665 309.667L137.501 310.828C135.352 303.019 133.692 297.463 130.718 290.765C112.922 248.78 84.362 227.184 61.022 211.344C89.2138 226.511 118.149 250.251 136.726 293.21Z" fill="url(#paint5_radial_1_115)"/>
|
||||||
<path d="M50 20 L70 38 L62 75 L38 75 L30 38 Z" fill="#A78BFA" opacity="0.4"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M142.965 314.949C152.827 360.838 141.826 419.137 109.406 475.807C136.504 419.644 149.641 365.703 138.7 315.864L142.965 314.949Z" fill="url(#paint6_radial_1_115)"/>
|
||||||
<path d="M50 5 L50 95" stroke="#8B5CF6" stroke-width="1" opacity="0.5"/>
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M254.86 310.821C308.014 330.713 328.48 374.389 343.778 410.822C324.885 372.671 298.618 330.538 252.953 314.9C218.207 303 188.862 304.411 138.697 315.796L137.579 310.821C190.819 298.691 218.655 297.272 254.86 310.821Z" fill="url(#paint7_radial_1_115)"/>
|
||||||
<path d="M10 35 L90 35" stroke="#8B5CF6" stroke-width="1" opacity="0.3"/>
|
<defs>
|
||||||
|
<radialGradient id="paint0_radial_1_115" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(103.845 469.791) rotate(-104.574) scale(232.965 155.247)">
|
||||||
|
<stop stop-color="white" stop-opacity="0.4"/>
|
||||||
|
<stop offset="1" stop-opacity="0.1"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint1_radial_1_115" cx="0" cy="0" r="1" gradientTransform="matrix(-96.2576 -163.001 187.145 -110.545 277.685 511.988)" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="white" stop-opacity="0.3"/>
|
||||||
|
<stop offset="1" stop-opacity="0.25"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint2_radial_1_115" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(302.401 374) rotate(-82.4846) scale(382.284 282.434)">
|
||||||
|
<stop stop-color="white" stop-opacity="0.55"/>
|
||||||
|
<stop offset="1" stop-color="white" stop-opacity="0.05"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint3_radial_1_115" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(117.805 306.884) rotate(-77.7214) scale(326.45 222.631)">
|
||||||
|
<stop stop-color="white" stop-opacity="0.83"/>
|
||||||
|
<stop offset="1" stop-color="white" stop-opacity="0.4"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint4_radial_1_115" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(252.4 128) rotate(102.236) scale(169.859 114.542)">
|
||||||
|
<stop stop-color="white" stop-opacity="0"/>
|
||||||
|
<stop offset="1" stop-color="white" stop-opacity="0.17"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint5_radial_1_115" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(53.399 220) rotate(45.3237) scale(125.16 266.579)">
|
||||||
|
<stop stop-color="white" stop-opacity="0.2"/>
|
||||||
|
<stop offset="1" stop-color="white" stop-opacity="0.44"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint6_radial_1_115" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(147.891 279.224) rotate(80.2016) scale(146.696 311.515)">
|
||||||
|
<stop stop-color="white" stop-opacity="0.25"/>
|
||||||
|
<stop offset="1" stop-color="white" stop-opacity="0.3"/>
|
||||||
|
</radialGradient>
|
||||||
|
<radialGradient id="paint7_radial_1_115" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(342.401 398.999) rotate(-152.297) scale(223.528 703.43)">
|
||||||
|
<stop stop-color="white" stop-opacity="0.21"/>
|
||||||
|
<stop offset="0.46738" stop-color="white" stop-opacity="0.19"/>
|
||||||
|
<stop offset="1" stop-color="white" stop-opacity="0.29"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 657 B After Width: | Height: | Size: 6.7 KiB |
Loading…
Add table
Add a link
Reference in a new issue