mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
merge: upstream/dev with migration renumbering
This commit is contained in:
commit
a7145b2c63
176 changed files with 8791 additions and 3608 deletions
|
|
@ -228,7 +228,7 @@ COPY scripts/docker/init-postgres.sh /app/init-postgres.sh
|
|||
RUN dos2unix /app/init-postgres.sh && chmod +x /app/init-postgres.sh
|
||||
|
||||
# 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 \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -29,8 +29,7 @@ SurfSense is a highly customizable AI research agent, connected to external sour
|
|||
|
||||
# Video
|
||||
|
||||
https://github.com/user-attachments/assets/42a29ea1-d4d8-4213-9c69-972b5b806d58
|
||||
|
||||
https://github.com/user-attachments/assets/cc0c84d3-1f2f-4f7a-b519-2ecce22310b1
|
||||
|
||||
|
||||
## 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.
|
||||
### 📄 **Cited Answers**
|
||||
- Get Cited answers just like Perplexity.
|
||||
### 🧩 **Universal Compatibility**
|
||||
- Connect virtually any inference provider via the OpenAI spec and LiteLLM.
|
||||
### 🔔 **Privacy & Local LLM Support**
|
||||
- Works Flawlessly with Ollama local LLMs.
|
||||
- Works Flawlessly with local LLMs like vLLM and Ollama.
|
||||
### 🏠 **Self Hostable**
|
||||
- Open source and easy to deploy locally.
|
||||
### 👥 **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)
|
||||
- Granular permissions for documents, chats, connectors, and settings
|
||||
- Share knowledge bases securely within your organization
|
||||
- Team chats update in real-time and "Chat about the chat" in comment threads
|
||||
### 🎙️ Podcasts
|
||||
- Blazingly fast podcast generation agent. (Creates a 3-minute podcast in under 20 seconds.)
|
||||
- Convert your chat conversations into engaging audio content
|
||||
|
|
@ -237,6 +239,8 @@ Before self-hosting installation, make sure to complete the [prerequisite setup
|
|||
|
||||
### **BackEnd**
|
||||
|
||||
- **LiteLLM**: Universal LLM integration supporting 100+ models (OpenAI, Anthropic, Ollama, etc.)
|
||||
|
||||
- **FastAPI**: Modern, fast web framework for building APIs with Python
|
||||
|
||||
- **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.
|
||||
|
||||
- **LiteLLM**: Universal LLM integration supporting 100+ models (OpenAI, Anthropic, Ollama, etc.)
|
||||
|
||||
- **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)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libxext6 \
|
||||
libxrender1 \
|
||||
dos2unix \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Update certificates and install SSL tools
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
"""Add Composio connector types to SearchSourceConnectorType and DocumentType enums
|
||||
|
||||
Revision ID: 79
|
||||
Revises: 78
|
||||
|
||||
This migration adds the Composio connector enum values to both:
|
||||
- searchsourceconnectortype (for connector type tracking)
|
||||
- documenttype (for document type tracking)
|
||||
|
||||
Composio is a managed OAuth integration service that allows connecting
|
||||
to various third-party services (Google Drive, Gmail, Calendar, etc.)
|
||||
without requiring separate OAuth app verification.
|
||||
|
||||
This migration adds three specific connector types:
|
||||
- COMPOSIO_GOOGLE_DRIVE_CONNECTOR
|
||||
- COMPOSIO_GMAIL_CONNECTOR
|
||||
- COMPOSIO_GOOGLE_CALENDAR_CONNECTOR
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "79"
|
||||
down_revision: str | None = "78"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# Define the ENUM type names and the new values
|
||||
CONNECTOR_ENUM = "searchsourceconnectortype"
|
||||
CONNECTOR_NEW_VALUES = [
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
"COMPOSIO_GMAIL_CONNECTOR",
|
||||
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
]
|
||||
DOCUMENT_ENUM = "documenttype"
|
||||
DOCUMENT_NEW_VALUES = [
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
"COMPOSIO_GMAIL_CONNECTOR",
|
||||
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema - add Composio connector types to connector and document enums safely."""
|
||||
# Add each Composio connector type to searchsourceconnectortype only if not exists
|
||||
for value in CONNECTOR_NEW_VALUES:
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum e
|
||||
JOIN pg_type t ON e.enumtypid = t.oid
|
||||
WHERE t.typname = '{CONNECTOR_ENUM}' AND e.enumlabel = '{value}'
|
||||
) THEN
|
||||
ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{value}';
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add each Composio connector type to documenttype only if not exists
|
||||
for value in DOCUMENT_NEW_VALUES:
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum e
|
||||
JOIN pg_type t ON e.enumtypid = t.oid
|
||||
WHERE t.typname = '{DOCUMENT_ENUM}' AND e.enumlabel = '{value}'
|
||||
) THEN
|
||||
ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{value}';
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema - remove Composio connector types from connector and document enums.
|
||||
|
||||
Note: PostgreSQL does not support removing enum values directly.
|
||||
To properly downgrade, you would need to:
|
||||
1. Delete any rows using the Composio connector type values
|
||||
2. Create new enums without the Composio connector types
|
||||
3. Alter the columns to use the new enums
|
||||
4. Drop the old enums
|
||||
|
||||
This is left as a no-op since removing enum values is complex
|
||||
and typically not needed in practice.
|
||||
"""
|
||||
pass
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"""Add user incentive tasks table for earning free pages
|
||||
|
||||
Revision ID: 80
|
||||
Revises: 79
|
||||
|
||||
Changes:
|
||||
1. Create incentive_task_type enum with GITHUB_STAR value
|
||||
2. Create user_incentive_tasks table to track completed tasks
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "80"
|
||||
down_revision: str | None = "79"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create incentive tasks infrastructure."""
|
||||
|
||||
# Check if enum already exists (handles partial migration recovery)
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(
|
||||
sa.text("SELECT 1 FROM pg_type WHERE typname = 'incentivetasktype'")
|
||||
)
|
||||
enum_exists = result.fetchone() is not None
|
||||
|
||||
# Create the enum type only if it doesn't exist
|
||||
if not enum_exists:
|
||||
incentive_task_type_enum = postgresql.ENUM(
|
||||
"GITHUB_STAR",
|
||||
name="incentivetasktype",
|
||||
create_type=False,
|
||||
)
|
||||
incentive_task_type_enum.create(op.get_bind(), checkfirst=True)
|
||||
|
||||
# Check if table already exists (handles partial migration recovery)
|
||||
result = conn.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name = 'user_incentive_tasks'"
|
||||
)
|
||||
)
|
||||
table_exists = result.fetchone() is not None
|
||||
|
||||
if not table_exists:
|
||||
# Create the user_incentive_tasks table
|
||||
op.create_table(
|
||||
"user_incentive_tasks",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, index=True),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
sa.UUID(as_uuid=True),
|
||||
sa.ForeignKey("user.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column(
|
||||
"task_type",
|
||||
postgresql.ENUM(
|
||||
"GITHUB_STAR", name="incentivetasktype", create_type=False
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
),
|
||||
sa.Column("pages_awarded", sa.Integer(), nullable=False),
|
||||
sa.Column(
|
||||
"completed_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.TIMESTAMP(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
index=True,
|
||||
),
|
||||
sa.UniqueConstraint("user_id", "task_type", name="uq_user_incentive_task"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove incentive tasks infrastructure."""
|
||||
|
||||
# Drop the table
|
||||
op.drop_table("user_incentive_tasks")
|
||||
|
||||
# Drop the enum type
|
||||
postgresql.ENUM(name="incentivetasktype").drop(op.get_bind(), checkfirst=True)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Add public sharing columns to new_chat_threads
|
||||
|
||||
Revision ID: 79
|
||||
Revises: 78
|
||||
Revision ID: 81
|
||||
Revises: 80
|
||||
Create Date: 2026-01-23
|
||||
|
||||
Adds public_share_token and public_share_enabled columns to enable
|
||||
|
|
@ -13,8 +13,8 @@ from collections.abc import Sequence
|
|||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "79"
|
||||
down_revision: str | None = "78"
|
||||
revision: str = "81"
|
||||
down_revision: str | None = "80"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""Add thread_id to podcasts
|
||||
|
||||
Revision ID: 80
|
||||
Revises: 79
|
||||
Revision ID: 82
|
||||
Revises: 81
|
||||
Create Date: 2026-01-23
|
||||
|
||||
"""
|
||||
|
|
@ -10,8 +10,8 @@ from collections.abc import Sequence
|
|||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "80"
|
||||
down_revision: str | None = "79"
|
||||
revision: str = "82"
|
||||
down_revision: str | None = "81"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
|
@ -7,6 +7,7 @@ via NewLLMConfig.
|
|||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from deepagents import create_deep_agent
|
||||
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.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
|
||||
# =============================================================================
|
||||
|
|
@ -117,6 +202,30 @@ async def create_surfsense_deep_agent(
|
|||
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
|
||||
dependencies = {
|
||||
"search_space_id": search_space_id,
|
||||
|
|
@ -125,6 +234,9 @@ async def create_surfsense_deep_agent(
|
|||
"firecrawl_api_key": firecrawl_api_key,
|
||||
"user_id": user_id, # Required for memory tools
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ Available tools:
|
|||
# Tool factory exports (for direct use)
|
||||
from .display_image import create_display_image_tool
|
||||
from .knowledge_base import (
|
||||
CONNECTOR_DESCRIPTIONS,
|
||||
create_search_knowledge_base_tool,
|
||||
format_documents_for_context,
|
||||
search_knowledge_base_async,
|
||||
|
|
@ -40,6 +41,8 @@ from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
|||
__all__ = [
|
||||
# Registry
|
||||
"BUILTIN_TOOLS",
|
||||
# Knowledge base utilities
|
||||
"CONNECTOR_DESCRIPTIONS",
|
||||
"ToolDefinition",
|
||||
"build_tools",
|
||||
# Tool factories
|
||||
|
|
@ -51,7 +54,6 @@ __all__ = [
|
|||
"create_scrape_webpage_tool",
|
||||
"create_search_knowledge_base_tool",
|
||||
"create_search_surfsense_docs_tool",
|
||||
# Knowledge base utilities
|
||||
"format_documents_for_context",
|
||||
"get_all_tool_names",
|
||||
"get_default_enabled_tools",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import json
|
|||
from datetime import datetime
|
||||
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 app.services.connector_service import ConnectorService
|
||||
|
|
@ -22,6 +23,7 @@ from app.services.connector_service import ConnectorService
|
|||
# =============================================================================
|
||||
|
||||
# Canonical connector values used internally by ConnectorService
|
||||
# Includes all document types and search source connectors
|
||||
_ALL_CONNECTORS: list[str] = [
|
||||
"EXTENSION",
|
||||
"FILE",
|
||||
|
|
@ -50,41 +52,117 @@ _ALL_CONNECTORS: list[str] = [
|
|||
"CRAWLED_URL",
|
||||
"CIRCLEBACK",
|
||||
"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.
|
||||
|
||||
- Accepts user-facing enums like WEBCRAWLER_CONNECTOR and maps them to canonical
|
||||
ConnectorService types.
|
||||
- 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:
|
||||
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] = []
|
||||
for raw in connectors_to_search:
|
||||
c = (raw or "").strip().upper()
|
||||
if not c:
|
||||
continue
|
||||
# Map user-facing aliases to canonical names
|
||||
if c == "WEBCRAWLER_CONNECTOR":
|
||||
c = "CRAWLED_URL"
|
||||
normalized.append(c)
|
||||
|
||||
# de-dupe while preserving order + filter unknown
|
||||
# de-dupe while preserving order + filter to valid connectors
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for c in normalized:
|
||||
if c in seen:
|
||||
continue
|
||||
# Only include if it's a known connector AND available
|
||||
if c not in _ALL_CONNECTORS:
|
||||
continue
|
||||
if c not in valid_set:
|
||||
continue
|
||||
seen.add(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,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
available_connectors: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
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
|
||||
start_date: Optional start 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:
|
||||
Formatted string with search results
|
||||
|
|
@ -262,7 +343,7 @@ async def search_knowledge_base_async(
|
|||
end_date=end_date,
|
||||
)
|
||||
|
||||
connectors = _normalize_connectors(connectors_to_search)
|
||||
connectors = _normalize_connectors(connectors_to_search, available_connectors)
|
||||
|
||||
for connector in connectors:
|
||||
try:
|
||||
|
|
@ -316,6 +397,16 @@ async def search_knowledge_base_async(
|
|||
)
|
||||
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":
|
||||
_, chunks = await connector_service.search_notion(
|
||||
user_query=query,
|
||||
|
|
@ -519,6 +610,39 @@ async def search_knowledge_base_async(
|
|||
)
|
||||
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:
|
||||
print(f"Error searching connector {connector}: {e}")
|
||||
continue
|
||||
|
|
@ -543,11 +667,68 @@ async def search_knowledge_base_async(
|
|||
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(
|
||||
search_space_id: int,
|
||||
db_session: AsyncSession,
|
||||
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.
|
||||
|
||||
|
|
@ -555,72 +736,57 @@ def create_search_knowledge_base_tool(
|
|||
search_space_id: The user's search space ID
|
||||
db_session: Database session
|
||||
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:
|
||||
A configured tool function
|
||||
A configured StructuredTool instance
|
||||
"""
|
||||
# Build connector documentation dynamically
|
||||
connector_docs = _build_connector_docstring(available_connectors)
|
||||
|
||||
@tool
|
||||
async def search_knowledge_base(
|
||||
# Build context about available document types
|
||||
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,
|
||||
top_k: int = 10,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
connectors_to_search: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
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
|
||||
"""
|
||||
"""Implementation function for knowledge base search."""
|
||||
from app.agents.new_chat.utils import parse_date_or_datetime
|
||||
|
||||
parsed_start: datetime | None = None
|
||||
|
|
@ -640,6 +806,16 @@ def create_search_knowledge_base_tool(
|
|||
top_k=top_k,
|
||||
start_date=parsed_start,
|
||||
end_date=parsed_end,
|
||||
available_connectors=_available_connectors,
|
||||
)
|
||||
|
||||
return search_knowledge_base
|
||||
# Create StructuredTool with dynamic description
|
||||
# This properly sets the description that the LLM sees
|
||||
tool = StructuredTool(
|
||||
name="search_knowledge_base",
|
||||
description=dynamic_description,
|
||||
coroutine=_search_knowledge_base_impl,
|
||||
args_schema=SearchKnowledgeBaseInput,
|
||||
)
|
||||
|
||||
return tool
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ class ToolDefinition:
|
|||
# Contributors: Add your new tools here!
|
||||
BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||
# Core tool - searches the user's knowledge base
|
||||
# Now supports dynamic connector/document type discovery
|
||||
ToolDefinition(
|
||||
name="search_knowledge_base",
|
||||
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"],
|
||||
db_session=deps["db_session"],
|
||||
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"],
|
||||
# Note: available_connectors and available_document_types are optional
|
||||
),
|
||||
# Podcast generation tool
|
||||
ToolDefinition(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""
|
||||
Composio Connector Module.
|
||||
Composio Connector Base Module.
|
||||
|
||||
Provides a unified interface for interacting with various services via Composio,
|
||||
Provides a base class for interacting with various services via Composio,
|
||||
primarily used during indexing operations.
|
||||
"""
|
||||
|
||||
|
|
@ -19,10 +19,10 @@ logger = logging.getLogger(__name__)
|
|||
|
||||
class ComposioConnector:
|
||||
"""
|
||||
Generic Composio connector for data retrieval.
|
||||
Base Composio connector for data retrieval.
|
||||
|
||||
Wraps the ComposioService to provide toolkit-specific data access
|
||||
for indexing operations.
|
||||
for indexing operations. Subclasses implement toolkit-specific methods.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
|
@ -89,302 +89,12 @@ class ComposioConnector:
|
|||
toolkit_id = await self.get_toolkit_id()
|
||||
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(
|
||||
self,
|
||||
folder_id: str | None = None,
|
||||
page_token: str | None = None,
|
||||
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}"
|
||||
@property
|
||||
def connector_id(self) -> int:
|
||||
"""Get the connector ID."""
|
||||
return self._connector_id
|
||||
|
|
|
|||
613
surfsense_backend/app/connectors/composio_gmail_connector.py
Normal file
613
surfsense_backend/app/connectors/composio_gmail_connector.py
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
"""
|
||||
Composio Gmail Connector Module.
|
||||
|
||||
Provides Gmail specific methods for data retrieval and indexing via Composio.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import config
|
||||
from app.connectors.composio_connector import ComposioConnector
|
||||
from app.db import Document, DocumentType
|
||||
from app.services.composio_service import TOOLKIT_TO_DOCUMENT_TYPE
|
||||
from app.services.llm_service import get_user_long_context_llm
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
from app.tasks.connector_indexers.base import calculate_date_range
|
||||
from app.utils.document_converters import (
|
||||
create_document_chunks,
|
||||
generate_content_hash,
|
||||
generate_document_summary,
|
||||
generate_unique_identifier_hash,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_current_timestamp() -> datetime:
|
||||
"""Get the current timestamp with timezone for updated_at field."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
async def check_document_by_unique_identifier(
|
||||
session: AsyncSession, unique_identifier_hash: str
|
||||
) -> Document | None:
|
||||
"""Check if a document with the given unique identifier hash already exists."""
|
||||
existing_doc_result = await session.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.chunks))
|
||||
.where(Document.unique_identifier_hash == unique_identifier_hash)
|
||||
)
|
||||
return existing_doc_result.scalars().first()
|
||||
|
||||
|
||||
async def update_connector_last_indexed(
|
||||
session: AsyncSession,
|
||||
connector,
|
||||
update_last_indexed: bool = True,
|
||||
) -> None:
|
||||
"""Update the last_indexed_at timestamp for a connector."""
|
||||
if update_last_indexed:
|
||||
connector.last_indexed_at = datetime.now(UTC)
|
||||
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
||||
|
||||
|
||||
class ComposioGmailConnector(ComposioConnector):
|
||||
"""
|
||||
Gmail specific Composio connector.
|
||||
|
||||
Provides methods for listing messages, getting message details, and formatting
|
||||
Gmail messages from Gmail via Composio.
|
||||
"""
|
||||
|
||||
async def list_gmail_messages(
|
||||
self,
|
||||
query: str = "",
|
||||
max_results: int = 50,
|
||||
page_token: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], str | None, int | None, str | None]:
|
||||
"""
|
||||
List Gmail messages via Composio with pagination support.
|
||||
|
||||
Args:
|
||||
query: Gmail search query.
|
||||
max_results: Maximum number of messages per page (default: 50).
|
||||
page_token: Optional pagination token for next page.
|
||||
|
||||
Returns:
|
||||
Tuple of (messages list, next_page_token, result_size_estimate, error message).
|
||||
"""
|
||||
connected_account_id = await self.get_connected_account_id()
|
||||
if not connected_account_id:
|
||||
return [], None, None, "No connected account ID found"
|
||||
|
||||
entity_id = await self.get_entity_id()
|
||||
service = await self._get_service()
|
||||
return await service.get_gmail_messages(
|
||||
connected_account_id=connected_account_id,
|
||||
entity_id=entity_id,
|
||||
query=query,
|
||||
max_results=max_results,
|
||||
page_token=page_token,
|
||||
)
|
||||
|
||||
async def get_gmail_message_detail(
|
||||
self, message_id: str
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Get full details of a Gmail message via Composio.
|
||||
|
||||
Args:
|
||||
message_id: Gmail message ID.
|
||||
|
||||
Returns:
|
||||
Tuple of (message details, error message).
|
||||
"""
|
||||
connected_account_id = await self.get_connected_account_id()
|
||||
if not connected_account_id:
|
||||
return None, "No connected account ID found"
|
||||
|
||||
entity_id = await self.get_entity_id()
|
||||
service = await self._get_service()
|
||||
return await service.get_gmail_message_detail(
|
||||
connected_account_id=connected_account_id,
|
||||
entity_id=entity_id,
|
||||
message_id=message_id,
|
||||
)
|
||||
|
||||
def format_gmail_message_to_markdown(self, message: dict[str, Any]) -> str:
|
||||
"""
|
||||
Format a Gmail message to markdown.
|
||||
|
||||
Args:
|
||||
message: Message object from Composio's GMAIL_FETCH_EMAILS response.
|
||||
Composio structure: messageId, messageText, messageTimestamp,
|
||||
payload.headers, labelIds, attachmentList
|
||||
|
||||
Returns:
|
||||
Formatted markdown string.
|
||||
"""
|
||||
try:
|
||||
# Composio uses 'messageId' (camelCase)
|
||||
message_id = message.get("messageId", "") or message.get("id", "")
|
||||
label_ids = message.get("labelIds", [])
|
||||
|
||||
# Extract headers from payload
|
||||
payload = message.get("payload", {})
|
||||
headers = payload.get("headers", [])
|
||||
|
||||
# Parse headers into a dict
|
||||
header_dict = {}
|
||||
for header in headers:
|
||||
name = header.get("name", "").lower()
|
||||
value = header.get("value", "")
|
||||
header_dict[name] = value
|
||||
|
||||
# Extract key information
|
||||
subject = header_dict.get("subject", "No Subject")
|
||||
from_email = header_dict.get("from", "Unknown Sender")
|
||||
to_email = header_dict.get("to", "Unknown Recipient")
|
||||
# Composio provides messageTimestamp directly
|
||||
date_str = message.get("messageTimestamp", "") or header_dict.get(
|
||||
"date", "Unknown Date"
|
||||
)
|
||||
|
||||
# Build markdown content
|
||||
markdown_content = f"# {subject}\n\n"
|
||||
markdown_content += f"**From:** {from_email}\n"
|
||||
markdown_content += f"**To:** {to_email}\n"
|
||||
markdown_content += f"**Date:** {date_str}\n"
|
||||
|
||||
if label_ids:
|
||||
markdown_content += f"**Labels:** {', '.join(label_ids)}\n"
|
||||
|
||||
markdown_content += "\n---\n\n"
|
||||
|
||||
# Composio provides full message text in 'messageText'
|
||||
message_text = message.get("messageText", "")
|
||||
if message_text:
|
||||
markdown_content += f"## Content\n\n{message_text}\n\n"
|
||||
else:
|
||||
# Fallback to snippet if no messageText
|
||||
snippet = message.get("snippet", "")
|
||||
if snippet:
|
||||
markdown_content += f"## Preview\n\n{snippet}\n\n"
|
||||
|
||||
# Add attachment info if present
|
||||
attachments = message.get("attachmentList", [])
|
||||
if attachments:
|
||||
markdown_content += "## Attachments\n\n"
|
||||
for att in attachments:
|
||||
att_name = att.get("filename", att.get("name", "Unknown"))
|
||||
markdown_content += f"- {att_name}\n"
|
||||
markdown_content += "\n"
|
||||
|
||||
# Add message metadata
|
||||
markdown_content += "## Message Details\n\n"
|
||||
markdown_content += f"- **Message ID:** {message_id}\n"
|
||||
|
||||
return markdown_content
|
||||
|
||||
except Exception as e:
|
||||
return f"Error formatting message to markdown: {e!s}"
|
||||
|
||||
|
||||
# ============ Indexer Functions ============
|
||||
|
||||
|
||||
async def _process_gmail_message_batch(
|
||||
session: AsyncSession,
|
||||
messages: list[dict[str, Any]],
|
||||
composio_connector: ComposioGmailConnector,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
total_documents_indexed: int = 0,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Process a batch of Gmail messages and index them.
|
||||
|
||||
Args:
|
||||
total_documents_indexed: Running total of documents indexed so far (for batch commits).
|
||||
|
||||
Returns:
|
||||
Tuple of (documents_indexed, documents_skipped)
|
||||
"""
|
||||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
|
||||
for message in messages:
|
||||
try:
|
||||
# Composio uses 'messageId' (camelCase), not 'id'
|
||||
message_id = message.get("messageId", "") or message.get("id", "")
|
||||
if not message_id:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Composio's GMAIL_FETCH_EMAILS already returns full message content
|
||||
# No need for a separate detail API call
|
||||
|
||||
# Extract message info from Composio response
|
||||
# Composio structure: messageId, messageText, messageTimestamp, payload.headers, labelIds
|
||||
payload = message.get("payload", {})
|
||||
headers = payload.get("headers", [])
|
||||
|
||||
subject = "No Subject"
|
||||
sender = "Unknown Sender"
|
||||
date_str = message.get("messageTimestamp", "Unknown Date")
|
||||
|
||||
for header in headers:
|
||||
name = header.get("name", "").lower()
|
||||
value = header.get("value", "")
|
||||
if name == "subject":
|
||||
subject = value
|
||||
elif name == "from":
|
||||
sender = value
|
||||
elif name == "date":
|
||||
date_str = value
|
||||
|
||||
# Format to markdown using the full message data
|
||||
markdown_content = composio_connector.format_gmail_message_to_markdown(
|
||||
message
|
||||
)
|
||||
|
||||
# Check for empty content (defensive parsing per Composio best practices)
|
||||
if not markdown_content.strip():
|
||||
logger.warning(f"Skipping Gmail message with no content: {subject}")
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Generate unique identifier
|
||||
document_type = DocumentType(TOOLKIT_TO_DOCUMENT_TYPE["gmail"])
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
document_type, f"gmail_{message_id}", search_space_id
|
||||
)
|
||||
|
||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||
|
||||
existing_document = await check_document_by_unique_identifier(
|
||||
session, unique_identifier_hash
|
||||
)
|
||||
|
||||
# Get label IDs from Composio response
|
||||
label_ids = message.get("labelIds", [])
|
||||
# Extract thread_id if available (for consistency with non-Composio implementation)
|
||||
thread_id = message.get("threadId", "") or message.get("thread_id", "")
|
||||
|
||||
if existing_document:
|
||||
if existing_document.content_hash == content_hash:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Update existing
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"message_id": message_id,
|
||||
"thread_id": thread_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"document_type": "Gmail Message (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
existing_document.title = f"Gmail: {subject}"
|
||||
existing_document.content = summary_content
|
||||
existing_document.content_hash = content_hash
|
||||
existing_document.embedding = summary_embedding
|
||||
existing_document.document_metadata = {
|
||||
"message_id": message_id,
|
||||
"thread_id": thread_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"date": date_str,
|
||||
"labels": label_ids,
|
||||
"connector_id": connector_id,
|
||||
"source": "composio",
|
||||
}
|
||||
existing_document.chunks = chunks
|
||||
existing_document.updated_at = get_current_timestamp()
|
||||
|
||||
documents_indexed += 1
|
||||
|
||||
# Batch commit every 10 documents
|
||||
current_total = total_documents_indexed + documents_indexed
|
||||
if current_total % 10 == 0:
|
||||
logger.info(
|
||||
f"Committing batch: {current_total} Gmail messages processed so far"
|
||||
)
|
||||
await session.commit()
|
||||
continue
|
||||
|
||||
# Create new document
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"message_id": message_id,
|
||||
"thread_id": thread_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"document_type": "Gmail Message (Composio)",
|
||||
}
|
||||
summary_content, summary_embedding = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Gmail: {subject}",
|
||||
document_type=DocumentType(TOOLKIT_TO_DOCUMENT_TYPE["gmail"]),
|
||||
document_metadata={
|
||||
"message_id": message_id,
|
||||
"thread_id": thread_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"date": date_str,
|
||||
"labels": label_ids,
|
||||
"connector_id": connector_id,
|
||||
"toolkit_id": "gmail",
|
||||
"source": "composio",
|
||||
},
|
||||
content=summary_content,
|
||||
content_hash=content_hash,
|
||||
unique_identifier_hash=unique_identifier_hash,
|
||||
embedding=summary_embedding,
|
||||
chunks=chunks,
|
||||
updated_at=get_current_timestamp(),
|
||||
)
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
|
||||
# Batch commit every 10 documents
|
||||
current_total = total_documents_indexed + documents_indexed
|
||||
if current_total % 10 == 0:
|
||||
logger.info(
|
||||
f"Committing batch: {current_total} Gmail messages processed so far"
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Gmail message: {e!s}", exc_info=True)
|
||||
documents_skipped += 1
|
||||
# Rollback on error to avoid partial state (per Composio best practices)
|
||||
try:
|
||||
await session.rollback()
|
||||
except Exception as rollback_error:
|
||||
logger.error(
|
||||
f"Error during rollback: {rollback_error!s}", exc_info=True
|
||||
)
|
||||
continue
|
||||
|
||||
return documents_indexed, documents_skipped
|
||||
|
||||
|
||||
async def index_composio_gmail(
|
||||
session: AsyncSession,
|
||||
connector,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
task_logger: TaskLoggingService,
|
||||
log_entry,
|
||||
update_last_indexed: bool = True,
|
||||
max_items: int = 1000,
|
||||
) -> tuple[int, str]:
|
||||
"""Index Gmail messages via Composio with pagination and incremental processing."""
|
||||
try:
|
||||
composio_connector = ComposioGmailConnector(session, connector_id)
|
||||
|
||||
# Normalize date values - handle "undefined" strings from frontend
|
||||
if start_date == "undefined" or start_date == "":
|
||||
start_date = None
|
||||
if end_date == "undefined" or end_date == "":
|
||||
end_date = None
|
||||
|
||||
# Use provided dates directly if both are provided, otherwise calculate from last_indexed_at
|
||||
# This ensures user-selected dates are respected (matching non-Composio Gmail connector behavior)
|
||||
if start_date is not None and end_date is not None:
|
||||
# User provided both dates - use them directly
|
||||
start_date_str = start_date
|
||||
end_date_str = end_date
|
||||
else:
|
||||
# Calculate date range with defaults (uses last_indexed_at or 365 days back)
|
||||
# This ensures indexing works even when user doesn't specify dates
|
||||
start_date_str, end_date_str = calculate_date_range(
|
||||
connector, start_date, end_date, default_days_back=365
|
||||
)
|
||||
|
||||
# Build query with date range
|
||||
query_parts = []
|
||||
if start_date_str:
|
||||
query_parts.append(f"after:{start_date_str.replace('-', '/')}")
|
||||
if end_date_str:
|
||||
query_parts.append(f"before:{end_date_str.replace('-', '/')}")
|
||||
query = " ".join(query_parts) if query_parts else ""
|
||||
|
||||
logger.info(
|
||||
f"Gmail query for connector {connector_id}: '{query}' "
|
||||
f"(start_date={start_date_str}, end_date={end_date_str})"
|
||||
)
|
||||
|
||||
# Use smaller batch size to avoid 413 payload too large errors
|
||||
batch_size = 50
|
||||
page_token = None
|
||||
total_documents_indexed = 0
|
||||
total_documents_skipped = 0
|
||||
total_messages_fetched = 0
|
||||
result_size_estimate = None # Will be set from first API response
|
||||
|
||||
while total_messages_fetched < max_items:
|
||||
# Calculate how many messages to fetch in this batch
|
||||
remaining = max_items - total_messages_fetched
|
||||
current_batch_size = min(batch_size, remaining)
|
||||
|
||||
# Use result_size_estimate if available, otherwise fall back to max_items
|
||||
estimated_total = (
|
||||
result_size_estimate if result_size_estimate is not None else max_items
|
||||
)
|
||||
# Cap estimated_total at max_items to avoid showing misleading progress
|
||||
estimated_total = min(estimated_total, max_items)
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Gmail messages batch via Composio for connector {connector_id} "
|
||||
f"({total_messages_fetched}/{estimated_total} fetched, {total_documents_indexed} indexed)",
|
||||
{
|
||||
"stage": "fetching_messages",
|
||||
"batch_size": current_batch_size,
|
||||
"total_fetched": total_messages_fetched,
|
||||
"total_indexed": total_documents_indexed,
|
||||
"estimated_total": estimated_total,
|
||||
},
|
||||
)
|
||||
|
||||
# Fetch batch of messages
|
||||
(
|
||||
messages,
|
||||
next_token,
|
||||
result_size_estimate_batch,
|
||||
error,
|
||||
) = await composio_connector.list_gmail_messages(
|
||||
query=query,
|
||||
max_results=current_batch_size,
|
||||
page_token=page_token,
|
||||
)
|
||||
|
||||
if error:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, f"Failed to fetch Gmail messages: {error}", {}
|
||||
)
|
||||
return 0, f"Failed to fetch Gmail messages: {error}"
|
||||
|
||||
if not messages:
|
||||
# No more messages available
|
||||
break
|
||||
|
||||
# Update result_size_estimate from first response (Gmail provides this estimate)
|
||||
if result_size_estimate is None and result_size_estimate_batch is not None:
|
||||
result_size_estimate = result_size_estimate_batch
|
||||
logger.info(
|
||||
f"Gmail API estimated {result_size_estimate} total messages for query: '{query}'"
|
||||
)
|
||||
|
||||
total_messages_fetched += len(messages)
|
||||
# Recalculate estimated_total after potentially updating result_size_estimate
|
||||
estimated_total = (
|
||||
result_size_estimate if result_size_estimate is not None else max_items
|
||||
)
|
||||
estimated_total = min(estimated_total, max_items)
|
||||
|
||||
logger.info(
|
||||
f"Fetched batch of {len(messages)} Gmail messages "
|
||||
f"(total: {total_messages_fetched}/{estimated_total})"
|
||||
)
|
||||
|
||||
# Process batch incrementally
|
||||
batch_indexed, batch_skipped = await _process_gmail_message_batch(
|
||||
session=session,
|
||||
messages=messages,
|
||||
composio_connector=composio_connector,
|
||||
connector_id=connector_id,
|
||||
search_space_id=search_space_id,
|
||||
user_id=user_id,
|
||||
total_documents_indexed=total_documents_indexed,
|
||||
)
|
||||
|
||||
total_documents_indexed += batch_indexed
|
||||
total_documents_skipped += batch_skipped
|
||||
|
||||
logger.info(
|
||||
f"Processed batch: {batch_indexed} indexed, {batch_skipped} skipped "
|
||||
f"(total: {total_documents_indexed} indexed, {total_documents_skipped} skipped)"
|
||||
)
|
||||
|
||||
# Batch commits happen in _process_gmail_message_batch every 10 documents
|
||||
# This ensures progress is saved incrementally, preventing data loss on crashes
|
||||
|
||||
# Check if we should continue
|
||||
if not next_token:
|
||||
# No more pages available
|
||||
break
|
||||
|
||||
if len(messages) < current_batch_size:
|
||||
# Last page had fewer items than requested, we're done
|
||||
break
|
||||
|
||||
# Continue with next page
|
||||
page_token = next_token
|
||||
|
||||
if total_messages_fetched == 0:
|
||||
success_msg = "No Gmail messages found in the specified date range"
|
||||
await task_logger.log_task_success(
|
||||
log_entry, success_msg, {"messages_count": 0}
|
||||
)
|
||||
# CRITICAL: Update timestamp even when no messages found so Electric SQL syncs and UI shows indexed status
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
await session.commit()
|
||||
return 0, None # Return None (not error) when no items found
|
||||
|
||||
# CRITICAL: Always update timestamp (even if 0 documents indexed) so Electric SQL syncs
|
||||
# This ensures the UI shows "Last indexed" instead of "Never indexed"
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
|
||||
# Final commit to ensure all documents are persisted (safety net)
|
||||
# This matches the pattern used in non-Composio Gmail indexer
|
||||
logger.info(
|
||||
f"Final commit: Total {total_documents_indexed} Gmail messages processed"
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Successfully committed all Composio Gmail document changes to database"
|
||||
)
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed Gmail indexing via Composio for connector {connector_id}",
|
||||
{
|
||||
"documents_indexed": total_documents_indexed,
|
||||
"documents_skipped": total_documents_skipped,
|
||||
"messages_fetched": total_messages_fetched,
|
||||
},
|
||||
)
|
||||
|
||||
return total_documents_indexed, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to index Gmail via Composio: {e!s}", exc_info=True)
|
||||
return 0, f"Failed to index Gmail via Composio: {e!s}"
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
"""
|
||||
Composio Google Calendar Connector Module.
|
||||
|
||||
Provides Google Calendar specific methods for data retrieval and indexing via Composio.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import config
|
||||
from app.connectors.composio_connector import ComposioConnector
|
||||
from app.db import Document, DocumentType
|
||||
from app.services.composio_service import TOOLKIT_TO_DOCUMENT_TYPE
|
||||
from app.services.llm_service import get_user_long_context_llm
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
from app.tasks.connector_indexers.base import (
|
||||
calculate_date_range,
|
||||
check_duplicate_document_by_hash,
|
||||
)
|
||||
from app.utils.document_converters import (
|
||||
create_document_chunks,
|
||||
generate_content_hash,
|
||||
generate_document_summary,
|
||||
generate_unique_identifier_hash,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_current_timestamp() -> datetime:
|
||||
"""Get the current timestamp with timezone for updated_at field."""
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
async def check_document_by_unique_identifier(
|
||||
session: AsyncSession, unique_identifier_hash: str
|
||||
) -> Document | None:
|
||||
"""Check if a document with the given unique identifier hash already exists."""
|
||||
existing_doc_result = await session.execute(
|
||||
select(Document)
|
||||
.options(selectinload(Document.chunks))
|
||||
.where(Document.unique_identifier_hash == unique_identifier_hash)
|
||||
)
|
||||
return existing_doc_result.scalars().first()
|
||||
|
||||
|
||||
async def update_connector_last_indexed(
|
||||
session: AsyncSession,
|
||||
connector,
|
||||
update_last_indexed: bool = True,
|
||||
) -> None:
|
||||
"""Update the last_indexed_at timestamp for a connector."""
|
||||
if update_last_indexed:
|
||||
connector.last_indexed_at = datetime.now(UTC)
|
||||
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
||||
|
||||
|
||||
class ComposioGoogleCalendarConnector(ComposioConnector):
|
||||
"""
|
||||
Google Calendar specific Composio connector.
|
||||
|
||||
Provides methods for listing calendar events and formatting them from
|
||||
Google Calendar via Composio.
|
||||
"""
|
||||
|
||||
async def list_calendar_events(
|
||||
self,
|
||||
time_min: str | None = None,
|
||||
time_max: str | None = None,
|
||||
max_results: int = 250,
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
List Google Calendar events via Composio.
|
||||
|
||||
Args:
|
||||
time_min: Start time (RFC3339 format).
|
||||
time_max: End time (RFC3339 format).
|
||||
max_results: Maximum number of events.
|
||||
|
||||
Returns:
|
||||
Tuple of (events list, error message).
|
||||
"""
|
||||
connected_account_id = await self.get_connected_account_id()
|
||||
if not connected_account_id:
|
||||
return [], "No connected account ID found"
|
||||
|
||||
entity_id = await self.get_entity_id()
|
||||
service = await self._get_service()
|
||||
return await service.get_calendar_events(
|
||||
connected_account_id=connected_account_id,
|
||||
entity_id=entity_id,
|
||||
time_min=time_min,
|
||||
time_max=time_max,
|
||||
max_results=max_results,
|
||||
)
|
||||
|
||||
def format_calendar_event_to_markdown(self, event: dict[str, Any]) -> str:
|
||||
"""
|
||||
Format a Google Calendar event to markdown.
|
||||
|
||||
Args:
|
||||
event: Event object from Google Calendar API.
|
||||
|
||||
Returns:
|
||||
Formatted markdown string.
|
||||
"""
|
||||
try:
|
||||
# Extract basic event information
|
||||
summary = event.get("summary", "No Title")
|
||||
description = event.get("description", "")
|
||||
location = event.get("location", "")
|
||||
|
||||
# Extract start and end times
|
||||
start = event.get("start", {})
|
||||
end = event.get("end", {})
|
||||
|
||||
start_time = start.get("dateTime") or start.get("date", "")
|
||||
end_time = end.get("dateTime") or end.get("date", "")
|
||||
|
||||
# Format times for display
|
||||
def format_time(time_str: str) -> str:
|
||||
if not time_str:
|
||||
return "Unknown"
|
||||
try:
|
||||
if "T" in time_str:
|
||||
dt = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M")
|
||||
return time_str
|
||||
except Exception:
|
||||
return time_str
|
||||
|
||||
start_formatted = format_time(start_time)
|
||||
end_formatted = format_time(end_time)
|
||||
|
||||
# Extract attendees
|
||||
attendees = event.get("attendees", [])
|
||||
attendee_list = []
|
||||
for attendee in attendees:
|
||||
email = attendee.get("email", "")
|
||||
display_name = attendee.get("displayName", email)
|
||||
response_status = attendee.get("responseStatus", "")
|
||||
attendee_list.append(f"- {display_name} ({response_status})")
|
||||
|
||||
# Build markdown content
|
||||
markdown_content = f"# {summary}\n\n"
|
||||
markdown_content += f"**Start:** {start_formatted}\n"
|
||||
markdown_content += f"**End:** {end_formatted}\n"
|
||||
|
||||
if location:
|
||||
markdown_content += f"**Location:** {location}\n"
|
||||
|
||||
markdown_content += "\n"
|
||||
|
||||
if description:
|
||||
markdown_content += f"## Description\n\n{description}\n\n"
|
||||
|
||||
if attendee_list:
|
||||
markdown_content += "## Attendees\n\n"
|
||||
markdown_content += "\n".join(attendee_list)
|
||||
markdown_content += "\n\n"
|
||||
|
||||
# Add event metadata
|
||||
markdown_content += "## Event Details\n\n"
|
||||
markdown_content += f"- **Event ID:** {event.get('id', 'Unknown')}\n"
|
||||
markdown_content += f"- **Created:** {event.get('created', 'Unknown')}\n"
|
||||
markdown_content += f"- **Updated:** {event.get('updated', 'Unknown')}\n"
|
||||
|
||||
return markdown_content
|
||||
|
||||
except Exception as e:
|
||||
return f"Error formatting event to markdown: {e!s}"
|
||||
|
||||
|
||||
# ============ Indexer Functions ============
|
||||
|
||||
|
||||
async def index_composio_google_calendar(
|
||||
session: AsyncSession,
|
||||
connector,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
task_logger: TaskLoggingService,
|
||||
log_entry,
|
||||
update_last_indexed: bool = True,
|
||||
max_items: int = 2500,
|
||||
) -> tuple[int, str]:
|
||||
"""Index Google Calendar events via Composio."""
|
||||
try:
|
||||
composio_connector = ComposioGoogleCalendarConnector(session, connector_id)
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Google Calendar events via Composio for connector {connector_id}",
|
||||
{"stage": "fetching_events"},
|
||||
)
|
||||
|
||||
# Normalize date values - handle "undefined" strings from frontend
|
||||
if start_date == "undefined" or start_date == "":
|
||||
start_date = None
|
||||
if end_date == "undefined" or end_date == "":
|
||||
end_date = None
|
||||
|
||||
# Use provided dates directly if both are provided, otherwise calculate from last_indexed_at
|
||||
# This ensures user-selected dates are respected (matching non-Composio Calendar connector behavior)
|
||||
if start_date is not None and end_date is not None:
|
||||
# User provided both dates - use them directly
|
||||
start_date_str = start_date
|
||||
end_date_str = end_date
|
||||
else:
|
||||
# Calculate date range with defaults (uses last_indexed_at or 365 days back)
|
||||
# This ensures indexing works even when user doesn't specify dates
|
||||
start_date_str, end_date_str = calculate_date_range(
|
||||
connector, start_date, end_date, default_days_back=365
|
||||
)
|
||||
|
||||
# Build time range for API call
|
||||
time_min = f"{start_date_str}T00:00:00Z"
|
||||
time_max = f"{end_date_str}T23:59:59Z"
|
||||
|
||||
logger.info(
|
||||
f"Google Calendar query for connector {connector_id}: "
|
||||
f"(start_date={start_date_str}, end_date={end_date_str})"
|
||||
)
|
||||
|
||||
events, error = await composio_connector.list_calendar_events(
|
||||
time_min=time_min,
|
||||
time_max=time_max,
|
||||
max_results=max_items,
|
||||
)
|
||||
|
||||
if error:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, f"Failed to fetch Calendar events: {error}", {}
|
||||
)
|
||||
return 0, f"Failed to fetch Calendar events: {error}"
|
||||
|
||||
if not events:
|
||||
success_msg = "No Google Calendar events found in the specified date range"
|
||||
await task_logger.log_task_success(
|
||||
log_entry, success_msg, {"events_count": 0}
|
||||
)
|
||||
# CRITICAL: Update timestamp even when no events found so Electric SQL syncs and UI shows indexed status
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
await session.commit()
|
||||
return (
|
||||
0,
|
||||
None,
|
||||
) # Return None (not error) when no items found - this is success with 0 items
|
||||
|
||||
logger.info(f"Found {len(events)} Google Calendar events to index via Composio")
|
||||
|
||||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
duplicate_content_count = (
|
||||
0 # Track events skipped due to duplicate content_hash
|
||||
)
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
# Handle both standard Google API and potential Composio variations
|
||||
event_id = event.get("id", "") or event.get("eventId", "")
|
||||
summary = (
|
||||
event.get("summary", "") or event.get("title", "") or "No Title"
|
||||
)
|
||||
|
||||
if not event_id:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Format to markdown
|
||||
markdown_content = composio_connector.format_calendar_event_to_markdown(
|
||||
event
|
||||
)
|
||||
|
||||
# Generate unique identifier
|
||||
document_type = DocumentType(TOOLKIT_TO_DOCUMENT_TYPE["googlecalendar"])
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
document_type, f"calendar_{event_id}", search_space_id
|
||||
)
|
||||
|
||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||
|
||||
existing_document = await check_document_by_unique_identifier(
|
||||
session, unique_identifier_hash
|
||||
)
|
||||
|
||||
# Extract event times
|
||||
start = event.get("start", {})
|
||||
end = event.get("end", {})
|
||||
start_time = start.get("dateTime") or start.get("date", "")
|
||||
end_time = end.get("dateTime") or end.get("date", "")
|
||||
location = event.get("location", "")
|
||||
|
||||
if existing_document:
|
||||
if existing_document.content_hash == content_hash:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Update existing
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"document_type": "Google Calendar Event (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||
if location:
|
||||
summary_content += f"\nLocation: {location}"
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
existing_document.title = f"Calendar: {summary}"
|
||||
existing_document.content = summary_content
|
||||
existing_document.content_hash = content_hash
|
||||
existing_document.embedding = summary_embedding
|
||||
existing_document.document_metadata = {
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"connector_id": connector_id,
|
||||
"source": "composio",
|
||||
}
|
||||
existing_document.chunks = chunks
|
||||
existing_document.updated_at = get_current_timestamp()
|
||||
|
||||
documents_indexed += 1
|
||||
|
||||
# Batch commit every 10 documents
|
||||
if documents_indexed % 10 == 0:
|
||||
logger.info(
|
||||
f"Committing batch: {documents_indexed} Google Calendar events processed so far"
|
||||
)
|
||||
await session.commit()
|
||||
continue
|
||||
|
||||
# Document doesn't exist by unique_identifier_hash
|
||||
# Check if a document with the same content_hash exists (from standard connector)
|
||||
with session.no_autoflush:
|
||||
duplicate_by_content = await check_duplicate_document_by_hash(
|
||||
session, content_hash
|
||||
)
|
||||
|
||||
if duplicate_by_content:
|
||||
# A document with the same content already exists (likely from standard connector)
|
||||
logger.info(
|
||||
f"Event {summary} already indexed by another connector "
|
||||
f"(existing document ID: {duplicate_by_content.id}, "
|
||||
f"type: {duplicate_by_content.document_type}). Skipping to avoid duplicate content."
|
||||
)
|
||||
duplicate_content_count += 1
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Create new document
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"document_type": "Google Calendar Event (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||
)
|
||||
if location:
|
||||
summary_content += f"\nLocation: {location}"
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Calendar: {summary}",
|
||||
document_type=DocumentType(
|
||||
TOOLKIT_TO_DOCUMENT_TYPE["googlecalendar"]
|
||||
),
|
||||
document_metadata={
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"connector_id": connector_id,
|
||||
"toolkit_id": "googlecalendar",
|
||||
"source": "composio",
|
||||
},
|
||||
content=summary_content,
|
||||
content_hash=content_hash,
|
||||
unique_identifier_hash=unique_identifier_hash,
|
||||
embedding=summary_embedding,
|
||||
chunks=chunks,
|
||||
updated_at=get_current_timestamp(),
|
||||
)
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
|
||||
# Batch commit every 10 documents
|
||||
if documents_indexed % 10 == 0:
|
||||
logger.info(
|
||||
f"Committing batch: {documents_indexed} Google Calendar events processed so far"
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Calendar event: {e!s}", exc_info=True)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# CRITICAL: Always update timestamp (even if 0 documents indexed) so Electric SQL syncs
|
||||
# This ensures the UI shows "Last indexed" instead of "Never indexed"
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
|
||||
# Final commit to ensure all documents are persisted (safety net)
|
||||
# This matches the pattern used in non-Composio Gmail indexer
|
||||
logger.info(
|
||||
f"Final commit: Total {documents_indexed} Google Calendar events processed"
|
||||
)
|
||||
try:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Successfully committed all Composio Google Calendar document changes to database"
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle any remaining integrity errors gracefully (race conditions, etc.)
|
||||
if (
|
||||
"duplicate key value violates unique constraint" in str(e).lower()
|
||||
or "uniqueviolationerror" in str(e).lower()
|
||||
):
|
||||
logger.warning(
|
||||
f"Duplicate content_hash detected during final commit. "
|
||||
f"This may occur if the same event was indexed by multiple connectors. "
|
||||
f"Rolling back and continuing. Error: {e!s}"
|
||||
)
|
||||
await session.rollback()
|
||||
# Don't fail the entire task - some documents may have been successfully indexed
|
||||
else:
|
||||
raise
|
||||
|
||||
# Build warning message if duplicates were found
|
||||
warning_message = None
|
||||
if duplicate_content_count > 0:
|
||||
warning_message = f"{duplicate_content_count} skipped (duplicate)"
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed Google Calendar indexing via Composio for connector {connector_id}",
|
||||
{
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
"duplicate_content_count": duplicate_content_count,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Composio Google Calendar indexing completed: {documents_indexed} new events, {documents_skipped} skipped "
|
||||
f"({duplicate_content_count} due to duplicate content from other connectors)"
|
||||
)
|
||||
return documents_indexed, warning_message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True
|
||||
)
|
||||
return 0, f"Failed to index Google Calendar via Composio: {e!s}"
|
||||
1167
surfsense_backend/app/connectors/composio_google_drive_connector.py
Normal file
1167
surfsense_backend/app/connectors/composio_google_drive_connector.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -142,6 +142,15 @@ class GoogleCalendarConnector:
|
|||
flag_modified(connector, "config")
|
||||
await self._session.commit()
|
||||
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(
|
||||
f"Failed to refresh Google OAuth credentials: {e!s}"
|
||||
) from e
|
||||
|
|
@ -165,6 +174,14 @@ class GoogleCalendarConnector:
|
|||
self.service = build("calendar", "v3", credentials=credentials)
|
||||
return self.service
|
||||
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
|
||||
|
||||
async def get_calendars(self) -> tuple[list[dict[str, Any]], str | None]:
|
||||
|
|
@ -271,6 +288,14 @@ class GoogleCalendarConnector:
|
|||
return events, None
|
||||
|
||||
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}"
|
||||
|
||||
def format_event_to_markdown(self, event: dict[str, Any]) -> str:
|
||||
|
|
|
|||
|
|
@ -141,6 +141,15 @@ class GoogleGmailConnector:
|
|||
flag_modified(connector, "config")
|
||||
await self._session.commit()
|
||||
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(
|
||||
f"Failed to refresh Google OAuth credentials: {e!s}"
|
||||
) from e
|
||||
|
|
@ -164,6 +173,14 @@ class GoogleGmailConnector:
|
|||
self.service = build("gmail", "v1", credentials=credentials)
|
||||
return self.service
|
||||
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
|
||||
|
||||
async def get_user_profile(self) -> tuple[dict[str, Any], str | None]:
|
||||
|
|
@ -225,6 +242,14 @@ class GoogleGmailConnector:
|
|||
return messages, None
|
||||
|
||||
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}"
|
||||
|
||||
async def get_message_details(
|
||||
|
|
@ -271,6 +296,13 @@ class GoogleGmailConnector:
|
|||
try:
|
||||
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
|
||||
query_parts = []
|
||||
|
||||
|
|
|
|||
|
|
@ -55,7 +55,9 @@ class DocumentType(str, Enum):
|
|||
CIRCLEBACK = "CIRCLEBACK"
|
||||
OBSIDIAN_CONNECTOR = "OBSIDIAN_CONNECTOR"
|
||||
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):
|
||||
|
|
@ -86,9 +88,9 @@ class SearchSourceConnectorType(str, Enum):
|
|||
"OBSIDIAN_CONNECTOR" # Self-hosted only - Local Obsidian vault indexing
|
||||
)
|
||||
MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools
|
||||
COMPOSIO_CONNECTOR = (
|
||||
"COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.)
|
||||
)
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR = "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"
|
||||
COMPOSIO_GMAIL_CONNECTOR = "COMPOSIO_GMAIL_CONNECTOR"
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR = "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR"
|
||||
|
||||
|
||||
class LiteLLMProvider(str, Enum):
|
||||
|
|
@ -142,6 +144,43 @@ class LogStatus(str, Enum):
|
|||
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):
|
||||
"""
|
||||
Granular permissions for search space resources.
|
||||
|
|
@ -936,6 +975,39 @@ class Notification(BaseModel, TimestampMixin):
|
|||
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):
|
||||
"""
|
||||
Custom roles that can be defined per search space.
|
||||
|
|
@ -1114,6 +1186,13 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
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
|
||||
pages_limit = Column(
|
||||
Integer,
|
||||
|
|
@ -1165,6 +1244,13 @@ else:
|
|||
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
|
||||
pages_limit = Column(
|
||||
Integer,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from .google_drive_add_connector_route import (
|
|||
from .google_gmail_add_connector_route import (
|
||||
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 .linear_add_connector_route import router as linear_add_connector_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(composio_router) # Composio OAuth and toolkit management
|
||||
router.include_router(public_chat_router) # Public chat sharing and cloning
|
||||
router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages
|
||||
|
|
|
|||
|
|
@ -8,16 +8,18 @@ Endpoints:
|
|||
- GET /composio/toolkits - List available Composio toolkits
|
||||
- GET /auth/composio/connector/add - Initiate OAuth for a specific toolkit
|
||||
- GET /auth/composio/connector/callback - Handle OAuth callback
|
||||
- GET /connectors/{connector_id}/composio-drive/folders - List folders/files for Composio Google Drive
|
||||
"""
|
||||
|
||||
import logging
|
||||
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 pydantic import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
|
|
@ -29,19 +31,31 @@ from app.db import (
|
|||
from app.services.composio_service import (
|
||||
COMPOSIO_TOOLKIT_NAMES,
|
||||
INDEXABLE_TOOLKITS,
|
||||
TOOLKIT_TO_CONNECTOR_TYPE,
|
||||
ComposioService,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
from app.utils.connector_naming import (
|
||||
check_duplicate_connector,
|
||||
generate_unique_connector_name,
|
||||
count_connectors_of_type,
|
||||
get_base_name_for_type,
|
||||
)
|
||||
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__)
|
||||
|
||||
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
|
||||
_state_manager = None
|
||||
|
||||
|
|
@ -166,11 +180,8 @@ async def initiate_composio_auth(
|
|||
|
||||
@router.get("/auth/composio/connector/callback")
|
||||
async def composio_callback(
|
||||
request: Request,
|
||||
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,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
|
|
@ -236,16 +247,17 @@ async def composio_callback(
|
|||
)
|
||||
|
||||
# Initialize Composio service
|
||||
ComposioService()
|
||||
service = ComposioService()
|
||||
|
||||
# Use camelCase param if provided (Composio's format), fallback to snake_case
|
||||
final_connected_account_id = (
|
||||
composio_connected_account_id or connected_account_id
|
||||
)
|
||||
# Extract connected_account_id from query params (accepts both camelCase and snake_case)
|
||||
query_params = request.query_params
|
||||
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(
|
||||
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
|
||||
|
|
@ -268,38 +280,89 @@ async def composio_callback(
|
|||
"is_indexable": toolkit_id in INDEXABLE_TOOLKITS,
|
||||
}
|
||||
|
||||
# Check for duplicate connector
|
||||
# For Composio, we use toolkit_id + connected_account_id as unique identifier
|
||||
identifier = final_connected_account_id or f"{toolkit_id}_{user_id}"
|
||||
# Get the specific connector type for this toolkit
|
||||
connector_type_str = TOOLKIT_TO_CONNECTOR_TYPE.get(toolkit_id)
|
||||
if not connector_type_str:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown toolkit: {toolkit_id}. Available: {list(TOOLKIT_TO_CONNECTOR_TYPE.keys())}",
|
||||
)
|
||||
connector_type = SearchSourceConnectorType(connector_type_str)
|
||||
|
||||
is_duplicate = await check_duplicate_connector(
|
||||
session,
|
||||
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
||||
space_id,
|
||||
user_id,
|
||||
identifier,
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
if is_duplicate:
|
||||
logger.warning(
|
||||
f"Duplicate Composio connector detected for user {user_id} with toolkit {toolkit_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(
|
||||
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(
|
||||
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:
|
||||
# Generate a unique, user-friendly connector name
|
||||
connector_name = await generate_unique_connector_name(
|
||||
session,
|
||||
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
||||
space_id,
|
||||
user_id,
|
||||
f"{toolkit_name} (Composio)",
|
||||
# Count existing connectors of this type to determine the number
|
||||
count = await count_connectors_of_type(
|
||||
session, connector_type, space_id, user_id
|
||||
)
|
||||
|
||||
# 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(
|
||||
name=connector_name,
|
||||
connector_type=SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
||||
connector_type=connector_type,
|
||||
config=connector_config,
|
||||
search_space_id=space_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}"
|
||||
)
|
||||
|
||||
# Get the frontend connector ID based on toolkit_id
|
||||
frontend_connector_id = TOOLKIT_TO_FRONTEND_CONNECTOR_ID.get(
|
||||
toolkit_id, "composio-connector"
|
||||
)
|
||||
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:
|
||||
|
|
@ -339,3 +406,136 @@ async def composio_callback(
|
|||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to complete Composio OAuth: {e!s}"
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/connectors/{connector_id}/composio-drive/folders")
|
||||
async def list_composio_drive_folders(
|
||||
connector_id: int,
|
||||
parent_id: str | None = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
List folders AND files in user's Google Drive via Composio with hierarchical support.
|
||||
|
||||
This is called at index time from the manage connector page to display
|
||||
the complete file system (folders and files). Only folders are selectable.
|
||||
|
||||
Args:
|
||||
connector_id: ID of the Composio Google Drive connector
|
||||
parent_id: Optional parent folder ID to list contents (None for root)
|
||||
|
||||
Returns:
|
||||
JSON with list of items: {
|
||||
"items": [
|
||||
{"id": str, "name": str, "mimeType": str, "isFolder": bool, ...},
|
||||
...
|
||||
]
|
||||
}
|
||||
"""
|
||||
if not ComposioService.is_enabled():
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Composio integration is not enabled.",
|
||||
)
|
||||
|
||||
try:
|
||||
# Get connector and verify ownership
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id,
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
if not connector:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Composio Google Drive connector not found or access denied",
|
||||
)
|
||||
|
||||
# Get Composio connected account ID from config
|
||||
composio_connected_account_id = connector.config.get(
|
||||
"composio_connected_account_id"
|
||||
)
|
||||
if not composio_connected_account_id:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Composio connected account not found. Please reconnect the connector.",
|
||||
)
|
||||
|
||||
# Initialize Composio service and fetch files
|
||||
service = ComposioService()
|
||||
entity_id = f"surfsense_{user.id}"
|
||||
|
||||
# Fetch files/folders from Composio Google Drive
|
||||
files, _next_token, error = await service.get_drive_files(
|
||||
connected_account_id=composio_connected_account_id,
|
||||
entity_id=entity_id,
|
||||
folder_id=parent_id,
|
||||
page_size=100,
|
||||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to list Composio Drive files: {error}")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to list folder contents: {error}"
|
||||
)
|
||||
|
||||
# Transform files to match the expected format with isFolder field
|
||||
items = []
|
||||
for file_info in files:
|
||||
file_id = file_info.get("id", "") or file_info.get("fileId", "")
|
||||
file_name = (
|
||||
file_info.get("name", "") or file_info.get("fileName", "") or "Untitled"
|
||||
)
|
||||
mime_type = file_info.get("mimeType", "") or file_info.get("mime_type", "")
|
||||
|
||||
if not file_id:
|
||||
continue
|
||||
|
||||
is_folder = mime_type == "application/vnd.google-apps.folder"
|
||||
|
||||
items.append(
|
||||
{
|
||||
"id": file_id,
|
||||
"name": file_name,
|
||||
"mimeType": mime_type,
|
||||
"isFolder": is_folder,
|
||||
"parents": file_info.get("parents", []),
|
||||
"size": file_info.get("size"),
|
||||
"iconLink": file_info.get("iconLink"),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort: folders first, then files, both alphabetically
|
||||
folders = sorted(
|
||||
[item for item in items if item["isFolder"]],
|
||||
key=lambda x: x["name"].lower(),
|
||||
)
|
||||
files_list = sorted(
|
||||
[item for item in items if not item["isFolder"]],
|
||||
key=lambda x: x["name"].lower(),
|
||||
)
|
||||
items = folders + files_list
|
||||
|
||||
folder_count = len(folders)
|
||||
file_count = len(files_list)
|
||||
|
||||
logger.info(
|
||||
f"Listed {len(items)} total items ({folder_count} folders, {file_count} files) for Composio connector {connector_id}"
|
||||
+ (f" in folder {parent_id}" if parent_id else " in ROOT")
|
||||
)
|
||||
|
||||
return {"items": items}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing Composio Drive contents: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to list Drive contents: {e!s}"
|
||||
) from e
|
||||
|
|
|
|||
|
|
@ -402,7 +402,7 @@ async def list_google_drive_folders(
|
|||
file_count = len(items) - folder_count
|
||||
|
||||
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")
|
||||
)
|
||||
|
||||
|
|
|
|||
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
131
surfsense_backend/app/routes/incentive_tasks_routes.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""
|
||||
Incentive Tasks API routes.
|
||||
Allows users to complete tasks (like starring GitHub repo) to earn free pages.
|
||||
Each task can only be completed once per user.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db import (
|
||||
INCENTIVE_TASKS_CONFIG,
|
||||
IncentiveTaskType,
|
||||
User,
|
||||
UserIncentiveTask,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas.incentive_tasks import (
|
||||
CompleteTaskResponse,
|
||||
IncentiveTaskInfo,
|
||||
IncentiveTasksResponse,
|
||||
TaskAlreadyCompletedResponse,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
router = APIRouter(prefix="/incentive-tasks", tags=["incentive-tasks"])
|
||||
|
||||
|
||||
@router.get("", response_model=IncentiveTasksResponse)
|
||||
async def get_incentive_tasks(
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> IncentiveTasksResponse:
|
||||
"""
|
||||
Get all available incentive tasks with the user's completion status.
|
||||
"""
|
||||
# Get all completed tasks for this user
|
||||
result = await session.execute(
|
||||
select(UserIncentiveTask).where(UserIncentiveTask.user_id == user.id)
|
||||
)
|
||||
completed_tasks = {task.task_type: task for task in result.scalars().all()}
|
||||
|
||||
# Build task list with completion status
|
||||
tasks = []
|
||||
total_pages_earned = 0
|
||||
|
||||
for task_type, config in INCENTIVE_TASKS_CONFIG.items():
|
||||
completed_task = completed_tasks.get(task_type)
|
||||
is_completed = completed_task is not None
|
||||
|
||||
if is_completed:
|
||||
total_pages_earned += completed_task.pages_awarded
|
||||
|
||||
tasks.append(
|
||||
IncentiveTaskInfo(
|
||||
task_type=task_type,
|
||||
title=config["title"],
|
||||
description=config["description"],
|
||||
pages_reward=config["pages_reward"],
|
||||
action_url=config["action_url"],
|
||||
completed=is_completed,
|
||||
completed_at=completed_task.completed_at if completed_task else None,
|
||||
)
|
||||
)
|
||||
|
||||
return IncentiveTasksResponse(
|
||||
tasks=tasks,
|
||||
total_pages_earned=total_pages_earned,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{task_type}/complete",
|
||||
response_model=CompleteTaskResponse | TaskAlreadyCompletedResponse,
|
||||
)
|
||||
async def complete_task(
|
||||
task_type: IncentiveTaskType,
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> CompleteTaskResponse | TaskAlreadyCompletedResponse:
|
||||
"""
|
||||
Mark an incentive task as completed and award pages to the user.
|
||||
|
||||
Each task can only be completed once. If the task was already completed,
|
||||
returns the existing completion information without awarding additional pages.
|
||||
"""
|
||||
# Validate task type exists in config
|
||||
task_config = INCENTIVE_TASKS_CONFIG.get(task_type)
|
||||
if not task_config:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unknown task type: {task_type}",
|
||||
)
|
||||
|
||||
# Check if task was already completed
|
||||
existing_task = await session.execute(
|
||||
select(UserIncentiveTask).where(
|
||||
UserIncentiveTask.user_id == user.id,
|
||||
UserIncentiveTask.task_type == task_type,
|
||||
)
|
||||
)
|
||||
existing = existing_task.scalar_one_or_none()
|
||||
|
||||
if existing:
|
||||
return TaskAlreadyCompletedResponse(
|
||||
success=False,
|
||||
message="Task already completed",
|
||||
completed_at=existing.completed_at,
|
||||
)
|
||||
|
||||
# Create the task completion record
|
||||
pages_reward = task_config["pages_reward"]
|
||||
new_task = UserIncentiveTask(
|
||||
user_id=user.id,
|
||||
task_type=task_type,
|
||||
pages_awarded=pages_reward,
|
||||
)
|
||||
session.add(new_task)
|
||||
|
||||
# Update user's pages_limit
|
||||
user.pages_limit += pages_reward
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
|
||||
return CompleteTaskResponse(
|
||||
success=True,
|
||||
message=f"Task completed! You earned {pages_reward} pages.",
|
||||
pages_awarded=pages_reward,
|
||||
new_pages_limit=user.pages_limit,
|
||||
)
|
||||
|
|
@ -59,6 +59,58 @@ router = APIRouter()
|
|||
|
||||
# ============ Permissions Endpoints ============
|
||||
|
||||
# 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)
|
||||
async def list_all_permissions(
|
||||
|
|
@ -71,12 +123,14 @@ async def list_all_permissions(
|
|||
for perm in Permission:
|
||||
# Extract category from permission value (e.g., "documents:read" -> "documents")
|
||||
category = perm.value.split(":")[0] if ":" in perm.value else "general"
|
||||
description = PERMISSION_DESCRIPTIONS.get(perm.value, f"Permission for {perm.value}")
|
||||
|
||||
permissions.append(
|
||||
PermissionInfo(
|
||||
value=perm.value,
|
||||
name=perm.name,
|
||||
category=category,
|
||||
description=description,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import logging
|
|||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
from dateutil.parser import isoparse
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
|
@ -47,6 +49,7 @@ from app.schemas import (
|
|||
SearchSourceConnectorRead,
|
||||
SearchSourceConnectorUpdate,
|
||||
)
|
||||
from app.services.composio_service import ComposioService
|
||||
from app.services.notification_service import NotificationService
|
||||
from app.tasks.connector_indexers import (
|
||||
index_airtable_records,
|
||||
|
|
@ -529,6 +532,38 @@ async def delete_search_source_connector(
|
|||
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.commit()
|
||||
return {"message": "Search source connector deleted successfully"}
|
||||
|
|
@ -611,32 +646,59 @@ async def index_connector_content(
|
|||
|
||||
# Handle different connector types
|
||||
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
|
||||
if start_date is None:
|
||||
# Use last_indexed_at or default to 365 days ago
|
||||
if connector.last_indexed_at:
|
||||
today = datetime.now().date()
|
||||
if connector.last_indexed_at.date() == today:
|
||||
# If last indexed today, go back 1 day to ensure we don't miss anything
|
||||
indexing_from = (today - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||
else:
|
||||
indexing_from = connector.last_indexed_at.strftime("%Y-%m-%d")
|
||||
else:
|
||||
indexing_from = (datetime.now() - timedelta(days=365)).strftime(
|
||||
"%Y-%m-%d"
|
||||
# Convert last_indexed_at to timezone-naive for comparison (like calculate_date_range does)
|
||||
last_indexed_naive = (
|
||||
connector.last_indexed_at.replace(tzinfo=None)
|
||||
if connector.last_indexed_at.tzinfo
|
||||
else connector.last_indexed_at
|
||||
)
|
||||
# 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:
|
||||
indexing_from = start_date
|
||||
|
||||
# For calendar connectors, default to today but allow future dates if explicitly provided
|
||||
if connector.connector_type in [
|
||||
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
|
||||
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR,
|
||||
SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||
]:
|
||||
# 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
|
||||
|
||||
# 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:
|
||||
# For non-calendar connectors, cap at today
|
||||
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."
|
||||
|
||||
elif connector.connector_type == SearchSourceConnectorType.COMPOSIO_CONNECTOR:
|
||||
elif (
|
||||
connector.connector_type
|
||||
== SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR
|
||||
):
|
||||
from app.tasks.celery_tasks.connector_tasks import (
|
||||
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(
|
||||
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()
|
||||
|
||||
if connector:
|
||||
connector.last_indexed_at = datetime.now()
|
||||
connector.last_indexed_at = datetime.now(
|
||||
UTC
|
||||
) # Use UTC for timezone consistency
|
||||
await session.commit()
|
||||
logger.info(f"Updated last_indexed_at for connector {connector_id}")
|
||||
except Exception as e:
|
||||
|
|
@ -1083,18 +1202,24 @@ async def _run_indexing_with_notifications(
|
|||
)
|
||||
|
||||
await update_timestamp_func(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
logger.info(
|
||||
f"Indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
|
||||
# Update notification on success
|
||||
# Update notification on success (or partial success with errors)
|
||||
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(
|
||||
session=session,
|
||||
notification=notification,
|
||||
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:
|
||||
# Update notification to storing stage
|
||||
if notification:
|
||||
|
|
@ -1110,24 +1235,73 @@ async def _run_indexing_with_notifications(
|
|||
f"Indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
if notification:
|
||||
# Refresh notification to ensure it's not stale after indexing function commits
|
||||
await session.refresh(notification)
|
||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=session,
|
||||
notification=notification,
|
||||
indexed_count=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:
|
||||
# No new documents processed - check if this is an error or just no changes
|
||||
if error_or_warning:
|
||||
# Actual failure
|
||||
logger.error(f"Indexing failed: {error_or_warning}")
|
||||
if notification:
|
||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=session,
|
||||
notification=notification,
|
||||
indexed_count=0,
|
||||
error_message=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
|
||||
logger.error(f"Indexing failed: {error_or_warning}")
|
||||
if notification:
|
||||
# Refresh notification to ensure it's not stale after indexing function commits
|
||||
await session.refresh(notification)
|
||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=session,
|
||||
notification=notification,
|
||||
indexed_count=0,
|
||||
error_message=error_or_warning,
|
||||
)
|
||||
await (
|
||||
session.commit()
|
||||
) # Commit to ensure Electric SQL syncs the notification update
|
||||
else:
|
||||
# Success - just no new documents to index (all skipped/unchanged)
|
||||
logger.info(
|
||||
|
|
@ -1136,13 +1310,19 @@ async def _run_indexing_with_notifications(
|
|||
# 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)
|
||||
await NotificationService.connector_indexing.notify_indexing_completed(
|
||||
session=session,
|
||||
notification=notification,
|
||||
indexed_count=0,
|
||||
error_message=None, # No error - sync succeeded
|
||||
)
|
||||
await (
|
||||
session.commit()
|
||||
) # Commit to ensure Electric SQL syncs the notification update
|
||||
except Exception as e:
|
||||
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
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ async def read_search_spaces(
|
|||
result = await session.execute(
|
||||
select(SearchSpace)
|
||||
.filter(SearchSpace.user_id == user.id)
|
||||
.order_by(SearchSpace.id.asc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
|
|
@ -138,6 +139,7 @@ async def read_search_spaces(
|
|||
select(SearchSpace)
|
||||
.join(SearchSpaceMembership)
|
||||
.filter(SearchSpaceMembership.user_id == user.id)
|
||||
.order_by(SearchSpace.id.asc())
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
|
|
|
|||
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
61
surfsense_backend/app/schemas/incentive_tasks.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""
|
||||
Schemas for incentive tasks API.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db import INCENTIVE_TASKS_CONFIG, IncentiveTaskType
|
||||
|
||||
|
||||
class IncentiveTaskInfo(BaseModel):
|
||||
"""Information about an available incentive task."""
|
||||
|
||||
task_type: IncentiveTaskType
|
||||
title: str
|
||||
description: str
|
||||
pages_reward: int
|
||||
action_url: str
|
||||
completed: bool
|
||||
completed_at: datetime | None = None
|
||||
|
||||
|
||||
class IncentiveTasksResponse(BaseModel):
|
||||
"""Response containing all available incentive tasks with completion status."""
|
||||
|
||||
tasks: list[IncentiveTaskInfo]
|
||||
total_pages_earned: int
|
||||
|
||||
|
||||
class CompleteTaskRequest(BaseModel):
|
||||
"""Request to mark a task as completed."""
|
||||
|
||||
task_type: IncentiveTaskType
|
||||
|
||||
|
||||
class CompleteTaskResponse(BaseModel):
|
||||
"""Response after completing a task."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
pages_awarded: int
|
||||
new_pages_limit: int
|
||||
|
||||
|
||||
class TaskAlreadyCompletedResponse(BaseModel):
|
||||
"""Response when task was already completed."""
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
completed_at: datetime
|
||||
|
||||
|
||||
def get_task_info(task_type: IncentiveTaskType) -> dict | None:
|
||||
"""Get task configuration by type."""
|
||||
return INCENTIVE_TASKS_CONFIG.get(task_type)
|
||||
|
||||
|
||||
def get_all_task_types() -> list[IncentiveTaskType]:
|
||||
"""Get all configured task types."""
|
||||
return list(INCENTIVE_TASKS_CONFIG.keys())
|
||||
|
|
@ -167,6 +167,7 @@ class PermissionInfo(BaseModel):
|
|||
value: str
|
||||
name: str
|
||||
category: str
|
||||
description: str
|
||||
|
||||
|
||||
class PermissionsListResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -39,21 +39,73 @@ COMPOSIO_TOOLKIT_NAMES = {
|
|||
# Toolkits that support indexing (Phase 1: Google services only)
|
||||
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:
|
||||
"""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.
|
||||
|
||||
Args:
|
||||
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
|
||||
if not self.api_key:
|
||||
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
|
||||
def is_enabled() -> bool:
|
||||
|
|
@ -252,7 +304,6 @@ class ComposioService:
|
|||
}
|
||||
)
|
||||
|
||||
logger.info(f"DEBUG: Found {len(result)} TOTAL connections in Composio")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list all connections: {e!s}")
|
||||
|
|
@ -269,7 +320,6 @@ class ComposioService:
|
|||
List of connected account details.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"DEBUG: Calling 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
|
||||
|
|
@ -312,6 +362,30 @@ class ComposioService:
|
|||
logger.error(f"Failed to list connections for user {user_id}: {e!s}")
|
||||
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(
|
||||
self,
|
||||
connected_account_id: str,
|
||||
|
|
@ -338,7 +412,6 @@ class ComposioService:
|
|||
# - connected_account_id: for authentication
|
||||
# - user_id: user identifier (SDK uses user_id, not entity_id)
|
||||
# - 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(
|
||||
slug=tool_name,
|
||||
connected_account_id=connected_account_id,
|
||||
|
|
@ -346,8 +419,6 @@ class ComposioService:
|
|||
arguments=params or {},
|
||||
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}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to execute tool {tool_name}: {e!s}")
|
||||
|
|
@ -382,7 +453,15 @@ class ComposioService:
|
|||
"page_size": min(page_size, 100),
|
||||
}
|
||||
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:
|
||||
params["page_token"] = page_token
|
||||
|
||||
|
|
@ -397,9 +476,6 @@ class ComposioService:
|
|||
return [], None, result.get("error", "Unknown error")
|
||||
|
||||
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
|
||||
files = []
|
||||
|
|
@ -415,7 +491,6 @@ class ComposioService:
|
|||
elif isinstance(data, list):
|
||||
files = data
|
||||
|
||||
logger.info(f"DEBUG: Extracted {len(files)} drive files")
|
||||
return files, next_token, None
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -428,6 +503,10 @@ class ComposioService:
|
|||
"""
|
||||
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:
|
||||
connected_account_id: Composio connected account ID.
|
||||
entity_id: The entity/user ID that owns the connected account.
|
||||
|
|
@ -436,27 +515,264 @@ class ComposioService:
|
|||
Returns:
|
||||
Tuple of (file content bytes, error message).
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
result = await self.execute_tool(
|
||||
connected_account_id=connected_account_id,
|
||||
tool_name="GOOGLEDRIVE_DOWNLOAD_FILE",
|
||||
params={"file_id": file_id}, # snake_case
|
||||
params={"file_id": file_id},
|
||||
entity_id=entity_id,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return None, result.get("error", "Unknown error")
|
||||
|
||||
content = result.get("data")
|
||||
if isinstance(content, str):
|
||||
content = content.encode("utf-8")
|
||||
data = result.get("data")
|
||||
if not data:
|
||||
return None, "No data returned from Composio"
|
||||
|
||||
return content, None
|
||||
# Per Composio docs, response includes file_path where file was downloaded
|
||||
# Response structure: {data: {...}, error: ..., successful: ...}
|
||||
# The actual file info is nested inside data["data"]
|
||||
file_path = None
|
||||
|
||||
if isinstance(data, dict):
|
||||
# Handle nested response structure: data contains {data, error, successful}
|
||||
# The actual file info is in data["data"]
|
||||
inner_data = data
|
||||
if "data" in data and isinstance(data["data"], dict):
|
||||
inner_data = data["data"]
|
||||
logger.debug(
|
||||
f"Found nested data structure. Inner keys: {list(inner_data.keys())}"
|
||||
)
|
||||
elif "successful" in data and "data" in data:
|
||||
# Standard Composio response wrapper
|
||||
inner_data = data["data"] if data["data"] else data
|
||||
|
||||
# Try documented fields: file_path, downloaded_file_content, path, uri
|
||||
file_path = (
|
||||
inner_data.get("file_path")
|
||||
or inner_data.get("downloaded_file_content")
|
||||
or inner_data.get("path")
|
||||
or inner_data.get("uri")
|
||||
)
|
||||
|
||||
# Handle nested dict case where downloaded_file_content contains the path
|
||||
if isinstance(file_path, dict):
|
||||
file_path = (
|
||||
file_path.get("file_path")
|
||||
or file_path.get("downloaded_file_content")
|
||||
or file_path.get("path")
|
||||
or file_path.get("uri")
|
||||
)
|
||||
|
||||
# If still no path, check if inner_data itself has the nested structure
|
||||
if not file_path and isinstance(inner_data, dict):
|
||||
for key in ["downloaded_file_content", "file_path", "path", "uri"]:
|
||||
if key in inner_data:
|
||||
val = inner_data[key]
|
||||
if isinstance(val, str):
|
||||
file_path = val
|
||||
break
|
||||
elif isinstance(val, dict):
|
||||
# One more level of nesting
|
||||
file_path = (
|
||||
val.get("file_path")
|
||||
or val.get("downloaded_file_content")
|
||||
or val.get("path")
|
||||
or val.get("uri")
|
||||
)
|
||||
if file_path:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
f"Composio response keys: {list(data.keys())}, inner keys: {list(inner_data.keys()) if isinstance(inner_data, dict) else 'N/A'}, extracted path: {file_path}"
|
||||
)
|
||||
elif isinstance(data, str):
|
||||
# Direct string response (could be path or content)
|
||||
file_path = data
|
||||
elif isinstance(data, bytes):
|
||||
# Direct bytes response
|
||||
return data, None
|
||||
|
||||
# Read file from the path
|
||||
if file_path and isinstance(file_path, str):
|
||||
path_obj = Path(file_path)
|
||||
|
||||
# Check if it's a valid file path (absolute or in .composio directory)
|
||||
if path_obj.is_absolute() or ".composio" in str(path_obj):
|
||||
try:
|
||||
if path_obj.exists():
|
||||
content = path_obj.read_bytes()
|
||||
logger.info(
|
||||
f"Successfully read {len(content)} bytes from Composio file: {file_path}"
|
||||
)
|
||||
return content, None
|
||||
else:
|
||||
logger.warning(
|
||||
f"File path from Composio does not exist: {file_path}"
|
||||
)
|
||||
return None, f"File not found at path: {file_path}"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to read file from Composio path {file_path}: {e!s}"
|
||||
)
|
||||
return None, f"Failed to read file: {e!s}"
|
||||
else:
|
||||
# Not a file path - might be base64 encoded content
|
||||
try:
|
||||
import base64
|
||||
|
||||
content = base64.b64decode(file_path)
|
||||
return content, None
|
||||
except Exception:
|
||||
# Not base64, return as UTF-8 bytes
|
||||
return file_path.encode("utf-8"), None
|
||||
|
||||
# If we got here, couldn't extract file path
|
||||
if isinstance(data, dict):
|
||||
# Log full structure for debugging
|
||||
inner_data = data.get("data", {})
|
||||
logger.warning(
|
||||
f"Could not extract file path from Composio response. "
|
||||
f"Top keys: {list(data.keys())}, "
|
||||
f"Inner data keys: {list(inner_data.keys()) if isinstance(inner_data, dict) else type(inner_data).__name__}, "
|
||||
f"Full inner data: {inner_data}"
|
||||
)
|
||||
return (
|
||||
None,
|
||||
f"No file path in Composio response. Keys: {list(data.keys())}, inner: {list(inner_data.keys()) if isinstance(inner_data, dict) else 'N/A'}",
|
||||
)
|
||||
|
||||
return None, f"Unexpected data type from Composio: {type(data).__name__}"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Drive file content: {e!s}")
|
||||
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 =====
|
||||
|
||||
async def get_gmail_messages(
|
||||
|
|
@ -464,25 +780,30 @@ class ComposioService:
|
|||
connected_account_id: str,
|
||||
entity_id: str,
|
||||
query: str = "",
|
||||
max_results: int = 100,
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
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.
|
||||
List Gmail messages via Composio with pagination support.
|
||||
|
||||
Args:
|
||||
connected_account_id: Composio connected account ID.
|
||||
entity_id: The entity/user ID that owns the connected account.
|
||||
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:
|
||||
Tuple of (messages list, error message).
|
||||
Tuple of (messages list, next_page_token, result_size_estimate, error message).
|
||||
"""
|
||||
try:
|
||||
# Composio uses snake_case for parameters, max is 500
|
||||
params = {"max_results": min(max_results, 500)}
|
||||
# Use smaller batch size to avoid 413 payload too large errors
|
||||
# Composio uses snake_case for parameters
|
||||
params = {"max_results": min(max_results, 50)} # Reduced from 500 to 50
|
||||
if query:
|
||||
params["query"] = query # Composio uses 'query' not 'q'
|
||||
if page_token:
|
||||
params["page_token"] = page_token
|
||||
|
||||
result = await self.execute_tool(
|
||||
connected_account_id=connected_account_id,
|
||||
|
|
@ -492,31 +813,42 @@ class ComposioService:
|
|||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return [], result.get("error", "Unknown error")
|
||||
return [], None, result.get("error", "Unknown error")
|
||||
|
||||
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
|
||||
messages = []
|
||||
next_token = None
|
||||
result_size_estimate = None
|
||||
if isinstance(data, dict):
|
||||
messages = (
|
||||
data.get("messages", [])
|
||||
or data.get("data", {}).get("messages", [])
|
||||
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):
|
||||
messages = data
|
||||
|
||||
logger.info(f"DEBUG: Extracted {len(messages)} messages")
|
||||
return messages, None
|
||||
return messages, next_token, result_size_estimate, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list Gmail messages: {e!s}")
|
||||
return [], str(e)
|
||||
return [], None, str(e)
|
||||
|
||||
async def get_gmail_message_detail(
|
||||
self, connected_account_id: str, entity_id: str, message_id: str
|
||||
|
|
@ -595,10 +927,6 @@ class ComposioService:
|
|||
return [], result.get("error", "Unknown error")
|
||||
|
||||
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
|
||||
events = []
|
||||
|
|
@ -611,7 +939,6 @@ class ComposioService:
|
|||
elif isinstance(data, list):
|
||||
events = data
|
||||
|
||||
logger.info(f"DEBUG: Extracted {len(events)} calendar events")
|
||||
return events, None
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -2871,3 +2871,350 @@ class ConnectorService:
|
|||
}
|
||||
|
||||
return result_object, obsidian_docs
|
||||
|
||||
# =========================================================================
|
||||
# Composio Connector Search Methods
|
||||
# =========================================================================
|
||||
|
||||
async def search_composio_google_drive(
|
||||
self,
|
||||
user_query: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Search for Composio Google Drive files and return both the source information
|
||||
and langchain documents.
|
||||
|
||||
Uses combined chunk-level and document-level hybrid search with RRF fusion.
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
start_date: Optional start date for filtering documents by updated_at
|
||||
end_date: Optional end date for filtering documents by updated_at
|
||||
|
||||
Returns:
|
||||
tuple: (sources_info, langchain_documents)
|
||||
"""
|
||||
composio_drive_docs = await self._combined_rrf_search(
|
||||
query_text=user_query,
|
||||
search_space_id=search_space_id,
|
||||
document_type="COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
top_k=top_k,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# Early return if no results
|
||||
if not composio_drive_docs:
|
||||
return {
|
||||
"id": 54,
|
||||
"name": "Google Drive (Composio)",
|
||||
"type": "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
"sources": [],
|
||||
}, []
|
||||
|
||||
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
return (
|
||||
doc_info.get("title")
|
||||
or metadata.get("title")
|
||||
or metadata.get("file_name")
|
||||
or "Untitled Document"
|
||||
)
|
||||
|
||||
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
return metadata.get("url") or metadata.get("web_view_link") or ""
|
||||
|
||||
def _description_fn(
|
||||
chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
|
||||
) -> str:
|
||||
description = self._chunk_preview(chunk.get("content", ""), limit=200)
|
||||
info_parts = []
|
||||
mime_type = metadata.get("mime_type")
|
||||
modified_time = metadata.get("modified_time")
|
||||
if mime_type:
|
||||
info_parts.append(f"Type: {mime_type}")
|
||||
if modified_time:
|
||||
info_parts.append(f"Modified: {modified_time}")
|
||||
if info_parts:
|
||||
description = (description + " | " + " | ".join(info_parts)).strip(" |")
|
||||
return description
|
||||
|
||||
def _extra_fields_fn(
|
||||
_chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"mime_type": metadata.get("mime_type", ""),
|
||||
"file_id": metadata.get("file_id", ""),
|
||||
"modified_time": metadata.get("modified_time", ""),
|
||||
}
|
||||
|
||||
sources_list = self._build_chunk_sources_from_documents(
|
||||
composio_drive_docs,
|
||||
title_fn=_title_fn,
|
||||
url_fn=_url_fn,
|
||||
description_fn=_description_fn,
|
||||
extra_fields_fn=_extra_fields_fn,
|
||||
)
|
||||
|
||||
# Create result object
|
||||
result_object = {
|
||||
"id": 54,
|
||||
"name": "Google Drive (Composio)",
|
||||
"type": "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
"sources": sources_list,
|
||||
}
|
||||
|
||||
return result_object, composio_drive_docs
|
||||
|
||||
async def search_composio_gmail(
|
||||
self,
|
||||
user_query: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Search for Composio Gmail messages and return both the source information
|
||||
and langchain documents.
|
||||
|
||||
Uses combined chunk-level and document-level hybrid search with RRF fusion.
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
start_date: Optional start date for filtering documents by updated_at
|
||||
end_date: Optional end date for filtering documents by updated_at
|
||||
|
||||
Returns:
|
||||
tuple: (sources_info, langchain_documents)
|
||||
"""
|
||||
composio_gmail_docs = await self._combined_rrf_search(
|
||||
query_text=user_query,
|
||||
search_space_id=search_space_id,
|
||||
document_type="COMPOSIO_GMAIL_CONNECTOR",
|
||||
top_k=top_k,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# Early return if no results
|
||||
if not composio_gmail_docs:
|
||||
return {
|
||||
"id": 55,
|
||||
"name": "Gmail (Composio)",
|
||||
"type": "COMPOSIO_GMAIL_CONNECTOR",
|
||||
"sources": [],
|
||||
}, []
|
||||
|
||||
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
return (
|
||||
doc_info.get("title")
|
||||
or metadata.get("subject")
|
||||
or metadata.get("title")
|
||||
or "Untitled Email"
|
||||
)
|
||||
|
||||
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
return metadata.get("url") or ""
|
||||
|
||||
def _description_fn(
|
||||
chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
|
||||
) -> str:
|
||||
description = self._chunk_preview(chunk.get("content", ""), limit=200)
|
||||
info_parts = []
|
||||
sender = metadata.get("from") or metadata.get("sender")
|
||||
date = metadata.get("date") or metadata.get("received_at")
|
||||
if sender:
|
||||
info_parts.append(f"From: {sender}")
|
||||
if date:
|
||||
info_parts.append(f"Date: {date}")
|
||||
if info_parts:
|
||||
description = (description + " | " + " | ".join(info_parts)).strip(" |")
|
||||
return description
|
||||
|
||||
def _extra_fields_fn(
|
||||
_chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"message_id": metadata.get("message_id", ""),
|
||||
"thread_id": metadata.get("thread_id", ""),
|
||||
"from": metadata.get("from", ""),
|
||||
"to": metadata.get("to", ""),
|
||||
"date": metadata.get("date", ""),
|
||||
}
|
||||
|
||||
sources_list = self._build_chunk_sources_from_documents(
|
||||
composio_gmail_docs,
|
||||
title_fn=_title_fn,
|
||||
url_fn=_url_fn,
|
||||
description_fn=_description_fn,
|
||||
extra_fields_fn=_extra_fields_fn,
|
||||
)
|
||||
|
||||
# Create result object
|
||||
result_object = {
|
||||
"id": 55,
|
||||
"name": "Gmail (Composio)",
|
||||
"type": "COMPOSIO_GMAIL_CONNECTOR",
|
||||
"sources": sources_list,
|
||||
}
|
||||
|
||||
return result_object, composio_gmail_docs
|
||||
|
||||
async def search_composio_google_calendar(
|
||||
self,
|
||||
user_query: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Search for Composio Google Calendar events and return both the source information
|
||||
and langchain documents.
|
||||
|
||||
Uses combined chunk-level and document-level hybrid search with RRF fusion.
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
start_date: Optional start date for filtering documents by updated_at
|
||||
end_date: Optional end date for filtering documents by updated_at
|
||||
|
||||
Returns:
|
||||
tuple: (sources_info, langchain_documents)
|
||||
"""
|
||||
composio_calendar_docs = await self._combined_rrf_search(
|
||||
query_text=user_query,
|
||||
search_space_id=search_space_id,
|
||||
document_type="COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
top_k=top_k,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# Early return if no results
|
||||
if not composio_calendar_docs:
|
||||
return {
|
||||
"id": 56,
|
||||
"name": "Google Calendar (Composio)",
|
||||
"type": "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
"sources": [],
|
||||
}, []
|
||||
|
||||
def _title_fn(doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
return (
|
||||
doc_info.get("title")
|
||||
or metadata.get("summary")
|
||||
or metadata.get("title")
|
||||
or "Untitled Event"
|
||||
)
|
||||
|
||||
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
return metadata.get("url") or metadata.get("html_link") or ""
|
||||
|
||||
def _description_fn(
|
||||
chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
|
||||
) -> str:
|
||||
description = self._chunk_preview(chunk.get("content", ""), limit=200)
|
||||
info_parts = []
|
||||
start_time = metadata.get("start_time") or metadata.get("start")
|
||||
end_time = metadata.get("end_time") or metadata.get("end")
|
||||
if start_time:
|
||||
info_parts.append(f"Start: {start_time}")
|
||||
if end_time:
|
||||
info_parts.append(f"End: {end_time}")
|
||||
if info_parts:
|
||||
description = (description + " | " + " | ".join(info_parts)).strip(" |")
|
||||
return description
|
||||
|
||||
def _extra_fields_fn(
|
||||
_chunk: dict[str, Any], _doc_info: dict[str, Any], metadata: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"event_id": metadata.get("event_id", ""),
|
||||
"calendar_id": metadata.get("calendar_id", ""),
|
||||
"start_time": metadata.get("start_time", ""),
|
||||
"end_time": metadata.get("end_time", ""),
|
||||
"location": metadata.get("location", ""),
|
||||
}
|
||||
|
||||
sources_list = self._build_chunk_sources_from_documents(
|
||||
composio_calendar_docs,
|
||||
title_fn=_title_fn,
|
||||
url_fn=_url_fn,
|
||||
description_fn=_description_fn,
|
||||
extra_fields_fn=_extra_fields_fn,
|
||||
)
|
||||
|
||||
# Create result object
|
||||
result_object = {
|
||||
"id": 56,
|
||||
"name": "Google Calendar (Composio)",
|
||||
"type": "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
"sources": sources_list,
|
||||
}
|
||||
|
||||
return result_object, composio_calendar_docs
|
||||
|
||||
# =========================================================================
|
||||
# Utility Methods for Connector Discovery
|
||||
# =========================================================================
|
||||
|
||||
async def get_available_connectors(
|
||||
self,
|
||||
search_space_id: int,
|
||||
) -> list[SearchSourceConnectorType]:
|
||||
"""
|
||||
Get all available (enabled) connector types for a search space.
|
||||
|
||||
Args:
|
||||
search_space_id: The search space ID
|
||||
|
||||
Returns:
|
||||
List of SearchSourceConnectorType enums for enabled connectors
|
||||
"""
|
||||
query = (
|
||||
select(SearchSourceConnector.connector_type)
|
||||
.filter(
|
||||
SearchSourceConnector.search_space_id == search_space_id,
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
connector_types = result.scalars().all()
|
||||
return list(connector_types)
|
||||
|
||||
async def get_available_document_types(
|
||||
self,
|
||||
search_space_id: int,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get all document types that have at least one document in the search space.
|
||||
|
||||
Args:
|
||||
search_space_id: The search space ID
|
||||
|
||||
Returns:
|
||||
List of document type strings that have documents indexed
|
||||
"""
|
||||
from sqlalchemy import distinct
|
||||
|
||||
from app.db import Document
|
||||
|
||||
query = select(distinct(Document.document_type)).filter(
|
||||
Document.search_space_id == search_space_id,
|
||||
)
|
||||
|
||||
result = await self.session.execute(query)
|
||||
doc_types = result.scalars().all()
|
||||
return [str(dt) for dt in doc_types]
|
||||
|
|
|
|||
|
|
@ -335,6 +335,7 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
|||
notification: Notification,
|
||||
indexed_count: int,
|
||||
error_message: str | None = None,
|
||||
is_warning: bool = False,
|
||||
) -> Notification:
|
||||
"""
|
||||
Update notification when connector indexing completes.
|
||||
|
|
@ -343,7 +344,8 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
|||
session: Database session
|
||||
notification: Notification to update
|
||||
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:
|
||||
Updated notification
|
||||
|
|
@ -352,10 +354,26 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
|||
"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:
|
||||
title = f"Failed: {connector_name}"
|
||||
message = f"Sync failed: {error_message}"
|
||||
status = "failed"
|
||||
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}"
|
||||
message = f"Sync failed: {error_message}"
|
||||
status = "failed"
|
||||
else:
|
||||
title = f"Ready: {connector_name}"
|
||||
if indexed_count == 0:
|
||||
|
|
@ -367,7 +385,9 @@ class ConnectorIndexingNotificationHandler(BaseNotificationHandler):
|
|||
|
||||
metadata_updates = {
|
||||
"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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -810,8 +810,8 @@ def index_composio_connector_task(
|
|||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
):
|
||||
"""Celery task to index Composio connector content (Google Drive, Gmail, Calendar via Composio)."""
|
||||
import asyncio
|
||||
|
|
@ -833,14 +833,16 @@ async def _index_composio_connector(
|
|||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
):
|
||||
"""Index Composio connector content with new session."""
|
||||
# Import from tasks folder (not connector_indexers) to avoid circular import
|
||||
from app.tasks.composio_indexer import index_composio_connector
|
||||
"""Index Composio connector content with new session and real-time notifications."""
|
||||
# Import from routes to use the notification-wrapped version
|
||||
from app.routes.search_source_connectors_routes import (
|
||||
run_composio_indexing,
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ async def _check_and_trigger_schedules():
|
|||
from app.tasks.celery_tasks.connector_tasks import (
|
||||
index_airtable_records_task,
|
||||
index_clickup_tasks_task,
|
||||
index_composio_connector_task,
|
||||
index_confluence_pages_task,
|
||||
index_crawled_urls_task,
|
||||
index_discord_messages_task,
|
||||
|
|
@ -98,6 +99,10 @@ async def _check_and_trigger_schedules():
|
|||
SearchSourceConnectorType.ELASTICSEARCH_CONNECTOR: index_elasticsearch_documents_task,
|
||||
SearchSourceConnectorType.WEBCRAWLER_CONNECTOR: index_crawled_urls_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
|
||||
|
|
|
|||
|
|
@ -54,21 +54,68 @@ def format_attachments_as_context(attachments: list[ChatAttachment]) -> 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:
|
||||
return ""
|
||||
|
||||
context_parts = ["<mentioned_documents>"]
|
||||
context_parts.append(
|
||||
"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(
|
||||
f"<document index='{i}' id='{doc.id}' title='{doc.title}' type='{doc.document_type.value}'>"
|
||||
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(f"<![CDATA[{doc.content}]]>")
|
||||
|
||||
context_parts.append("<document>")
|
||||
context_parts.append("<document_metadata>")
|
||||
context_parts.append(f" <document_id>{doc.id}</document_id>")
|
||||
context_parts.append(
|
||||
f" <document_type>{doc.document_type.value}</document_type>"
|
||||
)
|
||||
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("")
|
||||
|
||||
context_parts.append("</mentioned_documents>")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
|
@ -81,8 +128,6 @@ def format_mentioned_surfsense_docs_as_context(
|
|||
if not documents:
|
||||
return ""
|
||||
|
||||
import json
|
||||
|
||||
context_parts = ["<mentioned_surfsense_docs>"]
|
||||
context_parts.append(
|
||||
"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
|
||||
langchain_messages = []
|
||||
|
||||
# Fetch mentioned documents if any
|
||||
# Fetch mentioned documents if any (with chunks for proper citations)
|
||||
mentioned_documents: list[Document] = []
|
||||
if mentioned_document_ids:
|
||||
from sqlalchemy.orm import selectinload as doc_selectinload
|
||||
|
||||
result = await session.execute(
|
||||
select(Document).filter(
|
||||
select(Document)
|
||||
.options(doc_selectinload(Document.chunks))
|
||||
.filter(
|
||||
Document.id.in_(mentioned_document_ids),
|
||||
Document.search_space_id == search_space_id,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,83 +2,76 @@
|
|||
Composio connector indexer.
|
||||
|
||||
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/)
|
||||
to avoid circular import issues with the connector_indexers package.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from importlib import import_module
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
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,
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
)
|
||||
from app.services.composio_service import INDEXABLE_TOOLKITS
|
||||
from app.services.llm_service import get_user_long_context_llm
|
||||
from app.services.composio_service import INDEXABLE_TOOLKITS, TOOLKIT_TO_INDEXER
|
||||
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
|
||||
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:
|
||||
"""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()
|
||||
# ============ Utility functions ============
|
||||
|
||||
|
||||
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:
|
||||
"""Get a connector by ID and type from the database."""
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id,
|
||||
SearchSourceConnector.connector_type == connector_type,
|
||||
)
|
||||
"""Get a connector by ID and optionally by type from the database."""
|
||||
query = select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id
|
||||
)
|
||||
if connector_type is not None:
|
||||
query = query.filter(SearchSourceConnector.connector_type == connector_type)
|
||||
result = await session.execute(query)
|
||||
return result.scalars().first()
|
||||
|
||||
|
||||
async def update_connector_last_indexed(
|
||||
session: AsyncSession,
|
||||
connector: SearchSourceConnector,
|
||||
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()
|
||||
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
||||
def get_indexer_function(toolkit_id: str):
|
||||
"""
|
||||
Dynamically import and return the indexer function for a toolkit.
|
||||
|
||||
Args:
|
||||
toolkit_id: The toolkit ID (e.g., "googledrive", "gmail")
|
||||
|
||||
Returns:
|
||||
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 ============
|
||||
|
|
@ -98,6 +91,7 @@ async def index_composio_connector(
|
|||
Index content from a Composio connector.
|
||||
|
||||
Routes to toolkit-specific indexing based on the connector's toolkit_id.
|
||||
Uses a registry pattern for clean, extensible connector routing.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
|
|
@ -129,10 +123,16 @@ async def index_composio_connector(
|
|||
)
|
||||
|
||||
try:
|
||||
# Get connector by id
|
||||
connector = await get_connector_by_id(
|
||||
session, connector_id, SearchSourceConnectorType.COMPOSIO_CONNECTOR
|
||||
)
|
||||
# Get connector by id - accept any Composio connector type
|
||||
connector = await get_connector_by_id(session, connector_id, None)
|
||||
|
||||
# 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:
|
||||
error_msg = f"Composio connector with ID {connector_id} not found"
|
||||
|
|
@ -160,53 +160,35 @@ async def index_composio_connector(
|
|||
)
|
||||
return 0, error_msg
|
||||
|
||||
# Route to toolkit-specific indexer
|
||||
if toolkit_id == "googledrive":
|
||||
return await _index_composio_google_drive(
|
||||
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,
|
||||
)
|
||||
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}"
|
||||
# Get indexer function from registry
|
||||
try:
|
||||
indexer_func, supports_date_filter = get_indexer_function(toolkit_id)
|
||||
except ValueError as e:
|
||||
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:
|
||||
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)
|
||||
return 0, f"Failed to index Composio connector: {e!s}"
|
||||
|
||||
|
||||
async def _index_composio_google_drive(
|
||||
session: AsyncSession,
|
||||
connector,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
task_logger: TaskLoggingService,
|
||||
log_entry,
|
||||
update_last_indexed: bool = True,
|
||||
max_items: int = 1000,
|
||||
) -> tuple[int, str]:
|
||||
"""Index Google Drive files via Composio."""
|
||||
try:
|
||||
composio_connector = ComposioConnector(session, connector_id)
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Google Drive files via Composio for connector {connector_id}",
|
||||
{"stage": "fetching_files"},
|
||||
)
|
||||
|
||||
# Fetch files
|
||||
all_files = []
|
||||
page_token = None
|
||||
|
||||
while len(all_files) < max_items:
|
||||
files, next_token, error = await composio_connector.list_drive_files(
|
||||
page_token=page_token,
|
||||
page_size=min(100, max_items - len(all_files)),
|
||||
)
|
||||
|
||||
if error:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, f"Failed to fetch Drive files: {error}", {}
|
||||
)
|
||||
return 0, f"Failed to fetch Drive files: {error}"
|
||||
|
||||
all_files.extend(files)
|
||||
|
||||
if not next_token:
|
||||
break
|
||||
page_token = next_token
|
||||
|
||||
if not all_files:
|
||||
success_msg = "No Google Drive files found"
|
||||
await task_logger.log_task_success(
|
||||
log_entry, success_msg, {"files_count": 0}
|
||||
)
|
||||
return 0, success_msg
|
||||
|
||||
logger.info(f"Found {len(all_files)} Google Drive files to index via Composio")
|
||||
|
||||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
|
||||
for file_info in all_files:
|
||||
try:
|
||||
# Handle both standard Google API and potential Composio variations
|
||||
file_id = file_info.get("id", "") or file_info.get("fileId", "")
|
||||
file_name = (
|
||||
file_info.get("name", "")
|
||||
or file_info.get("fileName", "")
|
||||
or "Untitled"
|
||||
)
|
||||
mime_type = file_info.get("mimeType", "") or file_info.get(
|
||||
"mime_type", ""
|
||||
)
|
||||
|
||||
if not file_id:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Skip folders
|
||||
if mime_type == "application/vnd.google-apps.folder":
|
||||
continue
|
||||
|
||||
# Generate unique identifier hash
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.COMPOSIO_CONNECTOR, f"drive_{file_id}", search_space_id
|
||||
)
|
||||
|
||||
# Check if document exists
|
||||
existing_document = await check_document_by_unique_identifier(
|
||||
session, unique_identifier_hash
|
||||
)
|
||||
|
||||
# Get file content
|
||||
(
|
||||
content,
|
||||
content_error,
|
||||
) = await composio_connector.get_drive_file_content(file_id)
|
||||
|
||||
if content_error or not content:
|
||||
logger.warning(
|
||||
f"Could not get content for file {file_name}: {content_error}"
|
||||
)
|
||||
# Use metadata as content fallback
|
||||
markdown_content = f"# {file_name}\n\n"
|
||||
markdown_content += f"**File ID:** {file_id}\n"
|
||||
markdown_content += f"**Type:** {mime_type}\n"
|
||||
else:
|
||||
try:
|
||||
markdown_content = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
markdown_content = f"# {file_name}\n\n[Binary file content]\n"
|
||||
|
||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||
|
||||
if existing_document:
|
||||
if existing_document.content_hash == content_hash:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Update existing document
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"file_id": file_id,
|
||||
"file_name": file_name,
|
||||
"mime_type": mime_type,
|
||||
"document_type": "Google Drive File (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
existing_document.title = f"Drive: {file_name}"
|
||||
existing_document.content = summary_content
|
||||
existing_document.content_hash = content_hash
|
||||
existing_document.embedding = summary_embedding
|
||||
existing_document.document_metadata = {
|
||||
"file_id": file_id,
|
||||
"file_name": file_name,
|
||||
"mime_type": mime_type,
|
||||
"connector_id": connector_id,
|
||||
"source": "composio",
|
||||
}
|
||||
existing_document.chunks = chunks
|
||||
existing_document.updated_at = get_current_timestamp()
|
||||
|
||||
documents_indexed += 1
|
||||
continue
|
||||
|
||||
# Create new document
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"file_id": file_id,
|
||||
"file_name": file_name,
|
||||
"mime_type": mime_type,
|
||||
"document_type": "Google Drive File (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Drive: {file_name}",
|
||||
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
||||
document_metadata={
|
||||
"file_id": file_id,
|
||||
"file_name": file_name,
|
||||
"mime_type": mime_type,
|
||||
"connector_id": connector_id,
|
||||
"toolkit_id": "googledrive",
|
||||
"source": "composio",
|
||||
},
|
||||
content=summary_content,
|
||||
content_hash=content_hash,
|
||||
unique_identifier_hash=unique_identifier_hash,
|
||||
embedding=summary_embedding,
|
||||
chunks=chunks,
|
||||
updated_at=get_current_timestamp(),
|
||||
)
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
|
||||
if documents_indexed % 10 == 0:
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Drive file: {e!s}", exc_info=True)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
if documents_indexed > 0:
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed Google Drive indexing via Composio for connector {connector_id}",
|
||||
{
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
},
|
||||
)
|
||||
|
||||
return documents_indexed, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to index Google Drive via Composio: {e!s}", exc_info=True)
|
||||
return 0, f"Failed to index Google Drive via Composio: {e!s}"
|
||||
|
||||
|
||||
async def _index_composio_gmail(
|
||||
session: AsyncSession,
|
||||
connector,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
task_logger: TaskLoggingService,
|
||||
log_entry,
|
||||
update_last_indexed: bool = True,
|
||||
max_items: int = 1000,
|
||||
) -> tuple[int, str]:
|
||||
"""Index Gmail messages via Composio."""
|
||||
try:
|
||||
composio_connector = ComposioConnector(session, connector_id)
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Gmail messages via Composio for connector {connector_id}",
|
||||
{"stage": "fetching_messages"},
|
||||
)
|
||||
|
||||
# Build query with date range
|
||||
query_parts = []
|
||||
if start_date:
|
||||
query_parts.append(f"after:{start_date.replace('-', '/')}")
|
||||
if end_date:
|
||||
query_parts.append(f"before:{end_date.replace('-', '/')}")
|
||||
query = " ".join(query_parts)
|
||||
|
||||
messages, error = await composio_connector.list_gmail_messages(
|
||||
query=query,
|
||||
max_results=max_items,
|
||||
)
|
||||
|
||||
if error:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, f"Failed to fetch Gmail messages: {error}", {}
|
||||
)
|
||||
return 0, f"Failed to fetch Gmail messages: {error}"
|
||||
|
||||
if not messages:
|
||||
success_msg = "No Gmail messages found in the specified date range"
|
||||
await task_logger.log_task_success(
|
||||
log_entry, success_msg, {"messages_count": 0}
|
||||
)
|
||||
return 0, success_msg
|
||||
|
||||
logger.info(f"Found {len(messages)} Gmail messages to index via Composio")
|
||||
|
||||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
|
||||
for message in messages:
|
||||
try:
|
||||
# Composio uses 'messageId' (camelCase), not 'id'
|
||||
message_id = message.get("messageId", "") or message.get("id", "")
|
||||
if not message_id:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Composio's GMAIL_FETCH_EMAILS already returns full message content
|
||||
# No need for a separate detail API call
|
||||
|
||||
# Extract message info from Composio response
|
||||
# Composio structure: messageId, messageText, messageTimestamp, payload.headers, labelIds
|
||||
payload = message.get("payload", {})
|
||||
headers = payload.get("headers", [])
|
||||
|
||||
subject = "No Subject"
|
||||
sender = "Unknown Sender"
|
||||
date_str = message.get("messageTimestamp", "Unknown Date")
|
||||
|
||||
for header in headers:
|
||||
name = header.get("name", "").lower()
|
||||
value = header.get("value", "")
|
||||
if name == "subject":
|
||||
subject = value
|
||||
elif name == "from":
|
||||
sender = value
|
||||
elif name == "date":
|
||||
date_str = value
|
||||
|
||||
# Format to markdown using the full message data
|
||||
markdown_content = composio_connector.format_gmail_message_to_markdown(
|
||||
message
|
||||
)
|
||||
|
||||
# Generate unique identifier
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.COMPOSIO_CONNECTOR,
|
||||
f"gmail_{message_id}",
|
||||
search_space_id,
|
||||
)
|
||||
|
||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||
|
||||
existing_document = await check_document_by_unique_identifier(
|
||||
session, unique_identifier_hash
|
||||
)
|
||||
|
||||
# Get label IDs from Composio response
|
||||
label_ids = message.get("labelIds", [])
|
||||
|
||||
if existing_document:
|
||||
if existing_document.content_hash == content_hash:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Update existing
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"message_id": message_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"document_type": "Gmail Message (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
existing_document.title = f"Gmail: {subject}"
|
||||
existing_document.content = summary_content
|
||||
existing_document.content_hash = content_hash
|
||||
existing_document.embedding = summary_embedding
|
||||
existing_document.document_metadata = {
|
||||
"message_id": message_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"date": date_str,
|
||||
"labels": label_ids,
|
||||
"connector_id": connector_id,
|
||||
"source": "composio",
|
||||
}
|
||||
existing_document.chunks = chunks
|
||||
existing_document.updated_at = get_current_timestamp()
|
||||
|
||||
documents_indexed += 1
|
||||
continue
|
||||
|
||||
# Create new document
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"message_id": message_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"document_type": "Gmail Message (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||
)
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Gmail: {subject}",
|
||||
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
||||
document_metadata={
|
||||
"message_id": message_id,
|
||||
"subject": subject,
|
||||
"sender": sender,
|
||||
"date": date_str,
|
||||
"labels": label_ids,
|
||||
"connector_id": connector_id,
|
||||
"toolkit_id": "gmail",
|
||||
"source": "composio",
|
||||
},
|
||||
content=summary_content,
|
||||
content_hash=content_hash,
|
||||
unique_identifier_hash=unique_identifier_hash,
|
||||
embedding=summary_embedding,
|
||||
chunks=chunks,
|
||||
updated_at=get_current_timestamp(),
|
||||
)
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
|
||||
if documents_indexed % 10 == 0:
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Gmail message: {e!s}", exc_info=True)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
if documents_indexed > 0:
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed Gmail indexing via Composio for connector {connector_id}",
|
||||
{
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
},
|
||||
)
|
||||
|
||||
return documents_indexed, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to index Gmail via Composio: {e!s}", exc_info=True)
|
||||
return 0, f"Failed to index Gmail via Composio: {e!s}"
|
||||
|
||||
|
||||
async def _index_composio_google_calendar(
|
||||
session: AsyncSession,
|
||||
connector,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str | None,
|
||||
end_date: str | None,
|
||||
task_logger: TaskLoggingService,
|
||||
log_entry,
|
||||
update_last_indexed: bool = True,
|
||||
max_items: int = 2500,
|
||||
) -> tuple[int, str]:
|
||||
"""Index Google Calendar events via Composio."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
try:
|
||||
composio_connector = ComposioConnector(session, connector_id)
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Google Calendar events via Composio for connector {connector_id}",
|
||||
{"stage": "fetching_events"},
|
||||
)
|
||||
|
||||
# Build time range
|
||||
if start_date:
|
||||
time_min = f"{start_date}T00:00:00Z"
|
||||
else:
|
||||
# Default to 365 days ago
|
||||
default_start = datetime.now() - timedelta(days=365)
|
||||
time_min = default_start.strftime("%Y-%m-%dT00:00:00Z")
|
||||
|
||||
if end_date:
|
||||
time_max = f"{end_date}T23:59:59Z"
|
||||
else:
|
||||
time_max = datetime.now().strftime("%Y-%m-%dT23:59:59Z")
|
||||
|
||||
events, error = await composio_connector.list_calendar_events(
|
||||
time_min=time_min,
|
||||
time_max=time_max,
|
||||
max_results=max_items,
|
||||
)
|
||||
|
||||
if error:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry, f"Failed to fetch Calendar events: {error}", {}
|
||||
)
|
||||
return 0, f"Failed to fetch Calendar events: {error}"
|
||||
|
||||
if not events:
|
||||
success_msg = "No Google Calendar events found in the specified date range"
|
||||
await task_logger.log_task_success(
|
||||
log_entry, success_msg, {"events_count": 0}
|
||||
)
|
||||
return 0, success_msg
|
||||
|
||||
logger.info(f"Found {len(events)} Google Calendar events to index via Composio")
|
||||
|
||||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
# Handle both standard Google API and potential Composio variations
|
||||
event_id = event.get("id", "") or event.get("eventId", "")
|
||||
summary = (
|
||||
event.get("summary", "") or event.get("title", "") or "No Title"
|
||||
)
|
||||
|
||||
if not event_id:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Format to markdown
|
||||
markdown_content = composio_connector.format_calendar_event_to_markdown(
|
||||
event
|
||||
)
|
||||
|
||||
# Generate unique identifier
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.COMPOSIO_CONNECTOR,
|
||||
f"calendar_{event_id}",
|
||||
search_space_id,
|
||||
)
|
||||
|
||||
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||
|
||||
existing_document = await check_document_by_unique_identifier(
|
||||
session, unique_identifier_hash
|
||||
)
|
||||
|
||||
# Extract event times
|
||||
start = event.get("start", {})
|
||||
end = event.get("end", {})
|
||||
start_time = start.get("dateTime") or start.get("date", "")
|
||||
end_time = end.get("dateTime") or end.get("date", "")
|
||||
location = event.get("location", "")
|
||||
|
||||
if existing_document:
|
||||
if existing_document.content_hash == content_hash:
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Update existing
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"document_type": "Google Calendar Event (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||
if location:
|
||||
summary_content += f"\nLocation: {location}"
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
existing_document.title = f"Calendar: {summary}"
|
||||
existing_document.content = summary_content
|
||||
existing_document.content_hash = content_hash
|
||||
existing_document.embedding = summary_embedding
|
||||
existing_document.document_metadata = {
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"connector_id": connector_id,
|
||||
"source": "composio",
|
||||
}
|
||||
existing_document.chunks = chunks
|
||||
existing_document.updated_at = get_current_timestamp()
|
||||
|
||||
documents_indexed += 1
|
||||
continue
|
||||
|
||||
# Create new document
|
||||
user_llm = await get_user_long_context_llm(
|
||||
session, user_id, search_space_id
|
||||
)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"document_type": "Google Calendar Event (Composio)",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
markdown_content, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
summary_content = (
|
||||
f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||
)
|
||||
if location:
|
||||
summary_content += f"\nLocation: {location}"
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(markdown_content)
|
||||
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Calendar: {summary}",
|
||||
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
||||
document_metadata={
|
||||
"event_id": event_id,
|
||||
"summary": summary,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"location": location,
|
||||
"connector_id": connector_id,
|
||||
"toolkit_id": "googlecalendar",
|
||||
"source": "composio",
|
||||
},
|
||||
content=summary_content,
|
||||
content_hash=content_hash,
|
||||
unique_identifier_hash=unique_identifier_hash,
|
||||
embedding=summary_embedding,
|
||||
chunks=chunks,
|
||||
updated_at=get_current_timestamp(),
|
||||
)
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
|
||||
if documents_indexed % 10 == 0:
|
||||
await session.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing Calendar event: {e!s}", exc_info=True)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
if documents_indexed > 0:
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed Google Calendar indexing via Composio for connector {connector_id}",
|
||||
{
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
},
|
||||
)
|
||||
|
||||
return documents_indexed, None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True
|
||||
)
|
||||
return 0, f"Failed to index Google Calendar via Composio: {e!s}"
|
||||
|
|
|
|||
|
|
@ -112,6 +112,13 @@ def calculate_date_range(
|
|||
Returns:
|
||||
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:
|
||||
return start_date, end_date
|
||||
|
||||
|
|
|
|||
|
|
@ -136,10 +136,9 @@ async def index_bookstack_pages(
|
|||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to get BookStack pages: {error}")
|
||||
|
||||
# Don't treat "No pages found" as an error that should stop indexing
|
||||
if "No pages found" in error:
|
||||
logger.info(f"No BookStack pages found: {error}")
|
||||
logger.info(
|
||||
"No pages found is not a critical error, continuing with update"
|
||||
)
|
||||
|
|
@ -159,6 +158,7 @@ async def index_bookstack_pages(
|
|||
)
|
||||
return 0, None
|
||||
else:
|
||||
logger.error(f"Failed to get BookStack pages: {error}")
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to get BookStack pages: {error}",
|
||||
|
|
|
|||
|
|
@ -120,10 +120,9 @@ async def index_confluence_pages(
|
|||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to get Confluence pages: {error}")
|
||||
|
||||
# Don't treat "No pages found" as an error that should stop indexing
|
||||
if "No pages found" in error:
|
||||
logger.info(f"No Confluence pages found: {error}")
|
||||
logger.info(
|
||||
"No pages found is not a critical error, continuing with update"
|
||||
)
|
||||
|
|
@ -147,6 +146,7 @@ async def index_confluence_pages(
|
|||
await confluence_client.close()
|
||||
return 0, None
|
||||
else:
|
||||
logger.error(f"Failed to get Confluence pages: {error}")
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to get Confluence pages: {error}",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ Google Calendar connector indexer.
|
|||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from dateutil.parser import isoparse
|
||||
from google.oauth2.credentials import Credentials
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -21,6 +23,7 @@ from app.utils.document_converters import (
|
|||
|
||||
from .base import (
|
||||
check_document_by_unique_identifier,
|
||||
check_duplicate_document_by_hash,
|
||||
get_connector_by_id,
|
||||
get_current_timestamp,
|
||||
logger,
|
||||
|
|
@ -206,6 +209,23 @@ async def index_google_calendar_events(
|
|||
start_date_str = start_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(
|
||||
log_entry,
|
||||
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:
|
||||
logger.error(f"Failed to get Google Calendar events: {error}")
|
||||
|
||||
# Don't treat "No events found" as an error that should stop indexing
|
||||
if "No events found" in error:
|
||||
logger.info(f"No Google Calendar events found: {error}")
|
||||
logger.info(
|
||||
"No events found is not a critical error, continuing with update"
|
||||
)
|
||||
|
|
@ -246,13 +265,25 @@ async def index_google_calendar_events(
|
|||
)
|
||||
return 0, None
|
||||
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(
|
||||
log_entry,
|
||||
f"Failed to get Google Calendar events: {error}",
|
||||
"API Error",
|
||||
{"error_type": "APIError"},
|
||||
error_message,
|
||||
error,
|
||||
{"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")
|
||||
|
||||
|
|
@ -263,6 +294,9 @@ async def index_google_calendar_events(
|
|||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
skipped_events = []
|
||||
duplicate_content_count = (
|
||||
0 # Track events skipped due to duplicate content_hash
|
||||
)
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
|
|
@ -383,6 +417,27 @@ async def index_google_calendar_events(
|
|||
)
|
||||
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
|
||||
# Generate summary with metadata
|
||||
user_llm = await get_user_long_context_llm(
|
||||
|
|
@ -475,7 +530,28 @@ async def index_google_calendar_events(
|
|||
logger.info(
|
||||
f"Final commit: Total {documents_indexed} Google Calendar events processed"
|
||||
)
|
||||
await session.commit()
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
# Handle any remaining integrity errors gracefully (race conditions, etc.)
|
||||
if (
|
||||
"duplicate key value violates unique constraint" in str(e).lower()
|
||||
or "uniqueviolationerror" in str(e).lower()
|
||||
):
|
||||
logger.warning(
|
||||
f"Duplicate content_hash detected during final commit. "
|
||||
f"This may occur if the same event was indexed by multiple connectors. "
|
||||
f"Rolling back and continuing. Error: {e!s}"
|
||||
)
|
||||
await session.rollback()
|
||||
# Don't fail the entire task - some documents may have been successfully indexed
|
||||
else:
|
||||
raise
|
||||
|
||||
# Build warning message if duplicates were found
|
||||
warning_message = None
|
||||
if duplicate_content_count > 0:
|
||||
warning_message = f"{duplicate_content_count} skipped (duplicate)"
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
|
|
@ -484,14 +560,16 @@ async def index_google_calendar_events(
|
|||
"events_processed": total_processed,
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
"duplicate_content_count": duplicate_content_count,
|
||||
"skipped_events_count": len(skipped_events),
|
||||
},
|
||||
)
|
||||
|
||||
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:
|
||||
await session.rollback()
|
||||
|
|
|
|||
|
|
@ -578,7 +578,7 @@ async def _check_rename_only_update(
|
|||
- (True, message): Only filename changed, document was updated
|
||||
- (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 app.db import Document
|
||||
|
|
@ -603,7 +603,8 @@ async def _check_rename_only_update(
|
|||
select(Document).where(
|
||||
Document.search_space_id == search_space_id,
|
||||
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()
|
||||
|
|
@ -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.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import String, cast, select
|
||||
|
||||
from app.db import Document
|
||||
|
||||
|
|
@ -774,7 +775,8 @@ async def _remove_document(session: AsyncSession, file_id: str, search_space_id:
|
|||
select(Document).where(
|
||||
Document.search_space_id == search_space_id,
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -170,10 +170,21 @@ async def index_google_gmail_messages(
|
|||
)
|
||||
|
||||
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(
|
||||
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:
|
||||
success_msg = "No Google gmail messages found in the specified date range"
|
||||
|
|
|
|||
|
|
@ -126,10 +126,9 @@ async def index_jira_issues(
|
|||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to get Jira issues: {error}")
|
||||
|
||||
# Don't treat "No issues found" as an error that should stop indexing
|
||||
if "No issues found" in error:
|
||||
logger.info(f"No Jira issues found: {error}")
|
||||
logger.info(
|
||||
"No issues found is not a critical error, continuing with update"
|
||||
)
|
||||
|
|
@ -149,6 +148,7 @@ async def index_jira_issues(
|
|||
)
|
||||
return 0, None
|
||||
else:
|
||||
logger.error(f"Failed to get Jira issues: {error}")
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to get Jira issues: {error}",
|
||||
|
|
|
|||
|
|
@ -145,10 +145,9 @@ async def index_linear_issues(
|
|||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to get Linear issues: {error}")
|
||||
|
||||
# Don't treat "No issues found" as an error that should stop indexing
|
||||
if "No issues found" in error:
|
||||
logger.info(f"No Linear issues found: {error}")
|
||||
logger.info(
|
||||
"No issues found is not a critical error, continuing with update"
|
||||
)
|
||||
|
|
@ -162,6 +161,7 @@ async def index_linear_issues(
|
|||
)
|
||||
return 0, None
|
||||
else:
|
||||
logger.error(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")
|
||||
|
|
|
|||
|
|
@ -116,6 +116,13 @@ async def index_luma_events(
|
|||
|
||||
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
|
||||
# For calendar connectors, allow future dates to index upcoming events
|
||||
if start_date is None or end_date is None:
|
||||
|
|
@ -172,10 +179,9 @@ async def index_luma_events(
|
|||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to get Luma events: {error}")
|
||||
|
||||
# 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():
|
||||
logger.info(f"No Luma events found: {error}")
|
||||
logger.info(
|
||||
"No events found is not a critical error, continuing with update"
|
||||
)
|
||||
|
|
@ -195,6 +201,7 @@ async def index_luma_events(
|
|||
)
|
||||
return 0, None
|
||||
else:
|
||||
logger.error(f"Failed to get Luma events: {error}")
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to get Luma events: {error}",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ BASE_NAME_FOR_TYPE = {
|
|||
SearchSourceConnectorType.CONFLUENCE_CONNECTOR: "Confluence",
|
||||
SearchSourceConnectorType.AIRTABLE_CONNECTOR: "Airtable",
|
||||
SearchSourceConnectorType.MCP_CONNECTOR: "Model Context Protocol (MCP)",
|
||||
SearchSourceConnectorType.COMPOSIO_GMAIL_CONNECTOR: "Gmail",
|
||||
SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||
SearchSourceConnectorType.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "surf-new-backend"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
description = "SurfSense Backend"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
|
|
|
|||
2
surfsense_backend/uv.lock
generated
2
surfsense_backend/uv.lock
generated
|
|
@ -6545,7 +6545,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "surf-new-backend"
|
||||
version = "0.0.11"
|
||||
version = "0.0.12"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "surfsense_browser_extension",
|
||||
"displayName": "Surfsense Browser Extension",
|
||||
"version": "0.0.11",
|
||||
"version": "0.0.12",
|
||||
"description": "Extension to collect Browsing History for SurfSense.",
|
||||
"author": "https://github.com/MODSetter",
|
||||
"engines": {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FooterNew } from "@/components/homepage/footer-new";
|
||||
import { Navbar } from "@/components/homepage/navbar";
|
||||
|
||||
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isAuthPage = pathname === "/login" || pathname === "/register";
|
||||
|
||||
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">
|
||||
<Navbar />
|
||||
{children}
|
||||
<FooterNew />
|
||||
{!isAuthPage && <FooterNew />}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
|
|||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { loginMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { ValidationError } from "@/lib/error";
|
||||
|
|
@ -42,9 +43,6 @@ export function LocalLoginForm() {
|
|||
// Track login attempt
|
||||
trackLoginAttempt("local");
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading(tCommon("loading"));
|
||||
|
||||
try {
|
||||
const data = await login({
|
||||
username,
|
||||
|
|
@ -62,8 +60,7 @@ export function LocalLoginForm() {
|
|||
|
||||
// Success toast
|
||||
toast.success(t("login_success"), {
|
||||
id: loadingToast,
|
||||
description: "Redirecting to dashboard...",
|
||||
description: "Redirecting to dashboard",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
|
|
@ -76,7 +73,6 @@ export function LocalLoginForm() {
|
|||
trackLoginFailure("local", err.message);
|
||||
setError({ title: err.name, message: err.message });
|
||||
toast.error(err.name, {
|
||||
id: loadingToast,
|
||||
description: err.message,
|
||||
duration: 6000,
|
||||
});
|
||||
|
|
@ -106,7 +102,6 @@ export function LocalLoginForm() {
|
|||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
|
@ -244,9 +239,16 @@ export function LocalLoginForm() {
|
|||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
|
|
@ -66,7 +66,11 @@ function LoginContent() {
|
|||
});
|
||||
|
||||
// Show toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
const toastOptions: {
|
||||
description: string;
|
||||
duration: number;
|
||||
action?: { label: string; onClick: () => void };
|
||||
} = {
|
||||
description: errorDescription,
|
||||
duration: 6000,
|
||||
};
|
||||
|
|
@ -95,20 +99,12 @@ function LoginContent() {
|
|||
setIsLoading(false);
|
||||
}, [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) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
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() {
|
||||
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Suspense fallback={null}>
|
||||
<LoginContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
|||
import { toast } from "sonner";
|
||||
import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AUTH_TYPE } from "@/lib/env-config";
|
||||
import { AppError, ValidationError } from "@/lib/error";
|
||||
|
|
@ -60,9 +61,6 @@ export default function RegisterPage() {
|
|||
// Track registration attempt
|
||||
trackRegistrationAttempt();
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading(t("creating_account"));
|
||||
|
||||
try {
|
||||
await register({
|
||||
email,
|
||||
|
|
@ -77,7 +75,6 @@ export default function RegisterPage() {
|
|||
|
||||
// Success toast
|
||||
toast.success(t("register_success"), {
|
||||
id: loadingToast,
|
||||
description: t("redirecting_login"),
|
||||
duration: 2000,
|
||||
});
|
||||
|
|
@ -95,7 +92,6 @@ export default function RegisterPage() {
|
|||
trackRegistrationFailure("Registration disabled");
|
||||
setError({ title: "Registration is disabled", message: friendlyMessage });
|
||||
toast.error("Registration is disabled", {
|
||||
id: loadingToast,
|
||||
description: friendlyMessage,
|
||||
duration: 6000,
|
||||
});
|
||||
|
|
@ -109,7 +105,6 @@ export default function RegisterPage() {
|
|||
trackRegistrationFailure(err.message);
|
||||
setError({ title: err.name, message: err.message });
|
||||
toast.error(err.name, {
|
||||
id: loadingToast,
|
||||
description: err.message,
|
||||
duration: 6000,
|
||||
});
|
||||
|
|
@ -137,7 +132,6 @@ export default function RegisterPage() {
|
|||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
|
@ -295,9 +289,16 @@ export default function RegisterPage() {
|
|||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</form>
|
||||
|
||||
|
|
|
|||
14
surfsense_web/app/auth/callback/loading.tsx
Normal file
14
surfsense_web/app/auth/callback/loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
export default function AuthCallbackLoading() {
|
||||
const t = useTranslations("auth");
|
||||
|
||||
// Use global loading - spinner animation won't reset when page transitions
|
||||
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
|
||||
|
||||
// Return null - the GlobalLoadingProvider handles the loading UI
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,23 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import TokenHandler from "@/components/TokenHandler";
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
// Suspense fallback returns null - the GlobalLoadingProvider handles the loading UI
|
||||
// TokenHandler uses useGlobalLoadingEffect to show the loading screen
|
||||
return (
|
||||
<div className="container mx-auto p-4">
|
||||
<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
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<TokenHandler
|
||||
redirectPath="/dashboard"
|
||||
tokenParamName="token"
|
||||
storageKey="surfsense_bearer_token"
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
|
|
@ -19,6 +18,7 @@ import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
|||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
|
|
@ -146,31 +146,22 @@ export function DashboardClientLayout({
|
|||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||
|
||||
if (
|
||||
// Determine if we should show loading
|
||||
const shouldShowLoading =
|
||||
(!hasCheckedOnboarding &&
|
||||
(loading || accessLoading || globalConfigsLoading) &&
|
||||
!isOnboardingPage) ||
|
||||
isAutoConfiguring
|
||||
) {
|
||||
return (
|
||||
<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">
|
||||
{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>
|
||||
);
|
||||
isAutoConfiguring;
|
||||
|
||||
// Use global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(
|
||||
shouldShowLoading,
|
||||
isAutoConfiguring ? t("setting_up_ai") : t("checking_llm_prefs"),
|
||||
"default"
|
||||
);
|
||||
|
||||
if (shouldShowLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"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 { useParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -9,6 +9,7 @@ import { useDocumentUploadDialog } from "@/components/assistant-ui/document-uplo
|
|||
import { DocumentViewer } from "@/components/document-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -114,7 +115,7 @@ export function DocumentsTableShell({
|
|||
{loading ? (
|
||||
<div className="flex h-[400px] w-full items-center justify-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ export function RowActions({
|
|||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
{isDeleting ? "Deleting" : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
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 { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
|
@ -21,6 +20,7 @@ import {
|
|||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
|
|
@ -78,7 +78,6 @@ function extractTitleFromBlockNote(blocknoteDocument: BlockNoteDocument): string
|
|||
export default function EditorPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const documentId = params.documentId as string;
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const isNewNote = documentId === "new";
|
||||
|
|
@ -349,8 +348,8 @@ export default function EditorPage() {
|
|||
<div className="flex items-center justify-center min-h-[400px] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 className="h-12 w-12 text-primary animate-spin mb-4" />
|
||||
<p className="text-muted-foreground">Loading editor...</p>
|
||||
<Spinner size="xl" className="text-primary mb-4" />
|
||||
<p className="text-muted-foreground">Loading editor</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -437,7 +436,7 @@ export default function EditorPage() {
|
|||
>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
"use client";
|
||||
|
||||
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||
import {
|
||||
trackIncentiveContactOpened,
|
||||
trackIncentivePageViewed,
|
||||
trackIncentiveTaskClicked,
|
||||
trackIncentiveTaskCompleted,
|
||||
} from "@/lib/posthog/events";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function MorePagesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Track page view on mount
|
||||
useEffect(() => {
|
||||
trackIncentivePageViewed();
|
||||
}, []);
|
||||
|
||||
// Fetch tasks from API
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["incentive-tasks"],
|
||||
queryFn: () => incentiveTasksApiService.getTasks(),
|
||||
});
|
||||
|
||||
// Mutation to complete a task
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: incentiveTasksApiService.completeTask,
|
||||
onSuccess: (response, taskType) => {
|
||||
if (response.success) {
|
||||
toast.success(response.message);
|
||||
// Track task completion
|
||||
const task = data?.tasks.find((t) => t.task_type === taskType);
|
||||
if (task) {
|
||||
trackIncentiveTaskCompleted(taskType, task.pages_reward);
|
||||
}
|
||||
// Invalidate queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ["incentive-tasks"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["user"] });
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to complete task. Please try again.");
|
||||
},
|
||||
});
|
||||
|
||||
const handleTaskClick = (task: IncentiveTaskInfo) => {
|
||||
if (!task.completed) {
|
||||
trackIncentiveTaskClicked(task.task_type);
|
||||
completeMutation.mutate(task.task_type);
|
||||
}
|
||||
};
|
||||
|
||||
const allCompleted = data?.tasks.every((t) => t.completed) ?? false;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-64px)] items-center justify-center px-4 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="w-full max-w-md"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-6 text-center">
|
||||
<Gift className="mx-auto mb-3 h-8 w-8 text-primary" />
|
||||
<h2 className="text-xl font-bold tracking-tight">Get More Pages</h2>
|
||||
<p className="text-sm text-muted-foreground">Complete tasks to earn additional pages</p>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
{isLoading ? (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<Skeleton className="h-9 w-9 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/4" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data?.tasks.map((task) => (
|
||||
<Card
|
||||
key={task.task_type}
|
||||
className={cn("transition-colors", task.completed && "bg-muted/50")}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full",
|
||||
task.completed ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{task.completed ? <Check className="h-4 w-4" /> : <Star className="h-4 w-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
task.completed && "text-muted-foreground line-through"
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">+{task.pages_reward} pages</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={task.completed ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
disabled={task.completed || completeMutation.isPending}
|
||||
onClick={() => handleTaskClick(task)}
|
||||
asChild={!task.completed}
|
||||
>
|
||||
{task.completed ? (
|
||||
<span>Done</span>
|
||||
) : (
|
||||
<a
|
||||
href={task.action_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="gap-1"
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Go
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact */}
|
||||
<Separator className="my-6" />
|
||||
<div className="text-center">
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{allCompleted ? "Thanks! Need even more pages?" : "Need more pages?"}
|
||||
</p>
|
||||
<Dialog onOpenChange={(open) => open && trackIncentiveContactOpened()}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Mail className="h-4 w-4" />
|
||||
Contact Us
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Contact Us</DialogTitle>
|
||||
<DialogDescription>Schedule a meeting or send us an email.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-4 py-4">
|
||||
<Link
|
||||
href="https://calendly.com/eric-surfsense/surfsense-meeting"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
<IconCalendar className="h-4 w-4" />
|
||||
Schedule a Meeting
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="h-px w-8 bg-border" />
|
||||
<span className="text-xs">or</span>
|
||||
<span className="h-px w-8 bg-border" />
|
||||
</div>
|
||||
<Link
|
||||
href="mailto:eric@surfsense.com"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground transition hover:text-foreground"
|
||||
>
|
||||
<IconMailFilled className="h-4 w-4" />
|
||||
eric@surfsense.com
|
||||
</Link>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
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 { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||
import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||
|
|
@ -132,6 +134,7 @@ interface ThinkingStepData {
|
|||
}
|
||||
|
||||
export default function NewChatPage() {
|
||||
const t = useTranslations("dashboard");
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
|
@ -1379,8 +1382,9 @@ export default function NewChatPage() {
|
|||
// Show loading state only when loading an existing thread
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-64px)] items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading chat...</div>
|
||||
<div className="flex h-[calc(100vh-64px)] flex-col items-center justify-center gap-4">
|
||||
<Spinner size="lg" />
|
||||
<div className="text-sm text-muted-foreground">{t("loading_chat")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
|
@ -17,6 +16,7 @@ import {
|
|||
import { Logo } from "@/components/Logo";
|
||||
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getBearerToken, redirectToLogin } from "@/lib/auth-utils";
|
||||
|
||||
export default function OnboardPage() {
|
||||
|
|
@ -156,7 +156,7 @@ export default function OnboardPage() {
|
|||
<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="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 className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
Bot,
|
||||
Brain,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
|
|
@ -15,6 +16,7 @@ import { AnimatePresence, motion } from "motion/react";
|
|||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GeneralSettingsManager } from "@/components/settings/general-settings-manager";
|
||||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||
import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
||||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||
|
|
@ -30,6 +32,12 @@ interface SettingsNavItem {
|
|||
}
|
||||
|
||||
const settingsNavItems: SettingsNavItem[] = [
|
||||
{
|
||||
id: "general",
|
||||
labelKey: "nav_general",
|
||||
descriptionKey: "nav_general_desc",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
labelKey: "nav_agent_configs",
|
||||
|
|
@ -262,6 +270,9 @@ function SettingsContent({
|
|||
ease: [0.4, 0, 0.2, 1],
|
||||
}}
|
||||
>
|
||||
{activeSection === "general" && (
|
||||
<GeneralSettingsManager searchSpaceId={searchSpaceId} />
|
||||
)}
|
||||
{activeSection === "models" && <ModelConfigManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "roles" && <LLMRoleManager searchSpaceId={searchSpaceId} />}
|
||||
{activeSection === "prompts" && <PromptConfigManager searchSpaceId={searchSpaceId} />}
|
||||
|
|
@ -277,7 +288,7 @@ export default function SettingsPage() {
|
|||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const [activeSection, setActiveSection] = useState("models");
|
||||
const [activeSection, setActiveSection] = useState("general");
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
// Track settings section view
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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";
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
|
|
@ -10,8 +10,12 @@ interface DashboardLayoutProps {
|
|||
}
|
||||
|
||||
export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const [isCheckingAuth, setIsCheckingAuth] = useState(true);
|
||||
|
||||
// Use the global loading screen - spinner animation won't reset
|
||||
useGlobalLoadingEffect(isCheckingAuth, t("checking_auth"), "default");
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is authenticated
|
||||
const token = getBearerToken();
|
||||
|
|
@ -23,21 +27,9 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) {
|
|||
setIsCheckingAuth(false);
|
||||
}, []);
|
||||
|
||||
// Show loading screen while checking authentication
|
||||
// Return null while loading - the global provider handles the loading UI
|
||||
if (isCheckingAuth) {
|
||||
return (
|
||||
<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 null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
14
surfsense_web/app/dashboard/loading.tsx
Normal file
14
surfsense_web/app/dashboard/loading.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
export default function DashboardLoading() {
|
||||
const t = useTranslations("common");
|
||||
|
||||
// Use global loading - spinner animation won't reset when page transitions
|
||||
useGlobalLoadingEffect(true, t("loading"), "default");
|
||||
|
||||
// Return null - the GlobalLoadingProvider handles the loading UI
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
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 { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
|
@ -18,37 +18,7 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
|
||||
function ErrorScreen({ message }: { message: string }) {
|
||||
const t = useTranslations("dashboard");
|
||||
|
|
@ -121,6 +91,7 @@ export default function DashboardPage() {
|
|||
const router = useRouter();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const t = useTranslations("dashboard");
|
||||
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -131,11 +102,16 @@ export default function DashboardPage() {
|
|||
}
|
||||
}, [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 (searchSpaces.length > 0) {
|
||||
return <LoadingScreen />;
|
||||
if (shouldShowLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Loader2, Menu, User } from "lucide-react";
|
||||
import { Menu, User } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -11,6 +11,7 @@ import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
interface ProfileContentProps {
|
||||
onMenuClick: () => void;
|
||||
|
|
@ -129,7 +130,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
|
|||
>
|
||||
{isUserLoading ? (
|
||||
<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>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
|
|
@ -166,7 +167,7 @@ export function ProfileContent({ onMenuClick }: ProfileContentProps) {
|
|||
|
||||
<div className="flex justify-end">
|
||||
<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")}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import {
|
|||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Loader2,
|
||||
LogIn,
|
||||
Shield,
|
||||
Sparkles,
|
||||
|
|
@ -30,6 +28,7 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { AcceptInviteResponse } from "@/contracts/types/invites.types";
|
||||
import { invitesApiService } from "@/lib/apis/invites-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
|
|
@ -164,7 +163,7 @@ export default function InviteAcceptPage() {
|
|||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
<Spinner size="xl" className="text-primary" />
|
||||
</motion.div>
|
||||
<p className="mt-4 text-muted-foreground">Loading invite details...</p>
|
||||
</CardContent>
|
||||
|
|
@ -353,7 +352,7 @@ export default function InviteAcceptPage() {
|
|||
<Button className="flex-1 gap-2" onClick={handleAccept} disabled={accepting}>
|
||||
{accepting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
Accepting...
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import "./globals.css";
|
|||
import { RootProvider } from "fumadocs-ui/provider/next";
|
||||
import { Roboto } from "next/font/google";
|
||||
import { ElectricProvider } from "@/components/providers/ElectricProvider";
|
||||
import { GlobalLoadingProvider } from "@/components/providers/GlobalLoadingProvider";
|
||||
import { I18nProvider } from "@/components/providers/I18nProvider";
|
||||
import { PostHogProvider } from "@/components/providers/PostHogProvider";
|
||||
import { ThemeProvider } from "@/components/theme/theme-provider";
|
||||
|
|
@ -104,7 +105,9 @@ export default function RootLayout({
|
|||
>
|
||||
<RootProvider>
|
||||
<ReactQueryClientProvider>
|
||||
<ElectricProvider>{children}</ElectricProvider>
|
||||
<ElectricProvider>
|
||||
<GlobalLoadingProvider>{children}</GlobalLoadingProvider>
|
||||
</ElectricProvider>
|
||||
</ReactQueryClientProvider>
|
||||
<Toaster />
|
||||
</RootProvider>
|
||||
|
|
|
|||
|
|
@ -175,5 +175,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
// How-to documentation
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/how-to/electric-sql",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
30
surfsense_web/atoms/ui/loading.atoms.ts
Normal file
30
surfsense_web/atoms/ui/loading.atoms.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
interface GlobalLoadingState {
|
||||
isLoading: boolean;
|
||||
message?: string;
|
||||
variant: "login" | "default";
|
||||
}
|
||||
|
||||
export const globalLoadingAtom = atom<GlobalLoadingState>({
|
||||
isLoading: false,
|
||||
message: undefined,
|
||||
variant: "default",
|
||||
});
|
||||
|
||||
// Helper atom for showing global loading
|
||||
export const showGlobalLoadingAtom = atom(
|
||||
null,
|
||||
(
|
||||
get,
|
||||
set,
|
||||
{ message, variant = "default" }: { message?: string; variant?: "login" | "default" }
|
||||
) => {
|
||||
set(globalLoadingAtom, { isLoading: true, message, variant });
|
||||
}
|
||||
);
|
||||
|
||||
// Helper atom for hiding global loading
|
||||
export const hideGlobalLoadingAtom = atom(null, (get, set) => {
|
||||
set(globalLoadingAtom, { isLoading: false, message: undefined, variant: "default" });
|
||||
});
|
||||
88
surfsense_web/changelog/content/2026-01-26.mdx
Normal file
88
surfsense_web/changelog/content/2026-01-26.mdx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
---
|
||||
title: "SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments"
|
||||
description: "SurfSense v0.0.12 transforms the platform with a redesigned chat-first interface, real-time collaboration features, comment threads with @mentions, and instant notifications powered by ElectricSQL + PGlite."
|
||||
date: "2026-01-26"
|
||||
tags: ["UX", "UI", "Real-time chat", "Collaboration", "Comments"]
|
||||
version: "0.0.12"
|
||||
---
|
||||
|
||||
<img src="/changelog/0.0.12/header.gif" alt="SurfSense v0.0.12 - New Main UI, Real-time Collaboration and Comments" className="rounded-lg w-full" />
|
||||
|
||||
## What's New in v0.0.12
|
||||
|
||||
This release brings major improvements to **collaboration and user experience**. We've completely redesigned the main interface to be chat-first, introduced real-time notifications and live collaboration features, and added a powerful commenting system with @mentions. These changes make SurfSense faster, more intuitive, and better for team collaboration.
|
||||
|
||||
### Major UX/UI Overhaul
|
||||
|
||||
#### New Chat-First Interface
|
||||
|
||||
- **Dashboard Removed**: Users now land directly in a chat for faster access
|
||||
- **Redesigned Search Spaces**: Moved to a left column with color-coded icons and hover tooltips
|
||||
- **Collapsible Sidebar**: New sidebar design with collapsible sections for private and group chats
|
||||
- **Streamlined Settings**: Accessible through intuitive dropdown menus
|
||||
- **Mobile-Responsive Design**: Better experience on all devices
|
||||
- **Single-Click Google Login**: Replaces the old two-step process
|
||||
|
||||
### Real-Time Collaboration Features
|
||||
|
||||
#### Live Shared Chats
|
||||
|
||||
- **Multi-User Collaboration**: Multiple users can now collaborate in the same chat in real-time
|
||||
- **Status Indicators**: See when the AI is responding to another team member
|
||||
- **Instant Sync**: Changes sync instantly across all open tabs and users
|
||||
|
||||
#### Chat Comments with @Mentions
|
||||
|
||||
- **Comment on AI Responses**: Discuss responses directly with your team
|
||||
- **Single-Level Threading**: Reply to comments with organized threads
|
||||
- **@Mentions**: Tag teammates to get their attention
|
||||
- **Real-Time Notifications**: Receive instant alerts when someone mentions you
|
||||
|
||||
#### Real-Time Notifications
|
||||
|
||||
- **Instant Updates**: Replaced slow polling with instant notifications using ElectricSQL + PGlite
|
||||
- **New Inbox**: See connector indexing, document processing, and system events immediately
|
||||
- **Cross-Tab Sync**: Syncs across all your browser tabs in real-time
|
||||
|
||||
### Connector Enhancements
|
||||
|
||||
#### OAuth Migration for Better Security
|
||||
|
||||
- **New OAuth Connectors**: Migrated Linear, Slack, Notion, Discord, Confluence, and Jira to OAuth-based authentication
|
||||
- **Circleback Integration**: Connect your AI meeting notes from Circleback
|
||||
- **Future Date Indexing**: Index future dates for calendar-based connectors to plan ahead
|
||||
- **5-Minute Periodic Syncing**: Near-real-time updates option
|
||||
- **Real-Time UI Updates**: See connector indexing progress without page refreshes
|
||||
|
||||
<Accordion type="multiple" className="w-full not-prose">
|
||||
<AccordionItem value="item-1">
|
||||
<AccordionTrigger>Bug Fixes</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Syncs with no new content now show "Already up to date!" instead of falsely reporting failures</li>
|
||||
<li>Restored missing indexing options page for Google Drive connector</li>
|
||||
<li>File mention picker now handles large document counts with server-side search and pagination</li>
|
||||
<li>Reasoning steps no longer overlap with chat input field</li>
|
||||
<li>File upload modal is now scrollable when adding many files</li>
|
||||
<li>OAuth connectors now display properly on mobile devices</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="item-2">
|
||||
<AccordionTrigger>Technical Improvements</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<ul className="list-disc space-y-2 pl-4">
|
||||
<li>Made Alembic migrations idempotent for safer deployments</li>
|
||||
<li>Migrations now work on fresh databases from scratch</li>
|
||||
<li>Major refactoring of chat components for better maintainability</li>
|
||||
<li>Streamlined sidebar and connector page code</li>
|
||||
<li>Fixed legacy route handling</li>
|
||||
<li>Fixed hardcoded Docker values for complex deployments</li>
|
||||
</ul>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
This release transforms SurfSense into a truly collaborative, real-time platform with a redesigned interface that puts chat front and center. The addition of comments, @mentions, and live collaboration features makes it easier than ever for teams to work together without leaving the app.
|
||||
|
||||
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||
|
||||
|
|
@ -25,8 +27,12 @@ const TokenHandler = ({
|
|||
tokenParamName = "token",
|
||||
storageKey = "surfsense_bearer_token",
|
||||
}: TokenHandlerProps) => {
|
||||
const t = useTranslations("auth");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Always show loading for this component - spinner animation won't reset
|
||||
useGlobalLoadingEffect(true, t("processing_authentication"), "default");
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on client-side
|
||||
if (typeof window === "undefined") return;
|
||||
|
|
@ -66,11 +72,8 @@ const TokenHandler = ({
|
|||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<p className="text-gray-500">Processing authentication...</p>
|
||||
</div>
|
||||
);
|
||||
// Return null - the global provider handles the loading UI
|
||||
return null;
|
||||
};
|
||||
|
||||
export default TokenHandler;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
useAssistantApi,
|
||||
useAssistantState,
|
||||
} 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 { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentUploadDialog } from "./document-upload-popup";
|
||||
|
|
@ -135,7 +136,7 @@ const AttachmentThumb: FC = () => {
|
|||
if (isProcessing) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -213,7 +214,7 @@ const AttachmentUI: FC = () => {
|
|||
>
|
||||
{isProcessing ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Processing...
|
||||
</span>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatSessionStatusProps {
|
||||
|
|
@ -43,7 +43,7 @@ export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
|
|||
className
|
||||
)}
|
||||
>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
<span>Currently responding to {displayName}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,37 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Cable, Loader2 } from "lucide-react";
|
||||
import { Cable } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
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 { 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 type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { useConnectorsElectric } from "@/hooks/use-connectors-electric";
|
||||
import { useDocumentsElectric } from "@/hooks/use-documents-electric";
|
||||
import { useInbox } from "@/hooks/use-inbox";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header";
|
||||
import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view";
|
||||
import { ConnectorEditView } from "./connector-popup/connector-configs/views/connector-edit-view";
|
||||
import { IndexingConfigurationView } from "./connector-popup/connector-configs/views/indexing-configuration-view";
|
||||
import { 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 { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-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 { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||
|
||||
export const ConnectorIndicator: FC = () => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const searchParams = useSearchParams();
|
||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Fetch document type counts using Electric SQL + PGlite for real-time updates
|
||||
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
|
||||
const isYouTubeView = searchParams.get("view") === "youtube";
|
||||
|
||||
|
|
@ -88,12 +101,6 @@ export const ConnectorIndicator: FC = () => {
|
|||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
setConnectorName,
|
||||
// Composio
|
||||
viewingComposio,
|
||||
connectingComposioToolkit,
|
||||
handleOpenComposio,
|
||||
handleBackFromComposio,
|
||||
handleConnectComposioToolkit,
|
||||
} = useConnectorDialog();
|
||||
|
||||
// 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
|
||||
const { indexingConnectorIds, startIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[]
|
||||
// Also clears when failed notifications are detected
|
||||
const { indexingConnectorIds, startIndexing, stopIndexing } = useIndexingConnectors(
|
||||
connectors as SearchSourceConnector[],
|
||||
inboxItems
|
||||
);
|
||||
|
||||
const isLoading = connectorsLoading || documentTypesLoading;
|
||||
|
|
@ -142,7 +151,7 @@ export const ConnectorIndicator: FC = () => {
|
|||
|
||||
// Check which connectors are already connected
|
||||
// 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)
|
||||
);
|
||||
|
||||
|
|
@ -166,7 +175,7 @@ export const ConnectorIndicator: FC = () => {
|
|||
onClick={() => handleOpenChange(true)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<Cable className="size-4 stroke-[1.5px]" />
|
||||
|
|
@ -179,22 +188,11 @@ export const ConnectorIndicator: FC = () => {
|
|||
)}
|
||||
</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 */}
|
||||
{isYouTubeView && searchSpaceId ? (
|
||||
<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 ? (
|
||||
<ConnectorAccountsListView
|
||||
connectorType="MCP_CONNECTOR"
|
||||
|
|
@ -215,9 +213,14 @@ export const ConnectorIndicator: FC = () => {
|
|||
onBack={handleBackFromAccountsList}
|
||||
onManage={handleStartEdit}
|
||||
onAddAccount={() => {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === viewingAccountsType.connectorType
|
||||
);
|
||||
// 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
|
||||
);
|
||||
if (oauthConnector) {
|
||||
handleConnectOAuth(oauthConnector);
|
||||
}
|
||||
|
|
@ -260,7 +263,13 @@ export const ConnectorIndicator: FC = () => {
|
|||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||
? () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(editingConnector.id, editingConnector.connector_type);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
|
@ -331,7 +340,6 @@ export const ConnectorIndicator: FC = () => {
|
|||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||
onManage={handleStartEdit}
|
||||
onViewAccountsList={handleViewAccountsList}
|
||||
onOpenComposio={handleOpenComposio}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioConnectorCardProps {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
connectorCount?: number;
|
||||
onConnect: () => void;
|
||||
}
|
||||
|
||||
export const ComposioConnectorCard: FC<ComposioConnectorCardProps> = ({
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
connectorCount = 0,
|
||||
onConnect,
|
||||
}) => {
|
||||
const hasConnections = connectorCount > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
|
||||
"border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5",
|
||||
"hover:border-violet-500/40 hover:from-violet-500/10 hover:to-purple-500/10"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
|
||||
"bg-gradient-to-br from-violet-500/10 to-purple-500/10 border-violet-500/20"
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/connectors/composio.svg"
|
||||
alt="Composio"
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
|
||||
<Zap className="size-3.5 text-violet-500" />
|
||||
</div>
|
||||
{hasConnections ? (
|
||||
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||
<span>
|
||||
{connectorCount} {connectorCount === 1 ? "connection" : "connections"}
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={hasConnections ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium shadow-xs",
|
||||
!hasConnections && "bg-violet-600 hover:bg-violet-700 text-white",
|
||||
hasConnections &&
|
||||
"bg-white text-slate-700 hover:bg-slate-50 border-0 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
||||
)}
|
||||
onClick={onConnect}
|
||||
>
|
||||
{hasConnections ? "Manage" : "Browse"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { FileText } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -111,7 +112,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
</div>
|
||||
{isIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : isConnected ? (
|
||||
|
|
@ -151,7 +152,7 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
disabled={isConnecting || !isEnabled}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
) : !isEnabled ? (
|
||||
"Unavailable"
|
||||
) : isConnected ? (
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@
|
|||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||
},
|
||||
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "disabled",
|
||||
"statusMessage": "Not available yet."
|
||||
}
|
||||
},
|
||||
"globalSettings": {
|
||||
|
|
|
|||
|
|
@ -6,12 +6,6 @@ import type { FC } from "react";
|
|||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -85,6 +79,7 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
BOOKSTACK_TOKEN_SECRET: values.token_secret,
|
||||
},
|
||||
is_indexable: true,
|
||||
is_active: true,
|
||||
last_indexed_at: null,
|
||||
periodic_indexing_enabled: periodicEnabled,
|
||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||
|
|
@ -301,124 +296,6 @@ export const BookStackConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitt
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
||||
>
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The BookStack connector uses the BookStack REST API to fetch all pages from your
|
||||
BookStack instance that your account has access to.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves pages that have been updated
|
||||
since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">API Token Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need to create an API token from your BookStack instance. The token requires
|
||||
"Access System API" permission.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 1: Create an API Token
|
||||
</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log in to your BookStack instance</li>
|
||||
<li>Click on your profile icon → Edit Profile</li>
|
||||
<li>Navigate to the "API Tokens" tab</li>
|
||||
<li>Click "Create Token" and give it a name</li>
|
||||
<li>Copy both the Token ID and Token Secret</li>
|
||||
<li>Paste them in the form above</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 2: Grant necessary access
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
Your user account must have "Access System API" permission. The connector will
|
||||
only index content your account can view.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Rate Limiting</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
BookStack API has a rate limit of 180 requests per minute. The connector
|
||||
automatically handles rate limiting to ensure reliable indexing.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>BookStack</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>BookStack Instance URL</strong> (e.g.,
|
||||
https://docs.example.com)
|
||||
</li>
|
||||
<li>
|
||||
Enter your <strong>Token ID</strong> and <strong>Token Secret</strong> from your
|
||||
BookStack API token.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your BookStack pages will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The BookStack connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>All pages from your BookStack instance</li>
|
||||
<li>Page content in Markdown format</li>
|
||||
<li>Page titles and metadata</li>
|
||||
<li>Book and chapter hierarchy information</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,12 +6,6 @@ import type { FC } from "react";
|
|||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -253,131 +247,6 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
||||
>
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The Luma connector uses the Luma API to fetch all events that your API key has
|
||||
access to.
|
||||
</p>
|
||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
||||
<li>
|
||||
For follow up indexing runs, the connector retrieves events that have been updated
|
||||
since the last indexing attempt.
|
||||
</li>
|
||||
<li>
|
||||
Indexing is configured to run periodically, so updates should appear in your
|
||||
search results within minutes.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">API Key Required</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
You need a Luma API key to use this connector. The key will be used to read your
|
||||
Luma events with read-only permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 1: Get Your API Key
|
||||
</h4>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
||||
<li>Log into your Luma account</li>
|
||||
<li>Navigate to your account settings</li>
|
||||
<li>Go to API settings or Developer settings</li>
|
||||
<li>Generate a new API key</li>
|
||||
<li>Copy the generated API key</li>
|
||||
<li>
|
||||
You can also visit{" "}
|
||||
<a
|
||||
href="https://lu.ma/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline underline-offset-4"
|
||||
>
|
||||
Luma API Settings
|
||||
</a>{" "}
|
||||
for more information.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
||||
Step 2: Grant necessary access
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
||||
The API key will have access to all events that your user account can see.
|
||||
Make sure your account has appropriate permissions for the events you want to
|
||||
index.
|
||||
</p>
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">Data Privacy</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
Only event details, descriptions, and attendee information will be indexed.
|
||||
Event attachments and linked files are not indexed by this connector.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
||||
<li>
|
||||
Navigate to the Connector Dashboard and select the <strong>Luma</strong>{" "}
|
||||
Connector.
|
||||
</li>
|
||||
<li>
|
||||
Place your <strong>API Key</strong> in the form field.
|
||||
</li>
|
||||
<li>
|
||||
Click <strong>Connect</strong> to establish the connection.
|
||||
</li>
|
||||
<li>Once connected, your Luma events will be indexed automatically.</li>
|
||||
</ol>
|
||||
|
||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
||||
<p className="mb-2">The Luma connector indexes the following data:</p>
|
||||
<ul className="list-disc pl-5 space-y-1">
|
||||
<li>Event titles and descriptions</li>
|
||||
<li>Event details and metadata</li>
|
||||
<li>Attendee information</li>
|
||||
<li>Event dates and locations</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FolderOpen, Info } from "lucide-react";
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
Form,
|
||||
|
|
@ -109,7 +103,7 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
|||
return (
|
||||
<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">
|
||||
<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">
|
||||
<AlertTitle className="text-xs sm:text-sm">Self-Hosted Only</AlertTitle>
|
||||
<AlertDescription className="text-[10px] sm:text-xs pl-0!">
|
||||
|
|
@ -320,145 +314,6 @@ export const ObsidianConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitti
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Documentation Section */}
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
||||
>
|
||||
<AccordionItem value="documentation" className="border-0">
|
||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
||||
Documentation
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
||||
The 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioCalendarConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioCalendarConfig: FC<ComposioCalendarConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -1,160 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink, Info, Zap } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
// Get toolkit display info
|
||||
const getToolkitInfo = (toolkitId: string): { name: string; icon: string; description: string } => {
|
||||
switch (toolkitId) {
|
||||
case "googledrive":
|
||||
return {
|
||||
name: "Google Drive",
|
||||
icon: "/connectors/google-drive.svg",
|
||||
description: "Files and documents from Google Drive",
|
||||
};
|
||||
case "gmail":
|
||||
return {
|
||||
name: "Gmail",
|
||||
icon: "/connectors/google-gmail.svg",
|
||||
description: "Emails from Gmail",
|
||||
};
|
||||
case "googlecalendar":
|
||||
return {
|
||||
name: "Google Calendar",
|
||||
icon: "/connectors/google-calendar.svg",
|
||||
description: "Events from Google Calendar",
|
||||
};
|
||||
case "slack":
|
||||
return {
|
||||
name: "Slack",
|
||||
icon: "/connectors/slack.svg",
|
||||
description: "Messages from Slack",
|
||||
};
|
||||
case "notion":
|
||||
return {
|
||||
name: "Notion",
|
||||
icon: "/connectors/notion.svg",
|
||||
description: "Pages from Notion",
|
||||
};
|
||||
case "github":
|
||||
return {
|
||||
name: "GitHub",
|
||||
icon: "/connectors/github.svg",
|
||||
description: "Repositories from GitHub",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: toolkitId,
|
||||
icon: "/connectors/composio.svg",
|
||||
description: "Connected via Composio",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ComposioConfig: FC<ComposioConfigProps> = ({ connector }) => {
|
||||
const toolkitId = connector.config?.toolkit_id as string;
|
||||
const toolkitName = connector.config?.toolkit_name as string;
|
||||
const isIndexable = connector.config?.is_indexable as boolean;
|
||||
const composioAccountId = connector.config?.composio_connected_account_id as string;
|
||||
|
||||
const toolkitInfo = getToolkitInfo(toolkitId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Toolkit Info Card */}
|
||||
<div className="rounded-xl border border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5 p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/10 to-purple-500/10 border border-violet-500/20 shrink-0">
|
||||
<Image
|
||||
src={toolkitInfo.icon}
|
||||
alt={toolkitInfo.name}
|
||||
width={24}
|
||||
height={24}
|
||||
className="size-6"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-sm font-semibold">{toolkitName || toolkitInfo.name}</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||
>
|
||||
<Zap className="size-3 mr-0.5" />
|
||||
Composio
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{toolkitInfo.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Details */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Connection Details
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Toolkit</span>
|
||||
<span className="text-xs font-medium">{toolkitId}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Indexing Supported</span>
|
||||
<Badge
|
||||
variant={isIndexable ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"text-[10px] px-1.5 py-0 h-5",
|
||||
isIndexable
|
||||
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
)}
|
||||
>
|
||||
{isIndexable ? "Yes" : "Coming Soon"}
|
||||
</Badge>
|
||||
</div>
|
||||
{composioAccountId && (
|
||||
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||
<span className="text-xs text-muted-foreground">Account ID</span>
|
||||
<span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]">
|
||||
{composioAccountId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Banner */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/30 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Info className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
This connection uses Composio's managed OAuth, which means you don't need to
|
||||
wait for app verification. Your data is securely accessed through Composio.
|
||||
</p>
|
||||
<a
|
||||
href="https://composio.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400 hover:underline"
|
||||
>
|
||||
Learn more about Composio
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
File,
|
||||
FileSpreadsheet,
|
||||
FileText,
|
||||
FolderClosed,
|
||||
Image,
|
||||
Presentation,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { ComposioDriveFolderTree } from "@/components/connectors/composio-drive-folder-tree";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioDriveConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
interface SelectedFolder {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface IndexingOptions {
|
||||
max_files_per_folder: number;
|
||||
incremental_sync: boolean;
|
||||
include_subfolders: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_INDEXING_OPTIONS: IndexingOptions = {
|
||||
max_files_per_folder: 100,
|
||||
incremental_sync: true,
|
||||
include_subfolders: true,
|
||||
};
|
||||
|
||||
// Helper to get appropriate icon for file type based on file name
|
||||
function getFileIconFromName(fileName: string, className: string = "size-3.5 shrink-0") {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
// Spreadsheets
|
||||
if (
|
||||
lowerName.endsWith(".xlsx") ||
|
||||
lowerName.endsWith(".xls") ||
|
||||
lowerName.endsWith(".csv") ||
|
||||
lowerName.includes("spreadsheet")
|
||||
) {
|
||||
return <FileSpreadsheet className={`${className} text-green-500`} />;
|
||||
}
|
||||
// Presentations
|
||||
if (
|
||||
lowerName.endsWith(".pptx") ||
|
||||
lowerName.endsWith(".ppt") ||
|
||||
lowerName.includes("presentation")
|
||||
) {
|
||||
return <Presentation className={`${className} text-orange-500`} />;
|
||||
}
|
||||
// Documents (word, text only - not PDF)
|
||||
if (
|
||||
lowerName.endsWith(".docx") ||
|
||||
lowerName.endsWith(".doc") ||
|
||||
lowerName.endsWith(".txt") ||
|
||||
lowerName.includes("document") ||
|
||||
lowerName.includes("word") ||
|
||||
lowerName.includes("text")
|
||||
) {
|
||||
return <FileText className={`${className} text-gray-500`} />;
|
||||
}
|
||||
// Images
|
||||
if (
|
||||
lowerName.endsWith(".png") ||
|
||||
lowerName.endsWith(".jpg") ||
|
||||
lowerName.endsWith(".jpeg") ||
|
||||
lowerName.endsWith(".gif") ||
|
||||
lowerName.endsWith(".webp") ||
|
||||
lowerName.endsWith(".svg")
|
||||
) {
|
||||
return <Image className={`${className} text-purple-500`} />;
|
||||
}
|
||||
// Default (including PDF)
|
||||
return <File className={`${className} text-gray-500`} />;
|
||||
}
|
||||
|
||||
export const ComposioDriveConfig: FC<ComposioDriveConfigProps> = ({
|
||||
connector,
|
||||
onConfigChange,
|
||||
}) => {
|
||||
const isIndexable = connector.config?.is_indexable as boolean;
|
||||
|
||||
// Initialize with existing selected folders and files from connector config
|
||||
const existingFolders =
|
||||
(connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||
const existingFiles = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
|
||||
const existingIndexingOptions =
|
||||
(connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS;
|
||||
|
||||
const [selectedFolders, setSelectedFolders] = useState<SelectedFolder[]>(existingFolders);
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedFolder[]>(existingFiles);
|
||||
const [showFolderSelector, setShowFolderSelector] = useState(false);
|
||||
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
|
||||
|
||||
// Update selected folders and files when connector config changes
|
||||
useEffect(() => {
|
||||
const folders = (connector.config?.selected_folders as SelectedFolder[] | undefined) || [];
|
||||
const files = (connector.config?.selected_files as SelectedFolder[] | undefined) || [];
|
||||
const options =
|
||||
(connector.config?.indexing_options as IndexingOptions | undefined) ||
|
||||
DEFAULT_INDEXING_OPTIONS;
|
||||
setSelectedFolders(folders);
|
||||
setSelectedFiles(files);
|
||||
setIndexingOptions(options);
|
||||
}, [connector.config]);
|
||||
|
||||
const updateConfig = (
|
||||
folders: SelectedFolder[],
|
||||
files: SelectedFolder[],
|
||||
options: IndexingOptions
|
||||
) => {
|
||||
if (onConfigChange) {
|
||||
onConfigChange({
|
||||
...connector.config,
|
||||
selected_folders: folders,
|
||||
selected_files: files,
|
||||
indexing_options: options,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectFolders = (folders: SelectedFolder[]) => {
|
||||
setSelectedFolders(folders);
|
||||
updateConfig(folders, selectedFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const handleSelectFiles = (files: SelectedFolder[]) => {
|
||||
setSelectedFiles(files);
|
||||
updateConfig(selectedFolders, files, indexingOptions);
|
||||
};
|
||||
|
||||
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
|
||||
const newOptions = { ...indexingOptions, [key]: value };
|
||||
setIndexingOptions(newOptions);
|
||||
updateConfig(selectedFolders, selectedFiles, newOptions);
|
||||
};
|
||||
|
||||
const handleRemoveFolder = (folderId: string) => {
|
||||
const newFolders = selectedFolders.filter((folder) => folder.id !== folderId);
|
||||
setSelectedFolders(newFolders);
|
||||
updateConfig(newFolders, selectedFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const handleRemoveFile = (fileId: string) => {
|
||||
const newFiles = selectedFiles.filter((file) => file.id !== fileId);
|
||||
setSelectedFiles(newFiles);
|
||||
updateConfig(selectedFolders, newFiles, indexingOptions);
|
||||
};
|
||||
|
||||
const totalSelected = selectedFolders.length + selectedFiles.length;
|
||||
|
||||
// Only show configuration if the connector is indexable
|
||||
if (!isIndexable) {
|
||||
return <div className="space-y-6" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Folder & File Selection */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-3 sm:space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Folder & File Selection</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Select specific folders and/or individual files to index from your Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{totalSelected > 0 && (
|
||||
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
|
||||
<p className="font-medium">
|
||||
Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => {
|
||||
const parts: string[] = [];
|
||||
if (selectedFolders.length > 0) {
|
||||
parts.push(
|
||||
`${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`
|
||||
);
|
||||
}
|
||||
if (selectedFiles.length > 0) {
|
||||
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
|
||||
}
|
||||
return parts.length > 0 ? `(${parts.join(", ")})` : "";
|
||||
})()}
|
||||
</p>
|
||||
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
|
||||
{selectedFolders.map((folder) => (
|
||||
<div
|
||||
key={folder.id}
|
||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={folder.name}
|
||||
>
|
||||
<FolderClosed className="size-3.5 shrink-0 text-gray-500" />
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFolder(folder.id)}
|
||||
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||
aria-label={`Remove ${folder.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{selectedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
|
||||
title={file.name}
|
||||
>
|
||||
{getFileIconFromName(file.name)}
|
||||
<span className="flex-1 truncate">{file.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFile(file.id)}
|
||||
className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFolderSelector ? (
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<ComposioDriveFolderTree
|
||||
connectorId={connector.id}
|
||||
selectedFolders={selectedFolders}
|
||||
onSelectFolders={handleSelectFolders}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFiles={handleSelectFiles}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFolderSelector(false)}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
Done Selecting
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowFolderSelector(true)}
|
||||
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
|
||||
>
|
||||
{totalSelected > 0 ? "Change Selection" : "Select Folders & Files"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indexing Options */}
|
||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6 space-y-4">
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
<h3 className="font-medium text-sm sm:text-base">Indexing Options</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground">
|
||||
Configure how files are indexed from your Google Drive.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Max files per folder */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="max-files" className="text-sm font-medium">
|
||||
Max files per folder
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Maximum number of files to index from each folder
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={indexingOptions.max_files_per_folder.toString()}
|
||||
onValueChange={(value) =>
|
||||
handleIndexingOptionChange("max_files_per_folder", parseInt(value, 10))
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="max-files"
|
||||
className="w-[140px] bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 text-xs sm:text-sm"
|
||||
>
|
||||
<SelectValue placeholder="Select limit" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100]">
|
||||
<SelectItem value="50" className="text-xs sm:text-sm">
|
||||
50 files
|
||||
</SelectItem>
|
||||
<SelectItem value="100" className="text-xs sm:text-sm">
|
||||
100 files
|
||||
</SelectItem>
|
||||
<SelectItem value="250" className="text-xs sm:text-sm">
|
||||
250 files
|
||||
</SelectItem>
|
||||
<SelectItem value="500" className="text-xs sm:text-sm">
|
||||
500 files
|
||||
</SelectItem>
|
||||
<SelectItem value="1000" className="text-xs sm:text-sm">
|
||||
1000 files
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Include subfolders toggle */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-400/20">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="include-subfolders" className="text-sm font-medium">
|
||||
Include subfolders
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recursively index files in subfolders of selected folders
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="include-subfolders"
|
||||
checked={indexingOptions.include_subfolders}
|
||||
onCheckedChange={(checked) => handleIndexingOptionChange("include_subfolders", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
|
||||
interface ComposioGmailConfigProps {
|
||||
connector: SearchSourceConnector;
|
||||
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const ComposioGmailConfig: FC<ComposioGmailConfigProps> = () => {
|
||||
return <div className="space-y-6" />;
|
||||
};
|
||||
|
|
@ -1,6 +1,14 @@
|
|||
"use client";
|
||||
|
||||
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 { useEffect, useState } from "react";
|
||||
import { GoogleDriveFolderTree } from "@/components/connectors/google-drive-folder-tree";
|
||||
|
|
@ -135,6 +143,18 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
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;
|
||||
|
||||
return (
|
||||
|
|
@ -161,29 +181,45 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
if (selectedFiles.length > 0) {
|
||||
parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`);
|
||||
}
|
||||
return parts.length > 0 ? `(${parts.join(" ")})` : "";
|
||||
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) => (
|
||||
<p
|
||||
<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" />
|
||||
{folder.name}
|
||||
</p>
|
||||
<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) => (
|
||||
<p
|
||||
<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)}
|
||||
{file.name}
|
||||
</p>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
|||
import { BookStackConfig } from "./components/bookstack-config";
|
||||
import { CirclebackConfig } from "./components/circleback-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 { DiscordConfig } from "./components/discord-config";
|
||||
import { ElasticsearchConfig } from "./components/elasticsearch-config";
|
||||
|
|
@ -77,8 +79,12 @@ export function getConnectorConfigComponent(
|
|||
return MCPConfig;
|
||||
case "OBSIDIAN_CONNECTOR":
|
||||
return ObsidianConfig;
|
||||
case "COMPOSIO_CONNECTOR":
|
||||
return ComposioConfig;
|
||||
case "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
|
||||
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
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
|
|
@ -139,7 +140,7 @@ export const ConnectorConnectView: FC<ConnectorConnectViewProps> = ({
|
|||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Connecting
|
||||
</>
|
||||
) : connectorType === "MCP_CONNECTOR" ? (
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
"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 { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
|
||||
import { getConnectorDisplayName } from "../../tabs/all-connectors-tab";
|
||||
import { getConnectorConfigComponent } from "../index";
|
||||
|
||||
interface ConnectorEditViewProps {
|
||||
|
|
@ -97,12 +99,16 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
};
|
||||
}, [checkScrollState]);
|
||||
|
||||
// Reset local quick indexing state when indexing completes
|
||||
// Reset local quick indexing state when indexing completes or fails
|
||||
useEffect(() => {
|
||||
if (!isIndexing) {
|
||||
setIsQuickIndexing(false);
|
||||
if (!isIndexing && isQuickIndexing) {
|
||||
// Small delay to ensure smooth transition
|
||||
const timer = setTimeout(() => {
|
||||
setIsQuickIndexing(false);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isIndexing]);
|
||||
}, [isIndexing, isQuickIndexing]);
|
||||
|
||||
const handleDisconnectClick = () => {
|
||||
setShowDisconnectConfirm(true);
|
||||
|
|
@ -118,11 +124,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
};
|
||||
|
||||
const handleQuickIndex = useCallback(() => {
|
||||
if (onQuickIndex) {
|
||||
if (onQuickIndex && !isQuickIndexing && !isIndexing) {
|
||||
setIsQuickIndexing(true);
|
||||
onQuickIndex();
|
||||
}
|
||||
}, [onQuickIndex]);
|
||||
}, [onQuickIndex, isQuickIndexing, isIndexing]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
|
|
@ -151,7 +157,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
</div>
|
||||
<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">
|
||||
{connector.name}
|
||||
{getConnectorDisplayName(connector.name)}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
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 */}
|
||||
{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 !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
|
|
@ -217,6 +224,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "LUMA_CONNECTOR"
|
||||
}
|
||||
/>
|
||||
|
|
@ -224,8 +232,11 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
|
||||
{/* 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 isComposioGoogleDrive =
|
||||
connector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const requiresFolderSelection = isGoogleDrive || isComposioGoogleDrive;
|
||||
const selectedFolders =
|
||||
(connector.config?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
|
|
@ -235,7 +246,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
| Array<{ id: string; name: string }>
|
||||
| undefined) || [];
|
||||
const hasItemsSelected = selectedFolders.length > 0 || selectedFiles.length > 0;
|
||||
const isDisabled = isGoogleDrive && !hasItemsSelected;
|
||||
const isDisabled = requiresFolderSelection && !hasItemsSelected;
|
||||
|
||||
return (
|
||||
<PeriodicSyncConfig
|
||||
|
|
@ -266,8 +277,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
Re-indexing runs in the background
|
||||
</p>
|
||||
<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
|
||||
to see progress.
|
||||
You can continue using SurfSense while we sync your data. Check inbox for
|
||||
updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -301,7 +312,7 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Disconnecting
|
||||
</>
|
||||
) : (
|
||||
|
|
@ -337,8 +348,8 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Saving...
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Saving
|
||||
</>
|
||||
) : (
|
||||
"Save Changes"
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Check, Info } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
||||
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 { getConnectorConfigComponent } from "../index";
|
||||
|
||||
|
|
@ -91,8 +92,6 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
};
|
||||
}, [checkScrollState]);
|
||||
|
||||
const authConnector = OAUTH_CONNECTORS.find((c) => c.connectorType === connector?.connector_type);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
{/* Fixed Header */}
|
||||
|
|
@ -151,8 +150,9 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||
{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 !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||
<DateRangeSelector
|
||||
|
|
@ -162,20 +162,22 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "LUMA_CONNECTOR"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Periodic sync - not shown for Google Drive */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" && (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
)}
|
||||
{/* Periodic sync - not shown for Google Drive (regular and Composio) */}
|
||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
config.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" && (
|
||||
<PeriodicSyncConfig
|
||||
enabled={periodicEnabled}
|
||||
frequencyMinutes={frequencyMinutes}
|
||||
onEnabledChange={onPeriodicEnabledChange}
|
||||
onFrequencyChange={onFrequencyChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
@ -188,8 +190,8 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
<div className="text-xs sm:text-sm">
|
||||
<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">
|
||||
You can continue using SurfSense while we sync your data. Check the Active tab
|
||||
to see progress.
|
||||
You can continue using SurfSense while we sync your data. Check inbox for
|
||||
updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -215,7 +217,7 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
>
|
||||
{isStartingIndexing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -175,14 +175,28 @@ export const OTHER_CONNECTORS = [
|
|||
},
|
||||
] as const;
|
||||
|
||||
// Composio Connector (Single entry that opens toolkit selector)
|
||||
// Composio Connectors - Individual entries for each supported toolkit
|
||||
export const COMPOSIO_CONNECTORS = [
|
||||
{
|
||||
id: "composio-connector",
|
||||
title: "Composio",
|
||||
description: "Connect 100+ apps via Composio (Google, Slack, Notion, etc.)",
|
||||
connectorType: EnumConnectorName.COMPOSIO_CONNECTOR,
|
||||
// No authEndpoint - handled via toolkit selector view
|
||||
id: "composio-googledrive",
|
||||
title: "Google Drive",
|
||||
description: "Search your Drive files via Composio",
|
||||
connectorType: EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR,
|
||||
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;
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { searchSourceConnectorTypeEnum } from "@/contracts/types/connector.types
|
|||
export const connectorPopupQueryParamsSchema = z.object({
|
||||
modal: z.enum(["connectors"]).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(),
|
||||
connectorId: z.string().optional(),
|
||||
connectorType: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,11 @@ import {
|
|||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
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 {
|
||||
dateRangeSchema,
|
||||
frequencyMinutesSchema,
|
||||
|
|
@ -83,10 +87,6 @@ export const useConnectorDialog = () => {
|
|||
// MCP list view state (for managing multiple MCP connectors)
|
||||
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
|
||||
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
||||
connectorType: string;
|
||||
|
|
@ -159,32 +159,28 @@ export const useConnectorDialog = () => {
|
|||
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
|
||||
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
||||
setConnectingConnectorType(params.connectorType);
|
||||
}
|
||||
|
||||
// Handle accounts view
|
||||
if (params.view === "accounts" && params.connectorType && !viewingAccountsType) {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find(
|
||||
(c) => c.connectorType === params.connectorType
|
||||
);
|
||||
if (oauthConnector) {
|
||||
setViewingAccountsType({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
if (params.view === "accounts" && params.connectorType) {
|
||||
// Update state if not set, or if connectorType has changed
|
||||
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) {
|
||||
setViewingAccountsType({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +191,10 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle configure view (for page refresh support)
|
||||
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) {
|
||||
let existingConnector: SearchSourceConnector | undefined;
|
||||
if (params.connectorId) {
|
||||
|
|
@ -293,6 +292,7 @@ export const useConnectorDialog = () => {
|
|||
indexingConfig,
|
||||
connectingConnectorType,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
]);
|
||||
|
||||
// Detect OAuth success / Failure and transition to config view
|
||||
|
|
@ -328,58 +328,72 @@ export const useConnectorDialog = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
params.success === "true" &&
|
||||
params.connector &&
|
||||
searchSpaceId &&
|
||||
params.modal === "connectors"
|
||||
) {
|
||||
const oauthConnector = OAUTH_CONNECTORS.find((c) => c.id === params.connector);
|
||||
if (oauthConnector) {
|
||||
refetchAllConnectors().then((result) => {
|
||||
if (!result.data) return;
|
||||
if (params.success === "true" && searchSpaceId && params.modal === "connectors") {
|
||||
refetchAllConnectors().then((result) => {
|
||||
if (!result.data) return;
|
||||
|
||||
let newConnector: SearchSourceConnector | undefined;
|
||||
if (params.connectorId) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
} else {
|
||||
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) {
|
||||
const connectorId = parseInt(params.connectorId, 10);
|
||||
newConnector = result.data.find((c: SearchSourceConnector) => c.id === connectorId);
|
||||
|
||||
// If we found the connector, find the matching OAuth/Composio connector by type
|
||||
if (newConnector) {
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.connectorType === newConnector!.connector_type);
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a connector yet, try to find by connector param
|
||||
if (!newConnector && params.connector) {
|
||||
oauthConnector =
|
||||
OAUTH_CONNECTORS.find((c) => c.id === params.connector) ||
|
||||
COMPOSIO_CONNECTORS.find((c) => c.id === params.connector);
|
||||
|
||||
if (oauthConnector) {
|
||||
newConnector = result.data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector.connectorType
|
||||
(c: SearchSourceConnector) => c.connector_type === oauthConnector!.connectorType
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (newConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
// Track connector connected event for OAuth connectors
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
if (newConnector && oauthConnector) {
|
||||
const connectorValidation = searchSourceConnector.safeParse(newConnector);
|
||||
if (connectorValidation.success) {
|
||||
// Track connector connected event for OAuth/Composio connectors
|
||||
trackConnectorConnected(
|
||||
Number(searchSpaceId),
|
||||
oauthConnector.connectorType,
|
||||
newConnector.id
|
||||
);
|
||||
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: newConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(newConnector);
|
||||
setIndexingConnectorConfig(newConnector.config);
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.set("connectorId", newConnector.id.toString());
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
|
||||
toast.error("Failed to validate connector data");
|
||||
}
|
||||
const config = validateIndexingConfigState({
|
||||
connectorType: oauthConnector.connectorType,
|
||||
connectorId: newConnector.id,
|
||||
connectorTitle: oauthConnector.title,
|
||||
});
|
||||
setIndexingConfig(config);
|
||||
setIndexingConnector(newConnector);
|
||||
setIndexingConnectorConfig(newConnector.config);
|
||||
setIsOpen(true);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("success");
|
||||
url.searchParams.set("connectorId", newConnector.id.toString());
|
||||
url.searchParams.set("view", "configure");
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
} else {
|
||||
console.warn("Invalid connector data after OAuth:", connectorValidation.error);
|
||||
toast.error("Failed to validate connector data");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// Invalid query params - log but don't crash
|
||||
|
|
@ -389,17 +403,18 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle OAuth connection
|
||||
const handleConnectOAuth = useCallback(
|
||||
async (connector: (typeof OAUTH_CONNECTORS)[number]) => {
|
||||
async (connector: (typeof OAUTH_CONNECTORS)[number] | (typeof COMPOSIO_CONNECTORS)[number]) => {
|
||||
if (!searchSpaceId || !connector.authEndpoint) return;
|
||||
|
||||
// Set connecting state immediately to disable button and show spinner
|
||||
setConnectingId(connector.id);
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}${connector.authEndpoint}?space_id=${searchSpaceId}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
// Check if authEndpoint already has query parameters
|
||||
const separator = connector.authEndpoint.includes("?") ? "&" : "?";
|
||||
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) {
|
||||
throw new Error(`Failed to initiate ${connector.title} OAuth`);
|
||||
|
|
@ -799,23 +814,19 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle viewing accounts list for OAuth connector type
|
||||
const handleViewAccountsList = useCallback(
|
||||
(connectorType: string, connectorTitle: string) => {
|
||||
(connectorType: string, _connectorTitle?: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
|
||||
setViewingAccountsType({
|
||||
connectorType,
|
||||
connectorTitle,
|
||||
});
|
||||
|
||||
// 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);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "accounts");
|
||||
url.searchParams.set("connectorType", connectorType);
|
||||
// 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
|
||||
|
|
@ -839,8 +850,8 @@ export const useConnectorDialog = () => {
|
|||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "mcp-list");
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
}, [searchSpaceId]);
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [searchSpaceId, router]);
|
||||
|
||||
// Handle going back from MCP list view
|
||||
const handleBackFromMCPList = useCallback(() => {
|
||||
|
|
@ -861,71 +872,15 @@ export const useConnectorDialog = () => {
|
|||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [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
|
||||
const handleStartIndexing = useCallback(
|
||||
async (refreshConnectors: () => void) => {
|
||||
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 (
|
||||
indexingConfig.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" &&
|
||||
indexingConfig.connectorType !== "WEBCRAWLER_CONNECTOR"
|
||||
) {
|
||||
const dateRangeValidation = dateRangeSchema.safeParse({ startDate, endDate });
|
||||
|
|
@ -970,8 +925,12 @@ export const useConnectorDialog = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// Handle Google Drive folder selection
|
||||
if (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" && indexingConnectorConfig) {
|
||||
// Handle Google Drive folder selection (regular and Composio)
|
||||
if (
|
||||
(indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR") &&
|
||||
indexingConnectorConfig
|
||||
) {
|
||||
const selectedFolders = indexingConnectorConfig.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
@ -1191,8 +1150,12 @@ export const useConnectorDialog = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Prevent periodic indexing for Google Drive without folders/files selected
|
||||
if (periodicEnabled && editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
|
||||
// Prevent periodic indexing for Google Drive (regular or Composio) without folders/files selected
|
||||
if (
|
||||
periodicEnabled &&
|
||||
(editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR")
|
||||
) {
|
||||
const selectedFolders = (connectorConfig || editingConnector.config)?.selected_folders as
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
@ -1241,8 +1204,11 @@ export const useConnectorDialog = () => {
|
|||
if (!editingConnector.is_indexable) {
|
||||
// Non-indexable connectors (like Tavily API) don't need re-indexing
|
||||
indexingDescription = "Settings saved.";
|
||||
} else if (editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR") {
|
||||
// Google Drive uses folder selection from config, not date ranges
|
||||
} else if (
|
||||
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
|
||||
| Array<{ id: string; name: string }>
|
||||
| undefined;
|
||||
|
|
@ -1423,13 +1389,24 @@ export const useConnectorDialog = () => {
|
|||
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(
|
||||
async (connectorId: number, connectorType?: string) => {
|
||||
if (!searchSpaceId) return;
|
||||
async (
|
||||
connectorId: number,
|
||||
connectorType?: string,
|
||||
stopIndexing?: (id: number) => void,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
) => {
|
||||
if (!searchSpaceId) {
|
||||
if (stopIndexing) {
|
||||
stopIndexing(connectorId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Track quick index clicked event
|
||||
if (connectorType) {
|
||||
|
|
@ -1437,10 +1414,16 @@ export const useConnectorDialog = () => {
|
|||
}
|
||||
|
||||
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({
|
||||
connector_id: connectorId,
|
||||
queryParams: {
|
||||
search_space_id: searchSpaceId,
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
},
|
||||
});
|
||||
toast.success("Indexing started", {
|
||||
|
|
@ -1451,12 +1434,18 @@ export const useConnectorDialog = () => {
|
|||
queryClient.invalidateQueries({
|
||||
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) {
|
||||
console.error("Error indexing connector content:", error);
|
||||
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
|
||||
|
|
@ -1578,7 +1567,6 @@ export const useConnectorDialog = () => {
|
|||
allConnectors,
|
||||
viewingAccountsType,
|
||||
viewingMCPList,
|
||||
viewingComposio,
|
||||
|
||||
// Setters
|
||||
setSearchQuery,
|
||||
|
|
@ -1614,12 +1602,5 @@ export const useConnectorDialog = () => {
|
|||
connectorConfig,
|
||||
setConnectorConfig,
|
||||
setIndexingConnectorConfig,
|
||||
|
||||
// Composio
|
||||
viewingComposio,
|
||||
connectingComposioToolkit,
|
||||
handleOpenComposio,
|
||||
handleBackFromComposio,
|
||||
handleConnectComposioToolkit,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,17 +2,24 @@
|
|||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
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.
|
||||
*
|
||||
* This provides a better UX than polling by:
|
||||
* 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.
|
||||
*/
|
||||
export function useIndexingConnectors(connectors: SearchSourceConnector[]) {
|
||||
export function useIndexingConnectors(
|
||||
connectors: SearchSourceConnector[],
|
||||
inboxItems?: InboxItem[]
|
||||
) {
|
||||
// Set of connector IDs that are currently indexing
|
||||
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
|
||||
useEffect(() => {
|
||||
const previousValues = previousLastIndexedAtRef.current;
|
||||
const newIndexingIds = new Set(indexingConnectorIds);
|
||||
let hasChanges = false;
|
||||
|
||||
for (const connector of connectors) {
|
||||
const previousValue = previousValues.get(connector.id);
|
||||
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 (
|
||||
previousValue !== undefined && // We've seen this connector before
|
||||
previousValue !== currentValue && // Value changed
|
||||
indexingConnectorIds.has(connector.id) // It was marked as indexing
|
||||
previousValue !== currentValue // Value changed
|
||||
) {
|
||||
newIndexingIds.delete(connector.id);
|
||||
hasChanges = true;
|
||||
// Use functional update to access current state
|
||||
setIndexingConnectorIds((prev) => {
|
||||
if (prev.has(connector.id)) {
|
||||
const next = new Set(prev);
|
||||
next.delete(connector.id);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
// Update previous value tracking
|
||||
previousValues.set(connector.id, currentValue);
|
||||
}
|
||||
}, [connectors]);
|
||||
|
||||
if (hasChanges) {
|
||||
setIndexingConnectorIds(newIndexingIds);
|
||||
}
|
||||
}, [connectors, indexingConnectorIds]);
|
||||
// Detect notification status changes and update indexing state accordingly
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
const startIndexing = useCallback((connectorId: number) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowRight, Cable, Loader2 } from "lucide-react";
|
||||
import { ArrowRight, Cable } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TabsContent } from "@/components/ui/tabs";
|
||||
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 { connectorsApiService } from "@/lib/apis/connectors-api.service";
|
||||
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 { getConnectorDisplayName } from "./all-connectors-tab";
|
||||
|
||||
interface ActiveConnectorsTabProps {
|
||||
searchQuery: string;
|
||||
|
|
@ -113,7 +115,10 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
|
||||
// Get display info for OAuth connector type
|
||||
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 {
|
||||
title:
|
||||
oauthConnector?.title ||
|
||||
|
|
@ -205,7 +210,7 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
<p className="text-[14px] font-semibold leading-tight truncate">{title}</p>
|
||||
{isAnyIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -260,13 +265,13 @@ export const ActiveConnectorsTab: FC<ActiveConnectorsTabProps> = ({
|
|||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[14px] font-semibold leading-tight">
|
||||
{connector.name}
|
||||
<p className="text-[14px] font-semibold leading-tight truncate">
|
||||
{getConnectorDisplayName(connector.name)}
|
||||
</p>
|
||||
</div>
|
||||
{isIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : !isMCPConnector ? (
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import type { FC } from "react";
|
|||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import { isSelfHosted } from "@/lib/env-config";
|
||||
import { ComposioConnectorCard } from "../components/composio-connector-card";
|
||||
import { ConnectorCard } from "../components/connector-card";
|
||||
import {
|
||||
COMPOSIO_CONNECTORS,
|
||||
|
|
@ -35,13 +34,14 @@ interface AllConnectorsTabProps {
|
|||
allConnectors: SearchSourceConnector[] | undefined;
|
||||
documentTypeCounts?: Record<string, 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;
|
||||
onCreateWebcrawler?: () => void;
|
||||
onCreateYouTubeCrawler?: () => void;
|
||||
onManage?: (connector: SearchSourceConnector) => void;
|
||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||
onOpenComposio?: () => void;
|
||||
}
|
||||
|
||||
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||
|
|
@ -57,7 +57,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
onCreateYouTubeCrawler,
|
||||
onManage,
|
||||
onViewAccountsList,
|
||||
onOpenComposio,
|
||||
}) => {
|
||||
// Check if self-hosted mode (for showing self-hosted only connectors)
|
||||
const selfHosted = isSelfHosted();
|
||||
|
|
@ -93,23 +92,18 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
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 (
|
||||
<div className="space-y-8">
|
||||
{/* Quick Connect */}
|
||||
{filteredOAuth.length > 0 && (
|
||||
{/* Managed OAuth (Composio Integrations) */}
|
||||
{filteredComposio.length > 0 && (
|
||||
<section>
|
||||
<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 className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredOAuth.map((connector) => {
|
||||
{filteredComposio.map((connector) => {
|
||||
const isConnected = connectedTypes.has(connector.connectorType);
|
||||
const isConnecting = connectingId === connector.id;
|
||||
|
||||
|
|
@ -123,18 +117,6 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
|
||||
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(
|
||||
connector.connectorType,
|
||||
documentTypeCounts
|
||||
|
|
@ -168,29 +150,59 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
|||
</section>
|
||||
)}
|
||||
|
||||
{/* Composio Integrations */}
|
||||
{/* {filteredComposio.length > 0 && onOpenComposio && (
|
||||
{/* Quick Connect */}
|
||||
{filteredOAuth.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</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>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">Quick Connect</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{filteredComposio.map((connector) => (
|
||||
<ComposioConnectorCard
|
||||
key={connector.id}
|
||||
id={connector.id}
|
||||
title={connector.title}
|
||||
description={connector.description}
|
||||
connectorCount={composioConnectorCount}
|
||||
onConnect={onOpenComposio}
|
||||
/>
|
||||
))}
|
||||
{filteredOAuth.map((connector) => {
|
||||
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}
|
||||
id={connector.id}
|
||||
title={connector.title}
|
||||
description={connector.description}
|
||||
connectorType={connector.connectorType}
|
||||
isConnected={isConnected}
|
||||
isConnecting={isConnecting}
|
||||
documentCount={documentCount}
|
||||
accountCount={accountCount}
|
||||
isIndexing={isIndexing}
|
||||
onConnect={() => onConnectOAuth(connector)}
|
||||
onManage={
|
||||
isConnected && onViewAccountsList
|
||||
? () => onViewAccountsList(connector.connectorType, connector.title)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)} */}
|
||||
)}
|
||||
|
||||
{/* More Integrations */}
|
||||
{filteredOther.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
|||
// Special mappings (connector type differs from document type)
|
||||
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
||||
COMPOSIO_CONNECTOR: "COMPOSIO_CONNECTOR",
|
||||
// Composio connectors map to their own document types
|
||||
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "COMPOSIO_GOOGLE_DRIVE_CONNECTOR",
|
||||
COMPOSIO_GMAIL_CONNECTOR: "COMPOSIO_GMAIL_CONNECTOR",
|
||||
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR",
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,355 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Check,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Github,
|
||||
HardDrive,
|
||||
Loader2,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComposioToolkit {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isIndexable: boolean;
|
||||
}
|
||||
|
||||
interface ComposioToolkitViewProps {
|
||||
searchSpaceId: string;
|
||||
connectedToolkits: string[];
|
||||
onBack: () => void;
|
||||
onConnectToolkit: (toolkitId: string) => void;
|
||||
isConnecting: boolean;
|
||||
connectingToolkitId: string | null;
|
||||
}
|
||||
|
||||
// Available Composio toolkits
|
||||
const COMPOSIO_TOOLKITS: ComposioToolkit[] = [
|
||||
{
|
||||
id: "googledrive",
|
||||
name: "Google Drive",
|
||||
description: "Search your Drive files and documents",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "gmail",
|
||||
name: "Gmail",
|
||||
description: "Search through your emails",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "googlecalendar",
|
||||
name: "Google Calendar",
|
||||
description: "Search through your events",
|
||||
isIndexable: true,
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
description: "Search Slack messages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "notion",
|
||||
name: "Notion",
|
||||
description: "Search Notion pages",
|
||||
isIndexable: false,
|
||||
},
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
description: "Search repositories and code",
|
||||
isIndexable: false,
|
||||
},
|
||||
];
|
||||
|
||||
// Get icon for toolkit
|
||||
const getToolkitIcon = (toolkitId: string, className?: string) => {
|
||||
const iconClass = className || "size-5";
|
||||
|
||||
switch (toolkitId) {
|
||||
case "googledrive":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-drive.svg"
|
||||
alt="Google Drive"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "gmail":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-gmail.svg"
|
||||
alt="Gmail"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "googlecalendar":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/google-calendar.svg"
|
||||
alt="Google Calendar"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "slack":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/slack.svg"
|
||||
alt="Slack"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "notion":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/notion.svg"
|
||||
alt="Notion"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
case "github":
|
||||
return (
|
||||
<Image
|
||||
src="/connectors/github.svg"
|
||||
alt="GitHub"
|
||||
width={20}
|
||||
height={20}
|
||||
className={iconClass}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <Zap className={iconClass} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
||||
searchSpaceId,
|
||||
connectedToolkits,
|
||||
onBack,
|
||||
onConnectToolkit,
|
||||
isConnecting,
|
||||
connectingToolkitId,
|
||||
}) => {
|
||||
const [hoveredToolkit, setHoveredToolkit] = useState<string | null>(null);
|
||||
|
||||
// Separate indexable and non-indexable toolkits
|
||||
const indexableToolkits = COMPOSIO_TOOLKITS.filter((t) => t.isIndexable);
|
||||
const nonIndexableToolkits = COMPOSIO_TOOLKITS.filter((t) => !t.isIndexable);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 sm:pb-6 border-b border-border/50 bg-muted">
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit transition-colors"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
{/* Header content */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||
<div className="flex gap-4 flex-1 w-full sm:w-auto">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-500/20 border border-violet-500/30 shrink-0">
|
||||
<Image
|
||||
src="/connectors/composio.svg"
|
||||
alt="Composio"
|
||||
width={28}
|
||||
height={28}
|
||||
className="size-7"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">Composio</h2>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||
Connect 100+ apps with managed OAuth - no verification needed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href="https://composio.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Powered by Composio</span>
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
|
||||
{/* Indexable Toolkits (Google Services) */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
>
|
||||
Indexable
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect Google services via Composio's verified OAuth app. Your data will be
|
||||
indexed and searchable.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{indexableToolkits.map((toolkit) => {
|
||||
const isConnected = connectedToolkits.includes(toolkit.id);
|
||||
const isThisConnecting = connectingToolkitId === toolkit.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toolkit.id}
|
||||
onMouseEnter={() => setHoveredToolkit(toolkit.id)}
|
||||
onMouseLeave={() => setHoveredToolkit(null)}
|
||||
className={cn(
|
||||
"group relative flex flex-col p-4 rounded-xl border transition-all duration-200",
|
||||
isConnected
|
||||
? "border-emerald-500/30 bg-emerald-500/5"
|
||||
: "border-border bg-card hover:border-violet-500/30 hover:bg-violet-500/5"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-lg border transition-colors",
|
||||
isConnected
|
||||
? "bg-emerald-500/10 border-emerald-500/20"
|
||||
: "bg-muted border-border group-hover:border-violet-500/20 group-hover:bg-violet-500/10"
|
||||
)}
|
||||
>
|
||||
{getToolkitIcon(toolkit.id, "size-5")}
|
||||
</div>
|
||||
{isConnected && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||
>
|
||||
<Check className="size-3 mr-0.5" />
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={isConnected ? "secondary" : "default"}
|
||||
className={cn(
|
||||
"w-full h-8 text-xs font-medium",
|
||||
!isConnected && "bg-violet-600 hover:bg-violet-700 text-white"
|
||||
)}
|
||||
onClick={() => onConnectToolkit(toolkit.id)}
|
||||
disabled={isConnecting || isConnected}
|
||||
>
|
||||
{isThisConnecting ? (
|
||||
<>
|
||||
<Loader2 className="size-3 mr-1.5 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : isConnected ? (
|
||||
"Connected"
|
||||
) : (
|
||||
"Connect"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Non-Indexable Toolkits (Coming Soon) */}
|
||||
<section>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||
>
|
||||
Coming Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Connect these services for future indexing support. Currently available for connection
|
||||
only.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
|
||||
{nonIndexableToolkits.map((toolkit) => (
|
||||
<div
|
||||
key={toolkit.id}
|
||||
className="group relative flex flex-col p-4 rounded-xl border border-border bg-card/50"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg border bg-muted border-border">
|
||||
{getToolkitIcon(toolkit.id, "size-5")}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
||||
Soon
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||
<p className="text-xs text-muted-foreground mb-4 flex-1">{toolkit.description}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-8 text-xs font-medium"
|
||||
disabled
|
||||
>
|
||||
Coming Soon
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Info footer */}
|
||||
<div className="mt-8 p-4 rounded-xl bg-muted/50 border border-border/50">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 border border-violet-500/20 shrink-0">
|
||||
<Zap className="size-4 text-violet-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Composio provides pre-verified OAuth apps, so you don't need to wait for Google
|
||||
app verification. Your data is securely processed through Composio's managed
|
||||
authentication.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
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">
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin text-primary" />
|
||||
<Spinner size="xs" className="text-primary" />
|
||||
) : (
|
||||
<Plus className="size-3 text-primary" />
|
||||
)}
|
||||
|
|
@ -207,7 +208,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</p>
|
||||
{isIndexing ? (
|
||||
<p className="text-[11px] text-primary mt-1 flex items-center gap-1.5">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
<Spinner size="xs" />
|
||||
Syncing
|
||||
</p>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { TagInput, type Tag as TagType } from "emblor";
|
||||
import { useAtom } from "jotai";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { type FC, useState } from "react";
|
||||
|
|
@ -10,6 +10,7 @@ import { toast } from "sonner";
|
|||
import { createDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
|
||||
|
|
@ -222,7 +223,7 @@ export const YouTubeCrawlerView: FC<YouTubeCrawlerViewProps> = ({ searchSpaceId,
|
|||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
{t("processing")}
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue