Merge pull request #222 from CREDO23/feature/add-jira-connector

[Feat]: Add  JIRA connector
This commit is contained in:
Rohan Verma 2025-07-25 21:58:49 +05:30 committed by GitHub
commit 665074e6e9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 7710 additions and 4614 deletions

View file

@ -67,7 +67,7 @@ repos:
hooks: hooks:
- id: mypy - id: mypy
files: ^surfsense_backend/ files: ^surfsense_backend/
additional_dependencies: [] additional_dependencies: ['types-requests']
args: [--ignore-missing-imports, --disallow-untyped-defs] args: [--ignore-missing-imports, --disallow-untyped-defs]
- repo: https://github.com/PyCQA/bandit - repo: https://github.com/PyCQA/bandit
@ -75,7 +75,7 @@ repos:
hooks: hooks:
- id: bandit - id: bandit
files: ^surfsense_backend/ files: ^surfsense_backend/
args: ['-r', '.', '-f', 'json'] args: ['-r', '-f', 'json']
exclude: ^surfsense_backend/(tests/|alembic/) exclude: ^surfsense_backend/(tests/|alembic/)
# Frontend/Extension Hooks (TypeScript/JavaScript) # Frontend/Extension Hooks (TypeScript/JavaScript)

View file

@ -0,0 +1 @@
{"2d0ec64d93969318101ee479b664221b32241665":{"files":{"surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx":["EHKKvlOK0vfy0GgHwlG/J2Bx5rw=",true]},"modified":1753426633288}}

View file

@ -6,10 +6,8 @@ Revises: 10
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSON from alembic import op
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "11" revision: str = "11"
@ -21,66 +19,144 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema - add LiteLLMProvider enum, LLMConfig table and user LLM preferences.""" """Upgrade schema - add LiteLLMProvider enum, LLMConfig table and user LLM preferences."""
# Check if enum type exists and create if it doesn't # Create enum only if not exists
op.execute(""" op.execute(
"""
DO $$ DO $$
BEGIN BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'litellmprovider') THEN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'litellmprovider') THEN
CREATE TYPE litellmprovider AS ENUM ('OPENAI', 'ANTHROPIC', 'GROQ', 'COHERE', 'HUGGINGFACE', 'AZURE_OPENAI', 'GOOGLE', 'AWS_BEDROCK', 'OLLAMA', 'MISTRAL', 'TOGETHER_AI', 'REPLICATE', 'PALM', 'VERTEX_AI', 'ANYSCALE', 'PERPLEXITY', 'DEEPINFRA', 'AI21', 'NLPCLOUD', 'ALEPH_ALPHA', 'PETALS', 'CUSTOM'); CREATE TYPE litellmprovider AS ENUM (
'OPENAI', 'ANTHROPIC', 'GROQ', 'COHERE', 'HUGGINGFACE',
'AZURE_OPENAI', 'GOOGLE', 'AWS_BEDROCK', 'OLLAMA', 'MISTRAL',
'TOGETHER_AI', 'REPLICATE', 'PALM', 'VERTEX_AI', 'ANYSCALE',
'PERPLEXITY', 'DEEPINFRA', 'AI21', 'NLPCLOUD', 'ALEPH_ALPHA',
'PETALS', 'CUSTOM'
);
END IF; END IF;
END$$; END$$;
""") """
)
# Create llm_configs table using raw SQL to avoid enum creation conflicts # Create llm_configs table only if it doesn't already exist
op.execute(""" op.execute(
CREATE TABLE llm_configs ( """
id SERIAL PRIMARY KEY, DO $$
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), BEGIN
name VARCHAR(100) NOT NULL, IF NOT EXISTS (
provider litellmprovider NOT NULL, SELECT FROM information_schema.tables
custom_provider VARCHAR(100), WHERE table_name = 'llm_configs'
model_name VARCHAR(100) NOT NULL, ) THEN
api_key TEXT NOT NULL, CREATE TABLE llm_configs (
api_base VARCHAR(500), id SERIAL PRIMARY KEY,
litellm_params JSONB, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE name VARCHAR(100) NOT NULL,
) provider litellmprovider NOT NULL,
""") custom_provider VARCHAR(100),
model_name VARCHAR(100) NOT NULL,
api_key TEXT NOT NULL,
api_base VARCHAR(500),
litellm_params JSONB,
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
);
END IF;
END$$;
"""
)
# Create indexes # Create indexes if they don't exist
op.create_index(op.f('ix_llm_configs_id'), 'llm_configs', ['id'], unique=False) op.execute(
op.create_index(op.f('ix_llm_configs_created_at'), 'llm_configs', ['created_at'], unique=False) """
op.create_index(op.f('ix_llm_configs_name'), 'llm_configs', ['name'], unique=False) DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'llm_configs' AND indexname = 'ix_llm_configs_id'
) THEN
CREATE INDEX ix_llm_configs_id ON llm_configs(id);
END IF;
# Add LLM preference columns to user table IF NOT EXISTS (
op.add_column('user', sa.Column('long_context_llm_id', sa.Integer(), nullable=True)) SELECT 1 FROM pg_indexes
op.add_column('user', sa.Column('fast_llm_id', sa.Integer(), nullable=True)) WHERE tablename = 'llm_configs' AND indexname = 'ix_llm_configs_created_at'
op.add_column('user', sa.Column('strategic_llm_id', sa.Integer(), nullable=True)) ) THEN
CREATE INDEX ix_llm_configs_created_at ON llm_configs(created_at);
END IF;
# Create foreign key constraints for LLM preferences IF NOT EXISTS (
op.create_foreign_key(op.f('fk_user_long_context_llm_id_llm_configs'), 'user', 'llm_configs', ['long_context_llm_id'], ['id'], ondelete='SET NULL') SELECT 1 FROM pg_indexes
op.create_foreign_key(op.f('fk_user_fast_llm_id_llm_configs'), 'user', 'llm_configs', ['fast_llm_id'], ['id'], ondelete='SET NULL') WHERE tablename = 'llm_configs' AND indexname = 'ix_llm_configs_name'
op.create_foreign_key(op.f('fk_user_strategic_llm_id_llm_configs'), 'user', 'llm_configs', ['strategic_llm_id'], ['id'], ondelete='SET NULL') ) THEN
CREATE INDEX ix_llm_configs_name ON llm_configs(name);
END IF;
END$$;
"""
)
# Safely add columns to user table
bind = op.get_bind()
inspector = sa.inspect(bind)
existing_columns = [col["name"] for col in inspector.get_columns("user")]
with op.batch_alter_table("user") as batch_op:
if "long_context_llm_id" not in existing_columns:
batch_op.add_column(
sa.Column("long_context_llm_id", sa.Integer(), nullable=True)
)
batch_op.create_foreign_key(
op.f("fk_user_long_context_llm_id_llm_configs"),
"llm_configs",
["long_context_llm_id"],
["id"],
ondelete="SET NULL",
)
if "fast_llm_id" not in existing_columns:
batch_op.add_column(sa.Column("fast_llm_id", sa.Integer(), nullable=True))
batch_op.create_foreign_key(
op.f("fk_user_fast_llm_id_llm_configs"),
"llm_configs",
["fast_llm_id"],
["id"],
ondelete="SET NULL",
)
if "strategic_llm_id" not in existing_columns:
batch_op.add_column(
sa.Column("strategic_llm_id", sa.Integer(), nullable=True)
)
batch_op.create_foreign_key(
op.f("fk_user_strategic_llm_id_llm_configs"),
"llm_configs",
["strategic_llm_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema - remove LLMConfig table and user LLM preferences.""" """Downgrade schema - remove LLMConfig table and user LLM preferences."""
# Drop foreign key constraints # Drop foreign key constraints
op.drop_constraint(op.f('fk_user_strategic_llm_id_llm_configs'), 'user', type_='foreignkey') op.drop_constraint(
op.drop_constraint(op.f('fk_user_fast_llm_id_llm_configs'), 'user', type_='foreignkey') op.f("fk_user_strategic_llm_id_llm_configs"), "user", type_="foreignkey"
op.drop_constraint(op.f('fk_user_long_context_llm_id_llm_configs'), 'user', type_='foreignkey') )
op.drop_constraint(
op.f("fk_user_fast_llm_id_llm_configs"), "user", type_="foreignkey"
)
op.drop_constraint(
op.f("fk_user_long_context_llm_id_llm_configs"), "user", type_="foreignkey"
)
# Drop LLM preference columns from user table # Drop LLM preference columns from user table
op.drop_column('user', 'strategic_llm_id') op.drop_column("user", "strategic_llm_id")
op.drop_column('user', 'fast_llm_id') op.drop_column("user", "fast_llm_id")
op.drop_column('user', 'long_context_llm_id') op.drop_column("user", "long_context_llm_id")
# Drop indexes and table # Drop indexes and table
op.drop_index(op.f('ix_llm_configs_name'), table_name='llm_configs') op.drop_index(op.f("ix_llm_configs_name"), table_name="llm_configs")
op.drop_index(op.f('ix_llm_configs_created_at'), table_name='llm_configs') op.drop_index(op.f("ix_llm_configs_created_at"), table_name="llm_configs")
op.drop_index(op.f('ix_llm_configs_id'), table_name='llm_configs') op.drop_index(op.f("ix_llm_configs_id"), table_name="llm_configs")
op.drop_table('llm_configs') op.drop_table("llm_configs")
# Drop LiteLLMProvider enum # Drop LiteLLMProvider enum
op.execute("DROP TYPE IF EXISTS litellmprovider") op.execute("DROP TYPE IF EXISTS litellmprovider")

View file

@ -7,9 +7,7 @@ Revises: 11
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "12" revision: str = "12"
@ -21,50 +19,76 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema - add LogLevel and LogStatus enums and logs table.""" """Upgrade schema - add LogLevel and LogStatus enums and logs table."""
# Create LogLevel enum # Create LogLevel enum if it doesn't exist
op.execute(""" op.execute(
CREATE TYPE loglevel AS ENUM ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL') """
""") DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'loglevel') THEN
CREATE TYPE loglevel AS ENUM ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL');
END IF;
END$$;
"""
)
# Create LogStatus enum # Create LogStatus enum if it doesn't exist
op.execute(""" op.execute(
CREATE TYPE logstatus AS ENUM ('IN_PROGRESS', 'SUCCESS', 'FAILED') """
""") DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'logstatus') THEN
CREATE TYPE logstatus AS ENUM ('IN_PROGRESS', 'SUCCESS', 'FAILED');
END IF;
END$$;
"""
)
# Create logs table # Create logs table if it doesn't exist
op.execute(""" op.execute(
CREATE TABLE logs ( """
CREATE TABLE IF NOT EXISTS logs (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
level loglevel NOT NULL, level loglevel NOT NULL,
status logstatus NOT NULL, status logstatus NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
source VARCHAR(200), source VARCHAR(200),
log_metadata JSONB DEFAULT '{}', log_metadata JSONB DEFAULT '{}',
search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE
) );
""") """
)
# Create indexes # Get existing indexes
op.create_index(op.f('ix_logs_id'), 'logs', ['id'], unique=False) conn = op.get_bind()
op.create_index(op.f('ix_logs_created_at'), 'logs', ['created_at'], unique=False) inspector = inspect(conn)
op.create_index(op.f('ix_logs_level'), 'logs', ['level'], unique=False) existing_indexes = [idx["name"] for idx in inspector.get_indexes("logs")]
op.create_index(op.f('ix_logs_status'), 'logs', ['status'], unique=False)
op.create_index(op.f('ix_logs_source'), 'logs', ['source'], unique=False) # Create indexes only if they don't already exist
if "ix_logs_id" not in existing_indexes:
op.create_index("ix_logs_id", "logs", ["id"])
if "ix_logs_created_at" not in existing_indexes:
op.create_index("ix_logs_created_at", "logs", ["created_at"])
if "ix_logs_level" not in existing_indexes:
op.create_index("ix_logs_level", "logs", ["level"])
if "ix_logs_status" not in existing_indexes:
op.create_index("ix_logs_status", "logs", ["status"])
if "ix_logs_source" not in existing_indexes:
op.create_index("ix_logs_source", "logs", ["source"])
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema - remove logs table and enums.""" """Downgrade schema - remove logs table and enums."""
# Drop indexes # Drop indexes
op.drop_index(op.f('ix_logs_source'), table_name='logs') op.drop_index("ix_logs_source", table_name="logs")
op.drop_index(op.f('ix_logs_status'), table_name='logs') op.drop_index("ix_logs_status", table_name="logs")
op.drop_index(op.f('ix_logs_level'), table_name='logs') op.drop_index("ix_logs_level", table_name="logs")
op.drop_index(op.f('ix_logs_created_at'), table_name='logs') op.drop_index("ix_logs_created_at", table_name="logs")
op.drop_index(op.f('ix_logs_id'), table_name='logs') op.drop_index("ix_logs_id", table_name="logs")
# Drop logs table # Drop logs table
op.drop_table('logs') op.drop_table("logs")
# Drop enums # Drop enums
op.execute("DROP TYPE IF EXISTS logstatus") op.execute("DROP TYPE IF EXISTS logstatus")

View file

@ -0,0 +1,61 @@
"""Add JIRA_CONNECTOR to enums
Revision ID: 13
Revises: 12
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "13"
down_revision: Union[str, None] = "12"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Safely add 'JIRA_CONNECTOR' to enum types if missing."""
# Add to searchsourceconnectortype enum
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'searchsourceconnectortype' AND e.enumlabel = 'JIRA_CONNECTOR'
) THEN
ALTER TYPE searchsourceconnectortype ADD VALUE 'JIRA_CONNECTOR';
END IF;
END
$$;
"""
)
# Add to documenttype enum
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname = 'documenttype' AND e.enumlabel = 'JIRA_CONNECTOR'
) THEN
ALTER TYPE documenttype ADD VALUE 'JIRA_CONNECTOR';
END IF;
END
$$;
"""
)
def downgrade() -> None:
"""
Downgrade logic not implemented since PostgreSQL
does not support removing enum values.
"""
pass

View file

@ -4,16 +4,17 @@ Revision ID: 1
Revises: Revises:
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa
# Import pgvector if needed for other types, though not for this ENUM change # Import pgvector if needed for other types, though not for this ENUM change
# import pgvector # import pgvector
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '1' revision: str = "1"
down_revision: Union[str, None] = None down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
@ -24,7 +25,23 @@ def upgrade() -> None:
# Manually add the command to add the enum value # Manually add the command to add the enum value
# Note: It's generally better to let autogenerate handle this, but we're bypassing it # Note: It's generally better to let autogenerate handle this, but we're bypassing it
op.execute("ALTER TYPE searchsourceconnectortype ADD VALUE 'GITHUB_CONNECTOR'") op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_enum
WHERE enumlabel = 'GITHUB_CONNECTOR'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'searchsourceconnectortype'
)
) THEN
ALTER TYPE searchsourceconnectortype ADD VALUE 'GITHUB_CONNECTOR';
END IF;
END$$;
"""
)
# Pass for the rest, as autogenerate didn't run to add other schema details # Pass for the rest, as autogenerate didn't run to add other schema details
pass pass
@ -39,14 +56,19 @@ def downgrade() -> None:
# For now, we'll just pass. If you needed to reverse this, you'd likely # For now, we'll just pass. If you needed to reverse this, you'd likely
# have to manually check if 'GITHUB_CONNECTOR' is used in the table # have to manually check if 'GITHUB_CONNECTOR' is used in the table
# and then potentially recreate the type without it. # and then potentially recreate the type without it.
op.execute("ALTER TYPE searchsourceconnectortype RENAME TO searchsourceconnectortype_old") op.execute(
op.execute("CREATE TYPE searchsourceconnectortype AS ENUM('SERPER_API', 'TAVILY_API', 'SLACK_CONNECTOR', 'NOTION_CONNECTOR')") "ALTER TYPE searchsourceconnectortype RENAME TO searchsourceconnectortype_old"
op.execute(( )
"ALTER TABLE search_source_connectors ALTER COLUMN connector_type TYPE searchsourceconnectortype USING " op.execute(
"connector_type::text::searchsourceconnectortype" "CREATE TYPE searchsourceconnectortype AS ENUM('SERPER_API', 'TAVILY_API', 'SLACK_CONNECTOR', 'NOTION_CONNECTOR')"
)) )
op.execute(
(
"ALTER TABLE search_source_connectors ALTER COLUMN connector_type TYPE searchsourceconnectortype USING "
"connector_type::text::searchsourceconnectortype"
)
)
op.execute("DROP TYPE searchsourceconnectortype_old") op.execute("DROP TYPE searchsourceconnectortype_old")
pass pass
# ### end Alembic commands ### # ### end Alembic commands ###

View file

@ -4,40 +4,56 @@ Revision ID: 2
Revises: e55302644c51 Revises: e55302644c51
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '2' revision: str = "2"
down_revision: Union[str, None] = 'e55302644c51' down_revision: Union[str, None] = "e55302644c51"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = 'LINEAR_CONNECTOR'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = 'searchsourceconnectortype'
)
) THEN
ALTER TYPE searchsourceconnectortype ADD VALUE 'LINEAR_CONNECTOR';
END IF;
END$$;
"""
)
# Manually add the command to add the enum value
op.execute("ALTER TYPE searchsourceconnectortype ADD VALUE 'LINEAR_CONNECTOR'")
# Pass for the rest, as autogenerate didn't run to add other schema details #
pass
# ### end Alembic commands ###
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# Downgrading removal of an enum value requires recreating the type # Downgrading removal of an enum value requires recreating the type
op.execute("ALTER TYPE searchsourceconnectortype RENAME TO searchsourceconnectortype_old") op.execute(
op.execute("CREATE TYPE searchsourceconnectortype AS ENUM('SERPER_API', 'TAVILY_API', 'SLACK_CONNECTOR', 'NOTION_CONNECTOR', 'GITHUB_CONNECTOR')") "ALTER TYPE searchsourceconnectortype RENAME TO searchsourceconnectortype_old"
op.execute(( )
"ALTER TABLE search_source_connectors ALTER COLUMN connector_type TYPE searchsourceconnectortype USING " op.execute(
"connector_type::text::searchsourceconnectortype" "CREATE TYPE searchsourceconnectortype AS ENUM('SERPER_API', 'TAVILY_API', 'SLACK_CONNECTOR', 'NOTION_CONNECTOR', 'GITHUB_CONNECTOR')"
)) )
op.execute(
(
"ALTER TABLE search_source_connectors ALTER COLUMN connector_type TYPE searchsourceconnectortype USING "
"connector_type::text::searchsourceconnectortype"
)
)
op.execute("DROP TYPE searchsourceconnectortype_old") op.execute("DROP TYPE searchsourceconnectortype_old")
pass pass

View file

@ -4,25 +4,40 @@ Revision ID: 3
Revises: 2 Revises: 2
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '3' revision: str = "3"
down_revision: Union[str, None] = '2' down_revision: Union[str, None] = "2"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
# Define the ENUM type name and the new value # Define the ENUM type name and the new value
ENUM_NAME = 'documenttype' # Make sure this matches the name in your DB (usually lowercase class name) ENUM_NAME = "documenttype" # Make sure this matches the name in your DB (usually lowercase class name)
NEW_VALUE = 'LINEAR_CONNECTOR' NEW_VALUE = "LINEAR_CONNECTOR"
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
op.execute(f"ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}'") op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{NEW_VALUE}'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = '{ENUM_NAME}'
)
) THEN
ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}';
END IF;
END$$;
"""
)
# Warning: This will delete all rows with the new value # Warning: This will delete all rows with the new value
@ -34,19 +49,19 @@ def downgrade() -> None:
# Enum values *before* LINEAR_CONNECTOR was added # Enum values *before* LINEAR_CONNECTOR was added
old_values = ( old_values = (
'EXTENSION', "EXTENSION",
'CRAWLED_URL', "CRAWLED_URL",
'FILE', "FILE",
'SLACK_CONNECTOR', "SLACK_CONNECTOR",
'NOTION_CONNECTOR', "NOTION_CONNECTOR",
'YOUTUBE_VIDEO', "YOUTUBE_VIDEO",
'GITHUB_CONNECTOR' "GITHUB_CONNECTOR",
) )
old_values_sql = ", ".join([f"'{v}'" for v in old_values]) old_values_sql = ", ".join([f"'{v}'" for v in old_values])
# Table and column names (adjust if different) # Table and column names (adjust if different)
table_name = 'documents' table_name = "documents"
column_name = 'document_type' column_name = "document_type"
# 1. Rename the current enum type # 1. Rename the current enum type
op.execute(f"ALTER TYPE {ENUM_NAME} RENAME TO {old_enum_name}") op.execute(f"ALTER TYPE {ENUM_NAME} RENAME TO {old_enum_name}")
@ -55,9 +70,7 @@ def downgrade() -> None:
op.execute(f"CREATE TYPE {ENUM_NAME} AS ENUM({old_values_sql})") op.execute(f"CREATE TYPE {ENUM_NAME} AS ENUM({old_values_sql})")
# 3. Update the table: # 3. Update the table:
op.execute( op.execute(f"DELETE FROM {table_name} WHERE {column_name}::text = '{NEW_VALUE}'")
f"DELETE FROM {table_name} WHERE {column_name}::text = '{NEW_VALUE}'"
)
# 4. Alter the column to use the new enum type (casting old values) # 4. Alter the column to use the new enum type (casting old values)
op.execute( op.execute(

View file

@ -4,40 +4,56 @@ Revision ID: 4
Revises: 3 Revises: 3
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '4' revision: str = "4"
down_revision: Union[str, None] = '3' down_revision: Union[str, None] = "3"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### ENUM_NAME = "searchsourceconnectortype"
NEW_VALUE = "LINKUP_API"
# Manually add the command to add the enum value op.execute(
op.execute("ALTER TYPE searchsourceconnectortype ADD VALUE 'LINKUP_API'") f"""
DO $$
# Pass for the rest, as autogenerate didn't run to add other schema details BEGIN
pass IF NOT EXISTS (
# ### end Alembic commands ### SELECT 1 FROM pg_enum
WHERE enumlabel = '{NEW_VALUE}'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = '{ENUM_NAME}'
)
) THEN
ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}';
END IF;
END$$;
"""
)
def downgrade() -> None: def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ### # ### commands auto generated by Alembic - please adjust! ###
# Downgrading removal of an enum value requires recreating the type # Downgrading removal of an enum value requires recreating the type
op.execute("ALTER TYPE searchsourceconnectortype RENAME TO searchsourceconnectortype_old") op.execute(
op.execute("CREATE TYPE searchsourceconnectortype AS ENUM('SERPER_API', 'TAVILY_API', 'SLACK_CONNECTOR', 'NOTION_CONNECTOR', 'GITHUB_CONNECTOR', 'LINEAR_CONNECTOR')") "ALTER TYPE searchsourceconnectortype RENAME TO searchsourceconnectortype_old"
op.execute(( )
"ALTER TABLE search_source_connectors ALTER COLUMN connector_type TYPE searchsourceconnectortype USING " op.execute(
"connector_type::text::searchsourceconnectortype" "CREATE TYPE searchsourceconnectortype AS ENUM('SERPER_API', 'TAVILY_API', 'SLACK_CONNECTOR', 'NOTION_CONNECTOR', 'GITHUB_CONNECTOR', 'LINEAR_CONNECTOR')"
)) )
op.execute(
(
"ALTER TABLE search_source_connectors ALTER COLUMN connector_type TYPE searchsourceconnectortype USING "
"connector_type::text::searchsourceconnectortype"
)
)
op.execute("DROP TYPE searchsourceconnectortype_old") op.execute("DROP TYPE searchsourceconnectortype_old")
pass pass

View file

@ -4,40 +4,58 @@ Revision ID: 6
Revises: 5 Revises: 5
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.dialects.postgresql import JSON
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '6' revision: str = "6"
down_revision: Union[str, None] = '5' down_revision: Union[str, None] = "5"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Drop the old column and create a new one with the new name and type bind = op.get_bind()
# We need to do this because PostgreSQL doesn't support direct column renames with type changes inspector = inspect(bind)
op.add_column('podcasts', sa.Column('podcast_transcript', JSON, nullable=False, server_default='{}'))
# Copy data from old column to new column columns = [col["name"] for col in inspector.get_columns("podcasts")]
# Convert text to JSON by storing it as a JSON string value if "podcast_transcript" not in columns:
op.execute("UPDATE podcasts SET podcast_transcript = jsonb_build_object('text', podcast_content) WHERE podcast_content != ''") op.add_column(
"podcasts",
sa.Column("podcast_transcript", JSON, nullable=False, server_default="{}"),
)
# Drop the old column # Copy data from old column to new column
op.drop_column('podcasts', 'podcast_content') op.execute(
"""
UPDATE podcasts
SET podcast_transcript = jsonb_build_object('text', podcast_content)
WHERE podcast_content != ''
"""
)
# Drop the old column only if it exists
if "podcast_content" in columns:
op.drop_column("podcasts", "podcast_content")
def downgrade() -> None: def downgrade() -> None:
# Add back the original column # Add back the original column
op.add_column('podcasts', sa.Column('podcast_content', sa.Text(), nullable=False, server_default='')) op.add_column(
"podcasts",
sa.Column("podcast_content", sa.Text(), nullable=False, server_default=""),
)
# Copy data from JSON column back to text column # Copy data from JSON column back to text column
# Extract the 'text' field if it exists, otherwise use empty string # Extract the 'text' field if it exists, otherwise use empty string
op.execute("UPDATE podcasts SET podcast_content = COALESCE((podcast_transcript->>'text'), '')") op.execute(
"UPDATE podcasts SET podcast_content = COALESCE((podcast_transcript->>'text'), '')"
)
# Drop the new column # Drop the new column
op.drop_column('podcasts', 'podcast_transcript') op.drop_column("podcasts", "podcast_transcript")

View file

@ -4,24 +4,34 @@ Revision ID: 7
Revises: 6 Revises: 6
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '7' revision: str = "7"
down_revision: Union[str, None] = '6' down_revision: Union[str, None] = "6"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Drop the is_generated column # Get the current database connection
op.drop_column('podcasts', 'is_generated') bind = op.get_bind()
inspector = inspect(bind)
# Check if the column exists before attempting to drop it
columns = [col["name"] for col in inspector.get_columns("podcasts")]
if "is_generated" in columns:
op.drop_column("podcasts", "is_generated")
def downgrade() -> None: def downgrade() -> None:
# Add back the is_generated column with its original constraints # Add back the is_generated column with its original constraints
op.add_column('podcasts', sa.Column('is_generated', sa.Boolean(), nullable=False, server_default='false')) op.add_column(
"podcasts",
sa.Column("is_generated", sa.Boolean(), nullable=False, server_default="false"),
)

View file

@ -3,54 +3,68 @@
Revision ID: 8 Revision ID: 8
Revises: 7 Revises: 7
""" """
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = '8' revision: str = "8"
down_revision: Union[str, None] = '7' down_revision: Union[str, None] = "7"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None: def upgrade() -> None:
# Add content_hash column as nullable first to handle existing data bind = op.get_bind()
op.add_column('documents', sa.Column('content_hash', sa.String(), nullable=True)) inspector = inspect(bind)
columns = [col["name"] for col in inspector.get_columns("documents")]
# Update existing documents to generate content hashes # Only add the column if it doesn't already exist
# Using SHA-256 hash of the content column with proper UTF-8 encoding if "content_hash" not in columns:
op.execute(""" op.add_column(
UPDATE documents "documents", sa.Column("content_hash", sa.String(), nullable=True)
SET content_hash = encode(sha256(convert_to(content, 'UTF8')), 'hex')
WHERE content_hash IS NULL
""")
# Handle duplicate content hashes by keeping only the oldest document for each hash
# Delete newer documents with duplicate content hashes
op.execute("""
DELETE FROM documents
WHERE id NOT IN (
SELECT MIN(id)
FROM documents
GROUP BY content_hash
) )
""")
# Now alter the column to match the model: nullable=False, index=True, unique=True # Populate the content_hash column
op.alter_column('documents', 'content_hash', op.execute(
existing_type=sa.String(), """
nullable=False) UPDATE documents
op.create_index(op.f('ix_documents_content_hash'), 'documents', ['content_hash'], unique=False) SET content_hash = encode(sha256(convert_to(content, 'UTF8')), 'hex')
op.create_unique_constraint(op.f('uq_documents_content_hash'), 'documents', ['content_hash']) WHERE content_hash IS NULL
"""
)
op.execute(
"""
DELETE FROM documents
WHERE id NOT IN (
SELECT MIN(id)
FROM documents
GROUP BY content_hash
)
"""
)
op.alter_column(
"documents", "content_hash", existing_type=sa.String(), nullable=False
)
op.create_index(
op.f("ix_documents_content_hash"),
"documents",
["content_hash"],
unique=False,
)
op.create_unique_constraint(
op.f("uq_documents_content_hash"), "documents", ["content_hash"]
)
else:
print("Column 'content_hash' already exists. Skipping column creation.")
def downgrade() -> None: def downgrade() -> None:
# Remove constraints and index first op.drop_constraint(op.f("uq_documents_content_hash"), "documents", type_="unique")
op.drop_constraint(op.f('uq_documents_content_hash'), 'documents', type_='unique') op.drop_index(op.f("ix_documents_content_hash"), table_name="documents")
op.drop_index(op.f('ix_documents_content_hash'), table_name='documents') op.drop_column("documents", "content_hash")
# Remove content_hash column from documents table
op.drop_column('documents', 'content_hash')

View file

@ -7,8 +7,6 @@ Revises: 8
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = "9" revision: str = "9"
@ -24,11 +22,38 @@ DOCUMENT_NEW_VALUE = "DISCORD_CONNECTOR"
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema - add DISCORD_CONNECTOR to connector and document enum.""" """Upgrade schema - add DISCORD_CONNECTOR to connector and document enum safely."""
# Add DISCORD_CONNECTOR to searchsourceconnectortype # Add DISCORD_CONNECTOR to searchsourceconnectortype only if not exists
op.execute(f"ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}'") op.execute(
# Add DISCORD_CONNECTOR to documenttype f"""
op.execute(f"ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}'") DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{CONNECTOR_NEW_VALUE}'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{CONNECTOR_ENUM}')
) THEN
ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}';
END IF;
END$$;
"""
)
# Add DISCORD_CONNECTOR to documenttype only if not exists
op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{DOCUMENT_NEW_VALUE}'
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{DOCUMENT_ENUM}')
) THEN
ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}';
END IF;
END$$;
"""
)
def downgrade() -> None: def downgrade() -> None:
@ -85,7 +110,6 @@ def downgrade() -> None:
# 4. Drop the old connector enum type # 4. Drop the old connector enum type
op.execute(f"DROP TYPE {old_connector_enum_name}") op.execute(f"DROP TYPE {old_connector_enum_name}")
# Document Enum Downgrade Steps # Document Enum Downgrade Steps
# 1. Rename the current document enum type # 1. Rename the current document enum type
op.execute(f"ALTER TYPE {DOCUMENT_ENUM} RENAME TO {old_document_enum_name}") op.execute(f"ALTER TYPE {DOCUMENT_ENUM} RENAME TO {old_document_enum_name}")

View file

@ -1,69 +1,67 @@
"""Add GITHUB_CONNECTOR to DocumentType enum
Revision ID: e55302644c51
Revises: 1
"""
from typing import Sequence, Union from typing import Sequence, Union
from alembic import op from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic. # revision identifiers, used by Alembic.
revision: str = 'e55302644c51' revision: str = "e55302644c51"
down_revision: Union[str, None] = '1' down_revision: Union[str, None] = "1"
branch_labels: Union[str, Sequence[str], None] = None branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None
# Define the ENUM type name and the new value # Define the ENUM type name and the new value
ENUM_NAME = 'documenttype' # Make sure this matches the name in your DB (usually lowercase class name) ENUM_NAME = "documenttype"
NEW_VALUE = 'GITHUB_CONNECTOR' NEW_VALUE = "GITHUB_CONNECTOR"
def upgrade() -> None: def upgrade() -> None:
"""Upgrade schema.""" """Upgrade schema."""
op.execute(f"ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}'") op.execute(
f"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumlabel = '{NEW_VALUE}'
AND enumtypid = (
SELECT oid FROM pg_type WHERE typname = '{ENUM_NAME}'
)
) THEN
ALTER TYPE {ENUM_NAME} ADD VALUE '{NEW_VALUE}';
END IF;
END$$;
"""
)
# Warning: This will delete all rows with the new value
def downgrade() -> None: def downgrade() -> None:
"""Downgrade schema - remove GITHUB_CONNECTOR from enum.""" """Downgrade schema - remove GITHUB_CONNECTOR from enum."""
# The old type name
old_enum_name = f"{ENUM_NAME}_old" old_enum_name = f"{ENUM_NAME}_old"
# Enum values *before* GITHUB_CONNECTOR was added
old_values = ( old_values = (
'EXTENSION', "EXTENSION",
'CRAWLED_URL', "CRAWLED_URL",
'FILE', "FILE",
'SLACK_CONNECTOR', "SLACK_CONNECTOR",
'NOTION_CONNECTOR', "NOTION_CONNECTOR",
'YOUTUBE_VIDEO' "YOUTUBE_VIDEO",
) )
old_values_sql = ", ".join([f"'{v}'" for v in old_values]) old_values_sql = ", ".join([f"'{v}'" for v in old_values])
# Table and column names (adjust if different) table_name = "documents"
table_name = 'documents' column_name = "document_type"
column_name = 'document_type'
# 1. Rename the current enum type # 1. Create the new enum type with the old values
op.execute(f"ALTER TYPE {ENUM_NAME} RENAME TO {old_enum_name}") op.execute(f"CREATE TYPE {old_enum_name} AS ENUM({old_values_sql})")
# 2. Create the new enum type with the old values # 2. Delete rows using the new value
op.execute(f"CREATE TYPE {ENUM_NAME} AS ENUM({old_values_sql})") op.execute(f"DELETE FROM {table_name} WHERE {column_name}::text = '{NEW_VALUE}'")
# 3. Update the table: # 3. Alter the column to use the old enum type
op.execute(
f"DELETE FROM {table_name} WHERE {column_name}::text = '{NEW_VALUE}'"
)
# 4. Alter the column to use the new enum type (casting old values)
op.execute( op.execute(
f"ALTER TABLE {table_name} ALTER COLUMN {column_name} " f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
f"TYPE {ENUM_NAME} USING {column_name}::text::{ENUM_NAME}" f"TYPE {old_enum_name} USING {column_name}::text::{old_enum_name}"
) )
# 5. Drop the old enum type # 4. Drop the current enum type and rename the old one
op.execute(f"DROP TYPE {old_enum_name}") op.execute(f"DROP TYPE {ENUM_NAME}")
# ### end Alembic commands ### op.execute(f"ALTER TYPE {old_enum_name} RENAME TO {ENUM_NAME}")

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,8 @@ You are SurfSense, an advanced AI research assistant that provides detailed, wel
- YOUTUBE_VIDEO: "YouTube video transcripts and metadata" (personally saved videos) - YOUTUBE_VIDEO: "YouTube video transcripts and metadata" (personally saved videos)
- GITHUB_CONNECTOR: "GitHub repository content and issues" (personal repositories and interactions) - GITHUB_CONNECTOR: "GitHub repository content and issues" (personal repositories and interactions)
- LINEAR_CONNECTOR: "Linear project issues and discussions" (personal project management) - LINEAR_CONNECTOR: "Linear project issues and discussions" (personal project management)
- DISCORD_CONNECTOR: "Discord server messages and channels" (personal community interactions) - JIRA_CONNECTOR: "Jira project issues, tickets, and comments" (personal project tracking)
- DISCORD_CONNECTOR: "Discord server conversations and shared content" (personal community communications)
- TAVILY_API: "Tavily search API results" (personalized search results) - TAVILY_API: "Tavily search API results" (personalized search results)
- LINKUP_API: "Linkup search API results" (personalized search results) - LINKUP_API: "Linkup search API results" (personalized search results)
</knowledge_sources> </knowledge_sources>

View file

@ -33,6 +33,8 @@ def get_connector_emoji(connector_name: str) -> str:
"NOTION_CONNECTOR": "📘", "NOTION_CONNECTOR": "📘",
"GITHUB_CONNECTOR": "🐙", "GITHUB_CONNECTOR": "🐙",
"LINEAR_CONNECTOR": "📊", "LINEAR_CONNECTOR": "📊",
"JIRA_CONNECTOR": "🎫",
"DISCORD_CONNECTOR": "🗨️",
"TAVILY_API": "🔍", "TAVILY_API": "🔍",
"LINKUP_API": "🔗" "LINKUP_API": "🔗"
} }
@ -50,6 +52,8 @@ def get_connector_friendly_name(connector_name: str) -> str:
"NOTION_CONNECTOR": "Notion", "NOTION_CONNECTOR": "Notion",
"GITHUB_CONNECTOR": "GitHub", "GITHUB_CONNECTOR": "GitHub",
"LINEAR_CONNECTOR": "Linear", "LINEAR_CONNECTOR": "Linear",
"JIRA_CONNECTOR": "Jira",
"DISCORD_CONNECTOR": "Discord",
"TAVILY_API": "Tavily Search", "TAVILY_API": "Tavily Search",
"LINKUP_API": "Linkup Search" "LINKUP_API": "Linkup Search"
} }

View file

@ -0,0 +1,487 @@
"""
Jira Connector Module
A module for retrieving data from Jira.
Allows fetching issue lists and their comments, projects and more.
"""
import base64
from datetime import datetime
from typing import Any, Dict, List, Optional
import requests
class JiraConnector:
"""Class for retrieving data from Jira."""
def __init__(
self,
base_url: Optional[str] = None,
email: Optional[str] = None,
api_token: Optional[str] = None,
):
"""
Initialize the JiraConnector class.
Args:
base_url: Jira instance base URL (e.g., 'https://yourcompany.atlassian.net') (optional)
email: Jira account email address (optional)
api_token: Jira API token (optional)
"""
self.base_url = base_url.rstrip("/") if base_url else None
self.email = email
self.api_token = api_token
self.api_version = "3" # Jira Cloud API version
def set_credentials(self, base_url: str, email: str, api_token: str) -> None:
"""
Set the Jira credentials.
Args:
base_url: Jira instance base URL
email: Jira account email address
api_token: Jira API token
"""
self.base_url = base_url.rstrip("/")
self.email = email
self.api_token = api_token
def set_email(self, email: str) -> None:
"""
Set the Jira account email.
Args:
email: Jira account email address
"""
self.email = email
def set_api_token(self, api_token: str) -> None:
"""
Set the Jira API token.
Args:
api_token: Jira API token
"""
self.api_token = api_token
def get_headers(self) -> Dict[str, str]:
"""
Get headers for Jira API requests using Basic Authentication.
Returns:
Dictionary of headers
Raises:
ValueError: If email, api_token, or base_url have not been set
"""
if not all([self.base_url, self.email, self.api_token]):
raise ValueError(
"Jira credentials not initialized. Call set_credentials() first."
)
# Create Basic Auth header using email:api_token
auth_str = f"{self.email}:{self.api_token}"
auth_bytes = auth_str.encode("utf-8")
auth_header = "Basic " + base64.b64encode(auth_bytes).decode("ascii")
return {
"Content-Type": "application/json",
"Authorization": auth_header,
"Accept": "application/json",
}
def make_api_request(
self, endpoint: str, params: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Make a request to the Jira API.
Args:
endpoint: API endpoint (without base URL)
params: Query parameters for the request (optional)
Returns:
Response data from the API
Raises:
ValueError: If email, api_token, or base_url have not been set
Exception: If the API request fails
"""
if not all([self.base_url, self.email, self.api_token]):
raise ValueError(
"Jira credentials not initialized. Call set_credentials() first."
)
url = f"{self.base_url}/rest/api/{self.api_version}/{endpoint}"
headers = self.get_headers()
response = requests.get(url, headers=headers, params=params, timeout=500)
if response.status_code == 200:
return response.json()
else:
raise Exception(
f"API request failed with status code {response.status_code}: {response.text}"
)
def get_all_projects(self) -> dict[str, Any]:
"""
Fetch all projects from Jira.
Returns:
List of project objects
Raises:
ValueError: If credentials have not been set
Exception: If the API request fails
"""
return self.make_api_request("project/search")
def get_all_issues(self, project_key: Optional[str] = None) -> List[Dict[str, Any]]:
"""
Fetch all issues from Jira.
Args:
project_key: Optional project key to filter issues (e.g., 'PROJ')
Returns:
List of issue objects
Raises:
ValueError: If credentials have not been set
Exception: If the API request fails
"""
jql = "ORDER BY created DESC"
if project_key:
jql = f'project = "{project_key}" ' + jql
fields = [
"summary",
"description",
"status",
"assignee",
"reporter",
"created",
"updated",
"priority",
"issuetype",
"project",
]
params = {
"jql": jql,
"fields": ",".join(fields),
"maxResults": 100,
"startAt": 0,
}
all_issues = []
start_at = 0
while True:
params["startAt"] = start_at
result = self.make_api_request("search", params)
if not isinstance(result, dict) or "issues" not in result:
raise Exception("Invalid response from Jira API")
issues = result["issues"]
all_issues.extend(issues)
print(f"Fetched {len(issues)} issues (startAt={start_at})")
total = result.get("total", 0)
if start_at + len(issues) >= total:
break
start_at += len(issues)
return all_issues
def get_issues_by_date_range(
self,
start_date: str,
end_date: str,
include_comments: bool = True,
project_key: Optional[str] = None,
) -> tuple[List[Dict[str, Any]], Optional[str]]:
"""
Fetch issues within a date range.
Args:
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format (inclusive)
include_comments: Whether to include comments in the response
project_key: Optional project key to filter issues
Returns:
Tuple containing (issues list, error message or None)
"""
try:
# Build JQL query for date range
# Query issues that were either created OR updated within the date range
date_filter = (
f"(createdDate >= '{start_date}' AND createdDate <= '{end_date}')"
)
# TODO : This JQL needs some improvement to work as expected
jql = f"{date_filter}"
if project_key:
jql = (
f'project = "{project_key}" AND {date_filter} ORDER BY created DESC'
)
# Define fields to retrieve
fields = [
"summary",
"description",
"status",
"assignee",
"reporter",
"created",
"updated",
"priority",
"issuetype",
"project",
]
if include_comments:
fields.append("comment")
params = {
# "jql": "", TODO : Add a JQL query to filter from a date range
"fields": ",".join(fields),
"maxResults": 100,
"startAt": 0,
}
all_issues = []
start_at = 0
while True:
params["startAt"] = start_at
result = self.make_api_request("search", params)
if not isinstance(result, dict) or "issues" not in result:
return [], "Invalid response from Jira API"
issues = result["issues"]
all_issues.extend(issues)
# Check if there are more issues to fetch
total = result.get("total", 0)
if start_at + len(issues) >= total:
break
start_at += len(issues)
if not all_issues:
return [], "No issues found in the specified date range."
return all_issues, None
except Exception as e:
return [], f"Error fetching issues: {str(e)}"
def format_issue(self, issue: Dict[str, Any]) -> Dict[str, Any]:
"""
Format an issue for easier consumption.
Args:
issue: The issue object from Jira API
Returns:
Formatted issue dictionary
"""
fields = issue.get("fields", {})
# Extract basic issue details
formatted = {
"id": issue.get("id", ""),
"key": issue.get("key", ""),
"title": fields.get("summary", ""),
"description": fields.get("description", ""),
"status": (
fields.get("status", {}).get("name", "Unknown")
if fields.get("status")
else "Unknown"
),
"status_category": (
fields.get("status", {})
.get("statusCategory", {})
.get("name", "Unknown")
if fields.get("status")
else "Unknown"
),
"priority": (
fields.get("priority", {}).get("name", "Unknown")
if fields.get("priority")
else "Unknown"
),
"issue_type": (
fields.get("issuetype", {}).get("name", "Unknown")
if fields.get("issuetype")
else "Unknown"
),
"project": (
fields.get("project", {}).get("key", "Unknown")
if fields.get("project")
else "Unknown"
),
"created_at": fields.get("created", ""),
"updated_at": fields.get("updated", ""),
"reporter": (
{
"account_id": (
fields.get("reporter", {}).get("accountId", "")
if fields.get("reporter")
else ""
),
"display_name": (
fields.get("reporter", {}).get("displayName", "Unknown")
if fields.get("reporter")
else "Unknown"
),
"email": (
fields.get("reporter", {}).get("emailAddress", "")
if fields.get("reporter")
else ""
),
}
if fields.get("reporter")
else {"account_id": "", "display_name": "Unknown", "email": ""}
),
"assignee": (
{
"account_id": fields.get("assignee", {}).get("accountId", ""),
"display_name": fields.get("assignee", {}).get(
"displayName", "Unknown"
),
"email": fields.get("assignee", {}).get("emailAddress", ""),
}
if fields.get("assignee")
else None
),
"comments": [],
}
# Extract comments if available
if "comment" in fields and "comments" in fields["comment"]:
for comment in fields["comment"]["comments"]:
formatted_comment = {
"id": comment.get("id", ""),
"body": comment.get("body", ""),
"created_at": comment.get("created", ""),
"updated_at": comment.get("updated", ""),
"author": (
{
"account_id": (
comment.get("author", {}).get("accountId", "")
if comment.get("author")
else ""
),
"display_name": (
comment.get("author", {}).get("displayName", "Unknown")
if comment.get("author")
else "Unknown"
),
"email": (
comment.get("author", {}).get("emailAddress", "")
if comment.get("author")
else ""
),
}
if comment.get("author")
else {"account_id": "", "display_name": "Unknown", "email": ""}
),
}
formatted["comments"].append(formatted_comment)
return formatted
def format_issue_to_markdown(self, issue: Dict[str, Any]) -> str:
"""
Convert an issue to markdown format.
Args:
issue: The issue object (either raw or formatted)
Returns:
Markdown string representation of the issue
"""
# Format the issue if it's not already formatted
if "key" not in issue:
issue = self.format_issue(issue)
# Build the markdown content
markdown = (
f"# {issue.get('key', 'No Key')}: {issue.get('title', 'No Title')}\n\n"
)
if issue.get("status"):
markdown += f"**Status:** {issue['status']}\n"
if issue.get("priority"):
markdown += f"**Priority:** {issue['priority']}\n"
if issue.get("issue_type"):
markdown += f"**Type:** {issue['issue_type']}\n"
if issue.get("project"):
markdown += f"**Project:** {issue['project']}\n\n"
if issue.get("assignee") and issue["assignee"].get("display_name"):
markdown += f"**Assignee:** {issue['assignee']['display_name']}\n"
if issue.get("reporter") and issue["reporter"].get("display_name"):
markdown += f"**Reporter:** {issue['reporter']['display_name']}\n"
if issue.get("created_at"):
created_date = self.format_date(issue["created_at"])
markdown += f"**Created:** {created_date}\n"
if issue.get("updated_at"):
updated_date = self.format_date(issue["updated_at"])
markdown += f"**Updated:** {updated_date}\n\n"
if issue.get("description"):
markdown += f"## Description\n\n{issue['description']}\n\n"
if issue.get("comments"):
markdown += f"## Comments ({len(issue['comments'])})\n\n"
for comment in issue["comments"]:
author_name = "Unknown"
if comment.get("author") and comment["author"].get("display_name"):
author_name = comment["author"]["display_name"]
comment_date = "Unknown date"
if comment.get("created_at"):
comment_date = self.format_date(comment["created_at"])
markdown += f"### {author_name} ({comment_date})\n\n{comment.get('body', '')}\n\n---\n\n"
return markdown
@staticmethod
def format_date(iso_date: str) -> str:
"""
Format an ISO date string to a more readable format.
Args:
iso_date: ISO format date string
Returns:
Formatted date string
"""
if not iso_date or not isinstance(iso_date, str):
return "Unknown date"
try:
# Jira dates are typically in format: 2023-01-01T12:00:00.000+0000
dt = datetime.fromisoformat(iso_date.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
return iso_date

View file

@ -2,30 +2,30 @@ from collections.abc import AsyncGenerator
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import Enum from enum import Enum
from app.config import config
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
from fastapi import Depends from fastapi import Depends
from pgvector.sqlalchemy import Vector from pgvector.sqlalchemy import Vector
from sqlalchemy import ( from sqlalchemy import (
ARRAY, ARRAY,
JSON,
TIMESTAMP,
Boolean, Boolean,
Column, Column,
Enum as SQLAlchemyEnum, )
from sqlalchemy import Enum as SQLAlchemyEnum
from sqlalchemy import (
ForeignKey, ForeignKey,
Integer, Integer,
JSON,
String, String,
Text, Text,
text, text,
TIMESTAMP
) )
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, relationship
from app.config import config
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
if config.AUTH_TYPE == "GOOGLE": if config.AUTH_TYPE == "GOOGLE":
from fastapi_users.db import ( from fastapi_users.db import (
SQLAlchemyBaseOAuthAccountTableUUID, SQLAlchemyBaseOAuthAccountTableUUID,
@ -51,9 +51,11 @@ class DocumentType(str, Enum):
GITHUB_CONNECTOR = "GITHUB_CONNECTOR" GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
LINEAR_CONNECTOR = "LINEAR_CONNECTOR" LINEAR_CONNECTOR = "LINEAR_CONNECTOR"
DISCORD_CONNECTOR = "DISCORD_CONNECTOR" DISCORD_CONNECTOR = "DISCORD_CONNECTOR"
JIRA_CONNECTOR = "JIRA_CONNECTOR"
class SearchSourceConnectorType(str, Enum): class SearchSourceConnectorType(str, Enum):
SERPER_API = "SERPER_API" # NOT IMPLEMENTED YET : DON'T REMEMBER WHY : MOST PROBABLY BECAUSE WE NEED TO CRAWL THE RESULTS RETURNED BY IT SERPER_API = "SERPER_API" # NOT IMPLEMENTED YET : DON'T REMEMBER WHY : MOST PROBABLY BECAUSE WE NEED TO CRAWL THE RESULTS RETURNED BY IT
TAVILY_API = "TAVILY_API" TAVILY_API = "TAVILY_API"
LINKUP_API = "LINKUP_API" LINKUP_API = "LINKUP_API"
SLACK_CONNECTOR = "SLACK_CONNECTOR" SLACK_CONNECTOR = "SLACK_CONNECTOR"
@ -61,6 +63,8 @@ class SearchSourceConnectorType(str, Enum):
GITHUB_CONNECTOR = "GITHUB_CONNECTOR" GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
LINEAR_CONNECTOR = "LINEAR_CONNECTOR" LINEAR_CONNECTOR = "LINEAR_CONNECTOR"
DISCORD_CONNECTOR = "DISCORD_CONNECTOR" DISCORD_CONNECTOR = "DISCORD_CONNECTOR"
JIRA_CONNECTOR = "JIRA_CONNECTOR"
class ChatType(str, Enum): class ChatType(str, Enum):
QNA = "QNA" QNA = "QNA"
@ -68,6 +72,7 @@ class ChatType(str, Enum):
REPORT_DEEP = "REPORT_DEEP" REPORT_DEEP = "REPORT_DEEP"
REPORT_DEEPER = "REPORT_DEEPER" REPORT_DEEPER = "REPORT_DEEPER"
class LiteLLMProvider(str, Enum): class LiteLLMProvider(str, Enum):
OPENAI = "OPENAI" OPENAI = "OPENAI"
ANTHROPIC = "ANTHROPIC" ANTHROPIC = "ANTHROPIC"
@ -92,6 +97,7 @@ class LiteLLMProvider(str, Enum):
PETALS = "PETALS" PETALS = "PETALS"
CUSTOM = "CUSTOM" CUSTOM = "CUSTOM"
class LogLevel(str, Enum): class LogLevel(str, Enum):
DEBUG = "DEBUG" DEBUG = "DEBUG"
INFO = "INFO" INFO = "INFO"
@ -99,18 +105,27 @@ class LogLevel(str, Enum):
ERROR = "ERROR" ERROR = "ERROR"
CRITICAL = "CRITICAL" CRITICAL = "CRITICAL"
class LogStatus(str, Enum): class LogStatus(str, Enum):
IN_PROGRESS = "IN_PROGRESS" IN_PROGRESS = "IN_PROGRESS"
SUCCESS = "SUCCESS" SUCCESS = "SUCCESS"
FAILED = "FAILED" FAILED = "FAILED"
class Base(DeclarativeBase): class Base(DeclarativeBase):
pass pass
class TimestampMixin: class TimestampMixin:
@declared_attr @declared_attr
def created_at(cls): def created_at(cls):
return Column(TIMESTAMP(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True) return Column(
TIMESTAMP(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
index=True,
)
class BaseModel(Base): class BaseModel(Base):
__abstract__ = True __abstract__ = True
@ -118,6 +133,7 @@ class BaseModel(Base):
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
class Chat(BaseModel, TimestampMixin): class Chat(BaseModel, TimestampMixin):
__tablename__ = "chats" __tablename__ = "chats"
@ -126,8 +142,11 @@ class Chat(BaseModel, TimestampMixin):
initial_connectors = Column(ARRAY(String), nullable=True) initial_connectors = Column(ARRAY(String), nullable=True)
messages = Column(JSON, nullable=False) messages = Column(JSON, nullable=False)
search_space_id = Column(Integer, ForeignKey('searchspaces.id', ondelete='CASCADE'), nullable=False) search_space_id = Column(
search_space = relationship('SearchSpace', back_populates='chats') Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
)
search_space = relationship("SearchSpace", back_populates="chats")
class Document(BaseModel, TimestampMixin): class Document(BaseModel, TimestampMixin):
__tablename__ = "documents" __tablename__ = "documents"
@ -140,9 +159,14 @@ class Document(BaseModel, TimestampMixin):
content_hash = Column(String, nullable=False, index=True, unique=True) content_hash = Column(String, nullable=False, index=True, unique=True)
embedding = Column(Vector(config.embedding_model_instance.dimension)) embedding = Column(Vector(config.embedding_model_instance.dimension))
search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False) search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
)
search_space = relationship("SearchSpace", back_populates="documents") search_space = relationship("SearchSpace", back_populates="documents")
chunks = relationship("Chunk", back_populates="document", cascade="all, delete-orphan") chunks = relationship(
"Chunk", back_populates="document", cascade="all, delete-orphan"
)
class Chunk(BaseModel, TimestampMixin): class Chunk(BaseModel, TimestampMixin):
__tablename__ = "chunks" __tablename__ = "chunks"
@ -150,9 +174,12 @@ class Chunk(BaseModel, TimestampMixin):
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
embedding = Column(Vector(config.embedding_model_instance.dimension)) embedding = Column(Vector(config.embedding_model_instance.dimension))
document_id = Column(Integer, ForeignKey("documents.id", ondelete='CASCADE'), nullable=False) document_id = Column(
Integer, ForeignKey("documents.id", ondelete="CASCADE"), nullable=False
)
document = relationship("Document", back_populates="chunks") document = relationship("Document", back_populates="chunks")
class Podcast(BaseModel, TimestampMixin): class Podcast(BaseModel, TimestampMixin):
__tablename__ = "podcasts" __tablename__ = "podcasts"
@ -160,35 +187,66 @@ class Podcast(BaseModel, TimestampMixin):
podcast_transcript = Column(JSON, nullable=False, default={}) podcast_transcript = Column(JSON, nullable=False, default={})
file_location = Column(String(500), nullable=False, default="") file_location = Column(String(500), nullable=False, default="")
search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False) search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
)
search_space = relationship("SearchSpace", back_populates="podcasts") search_space = relationship("SearchSpace", back_populates="podcasts")
class SearchSpace(BaseModel, TimestampMixin): class SearchSpace(BaseModel, TimestampMixin):
__tablename__ = "searchspaces" __tablename__ = "searchspaces"
name = Column(String(100), nullable=False, index=True) name = Column(String(100), nullable=False, index=True)
description = Column(String(500), nullable=True) description = Column(String(500), nullable=True)
user_id = Column(UUID(as_uuid=True), ForeignKey("user.id", ondelete='CASCADE'), nullable=False) user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
user = relationship("User", back_populates="search_spaces") user = relationship("User", back_populates="search_spaces")
documents = relationship("Document", back_populates="search_space", order_by="Document.id", cascade="all, delete-orphan") documents = relationship(
podcasts = relationship("Podcast", back_populates="search_space", order_by="Podcast.id", cascade="all, delete-orphan") "Document",
chats = relationship('Chat', back_populates='search_space', order_by='Chat.id', cascade="all, delete-orphan") back_populates="search_space",
logs = relationship("Log", back_populates="search_space", order_by="Log.id", cascade="all, delete-orphan") order_by="Document.id",
cascade="all, delete-orphan",
)
podcasts = relationship(
"Podcast",
back_populates="search_space",
order_by="Podcast.id",
cascade="all, delete-orphan",
)
chats = relationship(
"Chat",
back_populates="search_space",
order_by="Chat.id",
cascade="all, delete-orphan",
)
logs = relationship(
"Log",
back_populates="search_space",
order_by="Log.id",
cascade="all, delete-orphan",
)
class SearchSourceConnector(BaseModel, TimestampMixin): class SearchSourceConnector(BaseModel, TimestampMixin):
__tablename__ = "search_source_connectors" __tablename__ = "search_source_connectors"
name = Column(String(100), nullable=False, index=True) name = Column(String(100), nullable=False, index=True)
connector_type = Column(SQLAlchemyEnum(SearchSourceConnectorType), nullable=False, unique=True) connector_type = Column(
SQLAlchemyEnum(SearchSourceConnectorType), nullable=False, unique=True
)
is_indexable = Column(Boolean, nullable=False, default=False) is_indexable = Column(Boolean, nullable=False, default=False)
last_indexed_at = Column(TIMESTAMP(timezone=True), nullable=True) last_indexed_at = Column(TIMESTAMP(timezone=True), nullable=True)
config = Column(JSON, nullable=False) config = Column(JSON, nullable=False)
user_id = Column(UUID(as_uuid=True), ForeignKey("user.id", ondelete='CASCADE'), nullable=False) user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
user = relationship("User", back_populates="search_source_connectors") user = relationship("User", back_populates="search_source_connectors")
class LLMConfig(BaseModel, TimestampMixin): class LLMConfig(BaseModel, TimestampMixin):
__tablename__ = "llm_configs" __tablename__ = "llm_configs"
@ -206,55 +264,102 @@ class LLMConfig(BaseModel, TimestampMixin):
# For any other parameters that litellm supports # For any other parameters that litellm supports
litellm_params = Column(JSON, nullable=True, default={}) litellm_params = Column(JSON, nullable=True, default={})
user_id = Column(UUID(as_uuid=True), ForeignKey("user.id", ondelete='CASCADE'), nullable=False) user_id = Column(
UUID(as_uuid=True), ForeignKey("user.id", ondelete="CASCADE"), nullable=False
)
user = relationship("User", back_populates="llm_configs", foreign_keys=[user_id]) user = relationship("User", back_populates="llm_configs", foreign_keys=[user_id])
class Log(BaseModel, TimestampMixin): class Log(BaseModel, TimestampMixin):
__tablename__ = "logs" __tablename__ = "logs"
level = Column(SQLAlchemyEnum(LogLevel), nullable=False, index=True) level = Column(SQLAlchemyEnum(LogLevel), nullable=False, index=True)
status = Column(SQLAlchemyEnum(LogStatus), nullable=False, index=True) status = Column(SQLAlchemyEnum(LogStatus), nullable=False, index=True)
message = Column(Text, nullable=False) message = Column(Text, nullable=False)
source = Column(String(200), nullable=True, index=True) # Service/component that generated the log source = Column(
String(200), nullable=True, index=True
) # Service/component that generated the log
log_metadata = Column(JSON, nullable=True, default={}) # Additional context data log_metadata = Column(JSON, nullable=True, default={}) # Additional context data
search_space_id = Column(Integer, ForeignKey("searchspaces.id", ondelete='CASCADE'), nullable=False) search_space_id = Column(
Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False
)
search_space = relationship("SearchSpace", back_populates="logs") search_space = relationship("SearchSpace", back_populates="logs")
if config.AUTH_TYPE == "GOOGLE": if config.AUTH_TYPE == "GOOGLE":
class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base):
pass pass
class User(SQLAlchemyBaseUserTableUUID, Base): class User(SQLAlchemyBaseUserTableUUID, Base):
oauth_accounts: Mapped[list[OAuthAccount]] = relationship( oauth_accounts: Mapped[list[OAuthAccount]] = relationship(
"OAuthAccount", lazy="joined" "OAuthAccount", lazy="joined"
) )
search_spaces = relationship("SearchSpace", back_populates="user") search_spaces = relationship("SearchSpace", back_populates="user")
search_source_connectors = relationship("SearchSourceConnector", back_populates="user") search_source_connectors = relationship(
llm_configs = relationship("LLMConfig", back_populates="user", foreign_keys="LLMConfig.user_id", cascade="all, delete-orphan") "SearchSourceConnector", back_populates="user"
)
llm_configs = relationship(
"LLMConfig",
back_populates="user",
foreign_keys="LLMConfig.user_id",
cascade="all, delete-orphan",
)
long_context_llm_id = Column(Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True) long_context_llm_id = Column(
fast_llm_id = Column(Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True) Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True
strategic_llm_id = Column(Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True) )
fast_llm_id = Column(
Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True
)
strategic_llm_id = Column(
Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True
)
long_context_llm = relationship(
"LLMConfig", foreign_keys=[long_context_llm_id], post_update=True
)
fast_llm = relationship(
"LLMConfig", foreign_keys=[fast_llm_id], post_update=True
)
strategic_llm = relationship(
"LLMConfig", foreign_keys=[strategic_llm_id], post_update=True
)
long_context_llm = relationship("LLMConfig", foreign_keys=[long_context_llm_id], post_update=True)
fast_llm = relationship("LLMConfig", foreign_keys=[fast_llm_id], post_update=True)
strategic_llm = relationship("LLMConfig", foreign_keys=[strategic_llm_id], post_update=True)
else: else:
class User(SQLAlchemyBaseUserTableUUID, Base): class User(SQLAlchemyBaseUserTableUUID, Base):
search_spaces = relationship("SearchSpace", back_populates="user") search_spaces = relationship("SearchSpace", back_populates="user")
search_source_connectors = relationship("SearchSourceConnector", back_populates="user") search_source_connectors = relationship(
llm_configs = relationship("LLMConfig", back_populates="user", foreign_keys="LLMConfig.user_id", cascade="all, delete-orphan") "SearchSourceConnector", back_populates="user"
)
llm_configs = relationship(
"LLMConfig",
back_populates="user",
foreign_keys="LLMConfig.user_id",
cascade="all, delete-orphan",
)
long_context_llm_id = Column(Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True) long_context_llm_id = Column(
fast_llm_id = Column(Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True) Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True
strategic_llm_id = Column(Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True) )
fast_llm_id = Column(
Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True
)
strategic_llm_id = Column(
Integer, ForeignKey("llm_configs.id", ondelete="SET NULL"), nullable=True
)
long_context_llm = relationship("LLMConfig", foreign_keys=[long_context_llm_id], post_update=True) long_context_llm = relationship(
fast_llm = relationship("LLMConfig", foreign_keys=[fast_llm_id], post_update=True) "LLMConfig", foreign_keys=[long_context_llm_id], post_update=True
strategic_llm = relationship("LLMConfig", foreign_keys=[strategic_llm_id], post_update=True) )
fast_llm = relationship(
"LLMConfig", foreign_keys=[fast_llm_id], post_update=True
)
strategic_llm = relationship(
"LLMConfig", foreign_keys=[strategic_llm_id], post_update=True
)
engine = create_async_engine(DATABASE_URL) engine = create_async_engine(DATABASE_URL)
@ -265,15 +370,32 @@ async def setup_indexes():
async with engine.begin() as conn: async with engine.begin() as conn:
# Create indexes # Create indexes
# Document Summary Indexes # Document Summary Indexes
await conn.execute(text('CREATE INDEX IF NOT EXISTS document_vector_index ON documents USING hnsw (embedding public.vector_cosine_ops)')) await conn.execute(
await conn.execute(text('CREATE INDEX IF NOT EXISTS document_search_index ON documents USING gin (to_tsvector(\'english\', content))')) text(
"CREATE INDEX IF NOT EXISTS document_vector_index ON documents USING hnsw (embedding public.vector_cosine_ops)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS document_search_index ON documents USING gin (to_tsvector('english', content))"
)
)
# Document Chuck Indexes # Document Chuck Indexes
await conn.execute(text('CREATE INDEX IF NOT EXISTS chucks_vector_index ON chunks USING hnsw (embedding public.vector_cosine_ops)')) await conn.execute(
await conn.execute(text('CREATE INDEX IF NOT EXISTS chucks_search_index ON chunks USING gin (to_tsvector(\'english\', content))')) text(
"CREATE INDEX IF NOT EXISTS chucks_vector_index ON chunks USING hnsw (embedding public.vector_cosine_ops)"
)
)
await conn.execute(
text(
"CREATE INDEX IF NOT EXISTS chucks_search_index ON chunks USING gin (to_tsvector('english', content))"
)
)
async def create_db_and_tables(): async def create_db_and_tables():
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector')) await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
await setup_indexes() await setup_indexes()
@ -284,14 +406,23 @@ async def get_async_session() -> AsyncGenerator[AsyncSession, None]:
if config.AUTH_TYPE == "GOOGLE": if config.AUTH_TYPE == "GOOGLE":
async def get_user_db(session: AsyncSession = Depends(get_async_session)): async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User, OAuthAccount) yield SQLAlchemyUserDatabase(session, User, OAuthAccount)
else: else:
async def get_user_db(session: AsyncSession = Depends(get_async_session)): async def get_user_db(session: AsyncSession = Depends(get_async_session)):
yield SQLAlchemyUserDatabase(session, User) yield SQLAlchemyUserDatabase(session, User)
async def get_chucks_hybrid_search_retriever(session: AsyncSession = Depends(get_async_session)):
async def get_chucks_hybrid_search_retriever(
session: AsyncSession = Depends(get_async_session),
):
return ChucksHybridSearchRetriever(session) return ChucksHybridSearchRetriever(session)
async def get_documents_hybrid_search_retriever(session: AsyncSession = Depends(get_async_session)):
async def get_documents_hybrid_search_retriever(
session: AsyncSession = Depends(get_async_session),
):
return DocumentHybridSearchRetriever(session) return DocumentHybridSearchRetriever(session)

View file

@ -9,35 +9,58 @@ POST /search-source-connectors/{connector_id}/index - Index content from a conne
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR, DISCORD_CONNECTOR). Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR, DISCORD_CONNECTOR).
""" """
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks, Body
from sqlalchemy.ext.asyncio import AsyncSession import logging
from sqlalchemy.future import select from datetime import datetime, timedelta
from sqlalchemy.exc import IntegrityError from typing import Any, Dict, List
from typing import List, Dict, Any
from app.db import get_async_session, User, SearchSourceConnector, SearchSourceConnectorType, SearchSpace, async_session_maker from app.connectors.github_connector import GitHubConnector
from app.schemas import SearchSourceConnectorCreate, SearchSourceConnectorUpdate, SearchSourceConnectorRead, SearchSourceConnectorBase from app.db import (
SearchSourceConnector,
SearchSourceConnectorType,
SearchSpace,
User,
async_session_maker,
get_async_session,
)
from app.schemas import (
SearchSourceConnectorBase,
SearchSourceConnectorCreate,
SearchSourceConnectorRead,
SearchSourceConnectorUpdate,
)
from app.tasks.connectors_indexing_tasks import (
index_discord_messages,
index_github_repos,
index_jira_issues,
index_linear_issues,
index_notion_pages,
index_slack_messages,
)
from app.users import current_active_user from app.users import current_active_user
from app.utils.check_ownership import check_ownership from app.utils.check_ownership import check_ownership
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from pydantic import BaseModel, Field, ValidationError from pydantic import BaseModel, Field, ValidationError
from app.tasks.connectors_indexing_tasks import index_slack_messages, index_notion_pages, index_github_repos, index_linear_issues, index_discord_messages from sqlalchemy.exc import IntegrityError
from app.connectors.github_connector import GitHubConnector from sqlalchemy.ext.asyncio import AsyncSession
from datetime import datetime, timedelta from sqlalchemy.future import select
import logging
# Set up logging # Set up logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Use Pydantic's BaseModel here # Use Pydantic's BaseModel here
class GitHubPATRequest(BaseModel): class GitHubPATRequest(BaseModel):
github_pat: str = Field(..., description="GitHub Personal Access Token") github_pat: str = Field(..., description="GitHub Personal Access Token")
# --- New Endpoint to list GitHub Repositories --- # --- New Endpoint to list GitHub Repositories ---
@router.post("/github/repositories/", response_model=List[Dict[str, Any]]) @router.post("/github/repositories/", response_model=List[Dict[str, Any]])
async def list_github_repositories( async def list_github_repositories(
pat_request: GitHubPATRequest, pat_request: GitHubPATRequest,
user: User = Depends(current_active_user) # Ensure the user is logged in user: User = Depends(current_active_user), # Ensure the user is logged in
): ):
""" """
Fetches a list of repositories accessible by the provided GitHub PAT. Fetches a list of repositories accessible by the provided GitHub PAT.
@ -54,14 +77,19 @@ async def list_github_repositories(
logger.error(f"GitHub PAT validation failed for user {user.id}: {str(e)}") logger.error(f"GitHub PAT validation failed for user {user.id}: {str(e)}")
raise HTTPException(status_code=400, detail=f"Invalid GitHub PAT: {str(e)}") raise HTTPException(status_code=400, detail=f"Invalid GitHub PAT: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f"Failed to fetch GitHub repositories for user {user.id}: {str(e)}") logger.error(
raise HTTPException(status_code=500, detail="Failed to fetch GitHub repositories.") f"Failed to fetch GitHub repositories for user {user.id}: {str(e)}"
)
raise HTTPException(
status_code=500, detail="Failed to fetch GitHub repositories."
)
@router.post("/search-source-connectors/", response_model=SearchSourceConnectorRead) @router.post("/search-source-connectors/", response_model=SearchSourceConnectorRead)
async def create_search_source_connector( async def create_search_source_connector(
connector: SearchSourceConnectorCreate, connector: SearchSourceConnectorCreate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user) user: User = Depends(current_active_user),
): ):
""" """
Create a new search source connector. Create a new search source connector.
@ -72,17 +100,16 @@ async def create_search_source_connector(
try: try:
# Check if a connector with the same type already exists for this user # Check if a connector with the same type already exists for this user
result = await session.execute( result = await session.execute(
select(SearchSourceConnector) select(SearchSourceConnector).filter(
.filter(
SearchSourceConnector.user_id == user.id, SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type == connector.connector_type SearchSourceConnector.connector_type == connector.connector_type,
) )
) )
existing_connector = result.scalars().first() existing_connector = result.scalars().first()
if existing_connector: if existing_connector:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"A connector with type {connector.connector_type} already exists. Each user can have only one connector of each type." detail=f"A connector with type {connector.connector_type} already exists. Each user can have only one connector of each type.",
) )
db_connector = SearchSourceConnector(**connector.model_dump(), user_id=user.id) db_connector = SearchSourceConnector(**connector.model_dump(), user_id=user.id)
session.add(db_connector) session.add(db_connector)
@ -91,15 +118,12 @@ async def create_search_source_connector(
return db_connector return db_connector
except ValidationError as e: except ValidationError as e:
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
status_code=422,
detail=f"Validation error: {str(e)}"
)
except IntegrityError as e: except IntegrityError as e:
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"Integrity error: A connector with this type already exists. {str(e)}" detail=f"Integrity error: A connector with this type already exists. {str(e)}",
) )
except HTTPException: except HTTPException:
await session.rollback() await session.rollback()
@ -109,38 +133,44 @@ async def create_search_source_connector(
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to create search source connector: {str(e)}" detail=f"Failed to create search source connector: {str(e)}",
) )
@router.get("/search-source-connectors/", response_model=List[SearchSourceConnectorRead])
@router.get(
"/search-source-connectors/", response_model=List[SearchSourceConnectorRead]
)
async def read_search_source_connectors( async def read_search_source_connectors(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
search_space_id: int = None, search_space_id: int = None,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user) user: User = Depends(current_active_user),
): ):
"""List all search source connectors for the current user.""" """List all search source connectors for the current user."""
try: try:
query = select(SearchSourceConnector).filter(SearchSourceConnector.user_id == user.id) query = select(SearchSourceConnector).filter(
SearchSourceConnector.user_id == user.id
)
# No need to filter by search_space_id as connectors are user-owned, not search space specific # No need to filter by search_space_id as connectors are user-owned, not search space specific
result = await session.execute( result = await session.execute(query.offset(skip).limit(limit))
query.offset(skip).limit(limit)
)
return result.scalars().all() return result.scalars().all()
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to fetch search source connectors: {str(e)}" detail=f"Failed to fetch search source connectors: {str(e)}",
) )
@router.get("/search-source-connectors/{connector_id}", response_model=SearchSourceConnectorRead)
@router.get(
"/search-source-connectors/{connector_id}", response_model=SearchSourceConnectorRead
)
async def read_search_source_connector( async def read_search_source_connector(
connector_id: int, connector_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user) user: User = Depends(current_active_user),
): ):
"""Get a specific search source connector by ID.""" """Get a specific search source connector by ID."""
try: try:
@ -149,30 +179,36 @@ async def read_search_source_connector(
raise raise
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail=f"Failed to fetch search source connector: {str(e)}"
detail=f"Failed to fetch search source connector: {str(e)}"
) )
@router.put("/search-source-connectors/{connector_id}", response_model=SearchSourceConnectorRead)
@router.put(
"/search-source-connectors/{connector_id}", response_model=SearchSourceConnectorRead
)
async def update_search_source_connector( async def update_search_source_connector(
connector_id: int, connector_id: int,
connector_update: SearchSourceConnectorUpdate, connector_update: SearchSourceConnectorUpdate,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user) user: User = Depends(current_active_user),
): ):
""" """
Update a search source connector. Update a search source connector.
Handles partial updates, including merging changes into the 'config' field. Handles partial updates, including merging changes into the 'config' field.
""" """
db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user) db_connector = await check_ownership(
session, SearchSourceConnector, connector_id, user
)
# Convert the sparse update data (only fields present in request) to a dict # Convert the sparse update data (only fields present in request) to a dict
update_data = connector_update.model_dump(exclude_unset=True) update_data = connector_update.model_dump(exclude_unset=True)
# Special handling for 'config' field # Special handling for 'config' field
if "config" in update_data: if "config" in update_data:
incoming_config = update_data["config"] # Config data from the request incoming_config = update_data["config"] # Config data from the request
existing_config = db_connector.config if db_connector.config else {} # Current config from DB existing_config = (
db_connector.config if db_connector.config else {}
) # Current config from DB
# Merge incoming config into existing config # Merge incoming config into existing config
# This preserves existing keys (like GITHUB_PAT) if they are not in the incoming data # This preserves existing keys (like GITHUB_PAT) if they are not in the incoming data
@ -182,24 +218,27 @@ async def update_search_source_connector(
# -- Validation after merging -- # -- Validation after merging --
# Validate the *merged* config based on the connector type # Validate the *merged* config based on the connector type
# We need the connector type - use the one from the update if provided, else the existing one # We need the connector type - use the one from the update if provided, else the existing one
current_connector_type = connector_update.connector_type if connector_update.connector_type is not None else db_connector.connector_type current_connector_type = (
connector_update.connector_type
if connector_update.connector_type is not None
else db_connector.connector_type
)
try: try:
# We can reuse the base validator by creating a temporary base model instance # We can reuse the base validator by creating a temporary base model instance
# Note: This assumes 'name' and 'is_indexable' are not crucial for config validation itself # Note: This assumes 'name' and 'is_indexable' are not crucial for config validation itself
temp_data_for_validation = { temp_data_for_validation = {
"name": db_connector.name, # Use existing name "name": db_connector.name, # Use existing name
"connector_type": current_connector_type, "connector_type": current_connector_type,
"is_indexable": db_connector.is_indexable, # Use existing value "is_indexable": db_connector.is_indexable, # Use existing value
"last_indexed_at": db_connector.last_indexed_at, # Not used by validator "last_indexed_at": db_connector.last_indexed_at, # Not used by validator
"config": merged_config "config": merged_config,
} }
SearchSourceConnectorBase.model_validate(temp_data_for_validation) SearchSourceConnectorBase.model_validate(temp_data_for_validation)
except ValidationError as e: except ValidationError as e:
# Raise specific validation error for the merged config # Raise specific validation error for the merged config
raise HTTPException( raise HTTPException(
status_code=422, status_code=422, detail=f"Validation error for merged config: {str(e)}"
detail=f"Validation error for merged config: {str(e)}"
) )
# If validation passes, update the main update_data dict with the merged config # If validation passes, update the main update_data dict with the merged config
@ -210,18 +249,17 @@ async def update_search_source_connector(
# Prevent changing connector_type if it causes a duplicate (check moved here) # Prevent changing connector_type if it causes a duplicate (check moved here)
if key == "connector_type" and value != db_connector.connector_type: if key == "connector_type" and value != db_connector.connector_type:
result = await session.execute( result = await session.execute(
select(SearchSourceConnector) select(SearchSourceConnector).filter(
.filter(
SearchSourceConnector.user_id == user.id, SearchSourceConnector.user_id == user.id,
SearchSourceConnector.connector_type == value, SearchSourceConnector.connector_type == value,
SearchSourceConnector.id != connector_id SearchSourceConnector.id != connector_id,
) )
) )
existing_connector = result.scalars().first() existing_connector = result.scalars().first()
if existing_connector: if existing_connector:
raise HTTPException( raise HTTPException(
status_code=409, status_code=409,
detail=f"A connector with type {value} already exists. Each user can have only one connector of each type." detail=f"A connector with type {value} already exists. Each user can have only one connector of each type.",
) )
setattr(db_connector, key, value) setattr(db_connector, key, value)
@ -234,26 +272,31 @@ async def update_search_source_connector(
await session.rollback() await session.rollback()
# This might occur if connector_type constraint is violated somehow after the check # This might occur if connector_type constraint is violated somehow after the check
raise HTTPException( raise HTTPException(
status_code=409, status_code=409, detail=f"Database integrity error during update: {str(e)}"
detail=f"Database integrity error during update: {str(e)}"
) )
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
logger.error(f"Failed to update search source connector {connector_id}: {e}", exc_info=True) logger.error(
f"Failed to update search source connector {connector_id}: {e}",
exc_info=True,
)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to update search source connector: {str(e)}" detail=f"Failed to update search source connector: {str(e)}",
) )
@router.delete("/search-source-connectors/{connector_id}", response_model=dict) @router.delete("/search-source-connectors/{connector_id}", response_model=dict)
async def delete_search_source_connector( async def delete_search_source_connector(
connector_id: int, connector_id: int,
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user) user: User = Depends(current_active_user),
): ):
"""Delete a search source connector.""" """Delete a search source connector."""
try: try:
db_connector = await check_ownership(session, SearchSourceConnector, connector_id, user) db_connector = await check_ownership(
session, SearchSourceConnector, connector_id, user
)
await session.delete(db_connector) await session.delete(db_connector)
await session.commit() await session.commit()
return {"message": "Search source connector deleted successfully"} return {"message": "Search source connector deleted successfully"}
@ -263,18 +306,29 @@ async def delete_search_source_connector(
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=f"Failed to delete search source connector: {str(e)}" detail=f"Failed to delete search source connector: {str(e)}",
) )
@router.post("/search-source-connectors/{connector_id}/index", response_model=Dict[str, Any])
@router.post(
"/search-source-connectors/{connector_id}/index", response_model=Dict[str, Any]
)
async def index_connector_content( async def index_connector_content(
connector_id: int, connector_id: int,
search_space_id: int = Query(..., description="ID of the search space to store indexed content"), search_space_id: int = Query(
start_date: str = Query(None, description="Start date for indexing (YYYY-MM-DD format). If not provided, uses last_indexed_at or defaults to 365 days ago"), ..., description="ID of the search space to store indexed content"
end_date: str = Query(None, description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date"), ),
start_date: str = Query(
None,
description="Start date for indexing (YYYY-MM-DD format). If not provided, uses last_indexed_at or defaults to 365 days ago",
),
end_date: str = Query(
None,
description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date",
),
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
background_tasks: BackgroundTasks = None background_tasks: BackgroundTasks = None,
): ):
""" """
Index content from a connector to a search space. Index content from a connector to a search space.
@ -284,6 +338,7 @@ async def index_connector_content(
- NOTION_CONNECTOR: Indexes pages from all accessible Notion pages - NOTION_CONNECTOR: Indexes pages from all accessible Notion pages
- GITHUB_CONNECTOR: Indexes code and documentation from GitHub repositories - GITHUB_CONNECTOR: Indexes code and documentation from GitHub repositories
- LINEAR_CONNECTOR: Indexes issues and comments from Linear - LINEAR_CONNECTOR: Indexes issues and comments from Linear
- JIRA_CONNECTOR: Indexes issues and comments from Jira
- DISCORD_CONNECTOR: Indexes messages from all accessible Discord channels - DISCORD_CONNECTOR: Indexes messages from all accessible Discord channels
Args: Args:
@ -296,10 +351,14 @@ async def index_connector_content(
""" """
try: try:
# Check if the connector belongs to the user # Check if the connector belongs to the user
connector = await check_ownership(session, SearchSourceConnector, connector_id, user) connector = await check_ownership(
session, SearchSourceConnector, connector_id, user
)
# Check if the search space belongs to the user # Check if the search space belongs to the user
search_space = await check_ownership(session, SearchSpace, search_space_id, user) search_space = await check_ownership(
session, SearchSpace, search_space_id, user
)
# Handle different connector types # Handle different connector types
response_message = "" response_message = ""
@ -316,7 +375,9 @@ async def index_connector_content(
else: else:
indexing_from = connector.last_indexed_at.strftime("%Y-%m-%d") indexing_from = connector.last_indexed_at.strftime("%Y-%m-%d")
else: else:
indexing_from = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") indexing_from = (datetime.now() - timedelta(days=365)).strftime(
"%Y-%m-%d"
)
else: else:
indexing_from = start_date indexing_from = start_date
@ -327,42 +388,98 @@ async def index_connector_content(
if connector.connector_type == SearchSourceConnectorType.SLACK_CONNECTOR: if connector.connector_type == SearchSourceConnectorType.SLACK_CONNECTOR:
# Run indexing in background # Run indexing in background
logger.info(f"Triggering Slack indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}") logger.info(
background_tasks.add_task(run_slack_indexing_with_new_session, connector_id, search_space_id, str(user.id), indexing_from, indexing_to) f"Triggering Slack indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
)
background_tasks.add_task(
run_slack_indexing_with_new_session,
connector_id,
search_space_id,
str(user.id),
indexing_from,
indexing_to,
)
response_message = "Slack indexing started in the background." response_message = "Slack indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR: elif connector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR:
# Run indexing in background # Run indexing in background
logger.info(f"Triggering Notion indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}") logger.info(
background_tasks.add_task(run_notion_indexing_with_new_session, connector_id, search_space_id, str(user.id), indexing_from, indexing_to) f"Triggering Notion indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
)
background_tasks.add_task(
run_notion_indexing_with_new_session,
connector_id,
search_space_id,
str(user.id),
indexing_from,
indexing_to,
)
response_message = "Notion indexing started in the background." response_message = "Notion indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR: elif connector.connector_type == SearchSourceConnectorType.GITHUB_CONNECTOR:
# Run indexing in background # Run indexing in background
logger.info(f"Triggering GitHub indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}") logger.info(
background_tasks.add_task(run_github_indexing_with_new_session, connector_id, search_space_id, str(user.id), indexing_from, indexing_to) f"Triggering GitHub indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
)
background_tasks.add_task(
run_github_indexing_with_new_session,
connector_id,
search_space_id,
str(user.id),
indexing_from,
indexing_to,
)
response_message = "GitHub indexing started in the background." response_message = "GitHub indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR: elif connector.connector_type == SearchSourceConnectorType.LINEAR_CONNECTOR:
# Run indexing in background # Run indexing in background
logger.info(f"Triggering Linear indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}") logger.info(
background_tasks.add_task(run_linear_indexing_with_new_session, connector_id, search_space_id, str(user.id), indexing_from, indexing_to) f"Triggering Linear indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
)
background_tasks.add_task(
run_linear_indexing_with_new_session,
connector_id,
search_space_id,
str(user.id),
indexing_from,
indexing_to,
)
response_message = "Linear indexing started in the background." response_message = "Linear indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.JIRA_CONNECTOR:
# Run indexing in background
logger.info(
f"Triggering Jira indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
)
background_tasks.add_task(
run_jira_indexing_with_new_session,
connector_id,
search_space_id,
str(user.id),
indexing_from,
indexing_to,
)
response_message = "Jira indexing started in the background."
elif connector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR: elif connector.connector_type == SearchSourceConnectorType.DISCORD_CONNECTOR:
# Run indexing in background # Run indexing in background
logger.info( logger.info(
f"Triggering Discord indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}" f"Triggering Discord indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
) )
background_tasks.add_task( background_tasks.add_task(
run_discord_indexing_with_new_session, connector_id, search_space_id, str(user.id), indexing_from, indexing_to run_discord_indexing_with_new_session,
connector_id,
search_space_id,
str(user.id),
indexing_from,
indexing_to,
) )
response_message = "Discord indexing started in the background." response_message = "Discord indexing started in the background."
else: else:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Indexing not supported for connector type: {connector.connector_type}" detail=f"Indexing not supported for connector type: {connector.connector_type}",
) )
return { return {
@ -370,21 +487,21 @@ async def index_connector_content(
"connector_id": connector_id, "connector_id": connector_id,
"search_space_id": search_space_id, "search_space_id": search_space_id,
"indexing_from": indexing_from, "indexing_from": indexing_from,
"indexing_to": indexing_to "indexing_to": indexing_to,
} }
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to initiate indexing for connector {connector_id}: {e}", exc_info=True) logger.error(
f"Failed to initiate indexing for connector {connector_id}: {e}",
exc_info=True,
)
raise HTTPException( raise HTTPException(
status_code=500, status_code=500, detail=f"Failed to initiate indexing: {str(e)}"
detail=f"Failed to initiate indexing: {str(e)}"
) )
async def update_connector_last_indexed(
session: AsyncSession, async def update_connector_last_indexed(session: AsyncSession, connector_id: int):
connector_id: int
):
""" """
Update the last_indexed_at timestamp for a connector. Update the last_indexed_at timestamp for a connector.
@ -394,8 +511,9 @@ async def update_connector_last_indexed(
""" """
try: try:
result = await session.execute( result = await session.execute(
select(SearchSourceConnector) select(SearchSourceConnector).filter(
.filter(SearchSourceConnector.id == connector_id) SearchSourceConnector.id == connector_id
)
) )
connector = result.scalars().first() connector = result.scalars().first()
@ -404,22 +522,28 @@ async def update_connector_last_indexed(
await session.commit() await session.commit()
logger.info(f"Updated last_indexed_at for connector {connector_id}") logger.info(f"Updated last_indexed_at for connector {connector_id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to update last_indexed_at for connector {connector_id}: {str(e)}") logger.error(
f"Failed to update last_indexed_at for connector {connector_id}: {str(e)}"
)
await session.rollback() await session.rollback()
async def run_slack_indexing_with_new_session( async def run_slack_indexing_with_new_session(
connector_id: int, connector_id: int,
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
""" """
Create a new session and run the Slack indexing task. Create a new session and run the Slack indexing task.
This prevents session leaks by creating a dedicated session for the background task. This prevents session leaks by creating a dedicated session for the background task.
""" """
async with async_session_maker() as session: async with async_session_maker() as session:
await run_slack_indexing(session, connector_id, search_space_id, user_id, start_date, end_date) await run_slack_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
async def run_slack_indexing( async def run_slack_indexing(
session: AsyncSession, session: AsyncSession,
@ -427,7 +551,7 @@ async def run_slack_indexing(
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
""" """
Background task to run Slack indexing. Background task to run Slack indexing.
@ -449,31 +573,39 @@ async def run_slack_indexing(
user_id=user_id, user_id=user_id,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
update_last_indexed=False # Don't update timestamp in the indexing function update_last_indexed=False, # Don't update timestamp in the indexing function
) )
# Only update last_indexed_at if indexing was successful (either new docs or updated docs) # Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0: if documents_processed > 0:
await update_connector_last_indexed(session, connector_id) await update_connector_last_indexed(session, connector_id)
logger.info(f"Slack indexing completed successfully: {documents_processed} documents processed") logger.info(
f"Slack indexing completed successfully: {documents_processed} documents processed"
)
else: else:
logger.error(f"Slack indexing failed or no documents processed: {error_or_warning}") logger.error(
f"Slack indexing failed or no documents processed: {error_or_warning}"
)
except Exception as e: except Exception as e:
logger.error(f"Error in background Slack indexing task: {str(e)}") logger.error(f"Error in background Slack indexing task: {str(e)}")
async def run_notion_indexing_with_new_session( async def run_notion_indexing_with_new_session(
connector_id: int, connector_id: int,
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
""" """
Create a new session and run the Notion indexing task. Create a new session and run the Notion indexing task.
This prevents session leaks by creating a dedicated session for the background task. This prevents session leaks by creating a dedicated session for the background task.
""" """
async with async_session_maker() as session: async with async_session_maker() as session:
await run_notion_indexing(session, connector_id, search_space_id, user_id, start_date, end_date) await run_notion_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
async def run_notion_indexing( async def run_notion_indexing(
session: AsyncSession, session: AsyncSession,
@ -481,7 +613,7 @@ async def run_notion_indexing(
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
""" """
Background task to run Notion indexing. Background task to run Notion indexing.
@ -503,112 +635,158 @@ async def run_notion_indexing(
user_id=user_id, user_id=user_id,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
update_last_indexed=False # Don't update timestamp in the indexing function update_last_indexed=False, # Don't update timestamp in the indexing function
) )
# Only update last_indexed_at if indexing was successful (either new docs or updated docs) # Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0: if documents_processed > 0:
await update_connector_last_indexed(session, connector_id) await update_connector_last_indexed(session, connector_id)
logger.info(f"Notion indexing completed successfully: {documents_processed} documents processed") logger.info(
f"Notion indexing completed successfully: {documents_processed} documents processed"
)
else: else:
logger.error(f"Notion indexing failed or no documents processed: {error_or_warning}") logger.error(
f"Notion indexing failed or no documents processed: {error_or_warning}"
)
except Exception as e: except Exception as e:
logger.error(f"Error in background Notion indexing task: {str(e)}") logger.error(f"Error in background Notion indexing task: {str(e)}")
# Add new helper functions for GitHub indexing # Add new helper functions for GitHub indexing
async def run_github_indexing_with_new_session( async def run_github_indexing_with_new_session(
connector_id: int, connector_id: int,
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
"""Wrapper to run GitHub indexing with its own database session.""" """Wrapper to run GitHub indexing with its own database session."""
logger.info(f"Background task started: Indexing GitHub connector {connector_id} into space {search_space_id} from {start_date} to {end_date}") logger.info(
f"Background task started: Indexing GitHub connector {connector_id} into space {search_space_id} from {start_date} to {end_date}"
)
async with async_session_maker() as session: async with async_session_maker() as session:
await run_github_indexing(session, connector_id, search_space_id, user_id, start_date, end_date) await run_github_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
logger.info(f"Background task finished: Indexing GitHub connector {connector_id}") logger.info(f"Background task finished: Indexing GitHub connector {connector_id}")
async def run_github_indexing( async def run_github_indexing(
session: AsyncSession, session: AsyncSession,
connector_id: int, connector_id: int,
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
"""Runs the GitHub indexing task and updates the timestamp.""" """Runs the GitHub indexing task and updates the timestamp."""
try: try:
indexed_count, error_message = await index_github_repos( indexed_count, error_message = await index_github_repos(
session, connector_id, search_space_id, user_id, start_date, end_date, update_last_indexed=False session,
connector_id,
search_space_id,
user_id,
start_date,
end_date,
update_last_indexed=False,
) )
if error_message: if error_message:
logger.error(f"GitHub indexing failed for connector {connector_id}: {error_message}") logger.error(
f"GitHub indexing failed for connector {connector_id}: {error_message}"
)
# Optionally update status in DB to indicate failure # Optionally update status in DB to indicate failure
else: else:
logger.info(f"GitHub indexing successful for connector {connector_id}. Indexed {indexed_count} documents.") logger.info(
f"GitHub indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success # Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id) await update_connector_last_indexed(session, connector_id)
await session.commit() # Commit timestamp update await session.commit() # Commit timestamp update
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
logger.error(f"Critical error in run_github_indexing for connector {connector_id}: {e}", exc_info=True) logger.error(
f"Critical error in run_github_indexing for connector {connector_id}: {e}",
exc_info=True,
)
# Optionally update status in DB to indicate failure # Optionally update status in DB to indicate failure
# Add new helper functions for Linear indexing # Add new helper functions for Linear indexing
async def run_linear_indexing_with_new_session( async def run_linear_indexing_with_new_session(
connector_id: int, connector_id: int,
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
"""Wrapper to run Linear indexing with its own database session.""" """Wrapper to run Linear indexing with its own database session."""
logger.info(f"Background task started: Indexing Linear connector {connector_id} into space {search_space_id} from {start_date} to {end_date}") logger.info(
f"Background task started: Indexing Linear connector {connector_id} into space {search_space_id} from {start_date} to {end_date}"
)
async with async_session_maker() as session: async with async_session_maker() as session:
await run_linear_indexing(session, connector_id, search_space_id, user_id, start_date, end_date) await run_linear_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
logger.info(f"Background task finished: Indexing Linear connector {connector_id}") logger.info(f"Background task finished: Indexing Linear connector {connector_id}")
async def run_linear_indexing( async def run_linear_indexing(
session: AsyncSession, session: AsyncSession,
connector_id: int, connector_id: int,
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
"""Runs the Linear indexing task and updates the timestamp.""" """Runs the Linear indexing task and updates the timestamp."""
try: try:
indexed_count, error_message = await index_linear_issues( indexed_count, error_message = await index_linear_issues(
session, connector_id, search_space_id, user_id, start_date, end_date, update_last_indexed=False session,
connector_id,
search_space_id,
user_id,
start_date,
end_date,
update_last_indexed=False,
) )
if error_message: if error_message:
logger.error(f"Linear indexing failed for connector {connector_id}: {error_message}") logger.error(
f"Linear indexing failed for connector {connector_id}: {error_message}"
)
# Optionally update status in DB to indicate failure # Optionally update status in DB to indicate failure
else: else:
logger.info(f"Linear indexing successful for connector {connector_id}. Indexed {indexed_count} documents.") logger.info(
f"Linear indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success # Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id) await update_connector_last_indexed(session, connector_id)
await session.commit() # Commit timestamp update await session.commit() # Commit timestamp update
except Exception as e: except Exception as e:
await session.rollback() await session.rollback()
logger.error(f"Critical error in run_linear_indexing for connector {connector_id}: {e}", exc_info=True) logger.error(
f"Critical error in run_linear_indexing for connector {connector_id}: {e}",
exc_info=True,
)
# Optionally update status in DB to indicate failure # Optionally update status in DB to indicate failure
# Add new helper functions for discord indexing # Add new helper functions for discord indexing
async def run_discord_indexing_with_new_session( async def run_discord_indexing_with_new_session(
connector_id: int, connector_id: int,
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
""" """
Create a new session and run the Discord indexing task. Create a new session and run the Discord indexing task.
This prevents session leaks by creating a dedicated session for the background task. This prevents session leaks by creating a dedicated session for the background task.
""" """
async with async_session_maker() as session: async with async_session_maker() as session:
await run_discord_indexing(session, connector_id, search_space_id, user_id, start_date, end_date) await run_discord_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
async def run_discord_indexing( async def run_discord_indexing(
session: AsyncSession, session: AsyncSession,
@ -616,7 +794,7 @@ async def run_discord_indexing(
search_space_id: int, search_space_id: int,
user_id: str, user_id: str,
start_date: str, start_date: str,
end_date: str end_date: str,
): ):
""" """
Background task to run Discord indexing. Background task to run Discord indexing.
@ -637,14 +815,76 @@ async def run_discord_indexing(
user_id=user_id, user_id=user_id,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,
update_last_indexed=False # Don't update timestamp in the indexing function update_last_indexed=False, # Don't update timestamp in the indexing function
) )
# Only update last_indexed_at if indexing was successful (either new docs or updated docs) # Only update last_indexed_at if indexing was successful (either new docs or updated docs)
if documents_processed > 0: if documents_processed > 0:
await update_connector_last_indexed(session, connector_id) await update_connector_last_indexed(session, connector_id)
logger.info(f"Discord indexing completed successfully: {documents_processed} documents processed") logger.info(
f"Discord indexing completed successfully: {documents_processed} documents processed"
)
else: else:
logger.error(f"Discord indexing failed or no documents processed: {error_or_warning}") logger.error(
f"Discord indexing failed or no documents processed: {error_or_warning}"
)
except Exception as e: except Exception as e:
logger.error(f"Error in background Discord indexing task: {str(e)}") logger.error(f"Error in background Discord indexing task: {str(e)}")
# Add new helper functions for Jira indexing
async def run_jira_indexing_with_new_session(
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Wrapper to run Jira indexing with its own database session."""
logger.info(
f"Background task started: Indexing Jira connector {connector_id} into space {search_space_id} from {start_date} to {end_date}"
)
async with async_session_maker() as session:
await run_jira_indexing(
session, connector_id, search_space_id, user_id, start_date, end_date
)
logger.info(f"Background task finished: Indexing Jira connector {connector_id}")
async def run_jira_indexing(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str,
end_date: str,
):
"""Runs the Jira indexing task and updates the timestamp."""
try:
indexed_count, error_message = await index_jira_issues(
session,
connector_id,
search_space_id,
user_id,
start_date,
end_date,
update_last_indexed=False,
)
if error_message:
logger.error(
f"Jira indexing failed for connector {connector_id}: {error_message}"
)
# Optionally update status in DB to indicate failure
else:
logger.info(
f"Jira indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
)
# Update the last indexed timestamp only on success
await update_connector_last_indexed(session, connector_id)
await session.commit() # Commit timestamp update
except Exception as e:
logger.error(
f"Critical error in run_jira_indexing for connector {connector_id}: {e}",
exc_info=True,
)
# Optionally update status in DB to indicate failure

View file

@ -1,9 +1,12 @@
from datetime import datetime
import uuid import uuid
from typing import Dict, Any, Optional from datetime import datetime
from pydantic import BaseModel, field_validator, ConfigDict from typing import Any, Dict, Optional
from .base import IDModel, TimestampModel
from app.db import SearchSourceConnectorType from app.db import SearchSourceConnectorType
from pydantic import BaseModel, ConfigDict, field_validator
from .base import IDModel, TimestampModel
class SearchSourceConnectorBase(BaseModel): class SearchSourceConnectorBase(BaseModel):
name: str name: str
@ -12,16 +15,20 @@ class SearchSourceConnectorBase(BaseModel):
last_indexed_at: Optional[datetime] = None last_indexed_at: Optional[datetime] = None
config: Dict[str, Any] config: Dict[str, Any]
@field_validator('config') @field_validator("config")
@classmethod @classmethod
def validate_config_for_connector_type(cls, config: Dict[str, Any], values: Dict[str, Any]) -> Dict[str, Any]: def validate_config_for_connector_type(
connector_type = values.data.get('connector_type') cls, config: Dict[str, Any], values: Dict[str, Any]
) -> Dict[str, Any]:
connector_type = values.data.get("connector_type")
if connector_type == SearchSourceConnectorType.SERPER_API: if connector_type == SearchSourceConnectorType.SERPER_API:
# For SERPER_API, only allow SERPER_API_KEY # For SERPER_API, only allow SERPER_API_KEY
allowed_keys = ["SERPER_API_KEY"] allowed_keys = ["SERPER_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For SERPER_API connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For SERPER_API connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the API key is not empty # Ensure the API key is not empty
if not config.get("SERPER_API_KEY"): if not config.get("SERPER_API_KEY"):
@ -31,7 +38,9 @@ class SearchSourceConnectorBase(BaseModel):
# For TAVILY_API, only allow TAVILY_API_KEY # For TAVILY_API, only allow TAVILY_API_KEY
allowed_keys = ["TAVILY_API_KEY"] allowed_keys = ["TAVILY_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For TAVILY_API connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For TAVILY_API connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the API key is not empty # Ensure the API key is not empty
if not config.get("TAVILY_API_KEY"): if not config.get("TAVILY_API_KEY"):
@ -41,7 +50,9 @@ class SearchSourceConnectorBase(BaseModel):
# For LINKUP_API, only allow LINKUP_API_KEY # For LINKUP_API, only allow LINKUP_API_KEY
allowed_keys = ["LINKUP_API_KEY"] allowed_keys = ["LINKUP_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For LINKUP_API connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For LINKUP_API connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the API key is not empty # Ensure the API key is not empty
if not config.get("LINKUP_API_KEY"): if not config.get("LINKUP_API_KEY"):
@ -51,7 +62,9 @@ class SearchSourceConnectorBase(BaseModel):
# For SLACK_CONNECTOR, only allow SLACK_BOT_TOKEN # For SLACK_CONNECTOR, only allow SLACK_BOT_TOKEN
allowed_keys = ["SLACK_BOT_TOKEN"] allowed_keys = ["SLACK_BOT_TOKEN"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For SLACK_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For SLACK_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the bot token is not empty # Ensure the bot token is not empty
if not config.get("SLACK_BOT_TOKEN"): if not config.get("SLACK_BOT_TOKEN"):
@ -61,7 +74,9 @@ class SearchSourceConnectorBase(BaseModel):
# For NOTION_CONNECTOR, only allow NOTION_INTEGRATION_TOKEN # For NOTION_CONNECTOR, only allow NOTION_INTEGRATION_TOKEN
allowed_keys = ["NOTION_INTEGRATION_TOKEN"] allowed_keys = ["NOTION_INTEGRATION_TOKEN"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For NOTION_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For NOTION_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the integration token is not empty # Ensure the integration token is not empty
if not config.get("NOTION_INTEGRATION_TOKEN"): if not config.get("NOTION_INTEGRATION_TOKEN"):
@ -71,7 +86,9 @@ class SearchSourceConnectorBase(BaseModel):
# For GITHUB_CONNECTOR, only allow GITHUB_PAT and repo_full_names # For GITHUB_CONNECTOR, only allow GITHUB_PAT and repo_full_names
allowed_keys = ["GITHUB_PAT", "repo_full_names"] allowed_keys = ["GITHUB_PAT", "repo_full_names"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For GITHUB_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the token is not empty # Ensure the token is not empty
if not config.get("GITHUB_PAT"): if not config.get("GITHUB_PAT"):
@ -86,7 +103,9 @@ class SearchSourceConnectorBase(BaseModel):
# For LINEAR_CONNECTOR, only allow LINEAR_API_KEY # For LINEAR_CONNECTOR, only allow LINEAR_API_KEY
allowed_keys = ["LINEAR_API_KEY"] allowed_keys = ["LINEAR_API_KEY"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For LINEAR_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For LINEAR_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the token is not empty # Ensure the token is not empty
if not config.get("LINEAR_API_KEY"): if not config.get("LINEAR_API_KEY"):
@ -96,17 +115,40 @@ class SearchSourceConnectorBase(BaseModel):
# For DISCORD_CONNECTOR, only allow DISCORD_BOT_TOKEN # For DISCORD_CONNECTOR, only allow DISCORD_BOT_TOKEN
allowed_keys = ["DISCORD_BOT_TOKEN"] allowed_keys = ["DISCORD_BOT_TOKEN"]
if set(config.keys()) != set(allowed_keys): if set(config.keys()) != set(allowed_keys):
raise ValueError(f"For DISCORD_CONNECTOR connector type, config must only contain these keys: {allowed_keys}") raise ValueError(
f"For DISCORD_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the bot token is not empty # Ensure the bot token is not empty
if not config.get("DISCORD_BOT_TOKEN"): if not config.get("DISCORD_BOT_TOKEN"):
raise ValueError("DISCORD_BOT_TOKEN cannot be empty") raise ValueError("DISCORD_BOT_TOKEN cannot be empty")
elif connector_type == SearchSourceConnectorType.JIRA_CONNECTOR:
# For JIRA_CONNECTOR, require JIRA_EMAIL, JIRA_API_TOKEN and JIRA_BASE_URL
allowed_keys = ["JIRA_EMAIL", "JIRA_API_TOKEN", "JIRA_BASE_URL"]
if set(config.keys()) != set(allowed_keys):
raise ValueError(
f"For JIRA_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
)
# Ensure the email is not empty
if not config.get("JIRA_EMAIL"):
raise ValueError("JIRA_EMAIL cannot be empty")
# Ensure the API token is not empty
if not config.get("JIRA_API_TOKEN"):
raise ValueError("JIRA_API_TOKEN cannot be empty")
# Ensure the base URL is not empty
if not config.get("JIRA_BASE_URL"):
raise ValueError("JIRA_BASE_URL cannot be empty")
return config return config
class SearchSourceConnectorCreate(SearchSourceConnectorBase): class SearchSourceConnectorCreate(SearchSourceConnectorBase):
pass pass
class SearchSourceConnectorUpdate(BaseModel): class SearchSourceConnectorUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
connector_type: Optional[SearchSourceConnectorType] = None connector_type: Optional[SearchSourceConnectorType] = None
@ -114,6 +156,7 @@ class SearchSourceConnectorUpdate(BaseModel):
last_indexed_at: Optional[datetime] = None last_indexed_at: Optional[datetime] = None
config: Optional[Dict[str, Any]] = None config: Optional[Dict[str, Any]] = None
class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel): class SearchSourceConnectorRead(SearchSourceConnectorBase, IDModel, TimestampModel):
user_id: uuid.UUID user_id: uuid.UUID

View file

@ -1,15 +1,21 @@
from typing import List, Dict, Optional
import asyncio import asyncio
from sqlalchemy.ext.asyncio import AsyncSession from typing import Dict, List, Optional
from sqlalchemy.future import select
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
from app.db import SearchSourceConnector, SearchSourceConnectorType, Chunk, Document, SearchSpace
from tavily import TavilyClient
from linkup import LinkupClient
from sqlalchemy import func
from app.agents.researcher.configuration import SearchMode from app.agents.researcher.configuration import SearchMode
from app.db import (
Chunk,
Document,
SearchSourceConnector,
SearchSourceConnectorType,
SearchSpace,
)
from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
from linkup import LinkupClient
from sqlalchemy import func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from tavily import TavilyClient
class ConnectorService: class ConnectorService:
@ -18,8 +24,12 @@ class ConnectorService:
self.chunk_retriever = ChucksHybridSearchRetriever(session) self.chunk_retriever = ChucksHybridSearchRetriever(session)
self.document_retriever = DocumentHybridSearchRetriever(session) self.document_retriever = DocumentHybridSearchRetriever(session)
self.user_id = user_id self.user_id = user_id
self.source_id_counter = 100000 # High starting value to avoid collisions with existing IDs self.source_id_counter = (
self.counter_lock = asyncio.Lock() # Lock to protect counter in multithreaded environments 100000 # High starting value to avoid collisions with existing IDs
)
self.counter_lock = (
asyncio.Lock()
) # Lock to protect counter in multithreaded environments
async def initialize_counter(self): async def initialize_counter(self):
""" """
@ -38,13 +48,22 @@ class ConnectorService:
) )
chunk_count = result.scalar() or 0 chunk_count = result.scalar() or 0
self.source_id_counter = chunk_count + 1 self.source_id_counter = chunk_count + 1
print(f"Initialized source_id_counter to {self.source_id_counter} for user {self.user_id}") print(
f"Initialized source_id_counter to {self.source_id_counter} for user {self.user_id}"
)
except Exception as e: except Exception as e:
print(f"Error initializing source_id_counter: {str(e)}") print(f"Error initializing source_id_counter: {str(e)}")
# Fallback to default value # Fallback to default value
self.source_id_counter = 1 self.source_id_counter = 1
async def search_crawled_urls(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_crawled_urls(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for crawled URLs and return both the source information and langchain documents Search for crawled URLs and return both the source information and langchain documents
@ -57,7 +76,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="CRAWLED_URL" document_type="CRAWLED_URL",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
crawled_urls_chunks = await self.document_retriever.hybrid_search( crawled_urls_chunks = await self.document_retriever.hybrid_search(
@ -65,7 +84,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="CRAWLED_URL" document_type="CRAWLED_URL",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
crawled_urls_chunks = self._transform_document_results(crawled_urls_chunks) crawled_urls_chunks = self._transform_document_results(crawled_urls_chunks)
@ -84,15 +103,18 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for _i, chunk in enumerate(crawled_urls_chunks): for _i, chunk in enumerate(crawled_urls_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Create a source entry # Create a source entry
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": document.get('title', 'Untitled Document'), "title": document.get("title", "Untitled Document"),
"description": metadata.get('og:description', metadata.get('ogDescription', chunk.get('content', '')[:100])), "description": metadata.get(
"url": metadata.get('url', '') "og:description",
metadata.get("ogDescription", chunk.get("content", "")[:100]),
),
"url": metadata.get("url", ""),
} }
self.source_id_counter += 1 self.source_id_counter += 1
@ -108,7 +130,14 @@ class ConnectorService:
return result_object, crawled_urls_chunks return result_object, crawled_urls_chunks
async def search_files(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_files(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for files and return both the source information and langchain documents Search for files and return both the source information and langchain documents
@ -121,7 +150,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="FILE" document_type="FILE",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
files_chunks = await self.document_retriever.hybrid_search( files_chunks = await self.document_retriever.hybrid_search(
@ -129,7 +158,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="FILE" document_type="FILE",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
files_chunks = self._transform_document_results(files_chunks) files_chunks = self._transform_document_results(files_chunks)
@ -148,15 +177,18 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for _i, chunk in enumerate(files_chunks): for _i, chunk in enumerate(files_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Create a source entry # Create a source entry
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": document.get('title', 'Untitled Document'), "title": document.get("title", "Untitled Document"),
"description": metadata.get('og:description', metadata.get('ogDescription', chunk.get('content', '')[:100])), "description": metadata.get(
"url": metadata.get('url', '') "og:description",
metadata.get("ogDescription", chunk.get("content", "")[:100]),
),
"url": metadata.get("url", ""),
} }
self.source_id_counter += 1 self.source_id_counter += 1
@ -185,19 +217,23 @@ class ConnectorService:
""" """
transformed_results = [] transformed_results = []
for doc in document_results: for doc in document_results:
transformed_results.append({ transformed_results.append(
'document': { {
'id': doc.get('document_id'), "document": {
'title': doc.get('title', 'Untitled Document'), "id": doc.get("document_id"),
'document_type': doc.get('document_type'), "title": doc.get("title", "Untitled Document"),
'metadata': doc.get('metadata', {}), "document_type": doc.get("document_type"),
}, "metadata": doc.get("metadata", {}),
'content': doc.get('chunks_content', doc.get('content', '')), },
'score': doc.get('score', 0.0) "content": doc.get("chunks_content", doc.get("content", "")),
}) "score": doc.get("score", 0.0),
}
)
return transformed_results return transformed_results
async def get_connector_by_type(self, user_id: str, connector_type: SearchSourceConnectorType) -> Optional[SearchSourceConnector]: async def get_connector_by_type(
self, user_id: str, connector_type: SearchSourceConnectorType
) -> Optional[SearchSourceConnector]:
""" """
Get a connector by type for a specific user Get a connector by type for a specific user
@ -209,15 +245,16 @@ class ConnectorService:
Optional[SearchSourceConnector]: The connector if found, None otherwise Optional[SearchSourceConnector]: The connector if found, None otherwise
""" """
result = await self.session.execute( result = await self.session.execute(
select(SearchSourceConnector) select(SearchSourceConnector).filter(
.filter(
SearchSourceConnector.user_id == user_id, SearchSourceConnector.user_id == user_id,
SearchSourceConnector.connector_type == connector_type SearchSourceConnector.connector_type == connector_type,
) )
) )
return result.scalars().first() return result.scalars().first()
async def search_tavily(self, user_query: str, user_id: str, top_k: int = 20) -> tuple: async def search_tavily(
self, user_query: str, user_id: str, top_k: int = 20
) -> tuple:
""" """
Search using Tavily API and return both the source information and documents Search using Tavily API and return both the source information and documents
@ -230,7 +267,9 @@ class ConnectorService:
tuple: (sources_info, documents) tuple: (sources_info, documents)
""" """
# Get Tavily connector configuration # Get Tavily connector configuration
tavily_connector = await self.get_connector_by_type(user_id, SearchSourceConnectorType.TAVILY_API) tavily_connector = await self.get_connector_by_type(
user_id, SearchSourceConnectorType.TAVILY_API
)
if not tavily_connector: if not tavily_connector:
# Return empty results if no Tavily connector is configured # Return empty results if no Tavily connector is configured
@ -250,7 +289,7 @@ class ConnectorService:
response = tavily_client.search( response = tavily_client.search(
query=user_query, query=user_query,
max_results=top_k, max_results=top_k,
search_depth="advanced" # Use advanced search for better results search_depth="advanced", # Use advanced search for better results
) )
# Extract results from Tavily response # Extract results from Tavily response
@ -271,13 +310,12 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for i, result in enumerate(tavily_results): for i, result in enumerate(tavily_results):
# Create a source entry # Create a source entry
source = { source = {
"id": self.source_id_counter, "id": self.source_id_counter,
"title": result.get("title", "Tavily Result"), "title": result.get("title", "Tavily Result"),
"description": result.get("content", "")[:100], "description": result.get("content", "")[:100],
"url": result.get("url", "") "url": result.get("url", ""),
} }
sources_list.append(source) sources_list.append(source)
@ -293,9 +331,9 @@ class ConnectorService:
"metadata": { "metadata": {
"url": result.get("url", ""), "url": result.get("url", ""),
"published_date": result.get("published_date", ""), "published_date": result.get("published_date", ""),
"source": "TAVILY_API" "source": "TAVILY_API",
} },
} },
} }
documents.append(document) documents.append(document)
self.source_id_counter += 1 self.source_id_counter += 1
@ -320,7 +358,14 @@ class ConnectorService:
"sources": [], "sources": [],
}, [] }, []
async def search_slack(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_slack(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for slack and return both the source information and langchain documents Search for slack and return both the source information and langchain documents
@ -333,7 +378,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="SLACK_CONNECTOR" document_type="SLACK_CONNECTOR",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
slack_chunks = await self.document_retriever.hybrid_search( slack_chunks = await self.document_retriever.hybrid_search(
@ -341,7 +386,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="SLACK_CONNECTOR" document_type="SLACK_CONNECTOR",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
slack_chunks = self._transform_document_results(slack_chunks) slack_chunks = self._transform_document_results(slack_chunks)
@ -360,13 +405,13 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for _i, chunk in enumerate(slack_chunks): for _i, chunk in enumerate(slack_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Create a mapped source entry with Slack-specific metadata # Create a mapped source entry with Slack-specific metadata
channel_name = metadata.get('channel_name', 'Unknown Channel') channel_name = metadata.get("channel_name", "Unknown Channel")
channel_id = metadata.get('channel_id', '') channel_id = metadata.get("channel_id", "")
message_date = metadata.get('start_date', '') message_date = metadata.get("start_date", "")
# Create a more descriptive title for Slack messages # Create a more descriptive title for Slack messages
title = f"Slack: {channel_name}" title = f"Slack: {channel_name}"
@ -374,7 +419,7 @@ class ConnectorService:
title += f" ({message_date})" title += f" ({message_date})"
# Create a more descriptive description for Slack messages # Create a more descriptive description for Slack messages
description = chunk.get('content', '')[:100] description = chunk.get("content", "")[:100]
if len(description) == 100: if len(description) == 100:
description += "..." description += "..."
@ -384,7 +429,7 @@ class ConnectorService:
url = f"https://slack.com/app_redirect?channel={channel_id}" url = f"https://slack.com/app_redirect?channel={channel_id}"
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": title, "title": title,
"description": description, "description": description,
"url": url, "url": url,
@ -403,7 +448,14 @@ class ConnectorService:
return result_object, slack_chunks return result_object, slack_chunks
async def search_notion(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_notion(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for Notion pages and return both the source information and langchain documents Search for Notion pages and return both the source information and langchain documents
@ -422,7 +474,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="NOTION_CONNECTOR" document_type="NOTION_CONNECTOR",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
notion_chunks = await self.document_retriever.hybrid_search( notion_chunks = await self.document_retriever.hybrid_search(
@ -430,7 +482,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="NOTION_CONNECTOR" document_type="NOTION_CONNECTOR",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
notion_chunks = self._transform_document_results(notion_chunks) notion_chunks = self._transform_document_results(notion_chunks)
@ -449,13 +501,13 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for _i, chunk in enumerate(notion_chunks): for _i, chunk in enumerate(notion_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Create a mapped source entry with Notion-specific metadata # Create a mapped source entry with Notion-specific metadata
page_title = metadata.get('page_title', 'Untitled Page') page_title = metadata.get("page_title", "Untitled Page")
page_id = metadata.get('page_id', '') page_id = metadata.get("page_id", "")
indexed_at = metadata.get('indexed_at', '') indexed_at = metadata.get("indexed_at", "")
# Create a more descriptive title for Notion pages # Create a more descriptive title for Notion pages
title = f"Notion: {page_title}" title = f"Notion: {page_title}"
@ -463,7 +515,7 @@ class ConnectorService:
title += f" (indexed: {indexed_at})" title += f" (indexed: {indexed_at})"
# Create a more descriptive description for Notion pages # Create a more descriptive description for Notion pages
description = chunk.get('content', '')[:100] description = chunk.get("content", "")[:100]
if len(description) == 100: if len(description) == 100:
description += "..." description += "..."
@ -474,7 +526,7 @@ class ConnectorService:
url = f"https://notion.so/{page_id.replace('-', '')}" url = f"https://notion.so/{page_id.replace('-', '')}"
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": title, "title": title,
"description": description, "description": description,
"url": url, "url": url,
@ -493,7 +545,14 @@ class ConnectorService:
return result_object, notion_chunks return result_object, notion_chunks
async def search_extension(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_extension(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for extension data and return both the source information and langchain documents Search for extension data and return both the source information and langchain documents
@ -512,7 +571,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="EXTENSION" document_type="EXTENSION",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
extension_chunks = await self.document_retriever.hybrid_search( extension_chunks = await self.document_retriever.hybrid_search(
@ -520,7 +579,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="EXTENSION" document_type="EXTENSION",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
extension_chunks = self._transform_document_results(extension_chunks) extension_chunks = self._transform_document_results(extension_chunks)
@ -539,15 +598,17 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for i, chunk in enumerate(extension_chunks): for i, chunk in enumerate(extension_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Extract extension-specific metadata # Extract extension-specific metadata
webpage_title = metadata.get('VisitedWebPageTitle', 'Untitled Page') webpage_title = metadata.get("VisitedWebPageTitle", "Untitled Page")
webpage_url = metadata.get('VisitedWebPageURL', '') webpage_url = metadata.get("VisitedWebPageURL", "")
visit_date = metadata.get('VisitedWebPageDateWithTimeInISOString', '') visit_date = metadata.get("VisitedWebPageDateWithTimeInISOString", "")
visit_duration = metadata.get('VisitedWebPageVisitDurationInMilliseconds', '') visit_duration = metadata.get(
browsing_session_id = metadata.get('BrowsingSessionId', '') "VisitedWebPageVisitDurationInMilliseconds", ""
)
browsing_session_id = metadata.get("BrowsingSessionId", "")
# Create a more descriptive title for extension data # Create a more descriptive title for extension data
title = webpage_title title = webpage_title
@ -555,14 +616,18 @@ class ConnectorService:
# Format the date for display (simplified) # Format the date for display (simplified)
try: try:
# Just extract the date part for display # Just extract the date part for display
formatted_date = visit_date.split('T')[0] if 'T' in visit_date else visit_date formatted_date = (
visit_date.split("T")[0]
if "T" in visit_date
else visit_date
)
title += f" (visited: {formatted_date})" title += f" (visited: {formatted_date})"
except: except:
# Fallback if date parsing fails # Fallback if date parsing fails
title += f" (visited: {visit_date})" title += f" (visited: {visit_date})"
# Create a more descriptive description for extension data # Create a more descriptive description for extension data
description = chunk.get('content', '')[:100] description = chunk.get("content", "")[:100]
if len(description) == 100: if len(description) == 100:
description += "..." description += "..."
@ -573,7 +638,7 @@ class ConnectorService:
if duration_seconds < 60: if duration_seconds < 60:
duration_text = f"{duration_seconds:.1f} seconds" duration_text = f"{duration_seconds:.1f} seconds"
else: else:
duration_text = f"{duration_seconds/60:.1f} minutes" duration_text = f"{duration_seconds / 60:.1f} minutes"
if description: if description:
description += f" | Duration: {duration_text}" description += f" | Duration: {duration_text}"
@ -582,10 +647,10 @@ class ConnectorService:
pass pass
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": title, "title": title,
"description": description, "description": description,
"url": webpage_url "url": webpage_url,
} }
self.source_id_counter += 1 self.source_id_counter += 1
@ -601,7 +666,14 @@ class ConnectorService:
return result_object, extension_chunks return result_object, extension_chunks
async def search_youtube(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_youtube(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for YouTube videos and return both the source information and langchain documents Search for YouTube videos and return both the source information and langchain documents
@ -620,7 +692,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="YOUTUBE_VIDEO" document_type="YOUTUBE_VIDEO",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
youtube_chunks = await self.document_retriever.hybrid_search( youtube_chunks = await self.document_retriever.hybrid_search(
@ -628,7 +700,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="YOUTUBE_VIDEO" document_type="YOUTUBE_VIDEO",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
youtube_chunks = self._transform_document_results(youtube_chunks) youtube_chunks = self._transform_document_results(youtube_chunks)
@ -647,13 +719,13 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for _i, chunk in enumerate(youtube_chunks): for _i, chunk in enumerate(youtube_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Extract YouTube-specific metadata # Extract YouTube-specific metadata
video_title = metadata.get('video_title', 'Untitled Video') video_title = metadata.get("video_title", "Untitled Video")
video_id = metadata.get('video_id', '') video_id = metadata.get("video_id", "")
channel_name = metadata.get('channel_name', '') channel_name = metadata.get("channel_name", "")
# published_date = metadata.get('published_date', '') # published_date = metadata.get('published_date', '')
# Create a more descriptive title for YouTube videos # Create a more descriptive title for YouTube videos
@ -662,7 +734,9 @@ class ConnectorService:
title += f" - {channel_name}" title += f" - {channel_name}"
# Create a more descriptive description for YouTube videos # Create a more descriptive description for YouTube videos
description = metadata.get('description', chunk.get('content', '')[:100]) description = metadata.get(
"description", chunk.get("content", "")[:100]
)
if len(description) == 100: if len(description) == 100:
description += "..." description += "..."
@ -670,12 +744,12 @@ class ConnectorService:
url = f"https://www.youtube.com/watch?v={video_id}" if video_id else "" url = f"https://www.youtube.com/watch?v={video_id}" if video_id else ""
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": title, "title": title,
"description": description, "description": description,
"url": url, "url": url,
"video_id": video_id, # Additional field for YouTube videos "video_id": video_id, # Additional field for YouTube videos
"channel_name": channel_name # Additional field for YouTube videos "channel_name": channel_name, # Additional field for YouTube videos
} }
self.source_id_counter += 1 self.source_id_counter += 1
@ -691,7 +765,14 @@ class ConnectorService:
return result_object, youtube_chunks return result_object, youtube_chunks
async def search_github(self, user_query: str, user_id: int, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_github(
self,
user_query: str,
user_id: int,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for GitHub documents and return both the source information and langchain documents Search for GitHub documents and return both the source information and langchain documents
@ -704,7 +785,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="GITHUB_CONNECTOR" document_type="GITHUB_CONNECTOR",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
github_chunks = await self.document_retriever.hybrid_search( github_chunks = await self.document_retriever.hybrid_search(
@ -712,7 +793,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="GITHUB_CONNECTOR" document_type="GITHUB_CONNECTOR",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
github_chunks = self._transform_document_results(github_chunks) github_chunks = self._transform_document_results(github_chunks)
@ -731,15 +812,19 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for _i, chunk in enumerate(github_chunks): for _i, chunk in enumerate(github_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Create a source entry # Create a source entry
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": document.get('title', 'GitHub Document'), # Use specific title if available "title": document.get(
"description": metadata.get('description', chunk.get('content', '')[:100]), # Use description or content preview "title", "GitHub Document"
"url": metadata.get('url', '') # Use URL if available in metadata ), # Use specific title if available
"description": metadata.get(
"description", chunk.get("content", "")[:100]
), # Use description or content preview
"url": metadata.get("url", ""), # Use URL if available in metadata
} }
self.source_id_counter += 1 self.source_id_counter += 1
@ -755,7 +840,14 @@ class ConnectorService:
return result_object, github_chunks return result_object, github_chunks
async def search_linear(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_linear(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for Linear issues and comments and return both the source information and langchain documents Search for Linear issues and comments and return both the source information and langchain documents
@ -774,7 +866,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="LINEAR_CONNECTOR" document_type="LINEAR_CONNECTOR",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
linear_chunks = await self.document_retriever.hybrid_search( linear_chunks = await self.document_retriever.hybrid_search(
@ -782,7 +874,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="LINEAR_CONNECTOR" document_type="LINEAR_CONNECTOR",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
linear_chunks = self._transform_document_results(linear_chunks) linear_chunks = self._transform_document_results(linear_chunks)
@ -801,14 +893,14 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for _i, chunk in enumerate(linear_chunks): for _i, chunk in enumerate(linear_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Extract Linear-specific metadata # Extract Linear-specific metadata
issue_identifier = metadata.get('issue_identifier', '') issue_identifier = metadata.get("issue_identifier", "")
issue_title = metadata.get('issue_title', 'Untitled Issue') issue_title = metadata.get("issue_title", "Untitled Issue")
issue_state = metadata.get('state', '') issue_state = metadata.get("state", "")
comment_count = metadata.get('comment_count', 0) comment_count = metadata.get("comment_count", 0)
# Create a more descriptive title for Linear issues # Create a more descriptive title for Linear issues
title = f"Linear: {issue_identifier} - {issue_title}" title = f"Linear: {issue_identifier} - {issue_title}"
@ -816,7 +908,7 @@ class ConnectorService:
title += f" ({issue_state})" title += f" ({issue_state})"
# Create a more descriptive description for Linear issues # Create a more descriptive description for Linear issues
description = chunk.get('content', '')[:100] description = chunk.get("content", "")[:100]
if len(description) == 100: if len(description) == 100:
description += "..." description += "..."
@ -835,13 +927,13 @@ class ConnectorService:
url = f"https://linear.app/issue/{issue_identifier}" url = f"https://linear.app/issue/{issue_identifier}"
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": title, "title": title,
"description": description, "description": description,
"url": url, "url": url,
"issue_identifier": issue_identifier, "issue_identifier": issue_identifier,
"state": issue_state, "state": issue_state,
"comment_count": comment_count "comment_count": comment_count,
} }
self.source_id_counter += 1 self.source_id_counter += 1
@ -857,7 +949,130 @@ class ConnectorService:
return result_object, linear_chunks return result_object, linear_chunks
async def search_linkup(self, user_query: str, user_id: str, mode: str = "standard") -> tuple: async def search_jira(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
"""
Search for Jira issues and comments and return both the source information and langchain documents
Args:
user_query: The user's query
user_id: The user's ID
search_space_id: The search space ID to search in
top_k: Maximum number of results to return
search_mode: Search mode (CHUNKS or DOCUMENTS)
Returns:
tuple: (sources_info, langchain_documents)
"""
if search_mode == SearchMode.CHUNKS:
jira_chunks = await self.chunk_retriever.hybrid_search(
query_text=user_query,
top_k=top_k,
user_id=user_id,
search_space_id=search_space_id,
document_type="JIRA_CONNECTOR",
)
elif search_mode == SearchMode.DOCUMENTS:
jira_chunks = await self.document_retriever.hybrid_search(
query_text=user_query,
top_k=top_k,
user_id=user_id,
search_space_id=search_space_id,
document_type="JIRA_CONNECTOR",
)
# Transform document retriever results to match expected format
jira_chunks = self._transform_document_results(jira_chunks)
# Early return if no results
if not jira_chunks:
return {
"id": 30,
"name": "Jira Issues",
"type": "JIRA_CONNECTOR",
"sources": [],
}, []
# Process each chunk and create sources directly without deduplication
sources_list = []
async with self.counter_lock:
for _i, chunk in enumerate(jira_chunks):
# Extract document metadata
document = chunk.get("document", {})
metadata = document.get("metadata", {})
# Extract Jira-specific metadata
issue_key = metadata.get("issue_key", "")
issue_title = metadata.get("issue_title", "Untitled Issue")
status = metadata.get("status", "")
priority = metadata.get("priority", "")
issue_type = metadata.get("issue_type", "")
comment_count = metadata.get("comment_count", 0)
# Create a more descriptive title for Jira issues
title = f"Jira: {issue_key} - {issue_title}"
if status:
title += f" ({status})"
# Create a more descriptive description for Jira issues
description = chunk.get("content", "")[:100]
if len(description) == 100:
description += "..."
# Add priority and type info to description
info_parts = []
if priority:
info_parts.append(f"Priority: {priority}")
if issue_type:
info_parts.append(f"Type: {issue_type}")
if comment_count:
info_parts.append(f"Comments: {comment_count}")
if info_parts:
if description:
description += f" | {' | '.join(info_parts)}"
else:
description = " | ".join(info_parts)
# For URL, we could construct a URL to the Jira issue if we have the base URL
# For now, use a generic placeholder
url = ""
if issue_key and metadata.get("base_url"):
url = f"{metadata.get('base_url')}/browse/{issue_key}"
source = {
"id": document.get("id", self.source_id_counter),
"title": title,
"description": description,
"url": url,
"issue_key": issue_key,
"status": status,
"priority": priority,
"issue_type": issue_type,
"comment_count": comment_count,
}
self.source_id_counter += 1
sources_list.append(source)
# Create result object
result_object = {
"id": 10, # Assign a unique ID for the Jira connector
"name": "Jira Issues",
"type": "JIRA_CONNECTOR",
"sources": sources_list,
}
return result_object, jira_chunks
async def search_linkup(
self, user_query: str, user_id: str, mode: str = "standard"
) -> tuple:
""" """
Search using Linkup API and return both the source information and documents Search using Linkup API and return both the source information and documents
@ -870,7 +1085,9 @@ class ConnectorService:
tuple: (sources_info, documents) tuple: (sources_info, documents)
""" """
# Get Linkup connector configuration # Get Linkup connector configuration
linkup_connector = await self.get_connector_by_type(user_id, SearchSourceConnectorType.LINKUP_API) linkup_connector = await self.get_connector_by_type(
user_id, SearchSourceConnectorType.LINKUP_API
)
if not linkup_connector: if not linkup_connector:
# Return empty results if no Linkup connector is configured # Return empty results if no Linkup connector is configured
@ -894,7 +1111,7 @@ class ConnectorService:
) )
# Extract results from Linkup response - access as attribute instead of using .get() # Extract results from Linkup response - access as attribute instead of using .get()
linkup_results = response.results if hasattr(response, 'results') else [] linkup_results = response.results if hasattr(response, "results") else []
# Only proceed if we have results # Only proceed if we have results
if not linkup_results: if not linkup_results:
@ -912,33 +1129,41 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for i, result in enumerate(linkup_results): for i, result in enumerate(linkup_results):
# Only process results that have content # Only process results that have content
if not hasattr(result, 'content') or not result.content: if not hasattr(result, "content") or not result.content:
continue continue
# Create a source entry # Create a source entry
source = { source = {
"id": self.source_id_counter, "id": self.source_id_counter,
"title": result.name if hasattr(result, 'name') else "Linkup Result", "title": (
"description": result.content[:100] if hasattr(result, 'content') else "", result.name if hasattr(result, "name") else "Linkup Result"
"url": result.url if hasattr(result, 'url') else "" ),
"description": (
result.content[:100] if hasattr(result, "content") else ""
),
"url": result.url if hasattr(result, "url") else "",
} }
sources_list.append(source) sources_list.append(source)
# Create a document entry # Create a document entry
document = { document = {
"chunk_id": f"linkup_chunk_{i}", "chunk_id": f"linkup_chunk_{i}",
"content": result.content if hasattr(result, 'content') else "", "content": result.content if hasattr(result, "content") else "",
"score": 1.0, # Default score since not provided by Linkup "score": 1.0, # Default score since not provided by Linkup
"document": { "document": {
"id": self.source_id_counter, "id": self.source_id_counter,
"title": result.name if hasattr(result, 'name') else "Linkup Result", "title": (
result.name
if hasattr(result, "name")
else "Linkup Result"
),
"document_type": "LINKUP_API", "document_type": "LINKUP_API",
"metadata": { "metadata": {
"url": result.url if hasattr(result, 'url') else "", "url": result.url if hasattr(result, "url") else "",
"type": result.type if hasattr(result, 'type') else "", "type": result.type if hasattr(result, "type") else "",
"source": "LINKUP_API" "source": "LINKUP_API",
} },
} },
} }
documents.append(document) documents.append(document)
self.source_id_counter += 1 self.source_id_counter += 1
@ -963,7 +1188,14 @@ class ConnectorService:
"sources": [], "sources": [],
}, [] }, []
async def search_discord(self, user_query: str, user_id: str, search_space_id: int, top_k: int = 20, search_mode: SearchMode = SearchMode.CHUNKS) -> tuple: async def search_discord(
self,
user_query: str,
user_id: str,
search_space_id: int,
top_k: int = 20,
search_mode: SearchMode = SearchMode.CHUNKS,
) -> tuple:
""" """
Search for Discord messages and return both the source information and langchain documents Search for Discord messages and return both the source information and langchain documents
@ -982,7 +1214,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="DISCORD_CONNECTOR" document_type="DISCORD_CONNECTOR",
) )
elif search_mode == SearchMode.DOCUMENTS: elif search_mode == SearchMode.DOCUMENTS:
discord_chunks = await self.document_retriever.hybrid_search( discord_chunks = await self.document_retriever.hybrid_search(
@ -990,7 +1222,7 @@ class ConnectorService:
top_k=top_k, top_k=top_k,
user_id=user_id, user_id=user_id,
search_space_id=search_space_id, search_space_id=search_space_id,
document_type="DISCORD_CONNECTOR" document_type="DISCORD_CONNECTOR",
) )
# Transform document retriever results to match expected format # Transform document retriever results to match expected format
discord_chunks = self._transform_document_results(discord_chunks) discord_chunks = self._transform_document_results(discord_chunks)
@ -1009,13 +1241,13 @@ class ConnectorService:
async with self.counter_lock: async with self.counter_lock:
for i, chunk in enumerate(discord_chunks): for i, chunk in enumerate(discord_chunks):
# Extract document metadata # Extract document metadata
document = chunk.get('document', {}) document = chunk.get("document", {})
metadata = document.get('metadata', {}) metadata = document.get("metadata", {})
# Create a mapped source entry with Discord-specific metadata # Create a mapped source entry with Discord-specific metadata
channel_name = metadata.get('channel_name', 'Unknown Channel') channel_name = metadata.get("channel_name", "Unknown Channel")
channel_id = metadata.get('channel_id', '') channel_id = metadata.get("channel_id", "")
message_date = metadata.get('start_date', '') message_date = metadata.get("start_date", "")
# Create a more descriptive title for Discord messages # Create a more descriptive title for Discord messages
title = f"Discord: {channel_name}" title = f"Discord: {channel_name}"
@ -1023,12 +1255,12 @@ class ConnectorService:
title += f" ({message_date})" title += f" ({message_date})"
# Create a more descriptive description for Discord messages # Create a more descriptive description for Discord messages
description = chunk.get('content', '')[:100] description = chunk.get("content", "")[:100]
if len(description) == 100: if len(description) == 100:
description += "..." description += "..."
url = "" url = ""
guild_id = metadata.get('guild_id', '') guild_id = metadata.get("guild_id", "")
if guild_id and channel_id: if guild_id and channel_id:
url = f"https://discord.com/channels/{guild_id}/{channel_id}" url = f"https://discord.com/channels/{guild_id}/{channel_id}"
elif channel_id: elif channel_id:
@ -1036,7 +1268,7 @@ class ConnectorService:
url = f"https://discord.com/channels/@me/{channel_id}" url = f"https://discord.com/channels/@me/{channel_id}"
source = { source = {
"id": document.get('id', self.source_id_counter), "id": document.get("id", self.source_id_counter),
"title": title, "title": title,
"description": description, "description": description,
"url": url, "url": url,
@ -1054,5 +1286,3 @@ class ConnectorService:
} }
return result_object, discord_chunks return result_object, discord_chunks

File diff suppressed because it is too large Load diff

View file

@ -9,12 +9,12 @@ import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
import { Form } from "@/components/ui/form"; import { Form } from "@/components/ui/form";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardFooter, CardFooter,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
// Import Utils, Types, Hook, and Components // Import Utils, Types, Hook, and Components
@ -27,201 +27,227 @@ import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenF
import { getConnectorIcon } from "@/components/chat"; import { getConnectorIcon } from "@/components/chat";
export default function EditConnectorPage() { export default function EditConnectorPage() {
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
// Ensure connectorId is parsed safely // Ensure connectorId is parsed safely
const connectorIdParam = params.connector_id as string; const connectorIdParam = params.connector_id as string;
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN; const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
// Use the custom hook to manage state and logic // Use the custom hook to manage state and logic
const { const {
connectorsLoading, connectorsLoading,
connector, connector,
isSaving, isSaving,
editForm, editForm,
patForm, // Needed for GitHub child component patForm, // Needed for GitHub child component
handleSaveChanges, handleSaveChanges,
// GitHub specific props for the child component // GitHub specific props for the child component
editMode, editMode,
setEditMode, // Pass down if needed by GitHub component setEditMode, // Pass down if needed by GitHub component
originalPat, originalPat,
currentSelectedRepos, currentSelectedRepos,
fetchedRepos, fetchedRepos,
setFetchedRepos, setFetchedRepos,
newSelectedRepos, newSelectedRepos,
setNewSelectedRepos, setNewSelectedRepos,
isFetchingRepos, isFetchingRepos,
handleFetchRepositories, handleFetchRepositories,
handleRepoSelectionChange, handleRepoSelectionChange,
} = useConnectorEditPage(connectorId, searchSpaceId); } = useConnectorEditPage(connectorId, searchSpaceId);
// Redirect if connectorId is not a valid number after parsing // Redirect if connectorId is not a valid number after parsing
useEffect(() => { useEffect(() => {
if (isNaN(connectorId)) { if (isNaN(connectorId)) {
toast.error("Invalid Connector ID."); toast.error("Invalid Connector ID.");
router.push(`/dashboard/${searchSpaceId}/connectors`); router.push(`/dashboard/${searchSpaceId}/connectors`);
} }
}, [connectorId, router, searchSpaceId]); }, [connectorId, router, searchSpaceId]);
// Loading State // Loading State
if (connectorsLoading || !connector) { if (connectorsLoading || !connector) {
// Handle NaN case before showing skeleton // Handle NaN case before showing skeleton
if (isNaN(connectorId)) return null; if (isNaN(connectorId)) return null;
return <EditConnectorLoadingSkeleton />; return <EditConnectorLoadingSkeleton />;
} }
// Main Render using data/handlers from the hook // Main Render using data/handlers from the hook
return ( return (
<div className="container mx-auto py-8 max-w-3xl"> <div className="container mx-auto py-8 max-w-3xl">
<Button <Button
variant="ghost" variant="ghost"
className="mb-6" className="mb-6"
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)} onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
> >
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors <ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
</Button> </Button>
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold flex items-center gap-2"> <CardTitle className="text-2xl font-bold flex items-center gap-2">
{getConnectorIcon(connector.connector_type)} {getConnectorIcon(connector.connector_type)}
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Modify connector name and configuration. Modify connector name and configuration.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<Form {...editForm}> <Form {...editForm}>
{/* Pass hook's handleSaveChanges */} {/* Pass hook's handleSaveChanges */}
<form <form
onSubmit={editForm.handleSubmit(handleSaveChanges)} onSubmit={editForm.handleSubmit(handleSaveChanges)}
className="space-y-6" className="space-y-6"
> >
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Pass form control from hook */} {/* Pass form control from hook */}
<EditConnectorNameForm control={editForm.control} /> <EditConnectorNameForm control={editForm.control} />
<hr /> <hr />
<h3 className="text-lg font-semibold">Configuration</h3> <h3 className="text-lg font-semibold">Configuration</h3>
{/* == GitHub == */} {/* == GitHub == */}
{connector.connector_type === "GITHUB_CONNECTOR" && ( {connector.connector_type === "GITHUB_CONNECTOR" && (
<EditGitHubConnectorConfig <EditGitHubConnectorConfig
// Pass relevant state and handlers from hook // Pass relevant state and handlers from hook
editMode={editMode} editMode={editMode}
setEditMode={setEditMode} // Pass setter if child manages mode setEditMode={setEditMode} // Pass setter if child manages mode
originalPat={originalPat} originalPat={originalPat}
currentSelectedRepos={currentSelectedRepos} currentSelectedRepos={currentSelectedRepos}
fetchedRepos={fetchedRepos} fetchedRepos={fetchedRepos}
newSelectedRepos={newSelectedRepos} newSelectedRepos={newSelectedRepos}
isFetchingRepos={isFetchingRepos} isFetchingRepos={isFetchingRepos}
patForm={patForm} patForm={patForm}
handleFetchRepositories={handleFetchRepositories} handleFetchRepositories={handleFetchRepositories}
handleRepoSelectionChange={handleRepoSelectionChange} handleRepoSelectionChange={handleRepoSelectionChange}
setNewSelectedRepos={setNewSelectedRepos} setNewSelectedRepos={setNewSelectedRepos}
setFetchedRepos={setFetchedRepos} setFetchedRepos={setFetchedRepos}
/> />
)} )}
{/* == Slack == */} {/* == Slack == */}
{connector.connector_type === "SLACK_CONNECTOR" && ( {connector.connector_type === "SLACK_CONNECTOR" && (
<EditSimpleTokenForm <EditSimpleTokenForm
control={editForm.control} control={editForm.control}
fieldName="SLACK_BOT_TOKEN" fieldName="SLACK_BOT_TOKEN"
fieldLabel="Slack Bot Token" fieldLabel="Slack Bot Token"
fieldDescription="Update the Slack Bot Token if needed." fieldDescription="Update the Slack Bot Token if needed."
placeholder="Begins with xoxb-..." placeholder="Begins with xoxb-..."
/> />
)} )}
{/* == Notion == */} {/* == Notion == */}
{connector.connector_type === "NOTION_CONNECTOR" && ( {connector.connector_type === "NOTION_CONNECTOR" && (
<EditSimpleTokenForm <EditSimpleTokenForm
control={editForm.control} control={editForm.control}
fieldName="NOTION_INTEGRATION_TOKEN" fieldName="NOTION_INTEGRATION_TOKEN"
fieldLabel="Notion Integration Token" fieldLabel="Notion Integration Token"
fieldDescription="Update the Notion Integration Token if needed." fieldDescription="Update the Notion Integration Token if needed."
placeholder="Begins with secret_..." placeholder="Begins with secret_..."
/> />
)} )}
{/* == Serper == */} {/* == Serper == */}
{connector.connector_type === "SERPER_API" && ( {connector.connector_type === "SERPER_API" && (
<EditSimpleTokenForm <EditSimpleTokenForm
control={editForm.control} control={editForm.control}
fieldName="SERPER_API_KEY" fieldName="SERPER_API_KEY"
fieldLabel="Serper API Key" fieldLabel="Serper API Key"
fieldDescription="Update the Serper API Key if needed." fieldDescription="Update the Serper API Key if needed."
/> />
)} )}
{/* == Tavily == */} {/* == Tavily == */}
{connector.connector_type === "TAVILY_API" && ( {connector.connector_type === "TAVILY_API" && (
<EditSimpleTokenForm <EditSimpleTokenForm
control={editForm.control} control={editForm.control}
fieldName="TAVILY_API_KEY" fieldName="TAVILY_API_KEY"
fieldLabel="Tavily API Key" fieldLabel="Tavily API Key"
fieldDescription="Update the Tavily API Key if needed." fieldDescription="Update the Tavily API Key if needed."
/> />
)} )}
{/* == Linear == */} {/* == Linear == */}
{connector.connector_type === "LINEAR_CONNECTOR" && ( {connector.connector_type === "LINEAR_CONNECTOR" && (
<EditSimpleTokenForm <EditSimpleTokenForm
control={editForm.control} control={editForm.control}
fieldName="LINEAR_API_KEY" fieldName="LINEAR_API_KEY"
fieldLabel="Linear API Key" fieldLabel="Linear API Key"
fieldDescription="Update your Linear API Key if needed." fieldDescription="Update your Linear API Key if needed."
placeholder="Begins with lin_api_..." placeholder="Begins with lin_api_..."
/> />
)} )}
{/* == Linkup == */} {/* == Jira == */}
{connector.connector_type === "LINKUP_API" && ( {connector.connector_type === "JIRA_CONNECTOR" && (
<EditSimpleTokenForm <div className="space-y-4">
control={editForm.control} <EditSimpleTokenForm
fieldName="LINKUP_API_KEY" control={editForm.control}
fieldLabel="Linkup API Key" fieldName="JIRA_BASE_URL"
fieldDescription="Update your Linkup API Key if needed." fieldLabel="Jira Base URL"
placeholder="Begins with linkup_..." fieldDescription="Update your Jira instance URL if needed."
/> placeholder="https://yourcompany.atlassian.net"
)} />
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_EMAIL"
fieldLabel="Jira Email"
fieldDescription="Update your Atlassian account email if needed."
placeholder="your.email@company.com"
/>
<EditSimpleTokenForm
control={editForm.control}
fieldName="JIRA_API_TOKEN"
fieldLabel="Jira API Token"
fieldDescription="Update your Jira API Token if needed."
placeholder="Your Jira API Token"
/>
</div>
)}
{/* == Discord == */} {/* == Linkup == */}
{connector.connector_type === "DISCORD_CONNECTOR" && ( {connector.connector_type === "LINKUP_API" && (
<EditSimpleTokenForm <EditSimpleTokenForm
control={editForm.control} control={editForm.control}
fieldName="DISCORD_BOT_TOKEN" fieldName="LINKUP_API_KEY"
fieldLabel="Discord Bot Token" fieldLabel="Linkup API Key"
fieldDescription="Update the Discord Bot Token if needed." fieldDescription="Update your Linkup API Key if needed."
placeholder="Bot token..." placeholder="Begins with linkup_..."
/> />
)} )}
</CardContent> {/* == Discord == */}
<CardFooter className="border-t pt-6"> {connector.connector_type === "DISCORD_CONNECTOR" && (
<Button <EditSimpleTokenForm
type="submit" control={editForm.control}
disabled={isSaving} fieldName="DISCORD_BOT_TOKEN"
className="w-full sm:w-auto" fieldLabel="Discord Bot Token"
> fieldDescription="Update the Discord Bot Token if needed."
{isSaving ? ( placeholder="Bot token..."
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> />
) : ( )}
<Check className="mr-2 h-4 w-4" /> </CardContent>
)} <CardFooter className="border-t pt-6">
Save Changes <Button
</Button> type="submit"
</CardFooter> disabled={isSaving}
</form> className="w-full sm:w-auto"
</Form> >
</Card> {isSaving ? (
</motion.div> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
</div> ) : (
); <Check className="mr-2 h-4 w-4" />
)}
Save Changes
</Button>
</CardFooter>
</form>
</Form>
</Card>
</motion.div>
</div>
);
} }

View file

@ -9,7 +9,10 @@ import * as z from "zod";
import { toast } from "sonner"; import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors, SearchSourceConnector } from "@/hooks/useSearchSourceConnectors"; import {
useSearchSourceConnectors,
SearchSourceConnector,
} from "@/hooks/useSearchSourceConnectors";
import { import {
Form, Form,
FormControl, FormControl,
@ -28,11 +31,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
Alert,
AlertDescription,
AlertTitle,
} from "@/components/ui/alert";
// Define the form schema with Zod // Define the form schema with Zod
const apiConnectorFormSchema = z.object({ const apiConnectorFormSchema = z.object({
@ -47,13 +46,15 @@ const apiConnectorFormSchema = z.object({
// Helper function to get connector type display name // Helper function to get connector type display name
const getConnectorTypeDisplay = (type: string): string => { const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
"SERPER_API": "Serper API", SERPER_API: "Serper API",
"TAVILY_API": "Tavily API", TAVILY_API: "Tavily API",
"SLACK_CONNECTOR": "Slack Connector", SLACK_CONNECTOR: "Slack Connector",
"NOTION_CONNECTOR": "Notion Connector", NOTION_CONNECTOR: "Notion Connector",
"GITHUB_CONNECTOR": "GitHub Connector", GITHUB_CONNECTOR: "GitHub Connector",
"DISCORD_CONNECTOR": "Discord Connector", LINEAR_CONNECTOR: "Linear Connector",
"LINKUP_API": "Linkup", JIRA_CONNECTOR: "Jira Connector",
DISCORD_CONNECTOR: "Discord Connector",
LINKUP_API: "Linkup",
// Add other connector types here as needed // Add other connector types here as needed
}; };
return typeMap[type] || type; return typeMap[type] || type;
@ -69,7 +70,9 @@ export default function EditConnectorPage() {
const connectorId = parseInt(params.connector_id as string, 10); const connectorId = parseInt(params.connector_id as string, 10);
const { connectors, updateConnector } = useSearchSourceConnectors(); const { connectors, updateConnector } = useSearchSourceConnectors();
const [connector, setConnector] = useState<SearchSourceConnector | null>(null); const [connector, setConnector] = useState<SearchSourceConnector | null>(
null,
);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// console.log("connector", connector); // console.log("connector", connector);
@ -85,20 +88,20 @@ export default function EditConnectorPage() {
// Get API key field name based on connector type // Get API key field name based on connector type
const getApiKeyFieldName = (connectorType: string): string => { const getApiKeyFieldName = (connectorType: string): string => {
const fieldMap: Record<string, string> = { const fieldMap: Record<string, string> = {
"SERPER_API": "SERPER_API_KEY", SERPER_API: "SERPER_API_KEY",
"TAVILY_API": "TAVILY_API_KEY", TAVILY_API: "TAVILY_API_KEY",
"SLACK_CONNECTOR": "SLACK_BOT_TOKEN", SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
"NOTION_CONNECTOR": "NOTION_INTEGRATION_TOKEN", NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
"GITHUB_CONNECTOR": "GITHUB_PAT", GITHUB_CONNECTOR: "GITHUB_PAT",
"DISCORD_CONNECTOR": "DISCORD_BOT_TOKEN", DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
"LINKUP_API": "LINKUP_API_KEY" LINKUP_API: "LINKUP_API_KEY",
}; };
return fieldMap[connectorType] || ""; return fieldMap[connectorType] || "";
}; };
// Find connector in the list // Find connector in the list
useEffect(() => { useEffect(() => {
const currentConnector = connectors.find(c => c.id === connectorId); const currentConnector = connectors.find((c) => c.id === connectorId);
if (currentConnector) { if (currentConnector) {
setConnector(currentConnector); setConnector(currentConnector);
@ -150,7 +153,9 @@ export default function EditConnectorPage() {
router.push(`/dashboard/${searchSpaceId}/connectors`); router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) { } catch (error) {
console.error("Error updating connector:", error); console.error("Error updating connector:", error);
toast.error(error instanceof Error ? error.message : "Failed to update connector"); toast.error(
error instanceof Error ? error.message : "Failed to update connector",
);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -186,24 +191,30 @@ export default function EditConnectorPage() {
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl font-bold"> <CardTitle className="text-2xl font-bold">
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector Edit{" "}
{connector
? getConnectorTypeDisplay(connector.connector_type)
: ""}{" "}
Connector
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Update your connector settings.</CardDescription>
Update your connector settings.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Alert className="mb-6 bg-muted"> <Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertTitle>API Key Security</AlertTitle> <AlertTitle>API Key Security</AlertTitle>
<AlertDescription> <AlertDescription>
Your API key is stored securely. For security reasons, we don't display your existing API key. Your API key is stored securely. For security reasons, we don't
If you don't update the API key field, your existing key will be preserved. display your existing API key. If you don't update the API key
field, your existing key will be preserved.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@ -245,7 +256,8 @@ export default function EditConnectorPage() {
? "Enter new Slack Bot Token (optional)" ? "Enter new Slack Bot Token (optional)"
: connector?.connector_type === "NOTION_CONNECTOR" : connector?.connector_type === "NOTION_CONNECTOR"
? "Enter new Notion Token (optional)" ? "Enter new Notion Token (optional)"
: connector?.connector_type === "GITHUB_CONNECTOR" : connector?.connector_type ===
"GITHUB_CONNECTOR"
? "Enter new GitHub PAT (optional)" ? "Enter new GitHub PAT (optional)"
: connector?.connector_type === "LINKUP_API" : connector?.connector_type === "LINKUP_API"
? "Enter new Linkup API Key (optional)" ? "Enter new Linkup API Key (optional)"

View file

@ -0,0 +1,472 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { motion } from "framer-motion";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { toast } from "sonner";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// Define the form schema with Zod
const jiraConnectorFormSchema = z.object({
name: z.string().min(3, {
message: "Connector name must be at least 3 characters.",
}),
base_url: z
.string()
.url({
message:
"Please enter a valid Jira URL (e.g., https://yourcompany.atlassian.net)",
})
.refine(
(url) => {
return url.includes("atlassian.net") || url.includes("jira");
},
{
message: "Please enter a valid Jira instance URL",
},
),
email: z.string().email({
message: "Please enter a valid email address.",
}),
api_token: z.string().min(10, {
message: "Jira API Token is required and must be valid.",
}),
});
// Define the type for the form values
type JiraConnectorFormValues = z.infer<typeof jiraConnectorFormSchema>;
export default function JiraConnectorPage() {
const router = useRouter();
const params = useParams();
const searchSpaceId = params.search_space_id as string;
const [isSubmitting, setIsSubmitting] = useState(false);
const { createConnector } = useSearchSourceConnectors();
// Initialize the form
const form = useForm<JiraConnectorFormValues>({
resolver: zodResolver(jiraConnectorFormSchema),
defaultValues: {
name: "Jira Connector",
base_url: "",
email: "",
api_token: "",
},
});
// Handle form submission
const onSubmit = async (values: JiraConnectorFormValues) => {
setIsSubmitting(true);
try {
await createConnector({
name: values.name,
connector_type: "JIRA_CONNECTOR",
config: {
JIRA_BASE_URL: values.base_url,
JIRA_EMAIL: values.email,
JIRA_API_TOKEN: values.api_token,
},
is_indexable: true,
last_indexed_at: null,
});
toast.success("Jira connector created successfully!");
// Navigate back to connectors page
router.push(`/dashboard/${searchSpaceId}/connectors`);
} catch (error) {
console.error("Error creating connector:", error);
toast.error(
error instanceof Error ? error.message : "Failed to create connector",
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="container mx-auto py-8 max-w-3xl">
<Button
variant="ghost"
className="mb-6"
onClick={() =>
router.push(`/dashboard/${searchSpaceId}/connectors/add`)
}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Connectors
</Button>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<Tabs defaultValue="connect" className="w-full">
<TabsList className="grid w-full grid-cols-2 mb-6">
<TabsTrigger value="connect">Connect</TabsTrigger>
<TabsTrigger value="documentation">Documentation</TabsTrigger>
</TabsList>
<TabsContent value="connect">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Connect Jira Instance
</CardTitle>
<CardDescription>
Integrate with Jira to search and retrieve information from
your issues, tickets, and comments. This connector can index
your Jira content for search.
</CardDescription>
</CardHeader>
<CardContent>
<Alert className="mb-6 bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Jira Personal Access Token Required</AlertTitle>
<AlertDescription>
You'll need a Jira Personal Access Token to use this
connector. You can create one from{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
Atlassian Account Settings
</a>
</AlertDescription>
</Alert>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Connector Name</FormLabel>
<FormControl>
<Input placeholder="My Jira Connector" {...field} />
</FormControl>
<FormDescription>
A friendly name to identify this connector.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base_url"
render={({ field }) => (
<FormItem>
<FormLabel>Jira Instance URL</FormLabel>
<FormControl>
<Input
placeholder="https://yourcompany.atlassian.net"
{...field}
/>
</FormControl>
<FormDescription>
Your Jira instance URL. For Atlassian Cloud, this is
typically https://yourcompany.atlassian.net
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
type="email"
placeholder="your.email@company.com"
{...field}
/>
</FormControl>
<FormDescription>
Your Atlassian account email address.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="api_token"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Your Jira API Token"
{...field}
/>
</FormControl>
<FormDescription>
Your Jira API Token will be encrypted and stored securely.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
disabled={isSubmitting}
className="w-full sm:w-auto"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Connecting...
</>
) : (
<>
<Check className="mr-2 h-4 w-4" />
Connect Jira
</>
)}
</Button>
</div>
</form>
</Form>
</CardContent>
<CardFooter className="flex flex-col items-start border-t bg-muted/50 px-6 py-4">
<h4 className="text-sm font-medium">
What you get with Jira integration:
</h4>
<ul className="mt-2 list-disc pl-5 text-sm text-muted-foreground">
<li>Search through all your Jira issues and tickets</li>
<li>
Access issue descriptions, comments, and full discussion
threads
</li>
<li>
Connect your team's project management directly to your
search space
</li>
<li>
Keep your search results up-to-date with latest Jira content
</li>
<li>
Index your Jira issues for enhanced search capabilities
</li>
<li>
Search by issue keys, status, priority, and assignee
information
</li>
</ul>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="documentation">
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-2xl font-bold">
Jira Connector Documentation
</CardTitle>
<CardDescription>
Learn how to set up and use the Jira connector to index your
project management data.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h3 className="text-xl font-semibold mb-2">How it works</h3>
<p className="text-muted-foreground">
The Jira connector uses the Jira REST API with Basic Authentication
to fetch all issues and comments that your account has
access to within your Jira instance.
</p>
<ul className="mt-2 list-disc pl-5 text-muted-foreground">
<li>
For follow up indexing runs, the connector retrieves
issues and comments 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>
<Accordion type="single" collapsible className="w-full">
<AccordionItem value="authorization">
<AccordionTrigger className="text-lg font-medium">
Authorization
</AccordionTrigger>
<AccordionContent className="space-y-4">
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Read-Only Access is Sufficient</AlertTitle>
<AlertDescription>
You only need read access for this connector to work.
The API Token will only be used to read your Jira data.
</AlertDescription>
</Alert>
<div className="space-y-6">
<div>
<h4 className="font-medium mb-2">
Step 1: Create an API Token
</h4>
<ol className="list-decimal pl-5 space-y-3">
<li>Log in to your Atlassian account</li>
<li>
Navigate to{" "}
<a
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
rel="noopener noreferrer"
className="font-medium underline underline-offset-4"
>
https://id.atlassian.com/manage-profile/security/api-tokens
</a>
</li>
<li>
Click <strong>Create API token</strong>
</li>
<li>
Enter a label for your token (like "SurfSense
Connector")
</li>
<li>
Click <strong>Create</strong>
</li>
<li>
Copy the generated token as it will only be shown
once
</li>
</ol>
</div>
<div>
<h4 className="font-medium mb-2">
Step 2: Grant necessary access
</h4>
<p className="text-muted-foreground mb-3">
The API Token will have access to all projects and
issues that your user account can see. Make sure your
account has appropriate permissions for the projects
you want to index.
</p>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>Data Privacy</AlertTitle>
<AlertDescription>
Only issues, comments, and basic metadata will be
indexed. Jira attachments and linked files are not
indexed by this connector.
</AlertDescription>
</Alert>
</div>
</div>
</AccordionContent>
</AccordionItem>
<AccordionItem value="indexing">
<AccordionTrigger className="text-lg font-medium">
Indexing
</AccordionTrigger>
<AccordionContent className="space-y-4">
<ol className="list-decimal pl-5 space-y-3">
<li>
Navigate to the Connector Dashboard and select the{" "}
<strong>Jira</strong> Connector.
</li>
<li>
Enter your <strong>Jira Instance URL</strong> (e.g.,
https://yourcompany.atlassian.net)
</li>
<li>
Place your <strong>Personal Access Token</strong> in
the form field.
</li>
<li>
Click <strong>Connect</strong> to establish the
connection.
</li>
<li>
Once connected, your Jira issues will be indexed
automatically.
</li>
</ol>
<Alert className="bg-muted">
<Info className="h-4 w-4" />
<AlertTitle>What Gets Indexed</AlertTitle>
<AlertDescription>
<p className="mb-2">
The Jira connector indexes the following data:
</p>
<ul className="list-disc pl-5">
<li>Issue keys and summaries (e.g., PROJ-123)</li>
<li>Issue descriptions</li>
<li>Issue comments and discussion threads</li>
<li>
Issue status, priority, and type information
</li>
<li>Assignee and reporter information</li>
<li>Project information</li>
</ul>
</AlertDescription>
</Alert>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</motion.div>
</div>
);
}

View file

@ -1,8 +1,17 @@
"use client"; "use client";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; import {
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { import {
IconBrandDiscord, IconBrandDiscord,
IconBrandGithub, IconBrandGithub,
@ -67,23 +76,26 @@ const connectorCategories: ConnectorCategory[] = [
{ {
id: "slack-connector", id: "slack-connector",
title: "Slack", title: "Slack",
description: "Connect to your Slack workspace to access messages and channels.", description:
"Connect to your Slack workspace to access messages and channels.",
icon: <IconBrandSlack className="h-6 w-6" />, icon: <IconBrandSlack className="h-6 w-6" />,
status: "available", status: "available",
}, },
{ {
id: "ms-teams", id: "ms-teams",
title: "Microsoft Teams", title: "Microsoft Teams",
description: "Connect to Microsoft Teams to access your team's conversations.", description:
"Connect to Microsoft Teams to access your team's conversations.",
icon: <IconBrandWindows className="h-6 w-6" />, icon: <IconBrandWindows className="h-6 w-6" />,
status: "coming-soon", status: "coming-soon",
}, },
{ {
id: "discord-connector", id: "discord-connector",
title: "Discord", title: "Discord",
description: "Connect to Discord servers to access messages and channels.", description:
"Connect to Discord servers to access messages and channels.",
icon: <IconBrandDiscord className="h-6 w-6" />, icon: <IconBrandDiscord className="h-6 w-6" />,
status: "available" status: "available",
}, },
], ],
}, },
@ -94,16 +106,18 @@ const connectorCategories: ConnectorCategory[] = [
{ {
id: "linear-connector", id: "linear-connector",
title: "Linear", title: "Linear",
description: "Connect to Linear to search issues, comments and project data.", description:
"Connect to Linear to search issues, comments and project data.",
icon: <IconLayoutKanban className="h-6 w-6" />, icon: <IconLayoutKanban className="h-6 w-6" />,
status: "available", status: "available",
}, },
{ {
id: "jira-connector", id: "jira-connector",
title: "Jira", title: "Jira",
description: "Connect to Jira to search issues, tickets and project data.", description:
"Connect to Jira to search issues, tickets and project data.",
icon: <IconTicket className="h-6 w-6" />, icon: <IconTicket className="h-6 w-6" />,
status: "coming-soon", status: "available",
}, },
], ],
}, },
@ -114,14 +128,16 @@ const connectorCategories: ConnectorCategory[] = [
{ {
id: "notion-connector", id: "notion-connector",
title: "Notion", title: "Notion",
description: "Connect to your Notion workspace to access pages and databases.", description:
"Connect to your Notion workspace to access pages and databases.",
icon: <IconBrandNotion className="h-6 w-6" />, icon: <IconBrandNotion className="h-6 w-6" />,
status: "available", status: "available",
}, },
{ {
id: "github-connector", id: "github-connector",
title: "GitHub", title: "GitHub",
description: "Connect a GitHub PAT to index code and docs from accessible repositories.", description:
"Connect a GitHub PAT to index code and docs from accessible repositories.",
icon: <IconBrandGithub className="h-6 w-6" />, icon: <IconBrandGithub className="h-6 w-6" />,
status: "available", status: "available",
}, },
@ -141,7 +157,8 @@ const connectorCategories: ConnectorCategory[] = [
{ {
id: "zoom", id: "zoom",
title: "Zoom", title: "Zoom",
description: "Connect to Zoom to access meeting recordings and transcripts.", description:
"Connect to Zoom to access meeting recordings and transcripts.",
icon: <IconBrandZoom className="h-6 w-6" />, icon: <IconBrandZoom className="h-6 w-6" />,
status: "coming-soon", status: "coming-soon",
}, },
@ -152,7 +169,7 @@ const connectorCategories: ConnectorCategory[] = [
// Animation variants // Animation variants
const fadeIn = { const fadeIn = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
visible: { opacity: 1, transition: { duration: 0.4 } } visible: { opacity: 1, transition: { duration: 0.4 } },
}; };
const staggerContainer = { const staggerContainer = {
@ -160,9 +177,9 @@ const staggerContainer = {
visible: { visible: {
opacity: 1, opacity: 1,
transition: { transition: {
staggerChildren: 0.1 staggerChildren: 0.1,
} },
} },
}; };
const cardVariants = { const cardVariants = {
@ -173,30 +190,36 @@ const cardVariants = {
transition: { transition: {
type: "spring", type: "spring",
stiffness: 260, stiffness: 260,
damping: 20 damping: 20,
} },
}, },
hover: { hover: {
scale: 1.02, scale: 1.02,
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)", boxShadow:
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
transition: { transition: {
type: "spring", type: "spring",
stiffness: 400, stiffness: 400,
damping: 10 damping: 10,
} },
} },
}; };
export default function ConnectorsPage() { export default function ConnectorsPage() {
const params = useParams(); const params = useParams();
const searchSpaceId = params.search_space_id as string; const searchSpaceId = params.search_space_id as string;
const [expandedCategories, setExpandedCategories] = useState<string[]>(["search-engines", "knowledge-bases", "project-management", "team-chats"]); const [expandedCategories, setExpandedCategories] = useState<string[]>([
"search-engines",
"knowledge-bases",
"project-management",
"team-chats",
]);
const toggleCategory = (categoryId: string) => { const toggleCategory = (categoryId: string) => {
setExpandedCategories(prev => setExpandedCategories((prev) =>
prev.includes(categoryId) prev.includes(categoryId)
? prev.filter(id => id !== categoryId) ? prev.filter((id) => id !== categoryId)
: [...prev, categoryId] : [...prev, categoryId],
); );
}; };
@ -207,7 +230,7 @@ export default function ConnectorsPage() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ transition={{
duration: 0.6, duration: 0.6,
ease: [0.22, 1, 0.36, 1] ease: [0.22, 1, 0.36, 1],
}} }}
className="mb-12 text-center" className="mb-12 text-center"
> >
@ -215,7 +238,8 @@ export default function ConnectorsPage() {
Connect Your Tools Connect Your Tools
</h1> </h1>
<p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto"> <p className="text-muted-foreground mt-3 text-lg max-w-2xl mx-auto">
Integrate with your favorite services to enhance your research capabilities. Integrate with your favorite services to enhance your research
capabilities.
</p> </p>
</motion.div> </motion.div>
@ -239,9 +263,17 @@ export default function ConnectorsPage() {
<div className="flex items-center justify-between space-x-4 p-4"> <div className="flex items-center justify-between space-x-4 p-4">
<h3 className="text-xl font-semibold">{category.title}</h3> <h3 className="text-xl font-semibold">{category.title}</h3>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-9 p-0 hover:bg-muted"> <Button
variant="ghost"
size="sm"
className="w-9 p-0 hover:bg-muted"
>
<motion.div <motion.div
animate={{ rotate: expandedCategories.includes(category.id) ? 180 : 0 }} animate={{
rotate: expandedCategories.includes(category.id)
? 180
: 0,
}}
transition={{ duration: 0.3, ease: "easeInOut" }} transition={{ duration: 0.3, ease: "easeInOut" }}
> >
<IconChevronDown className="h-5 w-5" /> <IconChevronDown className="h-5 w-5" />
@ -279,14 +311,22 @@ export default function ConnectorsPage() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="font-medium">{connector.title}</h3> <h3 className="font-medium">
{connector.title}
</h3>
{connector.status === "coming-soon" && ( {connector.status === "coming-soon" && (
<Badge variant="outline" className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"> <Badge
variant="outline"
className="text-xs bg-amber-100 dark:bg-amber-950 text-amber-800 dark:text-amber-300 border-amber-200 dark:border-amber-800"
>
Coming soon Coming soon
</Badge> </Badge>
)} )}
{connector.status === "connected" && ( {connector.status === "connected" && (
<Badge variant="outline" className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"> <Badge
variant="outline"
className="text-xs bg-green-100 dark:bg-green-950 text-green-800 dark:text-green-300 border-green-200 dark:border-green-800"
>
Connected Connected
</Badge> </Badge>
)} )}
@ -301,28 +341,45 @@ export default function ConnectorsPage() {
</CardContent> </CardContent>
<CardFooter className="mt-auto pt-2"> <CardFooter className="mt-auto pt-2">
{connector.status === 'available' && ( {connector.status === "available" && (
<Link href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`} className="w-full"> <Link
<Button variant="default" className="w-full group"> href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
className="w-full"
>
<Button
variant="default"
className="w-full group"
>
<span>Connect</span> <span>Connect</span>
<motion.div <motion.div
className="ml-1" className="ml-1"
initial={{ x: 0 }} initial={{ x: 0 }}
whileHover={{ x: 3 }} whileHover={{ x: 3 }}
transition={{ type: "spring", stiffness: 400, damping: 10 }} transition={{
type: "spring",
stiffness: 400,
damping: 10,
}}
> >
<IconChevronRight className="h-4 w-4" /> <IconChevronRight className="h-4 w-4" />
</motion.div> </motion.div>
</Button> </Button>
</Link> </Link>
)} )}
{connector.status === 'coming-soon' && ( {connector.status === "coming-soon" && (
<Button variant="outline" disabled className="w-full opacity-70"> <Button
variant="outline"
disabled
className="w-full opacity-70"
>
Coming Soon Coming Soon
</Button> </Button>
)} )}
{connector.status === 'connected' && ( {connector.status === "connected" && (
<Button variant="outline" className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"> <Button
variant="outline"
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
>
Manage Manage
</Button> </Button>
)} )}

View file

@ -1,4 +1,4 @@
import React from 'react'; import React from "react";
import { import {
ChevronDown, ChevronDown,
Plus, Plus,
@ -12,77 +12,98 @@ import {
Webhook, Webhook,
MessageCircle, MessageCircle,
FileText, FileText,
} from 'lucide-react'; } from "lucide-react";
import { IconBrandNotion, IconBrandSlack, IconBrandYoutube, IconBrandGithub, IconLayoutKanban, IconLinkPlus, IconBrandDiscord } from "@tabler/icons-react"; import {
import { Button } from '@/components/ui/button'; IconBrandNotion,
import { Connector, ResearchMode } from './types'; IconBrandSlack,
IconBrandYoutube,
IconBrandGithub,
IconLayoutKanban,
IconLinkPlus,
IconBrandDiscord,
IconTicket,
} from "@tabler/icons-react";
import { Button } from "@/components/ui/button";
import { Connector, ResearchMode } from "./types";
// Helper function to get connector icon // Helper function to get connector icon
export const getConnectorIcon = (connectorType: string) => { export const getConnectorIcon = (connectorType: string) => {
const iconProps = { className: "h-4 w-4" }; const iconProps = { className: "h-4 w-4" };
switch(connectorType) { switch (connectorType) {
case 'LINKUP_API': case "LINKUP_API":
return <IconLinkPlus {...iconProps} />; return <IconLinkPlus {...iconProps} />;
case 'LINEAR_CONNECTOR': case "LINEAR_CONNECTOR":
return <IconLayoutKanban {...iconProps} />; return <IconLayoutKanban {...iconProps} />;
case 'GITHUB_CONNECTOR': case "GITHUB_CONNECTOR":
return <IconBrandGithub {...iconProps} />; return <IconBrandGithub {...iconProps} />;
case 'YOUTUBE_VIDEO': case "YOUTUBE_VIDEO":
return <IconBrandYoutube {...iconProps} />; return <IconBrandYoutube {...iconProps} />;
case 'CRAWLED_URL': case "CRAWLED_URL":
return <Globe {...iconProps} />; return <Globe {...iconProps} />;
case 'FILE': case "FILE":
return <File {...iconProps} />; return <File {...iconProps} />;
case 'EXTENSION': case "EXTENSION":
return <Webhook {...iconProps} />; return <Webhook {...iconProps} />;
case 'SERPER_API': case "SERPER_API":
case 'TAVILY_API': case "TAVILY_API":
return <Link {...iconProps} />; return <Link {...iconProps} />;
case 'SLACK_CONNECTOR': case "SLACK_CONNECTOR":
return <IconBrandSlack {...iconProps} />; return <IconBrandSlack {...iconProps} />;
case 'NOTION_CONNECTOR': case "NOTION_CONNECTOR":
return <IconBrandNotion {...iconProps} />; return <IconBrandNotion {...iconProps} />;
case 'DISCORD_CONNECTOR': case "DISCORD_CONNECTOR":
return <IconBrandDiscord {...iconProps} />; return <IconBrandDiscord {...iconProps} />;
case 'DEEP': case "JIRA_CONNECTOR":
return <IconTicket {...iconProps} />;
case "DEEP":
return <Sparkles {...iconProps} />; return <Sparkles {...iconProps} />;
case 'DEEPER': case "DEEPER":
return <Microscope {...iconProps} />; return <Microscope {...iconProps} />;
case 'DEEPEST': case "DEEPEST":
return <Telescope {...iconProps} />; return <Telescope {...iconProps} />;
default: default:
return <Search {...iconProps} />; return <Search {...iconProps} />;
} }
}; };
export const researcherOptions: { value: ResearchMode; label: string; icon: React.ReactNode }[] = [ export const researcherOptions: {
value: ResearchMode;
label: string;
icon: React.ReactNode;
}[] = [
{ {
value: 'QNA', value: "QNA",
label: 'Q/A', label: "Q/A",
icon: getConnectorIcon('GENERAL') icon: getConnectorIcon("GENERAL"),
}, },
{ {
value: 'REPORT_GENERAL', value: "REPORT_GENERAL",
label: 'General', label: "General",
icon: getConnectorIcon('GENERAL') icon: getConnectorIcon("GENERAL"),
}, },
{ {
value: 'REPORT_DEEP', value: "REPORT_DEEP",
label: 'Deep', label: "Deep",
icon: getConnectorIcon('DEEP') icon: getConnectorIcon("DEEP"),
}, },
{ {
value: 'REPORT_DEEPER', value: "REPORT_DEEPER",
label: 'Deeper', label: "Deeper",
icon: getConnectorIcon('DEEPER') icon: getConnectorIcon("DEEPER"),
}, },
] ];
/** /**
* Displays a small icon for a connector type * Displays a small icon for a connector type
*/ */
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => ( export const ConnectorIcon = ({
type,
index = 0,
}: {
type: string;
index?: number;
}) => (
<div <div
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background" className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
style={{ zIndex: 10 - index }} style={{ zIndex: 10 - index }}
@ -109,15 +130,21 @@ type ConnectorButtonProps = {
/** /**
* Button that displays selected connectors and opens connector selection dialog * Button that displays selected connectors and opens connector selection dialog
*/ */
export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources }: ConnectorButtonProps) => { export const ConnectorButton = ({
selectedConnectors,
onClick,
connectorSources,
}: ConnectorButtonProps) => {
const totalConnectors = connectorSources.length; const totalConnectors = connectorSources.length;
const selectedCount = selectedConnectors.length; const selectedCount = selectedConnectors.length;
const progressPercentage = (selectedCount / totalConnectors) * 100; const progressPercentage = (selectedCount / totalConnectors) * 100;
// Get the name of a single selected connector // Get the name of a single selected connector
const getSingleConnectorName = () => { const getSingleConnectorName = () => {
const connector = connectorSources.find(c => c.type === selectedConnectors[0]); const connector = connectorSources.find(
return connector?.name || ''; (c) => c.type === selectedConnectors[0],
);
return connector?.name || "";
}; };
// Get display text based on selection count // Get display text based on selection count
@ -158,14 +185,18 @@ export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources
variant="outline" variant="outline"
className="h-8 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group" className="h-8 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group"
onClick={onClick} onClick={onClick}
aria-label={selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`} aria-label={
selectedCount === 0
? "Select Connectors"
: `${selectedCount} connectors selected`
}
> >
{/* Progress indicator */} {/* Progress indicator */}
<div <div
className="absolute bottom-0 left-0 h-1 bg-primary" className="absolute bottom-0 left-0 h-1 bg-primary"
style={{ style={{
width: `${progressPercentage}%`, width: `${progressPercentage}%`,
transition: 'width 0.3s ease' transition: "width 0.3s ease",
}} }}
/> />
@ -183,29 +214,32 @@ type ResearchModeControlProps = {
onChange: (value: ResearchMode) => void; onChange: (value: ResearchMode) => void;
}; };
export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProps) => { export const ResearchModeControl = ({
value,
onChange,
}: ResearchModeControlProps) => {
// Determine if we're in Q/A mode or Report mode // Determine if we're in Q/A mode or Report mode
const isQnaMode = value === 'QNA'; const isQnaMode = value === "QNA";
const isReportMode = value.startsWith('REPORT_'); const isReportMode = value.startsWith("REPORT_");
// Get the current report sub-mode // Get the current report sub-mode
const getCurrentReportMode = () => { const getCurrentReportMode = () => {
if (!isReportMode) return 'GENERAL'; if (!isReportMode) return "GENERAL";
return value.replace('REPORT_', '') as 'GENERAL' | 'DEEP' | 'DEEPER'; return value.replace("REPORT_", "") as "GENERAL" | "DEEP" | "DEEPER";
}; };
const reportSubOptions = [ const reportSubOptions = [
{ value: 'GENERAL', label: 'General', icon: getConnectorIcon('GENERAL') }, { value: "GENERAL", label: "General", icon: getConnectorIcon("GENERAL") },
{ value: 'DEEP', label: 'Deep', icon: getConnectorIcon('DEEP') }, { value: "DEEP", label: "Deep", icon: getConnectorIcon("DEEP") },
{ value: 'DEEPER', label: 'Deeper', icon: getConnectorIcon('DEEPER') }, { value: "DEEPER", label: "Deeper", icon: getConnectorIcon("DEEPER") },
]; ];
const handleModeToggle = (mode: 'QNA' | 'REPORT') => { const handleModeToggle = (mode: "QNA" | "REPORT") => {
if (mode === 'QNA') { if (mode === "QNA") {
onChange('QNA'); onChange("QNA");
} else { } else {
// Default to GENERAL for Report mode // Default to GENERAL for Report mode
onChange('REPORT_GENERAL'); onChange("REPORT_GENERAL");
} }
}; };
@ -220,10 +254,10 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
<button <button
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${ className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
isQnaMode isQnaMode
? 'bg-primary text-primary-foreground' ? "bg-primary text-primary-foreground"
: 'hover:bg-muted text-muted-foreground hover:text-foreground' : "hover:bg-muted text-muted-foreground hover:text-foreground"
}`} }`}
onClick={() => handleModeToggle('QNA')} onClick={() => handleModeToggle("QNA")}
aria-pressed={isQnaMode} aria-pressed={isQnaMode}
> >
<MessageCircle className="h-3 w-3" /> <MessageCircle className="h-3 w-3" />
@ -232,10 +266,10 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
<button <button
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${ className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
isReportMode isReportMode
? 'bg-primary text-primary-foreground' ? "bg-primary text-primary-foreground"
: 'hover:bg-muted text-muted-foreground hover:text-foreground' : "hover:bg-muted text-muted-foreground hover:text-foreground"
}`} }`}
onClick={() => handleModeToggle('REPORT')} onClick={() => handleModeToggle("REPORT")}
aria-pressed={isReportMode} aria-pressed={isReportMode}
> >
<FileText className="h-3 w-3" /> <FileText className="h-3 w-3" />
@ -251,8 +285,8 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
key={option.value} key={option.value}
className={`flex h-full items-center gap-1 px-2 text-xs font-medium transition-colors whitespace-nowrap ${ className={`flex h-full items-center gap-1 px-2 text-xs font-medium transition-colors whitespace-nowrap ${
getCurrentReportMode() === option.value getCurrentReportMode() === option.value
? 'bg-primary text-primary-foreground' ? "bg-primary text-primary-foreground"
: 'hover:bg-muted text-muted-foreground hover:text-foreground' : "hover:bg-muted text-muted-foreground hover:text-foreground"
}`} }`}
onClick={() => handleReportSubModeChange(option.value)} onClick={() => handleReportSubModeChange(option.value)}
aria-pressed={getCurrentReportMode() === option.value} aria-pressed={getCurrentReportMode() === option.value}

View file

@ -1,14 +1,15 @@
// Helper function to get connector type display name // Helper function to get connector type display name
export const getConnectorTypeDisplay = (type: string): string => { export const getConnectorTypeDisplay = (type: string): string => {
const typeMap: Record<string, string> = { const typeMap: Record<string, string> = {
"SERPER_API": "Serper API", SERPER_API: "Serper API",
"TAVILY_API": "Tavily API", TAVILY_API: "Tavily API",
"SLACK_CONNECTOR": "Slack", SLACK_CONNECTOR: "Slack",
"NOTION_CONNECTOR": "Notion", NOTION_CONNECTOR: "Notion",
"GITHUB_CONNECTOR": "GitHub", GITHUB_CONNECTOR: "GitHub",
"LINEAR_CONNECTOR": "Linear", LINEAR_CONNECTOR: "Linear",
"DISCORD_CONNECTOR": "Discord", JIRA_CONNECTOR: "Jira",
"LINKUP_API": "Linkup", DISCORD_CONNECTOR: "Discord",
}; LINKUP_API: "Linkup",
return typeMap[type] || type; };
return typeMap[type] || type;
}; };