This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-01-21 22:58:48 -08:00
commit 12b825bff0
59 changed files with 2292 additions and 1022 deletions

View 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;")

View file

@ -34,6 +34,7 @@ async def create_surfsense_deep_agent(
db_session: AsyncSession,
connector_service: ConnectorService,
checkpointer: Checkpointer,
user_id: str | None = None,
agent_config: AgentConfig | None = None,
enabled_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
- display_image: Display images in chat
- 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:
- 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
checkpointer: LangGraph checkpointer for conversation state persistence.
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.
If None, uses default system prompt with citations enabled.
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,
"connector_service": connector_service,
"firecrawl_api_key": firecrawl_api_key,
"user_id": user_id, # Required for memory tools
}
# Build tools using the async registry (includes MCP tools)

View file

@ -116,6 +116,45 @@ You have access to the following tools:
* This makes your response more visual and engaging.
* 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.
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>
<tool_call_examples>
- 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?"
- 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"
- 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")`

View file

@ -11,6 +11,8 @@ Available tools:
- link_preview: Fetch rich previews for URLs
- display_image: Display images in chat
- scrape_webpage: Extract content from webpages
- save_memory: Store facts/preferences about the user
- recall_memory: Retrieve relevant user memories
"""
# Registry exports
@ -33,6 +35,7 @@ from .registry import (
)
from .scrape_webpage import create_scrape_webpage_tool
from .search_surfsense_docs import create_search_surfsense_docs_tool
from .user_memory import create_recall_memory_tool, create_save_memory_tool
__all__ = [
# Registry
@ -43,6 +46,8 @@ __all__ = [
"create_display_image_tool",
"create_generate_podcast_tool",
"create_link_preview_tool",
"create_recall_memory_tool",
"create_save_memory_tool",
"create_scrape_webpage_tool",
"create_search_knowledge_base_tool",
"create_search_surfsense_docs_tool",

View file

@ -50,6 +50,7 @@ from .mcp_tool import load_mcp_tools
from .podcast import create_generate_podcast_tool
from .scrape_webpage import create_scrape_webpage_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
@ -138,6 +139,31 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
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
# =========================================================================
# Example:

View 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

View file

@ -1,296 +1,236 @@
import base64
import logging
from typing import Any
"""
GitHub connector using gitingest CLI for efficient repository digestion.
from github3 import exceptions as github_exceptions, login as github_login
from github3.exceptions import ForbiddenError, NotFoundError
from github3.repos.contents import Contents
This connector uses subprocess to call gitingest CLI, completely isolating
it from any Python event loop/async complexity that can cause hangs in Celery.
"""
import logging
import os
import subprocess
import tempfile
from dataclasses import dataclass
logger = logging.getLogger(__name__)
# List of common code file extensions to target
CODE_EXTENSIONS = {
".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",
}
# Maximum file size in bytes (5MB)
MAX_FILE_SIZE = 5 * 1024 * 1024
# 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)
MAX_FILE_SIZE = 1 * 1024 * 1024
@dataclass
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:
"""Connector for interacting with the GitHub API."""
"""
Connector for ingesting GitHub repositories using gitingest CLI.
# Directories to skip during file traversal
SKIPPED_DIRS = {
# 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
}
Uses subprocess to run gitingest, which avoids all async/event loop
issues that can occur when mixing gitingest with Celery workers.
"""
def __init__(self, token: str):
def __init__(self, token: str | None = None):
"""
Initializes the GitHub connector.
Initialize the GitHub connector.
Args:
token: GitHub Personal Access Token (PAT).
token: Optional GitHub Personal Access Token (PAT).
Only required for private repositories.
"""
if not token:
raise ValueError("GitHub token cannot be empty.")
try:
self.gh = github_login(token=token)
# Try a simple authenticated call to check token validity
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
self.token = token if token and token.strip() else None
if self.token:
logger.info("GitHub connector initialized with authentication token.")
else:
logger.info("GitHub connector initialized without token (public repos only).")
def get_user_repositories(self) -> list[dict[str, Any]]:
"""Fetches repositories accessible by the authenticated user."""
repos_data = []
try:
# type='owner' fetches repos owned by the user
# type='member' fetches repos the user is a collaborator on (including orgs)
# 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]]:
def ingest_repository(
self,
repo_full_name: str,
branch: str | None = None,
max_file_size: int = MAX_FILE_SIZE,
) -> RepositoryDigest | None:
"""
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:
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:
A list of dictionaries, each containing file details (path, sha, url, size).
Returns an empty list if the repository or path is not found or on error.
RepositoryDigest or None if ingestion fails.
"""
files_list = []
repo_url = f"https://github.com/{repo_full_name}"
logger.info(f"Starting gitingest CLI for repository: {repo_full_name}")
try:
owner, repo_name = repo_full_name.split("/")
repo = self.gh.repository(owner, repo_name)
if not repo:
logger.warning(f"Repository '{repo_full_name}' not found.")
return []
contents = repo.directory_contents(
directory_path=path
) # Use directory_contents for clarity
# Create a temporary file for output
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as tmp_file:
output_path = tmp_file.name
# contents returns a list of tuples (name, content_obj)
for _item_name, content_item in contents:
if not isinstance(content_item, Contents):
continue
# Build the gitingest CLI command
cmd = [
"gitingest",
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":
# Check if the directory name is in the skipped list
if content_item.name in self.SKIPPED_DIRS:
logger.debug(f"Skipping directory: {content_item.path}")
continue # Skip recursion for this directory
# Add branch if specified
if branch:
cmd.extend(["--branch", branch])
# Recursively fetch contents of subdirectory
files_list.extend(
self.get_repository_files(
repo_full_name, path=content_item.path
)
)
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
# Set up environment with token if provided
env = os.environ.copy()
if self.token:
env["GITHUB_TOKEN"] = self.token
if (is_code or is_doc) and content_item.size <= MAX_FILE_SIZE:
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}"
)
logger.info(f"Running gitingest CLI: {' '.join(cmd[:5])}...")
except (NotFoundError, ForbiddenError) as e:
logger.warning(f"Cannot access path '{path}' in '{repo_full_name}': {e}")
except Exception as e:
logger.error(
f"Failed to get files for {repo_full_name} at path '{path}': {e}"
# Run gitingest as subprocess with timeout
result = subprocess.run(
cmd,
env=env,
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.
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.
Fallback: Ingest using the Python library directly.
"""
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:
owner, repo_name = repo_full_name.split("/")
repo = self.gh.repository(owner, repo_name)
if not repo:
logger.warning(
f"Repository '{repo_full_name}' not found when fetching file '{file_path}'."
)
kwargs = {
"max_file_size": max_file_size,
"exclude_patterns": [
"node_modules/*",
"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
content_item = repo.file_contents(
path=file_path
) # Use file_contents for clarity
if (
not content_item
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 RepositoryDigest(
repo_full_name=repo_full_name,
summary=summary,
tree=tree,
content=content,
branch=branch,
)
return None
except Exception as e:
logger.error(
f"Failed to get content for file '{file_path}' in '{repo_full_name}': {e}"
)
logger.error(f"Python library failed for {repo_full_name}: {e}")
return None

View file

@ -474,6 +474,66 @@ class ChatCommentMention(BaseModel, TimestampMixin):
mentioned_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):
__tablename__ = "documents"
@ -661,6 +721,14 @@ class SearchSpace(BaseModel, TimestampMixin):
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):
__tablename__ = "search_source_connectors"
@ -969,6 +1037,14 @@ if config.AUTH_TYPE == "GOOGLE":
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
pages_limit = Column(
Integer,
@ -1012,6 +1088,14 @@ else:
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
pages_limit = Column(
Integer,

View file

@ -990,6 +990,7 @@ async def handle_new_chat(
search_space_id=request.search_space_id,
chat_id=request.chat_id,
session=session,
user_id=str(user.id), # Pass user ID for memory tools
llm_config_id=llm_config_id,
attachments=request.attachments,
mentioned_document_ids=request.mentioned_document_ids,

View file

@ -315,6 +315,8 @@ async def create_comment(
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
@ -426,6 +428,8 @@ async def create_reply(
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)
@ -565,6 +569,8 @@ async def update_comment(
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
author_avatar_url=user.avatar_url,
author_email=user.email,
content_preview=content_preview[:200],
search_space_id=search_space_id,
)

View file

@ -634,6 +634,8 @@ class MentionNotificationHandler(BaseNotificationHandler):
thread_title: str,
author_id: str,
author_name: str,
author_avatar_url: str | None,
author_email: str,
content_preview: str,
search_space_id: int,
) -> Notification:
@ -650,6 +652,8 @@ class MentionNotificationHandler(BaseNotificationHandler):
thread_title: Title of the chat thread
author_id: ID 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
search_space_id: Search space ID
@ -667,6 +671,8 @@ class MentionNotificationHandler(BaseNotificationHandler):
"thread_title": thread_title,
"author_id": author_id,
"author_name": author_name,
"author_avatar_url": author_avatar_url,
"author_email": author_email,
"content_preview": content_preview[:200],
}

View file

@ -149,6 +149,7 @@ async def stream_new_chat(
search_space_id: int,
chat_id: int,
session: AsyncSession,
user_id: str | None = None,
llm_config_id: int = -1,
attachments: list[ChatAttachment] | None = None,
mentioned_document_ids: list[int] | None = None,
@ -166,6 +167,7 @@ async def stream_new_chat(
search_space_id: The search space ID
chat_id: The chat ID (used as LangGraph thread_id for memory)
session: The database session
user_id: The current user's UUID string (for memory tools)
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
@ -243,6 +245,7 @@ async def stream_new_chat(
db_session=session,
connector_service=connector_service,
checkpointer=checkpointer,
user_id=user_id, # Pass user ID for memory tools
agent_config=agent_config, # Pass prompt configuration
firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured
)

View file

@ -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
@ -8,7 +11,7 @@ from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
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.services.llm_service import get_user_long_context_llm
from app.services.task_logging_service import TaskLoggingService
@ -26,43 +29,55 @@ from .base import (
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(
session: AsyncSession,
connector_id: int,
search_space_id: int,
user_id: str,
start_date: str | None = None,
end_date: str | None = None,
start_date: str | None = None, # Ignored - GitHub indexes full repo snapshots
end_date: str | None = None, # Ignored - GitHub indexes full repo snapshots
update_last_indexed: bool = True,
) -> 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:
session: Database session
connector_id: ID of the GitHub connector
search_space_id: ID of the search space to store documents in
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
end_date: End 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: Ignored - kept for API compatibility
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
Returns:
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)
# Log task start
log_entry = await task_logger.log_task_start(
task_name="github_repos_indexing",
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={
"connector_id": connector_id,
"user_id": str(user_id),
"start_date": start_date,
"end_date": end_date,
"method": "gitingest",
},
)
@ -93,19 +108,11 @@ async def index_github_repos(
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
github_pat = connector.config.get("GITHUB_PAT")
# 2. Get the GitHub PAT (optional) and selected repositories from the connector config
# 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")
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(
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"
# 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(
log_entry,
f"Initializing GitHub client for connector {connector_id}",
f"Initializing gitingest-based GitHub client for connector {connector_id}",
{
"stage": "client_initialization",
"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}"
# 4. Validate selected repositories
# 4. Process each repository with gitingest
await task_logger.log_task_progress(
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",
"repo_count": len(repo_full_names_to_index),
"start_date": start_date,
"end_date": end_date,
},
)
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:
if not repo_full_name or not isinstance(repo_full_name, str):
logger.warning(f"Skipping invalid repository entry: {repo_full_name}")
continue
logger.info(f"Processing 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(f"Ingesting repository: {repo_full_name}")
logger.info(
f"Found {len(files_to_index)} files to process in {repo_full_name}"
try:
# 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:
file_path = file_info.get("path")
file_url = file_info.get("url")
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
if not digest:
logger.warning(
f"No digest returned for repository: {repo_full_name}"
)
errors.append(f"No digest for {repo_full_name}")
continue
if file_content is None:
logger.warning(
f"Could not retrieve content for {full_path_key}. Skipping."
)
continue # Skip if content fetch failed
# Process the digest and create documents
docs_created = await _process_repository_digest(
session=session,
digest=digest,
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
unique_identifier_hash = generate_unique_identifier_hash(
DocumentType.GITHUB_CONNECTOR, file_sha, search_space_id
)
# 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()
documents_processed += docs_created
logger.info(
f"Created {docs_created} documents from repository: {repo_full_name}"
)
except Exception as repo_err:
logger.error(
@ -397,11 +209,11 @@ async def index_github_repos(
)
errors.append(f"Failed processing {repo_full_name}: {repo_err}")
# Final commit for any remaining documents not yet committed in batches
logger.info(f"Final commit: Total {documents_processed} GitHub files processed")
# Final commit
await session.commit()
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
@ -412,6 +224,7 @@ async def index_github_repos(
"documents_processed": documents_processed,
"errors_count": len(errors),
"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}")
return documents_processed, "; ".join(errors) if errors else str(db_err)
except Exception as e:
await session.rollback()
await task_logger.log_task_failure(
@ -445,3 +259,173 @@ async def index_github_repos(
error_message = "; ".join(errors) if errors else None
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

View file

@ -530,7 +530,10 @@ def validate_connector_config(
# "validators": {},
# },
"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": {
"repo_full_names": lambda: validate_list_field(
"repo_full_names", "repo_full_names"

View file

@ -60,6 +60,7 @@ dependencies = [
"mcp>=1.25.0",
"starlette>=0.40.0,<0.51.0",
"sse-starlette>=3.1.1,<3.1.2",
"gitingest>=0.3.1",
"composio>=0.10.9",
]

View file

@ -1978,6 +1978,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/61/ad/2394d4fb542574678b0ba342daf734d4d811768da3c2ee0c84d509dcb26c/github3.py-4.0.1-py3-none-any.whl", hash = "sha256:a89af7de25650612d1da2f0609622bcdeb07ee8a45a1c06b2d16a05e4234e753", size = 151800 },
]
[[package]]
name = "gitingest"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "httpx" },
{ name = "loguru" },
{ name = "pathspec" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "starlette" },
{ name = "tiktoken" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d6/fe/a915f0c32a3d7920206a677f73c185b3eadf4ec151fb05aedd52e64713f7/gitingest-0.3.1.tar.gz", hash = "sha256:4587cab873d4e08bdb16d612bb153c23e0ce59771a1d57a438239c5e39f05ebf", size = 70681, upload-time = "2025-07-31T13:56:19.845Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/15/f200ab2e73287e67d1dce6fbacf421552ae9fbafdc5f0cc8dd0d2fe4fc47/gitingest-0.3.1-py3-none-any.whl", hash = "sha256:8143a5e6a7140ede9f680e13d3931ac07c82ac9bd8bab9ad1fba017c8c1e8666", size = 68343, upload-time = "2025-07-31T13:56:17.729Z" },
]
[[package]]
name = "google-api-core"
version = "2.25.1"
@ -4493,6 +4512,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/39/c2/646d2e93e0af70f4e5359d870a63584dacbc324b54d73e6b3267920ff117/pandas-2.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bb3be958022198531eb7ec2008cfc78c5b1eed51af8600c6c5d9160d89d8d249", size = 13231847 },
]
[[package]]
name = "pathspec"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" },
]
[[package]]
name = "pdf2image"
version = "1.17.0"
@ -6523,6 +6551,7 @@ dependencies = [
{ name = "firecrawl-py" },
{ name = "flower" },
{ name = "github3-py" },
{ name = "gitingest" },
{ name = "google-api-python-client" },
{ name = "google-auth-oauthlib" },
{ name = "kokoro" },
@ -6589,6 +6618,7 @@ requires-dist = [
{ name = "firecrawl-py", specifier = ">=4.9.0" },
{ name = "flower", specifier = ">=2.0.1" },
{ name = "github3-py", specifier = "==4.0.1" },
{ name = "gitingest", specifier = ">=0.3.1" },
{ name = "google-api-python-client", specifier = ">=2.156.0" },
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
{ name = "kokoro", specifier = ">=0.9.4" },

View file

@ -16,7 +16,6 @@ import {
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { LayoutDataProvider } from "@/components/layout";
import { OnboardingTour } from "@/components/onboarding-tour";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@ -197,11 +196,7 @@ export function DashboardClientLayout({
return (
<DocumentUploadDialogProvider>
<OnboardingTour />
<LayoutDataProvider
searchSpaceId={searchSpaceId}
breadcrumb={<DashboardBreadcrumb />}
languageSwitcher={<LanguageSwitcher />}
>
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
{children}
</LayoutDataProvider>
</DocumentUploadDialogProvider>

View file

@ -32,6 +32,7 @@ import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
import { SaveMemoryToolUI, RecallMemoryToolUI } from "@/components/tool-ui/user-memory";
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
import { getBearerToken } from "@/lib/auth-utils";
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
@ -1056,17 +1057,13 @@ export default function NewChatPage() {
<LinkPreviewToolUI />
<DisplayImageToolUI />
<ScrapeWebpageToolUI />
<SaveMemoryToolUI />
<RecallMemoryToolUI />
{/* <WriteTodosToolUI /> Disabled for now */}
<div className="flex flex-col h-[calc(100vh-64px)] overflow-hidden">
<Thread
messageThinkingSteps={messageThinkingSteps}
header={
<ChatHeader
searchSpaceId={searchSpaceId}
thread={currentThread}
onThreadVisibilityChange={handleVisibilityChange}
/>
}
header={<ChatHeader searchSpaceId={searchSpaceId} />}
/>
</div>
</AssistantRuntimeProvider>

View file

@ -778,8 +778,7 @@ function RolesTab({
role.name === "Owner" && "text-amber-600",
role.name === "Editor" && "text-blue-600",
role.name === "Viewer" && "text-gray-600",
!["Owner", "Editor", "Viewer"].includes(role.name) &&
"text-primary"
!["Owner", "Editor", "Viewer"].includes(role.name) && "text-primary"
)}
/>
</div>
@ -1488,7 +1487,8 @@ function CreateRoleDialog({
</div>
</div>
<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>
<ScrollArea className="h-64 rounded-lg border p-4">
<div className="space-y-4">
@ -1500,9 +1500,7 @@ function CreateRoleDialog({
return (
<div key={category} className="space-y-2">
<label
className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left"
>
<label className="flex items-center gap-2 cursor-pointer hover:bg-muted/50 p-1 rounded w-full text-left">
<Checkbox
checked={allSelected}
onCheckedChange={() => toggleCategory(category)}

View file

@ -113,6 +113,8 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);

View file

@ -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) => {
set(currentThreadAtom, initialState);
});

View file

@ -191,7 +191,7 @@ export const AssistantMessage: FC = () => {
{/* Mobile & Medium screen comment trigger - shown below lg breakpoint */}
{showCommentTrigger && !isDesktop && (
<div className="mt-2 flex justify-start">
<div className="ml-2 mt-1 flex justify-start">
<button
type="button"
onClick={handleCommentTriggerClick}
@ -234,7 +234,7 @@ const AssistantActionBar: FC = () => {
hideWhenRunning
autohide="not-last"
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>
<TooltipIconButton tooltip="Copy">

View file

@ -24,11 +24,6 @@
"enabled": true,
"status": "warning",
"statusMessage": "Some requests may be blocked if not using Firecrawl."
},
"GITHUB_CONNECTOR": {
"enabled": false,
"status": "maintenance",
"statusMessage": "Rework in progress."
}
},
"globalSettings": {

View file

@ -1,17 +1,12 @@
"use client";
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 { useRef, useState } from "react";
import { useForm } from "react-hook-form";
import * as z from "zod";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
@ -34,8 +29,6 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { DateRangeSelector } from "../../components/date-range-selector";
import { getConnectorBenefits } from "../connector-benefits";
import type { ConnectFormProps } from "../index";
const githubConnectorFormSchema = z.object({
@ -44,10 +37,8 @@ const githubConnectorFormSchema = z.object({
}),
github_pat: z
.string()
.min(20, {
message: "GitHub Personal Access Token seems too short.",
})
.refine((pat) => pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
.optional()
.refine((pat) => !pat || pat.startsWith("ghp_") || pat.startsWith("github_pat_"), {
message: "GitHub PAT should start with 'ghp_' or 'github_pat_'",
}),
repo_full_names: z.string().min(1, {
@ -59,8 +50,6 @@ type GithubConnectorFormValues = z.infer<typeof githubConnectorFormSchema>;
export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }) => {
const isSubmittingRef = useRef(false);
const [startDate, setStartDate] = useState<Date | undefined>(undefined);
const [endDate, setEndDate] = useState<Date | undefined>(undefined);
const [periodicEnabled, setPeriodicEnabled] = useState(false);
const [frequencyMinutes, setFrequencyMinutes] = useState("1440");
const form = useForm<GithubConnectorFormValues>({
@ -94,16 +83,18 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
name: values.name,
connector_type: EnumConnectorName.GITHUB_CONNECTOR,
config: {
GITHUB_PAT: values.github_pat,
GITHUB_PAT: values.github_pat || null, // Optional - only for private repos
repo_full_names: repoList,
},
is_indexable: true,
is_active: true,
last_indexed_at: null,
periodic_indexing_enabled: periodicEnabled,
indexing_frequency_minutes: periodicEnabled ? parseInt(frequencyMinutes, 10) : null,
next_scheduled_at: null,
startDate,
endDate,
// GitHub indexes full repo snapshots - no date range needed
startDate: undefined,
endDate: undefined,
periodicEnabled,
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">
<Info className="h-3 w-3 sm:h-4 sm:w-4 shrink-0 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">
You'll need a GitHub Personal Access Token to use this connector. You can create one
from{" "}
A GitHub PAT is only required for private repositories. Public repos work without a
token.{" "}
<a
href="https://github.com/settings/tokens"
href="https://github.com/settings/tokens/new?description=surfsense&scopes=repo"
target="_blank"
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
</a>
Get your token
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
</a>{" "}
</AlertDescription>
</div>
</Alert>
@ -167,7 +159,10 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
name="github_pat"
render={({ field }) => (
<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>
<Input
type="password"
@ -178,8 +173,8 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
/>
</FormControl>
<FormDescription className="text-[10px] sm:text-xs">
Your GitHub PAT will be encrypted and stored securely. It typically starts with
"ghp_" or "github_pat_".
Only required for private repositories. Leave empty if indexing public repos
only.
</FormDescription>
<FormMessage />
</FormItem>
@ -225,15 +220,9 @@ export const GithubConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting
{/* Indexing Configuration */}
<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 */}
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
{/* Note: No date range for GitHub - it indexes full repo snapshots */}
{/* Periodic Sync Config */}
<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>
</div>
{/* What you get section */}
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR) && (
<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">
<h4 className="text-xs sm:text-sm font-medium">What you get with GitHub integration:</h4>
<ul className="list-disc pl-5 text-[10px] sm:text-xs text-muted-foreground space-y-1">
{getConnectorBenefits(EnumConnectorName.GITHUB_CONNECTOR)?.map((benefit) => (
<li key={benefit}>{benefit}</li>
))}
</ul>
</div>
)}
{/* Documentation Section */}
<Accordion
type="single"
collapsible
className="w-full border border-border rounded-xl bg-slate-400/5 dark:bg-white/5"
>
<AccordionItem value="documentation" className="border-0">
<AccordionTrigger className="text-sm sm:text-base font-medium px-3 sm:px-6 no-underline hover:no-underline">
Documentation
</AccordionTrigger>
<AccordionContent className="px-3 sm:px-6 pb-3 sm:pb-6 space-y-6">
<div>
<h3 className="text-sm sm:text-base font-semibold mb-2">How it works</h3>
<p className="text-[10px] sm:text-xs text-muted-foreground">
The 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>
{/* Documentation Link */}
<div>
<Link
href="/docs/connectors/github"
target="_blank"
rel="noopener noreferrer"
className="text-xs sm:text-sm font-medium underline underline-offset-4 hover:text-primary transition-colors inline-flex items-center gap-1.5"
>
View GitHub Connector Documentation
<ExternalLink className="h-3 w-3 sm:h-4 sm:w-4" />
</Link>
</div>
</div>
);
};

View file

@ -2,7 +2,7 @@
import { KeyRound } from "lucide-react";
import type { FC } from "react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -12,25 +12,29 @@ export interface GithubConfigProps extends ConnectorConfigProps {
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> = ({
connector,
onConfigChange,
onNameChange,
}) => {
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(", ");
};
// Track internal changes to prevent useEffect from overwriting user input
const isInternalChange = useRef(false);
const [githubPat, setGithubPat] = useState<string>(
(connector.config?.GITHUB_PAT as string) || ""
@ -40,8 +44,13 @@ export const GithubConfig: FC<GithubConfigProps> = ({
);
const [name, setName] = useState<string>(connector.name || "");
// Update values when connector changes
// Update values when connector changes externally (not from our own input)
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 repos = arrayToString(stringToArray(connector.config?.repo_full_names));
setGithubPat(pat);
@ -50,6 +59,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
}, [connector.config, connector.name]);
const handleGithubPatChange = (value: string) => {
isInternalChange.current = true;
setGithubPat(value);
if (onConfigChange) {
onConfigChange({
@ -60,6 +70,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
};
const handleRepoFullNamesChange = (value: string) => {
isInternalChange.current = true;
setRepoFullNames(value);
const repoList = stringToArray(value);
if (onConfigChange) {
@ -71,6 +82,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
};
const handleNameChange = (value: string) => {
isInternalChange.current = true;
setName(value);
if (onNameChange) {
onNameChange(value);
@ -105,7 +117,7 @@ export const GithubConfig: FC<GithubConfigProps> = ({
<div className="space-y-2">
<Label className="flex items-center gap-2 text-xs sm:text-sm">
<KeyRound className="h-4 w-4" />
GitHub Personal Access Token
GitHub Personal Access Token (optional)
</Label>
<Input
type="password"

View file

@ -206,9 +206,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
{/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector.is_indexable && (
<>
{/* Date range selector - not shown for Google Drive (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 !== "WEBCRAWLER_CONNECTOR" && (
connector.connector_type !== "WEBCRAWLER_CONNECTOR" &&
connector.connector_type !== "GITHUB_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}

View file

@ -151,9 +151,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
{/* Date range selector and periodic sync - only shown for indexable connectors */}
{connector?.is_indexable && (
<>
{/* Date range selector - not shown for Google Drive (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 !== "WEBCRAWLER_CONNECTOR" && (
config.connectorType !== "WEBCRAWLER_CONNECTOR" &&
config.connectorType !== "GITHUB_CONNECTOR" && (
<DateRangeSelector
startDate={startDate}
endDate={endDate}

View file

@ -44,7 +44,6 @@ function findMentionTrigger(
return { isActive: false, query: "", startIndex: 0 };
}
const fullMatch = mentionMatch[0];
const query = mentionMatch[1];
const atIndex = cursorPos - query.length - 1;
@ -80,7 +79,7 @@ function findMentionTrigger(
export function CommentComposer({
members,
membersLoading = false,
placeholder = "Write a comment...",
placeholder = "Comment or @mention",
submitLabel = "Send",
isSubmitting = false,
onSubmit,
@ -145,6 +144,13 @@ export function CommentComposer({
const cursorPos = e.target.selectionStart;
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);
if (triggerResult.isActive) {
@ -208,9 +214,9 @@ export function CommentComposer({
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
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 member = members.find(
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
@ -237,6 +243,19 @@ export function CommentComposer({
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 (
<div className="flex flex-col gap-2">
<Popover
@ -251,7 +270,8 @@ export function CommentComposer({
onChange={handleInputChange}
onKeyDown={handleKeyDown}
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}
/>
</PopoverAnchor>

View file

@ -21,7 +21,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment
<Button
variant="ghost"
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" />
</Button>

View file

@ -1,8 +1,6 @@
"use client";
import { useAtom } from "jotai";
import { MessageSquare } from "lucide-react";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@ -115,12 +113,8 @@ export function CommentItem({
members = [],
membersLoading = false,
}: CommentItemProps) {
const [{ data: currentUser }] = useAtom(currentUserAtom);
const isCurrentUser = currentUser?.id === comment.author?.id;
const displayName = isCurrentUser
? "Me"
: comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const displayName =
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || "";
const handleEditSubmit = (content: string) => {

View file

@ -1,13 +1,25 @@
"use client";
import { MessageSquarePlus } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { cn } from "@/lib/utils";
import { CommentComposer } from "../comment-composer/comment-composer";
import { CommentThread } from "../comment-thread/comment-thread";
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({
threads,
members,
@ -21,15 +33,10 @@ export function CommentPanel({
maxHeight,
variant = "desktop",
}: CommentPanelProps) {
const [isComposerOpen, setIsComposerOpen] = useState(false);
const [{ data: currentUser }] = useAtom(currentUserAtom);
const handleCommentSubmit = (content: string) => {
onCreateComment(content);
setIsComposerOpen(false);
};
const handleComposerCancel = () => {
setIsComposerOpen(false);
};
const isMobile = variant === "mobile";
@ -51,7 +58,6 @@ export function CommentPanel({
}
const hasThreads = threads.length > 0;
const showEmptyState = !hasThreads && !isComposerOpen;
// Ensure minimum usable height for empty state + composer button
const minHeight = 180;
@ -81,38 +87,35 @@ export function CommentPanel({
</div>
)}
{showEmptyState && (
<div className="flex min-h-[120px] flex-col items-center justify-center gap-2 p-4 text-center">
<MessageSquarePlus className="size-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No comments yet</p>
<p className="text-xs text-muted-foreground/70">
Start a conversation about this response
</p>
{!hasThreads && currentUser && (
<div className="flex items-center gap-3 px-4 pt-4 pb-1">
<Avatar className="size-10">
<AvatarImage
src={currentUser.avatar_url ?? undefined}
alt={currentUser.display_name ?? currentUser.email}
/>
<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 className={cn("p-3", showEmptyState && !isMobile && "border-t", isMobile && "border-t")}>
{isComposerOpen ? (
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Write a comment..."
submitLabel="Comment"
isSubmitting={isSubmitting}
onSubmit={handleCommentSubmit}
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 className="p-3">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Comment or @mention"
submitLabel="Comment"
isSubmitting={isSubmitting}
onSubmit={handleCommentSubmit}
autoFocus={!hasThreads}
/>
</div>
</div>
);

View file

@ -20,7 +20,7 @@ export function CommentSheet({
<SheetContent
side={side}
className={cn(
"flex flex-col p-0",
"flex flex-col gap-0 overflow-hidden p-0",
isBottomSheet ? "h-[85vh] max-h-[85vh] rounded-t-xl" : "h-full w-full max-w-md"
)}
>
@ -41,7 +41,7 @@ export function CommentSheet({
)}
</SheetTitle>
</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" />
</div>
</SheetContent>

View file

@ -128,23 +128,21 @@ export function CommentThread({
{/* Reply composer or button */}
{isReplyComposerOpen ? (
<>
<div className="pt-3">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Write a reply..."
submitLabel="Reply"
isSubmitting={isSubmitting}
onSubmit={handleReplySubmit}
onCancel={handleReplyCancel}
autoFocus
/>
</div>
</>
<div className="pt-3">
<CommentComposer
members={members}
membersLoading={membersLoading}
placeholder="Reply or @mention"
submitLabel="Reply"
isSubmitting={isSubmitting}
onSubmit={handleReplySubmit}
onCancel={handleReplyCancel}
autoFocus
/>
</div>
) : (
<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
</Button>
)}
@ -156,7 +154,7 @@ export function CommentThread({
{!hasReplies && !isReplyComposerOpen && (
<div className="ml-7 mt-1">
<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
</Button>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { MessageSquare } from "lucide-react";
import { MessageSquarePlus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { CommentTriggerProps } from "./types";
@ -25,7 +25,7 @@ export function CommentTrigger({ commentCount, isOpen, onClick, disabled }: Comm
)}
onClick={onClick}
>
<MessageSquare className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
<MessageSquarePlus className={cn("size-5", (hasComments || isOpen) && "fill-current")} />
{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">
{commentCount > 9 ? "9+" : commentCount}

View file

@ -76,10 +76,7 @@ export function DashboardBreadcrumb() {
const segments = path.split("/").filter(Boolean);
const breadcrumbs: BreadcrumbItemInterface[] = [];
// Always start with Dashboard
breadcrumbs.push({ label: t("dashboard"), href: "/dashboard" });
// Handle search space
// Handle search space (start directly with search space, skip "Dashboard")
if (segments[0] === "dashboard" && segments[1]) {
// Use the actual search space name if available, otherwise fall back to the ID
const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`;

View file

@ -34,14 +34,12 @@ interface LayoutDataProviderProps {
searchSpaceId: string;
children: React.ReactNode;
breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
}
export function LayoutDataProvider({
searchSpaceId,
children,
breadcrumb,
languageSwitcher,
}: LayoutDataProviderProps) {
const t = useTranslations("dashboard");
const tCommon = useTranslations("common");
@ -302,10 +300,6 @@ export function LayoutDataProvider({
}
}, [router]);
const handleToggleTheme = useCallback(() => {
setTheme(theme === "dark" ? "light" : "dark");
}, [theme, setTheme]);
const handleViewAllSharedChats = useCallback(() => {
setIsAllSharedChatsSidebarOpen(true);
}, []);
@ -375,9 +369,8 @@ export function LayoutDataProvider({
onLogout={handleLogout}
pageUsage={pageUsage}
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={handleToggleTheme}
setTheme={setTheme}
isChatPage={isChatPage}
>
{children}

View file

@ -1,25 +1,50 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useAtomValue } from "jotai";
import { usePathname } from "next/navigation";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { ChatShareButton } from "@/components/new-chat/chat-share-button";
import { NotificationButton } from "@/components/notifications/NotificationButton";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { ChatVisibility, ThreadRecord } from "@/lib/chat/thread-persistence";
interface HeaderProps {
breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
theme?: string;
onToggleTheme?: () => void;
mobileMenuTrigger?: React.ReactNode;
}
export function Header({
breadcrumb,
languageSwitcher,
theme,
onToggleTheme,
mobileMenuTrigger,
}: HeaderProps) {
export function Header({ breadcrumb, mobileMenuTrigger }: HeaderProps) {
const pathname = usePathname();
// Check if we're on a chat page
const isChatPage = pathname?.includes("/new-chat") ?? false;
// 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 (
<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 */}
@ -29,24 +54,13 @@ export function Header({
</div>
{/* Right side - Actions */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-4">
{/* Notifications */}
<NotificationButton />
{/* 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>
{/* Share button - only show on chat pages when thread exists */}
{hasThread && (
<ChatShareButton thread={threadForButton} onVisibilityChange={handleVisibilityChange} />
)}
{languageSwitcher}
</div>
</header>
);

View file

@ -35,9 +35,8 @@ interface LayoutShellProps {
onLogout?: () => void;
pageUsage?: PageUsage;
breadcrumb?: React.ReactNode;
languageSwitcher?: React.ReactNode;
theme?: string;
onToggleTheme?: () => void;
setTheme?: (theme: "light" | "dark" | "system") => void;
defaultCollapsed?: boolean;
isChatPage?: boolean;
children: React.ReactNode;
@ -69,9 +68,8 @@ export function LayoutShell({
onLogout,
pageUsage,
breadcrumb,
languageSwitcher,
theme,
onToggleTheme,
setTheme,
defaultCollapsed = false,
isChatPage = false,
children,
@ -88,9 +86,6 @@ export function LayoutShell({
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
<Header
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={onToggleTheme}
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
/>
@ -120,6 +115,8 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
/>
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
@ -166,16 +163,13 @@ export function LayoutShell({
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
className="hidden md:flex border-r shrink-0"
/>
<main className="flex-1 flex flex-col min-w-0">
<Header
breadcrumb={breadcrumb}
languageSwitcher={languageSwitcher}
theme={theme}
onToggleTheme={onToggleTheme}
/>
<Header breadcrumb={breadcrumb} />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}

View file

@ -5,12 +5,12 @@ import { format } from "date-fns";
import {
ArchiveIcon,
Loader2,
Lock,
MessageCircleMore,
MoreHorizontal,
RotateCcwIcon,
Search,
Trash2,
User,
X,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
@ -239,7 +239,7 @@ export function AllPrivateChatsSidebar({
<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">
<Lock className="h-5 w-5 text-primary" />
<User className="h-5 w-5 text-primary" />
<h2 className="text-lg font-semibold">{t("chats") || "Private Chats"}</h2>
</div>
<Button
@ -419,7 +419,7 @@ export function AllPrivateChatsSidebar({
</div>
) : (
<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">
{showArchived
? t("no_archived_chats") || "No archived chats"

View file

@ -33,6 +33,8 @@ interface MobileSidebarProps {
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
}
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
@ -70,6 +72,8 @@ export function MobileSidebar({
onUserSettings,
onLogout,
pageUsage,
theme,
setTheme,
}: MobileSidebarProps) {
const handleSearchSpaceSelect = (id: number) => {
onSearchSpaceSelect(id);
@ -145,6 +149,8 @@ export function MobileSidebar({
onUserSettings={onUserSettings}
onLogout={onLogout}
pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
className="w-full border-none"
/>
</div>

View file

@ -35,6 +35,8 @@ interface SidebarProps {
onUserSettings?: () => void;
onLogout?: () => void;
pageUsage?: PageUsage;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
className?: string;
}
@ -58,6 +60,8 @@ export function Sidebar({
onUserSettings,
onLogout,
pageUsage,
theme,
setTheme,
className,
}: SidebarProps) {
const t = useTranslations("sidebar");
@ -241,6 +245,8 @@ export function Sidebar({
onUserSettings={onUserSettings}
onLogout={onLogout}
isCollapsed={isCollapsed}
theme={theme}
setTheme={setTheme}
/>
</div>
</div>

View file

@ -1,24 +1,44 @@
"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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useLocaleContext } from "@/contexts/LocaleContext";
import { cn } from "@/lib/utils";
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 {
user: User;
onUserSettings?: () => void;
onLogout?: () => void;
isCollapsed?: boolean;
theme?: string;
setTheme?: (theme: "light" | "dark" | "system") => void;
}
/**
@ -99,12 +119,23 @@ export function SidebarUserProfile({
onUserSettings,
onLogout,
isCollapsed = false,
theme,
setTheme,
}: SidebarUserProfileProps) {
const t = useTranslations("sidebar");
const { locale, setLocale } = useLocaleContext();
const bgColor = stringToColor(user.email);
const initials = getInitials(user.email);
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
if (isCollapsed) {
return (
@ -118,7 +149,8 @@ export function SidebarUserProfile({
className={cn(
"flex h-10 w-full items-center justify-center rounded-md",
"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} />
@ -129,7 +161,7 @@ export function SidebarUserProfile({
<TooltipContent side="right">{displayName}</TooltipContent>
</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">
<div className="flex items-center gap-2">
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -147,6 +179,65 @@ export function SidebarUserProfile({
{t("user_settings")}
</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 />
<DropdownMenuItem onClick={onLogout}>
@ -169,7 +260,8 @@ export function SidebarUserProfile({
className={cn(
"flex w-full items-center gap-2 px-2 py-3 text-left",
"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} />
@ -185,7 +277,7 @@ export function SidebarUserProfile({
</button>
</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">
<div className="flex items-center gap-2">
<UserAvatar avatarUrl={user.avatarUrl} initials={initials} bgColor={bgColor} />
@ -203,6 +295,65 @@ export function SidebarUserProfile({
{t("user_settings")}
</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 />
<DropdownMenuItem onClick={onLogout}>

View file

@ -5,18 +5,14 @@ import type {
GlobalNewLLMConfig,
NewLLMConfigPublic,
} 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 { ModelSelector } from "./model-selector";
interface ChatHeaderProps {
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 [selectedConfig, setSelectedConfig] = useState<
NewLLMConfigPublic | GlobalNewLLMConfig | null
@ -52,7 +48,6 @@ export function ChatHeader({ searchSpaceId, thread, onThreadVisibilityChange }:
return (
<div className="flex items-center gap-2">
<ModelSelector onEdit={handleEditConfig} onAddNew={handleAddNew} />
<ChatShareButton thread={thread ?? null} onVisibilityChange={onThreadVisibilityChange} />
<ModelConfigSidebar
open={sidebarOpen}
onOpenChange={handleSidebarClose}

View file

@ -1,11 +1,14 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2, Lock, Users } from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { Loader2, User, Users } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom, setThreadVisibilityAtom } from "@/atoms/chat/current-thread.atom";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import {
type ChatVisibility,
type ThreadRecord,
@ -23,13 +26,13 @@ const visibilityOptions: {
value: ChatVisibility;
label: string;
description: string;
icon: typeof Lock;
icon: typeof User;
}[] = [
{
value: "PRIVATE",
label: "Private",
description: "Only you can access this chat",
icon: Lock,
icon: User,
},
{
value: "SEARCH_SPACE",
@ -44,7 +47,12 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
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 handleVisibilityChange = useCallback(
@ -55,10 +63,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
}
setIsUpdating(true);
// Update Jotai atom immediately for instant UI feedback
setThreadVisibility(newVisibility);
try {
await updateThreadVisibility(thread.id, newVisibility);
// Refetch all thread queries to update sidebar immediately
// Refetch threads list to update sidebar
await queryClient.refetchQueries({
predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "threads",
});
@ -70,12 +81,14 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
setOpen(false);
} catch (error) {
console.error("Failed to update visibility:", error);
// Revert Jotai state on error
setThreadVisibility(thread.visibility ?? "PRIVATE");
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)
@ -83,38 +96,41 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
return null;
}
const CurrentIcon = currentVisibility === "PRIVATE" ? Lock : Users;
const CurrentIcon = currentVisibility === "PRIVATE" ? User : Users;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
"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
)}
>
<CurrentIcon className="size-3.5 md:size-4 text-muted-foreground" />
<span className="hidden md:inline">
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
</span>
</Button>
</PopoverTrigger>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"h-8 w-8 md:w-auto md:px-3 md:gap-2 relative bg-muted hover:bg-muted/80 border-0",
className
)}
>
<CurrentIcon className="h-4 w-4" />
<span className="hidden md:inline text-sm">
{currentVisibility === "PRIVATE" ? "Private" : "Shared"}
</span>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Share settings</TooltipContent>
</Tooltip>
<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"
sideOffset={8}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<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="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Updating</span>
@ -133,7 +149,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
onClick={() => handleVisibilityChange(option.value)}
disabled={isUpdating}
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",
"focus:outline-none",
isSelected && "bg-accent/80"
@ -141,13 +157,13 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
>
<div
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"
)}
>
<Icon
className={cn(
"size-3.5",
"size-4 block",
isSelected ? "text-primary" : "text-muted-foreground"
)}
/>
@ -157,11 +173,6 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
<span className={cn("text-sm font-medium", isSelected && "text-primary")}>
{option.label}
</span>
{isSelected && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-primary/10 text-primary font-medium">
Current
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 leading-snug">
{option.description}

View file

@ -4,6 +4,7 @@ import { useAtomValue } from "jotai";
import { AlertCircle, Bot, ChevronRight, Globe, User, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import {
createNewLLMConfigMutationAtom,
@ -38,6 +39,12 @@ export function ModelConfigSidebar({
mode,
}: ModelConfigSidebarProps) {
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
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
@ -147,7 +154,9 @@ export function ModelConfigSidebar({
}
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
return (
if (!mounted) return null;
const sidebarContent = (
<AnimatePresence>
{open && (
<>
@ -157,7 +166,7 @@ export function ModelConfigSidebar({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
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)}
/>
@ -172,7 +181,7 @@ export function ModelConfigSidebar({
stiffness: 300,
}}
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",
"flex flex-col"
)}
@ -245,16 +254,16 @@ export function ModelConfigSidebar({
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<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
</label>
</div>
<p className="text-sm font-medium">{config.name}</p>
</div>
{config.description && (
<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
</label>
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
</div>
)}
@ -264,15 +273,15 @@ export function ModelConfigSidebar({
<div className="grid gap-4 sm:grid-cols-2">
<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
</label>
</div>
<p className="text-sm font-medium">{config.provider}</p>
</div>
<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
</label>
</div>
<p className="text-sm font-medium font-mono">{config.model_name}</p>
</div>
</div>
@ -281,9 +290,9 @@ export function ModelConfigSidebar({
<div className="grid gap-4 sm:grid-cols-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
</label>
</div>
<Badge
variant={config.citations_enabled ? "default" : "secondary"}
className="w-fit"
@ -297,9 +306,9 @@ export function ModelConfigSidebar({
<>
<div className="h-px bg-border/50" />
<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
</label>
</div>
<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">
{config.system_instructions}
@ -367,4 +376,6 @@ export function ModelConfigSidebar({
)}
</AnimatePresence>
);
return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null;
}

View file

@ -170,59 +170,55 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
variant="outline"
size="sm"
role="combobox"
aria-expanded={open}
className={cn(
"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
)}
className={cn("h-8 gap-2 px-3 text-sm border-border/60", className)}
>
{isLoading ? (
<>
<Loader2 className="size-3.5 md:size-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline">Loading...</span>
<span className="text-muted-foreground md:hidden">Load...</span>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground hidden md:inline">Loading</span>
</>
) : currentConfig ? (
<>
{getProviderIcon(currentConfig.provider)}
<span className="max-w-[80px] md:max-w-[150px] truncate">{currentConfig.name}</span>
<Badge
variant="secondary"
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"
>
<span className="max-w-[100px] md:max-w-[150px] truncate hidden md:inline">
{currentConfig.name}
</span>
<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.slice(0, 10)}
</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 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>
</PopoverTrigger>
<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"
sideOffset={8}
>
<Command
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="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-lg">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Switching model...</span>
@ -230,9 +226,9 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
</div>
)}
<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
placeholder="Search models..."
placeholder="Search models"
value={searchQuery}
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"
@ -243,7 +239,7 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
<CommandList className="max-h-[300px] md:max-h-[400px] overflow-y-auto">
<CommandEmpty className="py-8 text-center">
<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-xs text-muted-foreground/60">Try a different search term</p>
</div>
@ -264,8 +260,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`global-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer",
"aria-selected:bg-accent/50",
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>
@ -326,8 +322,8 @@ export function ModelSelector({ onEdit, onAddNew, className }: ModelSelectorProp
value={`user-${config.id}`}
onSelect={() => handleSelectConfig(config)}
className={cn(
"mx-2 rounded-lg mb-1 cursor-pointer",
"aria-selected:bg-accent/50",
"mx-2 rounded-lg mb-1 cursor-pointer transition-all",
"hover:bg-accent/50",
isSelected && "bg-accent/80"
)}
>

View file

@ -4,7 +4,6 @@ import { useQuery } from "@tanstack/react-query";
import {
BookOpen,
ChevronDown,
ChevronUp,
ExternalLink,
FileText,
Hash,
@ -387,7 +386,7 @@ export function SourceDetailPanel({
<div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
<Loader2 className="h-12 w-12 animate-spin text-primary relative" />
</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>
</div>
)}
@ -490,8 +489,8 @@ export function SourceDetailPanel({
>
{idx + 1}
{isCited && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-primary rounded-full border-2 border-background">
<Sparkles className="h-2 w-2 text-primary-foreground absolute top-0.5 left-0.5" />
<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.5 w-2.5 text-primary-foreground" />
</span>
)}
</motion.button>

View file

@ -3,27 +3,64 @@
import { useAtomValue } from "jotai";
import { Bell } from "lucide-react";
import { useParams } from "next/navigation";
import { useState } from "react";
import { useCallback, useEffect, 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 { useNotifications, type NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
import { NotificationPopup } from "./NotificationPopup";
const NOTIFICATION_FILTER_STORAGE_KEY = "surfsense_notification_filter";
export function NotificationButton() {
const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
// Filter state - null means show all, otherwise filter by type
const [activeFilter, setActiveFilter] = useState<NotificationTypeEnum | null>(null);
// Load filter from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(NOTIFICATION_FILTER_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (
parsed === null ||
["new_mention", "connector_indexing", "document_processing"].includes(parsed)
) {
setActiveFilter(parsed);
}
}
} catch {
// Ignore localStorage errors
}
}, []);
// Handle filter toggle - clicking same pill again shows all
const handleFilterChange = useCallback((filter: NotificationTypeEnum | null) => {
setActiveFilter((current) => {
const newFilter = current === filter ? null : filter;
try {
localStorage.setItem(NOTIFICATION_FILTER_STORAGE_KEY, JSON.stringify(newFilter));
} catch {
// Ignore localStorage errors
}
return newFilter;
});
}, []);
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
searchSpaceId,
activeFilter
);
return (
@ -31,7 +68,7 @@ export function NotificationButton() {
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8 relative">
<Button variant="outline" size="icon" className="h-8 w-8 relative border-0">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span
@ -57,6 +94,8 @@ export function NotificationButton() {
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
onClose={() => setOpen(false)}
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
/>
</PopoverContent>
</Popover>

View file

@ -1,15 +1,53 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { AlertCircle, Bell, CheckCheck, CheckCircle2, Loader2 } from "lucide-react";
import {
AlertCircle,
AtSign,
Bell,
Cable,
CheckCheck,
CheckCircle2,
FileText,
Loader2,
} from "lucide-react";
import { useRouter } from "next/navigation";
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 { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import type { Notification, NotificationTypeEnum } from "@/hooks/use-notifications";
import { cn } from "@/lib/utils";
/**
* Filter configuration for notification types
*/
const NOTIFICATION_FILTERS = {
new_mention: { label: "Mentions", icon: AtSign },
connector_indexing: { label: "Connectors", icon: Cable },
document_processing: { label: "Documents", icon: FileText },
} as const;
/**
* 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";
}
interface NotificationPopupProps {
notifications: Notification[];
unreadCount: number;
@ -17,6 +55,8 @@ interface NotificationPopupProps {
markAsRead: (id: number) => Promise<boolean>;
markAllAsRead: () => Promise<boolean>;
onClose?: () => void;
activeFilter: NotificationTypeEnum | null;
onFilterChange: (filter: NotificationTypeEnum | null) => void;
}
export function NotificationPopup({
@ -26,6 +66,8 @@ export function NotificationPopup({
markAsRead,
markAllAsRead,
onClose,
activeFilter,
onFilterChange,
}: NotificationPopupProps) {
const router = useRouter();
@ -66,6 +108,28 @@ export function NotificationPopup({
};
const getStatusIcon = (notification: Notification) => {
// For mentions, show the author's avatar with initials fallback
if (notification.type === "new_mention") {
const metadata = notification.metadata as {
author_name?: string;
author_avatar_url?: string | null;
author_email?: string;
};
const authorName = metadata?.author_name;
const avatarUrl = metadata?.author_avatar_url;
const authorEmail = metadata?.author_email;
return (
<Avatar className="h-6 w-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt={authorName || "User"} />}
<AvatarFallback className="text-[10px] bg-primary/10 text-primary">
{getInitials(authorName, authorEmail)}
</AvatarFallback>
</Avatar>
);
}
// For other notification types, show status icons
const status = notification.metadata?.status as string | undefined;
switch (status) {
@ -83,7 +147,7 @@ export function NotificationPopup({
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 justify-between px-4 py-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">Notifications</h3>
</div>
@ -95,6 +159,35 @@ export function NotificationPopup({
)}
</div>
{/* Filter Pills */}
<div className="flex items-center gap-1.5 px-4 py-2 overflow-x-auto">
{(
Object.entries(NOTIFICATION_FILTERS) as [
NotificationTypeEnum,
(typeof NOTIFICATION_FILTERS)[keyof typeof NOTIFICATION_FILTERS],
][]
).map(([key, { label, icon: Icon }]) => {
const isActive = activeFilter === key;
return (
<button
key={key}
type="button"
onClick={() => onFilterChange(key)}
className={cn(
"inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-medium transition-colors whitespace-nowrap",
"border focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
isActive
? "bg-primary text-primary-foreground border-primary"
: "bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-3 w-3" />
{label}
</button>
);
})}
</div>
{/* Notifications List */}
<ScrollArea className="h-[400px]">
{loading ? (

View file

@ -77,4 +77,17 @@ export {
ScrapeWebpageResultSchema,
ScrapeWebpageToolUI,
} 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";

View 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,
};

View file

@ -182,13 +182,13 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
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
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
<ChevronRightIcon className="ml-auto size-4 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger>
);
}

View file

@ -1,7 +1,7 @@
"use client";
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 { cn } from "@/lib/utils";
@ -94,16 +94,11 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
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
)}
{...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.Item>
);

View file

@ -3,4 +3,81 @@ title: GitHub
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

View file

@ -83,6 +83,8 @@ export const newMentionMetadata = z.object({
thread_title: z.string(),
author_id: z.string(),
author_name: z.string(),
author_avatar_url: z.string().nullable().optional(),
author_email: z.string().optional(),
content_preview: z.string(),
});

View file

@ -1,12 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import type { Notification } from "@/contracts/types/notification.types";
import type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import type { SyncHandle } from "@/lib/electric/client";
import { useElectricClient } from "@/lib/electric/context";
export type { Notification } from "@/contracts/types/notification.types";
export type { Notification, NotificationTypeEnum } from "@/contracts/types/notification.types";
/**
* Hook for managing notifications with Electric SQL real-time sync
@ -22,16 +22,23 @@ export type { Notification } from "@/contracts/types/notification.types";
*
* @param userId - The user ID to fetch notifications for
* @param searchSpaceId - The search space ID to filter notifications (null shows global notifications only)
* @param typeFilter - Optional notification type to filter by (null shows all types)
*/
export function useNotifications(userId: string | null, searchSpaceId: number | null) {
export function useNotifications(
userId: string | null,
searchSpaceId: number | null,
typeFilter: NotificationTypeEnum | null = null
) {
// Get Electric client from context - ElectricProvider handles initialization
const electricClient = useElectricClient();
const [notifications, setNotifications] = useState<Notification[]>([]);
const [totalUnreadCount, setTotalUnreadCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const syncHandleRef = useRef<SyncHandle | null>(null);
const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
const unreadCountLiveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
// Track user-level sync key to prevent duplicate sync subscriptions
const userSyncKeyRef = useRef<string | null>(null);
@ -108,7 +115,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
};
}, [userId, electricClient]);
// EFFECT 2: Search-space-level query - updates when searchSpaceId changes
// EFFECT 2: Search-space-level query - updates when searchSpaceId or typeFilter changes
// This runs independently of sync, allowing smooth transitions between search spaces
useEffect(() => {
if (!userId || !electricClient) {
@ -125,16 +132,24 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
}
try {
console.log("[useNotifications] Updating query for searchSpace:", searchSpaceId);
console.log(
"[useNotifications] Updating query for searchSpace:",
searchSpaceId,
"typeFilter:",
typeFilter
);
// Build query with optional type filter
const baseQuery = `SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)`;
const typeClause = typeFilter ? ` AND type = $3` : "";
const orderClause = ` ORDER BY created_at DESC`;
const fullQuery = baseQuery + typeClause + orderClause;
const params = typeFilter ? [userId, searchSpaceId, typeFilter] : [userId, searchSpaceId];
// Fetch notifications for current search space immediately
const result = await electricClient.db.query<Notification>(
`SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
ORDER BY created_at DESC`,
[userId, searchSpaceId]
);
const result = await electricClient.db.query<Notification>(fullQuery, params);
if (mounted) {
setNotifications(result.rows || []);
@ -145,13 +160,7 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(
`SELECT * FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
ORDER BY created_at DESC`,
[userId, searchSpaceId]
);
const liveQuery = await db.live.query(fullQuery, params);
if (!mounted) {
liveQuery.unsubscribe?.();
@ -192,6 +201,86 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
liveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, typeFilter, electricClient]);
// EFFECT 3: Total unread count - independent of type filter
// This ensures the badge count stays consistent regardless of active filter
useEffect(() => {
if (!userId || !electricClient) {
return;
}
let mounted = true;
async function updateUnreadCount() {
// Clean up previous live query
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
try {
const countQuery = `SELECT COUNT(*) as count FROM notifications
WHERE user_id = $1
AND (search_space_id = $2 OR search_space_id IS NULL)
AND read = false`;
// Fetch initial count
const result = await electricClient.db.query<{ count: number }>(countQuery, [
userId,
searchSpaceId,
]);
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
// Set up live query for real-time updates
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = electricClient.db as any;
if (db.live?.query && typeof db.live.query === "function") {
const liveQuery = await db.live.query(countQuery, [userId, searchSpaceId]);
if (!mounted) {
liveQuery.unsubscribe?.();
return;
}
// Set initial results from live query
if (liveQuery.initialResults?.rows?.[0]) {
setTotalUnreadCount(Number(liveQuery.initialResults.rows[0].count) || 0);
} else if (liveQuery.rows?.[0]) {
setTotalUnreadCount(Number(liveQuery.rows[0].count) || 0);
}
// Subscribe to changes
if (typeof liveQuery.subscribe === "function") {
liveQuery.subscribe((result: { rows: { count: number }[] }) => {
if (mounted && result.rows?.[0]) {
setTotalUnreadCount(Number(result.rows[0].count) || 0);
}
});
}
if (typeof liveQuery.unsubscribe === "function") {
unreadCountLiveQueryRef.current = liveQuery;
}
}
} catch (err) {
console.error("[useNotifications] Failed to update unread count:", err);
}
}
updateUnreadCount();
return () => {
mounted = false;
if (unreadCountLiveQueryRef.current) {
unreadCountLiveQueryRef.current.unsubscribe();
unreadCountLiveQueryRef.current = null;
}
};
}, [userId, searchSpaceId, electricClient]);
// Mark notification as read via backend API
@ -234,12 +323,9 @@ export function useNotifications(userId: string | null, searchSpaceId: number |
}
}, []);
// Get unread count
const unreadCount = notifications.filter((n) => !n.read).length;
return {
notifications,
unreadCount,
unreadCount: totalUnreadCount,
markAsRead,
markAllAsRead,
loading,

View file

@ -687,6 +687,11 @@
"expand_sidebar": "Expand sidebar",
"collapse_sidebar": "Collapse sidebar",
"user_settings": "User settings",
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"logout": "Logout"
},
"errors": {

View file

@ -672,6 +672,11 @@
"expand_sidebar": "展开侧边栏",
"collapse_sidebar": "收起侧边栏",
"user_settings": "用户设置",
"language": "语言",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统",
"logout": "退出登录"
},
"errors": {

View file

@ -52,6 +52,9 @@ module.exports = {
},
},
borderRadius: {
"3xl": "calc(var(--radius) + 12px)",
"2xl": "calc(var(--radius) + 8px)",
xl: "calc(var(--radius) + 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",