mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-29 02:46:25 +02:00
Merge upstream/dev - preserve local UI enhancements (Logs in menu, conditional search, hover edit buttons)
This commit is contained in:
commit
089beb8d8c
117 changed files with 12068 additions and 4857 deletions
|
|
@ -85,6 +85,11 @@ TEAMS_CLIENT_ID=your_teams_client_id_here
|
||||||
TEAMS_CLIENT_SECRET=your_teams_client_secret_here
|
TEAMS_CLIENT_SECRET=your_teams_client_secret_here
|
||||||
TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback
|
TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback
|
||||||
|
|
||||||
|
#Composio Coonnector
|
||||||
|
COMPOSIO_API_KEY=your_api_key_here
|
||||||
|
COMPOSIO_ENABLED=TRUE
|
||||||
|
COMPOSIO_REDIRECT_URI=http://localhost:8000/api/v1/auth/composio/connector/callback
|
||||||
|
|
||||||
# Embedding Model
|
# Embedding Model
|
||||||
# Examples:
|
# Examples:
|
||||||
# # Get sentence transformers embeddings
|
# # Get sentence transformers embeddings
|
||||||
|
|
|
||||||
135
surfsense_backend/alembic/versions/73_add_user_memories_table.py
Normal file
135
surfsense_backend/alembic/versions/73_add_user_memories_table.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
"""Add user_memories table for AI memory feature
|
||||||
|
|
||||||
|
Revision ID: 73
|
||||||
|
Revises: 72
|
||||||
|
Create Date: 2026-01-20
|
||||||
|
|
||||||
|
This migration adds the user_memories table which enables Claude-like memory
|
||||||
|
functionality - allowing the AI to remember facts, preferences, and context
|
||||||
|
about users across conversations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "73"
|
||||||
|
down_revision: str | None = "72"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# Get embedding dimension from config
|
||||||
|
EMBEDDING_DIM = config.embedding_model_instance.dimension
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create user_memories table and MemoryCategory enum."""
|
||||||
|
|
||||||
|
# Create the MemoryCategory enum type
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'memorycategory') THEN
|
||||||
|
CREATE TYPE memorycategory AS ENUM (
|
||||||
|
'preference',
|
||||||
|
'fact',
|
||||||
|
'instruction',
|
||||||
|
'context'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create user_memories table
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_name = 'user_memories'
|
||||||
|
) THEN
|
||||||
|
CREATE TABLE user_memories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE,
|
||||||
|
memory_text TEXT NOT NULL,
|
||||||
|
category memorycategory NOT NULL DEFAULT 'fact',
|
||||||
|
embedding vector({EMBEDDING_DIM}),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create indexes for efficient querying
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Index on user_id for filtering memories by user
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'user_memories' AND indexname = 'ix_user_memories_user_id'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_user_memories_user_id ON user_memories(user_id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Index on search_space_id for filtering memories by search space
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'user_memories' AND indexname = 'ix_user_memories_search_space_id'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_user_memories_search_space_id ON user_memories(search_space_id);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Index on updated_at for ordering by recency
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'user_memories' AND indexname = 'ix_user_memories_updated_at'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_user_memories_updated_at ON user_memories(updated_at);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Index on category for filtering by memory type
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'user_memories' AND indexname = 'ix_user_memories_category'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_user_memories_category ON user_memories(category);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Composite index for common query pattern (user + search space)
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'user_memories' AND indexname = 'ix_user_memories_user_search_space'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_user_memories_user_search_space ON user_memories(user_id, search_space_id);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create vector index for semantic search
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS user_memories_vector_index
|
||||||
|
ON user_memories USING hnsw (embedding public.vector_cosine_ops);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop user_memories table and MemoryCategory enum."""
|
||||||
|
|
||||||
|
# Drop the table
|
||||||
|
op.execute("DROP TABLE IF EXISTS user_memories CASCADE;")
|
||||||
|
|
||||||
|
# Drop the enum type
|
||||||
|
op.execute("DROP TYPE IF EXISTS memorycategory;")
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Add COMPOSIO_CONNECTOR to SearchSourceConnectorType and DocumentType enums
|
||||||
|
|
||||||
|
Revision ID: 74
|
||||||
|
Revises: 73
|
||||||
|
Create Date: 2026-01-21
|
||||||
|
|
||||||
|
This migration adds the COMPOSIO_CONNECTOR enum value to both:
|
||||||
|
- searchsourceconnectortype (for connector type tracking)
|
||||||
|
- documenttype (for document type tracking)
|
||||||
|
|
||||||
|
Composio is a managed OAuth integration service that allows connecting
|
||||||
|
to various third-party services (Google Drive, Gmail, Calendar, etc.)
|
||||||
|
without requiring separate OAuth app verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "74"
|
||||||
|
down_revision: str | None = "73"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
# Define the ENUM type names and the new value
|
||||||
|
CONNECTOR_ENUM = "searchsourceconnectortype"
|
||||||
|
CONNECTOR_NEW_VALUE = "COMPOSIO_CONNECTOR"
|
||||||
|
DOCUMENT_ENUM = "documenttype"
|
||||||
|
DOCUMENT_NEW_VALUE = "COMPOSIO_CONNECTOR"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema - add COMPOSIO_CONNECTOR to connector and document enums safely."""
|
||||||
|
# Add COMPOSIO_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 COMPOSIO_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:
|
||||||
|
"""Downgrade schema - remove COMPOSIO_CONNECTOR from connector and document enums.
|
||||||
|
|
||||||
|
Note: PostgreSQL does not support removing enum values directly.
|
||||||
|
To properly downgrade, you would need to:
|
||||||
|
1. Delete any rows using the COMPOSIO_CONNECTOR value
|
||||||
|
2. Create new enums without COMPOSIO_CONNECTOR
|
||||||
|
3. Alter the columns to use the new enums
|
||||||
|
4. Drop the old enums
|
||||||
|
|
||||||
|
This is left as a no-op since removing enum values is complex
|
||||||
|
and typically not needed in practice.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Add chat_session_state table for live collaboration
|
||||||
|
|
||||||
|
Revision ID: 75
|
||||||
|
Revises: 74
|
||||||
|
|
||||||
|
Creates chat_session_state table to track AI responding state per thread.
|
||||||
|
Enables real-time sync via Electric SQL for shared chat collaboration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "75"
|
||||||
|
down_revision: str | None = "74"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create chat_session_state table with Electric SQL replication."""
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_session_state (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
thread_id INTEGER NOT NULL REFERENCES new_chat_threads(id) ON DELETE CASCADE,
|
||||||
|
ai_responding_to_user_id UUID REFERENCES "user"(id) ON DELETE SET NULL,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (thread_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chat_session_state_thread_id ON chat_session_state(thread_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("ALTER TABLE chat_session_state REPLICA IDENTITY FULL;")
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'electric_publication_default'
|
||||||
|
AND tablename = 'chat_session_state'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION electric_publication_default ADD TABLE chat_session_state;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop chat_session_state table and remove from Electric SQL replication."""
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'electric_publication_default'
|
||||||
|
AND tablename = 'chat_session_state'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION electric_publication_default DROP TABLE chat_session_state;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute("DROP TABLE IF EXISTS chat_session_state;")
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
"""Add live collaboration tables to Electric SQL publication
|
||||||
|
|
||||||
|
Revision ID: 76
|
||||||
|
Revises: 75
|
||||||
|
|
||||||
|
Enables real-time sync for live collaboration features:
|
||||||
|
- new_chat_messages: Live message sync between users
|
||||||
|
- chat_comments: Live comment updates
|
||||||
|
|
||||||
|
Note: User/member info is fetched via API (membersAtom) for client-side joins,
|
||||||
|
not via Electric SQL, to keep where clauses optimized and reduce complexity.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "76"
|
||||||
|
down_revision: str | None = "75"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add live collaboration tables to Electric SQL replication."""
|
||||||
|
# Set REPLICA IDENTITY FULL for Electric SQL sync
|
||||||
|
op.execute("ALTER TABLE new_chat_messages REPLICA IDENTITY FULL;")
|
||||||
|
op.execute("ALTER TABLE chat_comments REPLICA IDENTITY FULL;")
|
||||||
|
|
||||||
|
# Add new_chat_messages to Electric publication
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'electric_publication_default'
|
||||||
|
AND tablename = 'new_chat_messages'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION electric_publication_default ADD TABLE new_chat_messages;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add chat_comments to Electric publication
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'electric_publication_default'
|
||||||
|
AND tablename = 'chat_comments'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION electric_publication_default ADD TABLE chat_comments;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove live collaboration tables from Electric SQL replication."""
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'electric_publication_default'
|
||||||
|
AND tablename = 'new_chat_messages'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION electric_publication_default DROP TABLE new_chat_messages;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM pg_publication_tables
|
||||||
|
WHERE pubname = 'electric_publication_default'
|
||||||
|
AND tablename = 'chat_comments'
|
||||||
|
) THEN
|
||||||
|
ALTER PUBLICATION electric_publication_default DROP TABLE chat_comments;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: Not reverting REPLICA IDENTITY as it doesn't harm normal operations
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Add thread_id to chat_comments for denormalized Electric subscriptions
|
||||||
|
|
||||||
|
This denormalization allows a single Electric SQL subscription per thread
|
||||||
|
instead of one per message, significantly reducing connection overhead.
|
||||||
|
|
||||||
|
Revision ID: 77
|
||||||
|
Revises: 76
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "77"
|
||||||
|
down_revision: str | None = "76"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add thread_id column to chat_comments and backfill from messages."""
|
||||||
|
# Add the column (nullable initially for backfill)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE chat_comments
|
||||||
|
ADD COLUMN IF NOT EXISTS thread_id INTEGER;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill thread_id from the related message
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE chat_comments c
|
||||||
|
SET thread_id = m.thread_id
|
||||||
|
FROM new_chat_messages m
|
||||||
|
WHERE c.message_id = m.id
|
||||||
|
AND c.thread_id IS NULL;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Make it NOT NULL after backfill
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE chat_comments
|
||||||
|
ALTER COLUMN thread_id SET NOT NULL;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add FK constraint
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE chat_comments
|
||||||
|
ADD CONSTRAINT fk_chat_comments_thread_id
|
||||||
|
FOREIGN KEY (thread_id) REFERENCES new_chat_threads(id) ON DELETE CASCADE;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add index for efficient Electric subscriptions by thread
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chat_comments_thread_id ON chat_comments(thread_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove thread_id column from chat_comments."""
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_chat_comments_thread_id")
|
||||||
|
op.execute("ALTER TABLE chat_comments DROP CONSTRAINT IF EXISTS fk_chat_comments_thread_id")
|
||||||
|
op.execute("ALTER TABLE chat_comments DROP COLUMN IF EXISTS thread_id")
|
||||||
|
|
@ -34,6 +34,7 @@ async def create_surfsense_deep_agent(
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
connector_service: ConnectorService,
|
connector_service: ConnectorService,
|
||||||
checkpointer: Checkpointer,
|
checkpointer: Checkpointer,
|
||||||
|
user_id: str | None = None,
|
||||||
agent_config: AgentConfig | None = None,
|
agent_config: AgentConfig | None = None,
|
||||||
enabled_tools: list[str] | None = None,
|
enabled_tools: list[str] | None = None,
|
||||||
disabled_tools: list[str] | None = None,
|
disabled_tools: list[str] | None = None,
|
||||||
|
|
@ -49,6 +50,8 @@ async def create_surfsense_deep_agent(
|
||||||
- link_preview: Fetch rich previews for URLs
|
- link_preview: Fetch rich previews for URLs
|
||||||
- display_image: Display images in chat
|
- display_image: Display images in chat
|
||||||
- scrape_webpage: Extract content from webpages
|
- scrape_webpage: Extract content from webpages
|
||||||
|
- save_memory: Store facts/preferences about the user
|
||||||
|
- recall_memory: Retrieve relevant user memories
|
||||||
|
|
||||||
The agent also includes TodoListMiddleware by default (via create_deep_agent) which provides:
|
The agent also includes TodoListMiddleware by default (via create_deep_agent) which provides:
|
||||||
- write_todos: Create and update planning/todo lists for complex tasks
|
- write_todos: Create and update planning/todo lists for complex tasks
|
||||||
|
|
@ -64,6 +67,7 @@ async def create_surfsense_deep_agent(
|
||||||
connector_service: Initialized connector service for knowledge base search
|
connector_service: Initialized connector service for knowledge base search
|
||||||
checkpointer: LangGraph checkpointer for conversation state persistence.
|
checkpointer: LangGraph checkpointer for conversation state persistence.
|
||||||
Use AsyncPostgresSaver for production or MemorySaver for testing.
|
Use AsyncPostgresSaver for production or MemorySaver for testing.
|
||||||
|
user_id: The current user's UUID string (required for memory tools)
|
||||||
agent_config: Optional AgentConfig from NewLLMConfig for prompt configuration.
|
agent_config: Optional AgentConfig from NewLLMConfig for prompt configuration.
|
||||||
If None, uses default system prompt with citations enabled.
|
If None, uses default system prompt with citations enabled.
|
||||||
enabled_tools: Explicit list of tool names to enable. If None, all default tools
|
enabled_tools: Explicit list of tool names to enable. If None, all default tools
|
||||||
|
|
@ -118,6 +122,7 @@ async def create_surfsense_deep_agent(
|
||||||
"db_session": db_session,
|
"db_session": db_session,
|
||||||
"connector_service": connector_service,
|
"connector_service": connector_service,
|
||||||
"firecrawl_api_key": firecrawl_api_key,
|
"firecrawl_api_key": firecrawl_api_key,
|
||||||
|
"user_id": user_id, # Required for memory tools
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build tools using the async registry (includes MCP tools)
|
# Build tools using the async registry (includes MCP tools)
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,45 @@ You have access to the following tools:
|
||||||
* This makes your response more visual and engaging.
|
* This makes your response more visual and engaging.
|
||||||
* Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content.
|
* Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content.
|
||||||
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
||||||
|
|
||||||
|
6. save_memory: Save facts, preferences, or context about the user for personalized responses.
|
||||||
|
- Use this when the user explicitly or implicitly shares information worth remembering.
|
||||||
|
- Trigger scenarios:
|
||||||
|
* User says "remember this", "keep this in mind", "note that", or similar
|
||||||
|
* User shares personal preferences (e.g., "I prefer Python over JavaScript")
|
||||||
|
* User shares facts about themselves (e.g., "I'm a senior developer at Company X")
|
||||||
|
* User gives standing instructions (e.g., "always respond in bullet points")
|
||||||
|
* User shares project context (e.g., "I'm working on migrating our codebase to TypeScript")
|
||||||
|
- Args:
|
||||||
|
- content: The fact/preference to remember. Phrase it clearly:
|
||||||
|
* "User prefers dark mode for all interfaces"
|
||||||
|
* "User is a senior Python developer"
|
||||||
|
* "User wants responses in bullet point format"
|
||||||
|
* "User is working on project called ProjectX"
|
||||||
|
- category: Type of memory:
|
||||||
|
* "preference": User preferences (coding style, tools, formats)
|
||||||
|
* "fact": Facts about the user (role, expertise, background)
|
||||||
|
* "instruction": Standing instructions (response format, communication style)
|
||||||
|
* "context": Current context (ongoing projects, goals, challenges)
|
||||||
|
- Returns: Confirmation of saved memory
|
||||||
|
- IMPORTANT: Only save information that would be genuinely useful for future conversations.
|
||||||
|
Don't save trivial or temporary information.
|
||||||
|
|
||||||
|
7. recall_memory: Retrieve relevant memories about the user for personalized responses.
|
||||||
|
- Use this to access stored information about the user.
|
||||||
|
- Trigger scenarios:
|
||||||
|
* You need user context to give a better, more personalized answer
|
||||||
|
* User references something they mentioned before
|
||||||
|
* User asks "what do you know about me?" or similar
|
||||||
|
* Personalization would significantly improve response quality
|
||||||
|
* Before making recommendations that should consider user preferences
|
||||||
|
- Args:
|
||||||
|
- query: Optional search query to find specific memories (e.g., "programming preferences")
|
||||||
|
- category: Optional filter by category ("preference", "fact", "instruction", "context")
|
||||||
|
- top_k: Number of memories to retrieve (default: 5)
|
||||||
|
- Returns: Relevant memories formatted as context
|
||||||
|
- IMPORTANT: Use the recalled memories naturally in your response without explicitly
|
||||||
|
stating "Based on your memory..." - integrate the context seamlessly.
|
||||||
</tools>
|
</tools>
|
||||||
<tool_call_examples>
|
<tool_call_examples>
|
||||||
- User: "How do I install SurfSense?"
|
- User: "How do I install SurfSense?"
|
||||||
|
|
@ -136,6 +175,23 @@ You have access to the following tools:
|
||||||
- User: "What did I discuss on Slack last week about the React migration?"
|
- User: "What did I discuss on Slack last week about the React migration?"
|
||||||
- Call: `search_knowledge_base(query="React migration", connectors_to_search=["SLACK_CONNECTOR"], start_date="YYYY-MM-DD", end_date="YYYY-MM-DD")`
|
- Call: `search_knowledge_base(query="React migration", connectors_to_search=["SLACK_CONNECTOR"], start_date="YYYY-MM-DD", end_date="YYYY-MM-DD")`
|
||||||
|
|
||||||
|
- User: "Remember that I prefer TypeScript over JavaScript"
|
||||||
|
- Call: `save_memory(content="User prefers TypeScript over JavaScript for development", category="preference")`
|
||||||
|
|
||||||
|
- User: "I'm a data scientist working on ML pipelines"
|
||||||
|
- Call: `save_memory(content="User is a data scientist working on ML pipelines", category="fact")`
|
||||||
|
|
||||||
|
- User: "Always give me code examples in Python"
|
||||||
|
- Call: `save_memory(content="User wants code examples to be written in Python", category="instruction")`
|
||||||
|
|
||||||
|
- User: "What programming language should I use for this project?"
|
||||||
|
- First recall: `recall_memory(query="programming language preferences")`
|
||||||
|
- Then provide a personalized recommendation based on their preferences
|
||||||
|
|
||||||
|
- User: "What do you know about me?"
|
||||||
|
- Call: `recall_memory(top_k=10)`
|
||||||
|
- Then summarize the stored memories
|
||||||
|
|
||||||
- User: "Give me a podcast about AI trends based on what we discussed"
|
- User: "Give me a podcast about AI trends based on what we discussed"
|
||||||
- First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
|
- First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ Available tools:
|
||||||
- link_preview: Fetch rich previews for URLs
|
- link_preview: Fetch rich previews for URLs
|
||||||
- display_image: Display images in chat
|
- display_image: Display images in chat
|
||||||
- scrape_webpage: Extract content from webpages
|
- scrape_webpage: Extract content from webpages
|
||||||
|
- save_memory: Store facts/preferences about the user
|
||||||
|
- recall_memory: Retrieve relevant user memories
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Registry exports
|
# Registry exports
|
||||||
|
|
@ -33,6 +35,7 @@ from .registry import (
|
||||||
)
|
)
|
||||||
from .scrape_webpage import create_scrape_webpage_tool
|
from .scrape_webpage import create_scrape_webpage_tool
|
||||||
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
||||||
|
from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Registry
|
# Registry
|
||||||
|
|
@ -43,6 +46,8 @@ __all__ = [
|
||||||
"create_display_image_tool",
|
"create_display_image_tool",
|
||||||
"create_generate_podcast_tool",
|
"create_generate_podcast_tool",
|
||||||
"create_link_preview_tool",
|
"create_link_preview_tool",
|
||||||
|
"create_recall_memory_tool",
|
||||||
|
"create_save_memory_tool",
|
||||||
"create_scrape_webpage_tool",
|
"create_scrape_webpage_tool",
|
||||||
"create_search_knowledge_base_tool",
|
"create_search_knowledge_base_tool",
|
||||||
"create_search_surfsense_docs_tool",
|
"create_search_surfsense_docs_tool",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ from .mcp_tool import load_mcp_tools
|
||||||
from .podcast import create_generate_podcast_tool
|
from .podcast import create_generate_podcast_tool
|
||||||
from .scrape_webpage import create_scrape_webpage_tool
|
from .scrape_webpage import create_scrape_webpage_tool
|
||||||
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
||||||
|
from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Tool Definition
|
# Tool Definition
|
||||||
|
|
@ -138,6 +139,31 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||||
requires=["db_session"],
|
requires=["db_session"],
|
||||||
),
|
),
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
# USER MEMORY TOOLS - Claude-like memory feature
|
||||||
|
# =========================================================================
|
||||||
|
# Save memory tool - stores facts/preferences about the user
|
||||||
|
ToolDefinition(
|
||||||
|
name="save_memory",
|
||||||
|
description="Save facts, preferences, or context about the user for personalized responses",
|
||||||
|
factory=lambda deps: create_save_memory_tool(
|
||||||
|
user_id=deps["user_id"],
|
||||||
|
search_space_id=deps["search_space_id"],
|
||||||
|
db_session=deps["db_session"],
|
||||||
|
),
|
||||||
|
requires=["user_id", "search_space_id", "db_session"],
|
||||||
|
),
|
||||||
|
# Recall memory tool - retrieves relevant user memories
|
||||||
|
ToolDefinition(
|
||||||
|
name="recall_memory",
|
||||||
|
description="Recall user memories for personalized and contextual responses",
|
||||||
|
factory=lambda deps: create_recall_memory_tool(
|
||||||
|
user_id=deps["user_id"],
|
||||||
|
search_space_id=deps["search_space_id"],
|
||||||
|
db_session=deps["db_session"],
|
||||||
|
),
|
||||||
|
requires=["user_id", "search_space_id", "db_session"],
|
||||||
|
),
|
||||||
|
# =========================================================================
|
||||||
# ADD YOUR CUSTOM TOOLS BELOW
|
# ADD YOUR CUSTOM TOOLS BELOW
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Example:
|
# Example:
|
||||||
|
|
|
||||||
352
surfsense_backend/app/agents/new_chat/tools/user_memory.py
Normal file
352
surfsense_backend/app/agents/new_chat/tools/user_memory.py
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
"""
|
||||||
|
User memory tools for the SurfSense agent.
|
||||||
|
|
||||||
|
This module provides tools for storing and retrieving user memories,
|
||||||
|
enabling personalized AI responses similar to Claude's memory feature.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- save_memory: Store facts, preferences, and context about the user
|
||||||
|
- recall_memory: Retrieve relevant memories using semantic search
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from langchain_core.tools import tool
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.db import MemoryCategory, UserMemory
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Constants
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Default number of memories to retrieve
|
||||||
|
DEFAULT_RECALL_TOP_K = 5
|
||||||
|
|
||||||
|
# Maximum number of memories per user (to prevent unbounded growth)
|
||||||
|
MAX_MEMORIES_PER_USER = 100
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _to_uuid(user_id: str) -> UUID:
|
||||||
|
"""Convert a string user_id to a UUID object."""
|
||||||
|
if isinstance(user_id, UUID):
|
||||||
|
return user_id
|
||||||
|
return UUID(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_memory_count(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
) -> int:
|
||||||
|
"""Get the count of memories for a user."""
|
||||||
|
uuid_user_id = _to_uuid(user_id)
|
||||||
|
query = select(UserMemory).where(UserMemory.user_id == uuid_user_id)
|
||||||
|
if search_space_id is not None:
|
||||||
|
query = query.where(
|
||||||
|
(UserMemory.search_space_id == search_space_id)
|
||||||
|
| (UserMemory.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
result = await db_session.execute(query)
|
||||||
|
return len(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_oldest_memory(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
user_id: str,
|
||||||
|
search_space_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Delete the oldest memory for a user to make room for new ones."""
|
||||||
|
uuid_user_id = _to_uuid(user_id)
|
||||||
|
query = (
|
||||||
|
select(UserMemory)
|
||||||
|
.where(UserMemory.user_id == uuid_user_id)
|
||||||
|
.order_by(UserMemory.updated_at.asc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if search_space_id is not None:
|
||||||
|
query = query.where(
|
||||||
|
(UserMemory.search_space_id == search_space_id)
|
||||||
|
| (UserMemory.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
result = await db_session.execute(query)
|
||||||
|
oldest_memory = result.scalars().first()
|
||||||
|
if oldest_memory:
|
||||||
|
await db_session.delete(oldest_memory)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def format_memories_for_context(memories: list[dict[str, Any]]) -> str:
|
||||||
|
"""Format retrieved memories into a readable context string for the LLM."""
|
||||||
|
if not memories:
|
||||||
|
return "No relevant memories found for this user."
|
||||||
|
|
||||||
|
parts = ["<user_memories>"]
|
||||||
|
for memory in memories:
|
||||||
|
category = memory.get("category", "unknown")
|
||||||
|
text = memory.get("memory_text", "")
|
||||||
|
updated = memory.get("updated_at", "")
|
||||||
|
parts.append(
|
||||||
|
f" <memory category='{category}' updated='{updated}'>{text}</memory>"
|
||||||
|
)
|
||||||
|
parts.append("</user_memories>")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Tool Factory Functions
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def create_save_memory_tool(
|
||||||
|
user_id: str,
|
||||||
|
search_space_id: int,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the save_memory tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's UUID
|
||||||
|
search_space_id: The search space ID (for space-specific memories)
|
||||||
|
db_session: Database session for executing queries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured tool function for saving user memories
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def save_memory(
|
||||||
|
content: str,
|
||||||
|
category: str = "fact",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Save a fact, preference, or context about the user for future reference.
|
||||||
|
|
||||||
|
Use this tool when:
|
||||||
|
- User explicitly says "remember this", "keep this in mind", or similar
|
||||||
|
- User shares personal preferences (e.g., "I prefer Python over JavaScript")
|
||||||
|
- User shares important facts about themselves (name, role, interests, projects)
|
||||||
|
- User gives standing instructions (e.g., "always respond in bullet points")
|
||||||
|
- User shares relevant context (e.g., "I'm working on project X")
|
||||||
|
|
||||||
|
The saved information will be available in future conversations to provide
|
||||||
|
more personalized and contextual responses.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The fact/preference/context to remember.
|
||||||
|
Phrase it clearly, e.g., "User prefers dark mode",
|
||||||
|
"User is a senior Python developer", "User is working on an AI project"
|
||||||
|
category: Type of memory. One of:
|
||||||
|
- "preference": User preferences (e.g., coding style, tools, formats)
|
||||||
|
- "fact": Facts about the user (e.g., name, role, expertise)
|
||||||
|
- "instruction": Standing instructions (e.g., response format preferences)
|
||||||
|
- "context": Current context (e.g., ongoing projects, goals)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary with the save status and memory details
|
||||||
|
"""
|
||||||
|
# Normalize and validate category (LLMs may send uppercase)
|
||||||
|
category = category.lower() if category else "fact"
|
||||||
|
valid_categories = ["preference", "fact", "instruction", "context"]
|
||||||
|
if category not in valid_categories:
|
||||||
|
category = "fact"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert user_id to UUID
|
||||||
|
uuid_user_id = _to_uuid(user_id)
|
||||||
|
|
||||||
|
# Check if we've hit the memory limit
|
||||||
|
memory_count = await get_user_memory_count(
|
||||||
|
db_session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
if memory_count >= MAX_MEMORIES_PER_USER:
|
||||||
|
# Delete oldest memory to make room
|
||||||
|
await delete_oldest_memory(db_session, user_id, search_space_id)
|
||||||
|
|
||||||
|
# Generate embedding for the memory
|
||||||
|
embedding = config.embedding_model_instance.embed(content)
|
||||||
|
|
||||||
|
# Create new memory using ORM
|
||||||
|
# The pgvector Vector column type handles embedding conversion automatically
|
||||||
|
new_memory = UserMemory(
|
||||||
|
user_id=uuid_user_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
memory_text=content,
|
||||||
|
category=MemoryCategory(category), # Convert string to enum
|
||||||
|
embedding=embedding, # Pass embedding directly (list or numpy array)
|
||||||
|
)
|
||||||
|
|
||||||
|
db_session.add(new_memory)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(new_memory)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "saved",
|
||||||
|
"memory_id": new_memory.id,
|
||||||
|
"memory_text": content,
|
||||||
|
"category": category,
|
||||||
|
"message": f"I'll remember: {content}",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to save memory for user {user_id}: {e}")
|
||||||
|
# Rollback the session to clear any failed transaction state
|
||||||
|
await db_session.rollback()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to save memory. Please try again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
return save_memory
|
||||||
|
|
||||||
|
|
||||||
|
def create_recall_memory_tool(
|
||||||
|
user_id: str,
|
||||||
|
search_space_id: int,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the recall_memory tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's UUID
|
||||||
|
search_space_id: The search space ID
|
||||||
|
db_session: Database session for executing queries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured tool function for recalling user memories
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def recall_memory(
|
||||||
|
query: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
top_k: int = DEFAULT_RECALL_TOP_K,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Recall relevant memories about the user to provide personalized responses.
|
||||||
|
|
||||||
|
Use this tool when:
|
||||||
|
- You need user context to give a better, more personalized answer
|
||||||
|
- User asks about their preferences or past information they shared
|
||||||
|
- User references something they told you before
|
||||||
|
- Personalization would significantly improve the response quality
|
||||||
|
- User asks "what do you know about me?" or similar
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Optional search query to find specific memories.
|
||||||
|
If not provided, returns the most recent memories.
|
||||||
|
Example: "programming preferences", "current projects"
|
||||||
|
category: Optional category filter. One of:
|
||||||
|
"preference", "fact", "instruction", "context"
|
||||||
|
If not provided, searches all categories.
|
||||||
|
top_k: Number of memories to retrieve (default: 5, max: 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary containing relevant memories and formatted context
|
||||||
|
"""
|
||||||
|
top_k = min(max(top_k, 1), 20) # Clamp between 1 and 20
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Convert user_id to UUID
|
||||||
|
uuid_user_id = _to_uuid(user_id)
|
||||||
|
|
||||||
|
if query:
|
||||||
|
# Semantic search using embeddings
|
||||||
|
query_embedding = config.embedding_model_instance.embed(query)
|
||||||
|
|
||||||
|
# Build query with vector similarity
|
||||||
|
stmt = (
|
||||||
|
select(UserMemory)
|
||||||
|
.where(UserMemory.user_id == uuid_user_id)
|
||||||
|
.where(
|
||||||
|
(UserMemory.search_space_id == search_space_id)
|
||||||
|
| (UserMemory.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add category filter if specified
|
||||||
|
if category and category in [
|
||||||
|
"preference",
|
||||||
|
"fact",
|
||||||
|
"instruction",
|
||||||
|
"context",
|
||||||
|
]:
|
||||||
|
stmt = stmt.where(UserMemory.category == MemoryCategory(category))
|
||||||
|
|
||||||
|
# Order by vector similarity
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
UserMemory.embedding.op("<=>")(query_embedding)
|
||||||
|
).limit(top_k)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# No query - return most recent memories
|
||||||
|
stmt = (
|
||||||
|
select(UserMemory)
|
||||||
|
.where(UserMemory.user_id == uuid_user_id)
|
||||||
|
.where(
|
||||||
|
(UserMemory.search_space_id == search_space_id)
|
||||||
|
| (UserMemory.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add category filter if specified
|
||||||
|
if category and category in [
|
||||||
|
"preference",
|
||||||
|
"fact",
|
||||||
|
"instruction",
|
||||||
|
"context",
|
||||||
|
]:
|
||||||
|
stmt = stmt.where(UserMemory.category == MemoryCategory(category))
|
||||||
|
|
||||||
|
stmt = stmt.order_by(UserMemory.updated_at.desc()).limit(top_k)
|
||||||
|
|
||||||
|
result = await db_session.execute(stmt)
|
||||||
|
memories = result.scalars().all()
|
||||||
|
|
||||||
|
# Format memories for response
|
||||||
|
memory_list = [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"memory_text": m.memory_text,
|
||||||
|
"category": m.category.value if m.category else "unknown",
|
||||||
|
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
|
||||||
|
}
|
||||||
|
for m in memories
|
||||||
|
]
|
||||||
|
|
||||||
|
formatted_context = format_memories_for_context(memory_list)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"count": len(memory_list),
|
||||||
|
"memories": memory_list,
|
||||||
|
"formatted_context": formatted_context,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Failed to recall memories for user {user_id}: {e}")
|
||||||
|
await db_session.rollback()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"memories": [],
|
||||||
|
"formatted_context": "Failed to recall memories.",
|
||||||
|
}
|
||||||
|
|
||||||
|
return recall_memory
|
||||||
|
|
@ -127,6 +127,12 @@ class Config:
|
||||||
CLICKUP_CLIENT_SECRET = os.getenv("CLICKUP_CLIENT_SECRET")
|
CLICKUP_CLIENT_SECRET = os.getenv("CLICKUP_CLIENT_SECRET")
|
||||||
CLICKUP_REDIRECT_URI = os.getenv("CLICKUP_REDIRECT_URI")
|
CLICKUP_REDIRECT_URI = os.getenv("CLICKUP_REDIRECT_URI")
|
||||||
|
|
||||||
|
# Composio Configuration (for managed OAuth integrations)
|
||||||
|
# Get your API key from https://app.composio.dev
|
||||||
|
COMPOSIO_API_KEY = os.getenv("COMPOSIO_API_KEY")
|
||||||
|
COMPOSIO_ENABLED = os.getenv("COMPOSIO_ENABLED", "FALSE").upper() == "TRUE"
|
||||||
|
COMPOSIO_REDIRECT_URI = os.getenv("COMPOSIO_REDIRECT_URI")
|
||||||
|
|
||||||
# LLM instances are now managed per-user through the LLMConfig system
|
# LLM instances are now managed per-user through the LLMConfig system
|
||||||
# Legacy environment variables removed in favor of user-specific configurations
|
# Legacy environment variables removed in favor of user-specific configurations
|
||||||
|
|
||||||
|
|
|
||||||
388
surfsense_backend/app/connectors/composio_connector.py
Normal file
388
surfsense_backend/app/connectors/composio_connector.py
Normal file
|
|
@ -0,0 +1,388 @@
|
||||||
|
"""
|
||||||
|
Composio Connector Module.
|
||||||
|
|
||||||
|
Provides a unified interface for interacting with various services via Composio,
|
||||||
|
primarily used during indexing operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.db import SearchSourceConnector
|
||||||
|
from app.services.composio_service import ComposioService, INDEXABLE_TOOLKITS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ComposioConnector:
|
||||||
|
"""
|
||||||
|
Generic Composio connector for data retrieval.
|
||||||
|
|
||||||
|
Wraps the ComposioService to provide toolkit-specific data access
|
||||||
|
for indexing operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
connector_id: int,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize the Composio connector.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session for updating connector.
|
||||||
|
connector_id: ID of the SearchSourceConnector.
|
||||||
|
"""
|
||||||
|
self._session = session
|
||||||
|
self._connector_id = connector_id
|
||||||
|
self._service: ComposioService | None = None
|
||||||
|
self._connector: SearchSourceConnector | None = None
|
||||||
|
self._config: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
async def _load_connector(self) -> SearchSourceConnector:
|
||||||
|
"""Load connector from database."""
|
||||||
|
if self._connector is None:
|
||||||
|
result = await self._session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == self._connector_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self._connector = result.scalars().first()
|
||||||
|
if not self._connector:
|
||||||
|
raise ValueError(f"Connector {self._connector_id} not found")
|
||||||
|
self._config = self._connector.config or {}
|
||||||
|
return self._connector
|
||||||
|
|
||||||
|
async def _get_service(self) -> ComposioService:
|
||||||
|
"""Get or create the Composio service instance."""
|
||||||
|
if self._service is None:
|
||||||
|
self._service = ComposioService()
|
||||||
|
return self._service
|
||||||
|
|
||||||
|
async def get_config(self) -> dict[str, Any]:
|
||||||
|
"""Get the connector configuration."""
|
||||||
|
await self._load_connector()
|
||||||
|
return self._config or {}
|
||||||
|
|
||||||
|
async def get_toolkit_id(self) -> str:
|
||||||
|
"""Get the toolkit ID for this connector."""
|
||||||
|
config = await self.get_config()
|
||||||
|
return config.get("toolkit_id", "")
|
||||||
|
|
||||||
|
async def get_connected_account_id(self) -> str | None:
|
||||||
|
"""Get the Composio connected account ID."""
|
||||||
|
config = await self.get_config()
|
||||||
|
return config.get("composio_connected_account_id")
|
||||||
|
|
||||||
|
async def get_entity_id(self) -> str:
|
||||||
|
"""Get the Composio entity ID (user identifier)."""
|
||||||
|
await self._load_connector()
|
||||||
|
# Entity ID is constructed from the connector's user_id
|
||||||
|
return f"surfsense_{self._connector.user_id}"
|
||||||
|
|
||||||
|
async def is_indexable(self) -> bool:
|
||||||
|
"""Check if this connector's toolkit supports indexing."""
|
||||||
|
toolkit_id = await self.get_toolkit_id()
|
||||||
|
return toolkit_id in INDEXABLE_TOOLKITS
|
||||||
|
|
||||||
|
# ===== Google Drive Methods =====
|
||||||
|
|
||||||
|
async def list_drive_files(
|
||||||
|
self,
|
||||||
|
folder_id: str | None = None,
|
||||||
|
page_token: str | None = None,
|
||||||
|
page_size: int = 100,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None, str | None]:
|
||||||
|
"""
|
||||||
|
List files from Google Drive via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Optional folder ID to list contents of.
|
||||||
|
page_token: Pagination token.
|
||||||
|
page_size: Number of files per page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (files list, next_page_token, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return [], None, "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_drive_files(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
folder_id=folder_id,
|
||||||
|
page_token=page_token,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_drive_file_content(
|
||||||
|
self, file_id: str
|
||||||
|
) -> tuple[bytes | None, str | None]:
|
||||||
|
"""
|
||||||
|
Download file content from Google Drive via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_id: Google Drive file ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (file content bytes, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return None, "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_drive_file_content(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===== Gmail Methods =====
|
||||||
|
|
||||||
|
async def list_gmail_messages(
|
||||||
|
self,
|
||||||
|
query: str = "",
|
||||||
|
max_results: int = 100,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
"""
|
||||||
|
List Gmail messages via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Gmail search query.
|
||||||
|
max_results: Maximum number of messages.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (messages list, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return [], "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_gmail_messages(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
query=query,
|
||||||
|
max_results=max_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_gmail_message_detail(
|
||||||
|
self, message_id: str
|
||||||
|
) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
|
"""
|
||||||
|
Get full details of a Gmail message via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message_id: Gmail message ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (message details, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return None, "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_gmail_message_detail(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
message_id=message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===== Google Calendar Methods =====
|
||||||
|
|
||||||
|
async def list_calendar_events(
|
||||||
|
self,
|
||||||
|
time_min: str | None = None,
|
||||||
|
time_max: str | None = None,
|
||||||
|
max_results: int = 250,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
"""
|
||||||
|
List Google Calendar events via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
time_min: Start time (RFC3339 format).
|
||||||
|
time_max: End time (RFC3339 format).
|
||||||
|
max_results: Maximum number of events.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (events list, error message).
|
||||||
|
"""
|
||||||
|
connected_account_id = await self.get_connected_account_id()
|
||||||
|
if not connected_account_id:
|
||||||
|
return [], "No connected account ID found"
|
||||||
|
|
||||||
|
entity_id = await self.get_entity_id()
|
||||||
|
service = await self._get_service()
|
||||||
|
return await service.get_calendar_events(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
time_min=time_min,
|
||||||
|
time_max=time_max,
|
||||||
|
max_results=max_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ===== Utility Methods =====
|
||||||
|
|
||||||
|
def format_gmail_message_to_markdown(self, message: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Format a Gmail message to markdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Message object from Composio's GMAIL_FETCH_EMAILS response.
|
||||||
|
Composio structure: messageId, messageText, messageTimestamp,
|
||||||
|
payload.headers, labelIds, attachmentList
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted markdown string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Composio uses 'messageId' (camelCase)
|
||||||
|
message_id = message.get("messageId", "") or message.get("id", "")
|
||||||
|
label_ids = message.get("labelIds", [])
|
||||||
|
|
||||||
|
# Extract headers from payload
|
||||||
|
payload = message.get("payload", {})
|
||||||
|
headers = payload.get("headers", [])
|
||||||
|
|
||||||
|
# Parse headers into a dict
|
||||||
|
header_dict = {}
|
||||||
|
for header in headers:
|
||||||
|
name = header.get("name", "").lower()
|
||||||
|
value = header.get("value", "")
|
||||||
|
header_dict[name] = value
|
||||||
|
|
||||||
|
# Extract key information
|
||||||
|
subject = header_dict.get("subject", "No Subject")
|
||||||
|
from_email = header_dict.get("from", "Unknown Sender")
|
||||||
|
to_email = header_dict.get("to", "Unknown Recipient")
|
||||||
|
# Composio provides messageTimestamp directly
|
||||||
|
date_str = message.get("messageTimestamp", "") or header_dict.get("date", "Unknown Date")
|
||||||
|
|
||||||
|
# Build markdown content
|
||||||
|
markdown_content = f"# {subject}\n\n"
|
||||||
|
markdown_content += f"**From:** {from_email}\n"
|
||||||
|
markdown_content += f"**To:** {to_email}\n"
|
||||||
|
markdown_content += f"**Date:** {date_str}\n"
|
||||||
|
|
||||||
|
if label_ids:
|
||||||
|
markdown_content += f"**Labels:** {', '.join(label_ids)}\n"
|
||||||
|
|
||||||
|
markdown_content += "\n---\n\n"
|
||||||
|
|
||||||
|
# Composio provides full message text in 'messageText'
|
||||||
|
message_text = message.get("messageText", "")
|
||||||
|
if message_text:
|
||||||
|
markdown_content += f"## Content\n\n{message_text}\n\n"
|
||||||
|
else:
|
||||||
|
# Fallback to snippet if no messageText
|
||||||
|
snippet = message.get("snippet", "")
|
||||||
|
if snippet:
|
||||||
|
markdown_content += f"## Preview\n\n{snippet}\n\n"
|
||||||
|
|
||||||
|
# Add attachment info if present
|
||||||
|
attachments = message.get("attachmentList", [])
|
||||||
|
if attachments:
|
||||||
|
markdown_content += "## Attachments\n\n"
|
||||||
|
for att in attachments:
|
||||||
|
att_name = att.get("filename", att.get("name", "Unknown"))
|
||||||
|
markdown_content += f"- {att_name}\n"
|
||||||
|
markdown_content += "\n"
|
||||||
|
|
||||||
|
# Add message metadata
|
||||||
|
markdown_content += "## Message Details\n\n"
|
||||||
|
markdown_content += f"- **Message ID:** {message_id}\n"
|
||||||
|
|
||||||
|
return markdown_content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error formatting message to markdown: {e!s}"
|
||||||
|
|
||||||
|
def format_calendar_event_to_markdown(self, event: dict[str, Any]) -> str:
|
||||||
|
"""
|
||||||
|
Format a Google Calendar event to markdown.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event object from Google Calendar API.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted markdown string.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Extract basic event information
|
||||||
|
summary = event.get("summary", "No Title")
|
||||||
|
description = event.get("description", "")
|
||||||
|
location = event.get("location", "")
|
||||||
|
|
||||||
|
# Extract start and end times
|
||||||
|
start = event.get("start", {})
|
||||||
|
end = event.get("end", {})
|
||||||
|
|
||||||
|
start_time = start.get("dateTime") or start.get("date", "")
|
||||||
|
end_time = end.get("dateTime") or end.get("date", "")
|
||||||
|
|
||||||
|
# Format times for display
|
||||||
|
def format_time(time_str: str) -> str:
|
||||||
|
if not time_str:
|
||||||
|
return "Unknown"
|
||||||
|
try:
|
||||||
|
if "T" in time_str:
|
||||||
|
dt = datetime.fromisoformat(time_str.replace("Z", "+00:00"))
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
return time_str
|
||||||
|
except Exception:
|
||||||
|
return time_str
|
||||||
|
|
||||||
|
start_formatted = format_time(start_time)
|
||||||
|
end_formatted = format_time(end_time)
|
||||||
|
|
||||||
|
# Extract attendees
|
||||||
|
attendees = event.get("attendees", [])
|
||||||
|
attendee_list = []
|
||||||
|
for attendee in attendees:
|
||||||
|
email = attendee.get("email", "")
|
||||||
|
display_name = attendee.get("displayName", email)
|
||||||
|
response_status = attendee.get("responseStatus", "")
|
||||||
|
attendee_list.append(f"- {display_name} ({response_status})")
|
||||||
|
|
||||||
|
# Build markdown content
|
||||||
|
markdown_content = f"# {summary}\n\n"
|
||||||
|
markdown_content += f"**Start:** {start_formatted}\n"
|
||||||
|
markdown_content += f"**End:** {end_formatted}\n"
|
||||||
|
|
||||||
|
if location:
|
||||||
|
markdown_content += f"**Location:** {location}\n"
|
||||||
|
|
||||||
|
markdown_content += "\n"
|
||||||
|
|
||||||
|
if description:
|
||||||
|
markdown_content += f"## Description\n\n{description}\n\n"
|
||||||
|
|
||||||
|
if attendee_list:
|
||||||
|
markdown_content += "## Attendees\n\n"
|
||||||
|
markdown_content += "\n".join(attendee_list)
|
||||||
|
markdown_content += "\n\n"
|
||||||
|
|
||||||
|
# Add event metadata
|
||||||
|
markdown_content += "## Event Details\n\n"
|
||||||
|
markdown_content += f"- **Event ID:** {event.get('id', 'Unknown')}\n"
|
||||||
|
markdown_content += f"- **Created:** {event.get('created', 'Unknown')}\n"
|
||||||
|
markdown_content += f"- **Updated:** {event.get('updated', 'Unknown')}\n"
|
||||||
|
|
||||||
|
return markdown_content
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"Error formatting event to markdown: {e!s}"
|
||||||
|
|
@ -1,296 +1,236 @@
|
||||||
import base64
|
"""
|
||||||
import logging
|
GitHub connector using gitingest CLI for efficient repository digestion.
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from github3 import exceptions as github_exceptions, login as github_login
|
This connector uses subprocess to call gitingest CLI, completely isolating
|
||||||
from github3.exceptions import ForbiddenError, NotFoundError
|
it from any Python event loop/async complexity that can cause hangs in Celery.
|
||||||
from github3.repos.contents import Contents
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# List of common code file extensions to target
|
# Maximum file size in bytes (5MB)
|
||||||
CODE_EXTENSIONS = {
|
MAX_FILE_SIZE = 5 * 1024 * 1024
|
||||||
".py",
|
|
||||||
".js",
|
|
||||||
".jsx",
|
|
||||||
".ts",
|
|
||||||
".tsx",
|
|
||||||
".java",
|
|
||||||
".c",
|
|
||||||
".cpp",
|
|
||||||
".h",
|
|
||||||
".hpp",
|
|
||||||
".cs",
|
|
||||||
".go",
|
|
||||||
".rb",
|
|
||||||
".php",
|
|
||||||
".swift",
|
|
||||||
".kt",
|
|
||||||
".scala",
|
|
||||||
".rs",
|
|
||||||
".m",
|
|
||||||
".sh",
|
|
||||||
".bash",
|
|
||||||
".ps1",
|
|
||||||
".lua",
|
|
||||||
".pl",
|
|
||||||
".pm",
|
|
||||||
".r",
|
|
||||||
".dart",
|
|
||||||
".sql",
|
|
||||||
}
|
|
||||||
|
|
||||||
# List of common documentation/text file extensions
|
|
||||||
DOC_EXTENSIONS = {
|
|
||||||
".md",
|
|
||||||
".txt",
|
|
||||||
".rst",
|
|
||||||
".adoc",
|
|
||||||
".html",
|
|
||||||
".htm",
|
|
||||||
".xml",
|
|
||||||
".json",
|
|
||||||
".yaml",
|
|
||||||
".yml",
|
|
||||||
".toml",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Maximum file size in bytes (e.g., 1MB)
|
@dataclass
|
||||||
MAX_FILE_SIZE = 1 * 1024 * 1024
|
class RepositoryDigest:
|
||||||
|
"""Represents a digested repository from gitingest."""
|
||||||
|
|
||||||
|
repo_full_name: str
|
||||||
|
summary: str
|
||||||
|
tree: str
|
||||||
|
content: str
|
||||||
|
branch: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_digest(self) -> str:
|
||||||
|
"""Returns the complete digest with tree and content."""
|
||||||
|
return f"# Repository: {self.repo_full_name}\n\n## File Structure\n\n{self.tree}\n\n## File Contents\n\n{self.content}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def estimated_tokens(self) -> int:
|
||||||
|
"""Rough estimate of tokens (1 token ≈ 4 characters)."""
|
||||||
|
return len(self.full_digest) // 4
|
||||||
|
|
||||||
|
|
||||||
class GitHubConnector:
|
class GitHubConnector:
|
||||||
"""Connector for interacting with the GitHub API."""
|
"""
|
||||||
|
Connector for ingesting GitHub repositories using gitingest CLI.
|
||||||
|
|
||||||
# Directories to skip during file traversal
|
Uses subprocess to run gitingest, which avoids all async/event loop
|
||||||
SKIPPED_DIRS = {
|
issues that can occur when mixing gitingest with Celery workers.
|
||||||
# Version control
|
"""
|
||||||
".git",
|
|
||||||
# Dependencies
|
|
||||||
"node_modules",
|
|
||||||
"vendor",
|
|
||||||
# Build artifacts / Caches
|
|
||||||
"build",
|
|
||||||
"dist",
|
|
||||||
"target",
|
|
||||||
"__pycache__",
|
|
||||||
# Virtual environments
|
|
||||||
"venv",
|
|
||||||
".venv",
|
|
||||||
"env",
|
|
||||||
# IDE/Editor config
|
|
||||||
".vscode",
|
|
||||||
".idea",
|
|
||||||
".project",
|
|
||||||
".settings",
|
|
||||||
# Temporary / Logs
|
|
||||||
"tmp",
|
|
||||||
"logs",
|
|
||||||
# Add other project-specific irrelevant directories if needed
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, token: str):
|
def __init__(self, token: str | None = None):
|
||||||
"""
|
"""
|
||||||
Initializes the GitHub connector.
|
Initialize the GitHub connector.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: GitHub Personal Access Token (PAT).
|
token: Optional GitHub Personal Access Token (PAT).
|
||||||
|
Only required for private repositories.
|
||||||
"""
|
"""
|
||||||
if not token:
|
self.token = token if token and token.strip() else None
|
||||||
raise ValueError("GitHub token cannot be empty.")
|
if self.token:
|
||||||
try:
|
logger.info("GitHub connector initialized with authentication token.")
|
||||||
self.gh = github_login(token=token)
|
else:
|
||||||
# Try a simple authenticated call to check token validity
|
logger.info("GitHub connector initialized without token (public repos only).")
|
||||||
self.gh.me()
|
|
||||||
logger.info("Successfully authenticated with GitHub API.")
|
|
||||||
except (github_exceptions.AuthenticationFailed, ForbiddenError) as e:
|
|
||||||
logger.error(f"GitHub authentication failed: {e}")
|
|
||||||
raise ValueError("Invalid GitHub token or insufficient permissions.") from e
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to initialize GitHub client: {e}")
|
|
||||||
raise e
|
|
||||||
|
|
||||||
def get_user_repositories(self) -> list[dict[str, Any]]:
|
def ingest_repository(
|
||||||
"""Fetches repositories accessible by the authenticated user."""
|
self,
|
||||||
repos_data = []
|
repo_full_name: str,
|
||||||
try:
|
branch: str | None = None,
|
||||||
# type='owner' fetches repos owned by the user
|
max_file_size: int = MAX_FILE_SIZE,
|
||||||
# type='member' fetches repos the user is a collaborator on (including orgs)
|
) -> RepositoryDigest | None:
|
||||||
# type='all' fetches both
|
|
||||||
for repo in self.gh.repositories(type="all", sort="updated"):
|
|
||||||
repos_data.append(
|
|
||||||
{
|
|
||||||
"id": repo.id,
|
|
||||||
"name": repo.name,
|
|
||||||
"full_name": repo.full_name,
|
|
||||||
"private": repo.private,
|
|
||||||
"url": repo.html_url,
|
|
||||||
"description": repo.description or "",
|
|
||||||
"last_updated": repo.updated_at if repo.updated_at else None,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
logger.info(f"Fetched {len(repos_data)} repositories.")
|
|
||||||
return repos_data
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to fetch GitHub repositories: {e}")
|
|
||||||
return [] # Return empty list on error
|
|
||||||
|
|
||||||
def get_repository_files(
|
|
||||||
self, repo_full_name: str, path: str = ""
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
"""
|
"""
|
||||||
Recursively fetches details of relevant files (code, docs) within a repository path.
|
Ingest a repository using gitingest CLI via subprocess.
|
||||||
|
|
||||||
|
This approach completely isolates gitingest from Python's event loop,
|
||||||
|
avoiding any async/Celery conflicts.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
repo_full_name: The full name of the repository (e.g., 'owner/repo').
|
repo_full_name: The full name of the repository (e.g., 'owner/repo').
|
||||||
path: The starting path within the repository (default is root).
|
branch: Optional specific branch or tag to ingest.
|
||||||
|
max_file_size: Maximum file size in bytes to include.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A list of dictionaries, each containing file details (path, sha, url, size).
|
RepositoryDigest or None if ingestion fails.
|
||||||
Returns an empty list if the repository or path is not found or on error.
|
|
||||||
"""
|
"""
|
||||||
files_list = []
|
repo_url = f"https://github.com/{repo_full_name}"
|
||||||
|
|
||||||
|
logger.info(f"Starting gitingest CLI for repository: {repo_full_name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
owner, repo_name = repo_full_name.split("/")
|
# Create a temporary file for output
|
||||||
repo = self.gh.repository(owner, repo_name)
|
with tempfile.NamedTemporaryFile(
|
||||||
if not repo:
|
mode="w", suffix=".txt", delete=False
|
||||||
logger.warning(f"Repository '{repo_full_name}' not found.")
|
) as tmp_file:
|
||||||
return []
|
output_path = tmp_file.name
|
||||||
contents = repo.directory_contents(
|
|
||||||
directory_path=path
|
|
||||||
) # Use directory_contents for clarity
|
|
||||||
|
|
||||||
# contents returns a list of tuples (name, content_obj)
|
# Build the gitingest CLI command
|
||||||
for _item_name, content_item in contents:
|
cmd = [
|
||||||
if not isinstance(content_item, Contents):
|
"gitingest",
|
||||||
continue
|
repo_url,
|
||||||
|
"--output", output_path,
|
||||||
|
"--max-size", str(max_file_size),
|
||||||
|
# Common exclude patterns
|
||||||
|
"-e", "node_modules/*",
|
||||||
|
"-e", "vendor/*",
|
||||||
|
"-e", ".git/*",
|
||||||
|
"-e", "__pycache__/*",
|
||||||
|
"-e", "dist/*",
|
||||||
|
"-e", "build/*",
|
||||||
|
"-e", "*.lock",
|
||||||
|
"-e", "package-lock.json",
|
||||||
|
]
|
||||||
|
|
||||||
if content_item.type == "dir":
|
# Add branch if specified
|
||||||
# Check if the directory name is in the skipped list
|
if branch:
|
||||||
if content_item.name in self.SKIPPED_DIRS:
|
cmd.extend(["--branch", branch])
|
||||||
logger.debug(f"Skipping directory: {content_item.path}")
|
|
||||||
continue # Skip recursion for this directory
|
|
||||||
|
|
||||||
# Recursively fetch contents of subdirectory
|
# Set up environment with token if provided
|
||||||
files_list.extend(
|
env = os.environ.copy()
|
||||||
self.get_repository_files(
|
if self.token:
|
||||||
repo_full_name, path=content_item.path
|
env["GITHUB_TOKEN"] = self.token
|
||||||
)
|
|
||||||
)
|
|
||||||
elif content_item.type == "file":
|
|
||||||
# Check if the file extension is relevant and size is within limits
|
|
||||||
file_extension = (
|
|
||||||
"." + content_item.name.split(".")[-1].lower()
|
|
||||||
if "." in content_item.name
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
is_code = file_extension in CODE_EXTENSIONS
|
|
||||||
is_doc = file_extension in DOC_EXTENSIONS
|
|
||||||
|
|
||||||
if (is_code or is_doc) and content_item.size <= MAX_FILE_SIZE:
|
logger.info(f"Running gitingest CLI: {' '.join(cmd[:5])}...")
|
||||||
files_list.append(
|
|
||||||
{
|
|
||||||
"path": content_item.path,
|
|
||||||
"sha": content_item.sha,
|
|
||||||
"url": content_item.html_url,
|
|
||||||
"size": content_item.size,
|
|
||||||
"type": "code" if is_code else "doc",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif content_item.size > MAX_FILE_SIZE:
|
|
||||||
logger.debug(
|
|
||||||
f"Skipping large file: {content_item.path} ({content_item.size} bytes)"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug(
|
|
||||||
f"Skipping irrelevant file type: {content_item.path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
except (NotFoundError, ForbiddenError) as e:
|
# Run gitingest as subprocess with timeout
|
||||||
logger.warning(f"Cannot access path '{path}' in '{repo_full_name}': {e}")
|
result = subprocess.run(
|
||||||
except Exception as e:
|
cmd,
|
||||||
logger.error(
|
env=env,
|
||||||
f"Failed to get files for {repo_full_name} at path '{path}': {e}"
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=900, # 5 minute timeout
|
||||||
)
|
)
|
||||||
# Return what we have collected so far in case of partial failure
|
|
||||||
|
|
||||||
return files_list
|
if result.returncode != 0:
|
||||||
|
logger.error(f"gitingest failed: {result.stderr}")
|
||||||
|
# Clean up temp file
|
||||||
|
if os.path.exists(output_path):
|
||||||
|
os.unlink(output_path)
|
||||||
|
return None
|
||||||
|
|
||||||
def get_file_content(self, repo_full_name: str, file_path: str) -> str | None:
|
# Read the output file
|
||||||
|
if not os.path.exists(output_path):
|
||||||
|
logger.error("gitingest did not create output file")
|
||||||
|
return None
|
||||||
|
|
||||||
|
with open(output_path, encoding="utf-8") as f:
|
||||||
|
full_content = f.read()
|
||||||
|
|
||||||
|
# Clean up temp file
|
||||||
|
os.unlink(output_path)
|
||||||
|
|
||||||
|
if not full_content or not full_content.strip():
|
||||||
|
logger.warning(f"No content retrieved from repository: {repo_full_name}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Parse the gitingest output
|
||||||
|
# The output format is: summary + tree + content
|
||||||
|
# We'll extract what we can
|
||||||
|
digest = RepositoryDigest(
|
||||||
|
repo_full_name=repo_full_name,
|
||||||
|
summary=f"Repository: {repo_full_name}",
|
||||||
|
tree="", # gitingest CLI combines everything into one file
|
||||||
|
content=full_content,
|
||||||
|
branch=branch,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully ingested {repo_full_name}: "
|
||||||
|
f"~{digest.estimated_tokens} estimated tokens"
|
||||||
|
)
|
||||||
|
return digest
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(f"gitingest timed out for repository: {repo_full_name}")
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(
|
||||||
|
"gitingest CLI not found. Falling back to Python library."
|
||||||
|
)
|
||||||
|
# Fall back to Python library
|
||||||
|
return self._ingest_with_python_library(repo_full_name, branch, max_file_size)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ingest repository {repo_full_name}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ingest_with_python_library(
|
||||||
|
self,
|
||||||
|
repo_full_name: str,
|
||||||
|
branch: str | None = None,
|
||||||
|
max_file_size: int = MAX_FILE_SIZE,
|
||||||
|
) -> RepositoryDigest | None:
|
||||||
"""
|
"""
|
||||||
Fetches the decoded content of a specific file.
|
Fallback: Ingest using the Python library directly.
|
||||||
|
|
||||||
Args:
|
|
||||||
repo_full_name: The full name of the repository (e.g., 'owner/repo').
|
|
||||||
file_path: The path to the file within the repository.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The decoded file content as a string, or None if fetching fails or file is too large.
|
|
||||||
"""
|
"""
|
||||||
|
from gitingest import ingest
|
||||||
|
|
||||||
|
repo_url = f"https://github.com/{repo_full_name}"
|
||||||
|
|
||||||
|
logger.info(f"Using Python gitingest library for: {repo_full_name}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
owner, repo_name = repo_full_name.split("/")
|
kwargs = {
|
||||||
repo = self.gh.repository(owner, repo_name)
|
"max_file_size": max_file_size,
|
||||||
if not repo:
|
"exclude_patterns": [
|
||||||
logger.warning(
|
"node_modules/*",
|
||||||
f"Repository '{repo_full_name}' not found when fetching file '{file_path}'."
|
"vendor/*",
|
||||||
)
|
".git/*",
|
||||||
|
"__pycache__/*",
|
||||||
|
"dist/*",
|
||||||
|
"build/*",
|
||||||
|
"*.lock",
|
||||||
|
"package-lock.json",
|
||||||
|
],
|
||||||
|
"include_gitignored": False,
|
||||||
|
"include_submodules": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.token:
|
||||||
|
kwargs["token"] = self.token
|
||||||
|
if branch:
|
||||||
|
kwargs["branch"] = branch
|
||||||
|
|
||||||
|
summary, tree, content = ingest(repo_url, **kwargs)
|
||||||
|
|
||||||
|
if not content or not content.strip():
|
||||||
|
logger.warning(f"No content from {repo_full_name}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
content_item = repo.file_contents(
|
return RepositoryDigest(
|
||||||
path=file_path
|
repo_full_name=repo_full_name,
|
||||||
) # Use file_contents for clarity
|
summary=summary,
|
||||||
|
tree=tree,
|
||||||
if (
|
content=content,
|
||||||
not content_item
|
branch=branch,
|
||||||
or not isinstance(content_item, Contents)
|
|
||||||
or content_item.type != "file"
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
f"File '{file_path}' not found or is not a file in '{repo_full_name}'."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if content_item.size > MAX_FILE_SIZE:
|
|
||||||
logger.warning(
|
|
||||||
f"File '{file_path}' in '{repo_full_name}' exceeds max size ({content_item.size} > {MAX_FILE_SIZE}). Skipping content fetch."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Content is base64 encoded
|
|
||||||
if content_item.content:
|
|
||||||
try:
|
|
||||||
decoded_content = base64.b64decode(content_item.content).decode(
|
|
||||||
"utf-8"
|
|
||||||
)
|
|
||||||
return decoded_content
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
logger.warning(
|
|
||||||
f"Could not decode file '{file_path}' in '{repo_full_name}' as UTF-8. Trying with 'latin-1'."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
# Try a fallback encoding
|
|
||||||
decoded_content = base64.b64decode(content_item.content).decode(
|
|
||||||
"latin-1"
|
|
||||||
)
|
|
||||||
return decoded_content
|
|
||||||
except Exception as decode_err:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to decode file '{file_path}' with fallback encoding: {decode_err}"
|
|
||||||
)
|
|
||||||
return None # Give up if fallback fails
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"No content returned for file '{file_path}' in '{repo_full_name}'. It might be empty."
|
|
||||||
)
|
|
||||||
return "" # Return empty string for empty files
|
|
||||||
|
|
||||||
except (NotFoundError, ForbiddenError) as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Cannot access file '{file_path}' in '{repo_full_name}': {e}"
|
|
||||||
)
|
)
|
||||||
return None
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(f"Python library failed for {repo_full_name}: {e}")
|
||||||
f"Failed to get content for file '{file_path}' in '{repo_full_name}': {e}"
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ class DocumentType(str, Enum):
|
||||||
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
|
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
|
||||||
CIRCLEBACK = "CIRCLEBACK"
|
CIRCLEBACK = "CIRCLEBACK"
|
||||||
NOTE = "NOTE"
|
NOTE = "NOTE"
|
||||||
|
COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR" # Generic Composio integration
|
||||||
|
|
||||||
|
|
||||||
class SearchSourceConnectorType(str, Enum):
|
class SearchSourceConnectorType(str, Enum):
|
||||||
|
|
@ -81,6 +82,7 @@ class SearchSourceConnectorType(str, Enum):
|
||||||
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
|
BOOKSTACK_CONNECTOR = "BOOKSTACK_CONNECTOR"
|
||||||
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR"
|
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR"
|
||||||
MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools
|
MCP_CONNECTOR = "MCP_CONNECTOR" # Model Context Protocol - User-defined API tools
|
||||||
|
COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR" # Generic Composio integration (Google, Slack, etc.)
|
||||||
|
|
||||||
|
|
||||||
class LiteLLMProvider(str, Enum):
|
class LiteLLMProvider(str, Enum):
|
||||||
|
|
@ -413,6 +415,13 @@ class ChatComment(BaseModel, TimestampMixin):
|
||||||
nullable=False,
|
nullable=False,
|
||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
|
# Denormalized thread_id for efficient Electric SQL subscriptions (one per thread)
|
||||||
|
thread_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
parent_id = Column(
|
parent_id = Column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("chat_comments.id", ondelete="CASCADE"),
|
ForeignKey("chat_comments.id", ondelete="CASCADE"),
|
||||||
|
|
@ -436,6 +445,7 @@ class ChatComment(BaseModel, TimestampMixin):
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
message = relationship("NewChatMessage", back_populates="comments")
|
message = relationship("NewChatMessage", back_populates="comments")
|
||||||
|
thread = relationship("NewChatThread")
|
||||||
author = relationship("User")
|
author = relationship("User")
|
||||||
parent = relationship(
|
parent = relationship(
|
||||||
"ChatComment", remote_side="ChatComment.id", backref="replies"
|
"ChatComment", remote_side="ChatComment.id", backref="replies"
|
||||||
|
|
@ -472,6 +482,98 @@ class ChatCommentMention(BaseModel, TimestampMixin):
|
||||||
mentioned_user = relationship("User")
|
mentioned_user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionState(BaseModel):
|
||||||
|
"""
|
||||||
|
Tracks real-time session state for shared chat collaboration.
|
||||||
|
One record per thread, synced via Electric SQL.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "chat_session_state"
|
||||||
|
|
||||||
|
thread_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("new_chat_threads.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
ai_responding_to_user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
updated_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
thread = relationship("NewChatThread")
|
||||||
|
ai_responding_to_user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class MemoryCategory(str, Enum):
|
||||||
|
"""Categories for user memories."""
|
||||||
|
|
||||||
|
# Using lowercase keys to match PostgreSQL enum values
|
||||||
|
preference = "preference" # User preferences (e.g., "prefers dark mode")
|
||||||
|
fact = "fact" # Facts about the user (e.g., "is a Python developer")
|
||||||
|
instruction = (
|
||||||
|
"instruction" # Standing instructions (e.g., "always respond in bullet points")
|
||||||
|
)
|
||||||
|
context = "context" # Contextual information (e.g., "working on project X")
|
||||||
|
|
||||||
|
|
||||||
|
class UserMemory(BaseModel, TimestampMixin):
|
||||||
|
"""
|
||||||
|
Stores facts, preferences, and context about users for personalized AI responses.
|
||||||
|
Similar to Claude's memory feature - enables the AI to remember user information
|
||||||
|
across conversations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "user_memories"
|
||||||
|
|
||||||
|
user_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
# Optional association with a search space (if memory is space-specific)
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("searchspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The actual memory content
|
||||||
|
memory_text = Column(Text, nullable=False)
|
||||||
|
# Category for organization and filtering
|
||||||
|
category = Column(
|
||||||
|
SQLAlchemyEnum(MemoryCategory),
|
||||||
|
nullable=False,
|
||||||
|
default=MemoryCategory.fact,
|
||||||
|
)
|
||||||
|
# Vector embedding for semantic search
|
||||||
|
embedding = Column(Vector(config.embedding_model_instance.dimension))
|
||||||
|
|
||||||
|
# Track when memory was last updated
|
||||||
|
updated_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = relationship("User", back_populates="memories")
|
||||||
|
search_space = relationship("SearchSpace", back_populates="user_memories")
|
||||||
|
|
||||||
|
|
||||||
class Document(BaseModel, TimestampMixin):
|
class Document(BaseModel, TimestampMixin):
|
||||||
__tablename__ = "documents"
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
|
@ -659,6 +761,14 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# User memories associated with this search space
|
||||||
|
user_memories = relationship(
|
||||||
|
"UserMemory",
|
||||||
|
back_populates="search_space",
|
||||||
|
order_by="UserMemory.updated_at.desc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchSourceConnector(BaseModel, TimestampMixin):
|
class SearchSourceConnector(BaseModel, TimestampMixin):
|
||||||
__tablename__ = "search_source_connectors"
|
__tablename__ = "search_source_connectors"
|
||||||
|
|
@ -967,6 +1077,14 @@ if config.AUTH_TYPE == "GOOGLE":
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# User memories for personalized AI responses
|
||||||
|
memories = relationship(
|
||||||
|
"UserMemory",
|
||||||
|
back_populates="user",
|
||||||
|
order_by="UserMemory.updated_at.desc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
@ -1010,6 +1128,14 @@ else:
|
||||||
passive_deletes=True,
|
passive_deletes=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# User memories for personalized AI responses
|
||||||
|
memories = relationship(
|
||||||
|
"UserMemory",
|
||||||
|
back_populates="user",
|
||||||
|
order_by="UserMemory.updated_at.desc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
# Page usage tracking for ETL services
|
# Page usage tracking for ETL services
|
||||||
pages_limit = Column(
|
pages_limit = Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from .airtable_add_connector_route import (
|
||||||
from .chat_comments_routes import router as chat_comments_router
|
from .chat_comments_routes import router as chat_comments_router
|
||||||
from .circleback_webhook_route import router as circleback_webhook_router
|
from .circleback_webhook_route import router as circleback_webhook_router
|
||||||
from .clickup_add_connector_route import router as clickup_add_connector_router
|
from .clickup_add_connector_route import router as clickup_add_connector_router
|
||||||
|
from .composio_routes import router as composio_router
|
||||||
from .confluence_add_connector_route import router as confluence_add_connector_router
|
from .confluence_add_connector_route import router as confluence_add_connector_router
|
||||||
from .discord_add_connector_route import router as discord_add_connector_router
|
from .discord_add_connector_route import router as discord_add_connector_router
|
||||||
from .documents_routes import router as documents_router
|
from .documents_routes import router as documents_router
|
||||||
|
|
@ -65,3 +66,4 @@ router.include_router(logs_router)
|
||||||
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
||||||
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
||||||
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
router.include_router(notifications_router) # Notifications with Electric SQL sync
|
||||||
|
router.include_router(composio_router) # Composio OAuth and toolkit management
|
||||||
|
|
|
||||||
333
surfsense_backend/app/routes/composio_routes.py
Normal file
333
surfsense_backend/app/routes/composio_routes.py
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
"""
|
||||||
|
Composio Connector OAuth Routes.
|
||||||
|
|
||||||
|
Handles OAuth flow for Composio-based integrations (Google Drive, Gmail, Calendar, etc.).
|
||||||
|
This provides a single connector that can connect to any Composio toolkit.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- GET /composio/toolkits - List available Composio toolkits
|
||||||
|
- GET /auth/composio/connector/add - Initiate OAuth for a specific toolkit
|
||||||
|
- GET /auth/composio/connector/callback - Handle OAuth callback
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
from pydantic import ValidationError
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.db import (
|
||||||
|
SearchSourceConnector,
|
||||||
|
SearchSourceConnectorType,
|
||||||
|
User,
|
||||||
|
get_async_session,
|
||||||
|
)
|
||||||
|
from app.services.composio_service import (
|
||||||
|
COMPOSIO_TOOLKIT_NAMES,
|
||||||
|
INDEXABLE_TOOLKITS,
|
||||||
|
ComposioService,
|
||||||
|
)
|
||||||
|
from app.users import current_active_user
|
||||||
|
from app.utils.connector_naming import (
|
||||||
|
check_duplicate_connector,
|
||||||
|
generate_unique_connector_name,
|
||||||
|
)
|
||||||
|
from app.utils.oauth_security import OAuthStateManager
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Initialize security utilities
|
||||||
|
_state_manager = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_state_manager() -> OAuthStateManager:
|
||||||
|
"""Get or create OAuth state manager instance."""
|
||||||
|
global _state_manager
|
||||||
|
if _state_manager is None:
|
||||||
|
if not config.SECRET_KEY:
|
||||||
|
raise ValueError("SECRET_KEY must be set for OAuth security")
|
||||||
|
_state_manager = OAuthStateManager(config.SECRET_KEY)
|
||||||
|
return _state_manager
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/composio/toolkits")
|
||||||
|
async def list_composio_toolkits(user: User = Depends(current_active_user)):
|
||||||
|
"""
|
||||||
|
List available Composio toolkits.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of available toolkits and their metadata.
|
||||||
|
"""
|
||||||
|
if not ComposioService.is_enabled():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Composio integration is not enabled. Set COMPOSIO_ENABLED=TRUE and provide COMPOSIO_API_KEY.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
service = ComposioService()
|
||||||
|
toolkits = service.list_available_toolkits()
|
||||||
|
return {"toolkits": toolkits}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list Composio toolkits: {e!s}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to list toolkits: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/composio/connector/add")
|
||||||
|
async def initiate_composio_auth(
|
||||||
|
space_id: int,
|
||||||
|
toolkit_id: str = Query(..., description="Composio toolkit ID (e.g., 'googledrive', 'gmail')"),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initiate Composio OAuth flow for a specific toolkit.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
space_id: Search space ID to add connector to
|
||||||
|
toolkit_id: Composio toolkit ID (e.g., "googledrive", "gmail", "googlecalendar")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with auth_url to redirect user to Composio authorization
|
||||||
|
"""
|
||||||
|
if not ComposioService.is_enabled():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="Composio integration is not enabled.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not space_id:
|
||||||
|
raise HTTPException(status_code=400, detail="space_id is required")
|
||||||
|
|
||||||
|
if toolkit_id not in COMPOSIO_TOOLKIT_NAMES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Unknown toolkit: {toolkit_id}. Available: {list(COMPOSIO_TOOLKIT_NAMES.keys())}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not config.SECRET_KEY:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="SECRET_KEY not configured for OAuth security."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate secure state parameter with HMAC signature
|
||||||
|
state_manager = get_state_manager()
|
||||||
|
state_encoded = state_manager.generate_secure_state(
|
||||||
|
space_id, user.id, toolkit_id=toolkit_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build callback URL
|
||||||
|
callback_url = config.COMPOSIO_REDIRECT_URI
|
||||||
|
if not callback_url:
|
||||||
|
# Fallback: construct from BACKEND_URL
|
||||||
|
backend_url = config.BACKEND_URL or "http://localhost:8000"
|
||||||
|
callback_url = f"{backend_url}/api/v1/auth/composio/connector/callback"
|
||||||
|
|
||||||
|
# Initiate Composio OAuth
|
||||||
|
service = ComposioService()
|
||||||
|
# Use user.id as the entity ID in Composio (converted to string for Composio)
|
||||||
|
entity_id = f"surfsense_{user.id}"
|
||||||
|
|
||||||
|
connection_result = await service.initiate_connection(
|
||||||
|
user_id=entity_id,
|
||||||
|
toolkit_id=toolkit_id,
|
||||||
|
redirect_uri=f"{callback_url}?state={state_encoded}",
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_url = connection_result.get("redirect_url")
|
||||||
|
if not auth_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to get authorization URL from Composio"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Initiating Composio OAuth for user {user.id}, toolkit {toolkit_id}, space {space_id}"
|
||||||
|
)
|
||||||
|
return {"auth_url": auth_url}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initiate Composio OAuth: {e!s}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to initiate Composio OAuth: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth/composio/connector/callback")
|
||||||
|
async def composio_callback(
|
||||||
|
state: str | None = None,
|
||||||
|
connectedAccountId: str | None = None, # Composio sends camelCase
|
||||||
|
connected_account_id: str | None = None, # Fallback snake_case
|
||||||
|
error: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle Composio OAuth callback.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
state: Encoded state with space_id, user_id, and toolkit_id
|
||||||
|
connected_account_id: Composio connected account ID (may not be present)
|
||||||
|
error: OAuth error (if user denied access or error occurred)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Redirect to frontend success page
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Handle OAuth errors
|
||||||
|
if error:
|
||||||
|
logger.warning(f"Composio OAuth error: {error}")
|
||||||
|
space_id = None
|
||||||
|
if state:
|
||||||
|
try:
|
||||||
|
state_manager = get_state_manager()
|
||||||
|
data = state_manager.validate_state(state)
|
||||||
|
space_id = data.get("space_id")
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to validate state in error handler")
|
||||||
|
|
||||||
|
if space_id:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=composio_oauth_denied"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard?error=composio_oauth_denied"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate required parameters
|
||||||
|
if not state:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing state parameter")
|
||||||
|
|
||||||
|
# Validate and decode state with signature verification
|
||||||
|
state_manager = get_state_manager()
|
||||||
|
try:
|
||||||
|
data = state_manager.validate_state(state)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid state parameter: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
user_id = UUID(data["user_id"])
|
||||||
|
space_id = data["space_id"]
|
||||||
|
toolkit_id = data.get("toolkit_id")
|
||||||
|
|
||||||
|
if not toolkit_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing toolkit_id in state")
|
||||||
|
|
||||||
|
toolkit_name = COMPOSIO_TOOLKIT_NAMES.get(toolkit_id, toolkit_id)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Processing Composio callback for user {user_id}, toolkit {toolkit_id}, space {space_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize Composio service
|
||||||
|
service = ComposioService()
|
||||||
|
entity_id = f"surfsense_{user_id}"
|
||||||
|
|
||||||
|
# Use camelCase param if provided (Composio's format), fallback to snake_case
|
||||||
|
final_connected_account_id = connectedAccountId or connected_account_id
|
||||||
|
|
||||||
|
# DEBUG: Log all query parameters received
|
||||||
|
logger.info(f"DEBUG: Callback received - connectedAccountId: {connectedAccountId}, connected_account_id: {connected_account_id}, using: {final_connected_account_id}")
|
||||||
|
|
||||||
|
# If we still don't have a connected_account_id, warn but continue
|
||||||
|
# (the connector will be created but indexing won't work until updated)
|
||||||
|
if not final_connected_account_id:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not find connected_account_id for toolkit {toolkit_id}. "
|
||||||
|
"The connector will be created but indexing may not work."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(f"Successfully got connected_account_id: {final_connected_account_id}")
|
||||||
|
|
||||||
|
# Build connector config
|
||||||
|
connector_config = {
|
||||||
|
"composio_connected_account_id": final_connected_account_id,
|
||||||
|
"toolkit_id": toolkit_id,
|
||||||
|
"toolkit_name": toolkit_name,
|
||||||
|
"is_indexable": toolkit_id in INDEXABLE_TOOLKITS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check for duplicate connector
|
||||||
|
# For Composio, we use toolkit_id + connected_account_id as unique identifier
|
||||||
|
identifier = final_connected_account_id or f"{toolkit_id}_{user_id}"
|
||||||
|
|
||||||
|
is_duplicate = await check_duplicate_connector(
|
||||||
|
session,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
||||||
|
space_id,
|
||||||
|
user_id,
|
||||||
|
identifier,
|
||||||
|
)
|
||||||
|
if is_duplicate:
|
||||||
|
logger.warning(
|
||||||
|
f"Duplicate Composio connector detected for user {user_id} with toolkit {toolkit_id}"
|
||||||
|
)
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&error=duplicate_account&connector=composio-connector"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate a unique, user-friendly connector name
|
||||||
|
connector_name = await generate_unique_connector_name(
|
||||||
|
session,
|
||||||
|
SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
||||||
|
space_id,
|
||||||
|
user_id,
|
||||||
|
f"{toolkit_name} (Composio)",
|
||||||
|
)
|
||||||
|
|
||||||
|
db_connector = SearchSourceConnector(
|
||||||
|
name=connector_name,
|
||||||
|
connector_type=SearchSourceConnectorType.COMPOSIO_CONNECTOR,
|
||||||
|
config=connector_config,
|
||||||
|
search_space_id=space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
is_indexable=toolkit_id in INDEXABLE_TOOLKITS,
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(db_connector)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(db_connector)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Successfully created Composio connector {db_connector.id} for user {user_id}, toolkit {toolkit_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/new-chat?modal=connectors&tab=all&success=true&connector=composio-connector&connectorId={db_connector.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except IntegrityError as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Database integrity error: {e!s}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Database integrity error: {e!s}",
|
||||||
|
) from e
|
||||||
|
except ValidationError as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.error(f"Validation error: {e!s}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail=f"Invalid connector configuration: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error in Composio callback: {e!s}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to complete Composio OAuth: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
@ -990,6 +990,7 @@ async def handle_new_chat(
|
||||||
search_space_id=request.search_space_id,
|
search_space_id=request.search_space_id,
|
||||||
chat_id=request.chat_id,
|
chat_id=request.chat_id,
|
||||||
session=session,
|
session=session,
|
||||||
|
user_id=str(user.id), # Pass user ID for memory tools and session state
|
||||||
llm_config_id=llm_config_id,
|
llm_config_id=llm_config_id,
|
||||||
attachments=request.attachments,
|
attachments=request.attachments,
|
||||||
mentioned_document_ids=request.mentioned_document_ids,
|
mentioned_document_ids=request.mentioned_document_ids,
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""
|
"""
|
||||||
Notifications API routes.
|
Notifications API routes.
|
||||||
These endpoints allow marking notifications as read.
|
These endpoints allow marking notifications as read and fetching older notifications.
|
||||||
Electric SQL automatically syncs the changes to all connected clients.
|
Electric SQL automatically syncs the changes to all connected clients for recent items.
|
||||||
|
For older items (beyond the sync window), use the list endpoint.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import desc, func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db import Notification, User, get_async_session
|
from app.db import Notification, User, get_async_session
|
||||||
|
|
@ -14,6 +17,36 @@ from app.users import current_active_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||||
|
|
||||||
|
# Must match frontend SYNC_WINDOW_DAYS in use-inbox.ts
|
||||||
|
SYNC_WINDOW_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationResponse(BaseModel):
|
||||||
|
"""Response model for a single notification."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
user_id: str
|
||||||
|
search_space_id: int | None
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
read: bool
|
||||||
|
metadata: dict
|
||||||
|
created_at: str
|
||||||
|
updated_at: str | None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationListResponse(BaseModel):
|
||||||
|
"""Response for listing notifications with pagination."""
|
||||||
|
|
||||||
|
items: list[NotificationResponse]
|
||||||
|
total: int
|
||||||
|
has_more: bool
|
||||||
|
next_offset: int | None
|
||||||
|
|
||||||
|
|
||||||
class MarkReadResponse(BaseModel):
|
class MarkReadResponse(BaseModel):
|
||||||
"""Response for mark as read operations."""
|
"""Response for mark as read operations."""
|
||||||
|
|
@ -30,6 +63,169 @@ class MarkAllReadResponse(BaseModel):
|
||||||
updated_count: int
|
updated_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class UnreadCountResponse(BaseModel):
|
||||||
|
"""Response for unread count with split between recent and older items."""
|
||||||
|
|
||||||
|
total_unread: int
|
||||||
|
recent_unread: int # Within SYNC_WINDOW_DAYS
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||||
|
async def get_unread_count(
|
||||||
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> UnreadCountResponse:
|
||||||
|
"""
|
||||||
|
Get the total unread notification count for the current user.
|
||||||
|
|
||||||
|
Returns both:
|
||||||
|
- total_unread: All unread notifications (for accurate badge count)
|
||||||
|
- recent_unread: Unread notifications within the sync window (last 14 days)
|
||||||
|
|
||||||
|
This allows the frontend to calculate:
|
||||||
|
- older_unread = total_unread - recent_unread (static until reconciliation)
|
||||||
|
- Display count = older_unread + live_recent_count (from Electric SQL)
|
||||||
|
"""
|
||||||
|
# Calculate cutoff date for sync window
|
||||||
|
cutoff_date = datetime.now(UTC) - timedelta(days=SYNC_WINDOW_DAYS)
|
||||||
|
|
||||||
|
# Base filter for user's unread notifications
|
||||||
|
base_filter = [
|
||||||
|
Notification.user_id == user.id,
|
||||||
|
Notification.read == False, # noqa: E712
|
||||||
|
]
|
||||||
|
|
||||||
|
# Add search space filter if provided (include null for global notifications)
|
||||||
|
if search_space_id is not None:
|
||||||
|
base_filter.append(
|
||||||
|
(Notification.search_space_id == search_space_id)
|
||||||
|
| (Notification.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Total unread count (all time)
|
||||||
|
total_query = select(func.count(Notification.id)).where(*base_filter)
|
||||||
|
total_result = await session.execute(total_query)
|
||||||
|
total_unread = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Recent unread count (within sync window)
|
||||||
|
recent_query = select(func.count(Notification.id)).where(
|
||||||
|
*base_filter,
|
||||||
|
Notification.created_at > cutoff_date,
|
||||||
|
)
|
||||||
|
recent_result = await session.execute(recent_query)
|
||||||
|
recent_unread = recent_result.scalar() or 0
|
||||||
|
|
||||||
|
return UnreadCountResponse(
|
||||||
|
total_unread=total_unread,
|
||||||
|
recent_unread=recent_unread,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=NotificationListResponse)
|
||||||
|
async def list_notifications(
|
||||||
|
search_space_id: int | None = Query(None, description="Filter by search space ID"),
|
||||||
|
type_filter: str | None = Query(
|
||||||
|
None, alias="type", description="Filter by notification type"
|
||||||
|
),
|
||||||
|
before_date: str | None = Query(
|
||||||
|
None, description="Get notifications before this ISO date (for pagination)"
|
||||||
|
),
|
||||||
|
limit: int = Query(50, ge=1, le=100, description="Number of items to return"),
|
||||||
|
offset: int = Query(0, ge=0, description="Number of items to skip"),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
) -> NotificationListResponse:
|
||||||
|
"""
|
||||||
|
List notifications for the current user with pagination.
|
||||||
|
|
||||||
|
This endpoint is used as a fallback for older notifications that are
|
||||||
|
outside the Electric SQL sync window (2 weeks).
|
||||||
|
|
||||||
|
Use `before_date` to paginate through older notifications efficiently.
|
||||||
|
"""
|
||||||
|
# Build base query
|
||||||
|
query = select(Notification).where(Notification.user_id == user.id)
|
||||||
|
count_query = select(func.count(Notification.id)).where(
|
||||||
|
Notification.user_id == user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by search space (include null search_space_id for global notifications)
|
||||||
|
if search_space_id is not None:
|
||||||
|
query = query.where(
|
||||||
|
(Notification.search_space_id == search_space_id)
|
||||||
|
| (Notification.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
count_query = count_query.where(
|
||||||
|
(Notification.search_space_id == search_space_id)
|
||||||
|
| (Notification.search_space_id.is_(None))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter by type
|
||||||
|
if type_filter:
|
||||||
|
query = query.where(Notification.type == type_filter)
|
||||||
|
count_query = count_query.where(Notification.type == type_filter)
|
||||||
|
|
||||||
|
# Filter by date (for efficient pagination of older items)
|
||||||
|
if before_date:
|
||||||
|
try:
|
||||||
|
before_datetime = datetime.fromisoformat(before_date.replace("Z", "+00:00"))
|
||||||
|
query = query.where(Notification.created_at < before_datetime)
|
||||||
|
count_query = count_query.where(Notification.created_at < before_datetime)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Invalid date format. Use ISO format (e.g., 2024-01-15T00:00:00Z)",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
total_result = await session.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Apply ordering and pagination
|
||||||
|
query = (
|
||||||
|
query.order_by(desc(Notification.created_at)).offset(offset).limit(limit + 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute query
|
||||||
|
result = await session.execute(query)
|
||||||
|
notifications = result.scalars().all()
|
||||||
|
|
||||||
|
# Check if there are more items
|
||||||
|
has_more = len(notifications) > limit
|
||||||
|
if has_more:
|
||||||
|
notifications = notifications[:limit]
|
||||||
|
|
||||||
|
# Convert to response format
|
||||||
|
items = []
|
||||||
|
for notification in notifications:
|
||||||
|
items.append(
|
||||||
|
NotificationResponse(
|
||||||
|
id=notification.id,
|
||||||
|
user_id=str(notification.user_id),
|
||||||
|
search_space_id=notification.search_space_id,
|
||||||
|
type=notification.type,
|
||||||
|
title=notification.title,
|
||||||
|
message=notification.message,
|
||||||
|
read=notification.read,
|
||||||
|
metadata=notification.notification_metadata or {},
|
||||||
|
created_at=notification.created_at.isoformat()
|
||||||
|
if notification.created_at
|
||||||
|
else "",
|
||||||
|
updated_at=notification.updated_at.isoformat()
|
||||||
|
if notification.updated_at
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationListResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
has_more=has_more,
|
||||||
|
next_offset=offset + limit if has_more else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
|
@router.patch("/{notification_id}/read", response_model=MarkReadResponse)
|
||||||
async def mark_notification_as_read(
|
async def mark_notification_as_read(
|
||||||
notification_id: int,
|
notification_id: int,
|
||||||
|
|
|
||||||
|
|
@ -868,6 +868,19 @@ async def index_connector_content(
|
||||||
)
|
)
|
||||||
response_message = "Web page indexing started in the background."
|
response_message = "Web page indexing started in the background."
|
||||||
|
|
||||||
|
elif connector.connector_type == SearchSourceConnectorType.COMPOSIO_CONNECTOR:
|
||||||
|
from app.tasks.celery_tasks.connector_tasks import (
|
||||||
|
index_composio_connector_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Triggering Composio connector indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
|
||||||
|
)
|
||||||
|
index_composio_connector_task.delay(
|
||||||
|
connector_id, search_space_id, str(user.id), indexing_from, indexing_to
|
||||||
|
)
|
||||||
|
response_message = "Composio connector indexing started in the background."
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
|
|
|
||||||
29
surfsense_backend/app/schemas/chat_session_state.py
Normal file
29
surfsense_backend/app/schemas/chat_session_state.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
"""
|
||||||
|
Pydantic schemas for chat session state (live collaboration).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class RespondingUser(BaseModel):
|
||||||
|
"""The user that the AI is currently responding to."""
|
||||||
|
|
||||||
|
id: UUID
|
||||||
|
display_name: str | None = None
|
||||||
|
email: str
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ChatSessionStateResponse(BaseModel):
|
||||||
|
"""Current session state for a chat thread."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
thread_id: int
|
||||||
|
responding_to: RespondingUser | None = None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
@ -281,8 +281,10 @@ async def create_comment(
|
||||||
detail="You don't have permission to create comments in this search space",
|
detail="You don't have permission to create comments in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
thread = message.thread
|
||||||
comment = ChatComment(
|
comment = ChatComment(
|
||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
|
thread_id=thread.id, # Denormalized for efficient Electric subscriptions
|
||||||
author_id=user.id,
|
author_id=user.id,
|
||||||
content=content,
|
content=content,
|
||||||
)
|
)
|
||||||
|
|
@ -299,7 +301,6 @@ async def create_comment(
|
||||||
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
||||||
|
|
||||||
# Create notifications for mentioned users (excluding author)
|
# Create notifications for mentioned users (excluding author)
|
||||||
thread = message.thread
|
|
||||||
author_name = user.display_name or user.email
|
author_name = user.display_name or user.email
|
||||||
content_preview = render_mentions(content, user_names)
|
content_preview = render_mentions(content, user_names)
|
||||||
for mentioned_user_id, mention_id in mentions_map.items():
|
for mentioned_user_id, mention_id in mentions_map.items():
|
||||||
|
|
@ -315,6 +316,8 @@ async def create_comment(
|
||||||
thread_title=thread.title or "Untitled thread",
|
thread_title=thread.title or "Untitled thread",
|
||||||
author_id=str(user.id),
|
author_id=str(user.id),
|
||||||
author_name=author_name,
|
author_name=author_name,
|
||||||
|
author_avatar_url=user.avatar_url,
|
||||||
|
author_email=user.email,
|
||||||
content_preview=content_preview[:200],
|
content_preview=content_preview[:200],
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
)
|
)
|
||||||
|
|
@ -391,8 +394,10 @@ async def create_reply(
|
||||||
detail="You don't have permission to create comments in this search space",
|
detail="You don't have permission to create comments in this search space",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
thread = parent_comment.message.thread
|
||||||
reply = ChatComment(
|
reply = ChatComment(
|
||||||
message_id=parent_comment.message_id,
|
message_id=parent_comment.message_id,
|
||||||
|
thread_id=thread.id, # Denormalized for efficient Electric subscriptions
|
||||||
parent_id=comment_id,
|
parent_id=comment_id,
|
||||||
author_id=user.id,
|
author_id=user.id,
|
||||||
content=content,
|
content=content,
|
||||||
|
|
@ -410,7 +415,6 @@ async def create_reply(
|
||||||
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
user_names = await get_user_names_for_mentions(session, set(mentions_map.keys()))
|
||||||
|
|
||||||
# Create notifications for mentioned users (excluding author)
|
# Create notifications for mentioned users (excluding author)
|
||||||
thread = parent_comment.message.thread
|
|
||||||
author_name = user.display_name or user.email
|
author_name = user.display_name or user.email
|
||||||
content_preview = render_mentions(content, user_names)
|
content_preview = render_mentions(content, user_names)
|
||||||
for mentioned_user_id, mention_id in mentions_map.items():
|
for mentioned_user_id, mention_id in mentions_map.items():
|
||||||
|
|
@ -426,6 +430,8 @@ async def create_reply(
|
||||||
thread_title=thread.title or "Untitled thread",
|
thread_title=thread.title or "Untitled thread",
|
||||||
author_id=str(user.id),
|
author_id=str(user.id),
|
||||||
author_name=author_name,
|
author_name=author_name,
|
||||||
|
author_avatar_url=user.avatar_url,
|
||||||
|
author_email=user.email,
|
||||||
content_preview=content_preview[:200],
|
content_preview=content_preview[:200],
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
)
|
)
|
||||||
|
|
@ -565,6 +571,8 @@ async def update_comment(
|
||||||
thread_title=thread.title or "Untitled thread",
|
thread_title=thread.title or "Untitled thread",
|
||||||
author_id=str(user.id),
|
author_id=str(user.id),
|
||||||
author_name=author_name,
|
author_name=author_name,
|
||||||
|
author_avatar_url=user.avatar_url,
|
||||||
|
author_email=user.email,
|
||||||
content_preview=content_preview[:200],
|
content_preview=content_preview[:200],
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
65
surfsense_backend/app/services/chat_session_state_service.py
Normal file
65
surfsense_backend/app/services/chat_session_state_service.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""
|
||||||
|
Service layer for chat session state (live collaboration).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.db import ChatSessionState
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session_state(
|
||||||
|
session: AsyncSession,
|
||||||
|
thread_id: int,
|
||||||
|
) -> ChatSessionState | None:
|
||||||
|
"""Get the current session state for a thread."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ChatSessionState)
|
||||||
|
.options(selectinload(ChatSessionState.ai_responding_to_user))
|
||||||
|
.filter(ChatSessionState.thread_id == thread_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def set_ai_responding(
|
||||||
|
session: AsyncSession,
|
||||||
|
thread_id: int,
|
||||||
|
user_id: UUID,
|
||||||
|
) -> ChatSessionState:
|
||||||
|
"""Mark AI as responding to a specific user. Uses upsert for atomicity."""
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
upsert_query = insert(ChatSessionState).values(
|
||||||
|
thread_id=thread_id,
|
||||||
|
ai_responding_to_user_id=user_id,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
upsert_query = upsert_query.on_conflict_do_update(
|
||||||
|
index_elements=["thread_id"],
|
||||||
|
set_={
|
||||||
|
"ai_responding_to_user_id": user_id,
|
||||||
|
"updated_at": now,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.execute(upsert_query)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
return await get_session_state(session, thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def clear_ai_responding(
|
||||||
|
session: AsyncSession,
|
||||||
|
thread_id: int,
|
||||||
|
) -> ChatSessionState | None:
|
||||||
|
"""Clear AI responding state when response is complete."""
|
||||||
|
state = await get_session_state(session, thread_id)
|
||||||
|
if state:
|
||||||
|
state.ai_responding_to_user_id = None
|
||||||
|
state.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(state)
|
||||||
|
return state
|
||||||
607
surfsense_backend/app/services/composio_service.py
Normal file
607
surfsense_backend/app/services/composio_service.py
Normal file
|
|
@ -0,0 +1,607 @@
|
||||||
|
"""
|
||||||
|
Composio Service Module.
|
||||||
|
|
||||||
|
Provides a wrapper around the Composio SDK for managing OAuth connections
|
||||||
|
and executing tools for various integrations (Google Drive, Gmail, Calendar, etc.).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from composio import Composio
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Mapping of toolkit IDs to their Composio auth config IDs
|
||||||
|
# These use Composio's managed OAuth (no custom credentials needed)
|
||||||
|
COMPOSIO_TOOLKIT_AUTH_CONFIGS = {
|
||||||
|
"googledrive": "default", # Uses Composio's managed Google OAuth
|
||||||
|
"gmail": "default",
|
||||||
|
"googlecalendar": "default",
|
||||||
|
"slack": "default",
|
||||||
|
"notion": "default",
|
||||||
|
"github": "default",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping of toolkit IDs to their display names
|
||||||
|
COMPOSIO_TOOLKIT_NAMES = {
|
||||||
|
"googledrive": "Google Drive",
|
||||||
|
"gmail": "Gmail",
|
||||||
|
"googlecalendar": "Google Calendar",
|
||||||
|
"slack": "Slack",
|
||||||
|
"notion": "Notion",
|
||||||
|
"github": "GitHub",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Toolkits that support indexing (Phase 1: Google services only)
|
||||||
|
INDEXABLE_TOOLKITS = {"googledrive", "gmail", "googlecalendar"}
|
||||||
|
|
||||||
|
|
||||||
|
class ComposioService:
|
||||||
|
"""Service for interacting with Composio API."""
|
||||||
|
|
||||||
|
def __init__(self, api_key: str | None = None):
|
||||||
|
"""
|
||||||
|
Initialize the Composio service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: Composio API key. If not provided, uses config.COMPOSIO_API_KEY.
|
||||||
|
"""
|
||||||
|
self.api_key = api_key or config.COMPOSIO_API_KEY
|
||||||
|
if not self.api_key:
|
||||||
|
raise ValueError("COMPOSIO_API_KEY is required but not configured")
|
||||||
|
self.client = Composio(api_key=self.api_key)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_enabled() -> bool:
|
||||||
|
"""Check if Composio integration is enabled."""
|
||||||
|
return config.COMPOSIO_ENABLED and bool(config.COMPOSIO_API_KEY)
|
||||||
|
|
||||||
|
def list_available_toolkits(self) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
List all available Composio toolkits for the UI.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of toolkit metadata dictionaries.
|
||||||
|
"""
|
||||||
|
toolkits = []
|
||||||
|
for toolkit_id, display_name in COMPOSIO_TOOLKIT_NAMES.items():
|
||||||
|
toolkits.append(
|
||||||
|
{
|
||||||
|
"id": toolkit_id,
|
||||||
|
"name": display_name,
|
||||||
|
"is_indexable": toolkit_id in INDEXABLE_TOOLKITS,
|
||||||
|
"description": f"Connect to {display_name} via Composio",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return toolkits
|
||||||
|
|
||||||
|
def _get_auth_config_for_toolkit(self, toolkit_id: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Get the auth_config_id for a specific toolkit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
toolkit_id: The toolkit ID (e.g., "googledrive", "gmail").
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The auth_config_id or None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# List all auth configs and find the one matching our toolkit
|
||||||
|
auth_configs = self.client.auth_configs.list()
|
||||||
|
for auth_config in auth_configs.items:
|
||||||
|
# Get toolkit - it may be an object with a 'slug' or 'name' attribute, or a string
|
||||||
|
config_toolkit = getattr(auth_config, "toolkit", None)
|
||||||
|
if config_toolkit is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract toolkit name/slug from the object
|
||||||
|
toolkit_name = None
|
||||||
|
if isinstance(config_toolkit, str):
|
||||||
|
toolkit_name = config_toolkit
|
||||||
|
elif hasattr(config_toolkit, "slug"):
|
||||||
|
toolkit_name = config_toolkit.slug
|
||||||
|
elif hasattr(config_toolkit, "name"):
|
||||||
|
toolkit_name = config_toolkit.name
|
||||||
|
elif hasattr(config_toolkit, "id"):
|
||||||
|
toolkit_name = config_toolkit.id
|
||||||
|
|
||||||
|
# Compare case-insensitively
|
||||||
|
if toolkit_name and toolkit_name.lower() == toolkit_id.lower():
|
||||||
|
logger.info(f"Found auth config {auth_config.id} for toolkit {toolkit_id}")
|
||||||
|
return auth_config.id
|
||||||
|
|
||||||
|
# Log available auth configs for debugging
|
||||||
|
logger.warning(f"No auth config found for toolkit '{toolkit_id}'. Available auth configs:")
|
||||||
|
for auth_config in auth_configs.items:
|
||||||
|
config_toolkit = getattr(auth_config, "toolkit", None)
|
||||||
|
logger.warning(f" - {auth_config.id}: toolkit={config_toolkit}")
|
||||||
|
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list auth configs: {e!s}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def initiate_connection(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
toolkit_id: str,
|
||||||
|
redirect_uri: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Initiate OAuth flow for a Composio toolkit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Unique identifier for the user (used as entity_id in Composio).
|
||||||
|
toolkit_id: The toolkit to connect (e.g., "googledrive", "gmail").
|
||||||
|
redirect_uri: URL to redirect after OAuth completion.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary containing redirect_url and connection_id.
|
||||||
|
"""
|
||||||
|
if toolkit_id not in COMPOSIO_TOOLKIT_NAMES:
|
||||||
|
raise ValueError(f"Unknown toolkit: {toolkit_id}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# First, get the auth_config_id for this toolkit
|
||||||
|
auth_config_id = self._get_auth_config_for_toolkit(toolkit_id)
|
||||||
|
|
||||||
|
if not auth_config_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"No auth config found for toolkit '{toolkit_id}'. "
|
||||||
|
f"Please create an auth config for {COMPOSIO_TOOLKIT_NAMES.get(toolkit_id, toolkit_id)} "
|
||||||
|
f"in your Composio dashboard at https://app.composio.dev"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initiate the connection using Composio SDK with auth_config_id
|
||||||
|
# allow_multiple=True allows creating multiple connections per user (e.g., different Google accounts)
|
||||||
|
connection_request = self.client.connected_accounts.initiate(
|
||||||
|
user_id=user_id,
|
||||||
|
auth_config_id=auth_config_id,
|
||||||
|
callback_url=redirect_uri,
|
||||||
|
allow_multiple=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Initiated Composio connection for user {user_id}, toolkit {toolkit_id}, auth_config {auth_config_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"redirect_url": connection_request.redirect_url,
|
||||||
|
"connection_id": getattr(connection_request, "id", None),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initiate Composio connection: {e!s}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_connected_account(
|
||||||
|
self, connected_account_id: str
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
Get details of a connected account.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: The Composio connected account ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Connected account details or None if not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Pass connected_account_id as positional argument (not keyword)
|
||||||
|
account = self.client.connected_accounts.get(connected_account_id)
|
||||||
|
return {
|
||||||
|
"id": account.id,
|
||||||
|
"status": getattr(account, "status", None),
|
||||||
|
"toolkit": getattr(account, "toolkit", None),
|
||||||
|
"user_id": getattr(account, "user_id", None),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get connected account {connected_account_id}: {e!s}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_all_connections(self) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
List ALL connected accounts (for debugging).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of all connected account details.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
accounts_response = self.client.connected_accounts.list()
|
||||||
|
|
||||||
|
if hasattr(accounts_response, "items"):
|
||||||
|
accounts = accounts_response.items
|
||||||
|
elif hasattr(accounts_response, "__iter__"):
|
||||||
|
accounts = accounts_response
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unexpected accounts response type: {type(accounts_response)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for acc in accounts:
|
||||||
|
toolkit_raw = getattr(acc, "toolkit", None)
|
||||||
|
toolkit_info = None
|
||||||
|
if toolkit_raw:
|
||||||
|
if isinstance(toolkit_raw, str):
|
||||||
|
toolkit_info = toolkit_raw
|
||||||
|
elif hasattr(toolkit_raw, "slug"):
|
||||||
|
toolkit_info = toolkit_raw.slug
|
||||||
|
elif hasattr(toolkit_raw, "name"):
|
||||||
|
toolkit_info = toolkit_raw.name
|
||||||
|
else:
|
||||||
|
toolkit_info = str(toolkit_raw)
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"id": acc.id,
|
||||||
|
"status": getattr(acc, "status", None),
|
||||||
|
"toolkit": toolkit_info,
|
||||||
|
"user_id": getattr(acc, "user_id", None),
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"DEBUG: Found {len(result)} TOTAL connections in Composio")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list all connections: {e!s}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def list_user_connections(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
List all connected accounts for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user's unique identifier.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of connected account details.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"DEBUG: Calling connected_accounts.list(user_id='{user_id}')")
|
||||||
|
accounts_response = self.client.connected_accounts.list(user_id=user_id)
|
||||||
|
|
||||||
|
# Handle paginated response (may have .items attribute) or direct list
|
||||||
|
if hasattr(accounts_response, "items"):
|
||||||
|
accounts = accounts_response.items
|
||||||
|
elif hasattr(accounts_response, "__iter__"):
|
||||||
|
accounts = accounts_response
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unexpected accounts response type: {type(accounts_response)}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for acc in accounts:
|
||||||
|
# Extract toolkit info - might be string or object
|
||||||
|
toolkit_raw = getattr(acc, "toolkit", None)
|
||||||
|
toolkit_info = None
|
||||||
|
if toolkit_raw:
|
||||||
|
if isinstance(toolkit_raw, str):
|
||||||
|
toolkit_info = toolkit_raw
|
||||||
|
elif hasattr(toolkit_raw, "slug"):
|
||||||
|
toolkit_info = toolkit_raw.slug
|
||||||
|
elif hasattr(toolkit_raw, "name"):
|
||||||
|
toolkit_info = toolkit_raw.name
|
||||||
|
else:
|
||||||
|
toolkit_info = toolkit_raw
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"id": acc.id,
|
||||||
|
"status": getattr(acc, "status", None),
|
||||||
|
"toolkit": toolkit_info,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Found {len(result)} connections for user {user_id}: {result}")
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list connections for user {user_id}: {e!s}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def execute_tool(
|
||||||
|
self,
|
||||||
|
connected_account_id: str,
|
||||||
|
tool_name: str,
|
||||||
|
params: dict[str, Any] | None = None,
|
||||||
|
entity_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute a Composio tool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: The connected account to use.
|
||||||
|
tool_name: Name of the tool (e.g., "GOOGLEDRIVE_LIST_FILES").
|
||||||
|
params: Parameters for the tool.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tool execution result.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Based on Composio SDK docs:
|
||||||
|
# - slug: tool name
|
||||||
|
# - arguments: tool parameters
|
||||||
|
# - connected_account_id: for authentication
|
||||||
|
# - user_id: user identifier (SDK uses user_id, not entity_id)
|
||||||
|
# - dangerously_skip_version_check: skip version check for manual execution
|
||||||
|
logger.info(f"DEBUG: Executing tool {tool_name} with params: {params}")
|
||||||
|
result = self.client.tools.execute(
|
||||||
|
slug=tool_name,
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
user_id=entity_id, # SDK expects user_id parameter
|
||||||
|
arguments=params or {},
|
||||||
|
dangerously_skip_version_check=True,
|
||||||
|
)
|
||||||
|
logger.info(f"DEBUG: Tool {tool_name} raw result type: {type(result)}")
|
||||||
|
logger.info(f"DEBUG: Tool {tool_name} raw result: {result}")
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to execute tool {tool_name}: {e!s}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
# ===== Google Drive specific methods =====
|
||||||
|
|
||||||
|
async def get_drive_files(
|
||||||
|
self,
|
||||||
|
connected_account_id: str,
|
||||||
|
entity_id: str,
|
||||||
|
folder_id: str | None = None,
|
||||||
|
page_token: str | None = None,
|
||||||
|
page_size: int = 100,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None, str | None]:
|
||||||
|
"""
|
||||||
|
List files from Google Drive via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: Composio connected account ID.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
folder_id: Optional folder ID to list contents of.
|
||||||
|
page_token: Pagination token.
|
||||||
|
page_size: Number of files per page.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (files list, next_page_token, error message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Composio uses snake_case for parameters
|
||||||
|
params = {
|
||||||
|
"page_size": min(page_size, 100),
|
||||||
|
}
|
||||||
|
if folder_id:
|
||||||
|
params["folder_id"] = folder_id
|
||||||
|
if page_token:
|
||||||
|
params["page_token"] = page_token
|
||||||
|
|
||||||
|
result = await self.execute_tool(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
tool_name="GOOGLEDRIVE_LIST_FILES",
|
||||||
|
params=params,
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
return [], None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
logger.info(f"DEBUG: Drive data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
|
||||||
|
|
||||||
|
# Handle nested response structure from Composio
|
||||||
|
files = []
|
||||||
|
next_token = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
# Try direct access first, then nested
|
||||||
|
files = data.get("files", []) or data.get("data", {}).get("files", [])
|
||||||
|
next_token = data.get("nextPageToken") or data.get("next_page_token") or data.get("data", {}).get("nextPageToken")
|
||||||
|
elif isinstance(data, list):
|
||||||
|
files = data
|
||||||
|
|
||||||
|
logger.info(f"DEBUG: Extracted {len(files)} drive files")
|
||||||
|
return files, next_token, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list Drive files: {e!s}")
|
||||||
|
return [], None, str(e)
|
||||||
|
|
||||||
|
async def get_drive_file_content(
|
||||||
|
self, connected_account_id: str, entity_id: str, file_id: str
|
||||||
|
) -> tuple[bytes | None, str | None]:
|
||||||
|
"""
|
||||||
|
Download file content from Google Drive via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: Composio connected account ID.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
file_id: Google Drive file ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (file content bytes, error message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await self.execute_tool(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
tool_name="GOOGLEDRIVE_DOWNLOAD_FILE",
|
||||||
|
params={"file_id": file_id}, # snake_case
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
return None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
|
content = result.get("data")
|
||||||
|
if isinstance(content, str):
|
||||||
|
content = content.encode("utf-8")
|
||||||
|
|
||||||
|
return content, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get Drive file content: {e!s}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
# ===== Gmail specific methods =====
|
||||||
|
|
||||||
|
async def get_gmail_messages(
|
||||||
|
self,
|
||||||
|
connected_account_id: str,
|
||||||
|
entity_id: str,
|
||||||
|
query: str = "",
|
||||||
|
max_results: int = 100,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
"""
|
||||||
|
List Gmail messages via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: Composio connected account ID.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
query: Gmail search query.
|
||||||
|
max_results: Maximum number of messages to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (messages list, error message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Composio uses snake_case for parameters, max is 500
|
||||||
|
params = {"max_results": min(max_results, 500)}
|
||||||
|
if query:
|
||||||
|
params["query"] = query # Composio uses 'query' not 'q'
|
||||||
|
|
||||||
|
result = await self.execute_tool(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
tool_name="GMAIL_FETCH_EMAILS",
|
||||||
|
params=params,
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
return [], result.get("error", "Unknown error")
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
logger.info(f"DEBUG: Gmail data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
|
||||||
|
logger.info(f"DEBUG: Gmail full data: {data}")
|
||||||
|
|
||||||
|
# Try different possible response structures
|
||||||
|
messages = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
messages = data.get("messages", []) or data.get("data", {}).get("messages", []) or data.get("emails", [])
|
||||||
|
elif isinstance(data, list):
|
||||||
|
messages = data
|
||||||
|
|
||||||
|
logger.info(f"DEBUG: Extracted {len(messages)} messages")
|
||||||
|
return messages, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list Gmail messages: {e!s}")
|
||||||
|
return [], str(e)
|
||||||
|
|
||||||
|
async def get_gmail_message_detail(
|
||||||
|
self, connected_account_id: str, entity_id: str, message_id: str
|
||||||
|
) -> tuple[dict[str, Any] | None, str | None]:
|
||||||
|
"""
|
||||||
|
Get full details of a Gmail message via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: Composio connected account ID.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
message_id: Gmail message ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (message details, error message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await self.execute_tool(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
tool_name="GMAIL_GET_MESSAGE_BY_MESSAGE_ID",
|
||||||
|
params={"message_id": message_id}, # snake_case
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
return None, result.get("error", "Unknown error")
|
||||||
|
|
||||||
|
return result.get("data"), None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get Gmail message detail: {e!s}")
|
||||||
|
return None, str(e)
|
||||||
|
|
||||||
|
# ===== Google Calendar specific methods =====
|
||||||
|
|
||||||
|
async def get_calendar_events(
|
||||||
|
self,
|
||||||
|
connected_account_id: str,
|
||||||
|
entity_id: str,
|
||||||
|
time_min: str | None = None,
|
||||||
|
time_max: str | None = None,
|
||||||
|
max_results: int = 250,
|
||||||
|
) -> tuple[list[dict[str, Any]], str | None]:
|
||||||
|
"""
|
||||||
|
List Google Calendar events via Composio.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
connected_account_id: Composio connected account ID.
|
||||||
|
entity_id: The entity/user ID that owns the connected account.
|
||||||
|
time_min: Start time (RFC3339 format).
|
||||||
|
time_max: End time (RFC3339 format).
|
||||||
|
max_results: Maximum number of events.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (events list, error message).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Composio uses snake_case for parameters
|
||||||
|
params = {
|
||||||
|
"max_results": min(max_results, 250),
|
||||||
|
"single_events": True,
|
||||||
|
"order_by": "startTime",
|
||||||
|
}
|
||||||
|
if time_min:
|
||||||
|
params["time_min"] = time_min
|
||||||
|
if time_max:
|
||||||
|
params["time_max"] = time_max
|
||||||
|
|
||||||
|
result = await self.execute_tool(
|
||||||
|
connected_account_id=connected_account_id,
|
||||||
|
tool_name="GOOGLECALENDAR_EVENTS_LIST",
|
||||||
|
params=params,
|
||||||
|
entity_id=entity_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.get("success"):
|
||||||
|
return [], result.get("error", "Unknown error")
|
||||||
|
|
||||||
|
data = result.get("data", {})
|
||||||
|
logger.info(f"DEBUG: Calendar data type: {type(data)}, keys: {data.keys() if isinstance(data, dict) else 'N/A'}")
|
||||||
|
logger.info(f"DEBUG: Calendar full data: {data}")
|
||||||
|
|
||||||
|
# Try different possible response structures
|
||||||
|
events = []
|
||||||
|
if isinstance(data, dict):
|
||||||
|
events = data.get("items", []) or data.get("data", {}).get("items", []) or data.get("events", [])
|
||||||
|
elif isinstance(data, list):
|
||||||
|
events = data
|
||||||
|
|
||||||
|
logger.info(f"DEBUG: Extracted {len(events)} calendar events")
|
||||||
|
return events, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list Calendar events: {e!s}")
|
||||||
|
return [], str(e)
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_composio_service: ComposioService | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_composio_service() -> ComposioService:
|
||||||
|
"""
|
||||||
|
Get or create the Composio service singleton.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComposioService instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If Composio is not properly configured.
|
||||||
|
"""
|
||||||
|
global _composio_service
|
||||||
|
if _composio_service is None:
|
||||||
|
_composio_service = ComposioService()
|
||||||
|
return _composio_service
|
||||||
|
|
@ -623,6 +623,28 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("new_mention")
|
super().__init__("new_mention")
|
||||||
|
|
||||||
|
async def find_notification_by_mention(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
mention_id: int,
|
||||||
|
) -> Notification | None:
|
||||||
|
"""
|
||||||
|
Find an existing notification by mention ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
mention_id: The mention ID to search for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Notification if found, None otherwise
|
||||||
|
"""
|
||||||
|
query = select(Notification).where(
|
||||||
|
Notification.type == self.notification_type,
|
||||||
|
Notification.notification_metadata["mention_id"].astext == str(mention_id),
|
||||||
|
)
|
||||||
|
result = await session.execute(query)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def notify_new_mention(
|
async def notify_new_mention(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -634,28 +656,41 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
||||||
thread_title: str,
|
thread_title: str,
|
||||||
author_id: str,
|
author_id: str,
|
||||||
author_name: str,
|
author_name: str,
|
||||||
|
author_avatar_url: str | None,
|
||||||
|
author_email: str,
|
||||||
content_preview: str,
|
content_preview: str,
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
) -> Notification:
|
) -> Notification:
|
||||||
"""
|
"""
|
||||||
Create notification when a user is @mentioned in a comment.
|
Create notification when a user is @mentioned in a comment.
|
||||||
|
Uses mention_id for idempotency to prevent duplicate notifications.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
mentioned_user_id: User who was mentioned
|
mentioned_user_id: User who was mentioned
|
||||||
mention_id: ID of the mention record
|
mention_id: ID of the mention record (used for idempotency)
|
||||||
comment_id: ID of the comment containing the mention
|
comment_id: ID of the comment containing the mention
|
||||||
message_id: ID of the message being commented on
|
message_id: ID of the message being commented on
|
||||||
thread_id: ID of the chat thread
|
thread_id: ID of the chat thread
|
||||||
thread_title: Title of the chat thread
|
thread_title: Title of the chat thread
|
||||||
author_id: ID of the comment author
|
author_id: ID of the comment author
|
||||||
author_name: Display name of the comment author
|
author_name: Display name of the comment author
|
||||||
|
author_avatar_url: Avatar URL of the comment author
|
||||||
|
author_email: Email of the comment author (for fallback initials)
|
||||||
content_preview: First ~100 chars of the comment
|
content_preview: First ~100 chars of the comment
|
||||||
search_space_id: Search space ID
|
search_space_id: Search space ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Notification: The created notification
|
Notification: The created or existing notification
|
||||||
"""
|
"""
|
||||||
|
# Check if notification already exists for this mention (idempotency)
|
||||||
|
existing = await self.find_notification_by_mention(session, mention_id)
|
||||||
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
f"Notification already exists for mention {mention_id}, returning existing"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
title = f"{author_name} mentioned you"
|
title = f"{author_name} mentioned you"
|
||||||
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
|
message = content_preview[:100] + ("..." if len(content_preview) > 100 else "")
|
||||||
|
|
||||||
|
|
@ -667,24 +702,39 @@ class MentionNotificationHandler(BaseNotificationHandler):
|
||||||
"thread_title": thread_title,
|
"thread_title": thread_title,
|
||||||
"author_id": author_id,
|
"author_id": author_id,
|
||||||
"author_name": author_name,
|
"author_name": author_name,
|
||||||
|
"author_avatar_url": author_avatar_url,
|
||||||
|
"author_email": author_email,
|
||||||
"content_preview": content_preview[:200],
|
"content_preview": content_preview[:200],
|
||||||
}
|
}
|
||||||
|
|
||||||
notification = Notification(
|
try:
|
||||||
user_id=mentioned_user_id,
|
notification = Notification(
|
||||||
search_space_id=search_space_id,
|
user_id=mentioned_user_id,
|
||||||
type=self.notification_type,
|
search_space_id=search_space_id,
|
||||||
title=title,
|
type=self.notification_type,
|
||||||
message=message,
|
title=title,
|
||||||
notification_metadata=metadata,
|
message=message,
|
||||||
)
|
notification_metadata=metadata,
|
||||||
session.add(notification)
|
)
|
||||||
await session.commit()
|
session.add(notification)
|
||||||
await session.refresh(notification)
|
await session.commit()
|
||||||
logger.info(
|
await session.refresh(notification)
|
||||||
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
|
logger.info(
|
||||||
)
|
f"Created new_mention notification {notification.id} for user {mentioned_user_id}"
|
||||||
return notification
|
)
|
||||||
|
return notification
|
||||||
|
except Exception as e:
|
||||||
|
# Handle race condition - if duplicate key error, try to fetch existing
|
||||||
|
await session.rollback()
|
||||||
|
if "duplicate key" in str(e).lower() or "unique constraint" in str(e).lower():
|
||||||
|
logger.warning(
|
||||||
|
f"Duplicate notification detected for mention {mention_id}, fetching existing"
|
||||||
|
)
|
||||||
|
existing = await self.find_notification_by_mention(session, mention_id)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
# Re-raise if not a duplicate key error or couldn't find existing
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
|
|
|
||||||
|
|
@ -759,3 +759,45 @@ async def _index_bookstack_pages(
|
||||||
await run_bookstack_indexing(
|
await run_bookstack_indexing(
|
||||||
session, connector_id, search_space_id, user_id, start_date, end_date
|
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@celery_app.task(name="index_composio_connector", bind=True)
|
||||||
|
def index_composio_connector_task(
|
||||||
|
self,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
):
|
||||||
|
"""Celery task to index Composio connector content (Google Drive, Gmail, Calendar via Composio)."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(
|
||||||
|
_index_composio_connector(
|
||||||
|
connector_id, search_space_id, user_id, start_date, end_date
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def _index_composio_connector(
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
):
|
||||||
|
"""Index Composio connector content with new session."""
|
||||||
|
# Import from tasks folder (not connector_indexers) to avoid circular import
|
||||||
|
from app.tasks.composio_indexer import index_composio_connector
|
||||||
|
|
||||||
|
async with get_celery_session_maker()() as session:
|
||||||
|
await index_composio_connector(
|
||||||
|
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Supports loading LLM configurations from:
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
@ -27,6 +28,10 @@ from app.agents.new_chat.llm_config import (
|
||||||
)
|
)
|
||||||
from app.db import Document, SurfsenseDocsDocument
|
from app.db import Document, SurfsenseDocsDocument
|
||||||
from app.schemas.new_chat import ChatAttachment
|
from app.schemas.new_chat import ChatAttachment
|
||||||
|
from app.services.chat_session_state_service import (
|
||||||
|
clear_ai_responding,
|
||||||
|
set_ai_responding,
|
||||||
|
)
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
from app.services.new_streaming_service import VercelStreamingService
|
from app.services.new_streaming_service import VercelStreamingService
|
||||||
|
|
||||||
|
|
@ -149,6 +154,7 @@ async def stream_new_chat(
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
chat_id: int,
|
chat_id: int,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
user_id: str | None = None,
|
||||||
llm_config_id: int = -1,
|
llm_config_id: int = -1,
|
||||||
attachments: list[ChatAttachment] | None = None,
|
attachments: list[ChatAttachment] | None = None,
|
||||||
mentioned_document_ids: list[int] | None = None,
|
mentioned_document_ids: list[int] | None = None,
|
||||||
|
|
@ -166,8 +172,8 @@ async def stream_new_chat(
|
||||||
search_space_id: The search space ID
|
search_space_id: The search space ID
|
||||||
chat_id: The chat ID (used as LangGraph thread_id for memory)
|
chat_id: The chat ID (used as LangGraph thread_id for memory)
|
||||||
session: The database session
|
session: The database session
|
||||||
|
user_id: The current user's UUID string (for memory tools and session state)
|
||||||
llm_config_id: The LLM configuration ID (default: -1 for first global config)
|
llm_config_id: The LLM configuration ID (default: -1 for first global config)
|
||||||
messages: Optional chat history from frontend (list of ChatMessage)
|
|
||||||
attachments: Optional attachments with extracted content
|
attachments: Optional attachments with extracted content
|
||||||
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
|
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
|
||||||
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
|
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
|
||||||
|
|
@ -181,6 +187,9 @@ async def stream_new_chat(
|
||||||
current_text_id: str | None = None
|
current_text_id: str | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Mark AI as responding to this user for live collaboration
|
||||||
|
if user_id:
|
||||||
|
await set_ai_responding(session, chat_id, UUID(user_id))
|
||||||
# Load LLM config - supports both YAML (negative IDs) and database (positive IDs)
|
# Load LLM config - supports both YAML (negative IDs) and database (positive IDs)
|
||||||
agent_config: AgentConfig | None = None
|
agent_config: AgentConfig | None = None
|
||||||
|
|
||||||
|
|
@ -243,6 +252,7 @@ async def stream_new_chat(
|
||||||
db_session=session,
|
db_session=session,
|
||||||
connector_service=connector_service,
|
connector_service=connector_service,
|
||||||
checkpointer=checkpointer,
|
checkpointer=checkpointer,
|
||||||
|
user_id=user_id, # Pass user ID for memory tools
|
||||||
agent_config=agent_config, # Pass prompt configuration
|
agent_config=agent_config, # Pass prompt configuration
|
||||||
firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured
|
firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured
|
||||||
)
|
)
|
||||||
|
|
@ -1144,3 +1154,7 @@ async def stream_new_chat(
|
||||||
yield streaming_service.format_finish_step()
|
yield streaming_service.format_finish_step()
|
||||||
yield streaming_service.format_finish()
|
yield streaming_service.format_finish()
|
||||||
yield streaming_service.format_done()
|
yield streaming_service.format_done()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clear AI responding state for live collaboration
|
||||||
|
await clear_ai_responding(session, chat_id)
|
||||||
|
|
|
||||||
878
surfsense_backend/app/tasks/composio_indexer.py
Normal file
878
surfsense_backend/app/tasks/composio_indexer.py
Normal file
|
|
@ -0,0 +1,878 @@
|
||||||
|
"""
|
||||||
|
Composio connector indexer.
|
||||||
|
|
||||||
|
Routes indexing requests to toolkit-specific handlers (Google Drive, Gmail, Calendar).
|
||||||
|
|
||||||
|
Note: This module is intentionally placed in app/tasks/ (not in connector_indexers/)
|
||||||
|
to avoid circular import issues with the connector_indexers package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.future import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
from app.connectors.composio_connector import ComposioConnector
|
||||||
|
from app.db import (
|
||||||
|
Document,
|
||||||
|
DocumentType,
|
||||||
|
SearchSourceConnector,
|
||||||
|
SearchSourceConnectorType,
|
||||||
|
)
|
||||||
|
from app.services.composio_service import INDEXABLE_TOOLKITS
|
||||||
|
from app.services.llm_service import get_user_long_context_llm
|
||||||
|
from app.services.task_logging_service import TaskLoggingService
|
||||||
|
from app.utils.document_converters import (
|
||||||
|
create_document_chunks,
|
||||||
|
generate_content_hash,
|
||||||
|
generate_document_summary,
|
||||||
|
generate_unique_identifier_hash,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Utility functions (copied from connector_indexers.base to avoid circular imports) ============
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_timestamp() -> datetime:
|
||||||
|
"""Get the current timestamp with timezone for updated_at field."""
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
async def check_document_by_unique_identifier(
|
||||||
|
session: AsyncSession, unique_identifier_hash: str
|
||||||
|
) -> Document | None:
|
||||||
|
"""Check if a document with the given unique identifier hash already exists."""
|
||||||
|
existing_doc_result = await session.execute(
|
||||||
|
select(Document)
|
||||||
|
.options(selectinload(Document.chunks))
|
||||||
|
.where(Document.unique_identifier_hash == unique_identifier_hash)
|
||||||
|
)
|
||||||
|
return existing_doc_result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_connector_by_id(
|
||||||
|
session: AsyncSession, connector_id: int, connector_type: SearchSourceConnectorType
|
||||||
|
) -> SearchSourceConnector | None:
|
||||||
|
"""Get a connector by ID and type from the database."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(SearchSourceConnector).filter(
|
||||||
|
SearchSourceConnector.id == connector_id,
|
||||||
|
SearchSourceConnector.connector_type == connector_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_connector_last_indexed(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector: SearchSourceConnector,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Update the last_indexed_at timestamp for a connector."""
|
||||||
|
if update_last_indexed:
|
||||||
|
connector.last_indexed_at = datetime.now()
|
||||||
|
logger.info(f"Updated last_indexed_at to {connector.last_indexed_at}")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ Main indexer function ============
|
||||||
|
|
||||||
|
|
||||||
|
async def index_composio_connector(
|
||||||
|
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,
|
||||||
|
max_items: int = 1000,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""
|
||||||
|
Index content from a Composio connector.
|
||||||
|
|
||||||
|
Routes to toolkit-specific indexing based on the connector's toolkit_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
connector_id: ID of the Composio connector
|
||||||
|
search_space_id: ID of the search space
|
||||||
|
user_id: ID of the user
|
||||||
|
start_date: Start date for filtering (YYYY-MM-DD format)
|
||||||
|
end_date: End date for filtering (YYYY-MM-DD format)
|
||||||
|
update_last_indexed: Whether to update the last_indexed_at timestamp
|
||||||
|
max_items: Maximum number of items to fetch
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (number_of_indexed_items, error_message or None)
|
||||||
|
"""
|
||||||
|
task_logger = TaskLoggingService(session, search_space_id)
|
||||||
|
|
||||||
|
# Log task start
|
||||||
|
log_entry = await task_logger.log_task_start(
|
||||||
|
task_name="composio_connector_indexing",
|
||||||
|
source="connector_indexing_task",
|
||||||
|
message=f"Starting Composio connector indexing for connector {connector_id}",
|
||||||
|
metadata={
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"user_id": str(user_id),
|
||||||
|
"max_items": max_items,
|
||||||
|
"start_date": start_date,
|
||||||
|
"end_date": end_date,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get connector by id
|
||||||
|
connector = await get_connector_by_id(
|
||||||
|
session, connector_id, SearchSourceConnectorType.COMPOSIO_CONNECTOR
|
||||||
|
)
|
||||||
|
|
||||||
|
if not connector:
|
||||||
|
error_msg = f"Composio connector with ID {connector_id} not found"
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, error_msg, {"error_type": "ConnectorNotFound"}
|
||||||
|
)
|
||||||
|
return 0, error_msg
|
||||||
|
|
||||||
|
# Get toolkit ID from config
|
||||||
|
toolkit_id = connector.config.get("toolkit_id")
|
||||||
|
if not toolkit_id:
|
||||||
|
error_msg = f"Composio connector {connector_id} has no toolkit_id configured"
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, error_msg, {"error_type": "MissingToolkitId"}
|
||||||
|
)
|
||||||
|
return 0, error_msg
|
||||||
|
|
||||||
|
# Check if toolkit is indexable
|
||||||
|
if toolkit_id not in INDEXABLE_TOOLKITS:
|
||||||
|
error_msg = f"Toolkit '{toolkit_id}' does not support indexing yet"
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, error_msg, {"error_type": "ToolkitNotIndexable"}
|
||||||
|
)
|
||||||
|
return 0, error_msg
|
||||||
|
|
||||||
|
# Route to toolkit-specific indexer
|
||||||
|
if toolkit_id == "googledrive":
|
||||||
|
return await _index_composio_google_drive(
|
||||||
|
session=session,
|
||||||
|
connector=connector,
|
||||||
|
connector_id=connector_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_logger=task_logger,
|
||||||
|
log_entry=log_entry,
|
||||||
|
update_last_indexed=update_last_indexed,
|
||||||
|
max_items=max_items,
|
||||||
|
)
|
||||||
|
elif toolkit_id == "gmail":
|
||||||
|
return await _index_composio_gmail(
|
||||||
|
session=session,
|
||||||
|
connector=connector,
|
||||||
|
connector_id=connector_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
task_logger=task_logger,
|
||||||
|
log_entry=log_entry,
|
||||||
|
update_last_indexed=update_last_indexed,
|
||||||
|
max_items=max_items,
|
||||||
|
)
|
||||||
|
elif toolkit_id == "googlecalendar":
|
||||||
|
return await _index_composio_google_calendar(
|
||||||
|
session=session,
|
||||||
|
connector=connector,
|
||||||
|
connector_id=connector_id,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
start_date=start_date,
|
||||||
|
end_date=end_date,
|
||||||
|
task_logger=task_logger,
|
||||||
|
log_entry=log_entry,
|
||||||
|
update_last_indexed=update_last_indexed,
|
||||||
|
max_items=max_items,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
error_msg = f"No indexer implemented for toolkit: {toolkit_id}"
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, error_msg, {"error_type": "NoIndexerImplemented"}
|
||||||
|
)
|
||||||
|
return 0, error_msg
|
||||||
|
|
||||||
|
except SQLAlchemyError as db_error:
|
||||||
|
await session.rollback()
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry,
|
||||||
|
f"Database error during Composio 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 Composio connector {connector_id}",
|
||||||
|
str(e),
|
||||||
|
{"error_type": type(e).__name__},
|
||||||
|
)
|
||||||
|
logger.error(f"Failed to index Composio connector: {e!s}", exc_info=True)
|
||||||
|
return 0, f"Failed to index Composio connector: {e!s}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _index_composio_google_drive(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
task_logger: TaskLoggingService,
|
||||||
|
log_entry,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
max_items: int = 1000,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Index Google Drive files via Composio."""
|
||||||
|
try:
|
||||||
|
composio_connector = ComposioConnector(session, connector_id)
|
||||||
|
|
||||||
|
await task_logger.log_task_progress(
|
||||||
|
log_entry,
|
||||||
|
f"Fetching Google Drive files via Composio for connector {connector_id}",
|
||||||
|
{"stage": "fetching_files"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch files
|
||||||
|
all_files = []
|
||||||
|
page_token = None
|
||||||
|
|
||||||
|
while len(all_files) < max_items:
|
||||||
|
files, next_token, error = await composio_connector.list_drive_files(
|
||||||
|
page_token=page_token,
|
||||||
|
page_size=min(100, max_items - len(all_files)),
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, f"Failed to fetch Drive files: {error}", {}
|
||||||
|
)
|
||||||
|
return 0, f"Failed to fetch Drive files: {error}"
|
||||||
|
|
||||||
|
all_files.extend(files)
|
||||||
|
|
||||||
|
if not next_token:
|
||||||
|
break
|
||||||
|
page_token = next_token
|
||||||
|
|
||||||
|
if not all_files:
|
||||||
|
success_msg = "No Google Drive files found"
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry, success_msg, {"files_count": 0}
|
||||||
|
)
|
||||||
|
return 0, success_msg
|
||||||
|
|
||||||
|
logger.info(f"Found {len(all_files)} Google Drive files to index via Composio")
|
||||||
|
|
||||||
|
documents_indexed = 0
|
||||||
|
documents_skipped = 0
|
||||||
|
|
||||||
|
for file_info in all_files:
|
||||||
|
try:
|
||||||
|
# Handle both standard Google API and potential Composio variations
|
||||||
|
file_id = file_info.get("id", "") or file_info.get("fileId", "")
|
||||||
|
file_name = file_info.get("name", "") or file_info.get("fileName", "") or "Untitled"
|
||||||
|
mime_type = file_info.get("mimeType", "") or file_info.get("mime_type", "")
|
||||||
|
|
||||||
|
if not file_id:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip folders
|
||||||
|
if mime_type == "application/vnd.google-apps.folder":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Generate unique identifier hash
|
||||||
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
|
DocumentType.COMPOSIO_CONNECTOR, f"drive_{file_id}", search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if document exists
|
||||||
|
existing_document = await check_document_by_unique_identifier(
|
||||||
|
session, unique_identifier_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get file content
|
||||||
|
content, content_error = await composio_connector.get_drive_file_content(
|
||||||
|
file_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if content_error or not content:
|
||||||
|
logger.warning(f"Could not get content for file {file_name}: {content_error}")
|
||||||
|
# Use metadata as content fallback
|
||||||
|
markdown_content = f"# {file_name}\n\n"
|
||||||
|
markdown_content += f"**File ID:** {file_id}\n"
|
||||||
|
markdown_content += f"**Type:** {mime_type}\n"
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
markdown_content = content.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
markdown_content = f"# {file_name}\n\n[Binary file content]\n"
|
||||||
|
|
||||||
|
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||||
|
|
||||||
|
if existing_document:
|
||||||
|
if existing_document.content_hash == content_hash:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing document
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"file_name": file_name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"document_type": "Google Drive File (Composio)",
|
||||||
|
}
|
||||||
|
summary_content, summary_embedding = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
existing_document.title = f"Drive: {file_name}"
|
||||||
|
existing_document.content = summary_content
|
||||||
|
existing_document.content_hash = content_hash
|
||||||
|
existing_document.embedding = summary_embedding
|
||||||
|
existing_document.document_metadata = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"file_name": file_name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"source": "composio",
|
||||||
|
}
|
||||||
|
existing_document.chunks = chunks
|
||||||
|
existing_document.updated_at = get_current_timestamp()
|
||||||
|
|
||||||
|
documents_indexed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new document
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"file_id": file_id,
|
||||||
|
"file_name": file_name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"document_type": "Google Drive File (Composio)",
|
||||||
|
}
|
||||||
|
summary_content, summary_embedding = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = f"Google Drive File: {file_name}\n\nType: {mime_type}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
title=f"Drive: {file_name}",
|
||||||
|
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
||||||
|
document_metadata={
|
||||||
|
"file_id": file_id,
|
||||||
|
"file_name": file_name,
|
||||||
|
"mime_type": mime_type,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"toolkit_id": "googledrive",
|
||||||
|
"source": "composio",
|
||||||
|
},
|
||||||
|
content=summary_content,
|
||||||
|
content_hash=content_hash,
|
||||||
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
embedding=summary_embedding,
|
||||||
|
chunks=chunks,
|
||||||
|
updated_at=get_current_timestamp(),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
documents_indexed += 1
|
||||||
|
|
||||||
|
if documents_indexed % 10 == 0:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Drive file: {e!s}", exc_info=True)
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if documents_indexed > 0:
|
||||||
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry,
|
||||||
|
f"Successfully completed Google Drive indexing via Composio for connector {connector_id}",
|
||||||
|
{
|
||||||
|
"documents_indexed": documents_indexed,
|
||||||
|
"documents_skipped": documents_skipped,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return documents_indexed, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to index Google Drive via Composio: {e!s}", exc_info=True)
|
||||||
|
return 0, f"Failed to index Google Drive via Composio: {e!s}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _index_composio_gmail(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str | None,
|
||||||
|
end_date: str | None,
|
||||||
|
task_logger: TaskLoggingService,
|
||||||
|
log_entry,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
max_items: int = 1000,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Index Gmail messages via Composio."""
|
||||||
|
try:
|
||||||
|
composio_connector = ComposioConnector(session, connector_id)
|
||||||
|
|
||||||
|
await task_logger.log_task_progress(
|
||||||
|
log_entry,
|
||||||
|
f"Fetching Gmail messages via Composio for connector {connector_id}",
|
||||||
|
{"stage": "fetching_messages"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build query with date range
|
||||||
|
query_parts = []
|
||||||
|
if start_date:
|
||||||
|
query_parts.append(f"after:{start_date.replace('-', '/')}")
|
||||||
|
if end_date:
|
||||||
|
query_parts.append(f"before:{end_date.replace('-', '/')}")
|
||||||
|
query = " ".join(query_parts)
|
||||||
|
|
||||||
|
messages, error = await composio_connector.list_gmail_messages(
|
||||||
|
query=query,
|
||||||
|
max_results=max_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, f"Failed to fetch Gmail messages: {error}", {}
|
||||||
|
)
|
||||||
|
return 0, f"Failed to fetch Gmail messages: {error}"
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
success_msg = "No Gmail messages found in the specified date range"
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry, success_msg, {"messages_count": 0}
|
||||||
|
)
|
||||||
|
return 0, success_msg
|
||||||
|
|
||||||
|
logger.info(f"Found {len(messages)} Gmail messages to index via Composio")
|
||||||
|
|
||||||
|
documents_indexed = 0
|
||||||
|
documents_skipped = 0
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
try:
|
||||||
|
# Composio uses 'messageId' (camelCase), not 'id'
|
||||||
|
message_id = message.get("messageId", "") or message.get("id", "")
|
||||||
|
if not message_id:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Composio's GMAIL_FETCH_EMAILS already returns full message content
|
||||||
|
# No need for a separate detail API call
|
||||||
|
|
||||||
|
# Extract message info from Composio response
|
||||||
|
# Composio structure: messageId, messageText, messageTimestamp, payload.headers, labelIds
|
||||||
|
payload = message.get("payload", {})
|
||||||
|
headers = payload.get("headers", [])
|
||||||
|
|
||||||
|
subject = "No Subject"
|
||||||
|
sender = "Unknown Sender"
|
||||||
|
date_str = message.get("messageTimestamp", "Unknown Date")
|
||||||
|
|
||||||
|
for header in headers:
|
||||||
|
name = header.get("name", "").lower()
|
||||||
|
value = header.get("value", "")
|
||||||
|
if name == "subject":
|
||||||
|
subject = value
|
||||||
|
elif name == "from":
|
||||||
|
sender = value
|
||||||
|
elif name == "date":
|
||||||
|
date_str = value
|
||||||
|
|
||||||
|
# Format to markdown using the full message data
|
||||||
|
markdown_content = composio_connector.format_gmail_message_to_markdown(message)
|
||||||
|
|
||||||
|
# Generate unique identifier
|
||||||
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
|
DocumentType.COMPOSIO_CONNECTOR, f"gmail_{message_id}", search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||||
|
|
||||||
|
existing_document = await check_document_by_unique_identifier(
|
||||||
|
session, unique_identifier_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get label IDs from Composio response
|
||||||
|
label_ids = message.get("labelIds", [])
|
||||||
|
|
||||||
|
if existing_document:
|
||||||
|
if existing_document.content_hash == content_hash:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"document_type": "Gmail Message (Composio)",
|
||||||
|
}
|
||||||
|
summary_content, summary_embedding = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
existing_document.title = f"Gmail: {subject}"
|
||||||
|
existing_document.content = summary_content
|
||||||
|
existing_document.content_hash = content_hash
|
||||||
|
existing_document.embedding = summary_embedding
|
||||||
|
existing_document.document_metadata = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"date": date_str,
|
||||||
|
"labels": label_ids,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"source": "composio",
|
||||||
|
}
|
||||||
|
existing_document.chunks = chunks
|
||||||
|
existing_document.updated_at = get_current_timestamp()
|
||||||
|
|
||||||
|
documents_indexed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new document
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"message_id": message_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"document_type": "Gmail Message (Composio)",
|
||||||
|
}
|
||||||
|
summary_content, summary_embedding = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = f"Gmail: {subject}\n\nFrom: {sender}\nDate: {date_str}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
title=f"Gmail: {subject}",
|
||||||
|
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
||||||
|
document_metadata={
|
||||||
|
"message_id": message_id,
|
||||||
|
"subject": subject,
|
||||||
|
"sender": sender,
|
||||||
|
"date": date_str,
|
||||||
|
"labels": label_ids,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"toolkit_id": "gmail",
|
||||||
|
"source": "composio",
|
||||||
|
},
|
||||||
|
content=summary_content,
|
||||||
|
content_hash=content_hash,
|
||||||
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
embedding=summary_embedding,
|
||||||
|
chunks=chunks,
|
||||||
|
updated_at=get_current_timestamp(),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
documents_indexed += 1
|
||||||
|
|
||||||
|
if documents_indexed % 10 == 0:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Gmail message: {e!s}", exc_info=True)
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if documents_indexed > 0:
|
||||||
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry,
|
||||||
|
f"Successfully completed Gmail indexing via Composio for connector {connector_id}",
|
||||||
|
{
|
||||||
|
"documents_indexed": documents_indexed,
|
||||||
|
"documents_skipped": documents_skipped,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return documents_indexed, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to index Gmail via Composio: {e!s}", exc_info=True)
|
||||||
|
return 0, f"Failed to index Gmail via Composio: {e!s}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _index_composio_google_calendar(
|
||||||
|
session: AsyncSession,
|
||||||
|
connector,
|
||||||
|
connector_id: int,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
start_date: str | None,
|
||||||
|
end_date: str | None,
|
||||||
|
task_logger: TaskLoggingService,
|
||||||
|
log_entry,
|
||||||
|
update_last_indexed: bool = True,
|
||||||
|
max_items: int = 2500,
|
||||||
|
) -> tuple[int, str]:
|
||||||
|
"""Index Google Calendar events via Composio."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
try:
|
||||||
|
composio_connector = ComposioConnector(session, connector_id)
|
||||||
|
|
||||||
|
await task_logger.log_task_progress(
|
||||||
|
log_entry,
|
||||||
|
f"Fetching Google Calendar events via Composio for connector {connector_id}",
|
||||||
|
{"stage": "fetching_events"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build time range
|
||||||
|
if start_date:
|
||||||
|
time_min = f"{start_date}T00:00:00Z"
|
||||||
|
else:
|
||||||
|
# Default to 365 days ago
|
||||||
|
default_start = datetime.now() - timedelta(days=365)
|
||||||
|
time_min = default_start.strftime("%Y-%m-%dT00:00:00Z")
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
time_max = f"{end_date}T23:59:59Z"
|
||||||
|
else:
|
||||||
|
time_max = datetime.now().strftime("%Y-%m-%dT23:59:59Z")
|
||||||
|
|
||||||
|
events, error = await composio_connector.list_calendar_events(
|
||||||
|
time_min=time_min,
|
||||||
|
time_max=time_max,
|
||||||
|
max_results=max_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
if error:
|
||||||
|
await task_logger.log_task_failure(
|
||||||
|
log_entry, f"Failed to fetch Calendar events: {error}", {}
|
||||||
|
)
|
||||||
|
return 0, f"Failed to fetch Calendar events: {error}"
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
success_msg = "No Google Calendar events found in the specified date range"
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry, success_msg, {"events_count": 0}
|
||||||
|
)
|
||||||
|
return 0, success_msg
|
||||||
|
|
||||||
|
logger.info(f"Found {len(events)} Google Calendar events to index via Composio")
|
||||||
|
|
||||||
|
documents_indexed = 0
|
||||||
|
documents_skipped = 0
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
try:
|
||||||
|
# Handle both standard Google API and potential Composio variations
|
||||||
|
event_id = event.get("id", "") or event.get("eventId", "")
|
||||||
|
summary = event.get("summary", "") or event.get("title", "") or "No Title"
|
||||||
|
|
||||||
|
if not event_id:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Format to markdown
|
||||||
|
markdown_content = composio_connector.format_calendar_event_to_markdown(event)
|
||||||
|
|
||||||
|
# Generate unique identifier
|
||||||
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
|
DocumentType.COMPOSIO_CONNECTOR, f"calendar_{event_id}", search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
content_hash = generate_content_hash(markdown_content, search_space_id)
|
||||||
|
|
||||||
|
existing_document = await check_document_by_unique_identifier(
|
||||||
|
session, unique_identifier_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract event times
|
||||||
|
start = event.get("start", {})
|
||||||
|
end = event.get("end", {})
|
||||||
|
start_time = start.get("dateTime") or start.get("date", "")
|
||||||
|
end_time = end.get("dateTime") or end.get("date", "")
|
||||||
|
location = event.get("location", "")
|
||||||
|
|
||||||
|
if existing_document:
|
||||||
|
if existing_document.content_hash == content_hash:
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"document_type": "Google Calendar Event (Composio)",
|
||||||
|
}
|
||||||
|
summary_content, summary_embedding = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||||
|
if location:
|
||||||
|
summary_content += f"\nLocation: {location}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
existing_document.title = f"Calendar: {summary}"
|
||||||
|
existing_document.content = summary_content
|
||||||
|
existing_document.content_hash = content_hash
|
||||||
|
existing_document.embedding = summary_embedding
|
||||||
|
existing_document.document_metadata = {
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"location": location,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"source": "composio",
|
||||||
|
}
|
||||||
|
existing_document.chunks = chunks
|
||||||
|
existing_document.updated_at = get_current_timestamp()
|
||||||
|
|
||||||
|
documents_indexed += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create new document
|
||||||
|
user_llm = await get_user_long_context_llm(
|
||||||
|
session, user_id, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
document_metadata = {
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"document_type": "Google Calendar Event (Composio)",
|
||||||
|
}
|
||||||
|
summary_content, summary_embedding = await generate_document_summary(
|
||||||
|
markdown_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
summary_content = f"Calendar: {summary}\n\nStart: {start_time}\nEnd: {end_time}"
|
||||||
|
if location:
|
||||||
|
summary_content += f"\nLocation: {location}"
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(summary_content)
|
||||||
|
|
||||||
|
chunks = await create_document_chunks(markdown_content)
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
title=f"Calendar: {summary}",
|
||||||
|
document_type=DocumentType.COMPOSIO_CONNECTOR,
|
||||||
|
document_metadata={
|
||||||
|
"event_id": event_id,
|
||||||
|
"summary": summary,
|
||||||
|
"start_time": start_time,
|
||||||
|
"end_time": end_time,
|
||||||
|
"location": location,
|
||||||
|
"connector_id": connector_id,
|
||||||
|
"toolkit_id": "googlecalendar",
|
||||||
|
"source": "composio",
|
||||||
|
},
|
||||||
|
content=summary_content,
|
||||||
|
content_hash=content_hash,
|
||||||
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
embedding=summary_embedding,
|
||||||
|
chunks=chunks,
|
||||||
|
updated_at=get_current_timestamp(),
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
documents_indexed += 1
|
||||||
|
|
||||||
|
if documents_indexed % 10 == 0:
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing Calendar event: {e!s}", exc_info=True)
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if documents_indexed > 0:
|
||||||
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await task_logger.log_task_success(
|
||||||
|
log_entry,
|
||||||
|
f"Successfully completed Google Calendar indexing via Composio for connector {connector_id}",
|
||||||
|
{
|
||||||
|
"documents_indexed": documents_indexed,
|
||||||
|
"documents_skipped": documents_skipped,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return documents_indexed, None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to index Google Calendar via Composio: {e!s}", exc_info=True)
|
||||||
|
return 0, f"Failed to index Google Calendar via Composio: {e!s}"
|
||||||
|
|
@ -26,6 +26,7 @@ Available indexers:
|
||||||
# Calendar and scheduling
|
# Calendar and scheduling
|
||||||
from .airtable_indexer import index_airtable_records
|
from .airtable_indexer import index_airtable_records
|
||||||
from .bookstack_indexer import index_bookstack_pages
|
from .bookstack_indexer import index_bookstack_pages
|
||||||
|
# Note: composio_indexer is imported directly in connector_tasks.py to avoid circular imports
|
||||||
from .clickup_indexer import index_clickup_tasks
|
from .clickup_indexer import index_clickup_tasks
|
||||||
from .confluence_indexer import index_confluence_pages
|
from .confluence_indexer import index_confluence_pages
|
||||||
from .discord_indexer import index_discord_messages
|
from .discord_indexer import index_discord_messages
|
||||||
|
|
@ -50,6 +51,7 @@ from .webcrawler_indexer import index_crawled_urls
|
||||||
__all__ = [ # noqa: RUF022
|
__all__ = [ # noqa: RUF022
|
||||||
"index_airtable_records",
|
"index_airtable_records",
|
||||||
"index_bookstack_pages",
|
"index_bookstack_pages",
|
||||||
|
# "index_composio_connector", # Imported directly in connector_tasks.py to avoid circular imports
|
||||||
"index_clickup_tasks",
|
"index_clickup_tasks",
|
||||||
"index_confluence_pages",
|
"index_confluence_pages",
|
||||||
"index_discord_messages",
|
"index_discord_messages",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
"""
|
"""
|
||||||
GitHub connector indexer.
|
GitHub connector indexer using gitingest.
|
||||||
|
|
||||||
|
This indexer processes entire repository digests in one pass, dramatically
|
||||||
|
reducing LLM API calls compared to the previous file-by-file approach.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
@ -8,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.connectors.github_connector import GitHubConnector
|
from app.connectors.github_connector import GitHubConnector, RepositoryDigest
|
||||||
from app.db import Document, DocumentType, SearchSourceConnectorType
|
from app.db import Document, DocumentType, SearchSourceConnectorType
|
||||||
from app.services.llm_service import get_user_long_context_llm
|
from app.services.llm_service import get_user_long_context_llm
|
||||||
from app.services.task_logging_service import TaskLoggingService
|
from app.services.task_logging_service import TaskLoggingService
|
||||||
|
|
@ -26,43 +29,55 @@ from .base import (
|
||||||
logger,
|
logger,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Maximum tokens for a single digest before splitting
|
||||||
|
# Most LLMs can handle 128k+ tokens now, but we'll be conservative
|
||||||
|
MAX_DIGEST_CHARS = 500_000 # ~125k tokens
|
||||||
|
|
||||||
|
|
||||||
async def index_github_repos(
|
async def index_github_repos(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
connector_id: int,
|
connector_id: int,
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
start_date: str | None = None,
|
start_date: str | None = None, # Ignored - GitHub indexes full repo snapshots
|
||||||
end_date: str | None = None,
|
end_date: str | None = None, # Ignored - GitHub indexes full repo snapshots
|
||||||
update_last_indexed: bool = True,
|
update_last_indexed: bool = True,
|
||||||
) -> tuple[int, str | None]:
|
) -> tuple[int, str | None]:
|
||||||
"""
|
"""
|
||||||
Index code and documentation files from accessible GitHub repositories.
|
Index GitHub repositories using gitingest for efficient processing.
|
||||||
|
|
||||||
|
This function ingests entire repositories as digests, generates a single
|
||||||
|
summary per repository, and chunks the content for vector storage.
|
||||||
|
|
||||||
|
Note: The start_date and end_date parameters are accepted for API compatibility
|
||||||
|
but are IGNORED. GitHub repositories are indexed as complete snapshots since
|
||||||
|
gitingest captures the current state of the entire codebase.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
connector_id: ID of the GitHub connector
|
connector_id: ID of the GitHub connector
|
||||||
search_space_id: ID of the search space to store documents in
|
search_space_id: ID of the search space to store documents in
|
||||||
user_id: ID of the user
|
user_id: ID of the user
|
||||||
start_date: Start date for filtering (YYYY-MM-DD format) - Note: GitHub indexing processes all files regardless of dates
|
start_date: Ignored - kept for API compatibility
|
||||||
end_date: End date for filtering (YYYY-MM-DD format) - Note: GitHub indexing processes all files regardless of dates
|
end_date: Ignored - kept for API compatibility
|
||||||
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
|
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple containing (number of documents indexed, error message or None)
|
Tuple containing (number of documents indexed, error message or None)
|
||||||
"""
|
"""
|
||||||
|
# Note: start_date and end_date are intentionally unused
|
||||||
|
_ = start_date, end_date
|
||||||
task_logger = TaskLoggingService(session, search_space_id)
|
task_logger = TaskLoggingService(session, search_space_id)
|
||||||
|
|
||||||
# Log task start
|
# Log task start
|
||||||
log_entry = await task_logger.log_task_start(
|
log_entry = await task_logger.log_task_start(
|
||||||
task_name="github_repos_indexing",
|
task_name="github_repos_indexing",
|
||||||
source="connector_indexing_task",
|
source="connector_indexing_task",
|
||||||
message=f"Starting GitHub repositories indexing for connector {connector_id}",
|
message=f"Starting GitHub repositories indexing for connector {connector_id} (using gitingest)",
|
||||||
metadata={
|
metadata={
|
||||||
"connector_id": connector_id,
|
"connector_id": connector_id,
|
||||||
"user_id": str(user_id),
|
"user_id": str(user_id),
|
||||||
"start_date": start_date,
|
"method": "gitingest",
|
||||||
"end_date": end_date,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -93,19 +108,11 @@ async def index_github_repos(
|
||||||
f"Connector with ID {connector_id} not found or is not a GitHub connector",
|
f"Connector with ID {connector_id} not found or is not a GitHub connector",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Get the GitHub PAT and selected repositories from the connector config
|
# 2. Get the GitHub PAT (optional) and selected repositories from the connector config
|
||||||
github_pat = connector.config.get("GITHUB_PAT")
|
# PAT is only required for private repositories - public repos work without it
|
||||||
|
github_pat = connector.config.get("GITHUB_PAT") # Can be None or empty
|
||||||
repo_full_names_to_index = connector.config.get("repo_full_names")
|
repo_full_names_to_index = connector.config.get("repo_full_names")
|
||||||
|
|
||||||
if not github_pat:
|
|
||||||
await task_logger.log_task_failure(
|
|
||||||
log_entry,
|
|
||||||
f"GitHub Personal Access Token (PAT) not found in connector config for connector {connector_id}",
|
|
||||||
"Missing GitHub PAT",
|
|
||||||
{"error_type": "MissingToken"},
|
|
||||||
)
|
|
||||||
return 0, "GitHub Personal Access Token (PAT) not found in connector config"
|
|
||||||
|
|
||||||
if not repo_full_names_to_index or not isinstance(
|
if not repo_full_names_to_index or not isinstance(
|
||||||
repo_full_names_to_index, list
|
repo_full_names_to_index, list
|
||||||
):
|
):
|
||||||
|
|
@ -117,10 +124,16 @@ async def index_github_repos(
|
||||||
)
|
)
|
||||||
return 0, "'repo_full_names' not found or is not a list in connector config"
|
return 0, "'repo_full_names' not found or is not a list in connector config"
|
||||||
|
|
||||||
# 3. Initialize GitHub connector client
|
# Log whether we're using authentication
|
||||||
|
if github_pat:
|
||||||
|
logger.info("Using GitHub PAT for authentication (private repos supported)")
|
||||||
|
else:
|
||||||
|
logger.info("No GitHub PAT provided - only public repositories can be indexed")
|
||||||
|
|
||||||
|
# 3. Initialize GitHub connector with gitingest backend
|
||||||
await task_logger.log_task_progress(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Initializing GitHub client for connector {connector_id}",
|
f"Initializing gitingest-based GitHub client for connector {connector_id}",
|
||||||
{
|
{
|
||||||
"stage": "client_initialization",
|
"stage": "client_initialization",
|
||||||
"repo_count": len(repo_full_names_to_index),
|
"repo_count": len(repo_full_names_to_index),
|
||||||
|
|
@ -138,258 +151,57 @@ async def index_github_repos(
|
||||||
)
|
)
|
||||||
return 0, f"Failed to initialize GitHub client: {e!s}"
|
return 0, f"Failed to initialize GitHub client: {e!s}"
|
||||||
|
|
||||||
# 4. Validate selected repositories
|
# 4. Process each repository with gitingest
|
||||||
await task_logger.log_task_progress(
|
await task_logger.log_task_progress(
|
||||||
log_entry,
|
log_entry,
|
||||||
f"Starting indexing for {len(repo_full_names_to_index)} selected repositories",
|
f"Starting gitingest processing for {len(repo_full_names_to_index)} repositories",
|
||||||
{
|
{
|
||||||
"stage": "repo_processing",
|
"stage": "repo_processing",
|
||||||
"repo_count": len(repo_full_names_to_index),
|
"repo_count": len(repo_full_names_to_index),
|
||||||
"start_date": start_date,
|
|
||||||
"end_date": end_date,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Starting indexing for {len(repo_full_names_to_index)} selected repositories."
|
f"Starting gitingest indexing for {len(repo_full_names_to_index)} repositories."
|
||||||
)
|
)
|
||||||
if start_date and end_date:
|
|
||||||
logger.info(
|
|
||||||
f"Date range requested: {start_date} to {end_date} (Note: GitHub indexing processes all files regardless of dates)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6. Iterate through selected repositories and index files
|
|
||||||
for repo_full_name in repo_full_names_to_index:
|
for repo_full_name in repo_full_names_to_index:
|
||||||
if not repo_full_name or not isinstance(repo_full_name, str):
|
if not repo_full_name or not isinstance(repo_full_name, str):
|
||||||
logger.warning(f"Skipping invalid repository entry: {repo_full_name}")
|
logger.warning(f"Skipping invalid repository entry: {repo_full_name}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Processing repository: {repo_full_name}")
|
logger.info(f"Ingesting repository: {repo_full_name}")
|
||||||
try:
|
|
||||||
files_to_index = github_client.get_repository_files(repo_full_name)
|
|
||||||
if not files_to_index:
|
|
||||||
logger.info(
|
|
||||||
f"No indexable files found in repository: {repo_full_name}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
logger.info(
|
try:
|
||||||
f"Found {len(files_to_index)} files to process in {repo_full_name}"
|
# Run gitingest via subprocess (isolated from event loop)
|
||||||
|
# Using to_thread to not block the async database operations
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
digest = await asyncio.to_thread(
|
||||||
|
github_client.ingest_repository, repo_full_name
|
||||||
)
|
)
|
||||||
|
|
||||||
for file_info in files_to_index:
|
if not digest:
|
||||||
file_path = file_info.get("path")
|
logger.warning(
|
||||||
file_url = file_info.get("url")
|
f"No digest returned for repository: {repo_full_name}"
|
||||||
file_sha = file_info.get("sha")
|
|
||||||
file_type = file_info.get("type") # 'code' or 'doc'
|
|
||||||
full_path_key = f"{repo_full_name}/{file_path}"
|
|
||||||
|
|
||||||
if not file_path or not file_url or not file_sha:
|
|
||||||
logger.warning(
|
|
||||||
f"Skipping file with missing info in {repo_full_name}: {file_info}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get file content
|
|
||||||
file_content = github_client.get_file_content(
|
|
||||||
repo_full_name, file_path
|
|
||||||
)
|
)
|
||||||
|
errors.append(f"No digest for {repo_full_name}")
|
||||||
|
continue
|
||||||
|
|
||||||
if file_content is None:
|
# Process the digest and create documents
|
||||||
logger.warning(
|
docs_created = await _process_repository_digest(
|
||||||
f"Could not retrieve content for {full_path_key}. Skipping."
|
session=session,
|
||||||
)
|
digest=digest,
|
||||||
continue # Skip if content fetch failed
|
search_space_id=search_space_id,
|
||||||
|
user_id=user_id,
|
||||||
|
task_logger=task_logger,
|
||||||
|
log_entry=log_entry,
|
||||||
|
)
|
||||||
|
|
||||||
# Generate unique identifier hash for this GitHub file
|
documents_processed += docs_created
|
||||||
unique_identifier_hash = generate_unique_identifier_hash(
|
logger.info(
|
||||||
DocumentType.GITHUB_CONNECTOR, file_sha, search_space_id
|
f"Created {docs_created} documents from repository: {repo_full_name}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate content hash
|
|
||||||
content_hash = generate_content_hash(file_content, search_space_id)
|
|
||||||
|
|
||||||
# Check if document with this unique identifier already exists
|
|
||||||
existing_document = await check_document_by_unique_identifier(
|
|
||||||
session, unique_identifier_hash
|
|
||||||
)
|
|
||||||
|
|
||||||
if existing_document:
|
|
||||||
# Document exists - check if content has changed
|
|
||||||
if existing_document.content_hash == content_hash:
|
|
||||||
logger.info(
|
|
||||||
f"Document for GitHub file {full_path_key} unchanged. Skipping."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Content has changed - update the existing document
|
|
||||||
logger.info(
|
|
||||||
f"Content changed for GitHub file {full_path_key}. Updating document."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate summary with metadata
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
if user_llm:
|
|
||||||
file_extension = (
|
|
||||||
file_path.split(".")[-1]
|
|
||||||
if "." in file_path
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
document_metadata = {
|
|
||||||
"file_path": full_path_key,
|
|
||||||
"repository": repo_full_name,
|
|
||||||
"file_type": file_extension or "unknown",
|
|
||||||
"document_type": "GitHub Repository File",
|
|
||||||
"connector_type": "GitHub",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
file_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
summary_content = f"GitHub file: {full_path_key}\n\n{file_content[:1000]}..."
|
|
||||||
summary_embedding = (
|
|
||||||
config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Chunk the content
|
|
||||||
try:
|
|
||||||
if hasattr(config, "code_chunker_instance"):
|
|
||||||
chunks_data = [
|
|
||||||
await create_document_chunks(file_content)
|
|
||||||
][0]
|
|
||||||
else:
|
|
||||||
chunks_data = await create_document_chunks(
|
|
||||||
file_content
|
|
||||||
)
|
|
||||||
except Exception as chunk_err:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to chunk file {full_path_key}: {chunk_err}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Update existing document
|
|
||||||
existing_document.title = f"GitHub - {full_path_key}"
|
|
||||||
existing_document.content = summary_content
|
|
||||||
existing_document.content_hash = content_hash
|
|
||||||
existing_document.embedding = summary_embedding
|
|
||||||
existing_document.document_metadata = {
|
|
||||||
"file_path": file_path,
|
|
||||||
"file_sha": file_sha,
|
|
||||||
"file_url": file_url,
|
|
||||||
"repository": repo_full_name,
|
|
||||||
"indexed_at": datetime.now(UTC).strftime(
|
|
||||||
"%Y-%m-%d %H:%M:%S"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
existing_document.chunks = chunks_data
|
|
||||||
existing_document.updated_at = get_current_timestamp()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Successfully updated GitHub file {full_path_key}"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Document doesn't exist - create new one
|
|
||||||
# Generate summary with metadata
|
|
||||||
user_llm = await get_user_long_context_llm(
|
|
||||||
session, user_id, search_space_id
|
|
||||||
)
|
|
||||||
if user_llm:
|
|
||||||
# Extract file extension from file path
|
|
||||||
file_extension = (
|
|
||||||
file_path.split(".")[-1] if "." in file_path else None
|
|
||||||
)
|
|
||||||
document_metadata = {
|
|
||||||
"file_path": full_path_key,
|
|
||||||
"repository": repo_full_name,
|
|
||||||
"file_type": file_extension or "unknown",
|
|
||||||
"document_type": "GitHub Repository File",
|
|
||||||
"connector_type": "GitHub",
|
|
||||||
}
|
|
||||||
(
|
|
||||||
summary_content,
|
|
||||||
summary_embedding,
|
|
||||||
) = await generate_document_summary(
|
|
||||||
file_content, user_llm, document_metadata
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback to simple summary if no LLM configured
|
|
||||||
summary_content = (
|
|
||||||
f"GitHub file: {full_path_key}\n\n{file_content[:1000]}..."
|
|
||||||
)
|
|
||||||
summary_embedding = config.embedding_model_instance.embed(
|
|
||||||
summary_content
|
|
||||||
)
|
|
||||||
|
|
||||||
# Chunk the content
|
|
||||||
try:
|
|
||||||
chunks_data = [await create_document_chunks(file_content)][0]
|
|
||||||
|
|
||||||
# Use code chunker if available, otherwise regular chunker
|
|
||||||
if hasattr(config, "code_chunker_instance"):
|
|
||||||
chunks_data = [
|
|
||||||
{
|
|
||||||
"content": chunk.text,
|
|
||||||
"embedding": config.embedding_model_instance.embed(
|
|
||||||
chunk.text
|
|
||||||
),
|
|
||||||
}
|
|
||||||
for chunk in config.code_chunker_instance.chunk(
|
|
||||||
file_content
|
|
||||||
)
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
chunks_data = await create_document_chunks(file_content)
|
|
||||||
|
|
||||||
except Exception as chunk_err:
|
|
||||||
logger.error(
|
|
||||||
f"Failed to chunk file {full_path_key}: {chunk_err}"
|
|
||||||
)
|
|
||||||
errors.append(
|
|
||||||
f"Chunking failed for {full_path_key}: {chunk_err}"
|
|
||||||
)
|
|
||||||
continue # Skip this file if chunking fails
|
|
||||||
|
|
||||||
doc_metadata = {
|
|
||||||
"repository_full_name": repo_full_name,
|
|
||||||
"file_path": file_path,
|
|
||||||
"full_path": full_path_key, # For easier lookup
|
|
||||||
"url": file_url,
|
|
||||||
"sha": file_sha,
|
|
||||||
"type": file_type,
|
|
||||||
"indexed_at": datetime.now(UTC).isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Create new document
|
|
||||||
logger.info(f"Creating new document for file: {full_path_key}")
|
|
||||||
document = Document(
|
|
||||||
title=f"GitHub - {file_path}",
|
|
||||||
document_type=DocumentType.GITHUB_CONNECTOR,
|
|
||||||
document_metadata=doc_metadata,
|
|
||||||
content=summary_content, # Store summary
|
|
||||||
content_hash=content_hash,
|
|
||||||
unique_identifier_hash=unique_identifier_hash,
|
|
||||||
embedding=summary_embedding,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
chunks=chunks_data, # Associate chunks directly
|
|
||||||
updated_at=get_current_timestamp(),
|
|
||||||
)
|
|
||||||
session.add(document)
|
|
||||||
documents_processed += 1
|
|
||||||
|
|
||||||
# Batch commit every 10 documents
|
|
||||||
if documents_processed % 10 == 0:
|
|
||||||
logger.info(
|
|
||||||
f"Committing batch: {documents_processed} GitHub files processed so far"
|
|
||||||
)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
except Exception as repo_err:
|
except Exception as repo_err:
|
||||||
logger.error(
|
logger.error(
|
||||||
|
|
@ -397,11 +209,11 @@ async def index_github_repos(
|
||||||
)
|
)
|
||||||
errors.append(f"Failed processing {repo_full_name}: {repo_err}")
|
errors.append(f"Failed processing {repo_full_name}: {repo_err}")
|
||||||
|
|
||||||
# Final commit for any remaining documents not yet committed in batches
|
# Final commit
|
||||||
logger.info(f"Final commit: Total {documents_processed} GitHub files processed")
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Finished GitHub indexing for connector {connector_id}. Processed {documents_processed} files."
|
f"Finished GitHub indexing for connector {connector_id}. "
|
||||||
|
f"Created {documents_processed} documents."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log success
|
# Log success
|
||||||
|
|
@ -412,6 +224,7 @@ async def index_github_repos(
|
||||||
"documents_processed": documents_processed,
|
"documents_processed": documents_processed,
|
||||||
"errors_count": len(errors),
|
"errors_count": len(errors),
|
||||||
"repo_count": len(repo_full_names_to_index),
|
"repo_count": len(repo_full_names_to_index),
|
||||||
|
"method": "gitingest",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -428,6 +241,7 @@ async def index_github_repos(
|
||||||
)
|
)
|
||||||
errors.append(f"Database error: {db_err}")
|
errors.append(f"Database error: {db_err}")
|
||||||
return documents_processed, "; ".join(errors) if errors else str(db_err)
|
return documents_processed, "; ".join(errors) if errors else str(db_err)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
await task_logger.log_task_failure(
|
await task_logger.log_task_failure(
|
||||||
|
|
@ -445,3 +259,173 @@ async def index_github_repos(
|
||||||
|
|
||||||
error_message = "; ".join(errors) if errors else None
|
error_message = "; ".join(errors) if errors else None
|
||||||
return documents_processed, error_message
|
return documents_processed, error_message
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_repository_digest(
|
||||||
|
session: AsyncSession,
|
||||||
|
digest: RepositoryDigest,
|
||||||
|
search_space_id: int,
|
||||||
|
user_id: str,
|
||||||
|
task_logger: TaskLoggingService,
|
||||||
|
log_entry,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Process a repository digest and create documents.
|
||||||
|
|
||||||
|
For each repository, we create:
|
||||||
|
1. One main document with the repository summary
|
||||||
|
2. Chunks from the full digest content for granular search
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: Database session
|
||||||
|
digest: The repository digest from gitingest
|
||||||
|
search_space_id: ID of the search space
|
||||||
|
user_id: ID of the user
|
||||||
|
task_logger: Task logging service
|
||||||
|
log_entry: Current log entry
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of documents created
|
||||||
|
"""
|
||||||
|
repo_full_name = digest.repo_full_name
|
||||||
|
documents_created = 0
|
||||||
|
|
||||||
|
# Generate unique identifier based on repo name and content hash
|
||||||
|
# This allows updates when repo content changes
|
||||||
|
full_content = digest.full_digest
|
||||||
|
content_hash = generate_content_hash(full_content, search_space_id)
|
||||||
|
|
||||||
|
# Use repo name as the unique identifier (one document per repo)
|
||||||
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
|
DocumentType.GITHUB_CONNECTOR, repo_full_name, search_space_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if document with this unique identifier already exists
|
||||||
|
existing_document = await check_document_by_unique_identifier(
|
||||||
|
session, unique_identifier_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_document:
|
||||||
|
# Document exists - check if content has changed
|
||||||
|
if existing_document.content_hash == content_hash:
|
||||||
|
logger.info(
|
||||||
|
f"Repository {repo_full_name} unchanged. Skipping."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
f"Content changed for repository {repo_full_name}. Updating document."
|
||||||
|
)
|
||||||
|
# Delete existing document to replace with new one
|
||||||
|
await session.delete(existing_document)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
# Generate summary using LLM (ONE call per repository!)
|
||||||
|
user_llm = await get_user_long_context_llm(session, user_id, search_space_id)
|
||||||
|
|
||||||
|
document_metadata = {
|
||||||
|
"repository": repo_full_name,
|
||||||
|
"document_type": "GitHub Repository",
|
||||||
|
"connector_type": "GitHub",
|
||||||
|
"ingestion_method": "gitingest",
|
||||||
|
"file_tree": digest.tree[:2000] if len(digest.tree) > 2000 else digest.tree,
|
||||||
|
"estimated_tokens": digest.estimated_tokens,
|
||||||
|
}
|
||||||
|
|
||||||
|
if user_llm:
|
||||||
|
# Prepare content for summarization
|
||||||
|
# Include tree structure and truncated content if too large
|
||||||
|
summary_content = digest.full_digest
|
||||||
|
if len(summary_content) > MAX_DIGEST_CHARS:
|
||||||
|
# Truncate but keep the tree and beginning of content
|
||||||
|
summary_content = (
|
||||||
|
f"# Repository: {repo_full_name}\n\n"
|
||||||
|
f"## File Structure\n\n{digest.tree}\n\n"
|
||||||
|
f"## File Contents (truncated)\n\n{digest.content[:MAX_DIGEST_CHARS - len(digest.tree) - 200]}..."
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_text, summary_embedding = await generate_document_summary(
|
||||||
|
summary_content, user_llm, document_metadata
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Fallback to simple summary if no LLM configured
|
||||||
|
summary_text = (
|
||||||
|
f"# GitHub Repository: {repo_full_name}\n\n"
|
||||||
|
f"## Summary\n{digest.summary}\n\n"
|
||||||
|
f"## File Structure\n{digest.tree[:3000]}"
|
||||||
|
)
|
||||||
|
summary_embedding = config.embedding_model_instance.embed(summary_text)
|
||||||
|
|
||||||
|
# Chunk the full digest content for granular search
|
||||||
|
try:
|
||||||
|
# Use the content (not the summary) for chunking
|
||||||
|
# This preserves file-level granularity in search
|
||||||
|
chunks_data = await create_document_chunks(digest.content)
|
||||||
|
except Exception as chunk_err:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to chunk repository {repo_full_name}: {chunk_err}"
|
||||||
|
)
|
||||||
|
# Fall back to a simpler chunking approach
|
||||||
|
chunks_data = await _simple_chunk_content(digest.content)
|
||||||
|
|
||||||
|
# Create the document
|
||||||
|
doc_metadata = {
|
||||||
|
"repository_full_name": repo_full_name,
|
||||||
|
"url": f"https://github.com/{repo_full_name}",
|
||||||
|
"branch": digest.branch,
|
||||||
|
"ingestion_method": "gitingest",
|
||||||
|
"file_tree": digest.tree,
|
||||||
|
"gitingest_summary": digest.summary,
|
||||||
|
"estimated_tokens": digest.estimated_tokens,
|
||||||
|
"indexed_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
document = Document(
|
||||||
|
title=f"GitHub Repository: {repo_full_name}",
|
||||||
|
document_type=DocumentType.GITHUB_CONNECTOR,
|
||||||
|
document_metadata=doc_metadata,
|
||||||
|
content=summary_text,
|
||||||
|
content_hash=content_hash,
|
||||||
|
unique_identifier_hash=unique_identifier_hash,
|
||||||
|
embedding=summary_embedding,
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
chunks=chunks_data,
|
||||||
|
updated_at=get_current_timestamp(),
|
||||||
|
)
|
||||||
|
|
||||||
|
session.add(document)
|
||||||
|
documents_created += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Created document for repository {repo_full_name} "
|
||||||
|
f"with {len(chunks_data)} chunks"
|
||||||
|
)
|
||||||
|
|
||||||
|
return documents_created
|
||||||
|
|
||||||
|
|
||||||
|
async def _simple_chunk_content(content: str, chunk_size: int = 4000) -> list:
|
||||||
|
"""
|
||||||
|
Simple fallback chunking when the regular chunker fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The content to chunk
|
||||||
|
chunk_size: Size of each chunk in characters
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of chunk dictionaries with content and embedding
|
||||||
|
"""
|
||||||
|
from app.db import Chunk
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
for i in range(0, len(content), chunk_size):
|
||||||
|
chunk_text = content[i : i + chunk_size]
|
||||||
|
if chunk_text.strip():
|
||||||
|
chunks.append(
|
||||||
|
Chunk(
|
||||||
|
content=chunk_text,
|
||||||
|
embedding=config.embedding_model_instance.embed(chunk_text),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
|
||||||
|
|
@ -530,7 +530,10 @@ def validate_connector_config(
|
||||||
# "validators": {},
|
# "validators": {},
|
||||||
# },
|
# },
|
||||||
"GITHUB_CONNECTOR": {
|
"GITHUB_CONNECTOR": {
|
||||||
"required": ["GITHUB_PAT", "repo_full_names"],
|
# GITHUB_PAT is optional - only required for private repositories
|
||||||
|
# Public repositories can be indexed without authentication
|
||||||
|
"required": ["repo_full_names"],
|
||||||
|
"optional": ["GITHUB_PAT"], # Optional - only needed for private repos
|
||||||
"validators": {
|
"validators": {
|
||||||
"repo_full_names": lambda: validate_list_field(
|
"repo_full_names": lambda: validate_list_field(
|
||||||
"repo_full_names", "repo_full_names"
|
"repo_full_names", "repo_full_names"
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,8 @@ dependencies = [
|
||||||
"mcp>=1.25.0",
|
"mcp>=1.25.0",
|
||||||
"starlette>=0.40.0,<0.51.0",
|
"starlette>=0.40.0,<0.51.0",
|
||||||
"sse-starlette>=3.1.1,<3.1.2",
|
"sse-starlette>=3.1.1,<3.1.2",
|
||||||
|
"gitingest>=0.3.1",
|
||||||
|
"composio>=0.10.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|
|
||||||
6110
surfsense_backend/uv.lock
generated
6110
surfsense_backend/uv.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,7 +16,6 @@ import {
|
||||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
|
||||||
import { LayoutDataProvider } from "@/components/layout";
|
import { LayoutDataProvider } from "@/components/layout";
|
||||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
@ -197,11 +196,7 @@ export function DashboardClientLayout({
|
||||||
return (
|
return (
|
||||||
<DocumentUploadDialogProvider>
|
<DocumentUploadDialogProvider>
|
||||||
<OnboardingTour />
|
<OnboardingTour />
|
||||||
<LayoutDataProvider
|
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
breadcrumb={<DashboardBreadcrumb />}
|
|
||||||
languageSwitcher={<LanguageSwitcher />}
|
|
||||||
>
|
|
||||||
{children}
|
{children}
|
||||||
</LayoutDataProvider>
|
</LayoutDataProvider>
|
||||||
</DocumentUploadDialogProvider>
|
</DocumentUploadDialogProvider>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
// extractWriteTodosFromContent,
|
// extractWriteTodosFromContent,
|
||||||
hydratePlanStateAtom,
|
hydratePlanStateAtom,
|
||||||
} from "@/atoms/chat/plan-state.atom";
|
} from "@/atoms/chat/plan-state.atom";
|
||||||
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { ChatHeader } from "@/components/new-chat/chat-header";
|
import { ChatHeader } from "@/components/new-chat/chat-header";
|
||||||
|
|
@ -32,6 +33,7 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||||
|
import { SaveMemoryToolUI, RecallMemoryToolUI } from "@/components/tool-ui/user-memory";
|
||||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
||||||
|
|
@ -49,6 +51,8 @@ import {
|
||||||
type MessageRecord,
|
type MessageRecord,
|
||||||
type ThreadRecord,
|
type ThreadRecord,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
|
import { useChatSessionStateSync } from "@/hooks/use-chat-session-state";
|
||||||
|
import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||||
import {
|
import {
|
||||||
trackChatCreated,
|
trackChatCreated,
|
||||||
trackChatError,
|
trackChatError,
|
||||||
|
|
@ -257,6 +261,44 @@ export default function NewChatPage() {
|
||||||
// Get current user for author info in shared chats
|
// Get current user for author info in shared chats
|
||||||
const { data: currentUser } = useAtomValue(currentUserAtom);
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
|
||||||
|
// Live collaboration: sync session state and messages via Electric SQL
|
||||||
|
useChatSessionStateSync(threadId);
|
||||||
|
const { data: membersData } = useAtomValue(membersAtom);
|
||||||
|
|
||||||
|
const handleElectricMessagesUpdate = useCallback(
|
||||||
|
(electricMessages: { id: number; thread_id: number; role: string; content: unknown; author_id: string | null; created_at: string }[]) => {
|
||||||
|
if (isRunning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (electricMessages.length < prev.length) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return electricMessages.map((msg) => {
|
||||||
|
const member = msg.author_id
|
||||||
|
? membersData?.find((m) => m.user_id === msg.author_id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return convertToThreadMessage({
|
||||||
|
id: msg.id,
|
||||||
|
thread_id: msg.thread_id,
|
||||||
|
role: msg.role.toLowerCase() as "user" | "assistant" | "system",
|
||||||
|
content: msg.content,
|
||||||
|
author_id: msg.author_id,
|
||||||
|
created_at: msg.created_at,
|
||||||
|
author_display_name: member?.user_display_name ?? null,
|
||||||
|
author_avatar_url: member?.user_avatar_url ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[isRunning, membersData]
|
||||||
|
);
|
||||||
|
|
||||||
|
useMessagesElectric(threadId, handleElectricMessagesUpdate);
|
||||||
|
|
||||||
// Create the attachment adapter for file processing
|
// Create the attachment adapter for file processing
|
||||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
||||||
|
|
||||||
|
|
@ -367,7 +409,7 @@ export default function NewChatPage() {
|
||||||
initializeThread();
|
initializeThread();
|
||||||
}, [initializeThread]);
|
}, [initializeThread]);
|
||||||
|
|
||||||
// Handle scroll to comment from URL query params (e.g., from notification click)
|
// Handle scroll to comment from URL query params (e.g., from inbox item click)
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const targetCommentId = searchParams.get("commentId");
|
const targetCommentId = searchParams.get("commentId");
|
||||||
|
|
||||||
|
|
@ -586,8 +628,6 @@ export default function NewChatPage() {
|
||||||
content: persistContent,
|
content: persistContent,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
// For new threads, the backend updates the title from the first user message
|
|
||||||
// Invalidate threads query so sidebar shows the updated title in real-time
|
|
||||||
if (isNewThread) {
|
if (isNewThread) {
|
||||||
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
queryClient.invalidateQueries({ queryKey: ["threads", String(searchSpaceId)] });
|
||||||
}
|
}
|
||||||
|
|
@ -1056,17 +1096,13 @@ export default function NewChatPage() {
|
||||||
<LinkPreviewToolUI />
|
<LinkPreviewToolUI />
|
||||||
<DisplayImageToolUI />
|
<DisplayImageToolUI />
|
||||||
<ScrapeWebpageToolUI />
|
<ScrapeWebpageToolUI />
|
||||||
|
<SaveMemoryToolUI />
|
||||||
|
<RecallMemoryToolUI />
|
||||||
{/* <WriteTodosToolUI /> Disabled for now */}
|
{/* <WriteTodosToolUI /> Disabled for now */}
|
||||||
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
|
||||||
<Thread
|
<Thread
|
||||||
messageThinkingSteps={messageThinkingSteps}
|
messageThinkingSteps={messageThinkingSteps}
|
||||||
header={
|
header={<ChatHeader searchSpaceId={searchSpaceId} />}
|
||||||
<ChatHeader
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
thread={currentThread}
|
|
||||||
onThreadVisibilityChange={handleVisibilityChange}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
|
|
|
||||||
|
|
@ -3,29 +3,38 @@
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import {
|
import {
|
||||||
|
Bot,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
Clock,
|
Clock,
|
||||||
Copy,
|
Copy,
|
||||||
Crown,
|
Crown,
|
||||||
Edit2,
|
Edit2,
|
||||||
|
FileText,
|
||||||
Hash,
|
Hash,
|
||||||
Link2,
|
Link2,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Logs,
|
||||||
|
type LucideIcon,
|
||||||
|
MessageCircle,
|
||||||
|
MessageSquare,
|
||||||
|
Mic,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
Plug,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
User,
|
|
||||||
UserMinus,
|
UserMinus,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Users,
|
Users,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
import Image from "next/image";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
@ -512,6 +521,25 @@ export default function TeamManagementPage() {
|
||||||
|
|
||||||
// ============ Members Tab ============
|
// ============ Members Tab ============
|
||||||
|
|
||||||
|
// Helper function to get avatar initials
|
||||||
|
function getAvatarInitials(member: Membership): string {
|
||||||
|
// Try display name first
|
||||||
|
if (member.user_display_name) {
|
||||||
|
const parts = member.user_display_name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return member.user_display_name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
// Try email
|
||||||
|
if (member.user_email) {
|
||||||
|
const emailName = member.user_email.split("@")[0];
|
||||||
|
return emailName.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
// Fallback
|
||||||
|
return "U";
|
||||||
|
}
|
||||||
|
|
||||||
function MembersTab({
|
function MembersTab({
|
||||||
members,
|
members,
|
||||||
roles,
|
roles,
|
||||||
|
|
@ -560,7 +588,7 @@ function MembersTab({
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search members..."
|
placeholder="Search members"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="pl-9"
|
className="pl-9"
|
||||||
|
|
@ -573,10 +601,30 @@ function MembersTab({
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-muted/50">
|
<TableRow className="bg-muted/50">
|
||||||
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">Member</TableHead>
|
<TableHead className="w-auto md:w-[300px] px-2 md:px-4">
|
||||||
<TableHead className="px-2 md:px-4">Role</TableHead>
|
<div className="flex items-center gap-2">
|
||||||
<TableHead className="hidden md:table-cell">Joined</TableHead>
|
<Users className="h-4 w-4" />
|
||||||
<TableHead className="text-right">Actions</TableHead>
|
Member
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="px-2 md:px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="h-4 w-4" />
|
||||||
|
Role
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
Joined
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -601,19 +649,36 @@ function MembersTab({
|
||||||
<TableCell className="py-2 px-2 md:py-4 md:px-4 align-middle">
|
<TableCell className="py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||||
<div className="flex items-center gap-1.5 md:gap-3">
|
<div className="flex items-center gap-1.5 md:gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center ring-2 ring-background">
|
{member.user_avatar_url ? (
|
||||||
<User className="h-4 w-4 md:h-5 md:w-5 text-primary" />
|
<Image
|
||||||
</div>
|
src={member.user_avatar_url}
|
||||||
|
alt={member.user_display_name || member.user_email || "User"}
|
||||||
|
width={40}
|
||||||
|
height={40}
|
||||||
|
className="h-8 w-8 md:h-10 md:w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 md:h-10 md:w-10 rounded-full bg-gradient-to-br from-primary/20 to-primary/5 flex items-center justify-center">
|
||||||
|
<span className="text-xs md:text-sm font-medium text-primary">
|
||||||
|
{getAvatarInitials(member)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{member.is_owner && (
|
{member.is_owner && (
|
||||||
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center ring-2 ring-background">
|
<div className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-amber-500 flex items-center justify-center">
|
||||||
<Crown className="h-3 w-3 text-white" />
|
<Crown className="h-3 w-3 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-xs md:text-sm truncate">
|
<p className="font-medium text-xs md:text-sm truncate">
|
||||||
{member.user_email || "Unknown"}
|
{member.user_display_name || member.user_email || "Unknown"}
|
||||||
</p>
|
</p>
|
||||||
|
{member.user_display_name && member.user_email && (
|
||||||
|
<p className="text-[10px] md:text-xs text-muted-foreground truncate">
|
||||||
|
{member.user_email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{member.is_owner && (
|
{member.is_owner && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -640,29 +705,21 @@ function MembersTab({
|
||||||
<SelectItem value="none">No role</SelectItem>
|
<SelectItem value="none">No role</SelectItem>
|
||||||
{roles.map((role) => (
|
{roles.map((role) => (
|
||||||
<SelectItem key={role.id} value={role.id.toString()}>
|
<SelectItem key={role.id} value={role.id.toString()}>
|
||||||
<div className="flex items-center gap-2">
|
{role.name}
|
||||||
<Shield className="h-3 w-3" />
|
|
||||||
{role.name}
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Badge
|
<Badge variant="secondary" className="text-[10px] md:text-xs py-0 md:py-0.5">
|
||||||
variant="secondary"
|
|
||||||
className="gap-1 text-[10px] md:text-xs py-0 md:py-0.5"
|
|
||||||
>
|
|
||||||
<Shield className="h-2.5 w-2.5 md:h-3 md:w-3" />
|
|
||||||
{member.role?.name || "No role"}
|
{member.role?.name || "No role"}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="hidden md:table-cell">
|
<TableCell className="hidden md:table-cell">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
<Calendar className="h-4 w-4" />
|
|
||||||
{new Date(member.joined_at).toLocaleDateString()}
|
{new Date(member.joined_at).toLocaleDateString()}
|
||||||
</div>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right py-2 px-2 md:py-4 md:px-4 align-middle">
|
<TableCell className="text-right py-2 px-2 md:py-4 md:px-4 align-middle">
|
||||||
{canRemove && !member.is_owner && (
|
{canRemove && !member.is_owner && (
|
||||||
|
|
@ -708,13 +765,137 @@ function MembersTab({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Role Permissions Display ============
|
||||||
|
|
||||||
|
const CATEGORY_CONFIG: Record<string, { label: string; icon: LucideIcon; order: number }> = {
|
||||||
|
documents: { label: "Documents", icon: FileText, order: 1 },
|
||||||
|
chats: { label: "Chats", icon: MessageSquare, order: 2 },
|
||||||
|
comments: { label: "Comments", icon: MessageCircle, order: 3 },
|
||||||
|
llm_configs: { label: "LLM Configs", icon: Bot, order: 4 },
|
||||||
|
podcasts: { label: "Podcasts", icon: Mic, order: 5 },
|
||||||
|
connectors: { label: "Connectors", icon: Plug, order: 6 },
|
||||||
|
logs: { label: "Logs", icon: Logs, order: 7 },
|
||||||
|
members: { label: "Members", icon: Users, order: 8 },
|
||||||
|
roles: { label: "Roles", icon: Shield, order: 9 },
|
||||||
|
settings: { label: "Settings", icon: Settings, order: 10 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
create: "Create",
|
||||||
|
read: "Read",
|
||||||
|
update: "Update",
|
||||||
|
delete: "Delete",
|
||||||
|
invite: "Invite",
|
||||||
|
view: "View",
|
||||||
|
remove: "Remove",
|
||||||
|
manage_roles: "Manage Roles",
|
||||||
|
};
|
||||||
|
|
||||||
|
function RolePermissionsDisplay({ permissions }: { permissions: string[] }) {
|
||||||
|
if (permissions.includes("*")) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3 p-3 rounded-lg bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center shrink-0">
|
||||||
|
<Crown className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold">Full Access</p>
|
||||||
|
<p className="text-xs text-muted-foreground">All permissions granted</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group permissions by category
|
||||||
|
const grouped: Record<string, string[]> = {};
|
||||||
|
for (const perm of permissions) {
|
||||||
|
const [category, action] = perm.split(":");
|
||||||
|
if (!grouped[category]) grouped[category] = [];
|
||||||
|
grouped[category].push(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort categories by predefined order
|
||||||
|
const sortedCategories = Object.keys(grouped).sort((a, b) => {
|
||||||
|
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
|
||||||
|
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
|
||||||
|
return orderA - orderB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryCount = sortedCategories.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-lg border border-border/50 bg-muted/30 hover:bg-muted/50 transition-colors cursor-pointer text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||||
|
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">{permissions.length} Permissions</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Across {categoryCount} {categoryCount === 1 ? "category" : "categories"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">View details</div>
|
||||||
|
</button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
|
||||||
|
<DialogHeader className="p-4 md:p-5 border-b">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||||
|
Role Permissions
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs">
|
||||||
|
{permissions.length} permissions across {categoryCount} categories
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<ScrollArea className="max-h-[55vh]">
|
||||||
|
<div className="divide-y divide-border/50">
|
||||||
|
{sortedCategories.map((category) => {
|
||||||
|
const actions = grouped[category];
|
||||||
|
const config = CATEGORY_CONFIG[category] || { label: category, icon: FileText };
|
||||||
|
const IconComponent = config.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={category}
|
||||||
|
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">{config.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-1">
|
||||||
|
{actions.map((action) => (
|
||||||
|
<span
|
||||||
|
key={action}
|
||||||
|
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] font-medium"
|
||||||
|
>
|
||||||
|
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Roles Tab ============
|
// ============ Roles Tab ============
|
||||||
|
|
||||||
function RolesTab({
|
function RolesTab({
|
||||||
roles,
|
roles,
|
||||||
groupedPermissions,
|
groupedPermissions: _groupedPermissions,
|
||||||
loading,
|
loading,
|
||||||
onUpdateRole,
|
onUpdateRole: _onUpdateRole,
|
||||||
onDeleteRole,
|
onDeleteRole,
|
||||||
canUpdate,
|
canUpdate,
|
||||||
canDelete,
|
canDelete,
|
||||||
|
|
@ -778,8 +959,7 @@ function RolesTab({
|
||||||
role.name === "Owner" && "text-amber-600",
|
role.name === "Owner" && "text-amber-600",
|
||||||
role.name === "Editor" && "text-blue-600",
|
role.name === "Editor" && "text-blue-600",
|
||||||
role.name === "Viewer" && "text-gray-600",
|
role.name === "Viewer" && "text-gray-600",
|
||||||
!["Owner", "Editor", "Viewer"].includes(role.name) &&
|
!["Owner", "Editor", "Viewer"].includes(role.name) && "text-primary"
|
||||||
"text-primary"
|
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -853,32 +1033,7 @@ function RolesTab({
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-2">
|
<RolePermissionsDisplay permissions={role.permissions} />
|
||||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
|
||||||
Permissions ({role.permissions.includes("*") ? "All" : role.permissions.length})
|
|
||||||
</Label>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{role.permissions.includes("*") ? (
|
|
||||||
<Badge
|
|
||||||
variant="default"
|
|
||||||
className="bg-gradient-to-r from-amber-500 to-orange-500"
|
|
||||||
>
|
|
||||||
Full Access
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
role.permissions.slice(0, 5).map((perm) => (
|
|
||||||
<Badge key={perm} variant="secondary" className="text-xs">
|
|
||||||
{perm.replace(":", " ")}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
{!role.permissions.includes("*") && role.permissions.length > 5 && (
|
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
+{role.permissions.length - 5} more
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -1488,7 +1643,8 @@ function CreateRoleDialog({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only) permissions
|
Use presets to quickly apply Editor (create/read/update) or Viewer (read-only)
|
||||||
|
permissions
|
||||||
</p>
|
</p>
|
||||||
<ScrollArea className="h-64 rounded-lg border p-4">
|
<ScrollArea className="h-64 rounded-lg border p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -1500,8 +1656,10 @@ function CreateRoleDialog({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={category} className="space-y-2">
|
<div key={category} className="space-y-2">
|
||||||
<label
|
<button
|
||||||
|
type="button"
|
||||||
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
|
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
|
||||||
|
onClick={() => toggleCategory(category)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelected}
|
checked={allSelected}
|
||||||
|
|
@ -1510,19 +1668,21 @@ function CreateRoleDialog({
|
||||||
<span className="text-sm font-medium capitalize">
|
<span className="text-sm font-medium capitalize">
|
||||||
{category} ({categorySelected}/{perms.length})
|
{category} ({categorySelected}/{perms.length})
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</button>
|
||||||
<div className="grid grid-cols-2 gap-2 ml-6">
|
<div className="grid grid-cols-2 gap-2 ml-6">
|
||||||
{perms.map((perm) => (
|
{perms.map((perm) => (
|
||||||
<label
|
<button
|
||||||
|
type="button"
|
||||||
key={perm.value}
|
key={perm.value}
|
||||||
className="flex items-center gap-2 cursor-pointer text-left"
|
className="flex items-center gap-2 cursor-pointer text-left"
|
||||||
|
onClick={() => togglePermission(perm.value)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={selectedPermissions.includes(perm.value)}
|
checked={selectedPermissions.includes(perm.value)}
|
||||||
onCheckedChange={() => togglePermission(perm.value)}
|
onCheckedChange={() => togglePermission(perm.value)}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">{perm.value.split(":")[1]}</span>
|
<span className="text-xs">{perm.value.split(":")[1]}</span>
|
||||||
</label>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,8 @@
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
|
|
||||||
15
surfsense_web/atoms/chat/chat-session-state.atom.ts
Normal file
15
surfsense_web/atoms/chat/chat-session-state.atom.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export interface ChatSessionStateData {
|
||||||
|
threadId: number;
|
||||||
|
isAiResponding: boolean;
|
||||||
|
respondingToUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global chat session state atom.
|
||||||
|
* Updated by useChatSessionStateSync hook, read anywhere.
|
||||||
|
*/
|
||||||
|
export const chatSessionStateAtom = atom<ChatSessionStateData | null>(null);
|
||||||
|
|
@ -47,6 +47,11 @@ export const addingCommentToMessageIdAtom = atom(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Setter atom for updating thread visibility
|
||||||
|
export const setThreadVisibilityAtom = atom(null, (get, set, newVisibility: ChatVisibility) => {
|
||||||
|
set(currentThreadAtom, { ...get(currentThreadAtom), visibility: newVisibility });
|
||||||
|
});
|
||||||
|
|
||||||
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
export const resetCurrentThreadAtom = atom(null, (_, set) => {
|
||||||
set(currentThreadAtom, initialState);
|
set(currentThreadAtom, initialState);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ export const membersAtom = atomWithQuery((get) => {
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!searchSpaceId) {
|
if (!searchSpaceId) {
|
||||||
return [];
|
return [];
|
||||||
|
|
|
||||||
|
|
@ -191,7 +191,7 @@ export const AssistantMessage: FC = () => {
|
||||||
|
|
||||||
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
|
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
|
||||||
{showCommentTrigger && !isDesktop && (
|
{showCommentTrigger && !isDesktop && (
|
||||||
<div className="mt-2 flex justify-start">
|
<div className="ml-2 mt-1 flex justify-start">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCommentTriggerClick}
|
onClick={handleCommentTriggerClick}
|
||||||
|
|
@ -234,7 +234,7 @@ const AssistantActionBar: FC = () => {
|
||||||
hideWhenRunning
|
hideWhenRunning
|
||||||
autohide="not-last"
|
autohide="not-last"
|
||||||
autohideFloat="single-branch"
|
autohideFloat="single-branch"
|
||||||
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground data-floating:absolute data-floating:rounded-md data-floating:border data-floating:bg-background data-floating:p-1 data-floating:shadow-sm"
|
className="aui-assistant-action-bar-root -ml-1 col-start-3 row-start-2 flex gap-1 text-muted-foreground md:data-floating:absolute md:data-floating:rounded-md md:data-floating:border md:data-floating:bg-background md:data-floating:p-1 md:data-floating:shadow-sm [&>button]:opacity-100 md:[&>button]:opacity-[var(--aui-button-opacity,1)]"
|
||||||
>
|
>
|
||||||
<ActionBarPrimitive.Copy asChild>
|
<ActionBarPrimitive.Copy asChild>
|
||||||
<TooltipIconButton tooltip="Copy">
|
<TooltipIconButton tooltip="Copy">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ChatSessionStatusProps {
|
||||||
|
isAiResponding: boolean;
|
||||||
|
respondingToUserId: string | null;
|
||||||
|
currentUserId: string | null;
|
||||||
|
members: Array<{
|
||||||
|
user_id: string;
|
||||||
|
user_display_name?: string | null;
|
||||||
|
user_email?: string | null;
|
||||||
|
}>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatSessionStatus: FC<ChatSessionStatusProps> = ({
|
||||||
|
isAiResponding,
|
||||||
|
respondingToUserId,
|
||||||
|
currentUserId,
|
||||||
|
members,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
if (!isAiResponding || !respondingToUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (respondingToUserId === currentUserId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const respondingUser = members.find((m) => m.user_id === respondingToUserId);
|
||||||
|
const displayName = respondingUser?.user_display_name || respondingUser?.user_email || "another user";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 px-3 py-2 text-sm text-muted-foreground bg-muted/50 rounded-lg",
|
||||||
|
"animate-in fade-in slide-in-from-bottom-2 duration-300 ease-out",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
<span>Currently responding to {displayName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -21,6 +21,7 @@ import { useConnectorDialog } from "./connector-popup/hooks/use-connector-dialog
|
||||||
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
import { useIndexingConnectors } from "./connector-popup/hooks/use-indexing-connectors";
|
||||||
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
import { ActiveConnectorsTab } from "./connector-popup/tabs/active-connectors-tab";
|
||||||
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
|
||||||
|
import { ComposioToolkitView } from "./connector-popup/views/composio-toolkit-view";
|
||||||
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
|
||||||
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
|
||||||
|
|
||||||
|
|
@ -87,6 +88,12 @@ export const ConnectorIndicator: FC = () => {
|
||||||
setConnectorConfig,
|
setConnectorConfig,
|
||||||
setIndexingConnectorConfig,
|
setIndexingConnectorConfig,
|
||||||
setConnectorName,
|
setConnectorName,
|
||||||
|
// Composio
|
||||||
|
viewingComposio,
|
||||||
|
connectingComposioToolkit,
|
||||||
|
handleOpenComposio,
|
||||||
|
handleBackFromComposio,
|
||||||
|
handleConnectComposioToolkit,
|
||||||
} = useConnectorDialog();
|
} = useConnectorDialog();
|
||||||
|
|
||||||
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
// Fetch connectors using Electric SQL + PGlite for real-time updates
|
||||||
|
|
@ -176,6 +183,20 @@ export const ConnectorIndicator: FC = () => {
|
||||||
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
{/* YouTube Crawler View - shown when adding YouTube videos */}
|
||||||
{isYouTubeView && searchSpaceId ? (
|
{isYouTubeView && searchSpaceId ? (
|
||||||
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
<YouTubeCrawlerView searchSpaceId={searchSpaceId} onBack={handleBackFromYouTube} />
|
||||||
|
) : viewingComposio && searchSpaceId ? (
|
||||||
|
<ComposioToolkitView
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
connectedToolkits={
|
||||||
|
(connectors || [])
|
||||||
|
.filter((c: SearchSourceConnector) => c.connector_type === "COMPOSIO_CONNECTOR")
|
||||||
|
.map((c: SearchSourceConnector) => c.config?.toolkit_id as string)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
onBack={handleBackFromComposio}
|
||||||
|
onConnectToolkit={handleConnectComposioToolkit}
|
||||||
|
isConnecting={connectingComposioToolkit !== null}
|
||||||
|
connectingToolkitId={connectingComposioToolkit}
|
||||||
|
/>
|
||||||
) : viewingMCPList ? (
|
) : viewingMCPList ? (
|
||||||
<ConnectorAccountsListView
|
<ConnectorAccountsListView
|
||||||
connectorType="MCP_CONNECTOR"
|
connectorType="MCP_CONNECTOR"
|
||||||
|
|
@ -312,6 +333,7 @@ export const ConnectorIndicator: FC = () => {
|
||||||
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
onCreateYouTubeCrawler={handleCreateYouTubeCrawler}
|
||||||
onManage={handleStartEdit}
|
onManage={handleStartEdit}
|
||||||
onViewAccountsList={handleViewAccountsList}
|
onViewAccountsList={handleViewAccountsList}
|
||||||
|
onOpenComposio={handleOpenComposio}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Zap } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ComposioConnectorCardProps {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
connectorCount?: number;
|
||||||
|
onConnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ComposioConnectorCard: FC<ComposioConnectorCardProps> = ({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
connectorCount = 0,
|
||||||
|
onConnect,
|
||||||
|
}) => {
|
||||||
|
const hasConnections = connectorCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
|
||||||
|
"border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5",
|
||||||
|
"hover:border-violet-500/40 hover:from-violet-500/10 hover:to-purple-500/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
|
||||||
|
"bg-gradient-to-br from-violet-500/10 to-purple-500/10 border-violet-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src="/connectors/composio.svg"
|
||||||
|
alt="Composio"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
|
||||||
|
<Zap className="size-3.5 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
{hasConnections ? (
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1 flex items-center gap-1.5">
|
||||||
|
<span>
|
||||||
|
{connectorCount} {connectorCount === 1 ? "connection" : "connections"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-muted-foreground mt-1">{description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={hasConnections ? "secondary" : "default"}
|
||||||
|
className={cn(
|
||||||
|
"h-8 text-[11px] px-3 rounded-lg shrink-0 font-medium shadow-xs",
|
||||||
|
!hasConnections && "bg-violet-600 hover:bg-violet-700 text-white",
|
||||||
|
hasConnections &&
|
||||||
|
"bg-white text-slate-700 hover:bg-slate-50 border-0 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/80"
|
||||||
|
)}
|
||||||
|
onClick={onConnect}
|
||||||
|
>
|
||||||
|
{hasConnections ? "Manage" : "Browse"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -24,11 +24,6 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"status": "warning",
|
"status": "warning",
|
||||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||||
},
|
|
||||||
"GITHUB_CONNECTOR": {
|
|
||||||
"enabled": false,
|
|
||||||
"status": "maintenance",
|
|
||||||
"statusMessage": "Rework in progress."
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"globalSettings": {
|
"globalSettings": {
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Info } from "lucide-react";
|
import { ExternalLink, Info } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion";
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -34,8 +29,6 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import { DateRangeSelector } from "../../components/date-range-selector";
|
|
||||||
import { getConnectorBenefits } from "../connector-benefits";
|
|
||||||
import type { ConnectFormProps } from "../index";
|
import type { ConnectFormProps } from "../index";
|
||||||
|
|
||||||
const githubConnectorFormSchema = z.object({
|
const githubConnectorFormSchema = z.object({
|
||||||
|
|
@ -44,10 +37,8 @@ const githubConnectorFormSchema = z.object({
|
||||||
}),
|
}),
|
||||||
github_pat: z
|
github_pat: z
|
||||||
.string()
|
.string()
|
||||||
.min(20, {
|
.optional()
|
||||||
message: "GitHub Personal Access Token seems too short.",
|
.refine((pat) => !pat || pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
||||||
})
|
|
||||||
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
|
|
||||||
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
|
||||||
}),
|
}),
|
||||||
repo_full_names: z.string().min(1, {
|
repo_full_names: z.string().min(1, {
|
||||||
|
|
@ -59,8 +50,6 @@ type GithubConnectorFormValues = z.infer<typeof githubConnectorFormSchema>;
|
||||||
|
|
||||||
export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
|
export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
|
||||||
const isSubmittingRef = useRef(false);
|
const isSubmittingRef = useRef(false);
|
||||||
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
|
|
||||||
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
|
|
||||||
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
const [periodicEnabled, setPeriodicEnabled] = useState(false);
|
||||||
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
|
||||||
const form = useForm<GithubConnectorFormValues>({
|
const form = useForm<GithubConnectorFormValues>({
|
||||||
|
|
@ -94,16 +83,18 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
name: values.name,
|
name: values.name,
|
||||||
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
|
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
|
||||||
config: {
|
config: {
|
||||||
GITHUB_PAT: values.github_pat,
|
GITHUB_PAT: values.github_pat || null, // Optional - only for private repos
|
||||||
repo_full_names: repoList,
|
repo_full_names: repoList,
|
||||||
},
|
},
|
||||||
is_indexable: true,
|
is_indexable: true,
|
||||||
|
is_active: true,
|
||||||
last_indexed_at: null,
|
last_indexed_at: null,
|
||||||
periodic_indexing_enabled: periodicEnabled,
|
periodic_indexing_enabled: periodicEnabled,
|
||||||
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
|
||||||
next_scheduled_at: null,
|
next_scheduled_at: null,
|
||||||
startDate,
|
// GitHub indexes full repo snapshots - no date range needed
|
||||||
endDate,
|
startDate: undefined,
|
||||||
|
endDate: undefined,
|
||||||
periodicEnabled,
|
periodicEnabled,
|
||||||
frequencyMinutes,
|
frequencyMinutes,
|
||||||
});
|
});
|
||||||
|
|
@ -117,18 +108,19 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 p-2 sm:p-3 flex items-center [&>svg]:relative [&>svg]:left-0 [&>svg]:top-0 [&>svg+div]:translate-y-0">
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 ml-1" />
|
||||||
<div className="-ml-1">
|
<div className="-ml-1">
|
||||||
<AlertTitle className="text-xs sm:text-sm">Personal Access Token Required</AlertTitle>
|
<AlertTitle className="text-xs sm:text-sm">Personal Access Token (Optional)</AlertTitle>
|
||||||
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
<AlertDescription className="text-[10px] sm:text-xs !pl-0">
|
||||||
You'll need a GitHub Personal Access Token to use this connector. You can create one
|
A GitHub PAT is only required for private repositories. Public repos work without a
|
||||||
from{" "}
|
token.{" "}
|
||||||
<a
|
<a
|
||||||
href="https://github.com/settings/tokens"
|
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium underline underline-offset-4"
|
className="font-medium underline underline-offset-4 inline-flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
GitHub Settings
|
Get your token
|
||||||
</a>
|
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
</a>{" "}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
@ -167,7 +159,10 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
name="github_pat"
|
name="github_pat"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel className="text-xs sm:text-sm">GitHub Personal Access Token</FormLabel>
|
<FormLabel className="text-xs sm:text-sm">
|
||||||
|
GitHub Personal Access Token{" "}
|
||||||
|
<span className="text-muted-foreground font-normal">(optional)</span>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
|
@ -178,8 +173,8 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription className="text-[10px] sm:text-xs">
|
<FormDescription className="text-[10px] sm:text-xs">
|
||||||
Your GitHub PAT will be encrypted and stored securely. It typically starts with
|
Only required for private repositories. Leave empty if indexing public repos
|
||||||
"ghp_" or "github_pat_".
|
only.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
@ -225,15 +220,9 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
|
|
||||||
{/* Indexing Configuration */}
|
{/* Indexing Configuration */}
|
||||||
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
<div className="space-y-4 pt-4 border-t border-slate-400/20">
|
||||||
<h3 className="text-sm sm:text-base font-medium">Indexing Configuration</h3>
|
<h3 className="text-sm sm:text-base font-medium">Sync Configuration</h3>
|
||||||
|
|
||||||
{/* Date Range Selector */}
|
{/* Note: No date range for GitHub - it indexes full repo snapshots */}
|
||||||
<DateRangeSelector
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
onStartDateChange={setStartDate}
|
|
||||||
onEndDateChange={setEndDate}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Periodic Sync Config */}
|
{/* Periodic Sync Config */}
|
||||||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||||
|
|
@ -301,169 +290,18 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What you get section */}
|
{/* Documentation Link */}
|
||||||
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR) && (
|
<div>
|
||||||
<div className="rounded-xl border border-border bg-slate-400/5 dark:bg-white/5 px-3 sm:px-6 py-4 space-y-2">
|
<Link
|
||||||
<h4 className="text-xs sm:text-sm font-medium">What you get with GitHub integration:</h4>
|
href="/docs/connectors/github"
|
||||||
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
target="_blank"
|
||||||
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR)?.map((benefit) => (
|
rel="noopener noreferrer"
|
||||||
<li key={benefit}>{benefit}</li>
|
className="text-xs sm:text-sm font-medium underline underline-offset-4 hover:text-primary transition-colors inline-flex items-center gap-1.5"
|
||||||
))}
|
>
|
||||||
</ul>
|
View GitHub Connector Documentation
|
||||||
</div>
|
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
)}
|
</Link>
|
||||||
|
</div>
|
||||||
{/* Documentation Section */}
|
|
||||||
<Accordion
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
|
|
||||||
>
|
|
||||||
<AccordionItem value="documentation" className="border-0">
|
|
||||||
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
|
|
||||||
Documentation
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
|
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground">
|
|
||||||
The GitHub connector uses a Personal Access Token (PAT) to authenticate with the
|
|
||||||
GitHub API. You provide a comma-separated list of repository full names (e.g.,
|
|
||||||
"owner/repo1, owner/repo2") that you want to index. The connector indexes relevant
|
|
||||||
files (code, markdown, text) from the selected repositories.
|
|
||||||
</p>
|
|
||||||
<ul className="mt-2 list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
|
|
||||||
<li>
|
|
||||||
The connector indexes files based on common code and documentation extensions.
|
|
||||||
</li>
|
|
||||||
<li>Large files (over 1MB) are skipped during indexing.</li>
|
|
||||||
<li>Only specified repositories are indexed.</li>
|
|
||||||
<li>
|
|
||||||
Indexing runs periodically (check connector settings for frequency) to keep
|
|
||||||
content up-to-date.
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Authorization</h3>
|
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 mb-4">
|
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
||||||
<AlertTitle className="text-[10px] sm:text-xs">
|
|
||||||
Personal Access Token Required
|
|
||||||
</AlertTitle>
|
|
||||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
|
||||||
You'll need a GitHub PAT with the appropriate scopes (e.g., 'repo') to fetch
|
|
||||||
repositories. The PAT will be stored securely to enable indexing.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
|
|
||||||
<div className="space-y-4 sm:space-y-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
|
||||||
Step 1: Generate GitHub PAT
|
|
||||||
</h4>
|
|
||||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground">
|
|
||||||
<li>
|
|
||||||
Go to your GitHub{" "}
|
|
||||||
<a
|
|
||||||
href="https://github.com/settings/tokens"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="font-medium underline underline-offset-4"
|
|
||||||
>
|
|
||||||
Developer settings
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Click on <strong>Personal access tokens</strong>, then choose{" "}
|
|
||||||
<strong>Tokens (classic)</strong> or <strong>Fine-grained tokens</strong>{" "}
|
|
||||||
(recommended if available).
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Click <strong>Generate new token</strong> (and choose the appropriate type).
|
|
||||||
</li>
|
|
||||||
<li>Give your token a descriptive name (e.g., "SurfSense Connector").</li>
|
|
||||||
<li>Set an expiration date for the token (recommended for security).</li>
|
|
||||||
<li>
|
|
||||||
Under <strong>Select scopes</strong> (for classic tokens) or{" "}
|
|
||||||
<strong>Repository access</strong> (for fine-grained), grant the necessary
|
|
||||||
permissions. At minimum, the <strong>`repo`</strong> scope (or equivalent
|
|
||||||
read access to repositories for fine-grained tokens) is required to read
|
|
||||||
repository content.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Click <strong>Generate token</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<strong>Important:</strong> Copy your new PAT immediately. You won't be able
|
|
||||||
to see it again after leaving the page.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h4 className="text-[10px] sm:text-xs font-medium mb-2">
|
|
||||||
Step 2: Specify repositories
|
|
||||||
</h4>
|
|
||||||
<p className="text-[10px] sm:text-xs text-muted-foreground mb-3">
|
|
||||||
Enter a comma-separated list of repository full names in the format
|
|
||||||
"owner/repo1, owner/repo2". The connector will index files from only the
|
|
||||||
specified repositories.
|
|
||||||
</p>
|
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
||||||
<AlertTitle className="text-[10px] sm:text-xs">Repository Access</AlertTitle>
|
|
||||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
|
||||||
Make sure your PAT has access to all repositories you want to index. Private
|
|
||||||
repositories require appropriate permissions.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm sm:text-base font-semibold mb-2">Indexing</h3>
|
|
||||||
<ol className="list-decimal pl-5 space-y-2 text-[10px] sm:text-xs text-muted-foreground mb-4">
|
|
||||||
<li>
|
|
||||||
Navigate to the Connector Dashboard and select the <strong>GitHub</strong>{" "}
|
|
||||||
Connector.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Enter your <strong>GitHub Personal Access Token</strong> in the form field.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Enter a comma-separated list of <strong>Repository Names</strong> (e.g.,
|
|
||||||
"owner/repo1, owner/repo2").
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Click <strong>Connect</strong> to establish the connection.
|
|
||||||
</li>
|
|
||||||
<li>Once connected, your GitHub repositories will be indexed automatically.</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<Alert className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20">
|
|
||||||
<Info className="h-3 w-3 sm:h-4 sm:w-4" />
|
|
||||||
<AlertTitle className="text-[10px] sm:text-xs">What Gets Indexed</AlertTitle>
|
|
||||||
<AlertDescription className="text-[9px] sm:text-[10px]">
|
|
||||||
<p className="mb-2">The GitHub connector indexes the following data:</p>
|
|
||||||
<ul className="list-disc pl-5 space-y-1">
|
|
||||||
<li>Code files from selected repositories</li>
|
|
||||||
<li>README files and Markdown documentation</li>
|
|
||||||
<li>Common text-based file formats</li>
|
|
||||||
<li>Repository metadata and structure</li>
|
|
||||||
</ul>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ExternalLink, Info, Zap } from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ComposioConfigProps {
|
||||||
|
connector: SearchSourceConnector;
|
||||||
|
onConfigChange?: (config: Record<string, unknown>) => void;
|
||||||
|
onNameChange?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get toolkit display info
|
||||||
|
const getToolkitInfo = (toolkitId: string): { name: string; icon: string; description: string } => {
|
||||||
|
switch (toolkitId) {
|
||||||
|
case "googledrive":
|
||||||
|
return {
|
||||||
|
name: "Google Drive",
|
||||||
|
icon: "/connectors/google-drive.svg",
|
||||||
|
description: "Files and documents from Google Drive",
|
||||||
|
};
|
||||||
|
case "gmail":
|
||||||
|
return {
|
||||||
|
name: "Gmail",
|
||||||
|
icon: "/connectors/google-gmail.svg",
|
||||||
|
description: "Emails from Gmail",
|
||||||
|
};
|
||||||
|
case "googlecalendar":
|
||||||
|
return {
|
||||||
|
name: "Google Calendar",
|
||||||
|
icon: "/connectors/google-calendar.svg",
|
||||||
|
description: "Events from Google Calendar",
|
||||||
|
};
|
||||||
|
case "slack":
|
||||||
|
return {
|
||||||
|
name: "Slack",
|
||||||
|
icon: "/connectors/slack.svg",
|
||||||
|
description: "Messages from Slack",
|
||||||
|
};
|
||||||
|
case "notion":
|
||||||
|
return {
|
||||||
|
name: "Notion",
|
||||||
|
icon: "/connectors/notion.svg",
|
||||||
|
description: "Pages from Notion",
|
||||||
|
};
|
||||||
|
case "github":
|
||||||
|
return {
|
||||||
|
name: "GitHub",
|
||||||
|
icon: "/connectors/github.svg",
|
||||||
|
description: "Repositories from GitHub",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
name: toolkitId,
|
||||||
|
icon: "/connectors/composio.svg",
|
||||||
|
description: "Connected via Composio",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComposioConfig: FC<ComposioConfigProps> = ({ connector }) => {
|
||||||
|
const toolkitId = connector.config?.toolkit_id as string;
|
||||||
|
const toolkitName = connector.config?.toolkit_name as string;
|
||||||
|
const isIndexable = connector.config?.is_indexable as boolean;
|
||||||
|
const composioAccountId = connector.config?.composio_connected_account_id as string;
|
||||||
|
|
||||||
|
const toolkitInfo = getToolkitInfo(toolkitId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Toolkit Info Card */}
|
||||||
|
<div className="rounded-xl border border-violet-500/20 bg-gradient-to-br from-violet-500/5 to-purple-500/5 p-4">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/10 to-purple-500/10 border border-violet-500/20 shrink-0">
|
||||||
|
<Image
|
||||||
|
src={toolkitInfo.icon}
|
||||||
|
alt={toolkitInfo.name}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-sm font-semibold">{toolkitName || toolkitInfo.name}</h3>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-[10px] px-1.5 py-0 h-5 bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20"
|
||||||
|
>
|
||||||
|
<Zap className="size-3 mr-0.5" />
|
||||||
|
Composio
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{toolkitInfo.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Details */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
Connection Details
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||||
|
<span className="text-xs text-muted-foreground">Toolkit</span>
|
||||||
|
<span className="text-xs font-medium">{toolkitId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||||
|
<span className="text-xs text-muted-foreground">Indexing Supported</span>
|
||||||
|
<Badge
|
||||||
|
variant={isIndexable ? "default" : "secondary"}
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0 h-5",
|
||||||
|
isIndexable
|
||||||
|
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
|
||||||
|
: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isIndexable ? "Yes" : "Coming Soon"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{composioAccountId && (
|
||||||
|
<div className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50">
|
||||||
|
<span className="text-xs text-muted-foreground">Account ID</span>
|
||||||
|
<span className="text-xs font-mono text-muted-foreground truncate max-w-[150px]">
|
||||||
|
{composioAccountId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Banner */}
|
||||||
|
<div className="rounded-lg border border-border/50 bg-muted/30 p-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<Info className="size-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
This connection uses Composio's managed OAuth, which means you don't need to
|
||||||
|
wait for app verification. Your data is securely accessed through Composio.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://composio.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-violet-600 dark:text-violet-400 hover:underline"
|
||||||
|
>
|
||||||
|
Learn more about Composio
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { KeyRound } from "lucide-react";
|
import { KeyRound } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -12,25 +12,29 @@ export interface GithubConfigProps extends ConnectorConfigProps {
|
||||||
onNameChange?: (name: string) => void;
|
onNameChange?: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper functions moved outside component to avoid useEffect dependency issues
|
||||||
|
const stringToArray = (arr: string[] | string | undefined): string[] => {
|
||||||
|
if (Array.isArray(arr)) return arr;
|
||||||
|
if (typeof arr === "string") {
|
||||||
|
return arr
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter((item) => item.length > 0);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const arrayToString = (arr: string[]): string => {
|
||||||
|
return arr.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
export const GithubConfig: FC<GithubConfigProps> = ({
|
export const GithubConfig: FC<GithubConfigProps> = ({
|
||||||
connector,
|
connector,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
}) => {
|
}) => {
|
||||||
const stringToArray = (arr: string[] | string | undefined): string[] => {
|
// Track internal changes to prevent useEffect from overwriting user input
|
||||||
if (Array.isArray(arr)) return arr;
|
const isInternalChange = useRef(false);
|
||||||
if (typeof arr === "string") {
|
|
||||||
return arr
|
|
||||||
.split(",")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter((item) => item.length > 0);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const arrayToString = (arr: string[]): string => {
|
|
||||||
return arr.join(", ");
|
|
||||||
};
|
|
||||||
|
|
||||||
const [githubPat, setGithubPat] = useState<string>(
|
const [githubPat, setGithubPat] = useState<string>(
|
||||||
(connector.config?.GITHUB_PAT as string) || ""
|
(connector.config?.GITHUB_PAT as string) || ""
|
||||||
|
|
@ -40,8 +44,13 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
||||||
);
|
);
|
||||||
const [name, setName] = useState<string>(connector.name || "");
|
const [name, setName] = useState<string>(connector.name || "");
|
||||||
|
|
||||||
// Update values when connector changes
|
// Update values when connector changes externally (not from our own input)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if this is our own internal change
|
||||||
|
if (isInternalChange.current) {
|
||||||
|
isInternalChange.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pat = (connector.config?.GITHUB_PAT as string) || "";
|
const pat = (connector.config?.GITHUB_PAT as string) || "";
|
||||||
const repos = arrayToString(stringToArray(connector.config?.repo_full_names));
|
const repos = arrayToString(stringToArray(connector.config?.repo_full_names));
|
||||||
setGithubPat(pat);
|
setGithubPat(pat);
|
||||||
|
|
@ -50,6 +59,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
||||||
}, [connector.config, connector.name]);
|
}, [connector.config, connector.name]);
|
||||||
|
|
||||||
const handleGithubPatChange = (value: string) => {
|
const handleGithubPatChange = (value: string) => {
|
||||||
|
isInternalChange.current = true;
|
||||||
setGithubPat(value);
|
setGithubPat(value);
|
||||||
if (onConfigChange) {
|
if (onConfigChange) {
|
||||||
onConfigChange({
|
onConfigChange({
|
||||||
|
|
@ -60,6 +70,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRepoFullNamesChange = (value: string) => {
|
const handleRepoFullNamesChange = (value: string) => {
|
||||||
|
isInternalChange.current = true;
|
||||||
setRepoFullNames(value);
|
setRepoFullNames(value);
|
||||||
const repoList = stringToArray(value);
|
const repoList = stringToArray(value);
|
||||||
if (onConfigChange) {
|
if (onConfigChange) {
|
||||||
|
|
@ -71,6 +82,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNameChange = (value: string) => {
|
const handleNameChange = (value: string) => {
|
||||||
|
isInternalChange.current = true;
|
||||||
setName(value);
|
setName(value);
|
||||||
if (onNameChange) {
|
if (onNameChange) {
|
||||||
onNameChange(value);
|
onNameChange(value);
|
||||||
|
|
@ -105,7 +117,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
<Label className="flex items-center gap-2 text-xs sm:text-sm">
|
||||||
<KeyRound className="h-4 w-4" />
|
<KeyRound className="h-4 w-4" />
|
||||||
GitHub Personal Access Token
|
GitHub Personal Access Token (optional)
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
import { BaiduSearchApiConfig } from "./components/baidu-search-api-config";
|
||||||
import { BookStackConfig } from "./components/bookstack-config";
|
import { BookStackConfig } from "./components/bookstack-config";
|
||||||
import { CirclebackConfig } from "./components/circleback-config";
|
import { CirclebackConfig } from "./components/circleback-config";
|
||||||
|
import { ComposioConfig } from "./components/composio-config";
|
||||||
import { ClickUpConfig } from "./components/clickup-config";
|
import { ClickUpConfig } from "./components/clickup-config";
|
||||||
import { ConfluenceConfig } from "./components/confluence-config";
|
import { ConfluenceConfig } from "./components/confluence-config";
|
||||||
import { DiscordConfig } from "./components/discord-config";
|
import { DiscordConfig } from "./components/discord-config";
|
||||||
|
|
@ -73,6 +74,8 @@ export function getConnectorConfigComponent(
|
||||||
return CirclebackConfig;
|
return CirclebackConfig;
|
||||||
case "MCP_CONNECTOR":
|
case "MCP_CONNECTOR":
|
||||||
return MCPConfig;
|
return MCPConfig;
|
||||||
|
case "COMPOSIO_CONNECTOR":
|
||||||
|
return ComposioConfig;
|
||||||
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
|
// OAuth connectors (Gmail, Calendar, Airtable, Notion) and others don't need special config UI
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -206,9 +206,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
||||||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||||
{connector.is_indexable && (
|
{connector.is_indexable && (
|
||||||
<>
|
<>
|
||||||
{/* Date range selector - not shown for Google Drive (uses folder selection) or Webcrawler (uses config) */}
|
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||||
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
{connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
connector.connector_type !== "WEBCRAWLER_CONNECTOR" && (
|
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
|
||||||
|
connector.connector_type !== "GITHUB_CONNECTOR" && (
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
{/* Date range selector and periodic sync - only shown for indexable connectors */}
|
||||||
{connector?.is_indexable && (
|
{connector?.is_indexable && (
|
||||||
<>
|
<>
|
||||||
{/* Date range selector - not shown for Google Drive (uses folder selection) or Webcrawler (uses config) */}
|
{/* Date range selector - not shown for Google Drive, Webcrawler, or GitHub (indexes full repo snapshots) */}
|
||||||
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
{config.connectorType !== "GOOGLE_DRIVE_CONNECTOR" &&
|
||||||
config.connectorType !== "WEBCRAWLER_CONNECTOR" && (
|
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
|
||||||
|
config.connectorType !== "GITHUB_CONNECTOR" && (
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
startDate={startDate}
|
startDate={startDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
|
|
|
||||||
|
|
@ -168,5 +168,56 @@ export const OTHER_CONNECTORS = [
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
// Composio Connector (Single entry that opens toolkit selector)
|
||||||
|
export const COMPOSIO_CONNECTORS = [
|
||||||
|
{
|
||||||
|
id: "composio-connector",
|
||||||
|
title: "Composio",
|
||||||
|
description: "Connect 100+ apps via Composio (Google, Slack, Notion, etc.)",
|
||||||
|
connectorType: EnumConnectorName.COMPOSIO_CONNECTOR,
|
||||||
|
// No authEndpoint - handled via toolkit selector view
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Composio Toolkits (available integrations via Composio)
|
||||||
|
export const COMPOSIO_TOOLKITS = [
|
||||||
|
{
|
||||||
|
id: "googledrive",
|
||||||
|
name: "Google Drive",
|
||||||
|
description: "Search your Drive files",
|
||||||
|
isIndexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gmail",
|
||||||
|
name: "Gmail",
|
||||||
|
description: "Search through your emails",
|
||||||
|
isIndexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "googlecalendar",
|
||||||
|
name: "Google Calendar",
|
||||||
|
description: "Search through your events",
|
||||||
|
isIndexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack",
|
||||||
|
name: "Slack",
|
||||||
|
description: "Search Slack messages",
|
||||||
|
isIndexable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notion",
|
||||||
|
name: "Notion",
|
||||||
|
description: "Search Notion pages",
|
||||||
|
isIndexable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github",
|
||||||
|
name: "GitHub",
|
||||||
|
description: "Search repositories",
|
||||||
|
isIndexable: false,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
// Re-export IndexingConfigState from schemas for backward compatibility
|
// Re-export IndexingConfigState from schemas for backward compatibility
|
||||||
export type { IndexingConfigState } from "./connector-popup.schemas";
|
export type { IndexingConfigState } from "./connector-popup.schemas";
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,10 @@ export const useConnectorDialog = () => {
|
||||||
// MCP list view state (for managing multiple MCP connectors)
|
// MCP list view state (for managing multiple MCP connectors)
|
||||||
const [viewingMCPList, setViewingMCPList] = useState(false);
|
const [viewingMCPList, setViewingMCPList] = useState(false);
|
||||||
|
|
||||||
|
// Composio toolkit view state
|
||||||
|
const [viewingComposio, setViewingComposio] = useState(false);
|
||||||
|
const [connectingComposioToolkit, setConnectingComposioToolkit] = useState<string | null>(null);
|
||||||
|
|
||||||
// Track if we came from accounts list when entering edit mode
|
// Track if we came from accounts list when entering edit mode
|
||||||
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
||||||
connectorType: string;
|
connectorType: string;
|
||||||
|
|
@ -155,6 +159,17 @@ export const useConnectorDialog = () => {
|
||||||
setViewingMCPList(true);
|
setViewingMCPList(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear Composio view if view is not "composio" anymore
|
||||||
|
if (params.view !== "composio" && viewingComposio) {
|
||||||
|
setViewingComposio(false);
|
||||||
|
setConnectingComposioToolkit(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Composio view
|
||||||
|
if (params.view === "composio" && !viewingComposio) {
|
||||||
|
setViewingComposio(true);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle connect view
|
// Handle connect view
|
||||||
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
if (params.view === "connect" && params.connectorType && !connectingConnectorType) {
|
||||||
setConnectingConnectorType(params.connectorType);
|
setConnectingConnectorType(params.connectorType);
|
||||||
|
|
@ -846,6 +861,63 @@ export const useConnectorDialog = () => {
|
||||||
router.replace(url.pathname + url.search, { scroll: false });
|
router.replace(url.pathname + url.search, { scroll: false });
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
|
// Handle opening Composio toolkit view
|
||||||
|
const handleOpenComposio = useCallback(() => {
|
||||||
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
|
setViewingComposio(true);
|
||||||
|
|
||||||
|
// Update URL to show Composio view
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("modal", "connectors");
|
||||||
|
url.searchParams.set("view", "composio");
|
||||||
|
window.history.pushState({ modal: true }, "", url.toString());
|
||||||
|
}, [searchSpaceId]);
|
||||||
|
|
||||||
|
// Handle going back from Composio view
|
||||||
|
const handleBackFromComposio = useCallback(() => {
|
||||||
|
setViewingComposio(false);
|
||||||
|
setConnectingComposioToolkit(null);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set("modal", "connectors");
|
||||||
|
url.searchParams.delete("view");
|
||||||
|
router.replace(url.pathname + url.search, { scroll: false });
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
// Handle connecting a Composio toolkit
|
||||||
|
const handleConnectComposioToolkit = useCallback(
|
||||||
|
async (toolkitId: string) => {
|
||||||
|
if (!searchSpaceId) return;
|
||||||
|
|
||||||
|
setConnectingComposioToolkit(toolkitId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(
|
||||||
|
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/auth/composio/connector/add?space_id=${searchSpaceId}&toolkit_id=${toolkitId}`,
|
||||||
|
{ method: "GET" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to initiate Composio OAuth for ${toolkitId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.auth_url) {
|
||||||
|
// Redirect to Composio OAuth
|
||||||
|
window.location.href = data.auth_url;
|
||||||
|
} else {
|
||||||
|
throw new Error("No authorization URL received from Composio");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error connecting Composio toolkit:", error);
|
||||||
|
toast.error(`Failed to connect ${toolkitId}. Please try again.`);
|
||||||
|
setConnectingComposioToolkit(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchSpaceId]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle starting indexing
|
// Handle starting indexing
|
||||||
const handleStartIndexing = useCallback(
|
const handleStartIndexing = useCallback(
|
||||||
async (refreshConnectors: () => void) => {
|
async (refreshConnectors: () => void) => {
|
||||||
|
|
@ -1506,6 +1578,7 @@ export const useConnectorDialog = () => {
|
||||||
allConnectors,
|
allConnectors,
|
||||||
viewingAccountsType,
|
viewingAccountsType,
|
||||||
viewingMCPList,
|
viewingMCPList,
|
||||||
|
viewingComposio,
|
||||||
|
|
||||||
// Setters
|
// Setters
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
|
@ -1541,5 +1614,12 @@ export const useConnectorDialog = () => {
|
||||||
connectorConfig,
|
connectorConfig,
|
||||||
setConnectorConfig,
|
setConnectorConfig,
|
||||||
setIndexingConnectorConfig,
|
setIndexingConnectorConfig,
|
||||||
|
|
||||||
|
// Composio
|
||||||
|
viewingComposio,
|
||||||
|
connectingComposioToolkit,
|
||||||
|
handleOpenComposio,
|
||||||
|
handleBackFromComposio,
|
||||||
|
handleConnectComposioToolkit,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import type { FC } from "react";
|
||||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||||
import { ConnectorCard } from "../components/connector-card";
|
import { ConnectorCard } from "../components/connector-card";
|
||||||
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS } from "../constants/connector-constants";
|
import { ComposioConnectorCard } from "../components/composio-connector-card";
|
||||||
|
import { CRAWLERS, OAUTH_CONNECTORS, OTHER_CONNECTORS, COMPOSIO_CONNECTORS } from "../constants/connector-constants";
|
||||||
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
import { getDocumentCountForConnector } from "../utils/connector-document-mapping";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -34,6 +35,7 @@ interface AllConnectorsTabProps {
|
||||||
onCreateYouTubeCrawler?: () => void;
|
onCreateYouTubeCrawler?: () => void;
|
||||||
onManage?: (connector: SearchSourceConnector) => void;
|
onManage?: (connector: SearchSourceConnector) => void;
|
||||||
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
onViewAccountsList?: (connectorType: string, connectorTitle: string) => void;
|
||||||
|
onOpenComposio?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
|
|
@ -49,6 +51,7 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
onCreateYouTubeCrawler,
|
onCreateYouTubeCrawler,
|
||||||
onManage,
|
onManage,
|
||||||
onViewAccountsList,
|
onViewAccountsList,
|
||||||
|
onOpenComposio,
|
||||||
}) => {
|
}) => {
|
||||||
// Filter connectors based on search
|
// Filter connectors based on search
|
||||||
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
const filteredOAuth = OAUTH_CONNECTORS.filter(
|
||||||
|
|
@ -69,6 +72,20 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Filter Composio connectors
|
||||||
|
const filteredComposio = COMPOSIO_CONNECTORS.filter(
|
||||||
|
(c) =>
|
||||||
|
c.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
c.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count Composio connectors
|
||||||
|
const composioConnectorCount = allConnectors
|
||||||
|
? allConnectors.filter(
|
||||||
|
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.COMPOSIO_CONNECTOR
|
||||||
|
).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Quick Connect */}
|
{/* Quick Connect */}
|
||||||
|
|
@ -137,6 +154,30 @@ export const AllConnectorsTab: FC<AllConnectorsTabProps> = ({
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Composio Integrations */}
|
||||||
|
{filteredComposio.length > 0 && onOpenComposio && (
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Managed OAuth</h3>
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20 font-medium">
|
||||||
|
No verification needed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{filteredComposio.map((connector) => (
|
||||||
|
<ComposioConnectorCard
|
||||||
|
key={connector.id}
|
||||||
|
id={connector.id}
|
||||||
|
title={connector.title}
|
||||||
|
description={connector.description}
|
||||||
|
connectorCount={composioConnectorCount}
|
||||||
|
onConnect={onOpenComposio}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* More Integrations */}
|
{/* More Integrations */}
|
||||||
{filteredOther.length > 0 && (
|
{filteredOther.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
||||||
// Special mappings (connector type differs from document type)
|
// Special mappings (connector type differs from document type)
|
||||||
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
GOOGLE_DRIVE_CONNECTOR: "GOOGLE_DRIVE_FILE",
|
||||||
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
WEBCRAWLER_CONNECTOR: "CRAWLED_URL",
|
||||||
|
COMPOSIO_CONNECTOR: "COMPOSIO_CONNECTOR",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
|
ExternalLink,
|
||||||
|
Github,
|
||||||
|
Loader2,
|
||||||
|
Mail,
|
||||||
|
HardDrive,
|
||||||
|
MessageSquare,
|
||||||
|
FileText,
|
||||||
|
Zap,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ComposioToolkit {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isIndexable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComposioToolkitViewProps {
|
||||||
|
searchSpaceId: string;
|
||||||
|
connectedToolkits: string[];
|
||||||
|
onBack: () => void;
|
||||||
|
onConnectToolkit: (toolkitId: string) => void;
|
||||||
|
isConnecting: boolean;
|
||||||
|
connectingToolkitId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available Composio toolkits
|
||||||
|
const COMPOSIO_TOOLKITS: ComposioToolkit[] = [
|
||||||
|
{
|
||||||
|
id: "googledrive",
|
||||||
|
name: "Google Drive",
|
||||||
|
description: "Search your Drive files and documents",
|
||||||
|
isIndexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gmail",
|
||||||
|
name: "Gmail",
|
||||||
|
description: "Search through your emails",
|
||||||
|
isIndexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "googlecalendar",
|
||||||
|
name: "Google Calendar",
|
||||||
|
description: "Search through your events",
|
||||||
|
isIndexable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slack",
|
||||||
|
name: "Slack",
|
||||||
|
description: "Search Slack messages",
|
||||||
|
isIndexable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "notion",
|
||||||
|
name: "Notion",
|
||||||
|
description: "Search Notion pages",
|
||||||
|
isIndexable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "github",
|
||||||
|
name: "GitHub",
|
||||||
|
description: "Search repositories and code",
|
||||||
|
isIndexable: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get icon for toolkit
|
||||||
|
const getToolkitIcon = (toolkitId: string, className?: string) => {
|
||||||
|
const iconClass = className || "size-5";
|
||||||
|
|
||||||
|
switch (toolkitId) {
|
||||||
|
case "googledrive":
|
||||||
|
return <Image src="/connectors/google-drive.svg" alt="Google Drive" width={20} height={20} className={iconClass} />;
|
||||||
|
case "gmail":
|
||||||
|
return <Image src="/connectors/google-gmail.svg" alt="Gmail" width={20} height={20} className={iconClass} />;
|
||||||
|
case "googlecalendar":
|
||||||
|
return <Image src="/connectors/google-calendar.svg" alt="Google Calendar" width={20} height={20} className={iconClass} />;
|
||||||
|
case "slack":
|
||||||
|
return <Image src="/connectors/slack.svg" alt="Slack" width={20} height={20} className={iconClass} />;
|
||||||
|
case "notion":
|
||||||
|
return <Image src="/connectors/notion.svg" alt="Notion" width={20} height={20} className={iconClass} />;
|
||||||
|
case "github":
|
||||||
|
return <Image src="/connectors/github.svg" alt="GitHub" width={20} height={20} className={iconClass} />;
|
||||||
|
default:
|
||||||
|
return <Zap className={iconClass} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ComposioToolkitView: FC<ComposioToolkitViewProps> = ({
|
||||||
|
searchSpaceId,
|
||||||
|
connectedToolkits,
|
||||||
|
onBack,
|
||||||
|
onConnectToolkit,
|
||||||
|
isConnecting,
|
||||||
|
connectingToolkitId,
|
||||||
|
}) => {
|
||||||
|
const [hoveredToolkit, setHoveredToolkit] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Separate indexable and non-indexable toolkits
|
||||||
|
const indexableToolkits = COMPOSIO_TOOLKITS.filter((t) => t.isIndexable);
|
||||||
|
const nonIndexableToolkits = COMPOSIO_TOOLKITS.filter((t) => !t.isIndexable);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 sm:pb-6 border-b border-border/50 bg-muted">
|
||||||
|
{/* Back button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBack}
|
||||||
|
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
Back to connectors
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header content */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4">
|
||||||
|
<div className="flex gap-4 flex-1 w-full sm:w-auto">
|
||||||
|
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500/20 to-purple-500/20 border border-violet-500/30 shrink-0">
|
||||||
|
<Image
|
||||||
|
src="/connectors/composio.svg"
|
||||||
|
alt="Composio"
|
||||||
|
width={28}
|
||||||
|
height={28}
|
||||||
|
className="size-7"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight">
|
||||||
|
Composio
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-muted-foreground mt-1">
|
||||||
|
Connect 100+ apps with managed OAuth - no verification needed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://composio.dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<span>Powered by Composio</span>
|
||||||
|
<ExternalLink className="size-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
|
||||||
|
{/* Indexable Toolkits (Google Services) */}
|
||||||
|
<section className="mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Google Services</h3>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||||
|
Indexable
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
Connect Google services via Composio's verified OAuth app. Your data will be indexed and searchable.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{indexableToolkits.map((toolkit) => {
|
||||||
|
const isConnected = connectedToolkits.includes(toolkit.id);
|
||||||
|
const isThisConnecting = connectingToolkitId === toolkit.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={toolkit.id}
|
||||||
|
onMouseEnter={() => setHoveredToolkit(toolkit.id)}
|
||||||
|
onMouseLeave={() => setHoveredToolkit(null)}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col p-4 rounded-xl border transition-all duration-200",
|
||||||
|
isConnected
|
||||||
|
? "border-emerald-500/30 bg-emerald-500/5"
|
||||||
|
: "border-border bg-card hover:border-violet-500/30 hover:bg-violet-500/5"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-10 items-center justify-center rounded-lg border transition-colors",
|
||||||
|
isConnected
|
||||||
|
? "bg-emerald-500/10 border-emerald-500/20"
|
||||||
|
: "bg-muted border-border group-hover:border-violet-500/20 group-hover:bg-violet-500/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{getToolkitIcon(toolkit.id, "size-5")}
|
||||||
|
</div>
|
||||||
|
{isConnected && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20">
|
||||||
|
<Check className="size-3 mr-0.5" />
|
||||||
|
Connected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4 flex-1">
|
||||||
|
{toolkit.description}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={isConnected ? "secondary" : "default"}
|
||||||
|
className={cn(
|
||||||
|
"w-full h-8 text-xs font-medium",
|
||||||
|
!isConnected && "bg-violet-600 hover:bg-violet-700 text-white"
|
||||||
|
)}
|
||||||
|
onClick={() => onConnectToolkit(toolkit.id)}
|
||||||
|
disabled={isConnecting || isConnected}
|
||||||
|
>
|
||||||
|
{isThisConnecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-3 mr-1.5 animate-spin" />
|
||||||
|
Connecting...
|
||||||
|
</>
|
||||||
|
) : isConnected ? (
|
||||||
|
"Connected"
|
||||||
|
) : (
|
||||||
|
"Connect"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Non-Indexable Toolkits (Coming Soon) */}
|
||||||
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">More Integrations</h3>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-5 bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20">
|
||||||
|
Coming Soon
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4">
|
||||||
|
Connect these services for future indexing support. Currently available for connection only.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 opacity-60">
|
||||||
|
{nonIndexableToolkits.map((toolkit) => (
|
||||||
|
<div
|
||||||
|
key={toolkit.id}
|
||||||
|
className="group relative flex flex-col p-4 rounded-xl border border-border bg-card/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg border bg-muted border-border">
|
||||||
|
{getToolkitIcon(toolkit.id, "size-5")}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5">
|
||||||
|
Soon
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">{toolkit.name}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground mb-4 flex-1">
|
||||||
|
{toolkit.description}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full h-8 text-xs font-medium"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Coming Soon
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Info footer */}
|
||||||
|
<div className="mt-8 p-4 rounded-xl bg-muted/50 border border-border/50">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/10 border border-violet-500/20 shrink-0">
|
||||||
|
<Zap className="size-4 text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium mb-1">Why use Composio?</h4>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||||
|
Composio provides pre-verified OAuth apps, so you don't need to wait for Google app verification.
|
||||||
|
Your data is securely processed through Composio's managed authentication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
mentionedDocumentIdsAtom,
|
mentionedDocumentIdsAtom,
|
||||||
mentionedDocumentsAtom,
|
mentionedDocumentsAtom,
|
||||||
} from "@/atoms/chat/mentioned-documents.atom";
|
} from "@/atoms/chat/mentioned-documents.atom";
|
||||||
|
import { membersAtom } from "@/atoms/members/members-query.atoms";
|
||||||
import {
|
import {
|
||||||
globalNewLLMConfigsAtom,
|
globalNewLLMConfigsAtom,
|
||||||
llmPreferencesAtom,
|
llmPreferencesAtom,
|
||||||
|
|
@ -39,6 +40,7 @@ import {
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||||
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
||||||
|
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||||
import {
|
import {
|
||||||
InlineMentionEditor,
|
InlineMentionEditor,
|
||||||
|
|
@ -59,6 +61,8 @@ import {
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
|
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||||
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface ThreadProps {
|
interface ThreadProps {
|
||||||
|
|
@ -86,6 +90,7 @@ const ThreadContent: FC<{ header?: React.ReactNode }> = ({ header }) => {
|
||||||
>
|
>
|
||||||
<ThreadPrimitive.Viewport
|
<ThreadPrimitive.Viewport
|
||||||
turnAnchor="top"
|
turnAnchor="top"
|
||||||
|
autoScroll
|
||||||
className={cn(
|
className={cn(
|
||||||
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
"aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 transition-[padding] duration-300 ease-out",
|
||||||
showGutter && "lg:pr-30"
|
showGutter && "lg:pr-30"
|
||||||
|
|
@ -215,7 +220,7 @@ const Composer: FC = () => {
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||||
const { search_space_id } = useParams();
|
const { search_space_id, chat_id } = useParams();
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||||
const composerRuntime = useComposerRuntime();
|
const composerRuntime = useComposerRuntime();
|
||||||
const hasAutoFocusedRef = useRef(false);
|
const hasAutoFocusedRef = useRef(false);
|
||||||
|
|
@ -223,6 +228,23 @@ const Composer: FC = () => {
|
||||||
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
const isThreadEmpty = useAssistantState(({ thread }) => thread.isEmpty);
|
||||||
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
const isThreadRunning = useAssistantState(({ thread }) => thread.isRunning);
|
||||||
|
|
||||||
|
// Live collaboration state
|
||||||
|
const { data: currentUser } = useAtomValue(currentUserAtom);
|
||||||
|
const { data: members } = useAtomValue(membersAtom);
|
||||||
|
const threadId = useMemo(() => {
|
||||||
|
if (Array.isArray(chat_id) && chat_id.length > 0) {
|
||||||
|
return Number.parseInt(chat_id[0], 10) || null;
|
||||||
|
}
|
||||||
|
return typeof chat_id === "string" ? Number.parseInt(chat_id, 10) || null : null;
|
||||||
|
}, [chat_id]);
|
||||||
|
const sessionState = useAtomValue(chatSessionStateAtom);
|
||||||
|
const isAiResponding = sessionState?.isAiResponding ?? false;
|
||||||
|
const respondingToUserId = sessionState?.respondingToUserId ?? null;
|
||||||
|
const isBlockedByOtherUser = isAiResponding && respondingToUserId !== currentUser?.id;
|
||||||
|
|
||||||
|
// Sync comments for the entire thread via Electric SQL (one subscription per thread)
|
||||||
|
useCommentsElectric(threadId);
|
||||||
|
|
||||||
// Auto-focus editor on new chat page after mount
|
// Auto-focus editor on new chat page after mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
if (isThreadEmpty && !hasAutoFocusedRef.current && editorRef.current) {
|
||||||
|
|
@ -298,9 +320,9 @@ const Composer: FC = () => {
|
||||||
[showDocumentPopover]
|
[showDocumentPopover]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Submit message (blocked during streaming or when document picker is open)
|
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (isThreadRunning) {
|
if (isThreadRunning || isBlockedByOtherUser) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!showDocumentPopover) {
|
if (!showDocumentPopover) {
|
||||||
|
|
@ -315,6 +337,7 @@ const Composer: FC = () => {
|
||||||
}, [
|
}, [
|
||||||
showDocumentPopover,
|
showDocumentPopover,
|
||||||
isThreadRunning,
|
isThreadRunning,
|
||||||
|
isBlockedByOtherUser,
|
||||||
composerRuntime,
|
composerRuntime,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setMentionedDocumentIds,
|
setMentionedDocumentIds,
|
||||||
|
|
@ -374,7 +397,13 @@ const Composer: FC = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col">
|
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||||
|
<ChatSessionStatus
|
||||||
|
isAiResponding={isAiResponding}
|
||||||
|
respondingToUserId={respondingToUserId}
|
||||||
|
currentUserId={currentUser?.id ?? null}
|
||||||
|
members={members ?? []}
|
||||||
|
/>
|
||||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
||||||
<ComposerAttachments />
|
<ComposerAttachments />
|
||||||
{/* Inline editor with @mention support */}
|
{/* Inline editor with @mention support */}
|
||||||
|
|
@ -417,13 +446,17 @@ const Composer: FC = () => {
|
||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
<ComposerAction />
|
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
||||||
</ComposerPrimitive.AttachmentDropzone>
|
</ComposerPrimitive.AttachmentDropzone>
|
||||||
</ComposerPrimitive.Root>
|
</ComposerPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ComposerAction: FC = () => {
|
interface ComposerActionProps {
|
||||||
|
isBlockedByOtherUser?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
||||||
// Check if any attachments are still being processed (running AND progress < 100)
|
// Check if any attachments are still being processed (running AND progress < 100)
|
||||||
// When progress is 100, processing is done but waiting for send()
|
// When progress is 100, processing is done but waiting for send()
|
||||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
||||||
|
|
@ -458,7 +491,8 @@ const ComposerAction: FC = () => {
|
||||||
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
return userConfigs?.some((c) => c.id === agentLlmId) ?? false;
|
||||||
}, [preferences, globalConfigs, userConfigs]);
|
}, [preferences, globalConfigs, userConfigs]);
|
||||||
|
|
||||||
const isSendDisabled = hasProcessingAttachments || isComposerEmpty || !hasModelConfigured;
|
const isSendDisabled =
|
||||||
|
hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||||
|
|
@ -487,13 +521,15 @@ const ComposerAction: FC = () => {
|
||||||
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
|
||||||
<TooltipIconButton
|
<TooltipIconButton
|
||||||
tooltip={
|
tooltip={
|
||||||
!hasModelConfigured
|
isBlockedByOtherUser
|
||||||
? "Please select a model from the header to start chatting"
|
? "Wait for AI to finish responding"
|
||||||
: hasProcessingAttachments
|
: !hasModelConfigured
|
||||||
? "Wait for attachments to process"
|
? "Please select a model from the header to start chatting"
|
||||||
: isComposerEmpty
|
: hasProcessingAttachments
|
||||||
? "Enter a message to send"
|
? "Wait for attachments to process"
|
||||||
: "Send message"
|
: isComposerEmpty
|
||||||
|
? "Enter a message to send"
|
||||||
|
: "Send message"
|
||||||
}
|
}
|
||||||
side="bottom"
|
side="bottom"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ function findMentionTrigger(
|
||||||
return { isActive: false, query: "", startIndex: 0 };
|
return { isActive: false, query: "", startIndex: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullMatch = mentionMatch[0];
|
|
||||||
const query = mentionMatch[1];
|
const query = mentionMatch[1];
|
||||||
const atIndex = cursorPos - query.length - 1;
|
const atIndex = cursorPos - query.length - 1;
|
||||||
|
|
||||||
|
|
@ -80,7 +79,7 @@ function findMentionTrigger(
|
||||||
export function CommentComposer({
|
export function CommentComposer({
|
||||||
members,
|
members,
|
||||||
membersLoading = false,
|
membersLoading = false,
|
||||||
placeholder = "Write a comment...",
|
placeholder = "Comment or @mention",
|
||||||
submitLabel = "Send",
|
submitLabel = "Send",
|
||||||
isSubmitting = false,
|
isSubmitting = false,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
|
@ -145,6 +144,13 @@ export function CommentComposer({
|
||||||
const cursorPos = e.target.selectionStart;
|
const cursorPos = e.target.selectionStart;
|
||||||
setDisplayContent(value);
|
setDisplayContent(value);
|
||||||
|
|
||||||
|
// Auto-resize textarea on content change
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const textarea = e.target;
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
});
|
||||||
|
|
||||||
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
|
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
|
||||||
|
|
||||||
if (triggerResult.isActive) {
|
if (triggerResult.isActive) {
|
||||||
|
|
@ -208,9 +214,9 @@ export function CommentComposer({
|
||||||
|
|
||||||
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
|
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
|
||||||
const foundMentions: InsertedMention[] = [];
|
const foundMentions: InsertedMention[] = [];
|
||||||
let match: RegExpExecArray | null;
|
const matches = initialValue.matchAll(mentionPattern);
|
||||||
|
|
||||||
while ((match = mentionPattern.exec(initialValue)) !== null) {
|
for (const match of matches) {
|
||||||
const displayName = match[1];
|
const displayName = match[1];
|
||||||
const member = members.find(
|
const member = members.find(
|
||||||
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
|
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
|
||||||
|
|
@ -237,6 +243,19 @@ export function CommentComposer({
|
||||||
|
|
||||||
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
|
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
|
||||||
|
|
||||||
|
// Auto-resize textarea
|
||||||
|
const adjustTextareaHeight = useCallback(() => {
|
||||||
|
const textarea = textareaRef.current;
|
||||||
|
if (textarea) {
|
||||||
|
textarea.style.height = "auto";
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adjustTextareaHeight();
|
||||||
|
}, [adjustTextareaHeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Popover
|
<Popover
|
||||||
|
|
@ -251,7 +270,8 @@ export function CommentComposer({
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
className="min-h-[80px] resize-none"
|
className="min-h-[40px] max-h-[200px] resize-none overflow-y-auto scrollbar-thin"
|
||||||
|
rows={1}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</PopoverAnchor>
|
</PopoverAnchor>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="size-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="size-7 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
<MoreHorizontal className="size-4" />
|
<MoreHorizontal className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { MessageSquare } from "lucide-react";
|
import { MessageSquare } from "lucide-react";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -115,12 +113,8 @@ export function CommentItem({
|
||||||
members = [],
|
members = [],
|
||||||
membersLoading = false,
|
membersLoading = false,
|
||||||
}: CommentItemProps) {
|
}: CommentItemProps) {
|
||||||
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
const displayName =
|
||||||
|
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
||||||
const isCurrentUser = currentUser?.id === comment.author?.id;
|
|
||||||
const displayName = isCurrentUser
|
|
||||||
? "Me"
|
|
||||||
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
|
|
||||||
const email = comment.author?.email || "";
|
const email = comment.author?.email || "";
|
||||||
|
|
||||||
const handleEditSubmit = (content: string) => {
|
const handleEditSubmit = (content: string) => {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageSquarePlus } from "lucide-react";
|
import { useAtom } from "jotai";
|
||||||
import { useState } from "react";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CommentComposer } from "../comment-composer/comment-composer";
|
import { CommentComposer } from "../comment-composer/comment-composer";
|
||||||
import { CommentThread } from "../comment-thread/comment-thread";
|
import { CommentThread } from "../comment-thread/comment-thread";
|
||||||
import type { CommentPanelProps } from "./types";
|
import type { CommentPanelProps } from "./types";
|
||||||
|
|
||||||
|
function getInitials(name: string | null | undefined, email: string): string {
|
||||||
|
if (name) {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((part) => part[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
return email[0].toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
export function CommentPanel({
|
export function CommentPanel({
|
||||||
threads,
|
threads,
|
||||||
members,
|
members,
|
||||||
|
|
@ -21,15 +33,10 @@ export function CommentPanel({
|
||||||
maxHeight,
|
maxHeight,
|
||||||
variant = "desktop",
|
variant = "desktop",
|
||||||
}: CommentPanelProps) {
|
}: CommentPanelProps) {
|
||||||
const [isComposerOpen, setIsComposerOpen] = useState(false);
|
const [{ data: currentUser }] = useAtom(currentUserAtom);
|
||||||
|
|
||||||
const handleCommentSubmit = (content: string) => {
|
const handleCommentSubmit = (content: string) => {
|
||||||
onCreateComment(content);
|
onCreateComment(content);
|
||||||
setIsComposerOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleComposerCancel = () => {
|
|
||||||
setIsComposerOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = variant === "mobile";
|
const isMobile = variant === "mobile";
|
||||||
|
|
@ -51,7 +58,6 @@ export function CommentPanel({
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasThreads = threads.length > 0;
|
const hasThreads = threads.length > 0;
|
||||||
const showEmptyState = !hasThreads && !isComposerOpen;
|
|
||||||
|
|
||||||
// Ensure minimum usable height for empty state + composer button
|
// Ensure minimum usable height for empty state + composer button
|
||||||
const minHeight = 180;
|
const minHeight = 180;
|
||||||
|
|
@ -63,7 +69,7 @@ export function CommentPanel({
|
||||||
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
style={!isMobile && effectiveMaxHeight ? { maxHeight: effectiveMaxHeight } : undefined}
|
||||||
>
|
>
|
||||||
{hasThreads && (
|
{hasThreads && (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
<div className={cn("min-h-0 flex-1 overflow-y-auto scrollbar-thin", isMobile && "pb-24")}>
|
||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{threads.map((thread) => (
|
{threads.map((thread) => (
|
||||||
<CommentThread
|
<CommentThread
|
||||||
|
|
@ -81,38 +87,35 @@ export function CommentPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showEmptyState && (
|
{!hasThreads && currentUser && (
|
||||||
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
|
<div className="flex items-center gap-3 px-4 pt-4 pb-1">
|
||||||
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
|
<Avatar className="size-10">
|
||||||
<p className="text-sm text-muted-foreground">No comments yet</p>
|
<AvatarImage
|
||||||
<p className="text-xs text-muted-foreground/70">
|
src={currentUser.avatar_url ?? undefined}
|
||||||
Start a conversation about this response
|
alt={currentUser.display_name ?? currentUser.email}
|
||||||
</p>
|
/>
|
||||||
|
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
|
||||||
|
{getInitials(currentUser.display_name, currentUser.email)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{currentUser.display_name ?? currentUser.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={cn("p-3", showEmptyState && !isMobile && "border-t", isMobile && "border-t")}>
|
<div className={cn("p-3", isMobile && "fixed bottom-0 left-0 right-0 z-50 bg-card border-t")}>
|
||||||
{isComposerOpen ? (
|
<CommentComposer
|
||||||
<CommentComposer
|
members={members}
|
||||||
members={members}
|
membersLoading={membersLoading}
|
||||||
membersLoading={membersLoading}
|
placeholder="Comment or @mention"
|
||||||
placeholder="Write a comment..."
|
submitLabel="Comment"
|
||||||
submitLabel="Comment"
|
isSubmitting={isSubmitting}
|
||||||
isSubmitting={isSubmitting}
|
onSubmit={handleCommentSubmit}
|
||||||
onSubmit={handleCommentSubmit}
|
autoFocus={!hasThreads}
|
||||||
onCancel={handleComposerCancel}
|
/>
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full justify-start text-muted-foreground hover:text-foreground"
|
|
||||||
onClick={() => setIsComposerOpen(true)}
|
|
||||||
>
|
|
||||||
<MessageSquarePlus className="mr-2 size-4" />
|
|
||||||
Add a comment...
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageSquare } from "lucide-react";
|
import { MessageSquare } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHandle,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
|
import { CommentPanelContainer } from "../comment-panel-container/comment-panel-container";
|
||||||
|
|
@ -15,22 +22,39 @@ export function CommentSheet({
|
||||||
}: CommentSheetProps) {
|
}: CommentSheetProps) {
|
||||||
const isBottomSheet = side === "bottom";
|
const isBottomSheet = side === "bottom";
|
||||||
|
|
||||||
|
// Use Drawer for mobile (bottom), Sheet for medium screens (right)
|
||||||
|
if (isBottomSheet) {
|
||||||
|
return (
|
||||||
|
<Drawer open={isOpen} onOpenChange={onOpenChange} shouldScaleBackground={false}>
|
||||||
|
<DrawerContent className="h-[85vh] max-h-[85vh] z-80" overlayClassName="z-80">
|
||||||
|
<DrawerHandle />
|
||||||
|
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||||
|
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||||
|
<MessageSquare className="size-5" />
|
||||||
|
Comments
|
||||||
|
{commentCount > 0 && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||||
|
{commentCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||||
|
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Sheet for medium screens (right side)
|
||||||
return (
|
return (
|
||||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side={side}
|
side={side}
|
||||||
className={cn(
|
className={cn("flex flex-col gap-0 overflow-hidden p-0 h-full w-full max-w-md")}
|
||||||
"flex flex-col p-0",
|
|
||||||
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{/* Drag handle indicator - only for bottom sheet */}
|
<SheetHeader className="flex-shrink-0 px-4 py-4">
|
||||||
{isBottomSheet && (
|
|
||||||
<div className="flex justify-center pt-3 pb-1">
|
|
||||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<SheetHeader className={cn("flex-shrink-0 border-b px-4", isBottomSheet ? "pb-3" : "py-4")}>
|
|
||||||
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
|
<SheetTitle className="flex items-center gap-2 text-base font-semibold">
|
||||||
<MessageSquare className="size-5" />
|
<MessageSquare className="size-5" />
|
||||||
Comments
|
Comments
|
||||||
|
|
@ -41,7 +65,7 @@ export function CommentSheet({
|
||||||
)}
|
)}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||||
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
|
<CommentPanelContainer messageId={messageId} isOpen={true} variant="mobile" />
|
||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
|
||||||
|
|
@ -128,23 +128,21 @@ export function CommentThread({
|
||||||
{/* Reply composer or button */}
|
{/* Reply composer or button */}
|
||||||
|
|
||||||
{isReplyComposerOpen ? (
|
{isReplyComposerOpen ? (
|
||||||
<>
|
<div className="pt-3">
|
||||||
<div className="pt-3">
|
<CommentComposer
|
||||||
<CommentComposer
|
members={members}
|
||||||
members={members}
|
membersLoading={membersLoading}
|
||||||
membersLoading={membersLoading}
|
placeholder="Reply or @mention"
|
||||||
placeholder="Write a reply..."
|
submitLabel="Reply"
|
||||||
submitLabel="Reply"
|
isSubmitting={isSubmitting}
|
||||||
isSubmitting={isSubmitting}
|
onSubmit={handleReplySubmit}
|
||||||
onSubmit={handleReplySubmit}
|
onCancel={handleReplyCancel}
|
||||||
onCancel={handleReplyCancel}
|
autoFocus
|
||||||
autoFocus
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||||
<MessageSquare className="mr-1.5 size-3" />
|
<MessageSquare className="mr-1 size-3" />
|
||||||
Reply
|
Reply
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -156,7 +154,7 @@ export function CommentThread({
|
||||||
{!hasReplies && !isReplyComposerOpen && (
|
{!hasReplies && !isReplyComposerOpen && (
|
||||||
<div className="ml-7 mt-1">
|
<div className="ml-7 mt-1">
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleReply}>
|
||||||
<MessageSquare className="mr-1.5 size-3" />
|
<MessageSquare className="mr-1 size-3" />
|
||||||
Reply
|
Reply
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageSquare } from "lucide-react";
|
import { MessageSquarePlus } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CommentTriggerProps } from "./types";
|
import type { CommentTriggerProps } from "./types";
|
||||||
|
|
@ -25,7 +25,7 @@ export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: Comm
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
<MessageSquarePlus className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
|
||||||
{hasComments && (
|
{hasComments && (
|
||||||
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||||
{commentCount > 9 ? "9+" : commentCount}
|
{commentCount > 9 ? "9+" : commentCount}
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ export function DashboardBreadcrumb() {
|
||||||
const segments = path.split("/").filter(Boolean);
|
const segments = path.split("/").filter(Boolean);
|
||||||
const breadcrumbs: BreadcrumbItemInterface[] = [];
|
const breadcrumbs: BreadcrumbItemInterface[] = [];
|
||||||
|
|
||||||
// Handle search space
|
// Handle search space (start directly with search space, skip "Dashboard")
|
||||||
if (segments[0] === "dashboard" && segments[1]) {
|
if (segments[0] === "dashboard" && segments[1]) {
|
||||||
// Use the actual search space name if available, otherwise fall back to the ID
|
// Use the actual search space name if available, otherwise fall back to the ID
|
||||||
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
|
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
import { Inbox, LogOut, SquareLibrary, Trash2 } from "lucide-react";
|
||||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||||
import { cleanupElectric } from "@/lib/electric/client";
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
|
|
@ -29,19 +30,18 @@ import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
||||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
||||||
|
import { InboxSidebar } from "../ui/sidebar/InboxSidebar";
|
||||||
|
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
breadcrumb?: React.ReactNode;
|
breadcrumb?: React.ReactNode;
|
||||||
languageSwitcher?: React.ReactNode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutDataProvider({
|
export function LayoutDataProvider({
|
||||||
searchSpaceId,
|
searchSpaceId,
|
||||||
children,
|
children,
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
languageSwitcher,
|
|
||||||
}: LayoutDataProviderProps) {
|
}: LayoutDataProviderProps) {
|
||||||
const t = useTranslations("dashboard");
|
const t = useTranslations("dashboard");
|
||||||
const tCommon = useTranslations("common");
|
const tCommon = useTranslations("common");
|
||||||
|
|
@ -61,8 +61,8 @@ export function LayoutDataProvider({
|
||||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Fetch current search space
|
// Fetch current search space (for caching purposes)
|
||||||
const { data: searchSpace } = useQuery({
|
useQuery({
|
||||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
|
|
@ -79,9 +79,25 @@ export function LayoutDataProvider({
|
||||||
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
const [isAllSharedChatsSidebarOpen, setIsAllSharedChatsSidebarOpen] = useState(false);
|
||||||
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
const [isAllPrivateChatsSidebarOpen, setIsAllPrivateChatsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// Inbox sidebar state
|
||||||
|
const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false);
|
||||||
|
|
||||||
// Search space dialog state
|
// Search space dialog state
|
||||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Inbox hook
|
||||||
|
const userId = user?.id ? String(user.id) : null;
|
||||||
|
const {
|
||||||
|
inboxItems,
|
||||||
|
unreadCount,
|
||||||
|
loading: inboxLoading,
|
||||||
|
loadingMore: inboxLoadingMore,
|
||||||
|
hasMore: inboxHasMore,
|
||||||
|
loadMore: inboxLoadMore,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
} = useInbox(userId, Number(searchSpaceId) || null, null);
|
||||||
|
|
||||||
// Delete dialogs state
|
// Delete dialogs state
|
||||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
|
@ -151,8 +167,15 @@ export function LayoutDataProvider({
|
||||||
icon: SquareLibrary,
|
icon: SquareLibrary,
|
||||||
isActive: pathname?.includes("/documents"),
|
isActive: pathname?.includes("/documents"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Inbox",
|
||||||
|
url: "#inbox", // Special URL to indicate this is handled differently
|
||||||
|
icon: Inbox,
|
||||||
|
isActive: isInboxSidebarOpen,
|
||||||
|
badge: unreadCount > 0 ? (unreadCount > 99 ? "99+" : unreadCount) : undefined,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[searchSpaceId, pathname]
|
[searchSpaceId, pathname, isInboxSidebarOpen, unreadCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
|
|
@ -244,6 +267,11 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
const handleNavItemClick = useCallback(
|
const handleNavItemClick = useCallback(
|
||||||
(item: NavItem) => {
|
(item: NavItem) => {
|
||||||
|
// Handle inbox specially - open sidebar instead of navigating
|
||||||
|
if (item.url === "#inbox") {
|
||||||
|
setIsInboxSidebarOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
},
|
},
|
||||||
[router]
|
[router]
|
||||||
|
|
@ -296,10 +324,6 @@ export function LayoutDataProvider({
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const handleToggleTheme = useCallback(() => {
|
|
||||||
setTheme(theme === "dark" ? "light" : "dark");
|
|
||||||
}, [theme, setTheme]);
|
|
||||||
|
|
||||||
const handleViewAllSharedChats = useCallback(() => {
|
const handleViewAllSharedChats = useCallback(() => {
|
||||||
setIsAllSharedChatsSidebarOpen(true);
|
setIsAllSharedChatsSidebarOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -369,9 +393,8 @@ export function LayoutDataProvider({
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
languageSwitcher={languageSwitcher}
|
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={handleToggleTheme}
|
setTheme={setTheme}
|
||||||
isChatPage={isChatPage}
|
isChatPage={isChatPage}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -518,6 +541,20 @@ export function LayoutDataProvider({
|
||||||
searchSpaceId={searchSpaceId}
|
searchSpaceId={searchSpaceId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Inbox Sidebar */}
|
||||||
|
<InboxSidebar
|
||||||
|
open={isInboxSidebarOpen}
|
||||||
|
onOpenChange={setIsInboxSidebarOpen}
|
||||||
|
inboxItems={inboxItems}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
loading={inboxLoading}
|
||||||
|
loadingMore={inboxLoadingMore}
|
||||||
|
hasMore={inboxHasMore}
|
||||||
|
loadMore={inboxLoadMore}
|
||||||
|
markAsRead={markAsRead}
|
||||||
|
markAllAsRead={markAllAsRead}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Create Search Space Dialog */}
|
{/* Create Search Space Dialog */}
|
||||||
<CreateSearchSpaceDialog
|
<CreateSearchSpaceDialog
|
||||||
open={isCreateSearchSpaceDialogOpen}
|
open={isCreateSearchSpaceDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,49 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Moon, Sun } from "lucide-react";
|
import { useAtomValue } from "jotai";
|
||||||
import { NotificationButton } from "@/components/notifications/NotificationButton";
|
import { usePathname } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
|
||||||
|
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
breadcrumb?: React.ReactNode;
|
breadcrumb?: React.ReactNode;
|
||||||
languageSwitcher?: React.ReactNode;
|
|
||||||
theme?: string;
|
|
||||||
onToggleTheme?: () => void;
|
|
||||||
mobileMenuTrigger?: React.ReactNode;
|
mobileMenuTrigger?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
|
||||||
breadcrumb,
|
const pathname = usePathname();
|
||||||
languageSwitcher,
|
|
||||||
theme,
|
// Check if we're on a chat page
|
||||||
onToggleTheme,
|
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||||
mobileMenuTrigger,
|
|
||||||
}: HeaderProps) {
|
// Use Jotai atom for thread state (synced from chat page)
|
||||||
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
|
|
||||||
|
// Show button only when we have a thread id (thread exists and is synced to Jotai)
|
||||||
|
const hasThread = isChatPage && currentThreadState.id !== null;
|
||||||
|
|
||||||
|
// Create minimal thread object for ChatShareButton (used for API calls)
|
||||||
|
const threadForButton: ThreadRecord | null =
|
||||||
|
hasThread && currentThreadState.id !== null
|
||||||
|
? {
|
||||||
|
id: currentThreadState.id,
|
||||||
|
visibility: currentThreadState.visibility ?? "PRIVATE",
|
||||||
|
// These fields are not used by ChatShareButton for display, only for checks
|
||||||
|
created_by_id: null,
|
||||||
|
search_space_id: 0,
|
||||||
|
title: "",
|
||||||
|
archived: false,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const handleVisibilityChange = (_visibility: ChatVisibility) => {
|
||||||
|
// Visibility change is handled by ChatShareButton internally via Jotai
|
||||||
|
// This callback can be used for additional side effects if needed
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
||||||
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
||||||
|
|
@ -29,24 +53,11 @@ export function Header({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Actions */}
|
{/* Right side - Actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-4">
|
||||||
{/* Notifications */}
|
{/* Share button - only show on chat pages when thread exists */}
|
||||||
<NotificationButton />
|
{hasThread && (
|
||||||
|
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
|
||||||
{/* Theme toggle */}
|
|
||||||
{onToggleTheme && (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" onClick={onToggleTheme} className="h-8 w-8">
|
|
||||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
|
||||||
<span className="sr-only">Toggle theme</span>
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{languageSwitcher}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,8 @@ interface LayoutShellProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
breadcrumb?: React.ReactNode;
|
breadcrumb?: React.ReactNode;
|
||||||
languageSwitcher?: React.ReactNode;
|
|
||||||
theme?: string;
|
theme?: string;
|
||||||
onToggleTheme?: () => void;
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
defaultCollapsed?: boolean;
|
defaultCollapsed?: boolean;
|
||||||
isChatPage?: boolean;
|
isChatPage?: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -69,9 +68,8 @@ export function LayoutShell({
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
breadcrumb,
|
breadcrumb,
|
||||||
languageSwitcher,
|
|
||||||
theme,
|
theme,
|
||||||
onToggleTheme,
|
setTheme,
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
isChatPage = false,
|
isChatPage = false,
|
||||||
children,
|
children,
|
||||||
|
|
@ -88,9 +86,6 @@ export function LayoutShell({
|
||||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||||
<Header
|
<Header
|
||||||
breadcrumb={breadcrumb}
|
breadcrumb={breadcrumb}
|
||||||
languageSwitcher={languageSwitcher}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={onToggleTheme}
|
|
||||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -120,6 +115,8 @@ export function LayoutShell({
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
|
theme={theme}
|
||||||
|
setTheme={setTheme}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
|
|
@ -166,16 +163,13 @@ export function LayoutShell({
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
|
theme={theme}
|
||||||
|
setTheme={setTheme}
|
||||||
className="hidden md:flex border-r shrink-0"
|
className="hidden md:flex border-r shrink-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="flex-1 flex flex-col min-w-0">
|
<main className="flex-1 flex flex-col min-w-0">
|
||||||
<Header
|
<Header breadcrumb={breadcrumb} />
|
||||||
breadcrumb={breadcrumb}
|
|
||||||
languageSwitcher={languageSwitcher}
|
|
||||||
theme={theme}
|
|
||||||
onToggleTheme={onToggleTheme}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,12 @@ import { format } from "date-fns";
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
ArchiveIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
|
||||||
MessageCircleMore,
|
MessageCircleMore,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
RotateCcwIcon,
|
RotateCcwIcon,
|
||||||
Search,
|
Search,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
User,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import {
|
import {
|
||||||
|
|
@ -237,20 +238,9 @@ export function AllPrivateChatsSidebar({
|
||||||
aria-label={t("chats") || "Private Chats"}
|
aria-label={t("chats") || "Private Chats"}
|
||||||
>
|
>
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<User className="h-5 w-5 text-primary" />
|
||||||
<Lock className="h-5 w-5 text-primary" />
|
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
||||||
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -277,32 +267,38 @@ export function AllPrivateChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSearchMode && (
|
{!isSearchMode && (
|
||||||
<div className="shrink-0 flex border-b mx-4">
|
<Tabs
|
||||||
<button
|
value={showArchived ? "archived" : "active"}
|
||||||
type="button"
|
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||||
onClick={() => setShowArchived(false)}
|
className="shrink-0 mx-4"
|
||||||
className={cn(
|
>
|
||||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
!showArchived
|
<TabsTrigger
|
||||||
? "border-b-2 border-primary text-primary"
|
value="active"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
)}
|
>
|
||||||
>
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
Active ({activeCount})
|
<MessageCircleMore className="h-4 w-4" />
|
||||||
</button>
|
<span>Active</span>
|
||||||
<button
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
type="button"
|
{activeCount}
|
||||||
onClick={() => setShowArchived(true)}
|
</span>
|
||||||
className={cn(
|
</span>
|
||||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
</TabsTrigger>
|
||||||
showArchived
|
<TabsTrigger
|
||||||
? "border-b-2 border-primary text-primary"
|
value="archived"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
)}
|
>
|
||||||
>
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
Archived ({archivedCount})
|
<ArchiveIcon className="h-4 w-4" />
|
||||||
</button>
|
<span>Archived</span>
|
||||||
</div>
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
{archivedCount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
|
|
@ -371,7 +367,7 @@ export function AllPrivateChatsSidebar({
|
||||||
{isDeleting ? (
|
{isDeleting ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -419,7 +415,7 @@ export function AllPrivateChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-8">
|
||||||
<Lock className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
<User className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{showArchived
|
{showArchived
|
||||||
? t("no_archived_chats") || "No archived chats"
|
? t("no_archived_chats") || "No archived chats"
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import {
|
import {
|
||||||
|
|
@ -237,20 +238,9 @@ export function AllSharedChatsSidebar({
|
||||||
aria-label={t("shared_chats") || "Shared Chats"}
|
aria-label={t("shared_chats") || "Shared Chats"}
|
||||||
>
|
>
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Users className="h-5 w-5 text-primary" />
|
||||||
<Users className="h-5 w-5 text-primary" />
|
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
||||||
<h2 className="text-lg font-semibold">{t("shared_chats") || "Shared Chats"}</h2>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 rounded-full"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|
@ -277,32 +267,38 @@ export function AllSharedChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isSearchMode && (
|
{!isSearchMode && (
|
||||||
<div className="shrink-0 flex border-b mx-4">
|
<Tabs
|
||||||
<button
|
value={showArchived ? "archived" : "active"}
|
||||||
type="button"
|
onValueChange={(value) => setShowArchived(value === "archived")}
|
||||||
onClick={() => setShowArchived(false)}
|
className="shrink-0 mx-4"
|
||||||
className={cn(
|
>
|
||||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
!showArchived
|
<TabsTrigger
|
||||||
? "border-b-2 border-primary text-primary"
|
value="active"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
)}
|
>
|
||||||
>
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
Active ({activeCount})
|
<MessageCircleMore className="h-4 w-4" />
|
||||||
</button>
|
<span>Active</span>
|
||||||
<button
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
type="button"
|
{activeCount}
|
||||||
onClick={() => setShowArchived(true)}
|
</span>
|
||||||
className={cn(
|
</span>
|
||||||
"flex-1 px-3 py-2 text-center text-xs font-medium transition-colors",
|
</TabsTrigger>
|
||||||
showArchived
|
<TabsTrigger
|
||||||
? "border-b-2 border-primary text-primary"
|
value="archived"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
)}
|
>
|
||||||
>
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
Archived ({archivedCount})
|
<ArchiveIcon className="h-4 w-4" />
|
||||||
</button>
|
<span>Archived</span>
|
||||||
</div>
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
{archivedCount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
|
|
@ -371,7 +367,7 @@ export function AllSharedChatsSidebar({
|
||||||
{isDeleting ? (
|
{isDeleting ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">{t("more_options") || "More options"}</span>
|
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,11 @@ export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItem
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Actions dropdown */}
|
{/* Actions dropdown */}
|
||||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-100 md:opacity-0 md:group-hover/item:opacity-100 transition-opacity">
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<span className="sr-only">{t("more_options")}</span>
|
<span className="sr-only">{t("more_options")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
|
||||||
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
854
surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx
Normal file
|
|
@ -0,0 +1,854 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
AtSign,
|
||||||
|
BellDot,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
CheckCircle2,
|
||||||
|
History,
|
||||||
|
Inbox,
|
||||||
|
LayoutGrid,
|
||||||
|
ListFilter,
|
||||||
|
Search,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHandle,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
} from "@/components/ui/drawer";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import {
|
||||||
|
type ConnectorIndexingMetadata,
|
||||||
|
type NewMentionMetadata,
|
||||||
|
isConnectorIndexingMetadata,
|
||||||
|
isNewMentionMetadata,
|
||||||
|
} from "@/contracts/types/inbox.types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get initials from name or email for avatar fallback
|
||||||
|
*/
|
||||||
|
function getInitials(name: string | null | undefined, email: string | null | undefined): string {
|
||||||
|
if (name) {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
const localPart = email.split("@")[0];
|
||||||
|
return localPart.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
return "U";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for connector type
|
||||||
|
*/
|
||||||
|
function getConnectorTypeDisplayName(connectorType: string): string {
|
||||||
|
const displayNames: Record<string, string> = {
|
||||||
|
GITHUB_CONNECTOR: "GitHub",
|
||||||
|
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||||
|
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||||
|
GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||||
|
LINEAR_CONNECTOR: "Linear",
|
||||||
|
NOTION_CONNECTOR: "Notion",
|
||||||
|
SLACK_CONNECTOR: "Slack",
|
||||||
|
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||||
|
DISCORD_CONNECTOR: "Discord",
|
||||||
|
JIRA_CONNECTOR: "Jira",
|
||||||
|
CONFLUENCE_CONNECTOR: "Confluence",
|
||||||
|
BOOKSTACK_CONNECTOR: "BookStack",
|
||||||
|
CLICKUP_CONNECTOR: "ClickUp",
|
||||||
|
AIRTABLE_CONNECTOR: "Airtable",
|
||||||
|
LUMA_CONNECTOR: "Luma",
|
||||||
|
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||||
|
WEBCRAWLER_CONNECTOR: "Web Crawler",
|
||||||
|
YOUTUBE_CONNECTOR: "YouTube",
|
||||||
|
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||||
|
MCP_CONNECTOR: "MCP",
|
||||||
|
TAVILY_API: "Tavily",
|
||||||
|
SEARXNG_API: "SearXNG",
|
||||||
|
LINKUP_API: "Linkup",
|
||||||
|
BAIDU_SEARCH_API: "Baidu",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
displayNames[connectorType] ||
|
||||||
|
connectorType
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/CONNECTOR|API/gi, "")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type InboxTab = "mentions" | "status";
|
||||||
|
type InboxFilter = "all" | "unread";
|
||||||
|
|
||||||
|
interface InboxSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
inboxItems: InboxItem[];
|
||||||
|
unreadCount: number;
|
||||||
|
loading: boolean;
|
||||||
|
loadingMore?: boolean;
|
||||||
|
hasMore?: boolean;
|
||||||
|
loadMore?: () => void;
|
||||||
|
markAsRead: (id: number) => Promise<boolean>;
|
||||||
|
markAllAsRead: () => Promise<boolean>;
|
||||||
|
onCloseMobileSidebar?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InboxSidebar({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
inboxItems,
|
||||||
|
unreadCount,
|
||||||
|
loading,
|
||||||
|
loadingMore = false,
|
||||||
|
hasMore = false,
|
||||||
|
loadMore,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
onCloseMobileSidebar,
|
||||||
|
}: InboxSidebarProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const router = useRouter();
|
||||||
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
|
||||||
|
const [activeFilter, setActiveFilter] = useState<InboxFilter>("all");
|
||||||
|
const [selectedConnector, setSelectedConnector] = useState<string | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
// Dropdown state for filter menu (desktop only)
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null);
|
||||||
|
// Drawer state for filter menu (mobile only)
|
||||||
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
|
const [markingAsReadId, setMarkingAsReadId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Prefetch trigger ref - placed on item near the end
|
||||||
|
const prefetchTriggerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleEscape);
|
||||||
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Reset connector filter when switching away from status tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== "status") {
|
||||||
|
setSelectedConnector(null);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
// Split items by type
|
||||||
|
const mentionItems = useMemo(
|
||||||
|
() => inboxItems.filter((item) => item.type === "new_mention"),
|
||||||
|
[inboxItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusItems = useMemo(
|
||||||
|
() =>
|
||||||
|
inboxItems.filter(
|
||||||
|
(item) => item.type === "connector_indexing" || item.type === "document_processing"
|
||||||
|
),
|
||||||
|
[inboxItems]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get unique connector types from status items for filtering
|
||||||
|
const uniqueConnectorTypes = useMemo(() => {
|
||||||
|
const connectorTypes = new Set<string>();
|
||||||
|
|
||||||
|
statusItems
|
||||||
|
.filter((item) => item.type === "connector_indexing")
|
||||||
|
.forEach((item) => {
|
||||||
|
// Use type guard for safe metadata access
|
||||||
|
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||||
|
connectorTypes.add(item.metadata.connector_type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(connectorTypes).map((type) => ({
|
||||||
|
type,
|
||||||
|
displayName: getConnectorTypeDisplayName(type),
|
||||||
|
}));
|
||||||
|
}, [statusItems]);
|
||||||
|
|
||||||
|
// Get items for current tab
|
||||||
|
const currentTabItems = activeTab === "mentions" ? mentionItems : statusItems;
|
||||||
|
|
||||||
|
// Filter items based on filter type, connector filter, and search query
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
let items = currentTabItems;
|
||||||
|
|
||||||
|
// Apply read/unread filter
|
||||||
|
if (activeFilter === "unread") {
|
||||||
|
items = items.filter((item) => !item.read);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply connector filter (only for status tab)
|
||||||
|
if (activeTab === "status" && selectedConnector) {
|
||||||
|
items = items.filter((item) => {
|
||||||
|
if (item.type === "connector_indexing") {
|
||||||
|
// Use type guard for safe metadata access
|
||||||
|
if (isConnectorIndexingMetadata(item.metadata)) {
|
||||||
|
return item.metadata.connector_type === selectedConnector;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return false; // Hide document_processing when a specific connector is selected
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search query
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(item) =>
|
||||||
|
item.title.toLowerCase().includes(query) || item.message.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [currentTabItems, activeFilter, activeTab, selectedConnector, searchQuery]);
|
||||||
|
|
||||||
|
// Intersection Observer for infinite scroll with prefetching
|
||||||
|
// Only active when not searching (search results are client-side filtered)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loadMore || !hasMore || loadingMore || !open || searchQuery.trim()) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
// When trigger element is visible, load more
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
root: null, // viewport
|
||||||
|
rootMargin: "100px", // Start loading 100px before visible
|
||||||
|
threshold: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (prefetchTriggerRef.current) {
|
||||||
|
observer.observe(prefetchTriggerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [loadMore, hasMore, loadingMore, open, searchQuery, filteredItems.length]);
|
||||||
|
|
||||||
|
// Count unread items per tab
|
||||||
|
const unreadMentionsCount = useMemo(() => {
|
||||||
|
return mentionItems.filter((item) => !item.read).length;
|
||||||
|
}, [mentionItems]);
|
||||||
|
|
||||||
|
const unreadStatusCount = useMemo(() => {
|
||||||
|
return statusItems.filter((item) => !item.read).length;
|
||||||
|
}, [statusItems]);
|
||||||
|
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
async (item: InboxItem) => {
|
||||||
|
if (!item.read) {
|
||||||
|
setMarkingAsReadId(item.id);
|
||||||
|
await markAsRead(item.id);
|
||||||
|
setMarkingAsReadId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === "new_mention") {
|
||||||
|
// Use type guard for safe metadata access
|
||||||
|
if (isNewMentionMetadata(item.metadata)) {
|
||||||
|
const searchSpaceId = item.search_space_id;
|
||||||
|
const threadId = item.metadata.thread_id;
|
||||||
|
const commentId = item.metadata.comment_id;
|
||||||
|
|
||||||
|
if (searchSpaceId && threadId) {
|
||||||
|
const url = commentId
|
||||||
|
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
||||||
|
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
||||||
|
onOpenChange(false);
|
||||||
|
onCloseMobileSidebar?.();
|
||||||
|
router.push(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[markAsRead, router, onOpenChange, onCloseMobileSidebar]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = useCallback(async () => {
|
||||||
|
await markAllAsRead();
|
||||||
|
}, [markAllAsRead]);
|
||||||
|
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffMins < 1) return "now";
|
||||||
|
if (diffMins < 60) return `${diffMins}m`;
|
||||||
|
if (diffHours < 24) return `${diffHours}h`;
|
||||||
|
if (diffDays < 7) return `${diffDays}d`;
|
||||||
|
return `${Math.floor(diffDays / 7)}w`;
|
||||||
|
} catch {
|
||||||
|
return "now";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (item: InboxItem) => {
|
||||||
|
// For mentions, show the author's avatar with initials fallback
|
||||||
|
if (item.type === "new_mention") {
|
||||||
|
// Use type guard for safe metadata access
|
||||||
|
if (isNewMentionMetadata(item.metadata)) {
|
||||||
|
const authorName = item.metadata.author_name;
|
||||||
|
const avatarUrl = item.metadata.author_avatar_url;
|
||||||
|
const authorEmail = item.metadata.author_email;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
|
||||||
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
|
{getInitials(authorName, authorEmail)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fallback for invalid metadata
|
||||||
|
return (
|
||||||
|
<Avatar className="h-8 w-8">
|
||||||
|
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
|
||||||
|
{getInitials(null, null)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For status items (connector/document), show status icons
|
||||||
|
// Safely access status from metadata
|
||||||
|
const metadata = item.metadata as Record<string, unknown>;
|
||||||
|
const status = typeof metadata?.status === "string" ? metadata.status : undefined;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "in_progress":
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||||
|
<Spinner size="sm" className="text-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-green-500/10">
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "failed":
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-red-500/10">
|
||||||
|
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-8 flex items-center justify-center rounded-full bg-muted">
|
||||||
|
<History className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getEmptyStateMessage = () => {
|
||||||
|
if (activeTab === "mentions") {
|
||||||
|
return {
|
||||||
|
title: t("no_mentions") || "No mentions",
|
||||||
|
hint: t("no_mentions_hint") || "You'll see mentions from others here",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: t("no_status_updates") || "No status updates",
|
||||||
|
hint: t("no_status_updates_hint") || "Document and connector updates will appear here",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 z-70 bg-black/50"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
className="fixed inset-y-0 left-0 z-70 w-90 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t("inbox") || "Inbox"}
|
||||||
|
>
|
||||||
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Inbox className="h-5 w-5 text-primary" />
|
||||||
|
<h2 className="text-lg font-semibold">{t("inbox") || "Inbox"}</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Mobile: Button that opens bottom drawer */}
|
||||||
|
{isMobile ? (
|
||||||
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={() => setFilterDrawerOpen(true)}
|
||||||
|
>
|
||||||
|
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Drawer
|
||||||
|
open={filterDrawerOpen}
|
||||||
|
onOpenChange={setFilterDrawerOpen}
|
||||||
|
shouldScaleBackground={false}
|
||||||
|
>
|
||||||
|
<DrawerContent className="max-h-[70vh] z-80" overlayClassName="z-80">
|
||||||
|
<DrawerHandle />
|
||||||
|
<DrawerHeader className="px-4 pb-3 pt-2">
|
||||||
|
<DrawerTitle className="flex items-center gap-2 text-base font-semibold">
|
||||||
|
<ListFilter className="size-5" />
|
||||||
|
{t("filter") || "Filter"}
|
||||||
|
</DrawerTitle>
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{/* Filter section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||||
|
{t("filter") || "Filter"}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveFilter("all");
|
||||||
|
setFilterDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
|
activeFilter === "all"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Inbox className="h-4 w-4" />
|
||||||
|
<span>{t("all") || "All"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setActiveFilter("unread");
|
||||||
|
setFilterDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
|
activeFilter === "unread"
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<BellDot className="h-4 w-4" />
|
||||||
|
<span>{t("unread") || "Unread"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Connectors section - only for status tab */}
|
||||||
|
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground/80 font-medium px-1">
|
||||||
|
{t("connectors") || "Connectors"}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedConnector(null);
|
||||||
|
setFilterDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
|
selectedConnector === null
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span>{t("all_connectors") || "All connectors"}</span>
|
||||||
|
</span>
|
||||||
|
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
{uniqueConnectorTypes.map((connector) => (
|
||||||
|
<button
|
||||||
|
key={connector.type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedConnector(connector.type);
|
||||||
|
setFilterDrawerOpen(false);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors",
|
||||||
|
selectedConnector === connector.type
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||||
|
<span>{connector.displayName}</span>
|
||||||
|
</span>
|
||||||
|
{selectedConnector === connector.type && (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
/* Desktop: Dropdown menu */
|
||||||
|
<DropdownMenu
|
||||||
|
open={openDropdown === "filter"}
|
||||||
|
onOpenChange={(isOpen) => setOpenDropdown(isOpen ? "filter" : null)}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
|
||||||
|
<ListFilter className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="sr-only">{t("filter") || "Filter"}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="z-80">{t("filter") || "Filter"}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className={cn("z-80", activeTab === "status" ? "w-52" : "w-44")}
|
||||||
|
>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal">
|
||||||
|
{t("filter") || "Filter"}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setActiveFilter("all")}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Inbox className="h-4 w-4" />
|
||||||
|
<span>{t("all") || "All"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "all" && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setActiveFilter("unread")}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<BellDot className="h-4 w-4" />
|
||||||
|
<span>{t("unread") || "Unread"}</span>
|
||||||
|
</span>
|
||||||
|
{activeFilter === "unread" && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{activeTab === "status" && uniqueConnectorTypes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="text-xs text-muted-foreground/80 font-normal mt-2">
|
||||||
|
{t("connectors") || "Connectors"}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setSelectedConnector(null)}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span>{t("all_connectors") || "All connectors"}</span>
|
||||||
|
</span>
|
||||||
|
{selectedConnector === null && <Check className="h-4 w-4" />}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{uniqueConnectorTypes.map((connector) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={connector.type}
|
||||||
|
onClick={() => setSelectedConnector(connector.type)}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{getConnectorIcon(connector.type, "h-4 w-4")}
|
||||||
|
<span>{connector.displayName}</span>
|
||||||
|
</span>
|
||||||
|
{selectedConnector === connector.type && (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
onClick={handleMarkAllAsRead}
|
||||||
|
disabled={unreadCount === 0}
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="sr-only">{t("mark_all_read") || "Mark all as read"}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="z-80">
|
||||||
|
{t("mark_all_read") || "Mark all as read"}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search_inbox") || "Search inbox"}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 pr-8 h-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => setActiveTab(value as InboxTab)}
|
||||||
|
className="shrink-0 mx-4"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full h-auto p-0 bg-transparent rounded-none border-b">
|
||||||
|
<TabsTrigger
|
||||||
|
value="mentions"
|
||||||
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
|
>
|
||||||
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
|
<AtSign className="h-4 w-4" />
|
||||||
|
<span>{t("mentions") || "Mentions"}</span>
|
||||||
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
{unreadMentionsCount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="status"
|
||||||
|
className="flex-1 rounded-none border-b-2 border-transparent px-1 py-2 text-xs font-medium data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:shadow-none"
|
||||||
|
>
|
||||||
|
<span className="w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 rounded-lg hover:bg-muted transition-colors">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
<span>{t("status") || "Status"}</span>
|
||||||
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-primary/20 text-muted-foreground text-xs font-medium">
|
||||||
|
{unreadStatusCount}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto overflow-x-hidden p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Spinner size="md" className="text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : filteredItems.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredItems.map((item, index) => {
|
||||||
|
const isMarkingAsRead = markingAsReadId === item.id;
|
||||||
|
// Place prefetch trigger on 5th item from end (only if not searching)
|
||||||
|
const isPrefetchTrigger =
|
||||||
|
!searchQuery && hasMore && index === filteredItems.length - 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
ref={isPrefetchTrigger ? prefetchTriggerRef : undefined}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-3 rounded-lg px-3 py-3 text-sm h-[80px] overflow-hidden",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"transition-colors cursor-pointer",
|
||||||
|
isMarkingAsRead && "opacity-50 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={isMarkingAsRead}
|
||||||
|
className="flex items-center gap-3 flex-1 min-w-0 text-left overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="shrink-0">{getStatusIcon(item)}</div>
|
||||||
|
<div className="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-xs font-medium line-clamp-2",
|
||||||
|
!item.read && "font-semibold"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground line-clamp-2 mt-0.5">
|
||||||
|
{convertRenderedToDisplay(item.message)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="start" className="max-w-[250px]">
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
{convertRenderedToDisplay(item.message)}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Time and unread dot - fixed width to prevent content shift */}
|
||||||
|
<div className="flex items-center justify-end gap-1.5 shrink-0 w-10">
|
||||||
|
<span className="text-[10px] text-muted-foreground">
|
||||||
|
{formatTime(item.created_at)}
|
||||||
|
</span>
|
||||||
|
{!item.read && (
|
||||||
|
<span className="h-2 w-2 rounded-full bg-blue-500 shrink-0" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Fallback trigger at the very end if less than 5 items and not searching */}
|
||||||
|
{!searchQuery && filteredItems.length < 5 && hasMore && (
|
||||||
|
<div ref={prefetchTriggerRef} className="h-1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : searchQuery ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Search className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("no_results_found") || "No results found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{t("try_different_search") || "Try a different search term"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
{activeTab === "mentions" ? (
|
||||||
|
<AtSign className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
|
) : (
|
||||||
|
<History className="h-12 w-12 mx-auto text-muted-foreground mb-3" />
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">{getEmptyStateMessage().title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{getEmptyStateMessage().hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,8 @@ interface MobileSidebarProps {
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
|
theme?: string;
|
||||||
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
||||||
|
|
@ -70,6 +72,8 @@ export function MobileSidebar({
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
}: MobileSidebarProps) {
|
}: MobileSidebarProps) {
|
||||||
const handleSearchSpaceSelect = (id: number) => {
|
const handleSearchSpaceSelect = (id: number) => {
|
||||||
onSearchSpaceSelect(id);
|
onSearchSpaceSelect(id);
|
||||||
|
|
@ -145,6 +149,8 @@ export function MobileSidebar({
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
|
theme={theme}
|
||||||
|
setTheme={setTheme}
|
||||||
className="w-full border-none"
|
className="w-full border-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onItemClick?.(item)}
|
onClick={() => onItemClick?.(item)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
"relative flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||||
"hover:bg-accent hover:text-accent-foreground",
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||||
item.isActive && "bg-accent text-accent-foreground"
|
item.isActive && "bg-accent text-accent-foreground"
|
||||||
|
|
@ -38,6 +38,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
{...joyrideAttr}
|
{...joyrideAttr}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4" />
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.badge && (
|
||||||
|
<span className="absolute top-0.5 right-0.5 inline-flex items-center justify-center min-w-4 h-4 px-1 rounded-full bg-red-500 text-white text-[10px] font-medium">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="sr-only">{item.title}</span>
|
<span className="sr-only">{item.title}</span>
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|
@ -64,7 +69,11 @@ export function NavSection({ items, onItemClick, isCollapsed = false }: NavSecti
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 shrink-0" />
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
<span className="flex-1 truncate">{item.title}</span>
|
<span className="flex-1 truncate">{item.title}</span>
|
||||||
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
|
{item.badge && (
|
||||||
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-red-500 text-white text-xs font-medium">
|
||||||
|
{item.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ interface SidebarProps {
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
pageUsage?: PageUsage;
|
pageUsage?: PageUsage;
|
||||||
|
theme?: string;
|
||||||
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,6 +60,8 @@ export function Sidebar({
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
pageUsage,
|
pageUsage,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
|
@ -241,6 +245,8 @@ export function Sidebar({
|
||||||
onUserSettings={onUserSettings}
|
onUserSettings={onUserSettings}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
theme={theme}
|
||||||
|
setTheme={setTheme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ export function SidebarSection({
|
||||||
|
|
||||||
{/* Action button - visible on hover (always visible on mobile) */}
|
{/* Action button - visible on hover (always visible on mobile) */}
|
||||||
{action && (
|
{action && (
|
||||||
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
<div className="shrink-0 opacity-100 md:opacity-0 md:group-hover/section:opacity-100 transition-opacity pr-1 flex items-center">
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,44 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ChevronUp, LogOut, Settings } from "lucide-react";
|
import { ChevronUp, Laptop, Languages, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuPortal,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useLocaleContext } from "@/contexts/LocaleContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { User } from "../../types/layout.types";
|
import type { User } from "../../types/layout.types";
|
||||||
|
|
||||||
|
// Supported languages configuration
|
||||||
|
const LANGUAGES = [
|
||||||
|
{ code: "en" as const, name: "English", flag: "🇺🇸" },
|
||||||
|
{ code: "zh" as const, name: "简体中文", flag: "🇨🇳" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Supported themes configuration
|
||||||
|
const THEMES = [
|
||||||
|
{ value: "light" as const, name: "Light", icon: Sun },
|
||||||
|
{ value: "dark" as const, name: "Dark", icon: Moon },
|
||||||
|
{ value: "system" as const, name: "System", icon: Laptop },
|
||||||
|
];
|
||||||
|
|
||||||
interface SidebarUserProfileProps {
|
interface SidebarUserProfileProps {
|
||||||
user: User;
|
user: User;
|
||||||
onUserSettings?: () => void;
|
onUserSettings?: () => void;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
|
theme?: string;
|
||||||
|
setTheme?: (theme: "light" | "dark" | "system") => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -99,12 +119,23 @@ export function SidebarUserProfile({
|
||||||
onUserSettings,
|
onUserSettings,
|
||||||
onLogout,
|
onLogout,
|
||||||
isCollapsed = false,
|
isCollapsed = false,
|
||||||
|
theme,
|
||||||
|
setTheme,
|
||||||
}: SidebarUserProfileProps) {
|
}: SidebarUserProfileProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
const { locale, setLocale } = useLocaleContext();
|
||||||
const bgColor = stringToColor(user.email);
|
const bgColor = stringToColor(user.email);
|
||||||
const initials = getInitials(user.email);
|
const initials = getInitials(user.email);
|
||||||
const displayName = user.name || user.email.split("@")[0];
|
const displayName = user.name || user.email.split("@")[0];
|
||||||
|
|
||||||
|
const handleLanguageChange = (newLocale: "en" | "zh") => {
|
||||||
|
setLocale(newLocale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (newTheme: "light" | "dark" | "system") => {
|
||||||
|
setTheme?.(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
// Collapsed view - just show avatar with dropdown
|
// Collapsed view - just show avatar with dropdown
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -118,7 +149,8 @@ export function SidebarUserProfile({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-10 w-full items-center justify-center rounded-md",
|
"flex h-10 w-full items-center justify-center rounded-md",
|
||||||
"hover:bg-accent transition-colors",
|
"hover:bg-accent transition-colors",
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
"focus:outline-none focus-visible:outline-none",
|
||||||
|
"data-[state=open]:bg-transparent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
|
|
@ -129,7 +161,7 @@ export function SidebarUserProfile({
|
||||||
<TooltipContent side="right">{displayName}</TooltipContent>
|
<TooltipContent side="right">{displayName}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
|
<DropdownMenuContent className="w-56" side="right" align="center" sideOffset={8}>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
|
|
@ -147,6 +179,65 @@ export function SidebarUserProfile({
|
||||||
{t("user_settings")}
|
{t("user_settings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{setTheme && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
{t("theme")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="gap-1">
|
||||||
|
{THEMES.map((themeOption) => {
|
||||||
|
const Icon = themeOption.icon;
|
||||||
|
const isSelected = theme === themeOption.value;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={themeOption.value}
|
||||||
|
onClick={() => handleThemeChange(themeOption.value)}
|
||||||
|
className={cn(
|
||||||
|
"mb-1 last:mb-0 transition-all",
|
||||||
|
"hover:bg-accent/50",
|
||||||
|
isSelected && "bg-accent/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
|
<span className="flex-1">{t(themeOption.value)}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Languages className="mr-2 h-4 w-4" />
|
||||||
|
{t("language")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="gap-1">
|
||||||
|
{LANGUAGES.map((language) => {
|
||||||
|
const isSelected = locale === language.code;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={language.code}
|
||||||
|
onClick={() => handleLanguageChange(language.code)}
|
||||||
|
className={cn(
|
||||||
|
"mb-1 last:mb-0 transition-all",
|
||||||
|
"hover:bg-accent/50",
|
||||||
|
isSelected && "bg-accent/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="mr-2">{language.flag}</span>
|
||||||
|
<span className="flex-1">{language.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
|
|
@ -169,7 +260,8 @@ export function SidebarUserProfile({
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-2 px-2 py-3 text-left",
|
"flex w-full items-center gap-2 px-2 py-3 text-left",
|
||||||
"hover:bg-accent transition-colors",
|
"hover:bg-accent transition-colors",
|
||||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
"focus:outline-none focus-visible:outline-none",
|
||||||
|
"data-[state=open]:bg-transparent"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
|
|
@ -185,7 +277,7 @@ export function SidebarUserProfile({
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
|
<DropdownMenuContent className="w-56" side="top" align="center" sideOffset={4}>
|
||||||
<DropdownMenuLabel className="font-normal">
|
<DropdownMenuLabel className="font-normal">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
|
||||||
|
|
@ -203,6 +295,65 @@ export function SidebarUserProfile({
|
||||||
{t("user_settings")}
|
{t("user_settings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
{setTheme && (
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
{t("theme")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="gap-1">
|
||||||
|
{THEMES.map((themeOption) => {
|
||||||
|
const Icon = themeOption.icon;
|
||||||
|
const isSelected = theme === themeOption.value;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={themeOption.value}
|
||||||
|
onClick={() => handleThemeChange(themeOption.value)}
|
||||||
|
className={cn(
|
||||||
|
"mb-1 last:mb-0 transition-all",
|
||||||
|
"hover:bg-accent/50",
|
||||||
|
isSelected && "bg-accent/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="mr-2 h-4 w-4" />
|
||||||
|
<span className="flex-1">{t(themeOption.value)}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DropdownMenuSub>
|
||||||
|
<DropdownMenuSubTrigger>
|
||||||
|
<Languages className="mr-2 h-4 w-4" />
|
||||||
|
{t("language")}
|
||||||
|
</DropdownMenuSubTrigger>
|
||||||
|
<DropdownMenuPortal>
|
||||||
|
<DropdownMenuSubContent className="gap-1">
|
||||||
|
{LANGUAGES.map((language) => {
|
||||||
|
const isSelected = locale === language.code;
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={language.code}
|
||||||
|
onClick={() => handleLanguageChange(language.code)}
|
||||||
|
className={cn(
|
||||||
|
"mb-1 last:mb-0 transition-all",
|
||||||
|
"hover:bg-accent/50",
|
||||||
|
isSelected && "bg-accent/80"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="mr-2">{language.flag}</span>
|
||||||
|
<span className="flex-1">{language.name}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuSubContent>
|
||||||
|
</DropdownMenuPortal>
|
||||||
|
</DropdownMenuSub>
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<DropdownMenuItem onClick={onLogout}>
|
<DropdownMenuItem onClick={onLogout}>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar";
|
||||||
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar";
|
||||||
export { ChatListItem } from "./ChatListItem";
|
export { ChatListItem } from "./ChatListItem";
|
||||||
|
export { InboxSidebar } from "./InboxSidebar";
|
||||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||||
export { NavSection } from "./NavSection";
|
export { NavSection } from "./NavSection";
|
||||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,14 @@ import type {
|
||||||
GlobalNewLLMConfig,
|
GlobalNewLLMConfig,
|
||||||
NewLLMConfigPublic,
|
NewLLMConfigPublic,
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
|
|
||||||
import { ChatShareButton } from "./chat-share-button";
|
|
||||||
import { ModelConfigSidebar } from "./model-config-sidebar";
|
import { ModelConfigSidebar } from "./model-config-sidebar";
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
interface ChatHeaderProps {
|
interface ChatHeaderProps {
|
||||||
searchSpaceId: number;
|
searchSpaceId: number;
|
||||||
thread?: ThreadRecord | null;
|
|
||||||
onThreadVisibilityChange?: (visibility: ChatVisibility) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }: ChatHeaderProps) {
|
export function ChatHeader({ searchSpaceId }: ChatHeaderProps) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [selectedConfig, setSelectedConfig] = useState<
|
const [selectedConfig, setSelectedConfig] = useState<
|
||||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||||
|
|
@ -52,7 +48,6 @@ export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }:
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
|
||||||
<ChatShareButton thread={thread ?? null} onVisibilityChange={onThreadVisibilityChange} />
|
|
||||||
<ModelConfigSidebar
|
<ModelConfigSidebar
|
||||||
open={sidebarOpen}
|
open={sidebarOpen}
|
||||||
onOpenChange={handleSidebarClose}
|
onOpenChange={handleSidebarClose}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Loader2, Lock, Users } from "lucide-react";
|
import { useAtomValue, useSetAtom } from "jotai";
|
||||||
|
import { User, Users } from "lucide-react";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
type ChatVisibility,
|
type ChatVisibility,
|
||||||
type ThreadRecord,
|
type ThreadRecord,
|
||||||
|
|
@ -23,13 +26,13 @@ const visibilityOptions: {
|
||||||
value: ChatVisibility;
|
value: ChatVisibility;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: typeof Lock;
|
icon: typeof User;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
value: "PRIVATE",
|
value: "PRIVATE",
|
||||||
label: "Private",
|
label: "Private",
|
||||||
description: "Only you can access this chat",
|
description: "Only you can access this chat",
|
||||||
icon: Lock,
|
icon: User,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: "SEARCH_SPACE",
|
value: "SEARCH_SPACE",
|
||||||
|
|
@ -42,9 +45,13 @@ const visibilityOptions: {
|
||||||
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
export function ChatShareButton({ thread, onVisibilityChange, className }: ChatShareButtonProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
|
||||||
|
|
||||||
const currentVisibility = thread?.visibility ?? "PRIVATE";
|
// Use Jotai atom for visibility (single source of truth)
|
||||||
|
const currentThreadState = useAtomValue(currentThreadAtom);
|
||||||
|
const setThreadVisibility = useSetAtom(setThreadVisibilityAtom);
|
||||||
|
|
||||||
|
// Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop
|
||||||
|
const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE";
|
||||||
const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it
|
const isOwnThread = thread?.created_by_id !== null; // If we have the thread, we can modify it
|
||||||
|
|
||||||
const handleVisibilityChange = useCallback(
|
const handleVisibilityChange = useCallback(
|
||||||
|
|
@ -54,11 +61,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsUpdating(true);
|
// Update Jotai atom immediately for instant UI feedback
|
||||||
|
setThreadVisibility(newVisibility);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateThreadVisibility(thread.id, newVisibility);
|
await updateThreadVisibility(thread.id, newVisibility);
|
||||||
|
|
||||||
// Refetch all thread queries to update sidebar immediately
|
// Refetch threads list to update sidebar
|
||||||
await queryClient.refetchQueries({
|
await queryClient.refetchQueries({
|
||||||
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
|
||||||
});
|
});
|
||||||
|
|
@ -70,12 +79,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update visibility:", error);
|
console.error("Failed to update visibility:", error);
|
||||||
|
// Revert Jotai state on error
|
||||||
|
setThreadVisibility(thread.visibility ?? "PRIVATE");
|
||||||
toast.error("Failed to update sharing settings");
|
toast.error("Failed to update sharing settings");
|
||||||
} finally {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[thread, currentVisibility, onVisibilityChange, queryClient]
|
[thread, currentVisibility, onVisibilityChange, queryClient, setThreadVisibility]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Don't show if no thread (new chat that hasn't been created yet)
|
// Don't show if no thread (new chat that hasn't been created yet)
|
||||||
|
|
@ -83,45 +92,38 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users;
|
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant="ghost"
|
<PopoverTrigger asChild>
|
||||||
size="sm"
|
<Button
|
||||||
className={cn(
|
variant="outline"
|
||||||
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
|
size="icon"
|
||||||
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
|
className={cn(
|
||||||
"text-xs md:text-sm font-medium text-foreground",
|
"h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0",
|
||||||
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
className
|
||||||
className
|
)}
|
||||||
)}
|
>
|
||||||
>
|
<CurrentIcon className="h-4 w-4" />
|
||||||
<CurrentIcon className="size-3.5 md:size-4 text-muted-foreground" />
|
<span className="hidden md:inline text-sm">
|
||||||
<span className="hidden md:inline">
|
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
|
||||||
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
|
</span>
|
||||||
</span>
|
</Button>
|
||||||
</Button>
|
</PopoverTrigger>
|
||||||
</PopoverTrigger>
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Share settings</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[280px] md:w-[320px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60"
|
className="w-[280px] md:w-[320px] p-0 rounded-lg shadow-lg border-border/60"
|
||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
|
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="p-1.5 space-y-1">
|
<div className="p-1.5 space-y-1">
|
||||||
{/* Updating overlay */}
|
|
||||||
{isUpdating && (
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
<span>Updating</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{visibilityOptions.map((option) => {
|
{visibilityOptions.map((option) => {
|
||||||
const isSelected = currentVisibility === option.value;
|
const isSelected = currentVisibility === option.value;
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
|
|
@ -131,9 +133,8 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
type="button"
|
type="button"
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onClick={() => handleVisibilityChange(option.value)}
|
onClick={() => handleVisibilityChange(option.value)}
|
||||||
disabled={isUpdating}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-start gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
"w-full flex items-center gap-2.5 px-2.5 py-2 rounded-md transition-all",
|
||||||
"hover:bg-accent/50 cursor-pointer",
|
"hover:bg-accent/50 cursor-pointer",
|
||||||
"focus:outline-none",
|
"focus:outline-none",
|
||||||
isSelected && "bg-accent/80"
|
isSelected && "bg-accent/80"
|
||||||
|
|
@ -141,13 +142,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"mt-0.5 p-1.5 rounded-md shrink-0",
|
"size-7 rounded-md shrink-0 grid place-items-center",
|
||||||
isSelected ? "bg-primary/10" : "bg-muted"
|
isSelected ? "bg-primary/10" : "bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-3.5",
|
"size-4 block",
|
||||||
isSelected ? "text-primary" : "text-muted-foreground"
|
isSelected ? "text-primary" : "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -157,11 +158,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
|
||||||
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
{isSelected && (
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
|
|
||||||
Current
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||||
{option.description}
|
{option.description}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useAtomValue } from "jotai";
|
||||||
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
|
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createNewLLMConfigMutationAtom,
|
createNewLLMConfigMutationAtom,
|
||||||
|
|
@ -38,6 +39,12 @@ export function ModelConfigSidebar({
|
||||||
mode,
|
mode,
|
||||||
}: ModelConfigSidebarProps) {
|
}: ModelConfigSidebarProps) {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
// Handle SSR - only render portal on client
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Mutations - use mutateAsync from the atom value
|
// Mutations - use mutateAsync from the atom value
|
||||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||||
|
|
@ -147,7 +154,9 @@ export function ModelConfigSidebar({
|
||||||
}
|
}
|
||||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||||
|
|
||||||
return (
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const sidebarContent = (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -157,7 +166,7 @@ export function ModelConfigSidebar({
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className="fixed inset-0 z-40 bg-black/20 backdrop-blur-sm"
|
className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={() => onOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -172,7 +181,7 @@ export function ModelConfigSidebar({
|
||||||
stiffness: 300,
|
stiffness: 300,
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed right-0 top-0 z-50 h-full w-full sm:w-[480px] lg:w-[540px]",
|
"fixed right-0 top-0 z-[25] h-full w-full sm:w-[480px] lg:w-[540px]",
|
||||||
"bg-background border-l border-border/50 shadow-2xl",
|
"bg-background border-l border-border/50 shadow-2xl",
|
||||||
"flex flex-col"
|
"flex flex-col"
|
||||||
)}
|
)}
|
||||||
|
|
@ -245,16 +254,16 @@ export function ModelConfigSidebar({
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Configuration Name
|
Configuration Name
|
||||||
</label>
|
</div>
|
||||||
<p className="text-sm font-medium">{config.name}</p>
|
<p className="text-sm font-medium">{config.name}</p>
|
||||||
</div>
|
</div>
|
||||||
{config.description && (
|
{config.description && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Description
|
Description
|
||||||
</label>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -264,15 +273,15 @@ export function ModelConfigSidebar({
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Provider
|
Provider
|
||||||
</label>
|
</div>
|
||||||
<p className="text-sm font-medium">{config.provider}</p>
|
<p className="text-sm font-medium">{config.provider}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Model
|
Model
|
||||||
</label>
|
</div>
|
||||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -281,9 +290,9 @@ export function ModelConfigSidebar({
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
Citations
|
Citations
|
||||||
</label>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={config.citations_enabled ? "default" : "secondary"}
|
variant={config.citations_enabled ? "default" : "secondary"}
|
||||||
className="w-fit"
|
className="w-fit"
|
||||||
|
|
@ -297,9 +306,9 @@ export function ModelConfigSidebar({
|
||||||
<>
|
<>
|
||||||
<div className="h-px bg-border/50" />
|
<div className="h-px bg-border/50" />
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
System Instructions
|
System Instructions
|
||||||
</label>
|
</div>
|
||||||
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
|
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
|
||||||
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
|
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
|
||||||
{config.system_instructions}
|
{config.system_instructions}
|
||||||
|
|
@ -367,4 +376,6 @@ export function ModelConfigSidebar({
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,6 @@ interface ModelSelectorProps {
|
||||||
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isSwitching, setIsSwitching] = useState(false);
|
|
||||||
|
|
||||||
// Fetch configs
|
// Fetch configs
|
||||||
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
const { data: userConfigs, isLoading: userConfigsLoading } = useAtomValue(newLLMConfigsAtom);
|
||||||
|
|
@ -142,7 +141,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSwitching(true);
|
|
||||||
try {
|
try {
|
||||||
await updatePreferences({
|
await updatePreferences({
|
||||||
search_space_id: Number(searchSpaceId),
|
search_space_id: Number(searchSpaceId),
|
||||||
|
|
@ -155,8 +153,6 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to switch model:", error);
|
console.error("Failed to switch model:", error);
|
||||||
toast.error("Failed to switch model");
|
toast.error("Failed to switch model");
|
||||||
} finally {
|
|
||||||
setIsSwitching(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[currentConfig, searchSpaceId, updatePreferences]
|
[currentConfig, searchSpaceId, updatePreferences]
|
||||||
|
|
@ -175,74 +171,59 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className={cn(
|
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
|
||||||
"h-7 md:h-9 gap-1 md:gap-2 px-2 md:px-3 rounded-lg md:rounded-xl border border-border/80 bg-background/50 backdrop-blur-sm",
|
|
||||||
"hover:bg-muted/80 hover:border-border/30 transition-all duration-200",
|
|
||||||
"text-xs md:text-sm font-medium text-foreground",
|
|
||||||
"focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="size-3.5 md:size-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
<span className="text-muted-foreground hidden md:inline">Loading...</span>
|
<span className="text-muted-foreground hidden md:inline">Loading</span>
|
||||||
<span className="text-muted-foreground md:hidden">Load...</span>
|
|
||||||
</>
|
</>
|
||||||
) : currentConfig ? (
|
) : currentConfig ? (
|
||||||
<>
|
<>
|
||||||
{getProviderIcon(currentConfig.provider)}
|
{getProviderIcon(currentConfig.provider)}
|
||||||
<span className="max-w-[80px] md:max-w-[150px] truncate">{currentConfig.name}</span>
|
<span className="max-w-[100px] md:max-w-[150px] truncate hidden md:inline">
|
||||||
<Badge
|
{currentConfig.name}
|
||||||
variant="secondary"
|
</span>
|
||||||
className="ml-0.5 md:ml-1 text-[9px] md:text-[10px] px-1 md:px-1.5 py-0 h-3.5 md:h-4 bg-muted/80"
|
<Badge variant="secondary" className="ml-1 text-[10px] px-1.5 py-0 h-4 bg-muted/80">
|
||||||
>
|
|
||||||
{currentConfig.model_name.split("/").pop()?.slice(0, 10) ||
|
{currentConfig.model_name.split("/").pop()?.slice(0, 10) ||
|
||||||
currentConfig.model_name.slice(0, 10)}
|
currentConfig.model_name.slice(0, 10)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Bot className="size-3.5 md:size-4 text-muted-foreground" />
|
<Bot className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
<span className="text-muted-foreground hidden md:inline">Select Model</span>
|
||||||
<span className="text-muted-foreground md:hidden">Model</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ChevronDown className="size-3 md:size-3.5 text-muted-foreground ml-1 shrink-0" />
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0 transition-transform duration-200",
|
||||||
|
open && "rotate-180"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[280px] md:w-[360px] p-0 rounded-lg md:rounded-xl shadow-lg border-border/60"
|
className="w-[280px] md:w-[360px] p-0 rounded-lg shadow-lg border-border/60"
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<Command
|
<Command
|
||||||
shouldFilter={false}
|
shouldFilter={false}
|
||||||
className="rounded-xl relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
className="rounded-lg relative [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||||
>
|
>
|
||||||
{/* Switching overlay */}
|
|
||||||
{isSwitching && (
|
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-xl">
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
<span>Switching model...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{totalModels > 3 && (
|
{totalModels > 3 && (
|
||||||
<div className="flex items-center gap-1 md:gap-2 border-b border-border/30 px-2 md:px-3 py-1.5 md:py-2">
|
<div className="flex items-center gap-1 md:gap-2 px-2 md:px-3 py-1.5 md:py-2">
|
||||||
<CommandInput
|
<CommandInput
|
||||||
placeholder="Search models..."
|
placeholder="Search models"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onValueChange={setSearchQuery}
|
onValueChange={setSearchQuery}
|
||||||
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
className="h-7 md:h-8 text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||||
disabled={isSwitching}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -250,7 +231,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
||||||
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
|
||||||
<CommandEmpty className="py-8 text-center">
|
<CommandEmpty className="py-8 text-center">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<Bot className="size-8 text-muted-foreground/40" />
|
<Bot className="size-8 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">No models found</p>
|
<p className="text-sm text-muted-foreground">No models found</p>
|
||||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -271,8 +252,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
||||||
value={`global-${config.id}`}
|
value={`global-${config.id}`}
|
||||||
onSelect={() => handleSelectConfig(config)}
|
onSelect={() => handleSelectConfig(config)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||||
"aria-selected:bg-accent/50",
|
"hover:bg-accent/50",
|
||||||
isSelected && "bg-accent/80"
|
isSelected && "bg-accent/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -333,8 +314,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
|
||||||
value={`user-${config.id}`}
|
value={`user-${config.id}`}
|
||||||
onSelect={() => handleSelectConfig(config)}
|
onSelect={() => handleSelectConfig(config)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"mx-2 rounded-lg mb-1 cursor-pointer group",
|
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all",
|
||||||
"aria-selected:bg-accent/50",
|
"hover:bg-accent/50",
|
||||||
isSelected && "bg-accent/80"
|
isSelected && "bg-accent/80"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
Hash,
|
Hash,
|
||||||
|
|
@ -387,7 +386,7 @@ export function SourceDetailPanel({
|
||||||
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
|
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground font-medium">Loading document...</p>
|
<p className="text-sm text-muted-foreground font-medium">Loading document</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -490,8 +489,8 @@ export function SourceDetailPanel({
|
||||||
>
|
>
|
||||||
{idx + 1}
|
{idx + 1}
|
||||||
{isCited && (
|
{isCited && (
|
||||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-background">
|
<span className="absolute -top-1.5 -right-1.5 flex items-center justify-center w-4 h-4 bg-primary rounded-full border-2 border-background shadow-sm">
|
||||||
<Sparkles className="h-2 w-2 text-primary-foreground absolute top-0.5 left-0.5" />
|
<Sparkles className="h-2.5 w-2.5 text-primary-foreground" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useAtomValue } from "jotai";
|
|
||||||
import { Bell } from "lucide-react";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { useNotifications } from "@/hooks/use-notifications";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { NotificationPopup } from "./NotificationPopup";
|
|
||||||
|
|
||||||
export function NotificationButton() {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const { data: user } = useAtomValue(currentUserAtom);
|
|
||||||
const params = useParams();
|
|
||||||
|
|
||||||
const userId = user?.id ? String(user.id) : null;
|
|
||||||
// Get searchSpaceId from URL params - the component is rendered within /dashboard/[search_space_id]/
|
|
||||||
const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null;
|
|
||||||
|
|
||||||
const { notifications, unreadCount, loading, markAsRead, markAllAsRead } = useNotifications(
|
|
||||||
userId,
|
|
||||||
searchSpaceId
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="h-8 w-8 relative">
|
|
||||||
<Bell className="h-4 w-4" />
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black text-[10px] font-medium text-white dark:bg-zinc-800 dark:text-zinc-50",
|
|
||||||
unreadCount > 9 && "px-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{unreadCount > 99 ? "99+" : unreadCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="sr-only">Notifications</span>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>Notifications</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<PopoverContent align="end" className="w-80 p-0">
|
|
||||||
<NotificationPopup
|
|
||||||
notifications={notifications}
|
|
||||||
unreadCount={unreadCount}
|
|
||||||
loading={loading}
|
|
||||||
markAsRead={markAsRead}
|
|
||||||
markAllAsRead={markAllAsRead}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,153 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import type { Notification } from "@/hooks/use-notifications";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface NotificationPopupProps {
|
|
||||||
notifications: Notification[];
|
|
||||||
unreadCount: number;
|
|
||||||
loading: boolean;
|
|
||||||
markAsRead: (id: number) => Promise<boolean>;
|
|
||||||
markAllAsRead: () => Promise<boolean>;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NotificationPopup({
|
|
||||||
notifications,
|
|
||||||
unreadCount,
|
|
||||||
loading,
|
|
||||||
markAsRead,
|
|
||||||
markAllAsRead,
|
|
||||||
onClose,
|
|
||||||
}: NotificationPopupProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleMarkAllAsRead = async () => {
|
|
||||||
await markAllAsRead();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotificationClick = async (notification: Notification) => {
|
|
||||||
if (!notification.read) {
|
|
||||||
await markAsRead(notification.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (notification.type === "new_mention") {
|
|
||||||
const metadata = notification.metadata as {
|
|
||||||
thread_id?: number;
|
|
||||||
comment_id?: number;
|
|
||||||
};
|
|
||||||
const searchSpaceId = notification.search_space_id;
|
|
||||||
const threadId = metadata?.thread_id;
|
|
||||||
const commentId = metadata?.comment_id;
|
|
||||||
|
|
||||||
if (searchSpaceId && threadId) {
|
|
||||||
const url = commentId
|
|
||||||
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
|
|
||||||
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
|
|
||||||
onClose?.();
|
|
||||||
router.push(url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
|
||||||
try {
|
|
||||||
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
|
|
||||||
} catch {
|
|
||||||
return "Recently";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusIcon = (notification: Notification) => {
|
|
||||||
const status = notification.metadata?.status as string | undefined;
|
|
||||||
|
|
||||||
switch (status) {
|
|
||||||
case "in_progress":
|
|
||||||
return <Loader2 className="h-4 w-4 text-foreground animate-spin" />;
|
|
||||||
case "completed":
|
|
||||||
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
|
|
||||||
case "failed":
|
|
||||||
return <AlertCircle className="h-4 w-4 text-red-500" />;
|
|
||||||
default:
|
|
||||||
return <Bell className="h-4 w-4 text-muted-foreground" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-80 max-w-[calc(100vw-2rem)]">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm">Notifications</h3>
|
|
||||||
</div>
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleMarkAllAsRead} className="h-7 text-xs">
|
|
||||||
<CheckCheck className="h-3.5 w-3.5 mr-0" />
|
|
||||||
Mark all read
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notifications List */}
|
|
||||||
<ScrollArea className="h-[400px]">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-foreground" />
|
|
||||||
</div>
|
|
||||||
) : notifications.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 px-4 text-center">
|
|
||||||
<Bell className="h-8 w-8 text-muted-foreground mb-2" />
|
|
||||||
<p className="text-sm text-muted-foreground">No notifications</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="pt-0 pb-2">
|
|
||||||
{notifications.map((notification, index) => (
|
|
||||||
<div key={notification.id}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleNotificationClick(notification)}
|
|
||||||
className={cn(
|
|
||||||
"w-full px-4 py-3 text-left hover:bg-accent transition-colors",
|
|
||||||
!notification.read && "bg-accent/50"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3 overflow-hidden">
|
|
||||||
<div className="flex-shrink-0 mt-0.5">{getStatusIcon(notification)}</div>
|
|
||||||
<div className="flex-1 min-w-0 overflow-hidden">
|
|
||||||
<div className="flex items-start justify-between gap-2 mb-1">
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-xs font-medium break-all",
|
|
||||||
!notification.read && "font-semibold"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{notification.title}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-muted-foreground break-all line-clamp-2">
|
|
||||||
{convertRenderedToDisplay(notification.message)}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center justify-between mt-2">
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{formatTime(notification.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{index < notifications.length - 1 && <Separator />}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -551,7 +551,9 @@ export function LLMConfigForm({
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<FormLabel className="text-xs sm:text-sm font-medium">Enable Citations</FormLabel>
|
<FormLabel className="text-xs sm:text-sm font-medium">
|
||||||
|
Enable Citations
|
||||||
|
</FormLabel>
|
||||||
<FormDescription className="text-[10px] sm:text-xs">
|
<FormDescription className="text-[10px] sm:text-xs">
|
||||||
Include [citation:id] references to source documents
|
Include [citation:id] references to source documents
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
|
||||||
|
|
@ -77,4 +77,17 @@ export {
|
||||||
ScrapeWebpageResultSchema,
|
ScrapeWebpageResultSchema,
|
||||||
ScrapeWebpageToolUI,
|
ScrapeWebpageToolUI,
|
||||||
} from "./scrape-webpage";
|
} from "./scrape-webpage";
|
||||||
|
export {
|
||||||
|
type MemoryItem,
|
||||||
|
type RecallMemoryArgs,
|
||||||
|
RecallMemoryArgsSchema,
|
||||||
|
type RecallMemoryResult,
|
||||||
|
RecallMemoryResultSchema,
|
||||||
|
RecallMemoryToolUI,
|
||||||
|
type SaveMemoryArgs,
|
||||||
|
SaveMemoryArgsSchema,
|
||||||
|
type SaveMemoryResult,
|
||||||
|
SaveMemoryResultSchema,
|
||||||
|
SaveMemoryToolUI,
|
||||||
|
} from "./user-memory";
|
||||||
export { type WriteTodosData, WriteTodosSchema, WriteTodosToolUI } from "./write-todos";
|
export { type WriteTodosData, WriteTodosSchema, WriteTodosToolUI } from "./write-todos";
|
||||||
|
|
|
||||||
283
surfsense_web/components/tool-ui/user-memory.tsx
Normal file
283
surfsense_web/components/tool-ui/user-memory.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { makeAssistantToolUI } from "@assistant-ui/react";
|
||||||
|
import { BrainIcon, CheckIcon, Loader2Icon, SearchIcon, XIcon } from "lucide-react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Zod Schemas for save_memory tool
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const SaveMemoryArgsSchema = z.object({
|
||||||
|
content: z.string(),
|
||||||
|
category: z.string().default("fact"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const SaveMemoryResultSchema = z.object({
|
||||||
|
status: z.enum(["saved", "error"]),
|
||||||
|
memory_id: z.number().nullish(),
|
||||||
|
memory_text: z.string().nullish(),
|
||||||
|
category: z.string().nullish(),
|
||||||
|
message: z.string().nullish(),
|
||||||
|
error: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SaveMemoryArgs = z.infer<typeof SaveMemoryArgsSchema>;
|
||||||
|
type SaveMemoryResult = z.infer<typeof SaveMemoryResultSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Zod Schemas for recall_memory tool
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const RecallMemoryArgsSchema = z.object({
|
||||||
|
query: z.string().nullish(),
|
||||||
|
category: z.string().nullish(),
|
||||||
|
top_k: z.number().default(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
const MemoryItemSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
memory_text: z.string(),
|
||||||
|
category: z.string(),
|
||||||
|
updated_at: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecallMemoryResultSchema = z.object({
|
||||||
|
status: z.enum(["success", "error"]),
|
||||||
|
count: z.number().nullish(),
|
||||||
|
memories: z.array(MemoryItemSchema).nullish(),
|
||||||
|
formatted_context: z.string().nullish(),
|
||||||
|
error: z.string().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RecallMemoryArgs = z.infer<typeof RecallMemoryArgsSchema>;
|
||||||
|
type RecallMemoryResult = z.infer<typeof RecallMemoryResultSchema>;
|
||||||
|
type MemoryItem = z.infer<typeof MemoryItemSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Category badge colors
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
preference: "bg-blue-500/10 text-blue-600 dark:text-blue-400",
|
||||||
|
fact: "bg-green-500/10 text-green-600 dark:text-green-400",
|
||||||
|
instruction: "bg-purple-500/10 text-purple-600 dark:text-purple-400",
|
||||||
|
context: "bg-orange-500/10 text-orange-600 dark:text-orange-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
function CategoryBadge({ category }: { category: string }) {
|
||||||
|
const colorClass = categoryColors[category] || "bg-gray-500/10 text-gray-600 dark:text-gray-400";
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${colorClass}`}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Save Memory Tool UI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SaveMemoryToolUI = makeAssistantToolUI<SaveMemoryArgs, SaveMemoryResult>({
|
||||||
|
toolName: "save_memory",
|
||||||
|
render: function SaveMemoryUI({ args, result, status }) {
|
||||||
|
const isRunning = status.type === "running" || status.type === "requires-action";
|
||||||
|
const isComplete = status.type === "complete";
|
||||||
|
const isError = result?.status === "error";
|
||||||
|
|
||||||
|
// Parse args safely
|
||||||
|
const parsedArgs = SaveMemoryArgsSchema.safeParse(args);
|
||||||
|
const content = parsedArgs.success ? parsedArgs.data.content : "";
|
||||||
|
const category = parsedArgs.success ? parsedArgs.data.category : "fact";
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isRunning) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Saving to memory...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm text-destructive">Failed to save memory</span>
|
||||||
|
{result?.error && <p className="mt-1 text-xs text-destructive/70">{result.error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state
|
||||||
|
if (isComplete && result?.status === "saved") {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border border-primary/20 bg-primary/5 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<BrainIcon className="size-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckIcon className="size-3 text-green-500 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-foreground">Memory saved</span>
|
||||||
|
<CategoryBadge category={category} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-sm text-muted-foreground">{content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default/incomplete state - show what's being saved
|
||||||
|
if (content) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||||
|
<BrainIcon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-muted-foreground">Saving memory</span>
|
||||||
|
<CategoryBadge category={category} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 truncate text-sm text-muted-foreground">{content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Recall Memory Tool UI
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const RecallMemoryToolUI = makeAssistantToolUI<RecallMemoryArgs, RecallMemoryResult>({
|
||||||
|
toolName: "recall_memory",
|
||||||
|
render: function RecallMemoryUI({ args, result, status }) {
|
||||||
|
const isRunning = status.type === "running" || status.type === "requires-action";
|
||||||
|
const isComplete = status.type === "complete";
|
||||||
|
const isError = result?.status === "error";
|
||||||
|
|
||||||
|
// Parse args safely
|
||||||
|
const parsedArgs = RecallMemoryArgsSchema.safeParse(args);
|
||||||
|
const query = parsedArgs.success ? parsedArgs.data.query : null;
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isRunning) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
|
||||||
|
<Loader2Icon className="size-4 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{query ? `Searching memories for "${query}"...` : "Recalling memories..."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border border-destructive/20 bg-destructive/5 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10">
|
||||||
|
<XIcon className="size-4 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-sm text-destructive">Failed to recall memories</span>
|
||||||
|
{result?.error && <p className="mt-1 text-xs text-destructive/70">{result.error}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success state with memories
|
||||||
|
if (isComplete && result?.status === "success") {
|
||||||
|
const memories = result.memories || [];
|
||||||
|
const count = result.count || 0;
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||||
|
<SearchIcon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">No memories found</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="my-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<BrainIcon className="size-4 text-primary" />
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
Recalled {count} {count === 1 ? "memory" : "memories"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{memories.slice(0, 5).map((memory: MemoryItem) => (
|
||||||
|
<div
|
||||||
|
key={memory.id}
|
||||||
|
className="flex items-start gap-2 rounded-md bg-muted/50 px-3 py-2"
|
||||||
|
>
|
||||||
|
<CategoryBadge category={memory.category} />
|
||||||
|
<span className="text-sm text-muted-foreground flex-1">{memory.memory_text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{memories.length > 5 && (
|
||||||
|
<p className="text-xs text-muted-foreground">...and {memories.length - 5} more</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default/incomplete state
|
||||||
|
if (query) {
|
||||||
|
return (
|
||||||
|
<div className="my-3 flex items-center gap-3 rounded-lg border bg-card/60 px-4 py-3">
|
||||||
|
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
|
||||||
|
<SearchIcon className="size-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-muted-foreground">Searching memories for "{query}"</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Exports
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
SaveMemoryArgsSchema,
|
||||||
|
SaveMemoryResultSchema,
|
||||||
|
RecallMemoryArgsSchema,
|
||||||
|
RecallMemoryResultSchema,
|
||||||
|
type SaveMemoryArgs,
|
||||||
|
type SaveMemoryResult,
|
||||||
|
type RecallMemoryArgs,
|
||||||
|
type RecallMemoryResult,
|
||||||
|
type MemoryItem,
|
||||||
|
};
|
||||||
115
surfsense_web/components/ui/drawer.tsx
Normal file
115
surfsense_web/components/ui/drawer.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />;
|
||||||
|
}
|
||||||
|
Drawer.displayName = "Drawer";
|
||||||
|
|
||||||
|
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||||
|
|
||||||
|
const DrawerPortal = DrawerPrimitive.Portal;
|
||||||
|
|
||||||
|
const DrawerClose = DrawerPrimitive.Close;
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
overlayClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content> & {
|
||||||
|
overlayClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerOverlay className={overlayClassName} />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DrawerContent.displayName = "DrawerContent";
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />;
|
||||||
|
}
|
||||||
|
DrawerHeader.displayName = "DrawerHeader";
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return <div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
|
||||||
|
}
|
||||||
|
DrawerFooter.displayName = "DrawerFooter";
|
||||||
|
|
||||||
|
function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
function DrawerHandle({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("mx-auto mt-4 h-1.5 w-12 rounded-full bg-muted-foreground/40", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
DrawerHandle.displayName = "DrawerHandle";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHandle,
|
||||||
|
};
|
||||||
|
|
@ -182,13 +182,13 @@ function DropdownMenuSubTrigger({
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -94,16 +94,11 @@ function SelectItem({
|
||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
data-slot="select-item"
|
data-slot="select-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
"focus:bg-accent/50 focus:text-accent-foreground hover:bg-accent/50 [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm outline-hidden select-none transition-all data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 data-[highlighted]:bg-accent/50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,15 @@ function SheetContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
|
overlayClassName,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left";
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
overlayClassName?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
<SheetOverlay />
|
<SheetOverlay className={overlayClassName} />
|
||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
33
surfsense_web/components/ui/spinner.tsx
Normal file
33
surfsense_web/components/ui/spinner.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
/** Size of the spinner */
|
||||||
|
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
||||||
|
/** Whether to hide the track behind the spinner arc */
|
||||||
|
hideTrack?: boolean;
|
||||||
|
/** Additional classes to apply */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: "h-3 w-3 border-[1.5px]",
|
||||||
|
sm: "h-4 w-4 border-2",
|
||||||
|
md: "h-6 w-6 border-2",
|
||||||
|
lg: "h-8 w-8 border-[3px]",
|
||||||
|
xl: "h-10 w-10 border-4",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Spinner({ size = "md", hideTrack = false, className }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<output
|
||||||
|
aria-label="Loading"
|
||||||
|
className={cn(
|
||||||
|
"block animate-spin rounded-full",
|
||||||
|
hideTrack ? "border-transparent" : "border-current/20",
|
||||||
|
"border-t-current",
|
||||||
|
sizeClasses[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,4 +3,81 @@ title: GitHub
|
||||||
description: Connect your GitHub repositories to SurfSense
|
description: Connect your GitHub repositories to SurfSense
|
||||||
---
|
---
|
||||||
|
|
||||||
# Documentation in progress
|
# GitHub Connector
|
||||||
|
|
||||||
|
Connect your GitHub repositories to SurfSense for code search and AI-powered insights. The connector uses [gitingest](https://gitingest.com) to efficiently index entire codebases.
|
||||||
|
|
||||||
|
## What Gets Indexed
|
||||||
|
|
||||||
|
| Content Type | Examples |
|
||||||
|
|--------------|----------|
|
||||||
|
| Code Files | Python, JavaScript, TypeScript, Go, Rust, Java, etc. |
|
||||||
|
| Documentation | README files, Markdown documents, text files |
|
||||||
|
| Configuration | JSON, YAML, TOML, .env examples, Dockerfiles |
|
||||||
|
|
||||||
|
> ⚠️ Binary files and files larger than 5MB are automatically excluded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start (Public Repos)
|
||||||
|
|
||||||
|
1. Navigate to **Connectors** → **Add Connector** → **GitHub**
|
||||||
|
2. Enter repository names: `owner/repo` (e.g., `facebook/react, vercel/next.js`)
|
||||||
|
3. Click **Connect GitHub**
|
||||||
|
|
||||||
|
No authentication required for public repositories.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Private Repositories
|
||||||
|
|
||||||
|
For private repos, you need a GitHub Personal Access Token (PAT).
|
||||||
|
|
||||||
|
### Generate a PAT
|
||||||
|
|
||||||
|
1. Go to [GitHub's token creation page](https://github.com/settings/tokens/new?description=surfsense&scopes=repo) (pre-filled with `repo` scope)
|
||||||
|
2. Set an expiration
|
||||||
|
3. Click **Generate token** and copy it
|
||||||
|
|
||||||
|
> ⚠️ The token starts with `ghp_`. Store it securely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Connector Configuration
|
||||||
|
|
||||||
|
| Field | Description | Required |
|
||||||
|
|-------|-------------|----------|
|
||||||
|
| **Connector Name** | A friendly name to identify this connector | Yes |
|
||||||
|
| **GitHub Personal Access Token** | Your PAT (only for private repos) | No |
|
||||||
|
| **Repository Names** | Comma-separated list: `owner/repo1, owner/repo2` | Yes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Periodic Sync
|
||||||
|
|
||||||
|
Enable periodic sync to automatically re-index repositories when content changes:
|
||||||
|
|
||||||
|
| Frequency | Use Case |
|
||||||
|
|-----------|----------|
|
||||||
|
| Every 5 minutes | Active development |
|
||||||
|
| Every 15 minutes | Frequent commits |
|
||||||
|
| Every hour | Regular workflow |
|
||||||
|
| Every 6 hours | Less active repos |
|
||||||
|
| Daily | Reference repositories |
|
||||||
|
| Weekly | Stable codebases |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Repository not found**
|
||||||
|
- Verify format is `owner/repo`
|
||||||
|
- For private repos, ensure PAT has access
|
||||||
|
|
||||||
|
**Authentication failed**
|
||||||
|
- Check PAT is valid and not expired
|
||||||
|
- Token should start with `ghp_` or `github_pat_`
|
||||||
|
|
||||||
|
**Rate limit exceeded**
|
||||||
|
- Use a PAT for higher limits (5,000/hour vs 60 unauthenticated)
|
||||||
|
- Reduce sync frequency
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@ description: Setting up Electric SQL for real-time data synchronization in SurfS
|
||||||
|
|
||||||
# Electric SQL
|
# Electric SQL
|
||||||
|
|
||||||
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for notifications, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
[Electric SQL](https://electric-sql.com/) enables real-time data synchronization in SurfSense, providing instant updates for inbox items, document indexing status, and connector sync progress without manual refresh. The frontend uses [PGlite](https://pglite.dev/) (a lightweight PostgreSQL in the browser) to maintain a local database that syncs with the backend via Electric SQL.
|
||||||
|
|
||||||
## What Does Electric SQL Do?
|
## What Does Electric SQL Do?
|
||||||
|
|
||||||
When you index documents or receive notifications, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
When you index documents or receive inbox updates, Electric SQL pushes updates to your browser in real-time. The data flows like this:
|
||||||
|
|
||||||
1. Backend writes data to PostgreSQL
|
1. Backend writes data to PostgreSQL
|
||||||
2. Electric SQL detects changes and streams them to the frontend
|
2. Electric SQL detects changes and streams them to the frontend
|
||||||
|
|
@ -18,7 +18,7 @@ When you index documents or receive notifications, Electric SQL pushes updates t
|
||||||
|
|
||||||
This means:
|
This means:
|
||||||
|
|
||||||
- **Notifications appear instantly** - No need to refresh the page
|
- **Inbox updates appear instantly** - No need to refresh the page
|
||||||
- **Document indexing progress updates live** - Watch your documents get processed
|
- **Document indexing progress updates live** - Watch your documents get processed
|
||||||
- **Connector status syncs automatically** - See when connectors finish syncing
|
- **Connector status syncs automatically** - See when connectors finish syncing
|
||||||
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
|
- **Offline support** - PGlite caches data locally, so previously loaded data remains accessible
|
||||||
|
|
|
||||||
|
|
@ -24,4 +24,5 @@ export enum EnumConnectorName {
|
||||||
YOUTUBE_CONNECTOR = "YOUTUBE_CONNECTOR",
|
YOUTUBE_CONNECTOR = "YOUTUBE_CONNECTOR",
|
||||||
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR",
|
CIRCLEBACK_CONNECTOR = "CIRCLEBACK_CONNECTOR",
|
||||||
MCP_CONNECTOR = "MCP_CONNECTOR",
|
MCP_CONNECTOR = "MCP_CONNECTOR",
|
||||||
|
COMPOSIO_CONNECTOR = "COMPOSIO_CONNECTOR",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
return <IconUsersGroup {...iconProps} />;
|
return <IconUsersGroup {...iconProps} />;
|
||||||
case EnumConnectorName.MCP_CONNECTOR:
|
case EnumConnectorName.MCP_CONNECTOR:
|
||||||
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
|
return <Image src="/connectors/modelcontextprotocol.svg" alt="MCP" {...imgProps} />;
|
||||||
|
case EnumConnectorName.COMPOSIO_CONNECTOR:
|
||||||
|
return <Image src="/connectors/composio.svg" alt="Composio" {...imgProps} />;
|
||||||
// Additional cases for non-enum connector types
|
// Additional cases for non-enum connector types
|
||||||
case "YOUTUBE_CONNECTOR":
|
case "YOUTUBE_CONNECTOR":
|
||||||
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
|
return <Image src="/connectors/youtube.svg" alt="YouTube" {...imgProps} />;
|
||||||
|
|
@ -85,6 +87,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
||||||
return <File {...iconProps} />;
|
return <File {...iconProps} />;
|
||||||
case "GOOGLE_DRIVE_FILE":
|
case "GOOGLE_DRIVE_FILE":
|
||||||
return <File {...iconProps} />;
|
return <File {...iconProps} />;
|
||||||
|
case "COMPOSIO_CONNECTOR":
|
||||||
|
return <Image src="/connectors/composio.svg" alt="Composio" {...imgProps} />;
|
||||||
case "NOTE":
|
case "NOTE":
|
||||||
return <FileText {...iconProps} />;
|
return <FileText {...iconProps} />;
|
||||||
case "EXTENSION":
|
case "EXTENSION":
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw comment
|
||||||
|
*/
|
||||||
|
export const rawComment = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
message_id: z.number(),
|
||||||
|
thread_id: z.number(), // Denormalized for efficient Electric subscriptions
|
||||||
|
parent_id: z.number().nullable(),
|
||||||
|
author_id: z.string().nullable(),
|
||||||
|
content: z.string(),
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const author = z.object({
|
export const author = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
display_name: z.string().nullable(),
|
display_name: z.string().nullable(),
|
||||||
|
|
@ -122,6 +136,7 @@ export const getMentionsResponse = z.object({
|
||||||
total_count: z.number(),
|
total_count: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type RawComment = z.infer<typeof rawComment>;
|
||||||
export type Author = z.infer<typeof author>;
|
export type Author = z.infer<typeof author>;
|
||||||
export type CommentReply = z.infer<typeof commentReply>;
|
export type CommentReply = z.infer<typeof commentReply>;
|
||||||
export type Comment = z.infer<typeof comment>;
|
export type Comment = z.infer<typeof comment>;
|
||||||
|
|
|
||||||
15
surfsense_web/contracts/types/chat-messages.types.ts
Normal file
15
surfsense_web/contracts/types/chat-messages.types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raw message from database (Electric SQL sync)
|
||||||
|
*/
|
||||||
|
export const rawMessage = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
thread_id: z.number(),
|
||||||
|
role: z.string(),
|
||||||
|
content: z.unknown(),
|
||||||
|
author_id: z.string().nullable(),
|
||||||
|
created_at: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RawMessage = z.infer<typeof rawMessage>;
|
||||||
24
surfsense_web/contracts/types/chat-session-state.types.ts
Normal file
24
surfsense_web/contracts/types/chat-session-state.types.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chat session state for live collaboration.
|
||||||
|
* Tracks which user the AI is currently responding to.
|
||||||
|
*/
|
||||||
|
export const chatSessionState = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
thread_id: z.number(),
|
||||||
|
ai_responding_to_user_id: z.string().uuid().nullable(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User currently being responded to by the AI.
|
||||||
|
*/
|
||||||
|
export const respondingUser = z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
display_name: z.string().nullable(),
|
||||||
|
email: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ChatSessionState = z.infer<typeof chatSessionState>;
|
||||||
|
export type RespondingUser = z.infer<typeof respondingUser>;
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue