merge: upstream/dev with migration renumbering

This commit is contained in:
CREDO23 2026-01-27 11:22:26 +02:00
commit a7145b2c63
176 changed files with 8791 additions and 3608 deletions

View file

@ -228,7 +228,7 @@ COPY scripts/docker/init-postgres.sh /app/init-postgres.sh
RUN dos2unix /app/init-postgres.sh && chmod +x /app/init-postgres.sh RUN dos2unix /app/init-postgres.sh && chmod +x /app/init-postgres.sh
# Clean up build dependencies to reduce image size # Clean up build dependencies to reduce image size
RUN apt-get purge -y build-essential postgresql-server-dev-14 git \ RUN apt-get purge -y build-essential postgresql-server-dev-14 \
&& apt-get autoremove -y \ && apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

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

@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
libxext6 \ libxext6 \
libxrender1 \ libxrender1 \
dos2unix \ dos2unix \
git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Update certificates and install SSL tools # Update certificates and install SSL tools

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

@ -0,0 +1,97 @@
"""Add user incentive tasks table for earning free pages
Revision ID: 80
Revises: 79
Changes:
1. Create incentive_task_type enum with GITHUB_STAR value
2. Create user_incentive_tasks table to track completed tasks
"""
from collections.abc import Sequence
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "80"
down_revision: str | None = "79"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create incentive tasks infrastructure."""
# Check if enum already exists (handles partial migration recovery)
conn = op.get_bind()
result = conn.execute(
sa.text("SELECT 1 FROM pg_type WHERE typname = 'incentivetasktype'")
)
enum_exists = result.fetchone() is not None
# Create the enum type only if it doesn't exist
if not enum_exists:
incentive_task_type_enum = postgresql.ENUM(
"GITHUB_STAR",
name="incentivetasktype",
create_type=False,
)
incentive_task_type_enum.create(op.get_bind(), checkfirst=True)
# Check if table already exists (handles partial migration recovery)
result = conn.execute(
sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_name = 'user_incentive_tasks'"
)
)
table_exists = result.fetchone() is not None
if not table_exists:
# Create the user_incentive_tasks table
op.create_table(
"user_incentive_tasks",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column(
"user_id",
sa.UUID(as_uuid=True),
sa.ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
),
sa.Column(
"task_type",
postgresql.ENUM(
"GITHUB_STAR", name="incentivetasktype", create_type=False
),
nullable=False,
index=True,
),
sa.Column("pages_awarded", sa.Integer(), nullable=False),
sa.Column(
"completed_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column(
"created_at",
sa.TIMESTAMP(timezone=True),
nullable=False,
server_default=sa.func.now(),
index=True,
),
sa.UniqueConstraint("user_id", "task_type", name="uq_user_incentive_task"),
)
def downgrade() -> None:
"""Remove incentive tasks infrastructure."""
# Drop the table
op.drop_table("user_incentive_tasks")
# Drop the enum type
postgresql.ENUM(name="incentivetasktype").drop(op.get_bind(), checkfirst=True)

View file

@ -1,7 +1,7 @@
"""Add public sharing columns to new_chat_threads """Add public sharing columns to new_chat_threads
Revision ID: 79 Revision ID: 81
Revises: 78 Revises: 80
Create Date: 2026-01-23 Create Date: 2026-01-23
Adds public_share_token and public_share_enabled columns to enable Adds public_share_token and public_share_enabled columns to enable
@ -13,8 +13,8 @@ from collections.abc import Sequence
from alembic import op from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "79" revision: str = "81"
down_revision: str | None = "78" down_revision: str | None = "80"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None

View file

@ -1,7 +1,7 @@
"""Add thread_id to podcasts """Add thread_id to podcasts
Revision ID: 80 Revision ID: 82
Revises: 79 Revises: 81
Create Date: 2026-01-23 Create Date: 2026-01-23
""" """
@ -10,8 +10,8 @@ from collections.abc import Sequence
from alembic import op from alembic import op
revision: str = "80" revision: str = "82"
down_revision: str | None = "79" down_revision: str | None = "81"
branch_labels: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None

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
# ============================================================================= # =============================================================================
@ -117,6 +202,30 @@ async def create_surfsense_deep_agent(
additional_tools=[my_custom_tool] additional_tools=[my_custom_tool]
) )
""" """
# Discover available connectors and document types for this search space
# This enables dynamic tool docstrings that inform the LLM about what's actually available
available_connectors: list[str] | None = None
available_document_types: list[str] | None = None
try:
# Get enabled search source connectors for this search space
connector_types = await connector_service.get_available_connectors(
search_space_id
)
if connector_types:
# Convert enum values to strings and also include mapped document types
available_connectors = _map_connectors_to_searchable_types(connector_types)
# Get document types that have at least one document indexed
available_document_types = await connector_service.get_available_document_types(
search_space_id
)
except Exception as e:
# Log but don't fail - fall back to all connectors if discovery fails
import logging
logging.warning(f"Failed to discover available connectors/document types: {e}")
# Build dependencies dict for the tools registry # Build dependencies dict for the tools registry
dependencies = { dependencies = {
"search_space_id": search_space_id, "search_space_id": search_space_id,
@ -125,6 +234,9 @@ async def create_surfsense_deep_agent(
"firecrawl_api_key": firecrawl_api_key, "firecrawl_api_key": firecrawl_api_key,
"user_id": user_id, # Required for memory tools "user_id": user_id, # Required for memory tools
"thread_id": thread_id, # For podcast tool "thread_id": thread_id, # For podcast tool
# Dynamic connector/document type discovery for knowledge base tool
"available_connectors": available_connectors,
"available_document_types": available_document_types,
} }
# Build tools using the async registry (includes MCP tools) # Build tools using the async registry (includes MCP tools)

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):
@ -142,6 +144,43 @@ class LogStatus(str, Enum):
FAILED = "FAILED" FAILED = "FAILED"
class IncentiveTaskType(str, Enum):
"""
Enum for incentive task types that users can complete to earn free pages.
Each task can only be completed once per user.
When adding new tasks:
1. Add a new enum value here
2. Add the task configuration to INCENTIVE_TASKS_CONFIG below
3. Create an Alembic migration to add the enum value to PostgreSQL
"""
GITHUB_STAR = "GITHUB_STAR"
# Future tasks can be added here:
# GITHUB_ISSUE = "GITHUB_ISSUE"
# SOCIAL_SHARE = "SOCIAL_SHARE"
# REFER_FRIEND = "REFER_FRIEND"
# Centralized configuration for incentive tasks
# This makes it easy to add new tasks without changing code in multiple places
INCENTIVE_TASKS_CONFIG = {
IncentiveTaskType.GITHUB_STAR: {
"title": "Star our GitHub repository",
"description": "Show your support by starring SurfSense on GitHub",
"pages_reward": 100,
"action_url": "https://github.com/MODSetter/SurfSense",
},
# Future tasks can be configured here:
# IncentiveTaskType.GITHUB_ISSUE: {
# "title": "Create an issue",
# "description": "Help improve SurfSense by reporting bugs or suggesting features",
# "pages_reward": 50,
# "action_url": "https://github.com/MODSetter/SurfSense/issues/new/choose",
# },
}
class Permission(str, Enum): class Permission(str, Enum):
""" """
Granular permissions for search space resources. Granular permissions for search space resources.
@ -936,6 +975,39 @@ class Notification(BaseModel, TimestampMixin):
search_space = relationship("SearchSpace", back_populates="notifications") search_space = relationship("SearchSpace", back_populates="notifications")
class UserIncentiveTask(BaseModel, TimestampMixin):
"""
Tracks completed incentive tasks for users.
Each user can only complete each task type once.
When a task is completed, the user's pages_limit is increased.
"""
__tablename__ = "user_incentive_tasks"
__table_args__ = (
UniqueConstraint(
"user_id",
"task_type",
name="uq_user_incentive_task",
),
)
user_id = Column(
UUID(as_uuid=True),
ForeignKey("user.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
task_type = Column(SQLAlchemyEnum(IncentiveTaskType), nullable=False, index=True)
pages_awarded = Column(Integer, nullable=False)
completed_at = Column(
TIMESTAMP(timezone=True),
nullable=False,
default=lambda: datetime.now(UTC),
)
user = relationship("User", back_populates="incentive_tasks")
class SearchSpaceRole(BaseModel, TimestampMixin): class SearchSpaceRole(BaseModel, TimestampMixin):
""" """
Custom roles that can be defined per search space. Custom roles that can be defined per search space.
@ -1114,6 +1186,13 @@ if config.AUTH_TYPE == "GOOGLE":
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Incentive tasks completed by this user
incentive_tasks = relationship(
"UserIncentiveTask",
back_populates="user",
cascade="all, delete-orphan",
)
# Page usage tracking for ETL services # Page usage tracking for ETL services
pages_limit = Column( pages_limit = Column(
Integer, Integer,
@ -1165,6 +1244,13 @@ else:
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
# Incentive tasks completed by this user
incentive_tasks = relationship(
"UserIncentiveTask",
back_populates="user",
cascade="all, delete-orphan",
)
# Page usage tracking for ETL services # Page usage tracking for ETL services
pages_limit = Column( pages_limit = Column(
Integer, Integer,

View file

@ -20,6 +20,7 @@ from .google_drive_add_connector_route import (
from .google_gmail_add_connector_route import ( from .google_gmail_add_connector_route import (
router as google_gmail_add_connector_router, router as google_gmail_add_connector_router,
) )
from .incentive_tasks_routes import router as incentive_tasks_router
from .jira_add_connector_route import router as jira_add_connector_router from .jira_add_connector_route import router as jira_add_connector_router
from .linear_add_connector_route import router as linear_add_connector_router from .linear_add_connector_route import router as linear_add_connector_router
from .logs_routes import router as logs_router from .logs_routes import router as logs_router
@ -69,3 +70,4 @@ router.include_router(surfsense_docs_router) # Surfsense documentation for cita
router.include_router(notifications_router) # Notifications with Electric SQL sync router.include_router(notifications_router) # Notifications with Electric SQL sync
router.include_router(composio_router) # Composio OAuth and toolkit management router.include_router(composio_router) # Composio OAuth and toolkit management
router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(public_chat_router) # Public chat sharing and cloning
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages

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(
is_duplicate = await check_duplicate_connector( status_code=400,
session, detail=f"Unknown toolkit: {toolkit_id}. Available: {list(TOOLKIT_TO_CONNECTOR_TYPE.keys())}",
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
space_id,
user_id,
identifier,
) )
if is_duplicate: connector_type = SearchSourceConnectorType(connector_type_str)
# Check for existing connector of the same type for this user/space
# When reconnecting, Composio gives a new connected_account_id, so we need to
# check by connector_type, user_id, and search_space_id instead of connected_account_id
existing_connector_result = await session.execute(
select(SearchSourceConnector).where(
SearchSourceConnector.connector_type == connector_type,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.user_id == user_id,
)
)
existing_connector = existing_connector_result.scalars().first()
if existing_connector:
# Delete the old Composio connected account before updating
old_connected_account_id = existing_connector.config.get(
"composio_connected_account_id"
)
if (
old_connected_account_id
and old_connected_account_id != final_connected_account_id
):
try:
deleted = await service.delete_connected_account(
old_connected_account_id
)
if deleted:
logger.info(
f"Deleted old Composio connected account {old_connected_account_id} "
f"before updating connector {existing_connector.id}"
)
else:
logger.warning( logger.warning(
f"Duplicate Composio connector detected for user {user_id} with toolkit {toolkit_id}" f"Failed to delete old Composio connected account {old_connected_account_id}"
)
except Exception as delete_error:
# Log but don't fail - the old account may already be deleted
logger.warning(
f"Error deleting old Composio connected account {old_connected_account_id}: {delete_error!s}"
)
# Update existing connector with new connected_account_id
logger.info(
f"Updating existing Composio connector {existing_connector.id} with new connected_account_id {final_connected_account_id}"
)
existing_connector.config = connector_config
await session.commit()
await session.refresh(existing_connector)
# Get the frontend connector ID based on toolkit_id
frontend_connector_id = TOOLKIT_TO_FRONTEND_CONNECTOR_ID.get(
toolkit_id, "composio-connector"
) )
return RedirectResponse( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=composio-connector" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector={frontend_connector_id}&connectorId={existing_connector.id}"
) )
try: try:
# Generate a unique, user-friendly connector name # Count existing connectors of this type to determine the number
connector_name = await generate_unique_connector_name( count = await count_connectors_of_type(
session, session, connector_type, space_id, user_id
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
space_id,
user_id,
f"{toolkit_name} (Composio)",
) )
# Generate base name (e.g., "Gmail", "Google Drive")
base_name = get_base_name_for_type(connector_type)
# Format: "Gmail (Composio) 1", "Gmail (Composio) 2", etc.
if count == 0:
connector_name = f"{base_name} (Composio) 1"
else:
connector_name = f"{base_name} (Composio) {count + 1}"
db_connector = SearchSourceConnector( db_connector = SearchSourceConnector(
name=connector_name, name=connector_name,
connector_type=SearchSourceConnectorType.COMPOSIO_CONNECTOR, connector_type=connector_type,
config=connector_config, config=connector_config,
search_space_id=space_id, search_space_id=space_id,
user_id=user_id, user_id=user_id,
@ -314,8 +377,12 @@ async def composio_callback(
f"Successfully created Composio connector {db_connector.id} for user {user_id}, toolkit {toolkit_id}" f"Successfully created Composio connector {db_connector.id} for user {user_id}, toolkit {toolkit_id}"
) )
# Get the frontend connector ID based on toolkit_id
frontend_connector_id = TOOLKIT_TO_FRONTEND_CONNECTOR_ID.get(
toolkit_id, "composio-connector"
)
return RedirectResponse( return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=composio-connector&connectorId={db_connector.id}" url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector={frontend_connector_id}&connectorId={db_connector.id}"
) )
except IntegrityError as e: except IntegrityError as e:
@ -339,3 +406,136 @@ async def composio_callback(
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to complete Composio OAuth: {e!s}" status_code=500, detail=f"Failed to complete Composio OAuth: {e!s}"
) from e ) from e
@router.get("/connectors/{connector_id}/composio-drive/folders")
async def list_composio_drive_folders(
connector_id: int,
parent_id: str | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List folders AND files in user's Google Drive via Composio with hierarchical support.
This is called at index time from the manage connector page to display
the complete file system (folders and files). Only folders are selectable.
Args:
connector_id: ID of the Composio Google Drive connector
parent_id: Optional parent folder ID to list contents (None for root)
Returns:
JSON with list of items: {
"items": [
{"id": str, "name": str, "mimeType": str, "isFolder": bool, ...},
...
]
}
"""
if not ComposioService.is_enabled():
raise HTTPException(
status_code=503,
detail="Composio integration is not enabled.",
)
try:
# Get connector and verify ownership
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(
status_code=404,
detail="Composio Google Drive connector not found or access denied",
)
# Get Composio connected account ID from config
composio_connected_account_id = connector.config.get(
"composio_connected_account_id"
)
if not composio_connected_account_id:
raise HTTPException(
status_code=400,
detail="Composio connected account not found. Please reconnect the connector.",
)
# Initialize Composio service and fetch files
service = ComposioService()
entity_id = f"surfsense_{user.id}"
# Fetch files/folders from Composio Google Drive
files, _next_token, error = await service.get_drive_files(
connected_account_id=composio_connected_account_id,
entity_id=entity_id,
folder_id=parent_id,
page_size=100,
)
if error:
logger.error(f"Failed to list Composio Drive files: {error}")
raise HTTPException(
status_code=500, detail=f"Failed to list folder contents: {error}"
)
# Transform files to match the expected format with isFolder field
items = []
for file_info in files:
file_id = file_info.get("id", "") or file_info.get("fileId", "")
file_name = (
file_info.get("name", "") or file_info.get("fileName", "") or "Untitled"
)
mime_type = file_info.get("mimeType", "") or file_info.get("mime_type", "")
if not file_id:
continue
is_folder = mime_type == "application/vnd.google-apps.folder"
items.append(
{
"id": file_id,
"name": file_name,
"mimeType": mime_type,
"isFolder": is_folder,
"parents": file_info.get("parents", []),
"size": file_info.get("size"),
"iconLink": file_info.get("iconLink"),
}
)
# Sort: folders first, then files, both alphabetically
folders = sorted(
[item for item in items if item["isFolder"]],
key=lambda x: x["name"].lower(),
)
files_list = sorted(
[item for item in items if not item["isFolder"]],
key=lambda x: x["name"].lower(),
)
items = folders + files_list
folder_count = len(folders)
file_count = len(files_list)
logger.info(
f"Listed {len(items)} total items ({folder_count} folders, {file_count} files) for Composio connector {connector_id}"
+ (f" in folder {parent_id}" if parent_id else " in ROOT")
)
return {"items": items}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error listing Composio Drive contents: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to list Drive contents: {e!s}"
) from e

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

@ -0,0 +1,131 @@
"""
Incentive Tasks API routes.
Allows users to complete tasks (like starring GitHub repo) to earn free pages.
Each task can only be completed once per user.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import (
INCENTIVE_TASKS_CONFIG,
IncentiveTaskType,
User,
UserIncentiveTask,
get_async_session,
)
from app.schemas.incentive_tasks import (
CompleteTaskResponse,
IncentiveTaskInfo,
IncentiveTasksResponse,
TaskAlreadyCompletedResponse,
)
from app.users import current_active_user
router = APIRouter(prefix="/incentive-tasks", tags=["incentive-tasks"])
@router.get("", response_model=IncentiveTasksResponse)
async def get_incentive_tasks(
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> IncentiveTasksResponse:
"""
Get all available incentive tasks with the user's completion status.
"""
# Get all completed tasks for this user
result = await session.execute(
select(UserIncentiveTask).where(UserIncentiveTask.user_id == user.id)
)
completed_tasks = {task.task_type: task for task in result.scalars().all()}
# Build task list with completion status
tasks = []
total_pages_earned = 0
for task_type, config in INCENTIVE_TASKS_CONFIG.items():
completed_task = completed_tasks.get(task_type)
is_completed = completed_task is not None
if is_completed:
total_pages_earned += completed_task.pages_awarded
tasks.append(
IncentiveTaskInfo(
task_type=task_type,
title=config["title"],
description=config["description"],
pages_reward=config["pages_reward"],
action_url=config["action_url"],
completed=is_completed,
completed_at=completed_task.completed_at if completed_task else None,
)
)
return IncentiveTasksResponse(
tasks=tasks,
total_pages_earned=total_pages_earned,
)
@router.post(
"/{task_type}/complete",
response_model=CompleteTaskResponse | TaskAlreadyCompletedResponse,
)
async def complete_task(
task_type: IncentiveTaskType,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
) -> CompleteTaskResponse | TaskAlreadyCompletedResponse:
"""
Mark an incentive task as completed and award pages to the user.
Each task can only be completed once. If the task was already completed,
returns the existing completion information without awarding additional pages.
"""
# Validate task type exists in config
task_config = INCENTIVE_TASKS_CONFIG.get(task_type)
if not task_config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unknown task type: {task_type}",
)
# Check if task was already completed
existing_task = await session.execute(
select(UserIncentiveTask).where(
UserIncentiveTask.user_id == user.id,
UserIncentiveTask.task_type == task_type,
)
)
existing = existing_task.scalar_one_or_none()
if existing:
return TaskAlreadyCompletedResponse(
success=False,
message="Task already completed",
completed_at=existing.completed_at,
)
# Create the task completion record
pages_reward = task_config["pages_reward"]
new_task = UserIncentiveTask(
user_id=user.id,
task_type=task_type,
pages_awarded=pages_reward,
)
session.add(new_task)
# Update user's pages_limit
user.pages_limit += pages_reward
await session.commit()
await session.refresh(user)
return CompleteTaskResponse(
success=True,
message=f"Task completed! You earned {pages_reward} pages.",
pages_awarded=pages_reward,
new_pages_limit=user.pages_limit,
)

View file

@ -59,6 +59,58 @@ router = APIRouter()
# ============ Permissions Endpoints ============ # ============ Permissions Endpoints ============
# Human-readable descriptions for each permission
PERMISSION_DESCRIPTIONS = {
# Documents
"documents:create": "Add new documents, files, and content to the search space",
"documents:read": "View and search documents in the search space",
"documents:update": "Edit existing documents and their metadata",
"documents:delete": "Remove documents from the search space",
# Chats
"chats:create": "Start new AI chat conversations",
"chats:read": "View chat history and conversations",
"chats:update": "Edit chat titles and settings",
"chats:delete": "Delete chat conversations",
# Comments
"comments:create": "Add comments and annotations to documents",
"comments:read": "View comments on documents",
"comments:delete": "Remove comments from documents",
# LLM Configs
"llm_configs:create": "Add new AI model configurations",
"llm_configs:read": "View AI model settings and configurations",
"llm_configs:update": "Modify AI model configurations",
"llm_configs:delete": "Remove AI model configurations",
# Podcasts
"podcasts:create": "Generate new AI podcasts from content",
"podcasts:read": "Listen to and view generated podcasts",
"podcasts:update": "Edit podcast settings and metadata",
"podcasts:delete": "Remove generated podcasts",
# Connectors
"connectors:create": "Set up new data source integrations",
"connectors:read": "View configured data sources and their status",
"connectors:update": "Modify data source configurations",
"connectors:delete": "Remove data source integrations",
# Logs
"logs:read": "View activity logs and audit trail",
"logs:delete": "Clear activity logs",
# Members
"members:invite": "Send invitations to new team members",
"members:view": "View the list of team members",
"members:remove": "Remove members from the search space",
"members:manage_roles": "Assign and change member roles",
# Roles
"roles:create": "Create new custom roles",
"roles:read": "View available roles and their permissions",
"roles:update": "Modify role permissions",
"roles:delete": "Remove custom roles",
# Settings
"settings:view": "View search space settings",
"settings:update": "Modify search space settings",
"settings:delete": "Delete the entire search space",
# Full access
"*": "Full access to all features and settings",
}
@router.get("/permissions", response_model=PermissionsListResponse) @router.get("/permissions", response_model=PermissionsListResponse)
async def list_all_permissions( async def list_all_permissions(
@ -71,12 +123,14 @@ async def list_all_permissions(
for perm in Permission: for perm in Permission:
# Extract category from permission value (e.g., "documents:read" -> "documents") # Extract category from permission value (e.g., "documents:read" -> "documents")
category = perm.value.split(":")[0] if ":" in perm.value else "general" category = perm.value.split(":")[0] if ":" in perm.value else "general"
description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}")
permissions.append( permissions.append(
PermissionInfo( PermissionInfo(
value=perm.value, value=perm.value,
name=perm.name, name=perm.name,
category=category, category=category,
description=description,
) )
) )

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:
# Check if this is a duplicate warning or empty result (success cases) or an actual error
# Handle both normal and Composio calendar connectors
error_or_warning_lower = (
str(error_or_warning).lower() if error_or_warning else ""
)
is_duplicate_warning = "skipped (duplicate)" in error_or_warning_lower
# "No X found" messages are success cases - sync worked, just found nothing in date range
is_empty_result = (
"no " in error_or_warning_lower
and "found" in error_or_warning_lower
)
if is_duplicate_warning or is_empty_result:
# These are success cases - sync worked, just found nothing new
logger.info(f"Indexing completed successfully: {error_or_warning}")
# Still update timestamp so ElectricSQL syncs and clears "Syncing" UI
if update_timestamp_func:
await update_timestamp_func(session, connector_id)
await session.commit() # Commit timestamp update
if notification:
# Refresh notification to ensure it's not stale after timestamp update commit
await session.refresh(notification)
# For empty results, use a cleaner message
notification_message = (
"No new items found in date range"
if is_empty_result
else error_or_warning
)
await NotificationService.connector_indexing.notify_indexing_completed(
session=session,
notification=notification,
indexed_count=0,
error_message=notification_message, # Pass as warning, not error
is_warning=True, # Flag to indicate this is a warning, not an error
)
await (
session.commit()
) # Commit to ensure Electric SQL syncs the notification update
else:
# Actual failure # Actual failure
logger.error(f"Indexing failed: {error_or_warning}") logger.error(f"Indexing failed: {error_or_warning}")
if notification: if notification:
# Refresh notification to ensure it's not stale after indexing function commits
await session.refresh(notification)
await NotificationService.connector_indexing.notify_indexing_completed( await NotificationService.connector_indexing.notify_indexing_completed(
session=session, session=session,
notification=notification, notification=notification,
indexed_count=0, indexed_count=0,
error_message=error_or_warning, error_message=error_or_warning,
) )
await (
session.commit()
) # Commit to ensure Electric SQL syncs the notification update
else: else:
# Success - just no new documents to index (all skipped/unchanged) # Success - just no new documents to index (all skipped/unchanged)
logger.info( logger.info(
@ -1136,13 +1310,19 @@ async def _run_indexing_with_notifications(
# Still update timestamp so ElectricSQL syncs and clears "Syncing" UI # Still update timestamp so ElectricSQL syncs and clears "Syncing" UI
if update_timestamp_func: if update_timestamp_func:
await update_timestamp_func(session, connector_id) await update_timestamp_func(session, connector_id)
await session.commit() # Commit timestamp update
if notification: if notification:
# Refresh notification to ensure it's not stale after timestamp update commit
await session.refresh(notification)
await NotificationService.connector_indexing.notify_indexing_completed( await NotificationService.connector_indexing.notify_indexing_completed(
session=session, session=session,
notification=notification, notification=notification,
indexed_count=0, indexed_count=0,
error_message=None, # No error - sync succeeded error_message=None, # No error - sync succeeded
) )
await (
session.commit()
) # Commit to ensure Electric SQL syncs the notification update
except Exception as e: except Exception as e:
logger.error(f"Error in indexing task: {e!s}", exc_info=True) logger.error(f"Error in indexing task: {e!s}", exc_info=True)
@ -2157,6 +2337,59 @@ async def run_obsidian_indexing(
) )
async def run_composio_indexing_with_new_session(
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""
Create a new session and run the Composio indexing task.
This prevents session leaks by creating a dedicated session for the background task.
"""
async with async_session_maker() as session:
await run_composio_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
async def run_composio_indexing(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str | None,
end_date: str | None,
):
"""
Run Composio connector indexing with real-time notifications.
This wraps the Composio indexer with the notification system so that
Electric SQL can sync indexing progress to the frontend in real-time.
Args:
session: Database session
connector_id: ID of the Composio connector
search_space_id: ID of the search space
user_id: ID of the user
start_date: Start date for indexing
end_date: End date for indexing
"""
from app.tasks.composio_indexer import index_composio_connector
await _run_indexing_with_notifications(
session=session,
connector_id=connector_id,
search_space_id=search_space_id,
user_id=user_id,
start_date=start_date,
end_date=end_date,
indexing_function=index_composio_connector,
update_timestamp_func=_update_connector_timestamp_by_id,
)
# ============================================================================= # =============================================================================
# MCP Connector Routes # MCP Connector Routes
# ============================================================================= # =============================================================================

View file

@ -129,6 +129,7 @@ async def read_search_spaces(
result = await session.execute( result = await session.execute(
select(SearchSpace) select(SearchSpace)
.filter(SearchSpace.user_id == user.id) .filter(SearchSpace.user_id == user.id)
.order_by(SearchSpace.id.asc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
) )
@ -138,6 +139,7 @@ async def read_search_spaces(
select(SearchSpace) select(SearchSpace)
.join(SearchSpaceMembership) .join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id) .filter(SearchSpaceMembership.user_id == user.id)
.order_by(SearchSpace.id.asc())
.offset(skip) .offset(skip)
.limit(limit) .limit(limit)
) )

View file

@ -0,0 +1,61 @@
"""
Schemas for incentive tasks API.
"""
from datetime import datetime
from pydantic import BaseModel
from app.db import INCENTIVE_TASKS_CONFIG, IncentiveTaskType
class IncentiveTaskInfo(BaseModel):
"""Information about an available incentive task."""
task_type: IncentiveTaskType
title: str
description: str
pages_reward: int
action_url: str
completed: bool
completed_at: datetime | None = None
class IncentiveTasksResponse(BaseModel):
"""Response containing all available incentive tasks with completion status."""
tasks: list[IncentiveTaskInfo]
total_pages_earned: int
class CompleteTaskRequest(BaseModel):
"""Request to mark a task as completed."""
task_type: IncentiveTaskType
class CompleteTaskResponse(BaseModel):
"""Response after completing a task."""
success: bool
message: str
pages_awarded: int
new_pages_limit: int
class TaskAlreadyCompletedResponse(BaseModel):
"""Response when task was already completed."""
success: bool
message: str
completed_at: datetime
def get_task_info(task_type: IncentiveTaskType) -> dict | None:
"""Get task configuration by type."""
return INCENTIVE_TASKS_CONFIG.get(task_type)
def get_all_task_types() -> list[IncentiveTaskType]:
"""Get all configured task types."""
return list(INCENTIVE_TASKS_CONFIG.keys())

View file

@ -167,6 +167,7 @@ class PermissionInfo(BaseModel):
value: str value: str
name: str name: str
category: str category: str
description: str
class PermissionsListResponse(BaseModel): class PermissionsListResponse(BaseModel):

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"
# Per Composio docs, response includes file_path where file was downloaded
# Response structure: {data: {...}, error: ..., successful: ...}
# The actual file info is nested inside data["data"]
file_path = None
if isinstance(data, dict):
# Handle nested response structure: data contains {data, error, successful}
# The actual file info is in data["data"]
inner_data = data
if "data" in data and isinstance(data["data"], dict):
inner_data = data["data"]
logger.debug(
f"Found nested data structure. Inner keys: {list(inner_data.keys())}"
)
elif "successful" in data and "data" in data:
# Standard Composio response wrapper
inner_data = data["data"] if data["data"] else data
# Try documented fields: file_path, downloaded_file_content, path, uri
file_path = (
inner_data.get("file_path")
or inner_data.get("downloaded_file_content")
or inner_data.get("path")
or inner_data.get("uri")
)
# Handle nested dict case where downloaded_file_content contains the path
if isinstance(file_path, dict):
file_path = (
file_path.get("file_path")
or file_path.get("downloaded_file_content")
or file_path.get("path")
or file_path.get("uri")
)
# If still no path, check if inner_data itself has the nested structure
if not file_path and isinstance(inner_data, dict):
for key in ["downloaded_file_content", "file_path", "path", "uri"]:
if key in inner_data:
val = inner_data[key]
if isinstance(val, str):
file_path = val
break
elif isinstance(val, dict):
# One more level of nesting
file_path = (
val.get("file_path")
or val.get("downloaded_file_content")
or val.get("path")
or val.get("uri")
)
if file_path:
break
logger.debug(
f"Composio response keys: {list(data.keys())}, inner keys: {list(inner_data.keys()) if isinstance(inner_data, dict) else 'N/A'}, extracted path: {file_path}"
)
elif isinstance(data, str):
# Direct string response (could be path or content)
file_path = data
elif isinstance(data, bytes):
# Direct bytes response
return data, None
# Read file from the path
if file_path and isinstance(file_path, str):
path_obj = Path(file_path)
# Check if it's a valid file path (absolute or in .composio directory)
if path_obj.is_absolute() or ".composio" in str(path_obj):
try:
if path_obj.exists():
content = path_obj.read_bytes()
logger.info(
f"Successfully read {len(content)} bytes from Composio file: {file_path}"
)
return content, None return content, None
else:
logger.warning(
f"File path from Composio does not exist: {file_path}"
)
return None, f"File not found at path: {file_path}"
except Exception as e:
logger.error(
f"Failed to read file from Composio path {file_path}: {e!s}"
)
return None, f"Failed to read file: {e!s}"
else:
# Not a file path - might be base64 encoded content
try:
import base64
content = base64.b64decode(file_path)
return content, None
except Exception:
# Not base64, return as UTF-8 bytes
return file_path.encode("utf-8"), None
# If we got here, couldn't extract file path
if isinstance(data, dict):
# Log full structure for debugging
inner_data = data.get("data", {})
logger.warning(
f"Could not extract file path from Composio response. "
f"Top keys: {list(data.keys())}, "
f"Inner data keys: {list(inner_data.keys()) if isinstance(inner_data, dict) else type(inner_data).__name__}, "
f"Full inner data: {inner_data}"
)
return (
None,
f"No file path in Composio response. Keys: {list(data.keys())}, inner: {list(inner_data.keys()) if isinstance(inner_data, dict) else 'N/A'}",
)
return None, f"Unexpected data type from Composio: {type(data).__name__}"
except Exception as e: except Exception as e:
logger.error(f"Failed to get Drive file content: {e!s}") logger.error(f"Failed to get Drive file content: {e!s}")
return None, str(e) return None, str(e)
async def get_drive_start_page_token(
self, connected_account_id: str, entity_id: str
) -> tuple[str | None, str | None]:
"""
Get the starting page token for Google Drive change tracking.
This token represents the current state and is used for future delta syncs.
Per Composio docs: Use GOOGLEDRIVE_GET_CHANGES_START_PAGE_TOKEN to get initial token.
Args:
connected_account_id: Composio connected account ID.
entity_id: The entity/user ID that owns the connected account.
Returns:
Tuple of (start_page_token, error message).
"""
try:
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GOOGLEDRIVE_GET_CHANGES_START_PAGE_TOKEN",
params={},
entity_id=entity_id,
)
if not result.get("success"):
return None, result.get("error", "Unknown error")
data = result.get("data", {})
# Handle nested response: {data: {startPageToken: ...}, successful: ...}
if isinstance(data, dict):
inner_data = data.get("data", data)
token = (
inner_data.get("startPageToken")
or inner_data.get("start_page_token")
or data.get("startPageToken")
or data.get("start_page_token")
)
if token:
logger.info(f"Got Drive start page token: {token}")
return token, None
logger.warning(f"Could not extract start page token from response: {data}")
return None, "No start page token in response"
except Exception as e:
logger.error(f"Failed to get Drive start page token: {e!s}")
return None, str(e)
async def list_drive_changes(
self,
connected_account_id: str,
entity_id: str,
page_token: str | None = None,
page_size: int = 100,
include_removed: bool = True,
) -> tuple[list[dict[str, Any]], str | None, str | None]:
"""
List changes in Google Drive since the given page token.
Per Composio docs: GOOGLEDRIVE_LIST_CHANGES tracks modifications to files/folders.
If pageToken is not provided, it auto-fetches the current start page token.
Response includes nextPageToken for pagination and newStartPageToken for future syncs.
Args:
connected_account_id: Composio connected account ID.
entity_id: The entity/user ID that owns the connected account.
page_token: Page token from previous sync (optional - will auto-fetch if not provided).
page_size: Number of changes per page.
include_removed: Whether to include removed items in the response.
Returns:
Tuple of (changes list, new_start_page_token, error message).
"""
try:
params = {
"pageSize": min(page_size, 100),
"includeRemoved": include_removed,
}
if page_token:
params["pageToken"] = page_token
result = await self.execute_tool(
connected_account_id=connected_account_id,
tool_name="GOOGLEDRIVE_LIST_CHANGES",
params=params,
entity_id=entity_id,
)
if not result.get("success"):
return [], None, result.get("error", "Unknown error")
data = result.get("data", {})
# Handle nested response structure
changes = []
new_start_token = None
if isinstance(data, dict):
inner_data = data.get("data", data)
changes = inner_data.get("changes", []) or data.get("changes", [])
# Get the token for next sync
# newStartPageToken is returned when all changes have been fetched
# nextPageToken is for pagination within the current fetch
new_start_token = (
inner_data.get("newStartPageToken")
or inner_data.get("new_start_page_token")
or inner_data.get("nextPageToken")
or inner_data.get("next_page_token")
or data.get("newStartPageToken")
or data.get("nextPageToken")
)
logger.info(
f"Got {len(changes)} Drive changes, new token: {new_start_token[:20] if new_start_token else 'None'}..."
)
return changes, new_start_token, None
except Exception as e:
logger.error(f"Failed to list Drive changes: {e!s}")
return [], None, str(e)
# ===== Gmail specific methods ===== # ===== Gmail specific methods =====
async def get_gmail_messages( async def get_gmail_messages(
@ -464,25 +780,30 @@ class ComposioService:
connected_account_id: str, connected_account_id: str,
entity_id: str, entity_id: str,
query: str = "", query: str = "",
max_results: int = 100, max_results: int = 50,
) -> tuple[list[dict[str, Any]], str | None]: page_token: str | None = None,
) -> tuple[list[dict[str, Any]], str | None, int | None, str | None]:
""" """
List Gmail messages via Composio. List Gmail messages via Composio with pagination support.
Args: Args:
connected_account_id: Composio connected account ID. connected_account_id: Composio connected account ID.
entity_id: The entity/user ID that owns the connected account. entity_id: The entity/user ID that owns the connected account.
query: Gmail search query. query: Gmail search query.
max_results: Maximum number of messages to return. max_results: Maximum number of messages to return per page (default: 50 to avoid payload size issues).
page_token: Optional pagination token for next page.
Returns: Returns:
Tuple of (messages list, error message). Tuple of (messages list, next_page_token, result_size_estimate, error message).
""" """
try: try:
# Composio uses snake_case for parameters, max is 500 # Use smaller batch size to avoid 413 payload too large errors
params = {"max_results": min(max_results, 500)} # Composio uses snake_case for parameters
params = {"max_results": min(max_results, 50)} # Reduced from 500 to 50
if query: if query:
params["query"] = query # Composio uses 'query' not 'q' params["query"] = query # Composio uses 'query' not 'q'
if page_token:
params["page_token"] = page_token
result = await self.execute_tool( result = await self.execute_tool(
connected_account_id=connected_account_id, connected_account_id=connected_account_id,
@ -492,31 +813,42 @@ class ComposioService:
) )
if not result.get("success"): if not result.get("success"):
return [], result.get("error", "Unknown error") return [], None, result.get("error", "Unknown error")
data = result.get("data", {}) data = result.get("data", {})
logger.info(
f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
)
logger.info(f"DEBUG: Gmail full data: {data}")
# Try different possible response structures # Try different possible response structures
messages = [] messages = []
next_token = None
result_size_estimate = None
if isinstance(data, dict): if isinstance(data, dict):
messages = ( messages = (
data.get("messages", []) data.get("messages", [])
or data.get("data", {}).get("messages", []) or data.get("data", {}).get("messages", [])
or data.get("emails", []) or data.get("emails", [])
) )
# Check for pagination token in various possible locations
next_token = (
data.get("nextPageToken")
or data.get("next_page_token")
or data.get("data", {}).get("nextPageToken")
or data.get("data", {}).get("next_page_token")
)
# Extract resultSizeEstimate if available (Gmail API provides this)
result_size_estimate = (
data.get("resultSizeEstimate")
or data.get("result_size_estimate")
or data.get("data", {}).get("resultSizeEstimate")
or data.get("data", {}).get("result_size_estimate")
)
elif isinstance(data, list): elif isinstance(data, list):
messages = data messages = data
logger.info(f"DEBUG: Extracted {len(messages)} messages") return messages, next_token, result_size_estimate, None
return messages, None
except Exception as e: except Exception as e:
logger.error(f"Failed to list Gmail messages: {e!s}") logger.error(f"Failed to list Gmail messages: {e!s}")
return [], str(e) return [], None, str(e)
async def get_gmail_message_detail( async def get_gmail_message_detail(
self, connected_account_id: str, entity_id: str, message_id: str self, connected_account_id: str, entity_id: str, message_id: str
@ -595,10 +927,6 @@ class ComposioService:
return [], result.get("error", "Unknown error") return [], result.get("error", "Unknown error")
data = result.get("data", {}) data = result.get("data", {})
logger.info(
f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}"
)
logger.info(f"DEBUG: Calendar full data: {data}")
# Try different possible response structures # Try different possible response structures
events = [] events = []
@ -611,7 +939,6 @@ class ComposioService:
elif isinstance(data, list): elif isinstance(data, list):
events = data events = data
logger.info(f"DEBUG: Extracted {len(events)} calendar events")
return events, None return events, None
except Exception as e: except Exception as e:

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,7 +354,23 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
"connector_name", "Connector" "connector_name", "Connector"
) )
# If there's an error message but items were indexed, treat it as a warning (partial success)
# If is_warning is True, treat it as success even with 0 items (e.g., duplicates found)
# Otherwise, treat it as a failure
if error_message: if error_message:
if indexed_count > 0:
# Partial success with warnings (e.g., duplicate content from other connectors)
title = f"Ready: {connector_name}"
item_text = "item" if indexed_count == 1 else "items"
message = f"Now searchable! {indexed_count} {item_text} synced. Note: {error_message}"
status = "completed"
elif is_warning:
# Warning case (e.g., duplicates found) - treat as success
title = f"Ready: {connector_name}"
message = f"Sync completed. {error_message}"
status = "completed"
else:
# Complete failure
title = f"Failed: {connector_name}" title = f"Failed: {connector_name}"
message = f"Sync failed: {error_message}" message = f"Sync failed: {error_message}"
status = "failed" status = "failed"
@ -367,7 +385,9 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
metadata_updates = { metadata_updates = {
"indexed_count": indexed_count, "indexed_count": indexed_count,
"sync_stage": "completed" if not error_message else "failed", "sync_stage": "completed"
if (not error_message or is_warning or indexed_count > 0)
else "failed",
"error_message": error_message, "error_message": error_message,
} }

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

@ -54,7 +54,12 @@ def format_attachments_as_context(attachments: list[ChatAttachment]) -> str:
def format_mentioned_documents_as_context(documents: list[Document]) -> str: def format_mentioned_documents_as_context(documents: list[Document]) -> str:
"""Format mentioned documents as context for the agent.""" """
Format mentioned documents as context for the agent.
Uses the same XML structure as knowledge_base.format_documents_for_context
to ensure citations work properly with chunk IDs.
"""
if not documents: if not documents:
return "" return ""
@ -62,13 +67,55 @@ def format_mentioned_documents_as_context(documents: list[Document]) -> str:
context_parts.append( context_parts.append(
"The user has explicitly mentioned the following documents from their knowledge base. " "The user has explicitly mentioned the following documents from their knowledge base. "
"These documents are directly relevant to the query and should be prioritized as primary sources. " "These documents are directly relevant to the query and should be prioritized as primary sources. "
"Use [citation:CHUNK_ID] format for citations (e.g., [citation:123])."
) )
for i, doc in enumerate(documents, 1): context_parts.append("")
for doc in documents:
# Build metadata JSON
metadata = doc.document_metadata or {}
metadata_json = json.dumps(metadata, ensure_ascii=False)
# Get URL from metadata
url = (
metadata.get("url")
or metadata.get("source")
or metadata.get("page_url")
or ""
)
context_parts.append("<document>")
context_parts.append("<document_metadata>")
context_parts.append(f" <document_id>{doc.id}</document_id>")
context_parts.append( context_parts.append(
f"<document index='{i}' id='{doc.id}' title='{doc.title}' type='{doc.document_type.value}'>" f" <document_type>{doc.document_type.value}</document_type>"
) )
context_parts.append(f"<![CDATA[{doc.content}]]>") context_parts.append(f" <title><![CDATA[{doc.title}]]></title>")
context_parts.append(f" <url><![CDATA[{url}]]></url>")
context_parts.append(
f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>"
)
context_parts.append("</document_metadata>")
context_parts.append("")
context_parts.append("<document_content>")
# Use chunks if available (preferred for proper citations)
if hasattr(doc, "chunks") and doc.chunks:
for chunk in doc.chunks:
context_parts.append(
f" <chunk id='{chunk.id}'><![CDATA[{chunk.content}]]></chunk>"
)
else:
# Fallback to document content if chunks not loaded
# Use document ID as chunk ID prefix for consistency
context_parts.append(
f" <chunk id='{doc.id}'><![CDATA[{doc.content}]]></chunk>"
)
context_parts.append("</document_content>")
context_parts.append("</document>") context_parts.append("</document>")
context_parts.append("")
context_parts.append("</mentioned_documents>") context_parts.append("</mentioned_documents>")
return "\n".join(context_parts) return "\n".join(context_parts)
@ -81,8 +128,6 @@ def format_mentioned_surfsense_docs_as_context(
if not documents: if not documents:
return "" return ""
import json
context_parts = ["<mentioned_surfsense_docs>"] context_parts = ["<mentioned_surfsense_docs>"]
context_parts.append( context_parts.append(
"The user has explicitly mentioned the following SurfSense documentation pages. " "The user has explicitly mentioned the following SurfSense documentation pages. "
@ -263,11 +308,15 @@ async def stream_new_chat(
# Build input with message history from frontend # Build input with message history from frontend
langchain_messages = [] langchain_messages = []
# Fetch mentioned documents if any # Fetch mentioned documents if any (with chunks for proper citations)
mentioned_documents: list[Document] = [] mentioned_documents: list[Document] = []
if mentioned_document_ids: if mentioned_document_ids:
from sqlalchemy.orm import selectinload as doc_selectinload
result = await session.execute( result = await session.execute(
select(Document).filter( select(Document)
.options(doc_selectinload(Document.chunks))
.filter(
Document.id.in_(mentioned_document_ids), Document.id.in_(mentioned_document_ids),
Document.search_space_id == search_space_id, Document.search_space_id == search_space_id,
) )

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"
) )
try:
await session.commit() await session.commit()
except Exception as e:
# Handle any remaining integrity errors gracefully (race conditions, etc.)
if (
"duplicate key value violates unique constraint" in str(e).lower()
or "uniqueviolationerror" in str(e).lower()
):
logger.warning(
f"Duplicate content_hash detected during final commit. "
f"This may occur if the same event was indexed by multiple connectors. "
f"Rolling back and continuing. Error: {e!s}"
)
await session.rollback()
# Don't fail the entire task - some documents may have been successfully indexed
else:
raise
# Build warning message if duplicates were found
warning_message = None
if duplicate_content_count > 0:
warning_message = f"{duplicate_content_count} skipped (duplicate)"
await task_logger.log_task_success( await task_logger.log_task_success(
log_entry, log_entry,
@ -484,14 +560,16 @@ async def index_google_calendar_events(
"events_processed": total_processed, "events_processed": total_processed,
"documents_indexed": documents_indexed, "documents_indexed": documents_indexed,
"documents_skipped": documents_skipped, "documents_skipped": documents_skipped,
"duplicate_content_count": duplicate_content_count,
"skipped_events_count": len(skipped_events), "skipped_events_count": len(skipped_events),
}, },
) )
logger.info( logger.info(
f"Google Calendar indexing completed: {documents_indexed} new events, {documents_skipped} skipped " f"Google Calendar indexing completed: {documents_indexed} new events, {documents_skipped} skipped "
f"({duplicate_content_count} due to duplicate content from other connectors)"
) )
return total_processed, None return total_processed, warning_message
except SQLAlchemyError as db_error: except SQLAlchemyError as db_error:
await session.rollback() await session.rollback()

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

@ -1,14 +1,18 @@
"use client"; "use client";
import { usePathname } from "next/navigation";
import { FooterNew } from "@/components/homepage/footer-new"; import { FooterNew } from "@/components/homepage/footer-new";
import { Navbar } from "@/components/homepage/navbar"; import { Navbar } from "@/components/homepage/navbar";
export default function HomePageLayout({ children }: { children: React.ReactNode }) { export default function HomePageLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAuthPage = pathname === "/login" || pathname === "/register";
return ( return (
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden"> <main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
<Navbar /> <Navbar />
{children} {children}
<FooterNew /> {!isAuthPage && <FooterNew />}
</main> </main>
); );
} }

View file

@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config"; import { AUTH_TYPE } from "@/lib/env-config";
import { ValidationError } from "@/lib/error"; import { ValidationError } from "@/lib/error";
@ -42,9 +43,6 @@ export function LocalLoginForm() {
// Track login attempt // Track login attempt
trackLoginAttempt("local"); trackLoginAttempt("local");
// Show loading toast
const loadingToast = toast.loading(tCommon("loading"));
try { try {
const data = await login({ const data = await login({
username, username,
@ -62,8 +60,7 @@ export function LocalLoginForm() {
// Success toast // Success toast
toast.success(t("login_success"), { toast.success(t("login_success"), {
id: loadingToast, description: "Redirecting to dashboard",
description: "Redirecting to dashboard...",
duration: 2000, duration: 2000,
}); });
@ -76,7 +73,6 @@ export function LocalLoginForm() {
trackLoginFailure("local", err.message); trackLoginFailure("local", err.message);
setError({ title: err.name, message: err.message }); setError({ title: err.name, message: err.message });
toast.error(err.name, { toast.error(err.name, {
id: loadingToast,
description: err.message, description: err.message,
duration: 6000, duration: 6000,
}); });
@ -106,7 +102,6 @@ export function LocalLoginForm() {
// Show error toast with conditional retry action // Show error toast with conditional retry action
const toastOptions: any = { const toastOptions: any = {
id: loadingToast,
description: errorDetails.description, description: errorDetails.description,
duration: 6000, duration: 6000,
}; };
@ -244,9 +239,16 @@ export function LocalLoginForm() {
<button <button
type="submit" type="submit"
disabled={isLoggingIn} disabled={isLoggingIn}
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base" className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
> >
{isLoggingIn ? tCommon("loading") : t("sign_in")} {isLoggingIn ? (
<>
<Spinner size="sm" className="text-white" />
<span>{t("signing_in")}</span>
</>
) : (
t("sign_in")
)}
</button> </button>
</form> </form>

View file

@ -1,12 +1,12 @@
"use client"; "use client";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Suspense, useEffect, useState } from "react"; import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config"; import { AUTH_TYPE } from "@/lib/env-config";
import { AmbientBackground } from "./AmbientBackground"; import { AmbientBackground } from "./AmbientBackground";
@ -66,7 +66,11 @@ function LoginContent() {
}); });
// Show toast with conditional retry action // Show toast with conditional retry action
const toastOptions: any = { const toastOptions: {
description: string;
duration: number;
action?: { label: string; onClick: () => void };
} = {
description: errorDescription, description: errorDescription,
duration: 6000, duration: 6000,
}; };
@ -95,20 +99,12 @@ function LoginContent() {
setIsLoading(false); setIsLoading(false);
}, [searchParams, t, tCommon]); }, [searchParams, t, tCommon]);
// Show loading state while determining auth type // Use global loading screen for auth type determination - spinner animation won't reset
useGlobalLoadingEffect(isLoading, tCommon("loading"), "login");
// Show nothing while loading - the GlobalLoadingProvider handles the loading UI
if (isLoading) { if (isLoading) {
return ( return null;
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">{tCommon("loading")}</span>
</div>
</div>
</div>
);
} }
if (authType === "GOOGLE") { if (authType === "GOOGLE") {
@ -189,23 +185,10 @@ function LoginContent() {
); );
} }
// Loading fallback for Suspense
const LoadingFallback = () => (
<div className="relative w-full overflow-hidden">
<AmbientBackground />
<div className="mx-auto flex h-screen max-w-lg flex-col items-center justify-center">
<Logo className="rounded-md" />
<div className="mt-8 flex items-center space-x-2">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
</div>
);
export default function LoginPage() { export default function LoginPage() {
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
return ( return (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={null}>
<LoginContent /> <LoginContent />
</Suspense> </Suspense>
); );

View file

@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { AUTH_TYPE } from "@/lib/env-config"; import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error"; import { AppError, ValidationError } from "@/lib/error";
@ -60,9 +61,6 @@ export default function RegisterPage() {
// Track registration attempt // Track registration attempt
trackRegistrationAttempt(); trackRegistrationAttempt();
// Show loading toast
const loadingToast = toast.loading(t("creating_account"));
try { try {
await register({ await register({
email, email,
@ -77,7 +75,6 @@ export default function RegisterPage() {
// Success toast // Success toast
toast.success(t("register_success"), { toast.success(t("register_success"), {
id: loadingToast,
description: t("redirecting_login"), description: t("redirecting_login"),
duration: 2000, duration: 2000,
}); });
@ -95,7 +92,6 @@ export default function RegisterPage() {
trackRegistrationFailure("Registration disabled"); trackRegistrationFailure("Registration disabled");
setError({ title: "Registration is disabled", message: friendlyMessage }); setError({ title: "Registration is disabled", message: friendlyMessage });
toast.error("Registration is disabled", { toast.error("Registration is disabled", {
id: loadingToast,
description: friendlyMessage, description: friendlyMessage,
duration: 6000, duration: 6000,
}); });
@ -109,7 +105,6 @@ export default function RegisterPage() {
trackRegistrationFailure(err.message); trackRegistrationFailure(err.message);
setError({ title: err.name, message: err.message }); setError({ title: err.name, message: err.message });
toast.error(err.name, { toast.error(err.name, {
id: loadingToast,
description: err.message, description: err.message,
duration: 6000, duration: 6000,
}); });
@ -137,7 +132,6 @@ export default function RegisterPage() {
// Show error toast with conditional retry action // Show error toast with conditional retry action
const toastOptions: any = { const toastOptions: any = {
id: loadingToast,
description: errorDetails.description, description: errorDetails.description,
duration: 6000, duration: 6000,
}; };
@ -295,9 +289,16 @@ export default function RegisterPage() {
<button <button
type="submit" type="submit"
disabled={isRegistering} disabled={isRegistering}
className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base" className="w-full rounded-md bg-blue-600 px-4 py-1.5 md:py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-all text-sm md:text-base flex items-center justify-center gap-2"
> >
{isRegistering ? t("creating_account_btn") : t("register")} {isRegistering ? (
<>
<Spinner size="sm" className="text-white" />
<span>{t("creating_account_btn")}</span>
</>
) : (
t("register")
)}
</button> </button>
</form> </form>

View file

@ -0,0 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function AuthCallbackLoading() {
const t = useTranslations("auth");
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
// Return null - the GlobalLoadingProvider handles the loading UI
return null;
}

View file

@ -1,23 +1,18 @@
"use client";
import { Suspense } from "react"; import { Suspense } from "react";
import TokenHandler from "@/components/TokenHandler"; import TokenHandler from "@/components/TokenHandler";
export default function AuthCallbackPage() { export default function AuthCallbackPage() {
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
// TokenHandler uses useGlobalLoadingEffect to show the loading screen
return ( return (
<div className="container mx-auto p-4"> <Suspense fallback={null}>
<h1 className="text-2xl font-bold mb-4">Authentication Callback</h1>
<Suspense
fallback={
<div className="flex items-center justify-center min-h-[200px]">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
}
>
<TokenHandler <TokenHandler
redirectPath="/dashboard" redirectPath="/dashboard"
tokenParamName="token" tokenParamName="token"
storageKey="surfsense_bearer_token" storageKey="surfsense_bearer_token"
/> />
</Suspense> </Suspense>
</div>
); );
} }

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { Loader2 } from "lucide-react";
import { useParams, usePathname, useRouter } from "next/navigation"; import { useParams, usePathname, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import type React from "react"; import type React from "react";
@ -19,6 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LayoutDataProvider } from "@/components/layout"; import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour"; import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export function DashboardClientLayout({ export function DashboardClientLayout({
children, children,
@ -146,31 +146,22 @@ export function DashboardClientLayout({
setActiveSearchSpaceIdState(activeSeacrhSpaceId); setActiveSearchSpaceIdState(activeSeacrhSpaceId);
}, [search_space_id, setActiveSearchSpaceIdState]); }, [search_space_id, setActiveSearchSpaceIdState]);
if ( // Determine if we should show loading
const shouldShowLoading =
(!hasCheckedOnboarding && (!hasCheckedOnboarding &&
(loading || accessLoading || globalConfigsLoading) && (loading || accessLoading || globalConfigsLoading) &&
!isOnboardingPage) || !isOnboardingPage) ||
isAutoConfiguring isAutoConfiguring;
) {
return ( // Use global loading screen - spinner animation won't reset
<div className="flex flex-col items-center justify-center min-h-screen space-y-4"> useGlobalLoadingEffect(
<Card className="w-[350px] bg-background/60 backdrop-blur-sm"> shouldShowLoading,
<CardHeader className="pb-2"> isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"),
<CardTitle className="text-xl font-medium"> "default"
{isAutoConfiguring ? "Setting up AI..." : t("loading_config")}
</CardTitle>
<CardDescription>
{isAutoConfiguring
? "Auto-configuring with available settings"
: t("checking_llm_prefs")}
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
); );
if (shouldShowLoading) {
return null;
} }
if (error && !hasCheckedOnboarding && !isOnboardingPage) { if (error && !hasCheckedOnboarding && !isOnboardingPage) {

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronDown, ChevronUp, FileX, Loader2, Plus } from "lucide-react"; import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -9,6 +9,7 @@ import { useDocumentUploadDialog } from "@/components/assistant-ui/document-uplo
import { DocumentViewer } from "@/components/document-viewer"; import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner";
import { import {
Table, Table,
TableBody, TableBody,
@ -114,7 +115,7 @@ export function DocumentsTableShell({
{loading ? ( {loading ? (
<div className="flex h-[400px] w-full items-center justify-center"> <div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Spinner size="lg" className="text-primary" />
<p className="text-sm text-muted-foreground">{t("loading")}</p> <p className="text-sm text-muted-foreground">{t("loading")}</p>
</div> </div>
</div> </div>

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

@ -1,8 +1,7 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react"; import { AlertCircle, ArrowLeft, FileText, Save } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
@ -21,6 +20,7 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { notesApiService } from "@/lib/apis/notes-api.service"; import { notesApiService } from "@/lib/apis/notes-api.service";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
@ -78,7 +78,6 @@ function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string
export default function EditorPage() { export default function EditorPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient();
const documentId = params.documentId as string; const documentId = params.documentId as string;
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const isNewNote = documentId === "new"; const isNewNote = documentId === "new";
@ -349,8 +348,8 @@ export default function EditorPage() {
<div className="flex items-center justify-center min-h-[400px] p-6"> <div className="flex items-center justify-center min-h-[400px] p-6">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" /> <Spinner size="xl" className="text-primary mb-4" />
<p className="text-muted-foreground">Loading editor...</p> <p className="text-muted-foreground">Loading editor</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -437,7 +436,7 @@ export default function EditorPage() {
> >
{saving ? ( {saving ? (
<> <>
<Loader2 className="h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" /> <Spinner size="sm" className="h-3.5 w-3.5 md:h-4 md:w-4" />
<span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span> <span className="text-xs md:text-sm">{isNewNote ? "Creating" : "Saving"}</span>
</> </>
) : ( ) : (

View file

@ -0,0 +1,210 @@
"use client";
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useEffect } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
import {
trackIncentiveContactOpened,
trackIncentivePageViewed,
trackIncentiveTaskClicked,
trackIncentiveTaskCompleted,
} from "@/lib/posthog/events";
import { cn } from "@/lib/utils";
export default function MorePagesPage() {
const queryClient = useQueryClient();
// Track page view on mount
useEffect(() => {
trackIncentivePageViewed();
}, []);
// Fetch tasks from API
const { data, isLoading } = useQuery({
queryKey: ["incentive-tasks"],
queryFn: () => incentiveTasksApiService.getTasks(),
});
// Mutation to complete a task
const completeMutation = useMutation({
mutationFn: incentiveTasksApiService.completeTask,
onSuccess: (response, taskType) => {
if (response.success) {
toast.success(response.message);
// Track task completion
const task = data?.tasks.find((t) => t.task_type === taskType);
if (task) {
trackIncentiveTaskCompleted(taskType, task.pages_reward);
}
// Invalidate queries to refresh data
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
onError: () => {
toast.error("Failed to complete task. Please try again.");
},
});
const handleTaskClick = (task: IncentiveTaskInfo) => {
if (!task.completed) {
trackIncentiveTaskClicked(task.task_type);
completeMutation.mutate(task.task_type);
}
};
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
return (
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-md"
>
{/* Header */}
<div className="mb-6 text-center">
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
<p className="text-sm text-muted-foreground">Complete tasks to earn additional pages</p>
</div>
{/* Tasks */}
{isLoading ? (
<Card>
<CardContent className="flex items-center gap-3 p-3">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/4" />
</div>
<Skeleton className="h-8 w-16" />
</CardContent>
</Card>
) : (
<div className="space-y-2">
{data?.tasks.map((task) => (
<Card
key={task.task_type}
className={cn("transition-colors", task.completed && "bg-muted/50")}
>
<CardContent className="flex items-center gap-3 p-3">
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
)}
>
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-medium",
task.completed && "text-muted-foreground line-through"
)}
>
{task.title}
</p>
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
</div>
<Button
variant={task.completed ? "ghost" : "outline"}
size="sm"
disabled={task.completed || completeMutation.isPending}
onClick={() => handleTaskClick(task)}
asChild={!task.completed}
>
{task.completed ? (
<span>Done</span>
) : (
<a
href={task.action_url}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
{completeMutation.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
Go
<ExternalLink className="h-3 w-3" />
</>
)}
</a>
)}
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* Contact */}
<Separator className="my-6" />
<div className="text-center">
<p className="mb-3 text-sm text-muted-foreground">
{allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"}
</p>
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Mail className="h-4 w-4" />
Contact Us
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Contact Us</DialogTitle>
<DialogDescription>Schedule a meeting or send us an email.</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-4 py-4">
<Link
href="https://calendly.com/eric-surfsense/surfsense-meeting"
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
<IconCalendar className="h-4 w-4" />
Schedule a Meeting
</Link>
<div className="flex items-center gap-2 text-muted-foreground">
<span className="h-px w-8 bg-border" />
<span className="text-xs">or</span>
<span className="h-px w-8 bg-border" />
</div>
<Link
href="mailto:eric@surfsense.com"
className="flex items-center gap-2 text-sm text-muted-foreground transition hover:text-foreground"
>
<IconMailFilled className="h-4 w-4" />
eric@surfsense.com
</Link>
</div>
</DialogContent>
</Dialog>
</div>
</motion.div>
</div>
);
}

View file

@ -9,6 +9,7 @@ import {
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { useParams, useSearchParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@ -34,6 +35,7 @@ import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview"; import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage"; import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
import { Spinner } from "@/components/ui/spinner";
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state"; import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
import { useMessagesElectric } from "@/hooks/use-messages-electric"; import { useMessagesElectric } from "@/hooks/use-messages-electric";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos"; // import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
@ -132,6 +134,7 @@ interface ThinkingStepData {
} }
export default function NewChatPage() { export default function NewChatPage() {
const t = useTranslations("dashboard");
const params = useParams(); const params = useParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isInitializing, setIsInitializing] = useState(true); const [isInitializing, setIsInitializing] = useState(true);
@ -1379,8 +1382,9 @@ export default function NewChatPage() {
// Show loading state only when loading an existing thread // Show loading state only when loading an existing thread
if (isInitializing) { if (isInitializing) {
return ( return (
<div className="flex h-[calc(100vh-64px)] items-center justify-center"> <div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
<div className="text-muted-foreground">Loading chat...</div> <Spinner size="lg" />
<div className="text-sm text-muted-foreground">{t("loading_chat")}</div>
</div> </div>
); );
} }

View file

@ -1,7 +1,6 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Loader2 } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
@ -17,6 +16,7 @@ import {
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form"; import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
export default function OnboardPage() { export default function OnboardPage() {
@ -156,7 +156,7 @@ export default function OnboardPage() {
<div className="relative"> <div className="relative">
<div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" /> <div className="absolute inset-0 blur-3xl bg-gradient-to-r from-violet-500/20 to-cyan-500/20 rounded-full" />
<div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25"> <div className="relative flex items-center justify-center w-24 h-24 mx-auto rounded-2xl bg-gradient-to-br from-violet-500 to-purple-600 shadow-2xl shadow-violet-500/25">
<Loader2 className="h-12 w-12 text-white animate-spin" /> <Spinner size="xl" className="text-white" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View file

@ -5,6 +5,7 @@ import {
Bot, Bot,
Brain, Brain,
ChevronRight, ChevronRight,
FileText,
type LucideIcon, type LucideIcon,
Menu, Menu,
MessageSquare, MessageSquare,
@ -15,6 +16,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
import { LLMRoleManager } from "@/components/settings/llm-role-manager"; import { LLMRoleManager } from "@/components/settings/llm-role-manager";
import { ModelConfigManager } from "@/components/settings/model-config-manager"; import { ModelConfigManager } from "@/components/settings/model-config-manager";
import { PromptConfigManager } from "@/components/settings/prompt-config-manager"; import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
@ -30,6 +32,12 @@ interface SettingsNavItem {
} }
const settingsNavItems: SettingsNavItem[] = [ const settingsNavItems: SettingsNavItem[] = [
{
id: "general",
labelKey: "nav_general",
descriptionKey: "nav_general_desc",
icon: FileText,
},
{ {
id: "models", id: "models",
labelKey: "nav_agent_configs", labelKey: "nav_agent_configs",
@ -262,6 +270,9 @@ function SettingsContent({
ease: [0.4, 0, 0.2, 1], ease: [0.4, 0, 0.2, 1],
}} }}
> >
{activeSection === "general" && (
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
)}
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />} {activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />} {activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />} {activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
@ -277,7 +288,7 @@ export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const [activeSection, setActiveSection] = useState("models"); const [activeSection, setActiveSection] = useState("general");
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
// Track settings section view // Track settings section view

View file

@ -14,7 +14,6 @@ import {
Hash, Hash,
Link2, Link2,
LinkIcon, LinkIcon,
Loader2,
Logs, Logs,
type LucideIcon, type LucideIcon,
MessageCircle, MessageCircle,
@ -96,6 +95,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { import {
Table, Table,
TableBody, TableBody,
@ -105,7 +105,6 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type { import type {
CreateInviteRequest, CreateInviteRequest,
DeleteInviteRequest, DeleteInviteRequest,
@ -122,6 +121,7 @@ import type {
Role, Role,
UpdateRoleRequest, UpdateRoleRequest,
} from "@/contracts/types/roles.types"; } from "@/contracts/types/roles.types";
import type { PermissionInfo } from "@/contracts/types/permissions.types";
import { invitesApiService } from "@/lib/apis/invites-api.service"; import { invitesApiService } from "@/lib/apis/invites-api.service";
import { rolesApiService } from "@/lib/apis/roles-api.service"; import { rolesApiService } from "@/lib/apis/roles-api.service";
import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events"; import { trackSearchSpaceInviteSent, trackSearchSpaceUsersViewed } from "@/lib/posthog/events";
@ -321,7 +321,7 @@ export default function TeamManagementPage() {
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-4" className="flex flex-col items-center gap-4"
> >
<Loader2 className="h-10 w-10 text-primary animate-spin" /> <Spinner size="lg" className="text-primary" />
<p className="text-muted-foreground">Loading team data...</p> <p className="text-muted-foreground">Loading team data...</p>
</motion.div> </motion.div>
</div> </div>
@ -471,13 +471,6 @@ export default function TeamManagementPage() {
className="w-full md:w-auto" className="w-full md:w-auto"
/> />
)} )}
{activeTab === "roles" && hasPermission("roles:create") && (
<CreateRoleDialog
groupedPermissions={groupedPermissions}
onCreateRole={handleCreateRole}
className="w-full md:w-auto"
/>
)}
</div> </div>
<TabsContent value="members" className="space-y-4"> <TabsContent value="members" className="space-y-4">
@ -499,8 +492,10 @@ export default function TeamManagementPage() {
loading={rolesLoading} loading={rolesLoading}
onUpdateRole={handleUpdateRole} onUpdateRole={handleUpdateRole}
onDeleteRole={handleDeleteRole} onDeleteRole={handleDeleteRole}
onCreateRole={handleCreateRole}
canUpdate={hasPermission("roles:update")} canUpdate={hasPermission("roles:update")}
canDelete={hasPermission("roles:delete")} canDelete={hasPermission("roles:delete")}
canCreate={hasPermission("roles:create")}
/> />
</TabsContent> </TabsContent>
@ -571,7 +566,7 @@ function MembersTab({
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-primary animate-spin" /> <Spinner size="md" className="text-primary" />
</div> </div>
); );
} }
@ -767,17 +762,71 @@ function MembersTab({
// ============ Role Permissions Display ============ // ============ Role Permissions Display ============
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = { // Unified category configuration used across all role-related components
documents: { label: "Documents", icon: FileText, order: 1 }, const CATEGORY_CONFIG: Record<
chats: { label: "Chats", icon: MessageSquare, order: 2 }, string,
comments: { label: "Comments", icon: MessageCircle, order: 3 }, { label: string; icon: LucideIcon; description: string; order: number }
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 }, > = {
podcasts: { label: "Podcasts", icon: Mic, order: 5 }, documents: {
connectors: { label: "Connectors", icon: Plug, order: 6 }, label: "Documents",
logs: { label: "Logs", icon: Logs, order: 7 }, icon: FileText,
members: { label: "Members", icon: Users, order: 8 }, description: "Manage files, notes, and content",
roles: { label: "Roles", icon: Shield, order: 9 }, order: 1,
settings: { label: "Settings", icon: Settings, order: 10 }, },
chats: {
label: "AI Chats",
icon: MessageSquare,
description: "Create and manage AI conversations",
order: 2,
},
comments: {
label: "Comments",
icon: MessageCircle,
description: "Add annotations to documents",
order: 3,
},
llm_configs: {
label: "AI Models",
icon: Bot,
description: "Configure AI model settings",
order: 4,
},
podcasts: {
label: "Podcasts",
icon: Mic,
description: "Generate AI podcasts from content",
order: 5,
},
connectors: {
label: "Integrations",
icon: Plug,
description: "Connect external data sources",
order: 6,
},
logs: {
label: "Activity Logs",
icon: Logs,
description: "View and manage audit trail",
order: 7,
},
members: {
label: "Team Members",
icon: Users,
description: "Manage team membership",
order: 8,
},
roles: {
label: "Roles",
icon: Shield,
description: "Configure role permissions",
order: 9,
},
settings: {
label: "Settings",
icon: Settings,
description: "Manage search space settings",
order: 10,
},
}; };
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
@ -893,25 +942,31 @@ function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
function RolesTab({ function RolesTab({
roles, roles,
groupedPermissions: _groupedPermissions, groupedPermissions,
loading, loading,
onUpdateRole: _onUpdateRole, onUpdateRole: _onUpdateRole,
onDeleteRole, onDeleteRole,
onCreateRole,
canUpdate, canUpdate,
canDelete, canDelete,
canCreate,
}: { }: {
roles: Role[]; roles: Role[];
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>; groupedPermissions: Record<string, PermissionWithDescription[]>;
loading: boolean; loading: boolean;
onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>; onUpdateRole: (roleId: number, data: { permissions?: string[] }) => Promise<Role>;
onDeleteRole: (roleId: number) => Promise<boolean>; onDeleteRole: (roleId: number) => Promise<boolean>;
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
canUpdate: boolean; canUpdate: boolean;
canDelete: boolean; canDelete: boolean;
canCreate: boolean;
}) { }) {
const [showCreateRole, setShowCreateRole] = useState(false);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-primary animate-spin" /> <Spinner size="md" className="text-primary" />
</div> </div>
); );
} }
@ -921,8 +976,33 @@ function RolesTab({
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }} exit={{ opacity: 0, y: -10 }}
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" className="space-y-6"
> >
{/* Create Role Button / Section */}
{canCreate && !showCreateRole && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex justify-end"
>
<Button onClick={() => setShowCreateRole(true)} className="gap-2">
<Plus className="h-4 w-4" />
Create Custom Role
</Button>
</motion.div>
)}
{/* Create Role Form */}
{showCreateRole && (
<CreateRoleSection
groupedPermissions={groupedPermissions}
onCreateRole={onCreateRole}
onCancel={() => setShowCreateRole(false)}
/>
)}
{/* Roles Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{roles.map((role, index) => ( {roles.map((role, index) => (
<motion.div <motion.div
key={role.id} key={role.id}
@ -1007,8 +1087,8 @@ function RolesTab({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete role?</AlertDialogTitle> <AlertDialogTitle>Delete role?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This will permanently delete the "{role.name}" role. Members with This will permanently delete the "{role.name}" role. Members
this role will lose their permissions. with this role will lose their permissions.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@ -1038,6 +1118,7 @@ function RolesTab({
</Card> </Card>
</motion.div> </motion.div>
))} ))}
</div>
</motion.div> </motion.div>
); );
} }
@ -1068,7 +1149,7 @@ function InvitesTab({
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 text-primary animate-spin" /> <Spinner size="md" className="text-primary" />
</div> </div>
); );
} }
@ -1446,7 +1527,7 @@ function CreateInviteDialog({
<Button onClick={handleCreate} disabled={creating}> <Button onClick={handleCreate} disabled={creating}>
{creating ? ( {creating ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Spinner size="sm" className="mr-2" />
Creating Creating
</> </>
) : ( ) : (
@ -1461,13 +1542,14 @@ function CreateInviteDialog({
); );
} }
// ============ Create Role Dialog ============ // ============ Create Role Section ============
// Preset permission sets for quick role creation // Preset permission sets for quick role creation
// Editor: can create/read/update content, but cannot manage roles, remove members, or change settings const ROLE_PRESETS = {
// Viewer: read-only access with ability to create comments editor: {
const PRESET_PERMISSIONS = { name: "Editor",
editor: [ description: "Can create, read, and update content, but cannot delete or manage team settings",
permissions: [
"documents:create", "documents:create",
"documents:read", "documents:read",
"documents:update", "documents:update",
@ -1491,7 +1573,11 @@ const PRESET_PERMISSIONS = {
"roles:read", "roles:read",
"settings:view", "settings:view",
], ],
viewer: [ },
viewer: {
name: "Viewer",
description: "Read-only access with ability to add comments",
permissions: [
"documents:read", "documents:read",
"chats:read", "chats:read",
"comments:create", "comments:create",
@ -1504,23 +1590,68 @@ const PRESET_PERMISSIONS = {
"roles:read", "roles:read",
"settings:view", "settings:view",
], ],
},
contributor: {
name: "Contributor",
description: "Can add and manage their own content",
permissions: [
"documents:create",
"documents:read",
"documents:update",
"chats:create",
"chats:read",
"comments:create",
"comments:read",
"llm_configs:read",
"podcasts:read",
"connectors:read",
"logs:read",
"members:view",
"roles:read",
"settings:view",
],
},
}; };
function CreateRoleDialog({ // Action display labels
const ACTION_DISPLAY: Record<string, { label: string; color: string }> = {
create: { label: "Create", color: "text-emerald-600 bg-emerald-500/10" },
read: { label: "View", color: "text-blue-600 bg-blue-500/10" },
update: { label: "Edit", color: "text-amber-600 bg-amber-500/10" },
delete: { label: "Delete", color: "text-red-600 bg-red-500/10" },
invite: { label: "Invite", color: "text-violet-600 bg-violet-500/10" },
view: { label: "View", color: "text-blue-600 bg-blue-500/10" },
remove: { label: "Remove", color: "text-red-600 bg-red-500/10" },
manage_roles: { label: "Manage Roles", color: "text-violet-600 bg-violet-500/10" },
};
// Use the imported PermissionInfo type which now includes description
type PermissionWithDescription = PermissionInfo;
function CreateRoleSection({
groupedPermissions, groupedPermissions,
onCreateRole, onCreateRole,
className, onCancel,
}: { }: {
groupedPermissions: Record<string, { value: string; name: string; category: string }[]>; groupedPermissions: Record<string, PermissionWithDescription[]>;
onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>; onCreateRole: (data: CreateRoleRequest["data"]) => Promise<Role>;
className?: string; onCancel: () => void;
}) { }) {
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]); const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
const [isDefault, setIsDefault] = useState(false); const [isDefault, setIsDefault] = useState(false);
const [expandedCategories, setExpandedCategories] = useState<string[]>([]);
// Sort categories by order
const sortedCategories = useMemo(() => {
return Object.keys(groupedPermissions).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
}, [groupedPermissions]);
const handleCreate = async () => { const handleCreate = async () => {
if (!name.trim()) { if (!name.trim()) {
@ -1536,11 +1667,7 @@ function CreateRoleDialog({
permissions: selectedPermissions, permissions: selectedPermissions,
is_default: isDefault, is_default: isDefault,
}); });
setOpen(false); onCancel();
setName("");
setDescription("");
setSelectedPermissions([]);
setIsDefault(false);
} catch (error) { } catch (error) {
console.error("Failed to create role:", error); console.error("Failed to create role:", error);
} finally { } finally {
@ -1548,13 +1675,14 @@ function CreateRoleDialog({
} }
}; };
const togglePermission = (perm: string) => { const togglePermission = useCallback((perm: string) => {
setSelectedPermissions((prev) => setSelectedPermissions((prev) =>
prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm] prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
); );
}; }, []);
const toggleCategory = (category: string) => { const toggleCategory = useCallback(
(category: string) => {
const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || []; const categoryPerms = groupedPermissions[category]?.map((p) => p.value) || [];
const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p)); const allSelected = categoryPerms.every((p) => selectedPermissions.includes(p));
@ -1563,151 +1691,342 @@ function CreateRoleDialog({
} else { } else {
setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]); setSelectedPermissions((prev) => [...new Set([...prev, ...categoryPerms])]);
} }
}; },
[groupedPermissions, selectedPermissions]
);
const applyPreset = (preset: "editor" | "viewer") => { const toggleCategoryExpanded = useCallback((category: string) => {
setSelectedPermissions(PRESET_PERMISSIONS[preset]); setExpandedCategories((prev) =>
toast.success(`Applied ${preset === "editor" ? "Editor" : "Viewer"} preset permissions`); prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
}; );
}, []);
const applyPreset = useCallback((presetKey: keyof typeof ROLE_PRESETS) => {
const preset = ROLE_PRESETS[presetKey];
setSelectedPermissions(preset.permissions);
if (!name.trim()) {
setName(preset.name);
setDescription(preset.description);
}
toast.success(`Applied ${preset.name} preset`);
}, [name]);
const getCategoryStats = useCallback(
(category: string) => {
const perms = groupedPermissions[category] || [];
const selected = perms.filter((p) => selectedPermissions.includes(p.value)).length;
return { selected, total: perms.length, allSelected: selected === perms.length };
},
[groupedPermissions, selectedPermissions]
);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <motion.div
<DialogTrigger asChild> initial={{ opacity: 0, y: -10 }}
<Button className={cn("gap-2", className)}> animate={{ opacity: 1, y: 0 }}
<Plus className="h-4 w-4" /> exit={{ opacity: 0, y: -10 }}
Create Role className="mb-6"
>
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-background to-background">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-xl bg-primary/10 flex items-center justify-center">
<Plus className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">Create Custom Role</CardTitle>
<CardDescription className="text-sm">
Define permissions for a new role in this search space
</CardDescription>
</div>
</div>
<Button variant="ghost" size="icon" onClick={onCancel}>
<Trash2 className="h-4 w-4" />
</Button> </Button>
</DialogTrigger> </div>
<DialogContent className="w-[92vw] max-w-[92vw] sm:max-w-xl p-4 md:p-6"> </CardHeader>
<DialogHeader> <CardContent className="space-y-6">
<DialogTitle>Create Custom Role</DialogTitle> {/* Quick Start with Presets */}
<DialogDescription className="text-xs md:text-sm"> <div className="space-y-3">
Define a new role with specific permissions for this search space. <Label className="text-sm font-medium">Quick Start with a Template</Label>
</DialogDescription> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
</DialogHeader> {Object.entries(ROLE_PRESETS).map(([key, preset]) => (
<div className="space-y-3 py-2 md:py-4"> <button
<div className="flex flex-col md:grid md:grid-cols-2 gap-3 md:gap-4"> key={key}
type="button"
onClick={() => applyPreset(key as keyof typeof ROLE_PRESETS)}
className={cn(
"p-4 rounded-lg border-2 text-left transition-all hover:border-primary/50 hover:bg-primary/5",
selectedPermissions.length > 0 &&
preset.permissions.every((p) => selectedPermissions.includes(p))
? "border-primary bg-primary/10"
: "border-border"
)}
>
<div className="flex items-center gap-2 mb-1">
<ShieldCheck
className={cn(
"h-4 w-4",
key === "editor" && "text-blue-600",
key === "viewer" && "text-gray-600",
key === "contributor" && "text-emerald-600"
)}
/>
<span className="font-medium text-sm">{preset.name}</span>
</div>
<p className="text-xs text-muted-foreground">{preset.description}</p>
</button>
))}
</div>
</div>
{/* Role Details */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="role-name">Role Name *</Label> <Label htmlFor="role-name">Role Name *</Label>
<Input <Input
id="role-name" id="role-name"
placeholder="e.g., Contributor" placeholder="e.g., Content Manager"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="flex items-center gap-2"> <Label htmlFor="role-description">Description</Label>
<Checkbox checked={isDefault} onCheckedChange={(v) => setIsDefault(!!v)} /> <Input
id="role-description"
placeholder="Brief description of this role"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</div>
{/* Default Role Checkbox */}
<div className="flex items-center gap-3 p-3 rounded-lg bg-muted/50">
<Checkbox
id="is-default"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked === true)}
/>
<div className="flex-1">
<Label htmlFor="is-default" className="cursor-pointer font-medium">
Set as default role Set as default role
</Label> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
New invites without a role will use this New members without a specific role will be assigned this role
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2">
<Label htmlFor="role-description">Description</Label> {/* Permissions Section */}
<Textarea <div className="space-y-3">
id="role-description"
placeholder="Describe what this role can do..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label>Permissions ({selectedPermissions.length} selected)</Label> <Label className="text-sm font-medium">
<div className="flex gap-2"> Permissions ({selectedPermissions.length} selected)
</Label>
<Button <Button
type="button" type="button"
variant="outline" variant="ghost"
size="sm" size="sm"
className="h-7 text-xs gap-1" className="text-xs h-7"
onClick={() => applyPreset("editor")} onClick={() =>
setExpandedCategories(
expandedCategories.length === sortedCategories.length ? [] : sortedCategories
)
}
> >
<ShieldCheck className="h-3 w-3 text-blue-600" /> {expandedCategories.length === sortedCategories.length
Editor Preset ? "Collapse All"
</Button> : "Expand All"}
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs gap-1"
onClick={() => applyPreset("viewer")}
>
<ShieldCheck className="h-3 w-3 text-gray-600" />
Viewer Preset
</Button> </Button>
</div> </div>
</div>
<p className="text-xs text-muted-foreground"> <div className="space-y-2">
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only) {sortedCategories.map((category) => {
permissions const config = CATEGORY_CONFIG[category] || {
</p> label: category,
<ScrollArea className="h-64 rounded-lg border p-4"> icon: FileText,
<div className="space-y-4"> description: "",
{Object.entries(groupedPermissions).map(([category, perms]) => { order: 99,
const categorySelected = perms.filter((p) => };
selectedPermissions.includes(p.value) const IconComponent = config.icon;
).length; const stats = getCategoryStats(category);
const allSelected = categorySelected === perms.length; const isExpanded = expandedCategories.includes(category);
const perms = groupedPermissions[category] || [];
return ( return (
<div key={category} className="space-y-2"> <div
<button key={category}
type="button" className="rounded-lg border bg-card overflow-hidden"
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
onClick={() => toggleCategory(category)}
> >
{/* Category Header */}
<div
className={cn(
"flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors",
stats.allSelected && "bg-primary/5"
)}
onClick={() => toggleCategoryExpanded(category)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleCategoryExpanded(category);
}
}}
tabIndex={0}
role="button"
>
<div className="flex items-center gap-3">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
stats.selected > 0 ? "bg-primary/10" : "bg-muted"
)}
>
<IconComponent
className={cn(
"h-4 w-4",
stats.selected > 0 ? "text-primary" : "text-muted-foreground"
)}
/>
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{config.label}</span>
<Badge
variant={stats.selected > 0 ? "default" : "secondary"}
className="text-xs h-5"
>
{stats.selected}/{stats.total}
</Badge>
</div>
<p className="text-xs text-muted-foreground hidden md:block">
{config.description}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Checkbox <Checkbox
checked={allSelected} checked={stats.allSelected}
onCheckedChange={() => toggleCategory(category)} onCheckedChange={() => toggleCategory(category)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`}
/> />
<span className="text-sm font-medium capitalize"> <motion.div
{category} ({categorySelected}/{perms.length}) animate={{ rotate: isExpanded ? 180 : 0 }}
</span> transition={{ duration: 0.2 }}
</button>
<div className="grid grid-cols-2 gap-2 ml-6">
{perms.map((perm) => (
<button
type="button"
key={perm.value}
className="flex items-center gap-2 cursor-pointer text-left"
onClick={() => togglePermission(perm.value)}
> >
<Checkbox <svg
checked={selectedPermissions.includes(perm.value)} className="h-4 w-4 text-muted-foreground"
onCheckedChange={() => togglePermission(perm.value)} fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/> />
<span className="text-xs">{perm.value.split(":")[1]}</span> </svg>
</button> </motion.div>
))} </div>
</div>
{/* Permissions List */}
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t"
>
<div className="p-3 space-y-1">
{perms.map((perm) => {
const action = perm.value.split(":")[1];
const actionConfig = ACTION_DISPLAY[action] || {
label: action,
color: "text-gray-600 bg-gray-500/10",
};
const isSelected = selectedPermissions.includes(perm.value);
return (
<div
key={perm.value}
className={cn(
"flex items-center justify-between p-2 rounded-md cursor-pointer transition-colors",
isSelected
? "bg-primary/10 hover:bg-primary/15"
: "hover:bg-muted/50"
)}
onClick={() => togglePermission(perm.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
togglePermission(perm.value);
}
}}
tabIndex={0}
role="checkbox"
aria-checked={isSelected}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Checkbox
checked={isSelected}
onCheckedChange={() => togglePermission(perm.value)}
onClick={(e) => e.stopPropagation()}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span
className={cn(
"text-xs font-medium px-2 py-0.5 rounded",
actionConfig.color
)}
>
{actionConfig.label}
</span>
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{perm.description}
</p>
</div>
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
</ScrollArea> </motion.div>
)}
</div>
);
})}
</div> </div>
</div> </div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}> {/* Actions */}
<div className="flex items-center justify-end gap-3 pt-4 border-t">
<Button variant="outline" onClick={onCancel}>
Cancel Cancel
</Button> </Button>
<Button onClick={handleCreate} disabled={creating || !name.trim()}> <Button onClick={handleCreate} disabled={creating || !name.trim()}>
{creating ? ( {creating ? (
<> <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> <Spinner size="sm" className="mr-2" />
Creating Creating...
</> </>
) : ( ) : (
"Create Role" <>
<Check className="h-4 w-4 mr-2" />
Create Role
</>
)} )}
</Button> </Button>
</DialogFooter> </div>
</DialogContent> </CardContent>
</Dialog> </Card>
</motion.div>
); );
} }

View file

@ -1,8 +1,8 @@
"use client"; "use client";
import { Loader2 } from "lucide-react"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils"; import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
interface DashboardLayoutProps { interface DashboardLayoutProps {
@ -10,8 +10,12 @@ interface DashboardLayoutProps {
} }
export default function DashboardLayout({ children }: DashboardLayoutProps) { export default function DashboardLayout({ children }: DashboardLayoutProps) {
const t = useTranslations("dashboard");
const [isCheckingAuth, setIsCheckingAuth] = useState(true); const [isCheckingAuth, setIsCheckingAuth] = useState(true);
// Use the global loading screen - spinner animation won't reset
useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default");
useEffect(() => { useEffect(() => {
// Check if user is authenticated // Check if user is authenticated
const token = getBearerToken(); const token = getBearerToken();
@ -23,21 +27,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
setIsCheckingAuth(false); setIsCheckingAuth(false);
}, []); }, []);
// Show loading screen while checking authentication // Return null while loading - the global provider handles the loading UI
if (isCheckingAuth) { if (isCheckingAuth) {
return ( return null;
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
<Card className="w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">Loading Dashboard</CardTitle>
<CardDescription>Checking authentication...</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Loader2 className="h-12 w-12 text-primary animate-spin" />
</CardContent>
</Card>
</div>
);
} }
return ( return (

View file

@ -0,0 +1,14 @@
"use client";
import { useTranslations } from "next-intl";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
export default function DashboardLoading() {
const t = useTranslations("common");
// Use global loading - spinner animation won't reset when page transitions
useGlobalLoadingEffect(true, t("loading"), "default");
// Return null - the GlobalLoadingProvider handles the loading UI
return null;
}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { AlertCircle, Loader2, Plus, Search } from "lucide-react"; import { AlertCircle, Plus, Search } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -18,37 +18,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
function LoadingScreen() {
const t = useTranslations("dashboard");
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5 }}
>
<Card className="w-full max-w-[350px] bg-background/60 backdrop-blur-sm">
<CardHeader className="pb-2">
<CardTitle className="text-xl font-medium">{t("loading")}</CardTitle>
<CardDescription>{t("fetching_spaces")}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
>
<Loader2 className="h-12 w-12 text-primary" />
</motion.div>
</CardContent>
<CardFooter className="border-t pt-4 text-sm text-muted-foreground">
{t("may_take_moment")}
</CardFooter>
</Card>
</motion.div>
</div>
);
}
function ErrorScreen({ message }: { message: string }) { function ErrorScreen({ message }: { message: string }) {
const t = useTranslations("dashboard"); const t = useTranslations("dashboard");
@ -121,6 +91,7 @@ export default function DashboardPage() {
const router = useRouter(); const router = useRouter();
const [showCreateDialog, setShowCreateDialog] = useState(false); const [showCreateDialog, setShowCreateDialog] = useState(false);
const t = useTranslations("dashboard");
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom); const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
useEffect(() => { useEffect(() => {
@ -131,11 +102,16 @@ export default function DashboardPage() {
} }
}, [isLoading, searchSpaces, router]); }, [isLoading, searchSpaces, router]);
if (isLoading) return <LoadingScreen />; // Show loading while fetching or while we have spaces and are about to redirect
const shouldShowLoading = isLoading || searchSpaces.length > 0;
// Use global loading screen - spinner animation won't reset
useGlobalLoadingEffect(shouldShowLoading, t("fetching_spaces"), "default");
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />; if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
if (searchSpaces.length > 0) { if (shouldShowLoading) {
return <LoadingScreen />; return null;
} }
return ( return (

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Loader2, Menu, User } from "lucide-react"; import { Menu, User } from "lucide-react";
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -11,6 +11,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
interface ProfileContentProps { interface ProfileContentProps {
onMenuClick: () => void; onMenuClick: () => void;
@ -129,7 +130,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
> >
{isUserLoading ? ( {isUserLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Spinner size="md" className="text-muted-foreground" />
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
@ -166,7 +167,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
<div className="flex justify-end"> <div className="flex justify-end">
<Button type="submit" disabled={isPending || !hasChanges}> <Button type="submit" disabled={isPending || !hasChanges}>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isPending && <Spinner size="sm" className="mr-2" />}
{t("profile_save")} {t("profile_save")}
</Button> </Button>
</div> </div>

View file

@ -6,8 +6,6 @@ import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
Clock,
Loader2,
LogIn, LogIn,
Shield, Shield,
Sparkles, Sparkles,
@ -30,6 +28,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
import type { AcceptInviteResponse } from "@/contracts/types/invites.types"; import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
import { invitesApiService } from "@/lib/apis/invites-api.service"; import { invitesApiService } from "@/lib/apis/invites-api.service";
import { getBearerToken } from "@/lib/auth-utils"; import { getBearerToken } from "@/lib/auth-utils";
@ -164,7 +163,7 @@ export default function InviteAcceptPage() {
animate={{ rotate: 360 }} animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }} transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
> >
<Loader2 className="h-12 w-12 text-primary" /> <Spinner size="xl" className="text-primary" />
</motion.div> </motion.div>
<p className="mt-4 text-muted-foreground">Loading invite details...</p> <p className="mt-4 text-muted-foreground">Loading invite details...</p>
</CardContent> </CardContent>
@ -353,7 +352,7 @@ export default function InviteAcceptPage() {
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}> <Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
{accepting ? ( {accepting ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin" /> <Spinner size="sm" />
Accepting... Accepting...
</> </>
) : ( ) : (

View file

@ -3,6 +3,7 @@ import "./globals.css";
import { RootProvider } from "fumadocs-ui/provider/next"; import { RootProvider } from "fumadocs-ui/provider/next";
import { Roboto } from "next/font/google"; import { Roboto } from "next/font/google";
import { ElectricProvider } from "@/components/providers/ElectricProvider"; import { ElectricProvider } from "@/components/providers/ElectricProvider";
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
import { I18nProvider } from "@/components/providers/I18nProvider"; import { I18nProvider } from "@/components/providers/I18nProvider";
import { PostHogProvider } from "@/components/providers/PostHogProvider"; import { PostHogProvider } from "@/components/providers/PostHogProvider";
import { ThemeProvider } from "@/components/theme/theme-provider"; import { ThemeProvider } from "@/components/theme/theme-provider";
@ -104,7 +105,9 @@ export default function RootLayout({
> >
<RootProvider> <RootProvider>
<ReactQueryClientProvider> <ReactQueryClientProvider>
<ElectricProvider>{children}</ElectricProvider> <ElectricProvider>
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
</ElectricProvider>
</ReactQueryClientProvider> </ReactQueryClientProvider>
<Toaster /> <Toaster />
</RootProvider> </RootProvider>

View file

@ -175,5 +175,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
}, },
// How-to documentation
{
url: "https://www.surfsense.com/docs/how-to/electric-sql",
lastModified,
changeFrequency: "daily",
priority: 0.8,
},
]; ];
} }

View file

@ -0,0 +1,30 @@
import { atom } from "jotai";
interface GlobalLoadingState {
isLoading: boolean;
message?: string;
variant: "login" | "default";
}
export const globalLoadingAtom = atom<GlobalLoadingState>({
isLoading: false,
message: undefined,
variant: "default",
});
// Helper atom for showing global loading
export const showGlobalLoadingAtom = atom(
null,
(
get,
set,
{ message, variant = "default" }: { message?: string; variant?: "login" | "default" }
) => {
set(globalLoadingAtom, { isLoading: true, message, variant });
}
);
// Helper atom for hiding global loading
export const hideGlobalLoadingAtom = atom(null, (get, set) => {
set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" });
});

View file

@ -0,0 +1,88 @@
---
title: "SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments"
description: "SurfSense v0.0.12 transforms the platform with a redesigned chat-first interface, real-time collaboration features, comment threads with @mentions, and instant notifications powered by ElectricSQL + PGlite."
date: "2026-01-26"
tags: ["UX", "UI", "Real-time chat", "Collaboration", "Comments"]
version: "0.0.12"
---
<img src="/changelog/0.0.12/header.gif" alt="SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments" className="rounded-lg w-full" />
## What's New in v0.0.12
This release brings major improvements to **collaboration and user experience**. We've completely redesigned the main interface to be chat-first, introduced real-time notifications and live collaboration features, and added a powerful commenting system with @mentions. These changes make SurfSense faster, more intuitive, and better for team collaboration.
### Major UX/UI Overhaul
#### New Chat-First Interface
- **Dashboard Removed**: Users now land directly in a chat for faster access
- **Redesigned Search Spaces**: Moved to a left column with color-coded icons and hover tooltips
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
- **Streamlined Settings**: Accessible through intuitive dropdown menus
- **Mobile-Responsive Design**: Better experience on all devices
- **Single-Click Google Login**: Replaces the old two-step process
### Real-Time Collaboration Features
#### Live Shared Chats
- **Multi-User Collaboration**: Multiple users can now collaborate in the same chat in real-time
- **Status Indicators**: See when the AI is responding to another team member
- **Instant Sync**: Changes sync instantly across all open tabs and users
#### Chat Comments with @Mentions
- **Comment on AI Responses**: Discuss responses directly with your team
- **Single-Level Threading**: Reply to comments with organized threads
- **@Mentions**: Tag teammates to get their attention
- **Real-Time Notifications**: Receive instant alerts when someone mentions you
#### Real-Time Notifications
- **Instant Updates**: Replaced slow polling with instant notifications using ElectricSQL + PGlite
- **New Inbox**: See connector indexing, document processing, and system events immediately
- **Cross-Tab Sync**: Syncs across all your browser tabs in real-time
### Connector Enhancements
#### OAuth Migration for Better Security
- **New OAuth Connectors**: Migrated Linear, Slack, Notion, Discord, Confluence, and Jira to OAuth-based authentication
- **Circleback Integration**: Connect your AI meeting notes from Circleback
- **Future Date Indexing**: Index future dates for calendar-based connectors to plan ahead
- **5-Minute Periodic Syncing**: Near-real-time updates option
- **Real-Time UI Updates**: See connector indexing progress without page refreshes
<Accordion type="multiple" className="w-full not-prose">
<AccordionItem value="item-1">
<AccordionTrigger>Bug Fixes</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>Syncs with no new content now show "Already up to date!" instead of falsely reporting failures</li>
<li>Restored missing indexing options page for Google Drive connector</li>
<li>File mention picker now handles large document counts with server-side search and pagination</li>
<li>Reasoning steps no longer overlap with chat input field</li>
<li>File upload modal is now scrollable when adding many files</li>
<li>OAuth connectors now display properly on mobile devices</li>
</ul>
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Technical Improvements</AccordionTrigger>
<AccordionContent className="flex flex-col gap-4 text-balance">
<ul className="list-disc space-y-2 pl-4">
<li>Made Alembic migrations idempotent for safer deployments</li>
<li>Migrations now work on fresh databases from scratch</li>
<li>Major refactoring of chat components for better maintainability</li>
<li>Streamlined sidebar and connector page code</li>
<li>Fixed legacy route handling</li>
<li>Fixed hardcoded Docker values for complex deployments</li>
</ul>
</AccordionContent>
</AccordionItem>
</Accordion>
This release transforms SurfSense into a truly collaborative, real-time platform with a redesigned interface that puts chat front and center. The addition of comments, @mentions, and live collaboration features makes it easier than ever for teams to work together without leaving the app.
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.

View file

@ -1,7 +1,9 @@
"use client"; "use client";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect } from "react"; import { useEffect } from "react";
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils"; import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
import { trackLoginSuccess } from "@/lib/posthog/events"; import { trackLoginSuccess } from "@/lib/posthog/events";
@ -25,8 +27,12 @@ const TokenHandler = ({
tokenParamName = "token", tokenParamName = "token",
storageKey = "surfsense_bearer_token", storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => { }: TokenHandlerProps) => {
const t = useTranslations("auth");
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
useEffect(() => { useEffect(() => {
// Only run on client-side // Only run on client-side
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@ -66,11 +72,8 @@ const TokenHandler = ({
} }
}, [searchParams, tokenParamName, storageKey, redirectPath]); }, [searchParams, tokenParamName, storageKey, redirectPath]);
return ( // Return null - the global provider handles the loading UI
<div className="flex items-center justify-center min-h-[200px]"> return null;
<p className="text-gray-500">Processing authentication...</p>
</div>
);
}; };
export default TokenHandler; export default TokenHandler;

View file

@ -7,7 +7,7 @@ import {
useAssistantApi, useAssistantApi,
useAssistantState, useAssistantState,
} from "@assistant-ui/react"; } from "@assistant-ui/react";
import { FileText, Loader2, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react"; import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react"; import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@ -20,6 +20,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useDocumentUploadDialog } from "./document-upload-popup"; import { useDocumentUploadDialog } from "./document-upload-popup";
@ -135,7 +136,7 @@ const AttachmentThumb: FC = () => {
if (isProcessing) { if (isProcessing) {
return ( return (
<div className="flex h-full w-full items-center justify-center bg-muted"> <div className="flex h-full w-full items-center justify-center bg-muted">
<Loader2 className="size-6 animate-spin text-muted-foreground" /> <Spinner size="md" className="text-muted-foreground" />
</div> </div>
); );
} }
@ -213,7 +214,7 @@ const AttachmentUI: FC = () => {
> >
{isProcessing ? ( {isProcessing ? (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" /> <Spinner size="xs" />
Processing... Processing...
</span> </span>
) : ( ) : (

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { Loader2 } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface ChatSessionStatusProps { interface ChatSessionStatusProps {
@ -43,7 +43,7 @@ export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
className className
)} )}
> >
<Loader2 className="size-3.5 animate-spin" /> <Spinner size="xs" />
<span>Currently responding to {displayName}</span> <span>Currently responding to {displayName}</span>
</div> </div>
); );

View file

@ -1,37 +1,50 @@
"use client"; "use client";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { Cable, Loader2 } from "lucide-react"; import { Cable } from "lucide-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import type { FC } from "react"; import type { FC } from "react";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent } from "@/components/ui/tabs"; import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { useConnectorsElectric } from "@/hooks/use-connectors-electric"; import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
import { useDocumentsElectric } from "@/hooks/use-documents-electric"; import { useDocumentsElectric } from "@/hooks/use-documents-electric";
import { useInbox } from "@/hooks/use-inbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view"; import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view"; import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
import { OAUTH_CONNECTORS } from "./connector-popup/constants/connector-constants"; import {
COMPOSIO_CONNECTORS,
OAUTH_CONNECTORS,
} from "./connector-popup/constants/connector-constants";
import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog"; import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog";
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors"; import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab"; import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ComposioToolkitView } from "./connector-popup/views/composio-toolkit-view";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view"; import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => { export const ConnectorIndicator: FC = () => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: currentUser } = useAtomValue(currentUserAtom);
// Fetch document type counts using Electric SQL + PGlite for real-time updates // Fetch document type counts using Electric SQL + PGlite for real-time updates
const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId); const { documentTypeCounts, loading: documentTypesLoading } = useDocumentsElectric(searchSpaceId);
// Fetch notifications to detect indexing failures
const { inboxItems = [] } = useInbox(
currentUser?.id ?? null,
searchSpaceId ? Number(searchSpaceId) : null,
"connector_indexing"
);
// Check if YouTube view is active // Check if YouTube view is active
const isYouTubeView = searchParams.get("view") === "youtube"; const isYouTubeView = searchParams.get("view") === "youtube";
@ -88,12 +101,6 @@ export const ConnectorIndicator: FC = () => {
setConnectorConfig, setConnectorConfig,
setIndexingConnectorConfig, setIndexingConnectorConfig,
setConnectorName, setConnectorName,
// Composio
viewingComposio,
connectingComposioToolkit,
handleOpenComposio,
handleBackFromComposio,
handleConnectComposioToolkit,
} = useConnectorDialog(); } = useConnectorDialog();
// Fetch connectors using Electric SQL + PGlite for real-time updates // Fetch connectors using Electric SQL + PGlite for real-time updates
@ -123,8 +130,10 @@ export const ConnectorIndicator: FC = () => {
}; };
// Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed // Track indexing state locally - clears automatically when Electric SQL detects last_indexed_at changed
const { indexingConnectorIds, startIndexing } = useIndexingConnectors( // Also clears when failed notifications are detected
connectors as SearchSourceConnector[] const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
connectors as SearchSourceConnector[],
inboxItems
); );
const isLoading = connectorsLoading || documentTypesLoading; const isLoading = connectorsLoading || documentTypesLoading;
@ -142,7 +151,7 @@ export const ConnectorIndicator: FC = () => {
// Check which connectors are already connected // Check which connectors are already connected
// Using Electric SQL + PGlite for real-time connector updates // Using Electric SQL + PGlite for real-time connector updates
const connectedTypes = new Set( const connectedTypes = new Set<string>(
(connectors || []).map((c: SearchSourceConnector) => c.connector_type) (connectors || []).map((c: SearchSourceConnector) => c.connector_type)
); );
@ -166,7 +175,7 @@ export const ConnectorIndicator: FC = () => {
onClick={() => handleOpenChange(true)} onClick={() => handleOpenChange(true)}
> >
{isLoading ? ( {isLoading ? (
<Loader2 className="size-4 animate-spin" /> <Spinner size="sm" />
) : ( ) : (
<> <>
<Cable className="size-4 stroke-[1.5px]" /> <Cable className="size-4 stroke-[1.5px]" />
@ -179,22 +188,11 @@ export const ConnectorIndicator: FC = () => {
)} )}
</TooltipIconButton> </TooltipIconButton>
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5"> <DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
{/* YouTube Crawler View - shown when adding YouTube videos */} {/* YouTube Crawler View - shown when adding YouTube videos */}
{isYouTubeView && searchSpaceId ? ( {isYouTubeView && searchSpaceId ? (
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} /> <YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
) : viewingComposio && searchSpaceId ? (
<ComposioToolkitView
searchSpaceId={searchSpaceId}
connectedToolkits={(connectors || [])
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
.filter(Boolean)}
onBack={handleBackFromComposio}
onConnectToolkit={handleConnectComposioToolkit}
isConnecting={connectingComposioToolkit !== null}
connectingToolkitId={connectingComposioToolkit}
/>
) : viewingMCPList ? ( ) : viewingMCPList ? (
<ConnectorAccountsListView <ConnectorAccountsListView
connectorType="MCP_CONNECTOR" connectorType="MCP_CONNECTOR"
@ -215,7 +213,12 @@ export const ConnectorIndicator: FC = () => {
onBack={handleBackFromAccountsList} onBack={handleBackFromAccountsList}
onManage={handleStartEdit} onManage={handleStartEdit}
onAddAccount={() => { onAddAccount={() => {
const oauthConnector = OAUTH_CONNECTORS.find( // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType
) ||
COMPOSIO_CONNECTORS.find(
(c) => c.connectorType === viewingAccountsType.connectorType (c) => c.connectorType === viewingAccountsType.connectorType
); );
if (oauthConnector) { if (oauthConnector) {
@ -260,7 +263,13 @@ export const ConnectorIndicator: FC = () => {
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
? () => { ? () => {
startIndexing(editingConnector.id); startIndexing(editingConnector.id);
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type); handleQuickIndexConnector(
editingConnector.id,
editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
} }
: undefined : undefined
} }
@ -331,7 +340,6 @@ export const ConnectorIndicator: FC = () => {
onCreateYouTubeCrawler={handleCreateYouTubeCrawler} onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
onManage={handleStartEdit} onManage={handleStartEdit}
onViewAccountsList={handleViewAccountsList} onViewAccountsList={handleViewAccountsList}
onOpenComposio={handleOpenComposio}
/> />
</TabsContent> </TabsContent>

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

@ -1,9 +1,10 @@
"use client"; "use client";
import { IconBrandYoutube } from "@tabler/icons-react"; import { IconBrandYoutube } from "@tabler/icons-react";
import { FileText, Loader2 } from "lucide-react"; import { FileText } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -111,7 +112,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
</div> </div>
{isIndexing ? ( {isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5"> <p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" /> <Spinner size="xs" />
Syncing Syncing
</p> </p>
) : isConnected ? ( ) : isConnected ? (
@ -151,7 +152,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
disabled={isConnecting || !isEnabled} disabled={isConnecting || !isEnabled}
> >
{isConnecting ? ( {isConnecting ? (
<Loader2 className="size-3 animate-spin" /> <Spinner size="xs" />
) : !isEnabled ? ( ) : !isEnabled ? (
"Unavailable" "Unavailable"
) : isConnected ? ( ) : isConnected ? (

View file

@ -24,6 +24,11 @@
"enabled": true, "enabled": true,
"status": "warning", "status": "warning",
"statusMessage": "Some requests may be blocked if not using Firecrawl." "statusMessage": "Some requests may be blocked if not using Firecrawl."
},
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": {
"enabled": false,
"status": "disabled",
"statusMessage": "Not available yet."
} }
}, },
"globalSettings": { "globalSettings": {

View file

@ -6,12 +6,6 @@ import type { FC } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
Form, Form,
@ -85,6 +79,7 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
BOOKSTACK_TOKEN_SECRET: values.token_secret, BOOKSTACK_TOKEN_SECRET: values.token_secret,
}, },
is_indexable: true, is_indexable: true,
is_active: true,
last_indexed_at: null, last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled, periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null, indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
@ -301,124 +296,6 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
</ul> </ul>
</div> </div>
)} )}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The BookStack connector uses the BookStack REST API to fetch all pages from your
BookStack instance that your account has access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves pages that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need to create an API token from your BookStack instance. The token requires
"Access System API" permission.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log in to your BookStack instance</li>
<li>Click on your profile icon Edit Profile</li>
<li>Navigate to the "API Tokens" tab</li>
<li>Click "Create Token" and give it a name</li>
<li>Copy both the Token ID and Token Secret</li>
<li>Paste them in the form above</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
Your user account must have "Access System API" permission. The connector will
only index content your account can view.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Rate Limiting</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
BookStack API has a rate limit of 180 requests per minute. The connector
automatically handles rate limiting to ensure reliable indexing.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>BookStack</strong>{" "}
Connector.
</li>
<li>
Enter your <strong>BookStack Instance URL</strong> (e.g.,
https://docs.example.com)
</li>
<li>
Enter your <strong>Token ID</strong> and <strong>Token Secret</strong> from your
BookStack API token.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your BookStack pages will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The BookStack connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All pages from your BookStack instance</li>
<li>Page content in Markdown format</li>
<li>Page titles and metadata</li>
<li>Book and chapter hierarchy information</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
); );
}; };

View file

@ -6,12 +6,6 @@ import type { FC } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
Form, Form,
@ -253,131 +247,6 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
</ul> </ul>
</div> </div>
)} )}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Luma connector uses the Luma API to fetch all events that your API key has
access to.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
For follow up indexing runs, the connector retrieves events that have been updated
since the last indexing attempt.
</li>
<li>
Indexing is configured to run periodically, so updates should appear in your
search results within minutes.
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">API Key Required</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
You need a Luma API key to use this connector. The key will be used to read your
Luma events with read-only permissions.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Get Your API Key
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>Log into your Luma account</li>
<li>Navigate to your account settings</li>
<li>Go to API settings or Developer settings</li>
<li>Generate a new API key</li>
<li>Copy the generated API key</li>
<li>
You can also visit{" "}
<a
href="https://lu.ma/api"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Luma API Settings
</a>{" "}
for more information.
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
The API key will have access to all events that your user account can see.
Make sure your account has appropriate permissions for the events you want to
index.
</p>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
Only event details, descriptions, and attendee information will be indexed.
Event attachments and linked files are not indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
<li>
Navigate to the Connector Dashboard and select the <strong>Luma</strong>{" "}
Connector.
</li>
<li>
Place your <strong>API Key</strong> in the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the connection.
</li>
<li>Once connected, your Luma events will be indexed automatically.</li>
</ol>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Luma connector indexes the following data:</p>
<ul className="list-disc pl-5 space-y-1">
<li>Event titles and descriptions</li>
<li>Event details and metadata</li>
<li>Attendee information</li>
<li>Event dates and locations</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
); );
}; };

View file

@ -1,17 +1,11 @@
"use client"; "use client";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { FolderOpen, Info } from "lucide-react"; import { Info } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import * as z from "zod"; import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { import {
Form, Form,
@ -109,7 +103,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
return ( return (
<div className="space-y-6 pb-6"> <div className="space-y-6 pb-6">
<Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0"> <Alert className="bg-purple-500/10 dark:bg-purple-500/10 border-purple-500/30 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
<FolderOpen className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" /> <Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1 text-purple-500" />
<div className="-ml-1"> <div className="-ml-1">
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle> <AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
<AlertDescription className="text-[10px] sm:text-xs pl-0!"> <AlertDescription className="text-[10px] sm:text-xs pl-0!">
@ -320,145 +314,6 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
</ul> </ul>
</div> </div>
)} )}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The Obsidian connector scans your local Obsidian vault directory and indexes all
Markdown files. It preserves your note structure and extracts metadata from YAML
frontmatter.
</p>
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
<li>
The connector parses frontmatter metadata (title, tags, aliases, dates, etc.)
</li>
<li>Wiki-style links ([[note]]) are extracted and preserved</li>
<li>Inline tags (#tag) are recognized and indexed</li>
<li>Content is chunked intelligently for optimal search results</li>
<li>
Subsequent indexing runs use content hashing to skip unchanged files for faster
sync
</li>
</ul>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">Setup</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">
File System Access Required
</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
The SurfSense backend must have read access to your Obsidian vault directory.
For Docker deployments, mount your vault as a volume.
</AlertDescription>
</Alert>
<div className="space-y-4 sm:space-y-6">
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 1: Locate your vault
</h4>
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
<li>
<strong>macOS/Linux:</strong> Right-click any note in Obsidian "Reveal in
Finder" to see the vault folder
</li>
<li>
<strong>Windows:</strong> Right-click any note "Show in system explorer"
</li>
<li>
<strong>Or:</strong> Click the vault switcher (bottom-left icon) "Open
folder" next to your vault name
</li>
</ol>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 2: Enter the path
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running locally (no Docker):</strong> Use the direct path to your
vault:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto mb-2">
{`/Users/yourname/Documents/MyObsidianVault`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mb-2">
<strong>Running in Docker:</strong> Mount your vault as a volume in
docker-compose.yml:
</p>
<pre className="bg-slate-800 text-slate-200 p-2 rounded text-[9px] sm:text-[10px] overflow-x-auto">
{`volumes:
- /path/to/your/vault:/app/obsidian_vaults/my-vault:ro`}
</pre>
<p className="text-[10px] sm:text-xs text-muted-foreground mt-2">
Then use <code>/app/obsidian_vaults/my-vault</code> as your vault path.
</p>
</div>
<div>
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
Step 3: Configure exclusions
</h4>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Common folders to exclude:
</p>
<ul className="list-disc pl-5 mt-1 space-y-1 text-[10px] sm:text-xs text-muted-foreground">
<li>
<code>.obsidian</code> - Obsidian config (always recommended)
</li>
<li>
<code>.trash</code> - Obsidian's trash folder
</li>
<li>
<code>templates</code> - If you have a templates folder
</li>
<li>
<code>daily-notes</code> - If you want to exclude daily notes
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">What Gets Indexed</h3>
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
<AlertTitle className="text-[10px] sm:text-xs">Indexed Content</AlertTitle>
<AlertDescription className="text-[9px] sm:text-[10px]">
<p className="mb-2">The Obsidian connector indexes:</p>
<ul className="list-disc pl-5 space-y-1">
<li>All Markdown files (.md) in your vault</li>
<li>YAML frontmatter metadata (title, tags, aliases, dates)</li>
<li>Wiki-style links between notes</li>
<li>Inline tags throughout your notes</li>
<li>Full note content with proper chunking</li>
</ul>
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
); );
}; };

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

@ -1,8 +1,9 @@
"use client"; "use client";
import { ArrowLeft, Loader2 } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { type FC, useMemo } from "react"; import { type FC, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { EnumConnectorName } from "@/contracts/enums/connector"; import type { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils"; import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
@ -139,7 +140,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Spinner size="sm" className="mr-2" />
Connecting Connecting
</> </>
) : connectorType === "MCP_CONNECTOR" ? ( ) : connectorType === "MCP_CONNECTOR" ? (

View file

@ -1,13 +1,15 @@
"use client"; "use client";
import { ArrowLeft, Info, Loader2, RefreshCw, Trash2 } from "lucide-react"; import { ArrowLeft, Info, RefreshCw, Trash2 } from "lucide-react";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector"; import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index"; import { getConnectorConfigComponent } from "../index";
interface ConnectorEditViewProps { interface ConnectorEditViewProps {
@ -97,12 +99,16 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
}; };
}, [checkScrollState]); }, [checkScrollState]);
// Reset local quick indexing state when indexing completes // Reset local quick indexing state when indexing completes or fails
useEffect(() => { useEffect(() => {
if (!isIndexing) { if (!isIndexing && isQuickIndexing) {
// Small delay to ensure smooth transition
const timer = setTimeout(() => {
setIsQuickIndexing(false); setIsQuickIndexing(false);
}, 100);
return () => clearTimeout(timer);
} }
}, [isIndexing]); }, [isIndexing, isQuickIndexing]);
const handleDisconnectClick = () => { const handleDisconnectClick = () => {
setShowDisconnectConfirm(true); setShowDisconnectConfirm(true);
@ -118,11 +124,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
}; };
const handleQuickIndex = useCallback(() => { const handleQuickIndex = useCallback(() => {
if (onQuickIndex) { if (onQuickIndex && !isQuickIndexing && !isIndexing) {
setIsQuickIndexing(true); setIsQuickIndexing(true);
onQuickIndex(); onQuickIndex();
} }
}, [onQuickIndex]); }, [onQuickIndex, isQuickIndexing, isIndexing]);
return ( return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
@ -151,7 +157,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word"> <h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
{connector.name} {getConnectorDisplayName(connector.name)}
</h2> </h2>
<p className="text-xs sm:text-base text-muted-foreground mt-1"> <p className="text-xs sm:text-base text-muted-foreground mt-1">
Manage your connector settings and sync configuration Manage your connector settings and sync configuration
@ -206,8 +212,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Date range selector and periodic sync - only shown for indexable connectors */} {/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector.is_indexable && ( {connector.is_indexable && (
<> <>
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */} {/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && {connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
connector.connector_type !== "WEBCRAWLER_CONNECTOR" && connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
connector.connector_type !== "GITHUB_CONNECTOR" && ( connector.connector_type !== "GITHUB_CONNECTOR" && (
<DateRangeSelector <DateRangeSelector
@ -217,6 +224,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
onEndDateChange={onEndDateChange} onEndDateChange={onEndDateChange}
allowFutureDates={ allowFutureDates={
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" || connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
connector.connector_type === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
connector.connector_type === "LUMA_CONNECTOR" connector.connector_type === "LUMA_CONNECTOR"
} }
/> />
@ -224,8 +232,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Periodic sync - shown for all indexable connectors */} {/* Periodic sync - shown for all indexable connectors */}
{(() => { {(() => {
// Check if Google Drive has folders/files selected // Check if Google Drive (regular or Composio) has folders/files selected
const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR"; const isGoogleDrive = connector.connector_type === "GOOGLE_DRIVE_CONNECTOR";
const isComposioGoogleDrive =
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
const selectedFolders = const selectedFolders =
(connector.config?.selected_folders as (connector.config?.selected_folders as
| Array<{ id: string; name: string }> | Array<{ id: string; name: string }>
@ -235,7 +246,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
| Array<{ id: string; name: string }> | Array<{ id: string; name: string }>
| undefined) || []; | undefined) || [];
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0; const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
const isDisabled = isGoogleDrive && !hasItemsSelected; const isDisabled = requiresFolderSelection && !hasItemsSelected;
return ( return (
<PeriodicSyncConfig <PeriodicSyncConfig
@ -266,8 +277,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
Re-indexing runs in the background Re-indexing runs in the background
</p> </p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm"> <p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
You can continue using SurfSense while we sync your data. Check the Active tab You can continue using SurfSense while we sync your data. Check inbox for
to see progress. updates.
</p> </p>
</div> </div>
</div> </div>
@ -301,7 +312,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
> >
{isDisconnecting ? ( {isDisconnecting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Spinner size="sm" className="mr-2" />
Disconnecting Disconnecting
</> </>
) : ( ) : (
@ -337,8 +348,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
> >
{isSaving ? ( {isSaving ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Spinner size="sm" className="mr-2" />
Saving... Saving
</> </>
) : ( ) : (
"Save Changes" "Save Changes"

View file

@ -1,15 +1,16 @@
"use client"; "use client";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info } from "lucide-react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { getConnectorTypeDisplay } from "@/lib/connectors/utils"; import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector"; import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config"; import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
import { type IndexingConfigState, OAUTH_CONNECTORS } from "../../constants/connector-constants"; import type { IndexingConfigState } from "../../constants/connector-constants";
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab"; import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
import { getConnectorConfigComponent } from "../index"; import { getConnectorConfigComponent } from "../index";
@ -91,8 +92,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
}; };
}, [checkScrollState]); }, [checkScrollState]);
const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type);
return ( return (
<div className="flex-1 flex flex-col min-h-0 overflow-hidden"> <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
{/* Fixed Header */} {/* Fixed Header */}
@ -151,8 +150,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Date range selector and periodic sync - only shown for indexable connectors */} {/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector?.is_indexable && ( {connector?.is_indexable && (
<> <>
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */} {/* Date range selector - not shown for Google Drive (regular and Composio), Webcrawler, or GitHub (indexes full repo snapshots) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && {config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "WEBCRAWLER_CONNECTOR" && config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
config.connectorType !== "GITHUB_CONNECTOR" && ( config.connectorType !== "GITHUB_CONNECTOR" && (
<DateRangeSelector <DateRangeSelector
@ -162,13 +162,15 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
onEndDateChange={onEndDateChange} onEndDateChange={onEndDateChange}
allowFutureDates={ allowFutureDates={
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" || config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
config.connectorType === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
config.connectorType === "LUMA_CONNECTOR" config.connectorType === "LUMA_CONNECTOR"
} }
/> />
)} )}
{/* Periodic sync - not shown for Google Drive */} {/* Periodic sync - not shown for Google Drive (regular and Composio) */}
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && ( {config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
<PeriodicSyncConfig <PeriodicSyncConfig
enabled={periodicEnabled} enabled={periodicEnabled}
frequencyMinutes={frequencyMinutes} frequencyMinutes={frequencyMinutes}
@ -188,8 +190,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
<div className="text-xs sm:text-sm"> <div className="text-xs sm:text-sm">
<p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p> <p className="font-medium text-xs sm:text-sm">Indexing runs in the background</p>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm"> <p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
You can continue using SurfSense while we sync your data. Check the Active tab You can continue using SurfSense while we sync your data. Check inbox for
to see progress. updates.
</p> </p>
</div> </div>
</div> </div>
@ -215,7 +217,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
> >
{isStartingIndexing ? ( {isStartingIndexing ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Spinner size="sm" className="mr-2" />
Starting... Starting...
</> </>
) : ( ) : (

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,27 +159,22 @@ export const useConnectorDialog = () => {
setViewingMCPList(true); setViewingMCPList(true);
} }
// Clear Composio view if view is not "composio" anymore
if (params.view !== "composio" && viewingComposio) {
setViewingComposio(false);
setConnectingComposioToolkit(null);
}
// Handle Composio view
if (params.view === "composio" && !viewingComposio) {
setViewingComposio(true);
}
// Handle connect view // Handle connect view
if (params.view === "connect" && params.connectorType && !connectingConnectorType) { if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
setConnectingConnectorType(params.connectorType); setConnectingConnectorType(params.connectorType);
} }
// Handle accounts view // Handle accounts view
if (params.view === "accounts" && params.connectorType && !viewingAccountsType) { if (params.view === "accounts" && params.connectorType) {
const oauthConnector = OAUTH_CONNECTORS.find( // Update state if not set, or if connectorType has changed
(c) => c.connectorType === params.connectorType const needsUpdate =
); !viewingAccountsType || viewingAccountsType.connectorType !== params.connectorType;
if (needsUpdate) {
// Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === params.connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === params.connectorType);
if (oauthConnector) { if (oauthConnector) {
setViewingAccountsType({ setViewingAccountsType({
connectorType: oauthConnector.connectorType, connectorType: oauthConnector.connectorType,
@ -187,6 +182,7 @@ export const useConnectorDialog = () => {
}); });
} }
} }
}
// Handle YouTube view // Handle YouTube view
if (params.view === "youtube") { if (params.view === "youtube") {
@ -195,7 +191,10 @@ export const useConnectorDialog = () => {
// Handle configure view (for page refresh support) // Handle configure view (for page refresh support)
if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) { if (params.view === "configure" && params.connector && !indexingConfig && allConnectors) {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector); // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) { if (oauthConnector) {
let existingConnector: SearchSourceConnector | undefined; let existingConnector: SearchSourceConnector | undefined;
if (params.connectorId) { if (params.connectorId) {
@ -293,6 +292,7 @@ export const useConnectorDialog = () => {
indexingConfig, indexingConfig,
connectingConnectorType, connectingConnectorType,
viewingAccountsType, viewingAccountsType,
viewingMCPList,
]); ]);
// Detect OAuth success / Failure and transition to config view // Detect OAuth success / Failure and transition to config view
@ -328,31 +328,46 @@ export const useConnectorDialog = () => {
return; return;
} }
if ( if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
params.success === "true" &&
params.connector &&
searchSpaceId &&
params.modal === "connectors"
) {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
refetchAllConnectors().then((result) => { refetchAllConnectors().then((result) => {
if (!result.data) return; if (!result.data) return;
let newConnector: SearchSourceConnector | undefined; let newConnector: SearchSourceConnector | undefined;
let oauthConnector:
| (typeof OAUTH_CONNECTORS)[number]
| (typeof COMPOSIO_CONNECTORS)[number]
| undefined;
// First, try to find connector by connectorId if provided
if (params.connectorId) { if (params.connectorId) {
const connectorId = parseInt(params.connectorId, 10); const connectorId = parseInt(params.connectorId, 10);
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId); newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
} else {
newConnector = result.data.find( // If we found the connector, find the matching OAuth/Composio connector by type
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType if (newConnector) {
); oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type);
}
} }
if (newConnector) { // If we don't have a connector yet, try to find by connector param
if (!newConnector && params.connector) {
oauthConnector =
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
if (oauthConnector) {
newConnector = result.data.find(
(c: SearchSourceConnector) => c.connector_type === oauthConnector!.connectorType
);
}
}
if (newConnector && oauthConnector) {
const connectorValidation = searchSourceConnector.safeParse(newConnector); const connectorValidation = searchSourceConnector.safeParse(newConnector);
if (connectorValidation.success) { if (connectorValidation.success) {
// Track connector connected event for OAuth connectors // Track connector connected event for OAuth/Composio connectors
trackConnectorConnected( trackConnectorConnected(
Number(searchSpaceId), Number(searchSpaceId),
oauthConnector.connectorType, oauthConnector.connectorType,
@ -380,7 +395,6 @@ export const useConnectorDialog = () => {
} }
}); });
} }
}
} catch (error) { } catch (error) {
// Invalid query params - log but don't crash // Invalid query params - log but don't crash
console.warn("Invalid connector popup query params in OAuth success handler:", error); console.warn("Invalid connector popup query params in OAuth success handler:", error);
@ -389,17 +403,18 @@ export const useConnectorDialog = () => {
// Handle OAuth connection // Handle OAuth connection
const handleConnectOAuth = useCallback( const handleConnectOAuth = useCallback(
async (connector: (typeof OAUTH_CONNECTORS)[number]) => { async (connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]) => {
if (!searchSpaceId || !connector.authEndpoint) return; if (!searchSpaceId || !connector.authEndpoint) return;
// Set connecting state immediately to disable button and show spinner // Set connecting state immediately to disable button and show spinner
setConnectingId(connector.id); setConnectingId(connector.id);
try { try {
const response = await authenticatedFetch( // Check if authEndpoint already has query parameters
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`, const separator = connector.authEndpoint.includes("?") ? "&" : "?";
{ method: "GET" } const url = `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}${separator}space_id=${searchSpaceId}`;
);
const response = await authenticatedFetch(url, { method: "GET" });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to initiate ${connector.title} OAuth`); throw new Error(`Failed to initiate ${connector.title} OAuth`);
@ -799,23 +814,19 @@ export const useConnectorDialog = () => {
// Handle viewing accounts list for OAuth connector type // Handle viewing accounts list for OAuth connector type
const handleViewAccountsList = useCallback( const handleViewAccountsList = useCallback(
(connectorType: string, connectorTitle: string) => { (connectorType: string, _connectorTitle?: string) => {
if (!searchSpaceId) return; if (!searchSpaceId) return;
setViewingAccountsType({
connectorType,
connectorTitle,
});
// Update URL to show accounts view, preserving current tab // Update URL to show accounts view, preserving current tab
// The useEffect will handle setting viewingAccountsType based on URL params
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors"); url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "accounts"); url.searchParams.set("view", "accounts");
url.searchParams.set("connectorType", connectorType); url.searchParams.set("connectorType", connectorType);
// Keep the current tab in URL so we can go back to it // Keep the current tab in URL so we can go back to it
window.history.pushState({ modal: true }, "", url.toString()); router.replace(url.pathname + url.search, { scroll: false });
}, },
[searchSpaceId] [searchSpaceId, router]
); );
// Handle going back from accounts list view // Handle going back from accounts list view
@ -839,8 +850,8 @@ export const useConnectorDialog = () => {
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors"); url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "mcp-list"); url.searchParams.set("view", "mcp-list");
window.history.pushState({ modal: true }, "", url.toString()); router.replace(url.pathname + url.search, { scroll: false });
}, [searchSpaceId]); }, [searchSpaceId, router]);
// Handle going back from MCP list view // Handle going back from MCP list view
const handleBackFromMCPList = useCallback(() => { const handleBackFromMCPList = useCallback(() => {
@ -861,71 +872,15 @@ export const useConnectorDialog = () => {
router.replace(url.pathname + url.search, { scroll: false }); router.replace(url.pathname + url.search, { scroll: false });
}, [router]); }, [router]);
// Handle opening Composio toolkit view
const handleOpenComposio = useCallback(() => {
if (!searchSpaceId) return;
setViewingComposio(true);
// Update URL to show Composio view
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.set("view", "composio");
window.history.pushState({ modal: true }, "", url.toString());
}, [searchSpaceId]);
// Handle going back from Composio view
const handleBackFromComposio = useCallback(() => {
setViewingComposio(false);
setConnectingComposioToolkit(null);
const url = new URL(window.location.href);
url.searchParams.set("modal", "connectors");
url.searchParams.delete("view");
router.replace(url.pathname + url.search, { scroll: false });
}, [router]);
// Handle connecting a Composio toolkit
const handleConnectComposioToolkit = useCallback(
async (toolkitId: string) => {
if (!searchSpaceId) return;
setConnectingComposioToolkit(toolkitId);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/composio/connector/add?space_id=${searchSpaceId}&toolkit_id=${toolkitId}`,
{ method: "GET" }
);
if (!response.ok) {
throw new Error(`Failed to initiate Composio OAuth for ${toolkitId}`);
}
const data = await response.json();
if (data.auth_url) {
// Redirect to Composio OAuth
window.location.href = data.auth_url;
} else {
throw new Error("No authorization URL received from Composio");
}
} catch (error) {
console.error("Error connecting Composio toolkit:", error);
toast.error(`Failed to connect ${toolkitId}. Please try again.`);
setConnectingComposioToolkit(null);
}
},
[searchSpaceId]
);
// Handle starting indexing // Handle starting indexing
const handleStartIndexing = useCallback( const handleStartIndexing = useCallback(
async (refreshConnectors: () => void) => { async (refreshConnectors: () => void) => {
if (!indexingConfig || !searchSpaceId) return; if (!indexingConfig || !searchSpaceId) return;
// Validate date range (skip for Google Drive and Webcrawler) // Validate date range (skip for Google Drive, Composio Drive, and Webcrawler)
if ( if (
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR" indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
) { ) {
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate }); const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
@ -970,8 +925,12 @@ export const useConnectorDialog = () => {
}); });
} }
// Handle Google Drive folder selection // Handle Google Drive folder selection (regular and Composio)
if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) { if (
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR") &&
indexingConnectorConfig
) {
const selectedFolders = indexingConnectorConfig.selected_folders as const selectedFolders = indexingConnectorConfig.selected_folders as
| Array<{ id: string; name: string }> | Array<{ id: string; name: string }>
| undefined; | undefined;
@ -1191,8 +1150,12 @@ export const useConnectorDialog = () => {
return; return;
} }
// Prevent periodic indexing for Google Drive without folders/files selected // Prevent periodic indexing for Google Drive (regular or Composio) without folders/files selected
if (periodicEnabled && editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") { if (
periodicEnabled &&
(editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR")
) {
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }> | Array<{ id: string; name: string }>
| undefined; | undefined;
@ -1241,8 +1204,11 @@ export const useConnectorDialog = () => {
if (!editingConnector.is_indexable) { if (!editingConnector.is_indexable) {
// Non-indexable connectors (like Tavily API) don't need re-indexing // Non-indexable connectors (like Tavily API) don't need re-indexing
indexingDescription = "Settings saved."; indexingDescription = "Settings saved.";
} else if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") { } else if (
// Google Drive uses folder selection from config, not date ranges editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
) {
// Google Drive (both regular and Composio) uses folder selection from config, not date ranges
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
| Array<{ id: string; name: string }> | Array<{ id: string; name: string }>
| undefined; | undefined;
@ -1423,13 +1389,24 @@ export const useConnectorDialog = () => {
setIsDisconnecting(false); setIsDisconnecting(false);
} }
}, },
[editingConnector, searchSpaceId, deleteConnector, router] [editingConnector, searchSpaceId, deleteConnector, router, cameFromMCPList]
); );
// Handle quick index (index without date picker, uses backend defaults) // Handle quick index (index with selected date range, or backend defaults if none selected)
const handleQuickIndexConnector = useCallback( const handleQuickIndexConnector = useCallback(
async (connectorId: number, connectorType?: string) => { async (
if (!searchSpaceId) return; connectorId: number,
connectorType?: string,
stopIndexing?: (id: number) => void,
startDate?: Date,
endDate?: Date
) => {
if (!searchSpaceId) {
if (stopIndexing) {
stopIndexing(connectorId);
}
return;
}
// Track quick index clicked event // Track quick index clicked event
if (connectorType) { if (connectorType) {
@ -1437,10 +1414,16 @@ export const useConnectorDialog = () => {
} }
try { try {
// Format dates if provided, otherwise pass undefined (backend will use defaults)
const startDateStr = startDate ? format(startDate, "yyyy-MM-dd") : undefined;
const endDateStr = endDate ? format(endDate, "yyyy-MM-dd") : undefined;
await indexConnector({ await indexConnector({
connector_id: connectorId, connector_id: connectorId,
queryParams: { queryParams: {
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
start_date: startDateStr,
end_date: endDateStr,
}, },
}); });
toast.success("Indexing started", { toast.success("Indexing started", {
@ -1451,12 +1434,18 @@ export const useConnectorDialog = () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: cacheKeys.logs.summary(Number(searchSpaceId)), queryKey: cacheKeys.logs.summary(Number(searchSpaceId)),
}); });
// Note: Don't call stopIndexing here - let useIndexingConnectors hook
// detect when last_indexed_at changes via Electric SQL
} catch (error) { } catch (error) {
console.error("Error indexing connector content:", error); console.error("Error indexing connector content:", error);
toast.error(error instanceof Error ? error.message : "Failed to start indexing"); toast.error(error instanceof Error ? error.message : "Failed to start indexing");
// Stop indexing state on error
if (stopIndexing) {
stopIndexing(connectorId);
}
} }
}, },
[searchSpaceId, indexConnector] [searchSpaceId, indexConnector, queryClient]
); );
// Handle going back from edit view // Handle going back from edit view
@ -1578,7 +1567,6 @@ export const useConnectorDialog = () => {
allConnectors, allConnectors,
viewingAccountsType, viewingAccountsType,
viewingMCPList, viewingMCPList,
viewingComposio,
// Setters // Setters
setSearchQuery, setSearchQuery,
@ -1614,12 +1602,5 @@ export const useConnectorDialog = () => {
connectorConfig, connectorConfig,
setConnectorConfig, setConnectorConfig,
setIndexingConnectorConfig, setIndexingConnectorConfig,
// Composio
viewingComposio,
connectingComposioToolkit,
handleOpenComposio,
handleBackFromComposio,
handleConnectComposioToolkit,
}; };
}; };

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(() => {
if (!inboxItems || inboxItems.length === 0) return;
setIndexingConnectorIds((prev) => {
const newIndexingIds = new Set(prev);
let hasChanges = false;
for (const item of inboxItems) {
// Only check connector_indexing notifications
if (item.type !== "connector_indexing") continue;
const metadata = isConnectorIndexingMetadata(item.metadata) ? item.metadata : null;
if (!metadata) continue;
// If status is "in_progress", add connector to indexing set
if (metadata.status === "in_progress") {
if (!newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.add(metadata.connector_id);
hasChanges = true;
} }
}, [connectors, indexingConnectorIds]); }
// If status is "completed" or "failed", remove connector from indexing set
else if (
metadata.status === "completed" ||
metadata.status === "failed" ||
(metadata.error_message && metadata.error_message.trim().length > 0)
) {
if (newIndexingIds.has(metadata.connector_id)) {
newIndexingIds.delete(metadata.connector_id);
hasChanges = true;
}
}
}
return hasChanges ? newIndexingIds : prev;
});
}, [inboxItems]);
// Add a connector to the indexing set (called when indexing starts) // Add a connector to the indexing set (called when indexing starts)
const startIndexing = useCallback((connectorId: number) => { const startIndexing = useCallback((connectorId: number) => {

View file

@ -1,11 +1,12 @@
"use client"; "use client";
import { ArrowRight, Cable, Loader2 } from "lucide-react"; import { ArrowRight, Cable } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { FC } from "react"; import type { FC } from "react";
import { useState } from "react"; import { useState } from "react";
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -13,8 +14,9 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types"; import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
import { connectorsApiService } from "@/lib/apis/connectors-api.service"; import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { OAUTH_CONNECTORS } from "../constants/connector-constants"; import { COMPOSIO_CONNECTORS, OAUTH_CONNECTORS } from "../constants/connector-constants";
import { getDocumentCountForConnector } from "../utils/connector-document-mapping"; import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
import { getConnectorDisplayName } from "./all-connectors-tab";
interface ActiveConnectorsTabProps { interface ActiveConnectorsTabProps {
searchQuery: string; searchQuery: string;
@ -113,7 +115,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
// Get display info for OAuth connector type // Get display info for OAuth connector type
const getOAuthConnectorTypeInfo = (connectorType: string) => { const getOAuthConnectorTypeInfo = (connectorType: string) => {
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType); // Check both OAUTH_CONNECTORS and COMPOSIO_CONNECTORS
const oauthConnector =
OAUTH_CONNECTORS.find((c) => c.connectorType === connectorType) ||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === connectorType);
return { return {
title: title:
oauthConnector?.title || oauthConnector?.title ||
@ -205,7 +210,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p> <p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
{isAnyIndexing ? ( {isAnyIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5"> <p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" /> <Spinner size="xs" />
Syncing Syncing
</p> </p>
) : ( ) : (
@ -260,13 +265,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-[14px] font-semibold leading-tight"> <p className="text-[14px] font-semibold leading-tight truncate">
{connector.name} {getConnectorDisplayName(connector.name)}
</p> </p>
</div> </div>
{isIndexing ? ( {isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5"> <p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" /> <Spinner size="xs" />
Syncing Syncing
</p> </p>
) : !isMCPConnector ? ( ) : !isMCPConnector ? (

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);
const isConnecting = connectingId === connector.id;
// Find all connectors of this type
const typeConnectors =
isConnected && allConnectors
? allConnectors.filter(
(c: SearchSourceConnector) => c.connector_type === connector.connectorType
)
: [];
const accountCount = typeConnectors.length;
const documentCount = getDocumentCountForConnector(
connector.connectorType,
documentTypeCounts
);
// Check if any account is currently indexing
const isIndexing = typeConnectors.some((c) => indexingConnectorIds?.has(c.id));
return (
<ConnectorCard
key={connector.id} key={connector.id}
id={connector.id} id={connector.id}
title={connector.title} title={connector.title}
description={connector.description} description={connector.description}
connectorCount={composioConnectorCount} connectorType={connector.connectorType}
onConnect={onOpenComposio} isConnected={isConnected}
isConnecting={isConnecting}
documentCount={documentCount}
accountCount={accountCount}
isIndexing={isIndexing}
onConnect={() => onConnectOAuth(connector)}
onManage={
isConnected && onViewAccountsList
? () => onViewAccountsList(connector.connectorType, connector.title)
: undefined
}
/> />
))} );
})}
</div> </div>
</section> </section>
)} */} )}
{/* More Integrations */} {/* More Integrations */}
{filteredOther.length > 0 && ( {filteredOther.length > 0 && (

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

@ -1,9 +1,10 @@
"use client"; "use client";
import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns"; import { differenceInDays, differenceInMinutes, format, isToday, isYesterday } from "date-fns";
import { ArrowLeft, Loader2, Plus, Server } from "lucide-react"; import { ArrowLeft, Plus, Server } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import type { SearchSourceConnector } from "@/contracts/types/connector.types";
@ -143,7 +144,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
> >
<div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/10 shrink-0"> <div className="flex h-5 w-5 items-center justify-center rounded-md bg-primary/10 shrink-0">
{isConnecting ? ( {isConnecting ? (
<Loader2 className="size-3 animate-spin text-primary" /> <Spinner size="xs" className="text-primary" />
) : ( ) : (
<Plus className="size-3 text-primary" /> <Plus className="size-3 text-primary" />
)} )}
@ -207,7 +208,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
</p> </p>
{isIndexing ? ( {isIndexing ? (
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5"> <p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
<Loader2 className="size-3 animate-spin" /> <Spinner size="xs" />
Syncing Syncing
</p> </p>
) : ( ) : (

View file

@ -2,7 +2,7 @@
import { TagInput, type Tag as TagType } from "emblor"; import { TagInput, type Tag as TagType } from "emblor";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { ArrowLeft, Loader2 } from "lucide-react"; import { ArrowLeft } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { type FC, useState } from "react"; import { type FC, useState } from "react";
@ -10,6 +10,7 @@ import { toast } from "sonner";
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { EnumConnectorName } from "@/contracts/enums/connector"; import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
@ -222,7 +223,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Spinner size="sm" className="mr-2" />
{t("processing")} {t("processing")}
</> </>
) : ( ) : (

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