mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-01 18:19:08 +00:00
Fixed formatting and linting post Jira connector PR
This commit is contained in:
commit
2827522ebc
30 changed files with 5428 additions and 3279 deletions
|
@ -10,7 +10,7 @@
|
|||
|
||||
|
||||
# SurfSense
|
||||
While tools like NotebookLM and Perplexity are impressive and highly effective for conducting research on any topic/query, SurfSense elevates this capability by integrating with your personal knowledge base. It is a highly customizable AI research agent, connected to external sources such as search engines (Tavily, LinkUp), Slack, Linear, Notion, YouTube, GitHub, Discord and more to come.
|
||||
While tools like NotebookLM and Perplexity are impressive and highly effective for conducting research on any topic/query, SurfSense elevates this capability by integrating with your personal knowledge base. It is a highly customizable AI research agent, connected to external sources such as search engines (Tavily, LinkUp), Slack, Linear, Jira, Notion, YouTube, GitHub, Discord and more to come.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
@ -63,6 +63,7 @@ Open source and easy to deploy locally.
|
|||
- Search Engines (Tavily, LinkUp)
|
||||
- Slack
|
||||
- Linear
|
||||
- Jira
|
||||
- Notion
|
||||
- Youtube Videos
|
||||
- GitHub
|
||||
|
|
1
node_modules/.cache/prettier/.prettier-caches/a2ecb2962bf19c1099cfe708e42daa0097f94976.json
generated
vendored
Normal file
1
node_modules/.cache/prettier/.prettier-caches/a2ecb2962bf19c1099cfe708e42daa0097f94976.json
generated
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
{"2d0ec64d93969318101ee479b664221b32241665":{"files":{"surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx":["EHKKvlOK0vfy0GgHwlG/J2Bx5rw=",true]},"modified":1753426633288}}
|
|
@ -20,70 +20,119 @@ depends_on: str | Sequence[str] | None = None
|
|||
def upgrade() -> None:
|
||||
"""Upgrade schema - add LiteLLMProvider enum, LLMConfig table and user LLM preferences."""
|
||||
|
||||
# Check if enum type exists and create if it doesn't
|
||||
op.execute("""
|
||||
# Create enum only if not exists
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
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$$;
|
||||
""")
|
||||
|
||||
# Create llm_configs table using raw SQL to avoid enum creation conflicts
|
||||
op.execute("""
|
||||
CREATE TABLE llm_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
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
|
||||
)
|
||||
""")
|
||||
|
||||
# Create indexes
|
||||
op.create_index(op.f("ix_llm_configs_id"), "llm_configs", ["id"], unique=False)
|
||||
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)
|
||||
|
||||
# Add LLM preference columns to user table
|
||||
op.add_column("user", sa.Column("long_context_llm_id", sa.Integer(), nullable=True))
|
||||
op.add_column("user", sa.Column("fast_llm_id", sa.Integer(), nullable=True))
|
||||
op.add_column("user", sa.Column("strategic_llm_id", sa.Integer(), nullable=True))
|
||||
# Create llm_configs table only if it doesn't already exist
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'llm_configs'
|
||||
) THEN
|
||||
CREATE TABLE llm_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
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 foreign key constraints for LLM preferences
|
||||
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",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
op.f("fk_user_fast_llm_id_llm_configs"),
|
||||
"user",
|
||||
"llm_configs",
|
||||
["fast_llm_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
op.f("fk_user_strategic_llm_id_llm_configs"),
|
||||
"user",
|
||||
"llm_configs",
|
||||
["strategic_llm_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
# Create indexes if they don't exist
|
||||
op.execute(
|
||||
"""
|
||||
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;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'llm_configs' AND indexname = 'ix_llm_configs_created_at'
|
||||
) THEN
|
||||
CREATE INDEX ix_llm_configs_created_at ON llm_configs(created_at);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'llm_configs' AND indexname = 'ix_llm_configs_name'
|
||||
) 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:
|
||||
"""Downgrade schema - remove LLMConfig table and user LLM preferences."""
|
||||
|
|
|
@ -6,6 +6,8 @@ Revises: 11
|
|||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
@ -18,47 +20,73 @@ depends_on: str | Sequence[str] | None = None
|
|||
def upgrade() -> None:
|
||||
"""Upgrade schema - add LogLevel and LogStatus enums and logs table."""
|
||||
|
||||
# Create LogLevel enum
|
||||
op.execute("""
|
||||
CREATE TYPE loglevel AS ENUM ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
|
||||
""")
|
||||
# Create LogLevel enum if it doesn't exist
|
||||
op.execute(
|
||||
"""
|
||||
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
|
||||
op.execute("""
|
||||
CREATE TYPE logstatus AS ENUM ('IN_PROGRESS', 'SUCCESS', 'FAILED')
|
||||
""")
|
||||
# Create LogStatus enum if it doesn't exist
|
||||
op.execute(
|
||||
"""
|
||||
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
|
||||
op.execute("""
|
||||
CREATE TABLE logs (
|
||||
# Create logs table if it doesn't exist
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS logs (
|
||||
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,
|
||||
status logstatus NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
source VARCHAR(200),
|
||||
log_metadata JSONB DEFAULT '{}',
|
||||
search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes
|
||||
op.create_index(op.f("ix_logs_id"), "logs", ["id"], unique=False)
|
||||
op.create_index(op.f("ix_logs_created_at"), "logs", ["created_at"], unique=False)
|
||||
op.create_index(op.f("ix_logs_level"), "logs", ["level"], unique=False)
|
||||
op.create_index(op.f("ix_logs_status"), "logs", ["status"], unique=False)
|
||||
op.create_index(op.f("ix_logs_source"), "logs", ["source"], unique=False)
|
||||
# Get existing indexes
|
||||
conn = op.get_bind()
|
||||
inspector = inspect(conn)
|
||||
existing_indexes = [idx["name"] for idx in inspector.get_indexes("logs")]
|
||||
|
||||
# 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:
|
||||
"""Downgrade schema - remove logs table and enums."""
|
||||
|
||||
# Drop indexes
|
||||
op.drop_index(op.f("ix_logs_source"), table_name="logs")
|
||||
op.drop_index(op.f("ix_logs_status"), table_name="logs")
|
||||
op.drop_index(op.f("ix_logs_level"), table_name="logs")
|
||||
op.drop_index(op.f("ix_logs_created_at"), table_name="logs")
|
||||
op.drop_index(op.f("ix_logs_id"), table_name="logs")
|
||||
op.drop_index("ix_logs_source", table_name="logs")
|
||||
op.drop_index("ix_logs_status", table_name="logs")
|
||||
op.drop_index("ix_logs_level", table_name="logs")
|
||||
op.drop_index("ix_logs_created_at", table_name="logs")
|
||||
op.drop_index("ix_logs_id", table_name="logs")
|
||||
|
||||
# Drop logs table
|
||||
op.drop_table("logs")
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
"""Add JIRA_CONNECTOR to enums
|
||||
|
||||
Revision ID: 13
|
||||
Revises: 12
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "13"
|
||||
down_revision: str | None = "12"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: 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
|
|
@ -25,7 +25,23 @@ def upgrade() -> None:
|
|||
|
||||
# Manually add the command to add the enum value
|
||||
# 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
|
||||
|
|
|
@ -17,14 +17,25 @@ depends_on: str | Sequence[str] | None = 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:
|
||||
|
|
|
@ -22,7 +22,22 @@ NEW_VALUE = "LINEAR_CONNECTOR"
|
|||
|
||||
def upgrade() -> None:
|
||||
"""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
|
||||
|
|
|
@ -8,6 +8,7 @@ Revises: 5
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
|
||||
from alembic import op
|
||||
|
@ -20,21 +21,28 @@ depends_on: str | Sequence[str] | None = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the old column and create a new one with the new name and type
|
||||
# We need to do this because PostgreSQL doesn't support direct column renames with type changes
|
||||
op.add_column(
|
||||
"podcasts",
|
||||
sa.Column("podcast_transcript", JSON, nullable=False, server_default="{}"),
|
||||
)
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
# Copy data from old column to new column
|
||||
# Convert text to JSON by storing it as a JSON string value
|
||||
op.execute(
|
||||
"UPDATE podcasts SET podcast_transcript = jsonb_build_object('text', podcast_content) WHERE podcast_content != ''"
|
||||
)
|
||||
columns = [col["name"] for col in inspector.get_columns("podcasts")]
|
||||
if "podcast_transcript" not in columns:
|
||||
op.add_column(
|
||||
"podcasts",
|
||||
sa.Column("podcast_transcript", JSON, nullable=False, server_default="{}"),
|
||||
)
|
||||
|
||||
# Drop the old column
|
||||
op.drop_column("podcasts", "podcast_content")
|
||||
# Copy data from old column to new column
|
||||
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:
|
||||
|
|
|
@ -8,6 +8,7 @@ Revises: 6
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
@ -19,8 +20,14 @@ depends_on: str | Sequence[str] | None = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Drop the is_generated column
|
||||
op.drop_column("podcasts", "is_generated")
|
||||
# Get the current database connection
|
||||
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:
|
||||
|
|
|
@ -7,6 +7,7 @@ Revises: 7
|
|||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
@ -18,44 +19,53 @@ depends_on: str | Sequence[str] | None = None
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add content_hash column as nullable first to handle existing data
|
||||
op.add_column("documents", sa.Column("content_hash", sa.String(), nullable=True))
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
columns = [col["name"] for col in inspector.get_columns("documents")]
|
||||
|
||||
# Update existing documents to generate content hashes
|
||||
# Using SHA-256 hash of the content column with proper UTF-8 encoding
|
||||
op.execute("""
|
||||
UPDATE documents
|
||||
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
|
||||
# Only add the column if it doesn't already exist
|
||||
if "content_hash" not in columns:
|
||||
op.add_column(
|
||||
"documents", sa.Column("content_hash", sa.String(), nullable=True)
|
||||
)
|
||||
""")
|
||||
|
||||
# Now alter the column to match the model: nullable=False, index=True, unique=True
|
||||
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"]
|
||||
)
|
||||
# Populate the content_hash column
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE documents
|
||||
SET content_hash = encode(sha256(convert_to(content, 'UTF8')), 'hex')
|
||||
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:
|
||||
# Remove constraints and index first
|
||||
op.drop_constraint(op.f("uq_documents_content_hash"), "documents", type_="unique")
|
||||
op.drop_index(op.f("ix_documents_content_hash"), table_name="documents")
|
||||
|
||||
# Remove content_hash column from documents table
|
||||
op.drop_column("documents", "content_hash")
|
||||
|
|
|
@ -22,11 +22,38 @@ DOCUMENT_NEW_VALUE = "DISCORD_CONNECTOR"
|
|||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema - add DISCORD_CONNECTOR to connector and document enum."""
|
||||
# Add DISCORD_CONNECTOR to searchsourceconnectortype
|
||||
op.execute(f"ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}'")
|
||||
# Add DISCORD_CONNECTOR to documenttype
|
||||
op.execute(f"ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}'")
|
||||
"""Upgrade schema - add DISCORD_CONNECTOR to connector and document enum safely."""
|
||||
# Add DISCORD_CONNECTOR to searchsourceconnectortype only if not exists
|
||||
op.execute(
|
||||
f"""
|
||||
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:
|
||||
|
|
|
@ -1,10 +1,3 @@
|
|||
"""Add GITHUB_CONNECTOR to DocumentType enum
|
||||
|
||||
Revision ID: e55302644c51
|
||||
Revises: 1
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
@ -16,23 +9,34 @@ branch_labels: str | Sequence[str] | None = None
|
|||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""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:
|
||||
"""Downgrade schema - remove GITHUB_CONNECTOR from enum."""
|
||||
|
||||
# The old type name
|
||||
old_enum_name = f"{ENUM_NAME}_old"
|
||||
|
||||
# Enum values *before* GITHUB_CONNECTOR was added
|
||||
old_values = (
|
||||
"EXTENSION",
|
||||
"CRAWLED_URL",
|
||||
|
@ -43,25 +47,21 @@ def downgrade() -> None:
|
|||
)
|
||||
old_values_sql = ", ".join([f"'{v}'" for v in old_values])
|
||||
|
||||
# Table and column names (adjust if different)
|
||||
table_name = "documents"
|
||||
column_name = "document_type"
|
||||
|
||||
# 1. Rename the current enum type
|
||||
op.execute(f"ALTER TYPE {ENUM_NAME} RENAME TO {old_enum_name}")
|
||||
# 1. Create the new enum type with the old values
|
||||
op.execute(f"CREATE TYPE {old_enum_name} AS ENUM({old_values_sql})")
|
||||
|
||||
# 2. Create the new enum type with the old values
|
||||
op.execute(f"CREATE TYPE {ENUM_NAME} AS ENUM({old_values_sql})")
|
||||
|
||||
# 3. Update the table:
|
||||
# 2. Delete rows using the new value
|
||||
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)
|
||||
# 3. Alter the column to use the old enum type
|
||||
op.execute(
|
||||
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
|
||||
op.execute(f"DROP TYPE {old_enum_name}")
|
||||
# ### end Alembic commands ###
|
||||
# 4. Drop the current enum type and rename the old one
|
||||
op.execute(f"DROP TYPE {ENUM_NAME}")
|
||||
op.execute(f"ALTER TYPE {old_enum_name} RENAME TO {ENUM_NAME}")
|
||||
|
|
|
@ -84,9 +84,9 @@ async def fetch_documents_by_ids(
|
|||
"document": {
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
"document_type": doc.document_type.value
|
||||
if doc.document_type
|
||||
else "UNKNOWN",
|
||||
"document_type": (
|
||||
doc.document_type.value if doc.document_type else "UNKNOWN"
|
||||
),
|
||||
"metadata": doc.document_metadata or {},
|
||||
},
|
||||
"source": doc.document_type.value if doc.document_type else "UNKNOWN",
|
||||
|
@ -186,9 +186,11 @@ async def fetch_documents_by_ids(
|
|||
title = f"GitHub: {doc.title}"
|
||||
description = metadata.get(
|
||||
"description",
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content,
|
||||
(
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content
|
||||
),
|
||||
)
|
||||
url = metadata.get("url", "")
|
||||
|
||||
|
@ -204,9 +206,11 @@ async def fetch_documents_by_ids(
|
|||
|
||||
description = metadata.get(
|
||||
"description",
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content,
|
||||
(
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content
|
||||
),
|
||||
)
|
||||
url = (
|
||||
f"https://www.youtube.com/watch?v={video_id}"
|
||||
|
@ -238,6 +242,35 @@ async def fetch_documents_by_ids(
|
|||
else:
|
||||
url = ""
|
||||
|
||||
elif doc_type == "JIRA_CONNECTOR":
|
||||
# Extract Jira-specific metadata
|
||||
issue_key = metadata.get("issue_key", "Unknown Issue")
|
||||
issue_title = metadata.get("issue_title", "Untitled Issue")
|
||||
status = metadata.get("status", "")
|
||||
priority = metadata.get("priority", "")
|
||||
issue_type = metadata.get("issue_type", "")
|
||||
|
||||
title = f"Jira: {issue_key} - {issue_title}"
|
||||
if status:
|
||||
title += f" ({status})"
|
||||
|
||||
description = (
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content
|
||||
)
|
||||
if priority:
|
||||
description += f" | Priority: {priority}"
|
||||
if issue_type:
|
||||
description += f" | Type: {issue_type}"
|
||||
|
||||
# Construct Jira URL if we have the base URL
|
||||
base_url = metadata.get("base_url", "")
|
||||
if base_url and issue_key:
|
||||
url = f"{base_url}/browse/{issue_key}"
|
||||
else:
|
||||
url = ""
|
||||
|
||||
elif doc_type == "EXTENSION":
|
||||
# Extract Extension-specific metadata
|
||||
webpage_title = metadata.get("VisitedWebPageTitle", doc.title)
|
||||
|
@ -268,9 +301,11 @@ async def fetch_documents_by_ids(
|
|||
"og:description",
|
||||
metadata.get(
|
||||
"ogDescription",
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content,
|
||||
(
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content
|
||||
),
|
||||
),
|
||||
)
|
||||
url = metadata.get("url", "")
|
||||
|
@ -301,6 +336,7 @@ async def fetch_documents_by_ids(
|
|||
"GITHUB_CONNECTOR": "GitHub (Selected)",
|
||||
"YOUTUBE_VIDEO": "YouTube Videos (Selected)",
|
||||
"DISCORD_CONNECTOR": "Discord (Selected)",
|
||||
"JIRA_CONNECTOR": "Jira Issues (Selected)",
|
||||
"EXTENSION": "Browser Extension (Selected)",
|
||||
"CRAWLED_URL": "Web Pages (Selected)",
|
||||
"FILE": "Files (Selected)",
|
||||
|
@ -376,10 +412,10 @@ async def write_answer_outline(
|
|||
# Create the human message content
|
||||
human_message_content = f"""
|
||||
Now Please create an answer outline for the following query:
|
||||
|
||||
|
||||
User Query: {reformulated_query}
|
||||
Number of Sections: {num_sections}
|
||||
|
||||
|
||||
Remember to format your response as valid JSON exactly matching this structure:
|
||||
{{
|
||||
"answer_outline": [
|
||||
|
@ -393,7 +429,7 @@ async def write_answer_outline(
|
|||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
|
||||
Your output MUST be valid JSON in exactly this format. Do not include any other text or explanation.
|
||||
"""
|
||||
|
||||
|
@ -802,7 +838,9 @@ async def fetch_relevant_documents(
|
|||
source_object,
|
||||
linkup_chunks,
|
||||
) = await connector_service.search_linkup(
|
||||
user_query=reformulated_query, user_id=user_id, mode=linkup_mode
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
mode=linkup_mode,
|
||||
)
|
||||
|
||||
# Add to sources and raw documents
|
||||
|
@ -845,6 +883,30 @@ async def fetch_relevant_documents(
|
|||
}
|
||||
)
|
||||
|
||||
elif connector == "JIRA_CONNECTOR":
|
||||
source_object, jira_chunks = await connector_service.search_jira(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
)
|
||||
|
||||
# Add to sources and raw documents
|
||||
if source_object:
|
||||
all_sources.append(source_object)
|
||||
all_raw_documents.extend(jira_chunks)
|
||||
|
||||
# Stream found document count
|
||||
if streaming_service and writer:
|
||||
writer(
|
||||
{
|
||||
"yield_value": streaming_service.format_terminal_info_delta(
|
||||
f"🎫 Found {len(jira_chunks)} Jira issues related to your query"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_message = f"Error searching connector {connector}: {e!s}"
|
||||
print(error_message)
|
||||
|
@ -1214,7 +1276,7 @@ async def process_sections(
|
|||
|
||||
# Combine the results into a final report with section titles
|
||||
final_report = []
|
||||
for _, (section, content) in enumerate(
|
||||
for _i, (section, content) in enumerate(
|
||||
zip(answer_outline.answer_outline, processed_results, strict=False)
|
||||
):
|
||||
# Skip adding the section header since the content already contains the title
|
||||
|
@ -1725,11 +1787,11 @@ async def generate_further_questions(
|
|||
# Create the human message content
|
||||
human_message_content = f"""
|
||||
{chat_history_xml}
|
||||
|
||||
|
||||
{documents_xml}
|
||||
|
||||
|
||||
Based on the chat history and available documents above, generate 3-5 contextually relevant follow-up questions that would naturally extend the conversation and provide additional value to the user. Make sure the questions can be reasonably answered using the available documents or knowledge base.
|
||||
|
||||
|
||||
Your response MUST be valid JSON in exactly this format:
|
||||
{{
|
||||
"further_questions": [
|
||||
|
@ -1743,7 +1805,7 @@ async def generate_further_questions(
|
|||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
|
||||
Do not include any other text or explanation. Only return the JSON.
|
||||
"""
|
||||
|
||||
|
|
|
@ -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)
|
||||
- GITHUB_CONNECTOR: "GitHub repository content and issues" (personal repositories and interactions)
|
||||
- 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)
|
||||
- LINKUP_API: "Linkup search API results" (personalized search results)
|
||||
</knowledge_sources>
|
||||
|
@ -71,7 +72,7 @@ You are SurfSense, an advanced AI research assistant that provides detailed, wel
|
|||
Python's asyncio library provides tools for writing concurrent code using the async/await syntax. It's particularly useful for I/O-bound and high-level structured network code.
|
||||
</content>
|
||||
</document>
|
||||
|
||||
|
||||
<document>
|
||||
<metadata>
|
||||
<source_id>12</source_id>
|
||||
|
|
|
@ -43,6 +43,8 @@ def get_connector_emoji(connector_name: str) -> str:
|
|||
"NOTION_CONNECTOR": "📘",
|
||||
"GITHUB_CONNECTOR": "🐙",
|
||||
"LINEAR_CONNECTOR": "📊",
|
||||
"JIRA_CONNECTOR": "🎫",
|
||||
"DISCORD_CONNECTOR": "🗨️",
|
||||
"TAVILY_API": "🔍",
|
||||
"LINKUP_API": "🔗",
|
||||
}
|
||||
|
@ -60,6 +62,8 @@ def get_connector_friendly_name(connector_name: str) -> str:
|
|||
"NOTION_CONNECTOR": "Notion",
|
||||
"GITHUB_CONNECTOR": "GitHub",
|
||||
"LINEAR_CONNECTOR": "Linear",
|
||||
"JIRA_CONNECTOR": "Jira",
|
||||
"DISCORD_CONNECTOR": "Discord",
|
||||
"TAVILY_API": "Tavily Search",
|
||||
"LINKUP_API": "Linkup Search",
|
||||
}
|
||||
|
|
487
surfsense_backend/app/connectors/jira_connector.py
Normal file
487
surfsense_backend/app/connectors/jira_connector.py
Normal 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
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class JiraConnector:
|
||||
"""Class for retrieving data from Jira."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
email: str | None = None,
|
||||
api_token: str | None = 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: dict[str, Any] | None = 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: str | None = 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: str | None = None,
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
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: {e!s}"
|
||||
|
||||
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
|
|
@ -3,6 +3,7 @@ from datetime import UTC, datetime
|
|||
from enum import Enum
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import (
|
||||
ARRAY,
|
||||
|
@ -26,13 +27,7 @@ from app.retriver.chunks_hybrid_search import ChucksHybridSearchRetriever
|
|||
from app.retriver.documents_hybrid_search import DocumentHybridSearchRetriever
|
||||
|
||||
if config.AUTH_TYPE == "GOOGLE":
|
||||
from fastapi_users.db import (
|
||||
SQLAlchemyBaseOAuthAccountTableUUID,
|
||||
SQLAlchemyBaseUserTableUUID,
|
||||
SQLAlchemyUserDatabase,
|
||||
)
|
||||
else:
|
||||
from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyUserDatabase
|
||||
from fastapi_users.db import SQLAlchemyBaseOAuthAccountTableUUID
|
||||
|
||||
DATABASE_URL = config.DATABASE_URL
|
||||
|
||||
|
@ -47,6 +42,7 @@ class DocumentType(str, Enum):
|
|||
GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
|
||||
LINEAR_CONNECTOR = "LINEAR_CONNECTOR"
|
||||
DISCORD_CONNECTOR = "DISCORD_CONNECTOR"
|
||||
JIRA_CONNECTOR = "JIRA_CONNECTOR"
|
||||
|
||||
|
||||
class SearchSourceConnectorType(str, Enum):
|
||||
|
@ -58,6 +54,7 @@ class SearchSourceConnectorType(str, Enum):
|
|||
GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
|
||||
LINEAR_CONNECTOR = "LINEAR_CONNECTOR"
|
||||
DISCORD_CONNECTOR = "DISCORD_CONNECTOR"
|
||||
JIRA_CONNECTOR = "JIRA_CONNECTOR"
|
||||
|
||||
|
||||
class ChatType(str, Enum):
|
||||
|
@ -320,6 +317,7 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
strategic_llm = relationship(
|
||||
"LLMConfig", foreign_keys=[strategic_llm_id], post_update=True
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||
|
@ -402,6 +400,7 @@ if config.AUTH_TYPE == "GOOGLE":
|
|||
|
||||
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
|
||||
yield SQLAlchemyUserDatabase(session, User, OAuthAccount)
|
||||
|
||||
else:
|
||||
|
||||
async def get_user_db(session: AsyncSession = Depends(get_async_session)):
|
||||
|
|
|
@ -38,6 +38,7 @@ from app.schemas import (
|
|||
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,
|
||||
|
@ -336,6 +337,7 @@ async def index_connector_content(
|
|||
- NOTION_CONNECTOR: Indexes pages from all accessible Notion pages
|
||||
- GITHUB_CONNECTOR: Indexes code and documentation from GitHub repositories
|
||||
- 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
|
||||
|
||||
Args:
|
||||
|
@ -353,7 +355,9 @@ async def index_connector_content(
|
|||
)
|
||||
|
||||
# Check if the search space belongs to the user
|
||||
await check_ownership(session, SearchSpace, search_space_id, user)
|
||||
_search_space = await check_ownership(
|
||||
session, SearchSpace, search_space_id, user
|
||||
)
|
||||
|
||||
# Handle different connector types
|
||||
response_message = ""
|
||||
|
@ -438,6 +442,21 @@ async def index_connector_content(
|
|||
)
|
||||
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:
|
||||
# Run indexing in background
|
||||
logger.info(
|
||||
|
@ -807,3 +826,61 @@ async def run_discord_indexing(
|
|||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background Discord indexing task: {e!s}")
|
||||
|
||||
|
||||
# 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
|
||||
|
|
|
@ -123,6 +123,25 @@ class SearchSourceConnectorBase(BaseModel):
|
|||
# Ensure the bot token is not empty
|
||||
if not config.get("DISCORD_BOT_TOKEN"):
|
||||
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
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from linkup import LinkupClient
|
||||
from sqlalchemy import func
|
||||
|
@ -204,7 +205,9 @@ class ConnectorService:
|
|||
|
||||
return result_object, files_chunks
|
||||
|
||||
def _transform_document_results(self, document_results: list[dict]) -> list[dict]:
|
||||
def _transform_document_results(
|
||||
self, document_results: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Transform results from document_retriever.hybrid_search() to match the format
|
||||
expected by the processing code.
|
||||
|
@ -608,6 +611,7 @@ class ConnectorService:
|
|||
visit_duration = metadata.get(
|
||||
"VisitedWebPageVisitDurationInMilliseconds", ""
|
||||
)
|
||||
_browsing_session_id = metadata.get("BrowsingSessionId", "")
|
||||
|
||||
# Create a more descriptive title for extension data
|
||||
title = webpage_title
|
||||
|
@ -948,6 +952,127 @@ class ConnectorService:
|
|||
|
||||
return result_object, linear_chunks
|
||||
|
||||
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:
|
||||
|
@ -1013,12 +1138,12 @@ class ConnectorService:
|
|||
# Create a source entry
|
||||
source = {
|
||||
"id": self.source_id_counter,
|
||||
"title": result.name
|
||||
if hasattr(result, "name")
|
||||
else "Linkup Result",
|
||||
"description": result.content[:100]
|
||||
if hasattr(result, "content")
|
||||
else "",
|
||||
"title": (
|
||||
result.name if hasattr(result, "name") else "Linkup Result"
|
||||
),
|
||||
"description": (
|
||||
result.content[:100] if hasattr(result, "content") else ""
|
||||
),
|
||||
"url": result.url if hasattr(result, "url") else "",
|
||||
}
|
||||
sources_list.append(source)
|
||||
|
@ -1030,9 +1155,11 @@ class ConnectorService:
|
|||
"score": 1.0, # Default score since not provided by Linkup
|
||||
"document": {
|
||||
"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",
|
||||
"metadata": {
|
||||
"url": result.url if hasattr(result, "url") else "",
|
||||
|
|
|
@ -10,6 +10,7 @@ from sqlalchemy.future import select
|
|||
from app.config import config
|
||||
from app.connectors.discord_connector import DiscordConnector
|
||||
from app.connectors.github_connector import GitHubConnector
|
||||
from app.connectors.jira_connector import JiraConnector
|
||||
from app.connectors.linear_connector import LinearConnector
|
||||
from app.connectors.notion_history import NotionHistoryConnector
|
||||
from app.connectors.slack_history import SlackHistory
|
||||
|
@ -1374,9 +1375,9 @@ async def index_linear_issues(
|
|||
# Process each issue
|
||||
for issue in issues:
|
||||
try:
|
||||
issue_id = issue.get("id")
|
||||
issue_identifier = issue.get("identifier", "")
|
||||
issue_title = issue.get("title", "")
|
||||
issue_id = issue.get("key")
|
||||
issue_identifier = issue.get("id", "")
|
||||
issue_title = issue.get("key", "")
|
||||
|
||||
if not issue_id or not issue_title:
|
||||
logger.warning(
|
||||
|
@ -1978,3 +1979,353 @@ async def index_discord_messages(
|
|||
)
|
||||
logger.error(f"Failed to index Discord messages: {e!s}", exc_info=True)
|
||||
return 0, f"Failed to index Discord messages: {e!s}"
|
||||
|
||||
|
||||
async def index_jira_issues(
|
||||
session: AsyncSession,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
update_last_indexed: bool = True,
|
||||
) -> tuple[int, str | None]:
|
||||
"""
|
||||
Index Jira issues and comments.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
connector_id: ID of the Jira connector
|
||||
search_space_id: ID of the search space to store documents in
|
||||
user_id: User ID
|
||||
start_date: Start date for indexing (YYYY-MM-DD format)
|
||||
end_date: End date for indexing (YYYY-MM-DD format)
|
||||
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
|
||||
|
||||
Returns:
|
||||
Tuple containing (number of documents indexed, error message or None)
|
||||
"""
|
||||
task_logger = TaskLoggingService(session, search_space_id)
|
||||
|
||||
# Log task start
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="jira_issues_indexing",
|
||||
source="connector_indexing_task",
|
||||
message=f"Starting Jira issues indexing for connector {connector_id}",
|
||||
metadata={
|
||||
"connector_id": connector_id,
|
||||
"user_id": str(user_id),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the connector from the database
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == connector_id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.JIRA_CONNECTOR,
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
if not connector:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Connector with ID {connector_id} not found",
|
||||
"Connector not found",
|
||||
{"error_type": "ConnectorNotFound"},
|
||||
)
|
||||
return 0, f"Connector with ID {connector_id} not found"
|
||||
|
||||
# Get the Jira credentials from the connector config
|
||||
jira_email = connector.config.get("JIRA_EMAIL")
|
||||
jira_api_token = connector.config.get("JIRA_API_TOKEN")
|
||||
jira_base_url = connector.config.get("JIRA_BASE_URL")
|
||||
|
||||
if not jira_email or not jira_api_token or not jira_base_url:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Jira credentials not found in connector config for connector {connector_id}",
|
||||
"Missing Jira credentials",
|
||||
{"error_type": "MissingCredentials"},
|
||||
)
|
||||
return 0, "Jira credentials not found in connector config"
|
||||
|
||||
# Initialize Jira client
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Initializing Jira client for connector {connector_id}",
|
||||
{"stage": "client_initialization"},
|
||||
)
|
||||
|
||||
jira_client = JiraConnector(
|
||||
base_url=jira_base_url, email=jira_email, api_token=jira_api_token
|
||||
)
|
||||
|
||||
# Calculate date range
|
||||
if start_date is None or end_date is None:
|
||||
# Fall back to calculating dates based on last_indexed_at
|
||||
calculated_end_date = datetime.now()
|
||||
|
||||
# Use last_indexed_at as start date if available, otherwise use 365 days ago
|
||||
if connector.last_indexed_at:
|
||||
# Convert dates to be comparable (both timezone-naive)
|
||||
last_indexed_naive = (
|
||||
connector.last_indexed_at.replace(tzinfo=None)
|
||||
if connector.last_indexed_at.tzinfo
|
||||
else connector.last_indexed_at
|
||||
)
|
||||
|
||||
# Check if last_indexed_at is in the future or after end_date
|
||||
if last_indexed_naive > calculated_end_date:
|
||||
logger.warning(
|
||||
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 365 days ago instead."
|
||||
)
|
||||
calculated_start_date = calculated_end_date - timedelta(days=365)
|
||||
else:
|
||||
calculated_start_date = last_indexed_naive
|
||||
logger.info(
|
||||
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
|
||||
)
|
||||
else:
|
||||
calculated_start_date = calculated_end_date - timedelta(
|
||||
days=365
|
||||
) # Use 365 days as default
|
||||
logger.info(
|
||||
f"No last_indexed_at found, using {calculated_start_date.strftime('%Y-%m-%d')} (365 days ago) as start date"
|
||||
)
|
||||
|
||||
# Use calculated dates if not provided
|
||||
start_date_str = (
|
||||
start_date if start_date else calculated_start_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
end_date_str = (
|
||||
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
else:
|
||||
# Use provided dates
|
||||
start_date_str = start_date
|
||||
end_date_str = end_date
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Jira issues from {start_date_str} to {end_date_str}",
|
||||
{
|
||||
"stage": "fetching_issues",
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
},
|
||||
)
|
||||
|
||||
# Get issues within date range
|
||||
try:
|
||||
issues, error = jira_client.get_issues_by_date_range(
|
||||
start_date=start_date_str, end_date=end_date_str, include_comments=True
|
||||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to get Jira issues: {error}")
|
||||
|
||||
# Don't treat "No issues found" as an error that should stop indexing
|
||||
if "No issues found" in error:
|
||||
logger.info(
|
||||
"No issues found is not a critical error, continuing with update"
|
||||
)
|
||||
if update_last_indexed:
|
||||
connector.last_indexed_at = datetime.now()
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Updated last_indexed_at to {connector.last_indexed_at} despite no issues found"
|
||||
)
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"No Jira issues found in date range {start_date_str} to {end_date_str}",
|
||||
{"issues_found": 0},
|
||||
)
|
||||
return 0, None
|
||||
else:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to get Jira issues: {error}",
|
||||
"API Error",
|
||||
{"error_type": "APIError"},
|
||||
)
|
||||
return 0, f"Failed to get Jira issues: {error}"
|
||||
|
||||
logger.info(f"Retrieved {len(issues)} issues from Jira API")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Jira issues: {e!s}", exc_info=True)
|
||||
return 0, f"Error fetching Jira issues: {e!s}"
|
||||
|
||||
# Process and index each issue
|
||||
documents_indexed = 0
|
||||
skipped_issues = []
|
||||
documents_skipped = 0
|
||||
|
||||
for issue in issues:
|
||||
try:
|
||||
issue_id = issue.get("key")
|
||||
issue_identifier = issue.get("key", "")
|
||||
issue_title = issue.get("id", "")
|
||||
|
||||
if not issue_id or not issue_title:
|
||||
logger.warning(
|
||||
f"Skipping issue with missing ID or title: {issue_id or 'Unknown'}"
|
||||
)
|
||||
skipped_issues.append(
|
||||
f"{issue_identifier or 'Unknown'} (missing data)"
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Format the issue for better readability
|
||||
formatted_issue = jira_client.format_issue(issue)
|
||||
|
||||
# Convert to markdown
|
||||
issue_content = jira_client.format_issue_to_markdown(formatted_issue)
|
||||
|
||||
if not issue_content:
|
||||
logger.warning(
|
||||
f"Skipping issue with no content: {issue_identifier} - {issue_title}"
|
||||
)
|
||||
skipped_issues.append(f"{issue_identifier} (no content)")
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Create a simple summary
|
||||
summary_content = f"Jira Issue {issue_identifier}: {issue_title}\n\nStatus: {formatted_issue.get('status', 'Unknown')}\n\n"
|
||||
if formatted_issue.get("description"):
|
||||
summary_content += (
|
||||
f"Description: {formatted_issue.get('description')}\n\n"
|
||||
)
|
||||
|
||||
# Add comment count
|
||||
comment_count = len(formatted_issue.get("comments", []))
|
||||
summary_content += f"Comments: {comment_count}"
|
||||
|
||||
# Generate content hash
|
||||
content_hash = generate_content_hash(issue_content, search_space_id)
|
||||
|
||||
# Check if document already exists
|
||||
existing_doc_by_hash_result = await session.execute(
|
||||
select(Document).where(Document.content_hash == content_hash)
|
||||
)
|
||||
existing_document_by_hash = (
|
||||
existing_doc_by_hash_result.scalars().first()
|
||||
)
|
||||
|
||||
if existing_document_by_hash:
|
||||
logger.info(
|
||||
f"Document with content hash {content_hash} already exists for issue {issue_identifier}. Skipping processing."
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Generate embedding for the summary
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
# Process chunks - using the full issue content with comments
|
||||
chunks = [
|
||||
Chunk(
|
||||
content=chunk.text,
|
||||
embedding=config.embedding_model_instance.embed(chunk.text),
|
||||
)
|
||||
for chunk in config.chunker_instance.chunk(issue_content)
|
||||
]
|
||||
|
||||
# Create and store new document
|
||||
logger.info(
|
||||
f"Creating new document for issue {issue_identifier} - {issue_title}"
|
||||
)
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Jira - {issue_identifier}: {issue_title}",
|
||||
document_type=DocumentType.JIRA_CONNECTOR,
|
||||
document_metadata={
|
||||
"issue_id": issue_id,
|
||||
"issue_identifier": issue_identifier,
|
||||
"issue_title": issue_title,
|
||||
"state": formatted_issue.get("status", "Unknown"),
|
||||
"comment_count": comment_count,
|
||||
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
},
|
||||
content=summary_content,
|
||||
content_hash=content_hash,
|
||||
embedding=summary_embedding,
|
||||
chunks=chunks,
|
||||
)
|
||||
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
logger.info(
|
||||
f"Successfully indexed new issue {issue_identifier} - {issue_title}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing issue {issue.get('identifier', 'Unknown')}: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
skipped_issues.append(
|
||||
f"{issue.get('identifier', 'Unknown')} (processing error)"
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue # Skip this issue and continue with others
|
||||
|
||||
# Update the last_indexed_at timestamp for the connector only if requested
|
||||
total_processed = documents_indexed
|
||||
if update_last_indexed:
|
||||
connector.last_indexed_at = datetime.now()
|
||||
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
||||
|
||||
# Commit all changes
|
||||
await session.commit()
|
||||
logger.info("Successfully committed all JIRA document changes to database")
|
||||
|
||||
# Log success
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed JIRA indexing for connector {connector_id}",
|
||||
{
|
||||
"issues_processed": total_processed,
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
"skipped_issues_count": len(skipped_issues),
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"JIRA indexing completed: {documents_indexed} new issues, {documents_skipped} skipped"
|
||||
)
|
||||
return (
|
||||
total_processed,
|
||||
None,
|
||||
) # Return None as the error message to indicate success
|
||||
|
||||
except SQLAlchemyError as db_error:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Database error during JIRA indexing for connector {connector_id}",
|
||||
str(db_error),
|
||||
{"error_type": "SQLAlchemyError"},
|
||||
)
|
||||
logger.error(f"Database error: {db_error!s}", exc_info=True)
|
||||
return 0, f"Database error: {db_error!s}"
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to index JIRA issues for connector {connector_id}",
|
||||
str(e),
|
||||
{"error_type": type(e).__name__},
|
||||
)
|
||||
logger.error(f"Failed to index JIRA issues: {e!s}", exc_info=True)
|
||||
return 0, f"Failed to index JIRA issues: {e!s}"
|
||||
|
|
|
@ -9,12 +9,12 @@ import { ArrowLeft, Check, Loader2, Github } from "lucide-react";
|
|||
import { Form } from "@/components/ui/form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
// Import Utils, Types, Hook, and Components
|
||||
|
@ -27,201 +27,227 @@ import { EditSimpleTokenForm } from "@/components/editConnector/EditSimpleTokenF
|
|||
import { getConnectorIcon } from "@/components/chat";
|
||||
|
||||
export default function EditConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
// Ensure connectorId is parsed safely
|
||||
const connectorIdParam = params.connector_id as string;
|
||||
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
// Ensure connectorId is parsed safely
|
||||
const connectorIdParam = params.connector_id as string;
|
||||
const connectorId = connectorIdParam ? parseInt(connectorIdParam, 10) : NaN;
|
||||
|
||||
// Use the custom hook to manage state and logic
|
||||
const {
|
||||
connectorsLoading,
|
||||
connector,
|
||||
isSaving,
|
||||
editForm,
|
||||
patForm, // Needed for GitHub child component
|
||||
handleSaveChanges,
|
||||
// GitHub specific props for the child component
|
||||
editMode,
|
||||
setEditMode, // Pass down if needed by GitHub component
|
||||
originalPat,
|
||||
currentSelectedRepos,
|
||||
fetchedRepos,
|
||||
setFetchedRepos,
|
||||
newSelectedRepos,
|
||||
setNewSelectedRepos,
|
||||
isFetchingRepos,
|
||||
handleFetchRepositories,
|
||||
handleRepoSelectionChange,
|
||||
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||
// Use the custom hook to manage state and logic
|
||||
const {
|
||||
connectorsLoading,
|
||||
connector,
|
||||
isSaving,
|
||||
editForm,
|
||||
patForm, // Needed for GitHub child component
|
||||
handleSaveChanges,
|
||||
// GitHub specific props for the child component
|
||||
editMode,
|
||||
setEditMode, // Pass down if needed by GitHub component
|
||||
originalPat,
|
||||
currentSelectedRepos,
|
||||
fetchedRepos,
|
||||
setFetchedRepos,
|
||||
newSelectedRepos,
|
||||
setNewSelectedRepos,
|
||||
isFetchingRepos,
|
||||
handleFetchRepositories,
|
||||
handleRepoSelectionChange,
|
||||
} = useConnectorEditPage(connectorId, searchSpaceId);
|
||||
|
||||
// Redirect if connectorId is not a valid number after parsing
|
||||
useEffect(() => {
|
||||
if (isNaN(connectorId)) {
|
||||
toast.error("Invalid Connector ID.");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectorId, router, searchSpaceId]);
|
||||
// Redirect if connectorId is not a valid number after parsing
|
||||
useEffect(() => {
|
||||
if (isNaN(connectorId)) {
|
||||
toast.error("Invalid Connector ID.");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
}, [connectorId, router, searchSpaceId]);
|
||||
|
||||
// Loading State
|
||||
if (connectorsLoading || !connector) {
|
||||
// Handle NaN case before showing skeleton
|
||||
if (isNaN(connectorId)) return null;
|
||||
return <EditConnectorLoadingSkeleton />;
|
||||
}
|
||||
// Loading State
|
||||
if (connectorsLoading || !connector) {
|
||||
// Handle NaN case before showing skeleton
|
||||
if (isNaN(connectorId)) return null;
|
||||
return <EditConnectorLoadingSkeleton />;
|
||||
}
|
||||
|
||||
// Main Render using data/handlers from the hook
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Connectors
|
||||
</Button>
|
||||
// Main Render using data/handlers from the hook
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors`)}
|
||||
>
|
||||
<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 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{getConnectorIcon(connector.connector_type)}
|
||||
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Modify connector name and configuration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
{getConnectorIcon(connector.connector_type)}
|
||||
Edit {getConnectorTypeDisplay(connector.connector_type)} Connector
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Modify connector name and configuration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<Form {...editForm}>
|
||||
{/* Pass hook's handleSaveChanges */}
|
||||
<form
|
||||
onSubmit={editForm.handleSubmit(handleSaveChanges)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Pass form control from hook */}
|
||||
<EditConnectorNameForm control={editForm.control} />
|
||||
<Form {...editForm}>
|
||||
{/* Pass hook's handleSaveChanges */}
|
||||
<form
|
||||
onSubmit={editForm.handleSubmit(handleSaveChanges)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Pass form control from hook */}
|
||||
<EditConnectorNameForm control={editForm.control} />
|
||||
|
||||
<hr />
|
||||
<hr />
|
||||
|
||||
<h3 className="text-lg font-semibold">Configuration</h3>
|
||||
<h3 className="text-lg font-semibold">Configuration</h3>
|
||||
|
||||
{/* == GitHub == */}
|
||||
{connector.connector_type === "GITHUB_CONNECTOR" && (
|
||||
<EditGitHubConnectorConfig
|
||||
// Pass relevant state and handlers from hook
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||
originalPat={originalPat}
|
||||
currentSelectedRepos={currentSelectedRepos}
|
||||
fetchedRepos={fetchedRepos}
|
||||
newSelectedRepos={newSelectedRepos}
|
||||
isFetchingRepos={isFetchingRepos}
|
||||
patForm={patForm}
|
||||
handleFetchRepositories={handleFetchRepositories}
|
||||
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||
setNewSelectedRepos={setNewSelectedRepos}
|
||||
setFetchedRepos={setFetchedRepos}
|
||||
/>
|
||||
)}
|
||||
{/* == GitHub == */}
|
||||
{connector.connector_type === "GITHUB_CONNECTOR" && (
|
||||
<EditGitHubConnectorConfig
|
||||
// Pass relevant state and handlers from hook
|
||||
editMode={editMode}
|
||||
setEditMode={setEditMode} // Pass setter if child manages mode
|
||||
originalPat={originalPat}
|
||||
currentSelectedRepos={currentSelectedRepos}
|
||||
fetchedRepos={fetchedRepos}
|
||||
newSelectedRepos={newSelectedRepos}
|
||||
isFetchingRepos={isFetchingRepos}
|
||||
patForm={patForm}
|
||||
handleFetchRepositories={handleFetchRepositories}
|
||||
handleRepoSelectionChange={handleRepoSelectionChange}
|
||||
setNewSelectedRepos={setNewSelectedRepos}
|
||||
setFetchedRepos={setFetchedRepos}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Slack == */}
|
||||
{connector.connector_type === "SLACK_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SLACK_BOT_TOKEN"
|
||||
fieldLabel="Slack Bot Token"
|
||||
fieldDescription="Update the Slack Bot Token if needed."
|
||||
placeholder="Begins with xoxb-..."
|
||||
/>
|
||||
)}
|
||||
{/* == Notion == */}
|
||||
{connector.connector_type === "NOTION_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="NOTION_INTEGRATION_TOKEN"
|
||||
fieldLabel="Notion Integration Token"
|
||||
fieldDescription="Update the Notion Integration Token if needed."
|
||||
placeholder="Begins with secret_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Serper == */}
|
||||
{connector.connector_type === "SERPER_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SERPER_API_KEY"
|
||||
fieldLabel="Serper API Key"
|
||||
fieldDescription="Update the Serper API Key if needed."
|
||||
/>
|
||||
)}
|
||||
{/* == Tavily == */}
|
||||
{connector.connector_type === "TAVILY_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="TAVILY_API_KEY"
|
||||
fieldLabel="Tavily API Key"
|
||||
fieldDescription="Update the Tavily API Key if needed."
|
||||
/>
|
||||
)}
|
||||
{/* == Slack == */}
|
||||
{connector.connector_type === "SLACK_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SLACK_BOT_TOKEN"
|
||||
fieldLabel="Slack Bot Token"
|
||||
fieldDescription="Update the Slack Bot Token if needed."
|
||||
placeholder="Begins with xoxb-..."
|
||||
/>
|
||||
)}
|
||||
{/* == Notion == */}
|
||||
{connector.connector_type === "NOTION_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="NOTION_INTEGRATION_TOKEN"
|
||||
fieldLabel="Notion Integration Token"
|
||||
fieldDescription="Update the Notion Integration Token if needed."
|
||||
placeholder="Begins with secret_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Serper == */}
|
||||
{connector.connector_type === "SERPER_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="SERPER_API_KEY"
|
||||
fieldLabel="Serper API Key"
|
||||
fieldDescription="Update the Serper API Key if needed."
|
||||
/>
|
||||
)}
|
||||
{/* == Tavily == */}
|
||||
{connector.connector_type === "TAVILY_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="TAVILY_API_KEY"
|
||||
fieldLabel="Tavily API Key"
|
||||
fieldDescription="Update the Tavily API Key if needed."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Linear == */}
|
||||
{connector.connector_type === "LINEAR_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINEAR_API_KEY"
|
||||
fieldLabel="Linear API Key"
|
||||
fieldDescription="Update your Linear API Key if needed."
|
||||
placeholder="Begins with lin_api_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Linear == */}
|
||||
{connector.connector_type === "LINEAR_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINEAR_API_KEY"
|
||||
fieldLabel="Linear API Key"
|
||||
fieldDescription="Update your Linear API Key if needed."
|
||||
placeholder="Begins with lin_api_..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Linkup == */}
|
||||
{connector.connector_type === "LINKUP_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINKUP_API_KEY"
|
||||
fieldLabel="Linkup API Key"
|
||||
fieldDescription="Update your Linkup API Key if needed."
|
||||
placeholder="Begins with linkup_..."
|
||||
/>
|
||||
)}
|
||||
{/* == Jira == */}
|
||||
{connector.connector_type === "JIRA_CONNECTOR" && (
|
||||
<div className="space-y-4">
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="JIRA_BASE_URL"
|
||||
fieldLabel="Jira Base URL"
|
||||
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 == */}
|
||||
{connector.connector_type === "DISCORD_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="DISCORD_BOT_TOKEN"
|
||||
fieldLabel="Discord Bot Token"
|
||||
fieldDescription="Update the Discord Bot Token if needed."
|
||||
placeholder="Bot token..."
|
||||
/>
|
||||
)}
|
||||
{/* == Linkup == */}
|
||||
{connector.connector_type === "LINKUP_API" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LINKUP_API_KEY"
|
||||
fieldLabel="Linkup API Key"
|
||||
fieldDescription="Update your Linkup API Key if needed."
|
||||
placeholder="Begins with linkup_..."
|
||||
/>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
{/* == Discord == */}
|
||||
{connector.connector_type === "DISCORD_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="DISCORD_BOT_TOKEN"
|
||||
fieldLabel="Discord Bot Token"
|
||||
fieldDescription="Update the Discord Bot Token if needed."
|
||||
placeholder="Bot token..."
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,10 @@ import * as z from "zod";
|
|||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
|
||||
import { useSearchSourceConnectors, SearchSourceConnector } from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
useSearchSourceConnectors,
|
||||
SearchSourceConnector,
|
||||
} from "@/hooks/useSearchSourceConnectors";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
@ -28,11 +31,7 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@/components/ui/alert";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const apiConnectorFormSchema = z.object({
|
||||
|
@ -47,13 +46,15 @@ const apiConnectorFormSchema = z.object({
|
|||
// Helper function to get connector type display name
|
||||
const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
"SERPER_API": "Serper API",
|
||||
"TAVILY_API": "Tavily API",
|
||||
"SLACK_CONNECTOR": "Slack Connector",
|
||||
"NOTION_CONNECTOR": "Notion Connector",
|
||||
"GITHUB_CONNECTOR": "GitHub Connector",
|
||||
"DISCORD_CONNECTOR": "Discord Connector",
|
||||
"LINKUP_API": "Linkup",
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
SLACK_CONNECTOR: "Slack Connector",
|
||||
NOTION_CONNECTOR: "Notion Connector",
|
||||
GITHUB_CONNECTOR: "GitHub Connector",
|
||||
LINEAR_CONNECTOR: "Linear Connector",
|
||||
JIRA_CONNECTOR: "Jira Connector",
|
||||
DISCORD_CONNECTOR: "Discord Connector",
|
||||
LINKUP_API: "Linkup",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
|
@ -67,9 +68,11 @@ export default function EditConnectorPage() {
|
|||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const connectorId = parseInt(params.connector_id as string, 10);
|
||||
|
||||
|
||||
const { connectors, updateConnector } = useSearchSourceConnectors();
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(null);
|
||||
const [connector, setConnector] = useState<SearchSourceConnector | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
// console.log("connector", connector);
|
||||
|
@ -85,24 +88,24 @@ export default function EditConnectorPage() {
|
|||
// Get API key field name based on connector type
|
||||
const getApiKeyFieldName = (connectorType: string): string => {
|
||||
const fieldMap: Record<string, string> = {
|
||||
"SERPER_API": "SERPER_API_KEY",
|
||||
"TAVILY_API": "TAVILY_API_KEY",
|
||||
"SLACK_CONNECTOR": "SLACK_BOT_TOKEN",
|
||||
"NOTION_CONNECTOR": "NOTION_INTEGRATION_TOKEN",
|
||||
"GITHUB_CONNECTOR": "GITHUB_PAT",
|
||||
"DISCORD_CONNECTOR": "DISCORD_BOT_TOKEN",
|
||||
"LINKUP_API": "LINKUP_API_KEY"
|
||||
SERPER_API: "SERPER_API_KEY",
|
||||
TAVILY_API: "TAVILY_API_KEY",
|
||||
SLACK_CONNECTOR: "SLACK_BOT_TOKEN",
|
||||
NOTION_CONNECTOR: "NOTION_INTEGRATION_TOKEN",
|
||||
GITHUB_CONNECTOR: "GITHUB_PAT",
|
||||
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
||||
LINKUP_API: "LINKUP_API_KEY",
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
|
||||
// Find connector in the list
|
||||
useEffect(() => {
|
||||
const currentConnector = connectors.find(c => c.id === connectorId);
|
||||
|
||||
const currentConnector = connectors.find((c) => c.id === connectorId);
|
||||
|
||||
if (currentConnector) {
|
||||
setConnector(currentConnector);
|
||||
|
||||
|
||||
// Check if connector type is supported
|
||||
const apiKeyField = getApiKeyFieldName(currentConnector.connector_type);
|
||||
if (apiKeyField) {
|
||||
|
@ -115,7 +118,7 @@ export default function EditConnectorPage() {
|
|||
toast.error("This connector type is not supported for editing");
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
}
|
||||
|
||||
|
||||
setIsLoading(false);
|
||||
} else if (!isLoading && connectors.length > 0) {
|
||||
// If connectors are loaded but this one isn't found
|
||||
|
@ -127,11 +130,11 @@ export default function EditConnectorPage() {
|
|||
// Handle form submission
|
||||
const onSubmit = async (values: ApiConnectorFormValues) => {
|
||||
if (!connector) return;
|
||||
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const apiKeyField = getApiKeyFieldName(connector.connector_type);
|
||||
|
||||
|
||||
// Only update the API key if a new one was provided
|
||||
const updatedConfig = { ...connector.config };
|
||||
if (values.api_key) {
|
||||
|
@ -150,7 +153,9 @@ export default function EditConnectorPage() {
|
|||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (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 {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
@ -186,24 +191,30 @@ export default function EditConnectorPage() {
|
|||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
Edit {connector ? getConnectorTypeDisplay(connector.connector_type) : ""} Connector
|
||||
Edit{" "}
|
||||
{connector
|
||||
? getConnectorTypeDisplay(connector.connector_type)
|
||||
: ""}{" "}
|
||||
Connector
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Update your connector settings.
|
||||
</CardDescription>
|
||||
<CardDescription>Update your connector settings.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert className="mb-6 bg-muted">
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertTitle>API Key Security</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key is stored securely. For security reasons, we don't display your existing API key.
|
||||
If you don't update the API key field, your existing key will be preserved.
|
||||
Your API key is stored securely. For security reasons, we don't
|
||||
display your existing API key. If you don't update the API key
|
||||
field, your existing key will be preserved.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
@ -227,10 +238,10 @@ export default function EditConnectorPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Notion Integration Token"
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Slack Bot Token"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Notion Integration Token"
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "GitHub Personal Access Token (PAT)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
|
@ -238,27 +249,28 @@ export default function EditConnectorPage() {
|
|||
: "API Key"}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={
|
||||
connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter new Slack Bot Token (optional)"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter new Slack Bot Token (optional)"
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter new Notion Token (optional)"
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
: connector?.connector_type ===
|
||||
"GITHUB_CONNECTOR"
|
||||
? "Enter new GitHub PAT (optional)"
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
? "Enter new Linkup API Key (optional)"
|
||||
: "Enter new API key (optional)"
|
||||
}
|
||||
{...field}
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||
{connector?.connector_type === "SLACK_CONNECTOR"
|
||||
? "Enter a new Slack Bot Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "NOTION_CONNECTOR"
|
||||
? "Enter a new Notion Integration Token or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "GITHUB_CONNECTOR"
|
||||
? "Enter a new GitHub PAT or leave blank to keep your existing token."
|
||||
: connector?.connector_type === "LINKUP_API"
|
||||
|
@ -271,8 +283,8 @@ export default function EditConnectorPage() {
|
|||
/>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
|
@ -296,4 +308,4 @@ export default function EditConnectorPage() {
|
|||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,8 +1,17 @@
|
|||
"use client";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
|
@ -67,23 +76,26 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
{
|
||||
id: "slack-connector",
|
||||
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" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "ms-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" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
{
|
||||
id: "discord-connector",
|
||||
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" />,
|
||||
status: "available"
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -94,16 +106,18 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
{
|
||||
id: "linear-connector",
|
||||
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" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "jira-connector",
|
||||
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" />,
|
||||
status: "coming-soon",
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
@ -114,14 +128,16 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
{
|
||||
id: "notion-connector",
|
||||
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" />,
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "github-connector",
|
||||
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" />,
|
||||
status: "available",
|
||||
},
|
||||
|
@ -141,7 +157,8 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
{
|
||||
id: "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" />,
|
||||
status: "coming-soon",
|
||||
},
|
||||
|
@ -152,7 +169,7 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
// Animation variants
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } }
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
|
@ -160,43 +177,49 @@ const staggerContainer = {
|
|||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 260,
|
||||
damping: 20
|
||||
}
|
||||
damping: 20,
|
||||
},
|
||||
},
|
||||
hover: {
|
||||
hover: {
|
||||
scale: 1.02,
|
||||
boxShadow: "0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||||
transition: {
|
||||
boxShadow:
|
||||
"0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)",
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10
|
||||
}
|
||||
}
|
||||
damping: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default function ConnectorsPage() {
|
||||
const params = useParams();
|
||||
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) => {
|
||||
setExpandedCategories(prev =>
|
||||
prev.includes(categoryId)
|
||||
? prev.filter(id => id !== categoryId)
|
||||
: [...prev, categoryId]
|
||||
setExpandedCategories((prev) =>
|
||||
prev.includes(categoryId)
|
||||
? prev.filter((id) => id !== categoryId)
|
||||
: [...prev, categoryId],
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -205,9 +228,9 @@ export default function ConnectorsPage() {
|
|||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
transition={{
|
||||
duration: 0.6,
|
||||
ease: [0.22, 1, 0.36, 1]
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
}}
|
||||
className="mb-12 text-center"
|
||||
>
|
||||
|
@ -215,18 +238,19 @@ export default function ConnectorsPage() {
|
|||
Connect Your Tools
|
||||
</h1>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="space-y-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{connectorCategories.map((category) => (
|
||||
<motion.div
|
||||
<motion.div
|
||||
key={category.id}
|
||||
variants={fadeIn}
|
||||
className="rounded-lg border bg-card text-card-foreground shadow-sm"
|
||||
|
@ -239,9 +263,17 @@ export default function ConnectorsPage() {
|
|||
<div className="flex items-center justify-between space-x-4 p-4">
|
||||
<h3 className="text-xl font-semibold">{category.title}</h3>
|
||||
<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
|
||||
animate={{ rotate: expandedCategories.includes(category.id) ? 180 : 0 }}
|
||||
animate={{
|
||||
rotate: expandedCategories.includes(category.id)
|
||||
? 180
|
||||
: 0,
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
>
|
||||
<IconChevronDown className="h-5 w-5" />
|
||||
|
@ -250,10 +282,10 @@ export default function ConnectorsPage() {
|
|||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
|
||||
|
||||
<CollapsibleContent>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4"
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
|
@ -279,50 +311,75 @@ export default function ConnectorsPage() {
|
|||
</div>
|
||||
<div>
|
||||
<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" && (
|
||||
<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
|
||||
</Badge>
|
||||
)}
|
||||
{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
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
||||
<CardContent className="pb-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{connector.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
|
||||
|
||||
<CardFooter className="mt-auto pt-2">
|
||||
{connector.status === 'available' && (
|
||||
<Link href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`} className="w-full">
|
||||
<Button variant="default" className="w-full group">
|
||||
{connector.status === "available" && (
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add/${connector.id}`}
|
||||
className="w-full"
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full group"
|
||||
>
|
||||
<span>Connect</span>
|
||||
<motion.div
|
||||
className="ml-1"
|
||||
initial={{ x: 0 }}
|
||||
whileHover={{ x: 3 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 10,
|
||||
}}
|
||||
>
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
</motion.div>
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{connector.status === 'coming-soon' && (
|
||||
<Button variant="outline" disabled className="w-full opacity-70">
|
||||
{connector.status === "coming-soon" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled
|
||||
className="w-full opacity-70"
|
||||
>
|
||||
Coming Soon
|
||||
</Button>
|
||||
)}
|
||||
{connector.status === 'connected' && (
|
||||
<Button variant="outline" className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950">
|
||||
{connector.status === "connected" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-green-500 text-green-600 hover:bg-green-50 dark:hover:bg-green-950"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
)}
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
import React from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Search,
|
||||
Globe,
|
||||
|
@ -12,78 +12,99 @@ import {
|
|||
Webhook,
|
||||
MessageCircle,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { IconBrandNotion, IconBrandSlack, IconBrandYoutube, IconBrandGithub, IconLayoutKanban, IconLinkPlus, IconBrandDiscord } from "@tabler/icons-react";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Connector, ResearchMode } from './types';
|
||||
} from "lucide-react";
|
||||
import {
|
||||
IconBrandNotion,
|
||||
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
|
||||
export const getConnectorIcon = (connectorType: string) => {
|
||||
const iconProps = { className: "h-4 w-4" };
|
||||
|
||||
switch(connectorType) {
|
||||
case 'LINKUP_API':
|
||||
|
||||
switch (connectorType) {
|
||||
case "LINKUP_API":
|
||||
return <IconLinkPlus {...iconProps} />;
|
||||
case 'LINEAR_CONNECTOR':
|
||||
case "LINEAR_CONNECTOR":
|
||||
return <IconLayoutKanban {...iconProps} />;
|
||||
case 'GITHUB_CONNECTOR':
|
||||
case "GITHUB_CONNECTOR":
|
||||
return <IconBrandGithub {...iconProps} />;
|
||||
case 'YOUTUBE_VIDEO':
|
||||
case "YOUTUBE_VIDEO":
|
||||
return <IconBrandYoutube {...iconProps} />;
|
||||
case 'CRAWLED_URL':
|
||||
case "CRAWLED_URL":
|
||||
return <Globe {...iconProps} />;
|
||||
case 'FILE':
|
||||
return <File {...iconProps} />;
|
||||
case 'EXTENSION':
|
||||
return <Webhook {...iconProps} />;
|
||||
case 'SERPER_API':
|
||||
case 'TAVILY_API':
|
||||
case "FILE":
|
||||
return <File {...iconProps} />;
|
||||
case "EXTENSION":
|
||||
return <Webhook {...iconProps} />;
|
||||
case "SERPER_API":
|
||||
case "TAVILY_API":
|
||||
return <Link {...iconProps} />;
|
||||
case 'SLACK_CONNECTOR':
|
||||
case "SLACK_CONNECTOR":
|
||||
return <IconBrandSlack {...iconProps} />;
|
||||
case 'NOTION_CONNECTOR':
|
||||
case "NOTION_CONNECTOR":
|
||||
return <IconBrandNotion {...iconProps} />;
|
||||
case 'DISCORD_CONNECTOR':
|
||||
case "DISCORD_CONNECTOR":
|
||||
return <IconBrandDiscord {...iconProps} />;
|
||||
case 'DEEP':
|
||||
case "JIRA_CONNECTOR":
|
||||
return <IconTicket {...iconProps} />;
|
||||
case "DEEP":
|
||||
return <Sparkles {...iconProps} />;
|
||||
case 'DEEPER':
|
||||
case "DEEPER":
|
||||
return <Microscope {...iconProps} />;
|
||||
case 'DEEPEST':
|
||||
case "DEEPEST":
|
||||
return <Telescope {...iconProps} />;
|
||||
default:
|
||||
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',
|
||||
label: 'Q/A',
|
||||
icon: getConnectorIcon('GENERAL')
|
||||
value: "QNA",
|
||||
label: "Q/A",
|
||||
icon: getConnectorIcon("GENERAL"),
|
||||
},
|
||||
{
|
||||
value: 'REPORT_GENERAL',
|
||||
label: 'General',
|
||||
icon: getConnectorIcon('GENERAL')
|
||||
value: "REPORT_GENERAL",
|
||||
label: "General",
|
||||
icon: getConnectorIcon("GENERAL"),
|
||||
},
|
||||
{
|
||||
value: 'REPORT_DEEP',
|
||||
label: 'Deep',
|
||||
icon: getConnectorIcon('DEEP')
|
||||
value: "REPORT_DEEP",
|
||||
label: "Deep",
|
||||
icon: getConnectorIcon("DEEP"),
|
||||
},
|
||||
{
|
||||
value: 'REPORT_DEEPER',
|
||||
label: 'Deeper',
|
||||
icon: getConnectorIcon('DEEPER')
|
||||
value: "REPORT_DEEPER",
|
||||
label: "Deeper",
|
||||
icon: getConnectorIcon("DEEPER"),
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Displays a small icon for a connector type
|
||||
*/
|
||||
export const ConnectorIcon = ({ type, index = 0 }: { type: string; index?: number }) => (
|
||||
<div
|
||||
export const ConnectorIcon = ({
|
||||
type,
|
||||
index = 0,
|
||||
}: {
|
||||
type: string;
|
||||
index?: number;
|
||||
}) => (
|
||||
<div
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center bg-muted border border-background"
|
||||
style={{ zIndex: 10 - index }}
|
||||
>
|
||||
|
@ -109,24 +130,30 @@ type ConnectorButtonProps = {
|
|||
/**
|
||||
* 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 selectedCount = selectedConnectors.length;
|
||||
const progressPercentage = (selectedCount / totalConnectors) * 100;
|
||||
|
||||
|
||||
// Get the name of a single selected connector
|
||||
const getSingleConnectorName = () => {
|
||||
const connector = connectorSources.find(c => c.type === selectedConnectors[0]);
|
||||
return connector?.name || '';
|
||||
const connector = connectorSources.find(
|
||||
(c) => c.type === selectedConnectors[0],
|
||||
);
|
||||
return connector?.name || "";
|
||||
};
|
||||
|
||||
|
||||
// Get display text based on selection count
|
||||
const getDisplayText = () => {
|
||||
if (selectedCount === totalConnectors) return "All Connectors";
|
||||
if (selectedCount === 1) return getSingleConnectorName();
|
||||
return `${selectedCount} Connectors`;
|
||||
};
|
||||
|
||||
|
||||
// Render the empty state (no connectors selected)
|
||||
const renderEmptyState = () => (
|
||||
<>
|
||||
|
@ -134,7 +161,7 @@ export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources
|
|||
<span className="text-muted-foreground">Select Connectors</span>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
// Render the selected connectors preview
|
||||
const renderSelectedConnectors = () => (
|
||||
<>
|
||||
|
@ -143,32 +170,36 @@ export const ConnectorButton = ({ selectedConnectors, onClick, connectorSources
|
|||
{selectedConnectors.slice(0, 3).map((type, index) => (
|
||||
<ConnectorIcon key={type} type={type} index={index} />
|
||||
))}
|
||||
|
||||
|
||||
{/* Show count indicator if more than 3 connectors are selected */}
|
||||
{selectedCount > 3 && <ConnectorCountBadge count={selectedCount - 3} />}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Display text */}
|
||||
<span className="font-medium">{getDisplayText()}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 px-2 text-xs font-medium rounded-md border-border relative overflow-hidden group"
|
||||
onClick={onClick}
|
||||
aria-label={selectedCount === 0 ? "Select Connectors" : `${selectedCount} connectors selected`}
|
||||
aria-label={
|
||||
selectedCount === 0
|
||||
? "Select Connectors"
|
||||
: `${selectedCount} connectors selected`
|
||||
}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary"
|
||||
style={{
|
||||
<div
|
||||
className="absolute bottom-0 left-0 h-1 bg-primary"
|
||||
style={{
|
||||
width: `${progressPercentage}%`,
|
||||
transition: 'width 0.3s ease'
|
||||
}}
|
||||
transition: "width 0.3s ease",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex items-center gap-1.5 z-10 relative">
|
||||
{selectedCount === 0 ? renderEmptyState() : renderSelectedConnectors()}
|
||||
<ChevronDown className="h-3 w-3 ml-0.5 text-muted-foreground opacity-70" />
|
||||
|
@ -183,29 +214,32 @@ type ResearchModeControlProps = {
|
|||
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
|
||||
const isQnaMode = value === 'QNA';
|
||||
const isReportMode = value.startsWith('REPORT_');
|
||||
|
||||
const isQnaMode = value === "QNA";
|
||||
const isReportMode = value.startsWith("REPORT_");
|
||||
|
||||
// Get the current report sub-mode
|
||||
const getCurrentReportMode = () => {
|
||||
if (!isReportMode) return 'GENERAL';
|
||||
return value.replace('REPORT_', '') as 'GENERAL' | 'DEEP' | 'DEEPER';
|
||||
if (!isReportMode) return "GENERAL";
|
||||
return value.replace("REPORT_", "") as "GENERAL" | "DEEP" | "DEEPER";
|
||||
};
|
||||
|
||||
const reportSubOptions = [
|
||||
{ value: 'GENERAL', label: 'General', icon: getConnectorIcon('GENERAL') },
|
||||
{ value: 'DEEP', label: 'Deep', icon: getConnectorIcon('DEEP') },
|
||||
{ value: 'DEEPER', label: 'Deeper', icon: getConnectorIcon('DEEPER') },
|
||||
{ value: "GENERAL", label: "General", icon: getConnectorIcon("GENERAL") },
|
||||
{ value: "DEEP", label: "Deep", icon: getConnectorIcon("DEEP") },
|
||||
{ value: "DEEPER", label: "Deeper", icon: getConnectorIcon("DEEPER") },
|
||||
];
|
||||
|
||||
const handleModeToggle = (mode: 'QNA' | 'REPORT') => {
|
||||
if (mode === 'QNA') {
|
||||
onChange('QNA');
|
||||
const handleModeToggle = (mode: "QNA" | "REPORT") => {
|
||||
if (mode === "QNA") {
|
||||
onChange("QNA");
|
||||
} else {
|
||||
// Default to GENERAL for Report mode
|
||||
onChange('REPORT_GENERAL');
|
||||
onChange("REPORT_GENERAL");
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -219,11 +253,11 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
|
|||
<div className="flex h-8 rounded-md border border-border overflow-hidden">
|
||||
<button
|
||||
className={`flex h-full items-center gap-1 px-3 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
isQnaMode
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
isQnaMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleModeToggle('QNA')}
|
||||
onClick={() => handleModeToggle("QNA")}
|
||||
aria-pressed={isQnaMode}
|
||||
>
|
||||
<MessageCircle className="h-3 w-3" />
|
||||
|
@ -231,11 +265,11 @@ 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 ${
|
||||
isReportMode
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
isReportMode
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleModeToggle('REPORT')}
|
||||
onClick={() => handleModeToggle("REPORT")}
|
||||
aria-pressed={isReportMode}
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
|
@ -250,9 +284,9 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
|
|||
<button
|
||||
key={option.value}
|
||||
className={`flex h-full items-center gap-1 px-2 text-xs font-medium transition-colors whitespace-nowrap ${
|
||||
getCurrentReportMode() === option.value
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
getCurrentReportMode() === option.value
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
onClick={() => handleReportSubModeChange(option.value)}
|
||||
aria-pressed={getCurrentReportMode() === option.value}
|
||||
|
@ -265,4 +299,4 @@ export const ResearchModeControl = ({ value, onChange }: ResearchModeControlProp
|
|||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
// Helper function to get connector type display name
|
||||
export const getConnectorTypeDisplay = (type: string): string => {
|
||||
const typeMap: Record<string, string> = {
|
||||
"SERPER_API": "Serper API",
|
||||
"TAVILY_API": "Tavily API",
|
||||
"SLACK_CONNECTOR": "Slack",
|
||||
"NOTION_CONNECTOR": "Notion",
|
||||
"GITHUB_CONNECTOR": "GitHub",
|
||||
"LINEAR_CONNECTOR": "Linear",
|
||||
"DISCORD_CONNECTOR": "Discord",
|
||||
"LINKUP_API": "Linkup",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
const typeMap: Record<string, string> = {
|
||||
SERPER_API: "Serper API",
|
||||
TAVILY_API: "Tavily API",
|
||||
SLACK_CONNECTOR: "Slack",
|
||||
NOTION_CONNECTOR: "Notion",
|
||||
GITHUB_CONNECTOR: "GitHub",
|
||||
LINEAR_CONNECTOR: "Linear",
|
||||
JIRA_CONNECTOR: "Jira",
|
||||
DISCORD_CONNECTOR: "Discord",
|
||||
LINKUP_API: "Linkup",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
|
Loading…
Add table
Reference in a new issue