Merge pull request #740 from MODSetter/dev

feat(0.0.12): bumped version, added composio connectors and various fixes
This commit is contained in:
Rohan Verma 2026-01-24 17:56:01 -08:00 committed by GitHub
commit b87a8af036
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 5672 additions and 2277 deletions

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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",

View file

@ -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

View file

@ -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(

View file

@ -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}"

View 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}"

View file

@ -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}"

File diff suppressed because it is too large Load diff

View file

@ -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:

View file

@ -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 = []

View file

@ -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):

View file

@ -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(
status_code=400,
detail=f"Unknown toolkit: {toolkit_id}. Available: {list(TOOLKIT_TO_CONNECTOR_TYPE.keys())}",
)
connector_type = SearchSourceConnectorType(connector_type_str)
is_duplicate = await check_duplicate_connector( # Check for existing connector of the same type for this user/space
session, # When reconnecting, Composio gives a new connected_account_id, so we need to
SearchSourceConnectorType.COMPOSIO_CONNECTOR, # check by connector_type, user_id, and search_space_id instead of connected_account_id
space_id, existing_connector_result = await session.execute(
user_id, select(SearchSourceConnector).where(
identifier, SearchSourceConnector.connector_type == connector_type,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.user_id == user_id,
)
) )
if is_duplicate: existing_connector = existing_connector_result.scalars().first()
logger.warning(
f"Duplicate Composio connector detected for user {user_id} with toolkit {toolkit_id}" 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(
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

View file

@ -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")
) )

View file

@ -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:
# Actual failure # Check if this is a duplicate warning or empty result (success cases) or an actual error
logger.error(f"Indexing failed: {error_or_warning}") # Handle both normal and Composio calendar connectors
if notification: error_or_warning_lower = (
await NotificationService.connector_indexing.notify_indexing_completed( str(error_or_warning).lower() if error_or_warning else ""
session=session, )
notification=notification, is_duplicate_warning = "skipped (duplicate)" in error_or_warning_lower
indexed_count=0, # "No X found" messages are success cases - sync worked, just found nothing in date range
error_message=error_or_warning, 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
logger.error(f"Indexing failed: {error_or_warning}")
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(
session=session,
notification=notification,
indexed_count=0,
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
# ============================================================================= # =============================================================================

View file

@ -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"
return content, None # 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
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:

View file

@ -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]

View file

@ -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,10 +354,26 @@ 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:
title = f"Failed: {connector_name}" if indexed_count > 0:
message = f"Sync failed: {error_message}" # Partial success with warnings (e.g., duplicate content from other connectors)
status = "failed" 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}"
message = f"Sync failed: {error_message}"
status = "failed"
else: else:
title = f"Ready: {connector_name}" title = f"Ready: {connector_name}"
if indexed_count == 0: if indexed_count == 0:
@ -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,
} }

View file

@ -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
) )

View file

@ -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

View file

@ -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}"

View file

@ -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

View file

@ -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}",

View file

@ -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}",

View file

@ -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"
) )
await session.commit() try:
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()

View file

@ -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()

View file

@ -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"

View file

@ -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}",

View file

@ -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")

View file

@ -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}",

View file

@ -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",
} }

View file

@ -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 = [

View file

@ -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" },

View file

@ -1,7 +1,7 @@
{ {
"name": "surfsense_browser_extension", "name": "surfsense_browser_extension",
"displayName": "Surfsense Browser Extension", "displayName": "Surfsense Browser Extension",
"version": "0.0.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": {

View file

@ -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>

View file

@ -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,9 +212,14 @@ 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
(c) => c.connectorType === viewingAccountsType.connectorType const oauthConnector =
); OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
);
if (oauthConnector) { if (oauthConnector) {
handleConnectOAuth(oauthConnector); handleConnectOAuth(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>

View file

@ -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>
);
};

View file

@ -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": {

View file

@ -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!">

View file

@ -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" />;
};

View file

@ -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&apos;s managed OAuth, which means you don&apos;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>
);
};

View file

@ -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>
);
};

View file

@ -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" />;
};

View file

@ -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>

View file

@ -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;

View file

@ -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) {
setIsQuickIndexing(false); // Small delay to ensure smooth transition
const timer = setTimeout(() => {
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"

View file

@ -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,20 +161,22 @@ 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" &&
<PeriodicSyncConfig config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
enabled={periodicEnabled} <PeriodicSyncConfig
frequencyMinutes={frequencyMinutes} enabled={periodicEnabled}
onEnabledChange={onPeriodicEnabledChange} frequencyMinutes={frequencyMinutes}
onFrequencyChange={onFrequencyChange} onEnabledChange={onPeriodicEnabledChange}
/> onFrequencyChange={onFrequencyChange}
)} />
)}
</> </>
)} )}
@ -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>

View file

@ -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;

View file

@ -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(),

View file

@ -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,32 +159,28 @@ 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 (oauthConnector) {
setViewingAccountsType({ if (needsUpdate) {
connectorType: oauthConnector.connectorType, // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
connectorTitle: oauthConnector.title, const oauthConnector =
}); OAUTH_CONNECTORS.find((c) => c.connectorType === params.connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === params.connectorType);
if (oauthConnector) {
setViewingAccountsType({
connectorType: oauthConnector.connectorType,
connectorTitle: oauthConnector.title,
});
}
} }
} }
@ -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,58 +328,72 @@ export const useConnectorDialog = () => {
return; return;
} }
if ( if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
params.success === "true" && refetchAllConnectors().then((result) => {
params.connector && if (!result.data) return;
searchSpaceId &&
params.modal === "connectors"
) {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
refetchAllConnectors().then((result) => {
if (!result.data) return;
let newConnector: SearchSourceConnector | undefined; let newConnector: SearchSourceConnector | undefined;
if (params.connectorId) { let oauthConnector:
const connectorId = parseInt(params.connectorId, 10); | (typeof OAUTH_CONNECTORS)[number]
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId); | (typeof COMPOSIO_CONNECTORS)[number]
} else { | undefined;
// First, try to find connector by connectorId if provided
if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10);
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
// If we found the connector, find the matching OAuth/Composio connector by type
if (newConnector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type);
}
}
// 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( newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType (c: SearchSourceConnector) => c.connector_type === oauthConnector!.connectorType
); );
} }
}
if (newConnector) { 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,
newConnector.id newConnector.id
); );
const config = validateIndexingConfigState({ const config = validateIndexingConfigState({
connectorType: oauthConnector.connectorType, connectorType: oauthConnector.connectorType,
connectorId: newConnector.id, connectorId: newConnector.id,
connectorTitle: oauthConnector.title, connectorTitle: oauthConnector.title,
}); });
setIndexingConfig(config); setIndexingConfig(config);
setIndexingConnector(newConnector); setIndexingConnector(newConnector);
setIndexingConnectorConfig(newConnector.config); setIndexingConnectorConfig(newConnector.config);
setIsOpen(true); setIsOpen(true);
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.delete("success"); url.searchParams.delete("success");
url.searchParams.set("connectorId", newConnector.id.toString()); url.searchParams.set("connectorId", newConnector.id.toString());
url.searchParams.set("view", "configure"); url.searchParams.set("view", "configure");
window.history.replaceState({}, "", url.toString()); window.history.replaceState({}, "", url.toString());
} else { } else {
console.warn("Invalid connector data after OAuth:", connectorValidation.error); console.warn("Invalid connector data after OAuth:", connectorValidation.error);
toast.error("Failed to validate connector data"); toast.error("Failed to validate connector data");
}
} }
}); }
} });
} }
} catch (error) { } catch (error) {
// Invalid query params - log but don't crash // Invalid query params - log but don't crash
@ -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,
}; };
}; };

View file

@ -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(() => {
}, [connectors, indexingConnectorIds]); 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;
}
}
// 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) => {

View file

@ -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 ? (

View file

@ -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);
key={connector.id} const isConnecting = connectingId === connector.id;
id={connector.id}
title={connector.title} // Find all connectors of this type
description={connector.description} const typeConnectors =
connectorCount={composioConnectorCount} isConnected && allConnectors
onConnect={onOpenComposio} ? 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}
id={connector.id}
title={connector.title}
description={connector.description}
connectorType={connector.connectorType}
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 && (

View file

@ -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",
}; };
/** /**

View file

@ -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&apos;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&apos;t need to wait for Google
app verification. Your data is securely processed through Composio&apos;s managed
authentication.
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -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>
);
}

View file

@ -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(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`); // Check if router is out of sync (thread created via replaceState but params don't have chat_id)
}, [router, searchSpaceId]); 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, searchSpaceId, currentThreadState.id, params?.chat_id]);
const handleChatSelect = useCallback( const handleChatSelect = useCallback(
(chat: ChatItem) => { (chat: ChatItem) => {

View file

@ -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"

View file

@ -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"

View file

@ -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>

View file

@ -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>
)} )}

View file

@ -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"

View file

@ -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
</> </>
) : ( ) : (
<> <>

View file

@ -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>

View file

@ -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}

View file

@ -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",
} }

View file

@ -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":

View file

@ -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(),
}); });
/** /**

View file

@ -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({

View 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,
});
}

View file

@ -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
// ============================================================================= // =============================================================================

View file

@ -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;
}; };

View file

@ -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,

View file

@ -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",

View file

@ -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": {

View file

@ -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

Before After
Before After