mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-05 13:52:40 +02:00
Merge remote-tracking branch 'upstream/dev' into fix/auth
This commit is contained in:
commit
2dec643cb4
80 changed files with 2968 additions and 2379 deletions
|
|
@ -9,6 +9,8 @@ CELERY_TASK_DEFAULT_QUEUE=surfsense
|
||||||
# Redis for app-level features (heartbeats, podcast markers)
|
# Redis for app-level features (heartbeats, podcast markers)
|
||||||
# Defaults to CELERY_BROKER_URL when not set
|
# Defaults to CELERY_BROKER_URL when not set
|
||||||
REDIS_APP_URL=redis://localhost:6379/0
|
REDIS_APP_URL=redis://localhost:6379/0
|
||||||
|
# Optional: TTL in seconds for connector indexing lock key
|
||||||
|
# CONNECTOR_INDEXING_LOCK_TTL_SECONDS=28800
|
||||||
|
|
||||||
#Electric(for migrations only)
|
#Electric(for migrations only)
|
||||||
ELECTRIC_DB_USER=electric
|
ELECTRIC_DB_USER=electric
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
"""Add shared_memories table (SUR-152)."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
revision: str = "96"
|
||||||
|
down_revision: str | None = "95"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
EMBEDDING_DIM = config.embedding_model_instance.dimension
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_name = 'shared_memories'
|
||||||
|
) THEN
|
||||||
|
CREATE TABLE shared_memories (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
search_space_id INTEGER NOT NULL REFERENCES searchspaces(id) ON DELETE CASCADE,
|
||||||
|
created_by_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
memory_text TEXT NOT NULL,
|
||||||
|
category memorycategory NOT NULL DEFAULT 'fact',
|
||||||
|
embedding vector({EMBEDDING_DIM})
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'shared_memories' AND indexname = 'ix_shared_memories_search_space_id'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_shared_memories_search_space_id ON shared_memories(search_space_id);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'shared_memories' AND indexname = 'ix_shared_memories_updated_at'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_shared_memories_updated_at ON shared_memories(updated_at);
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_indexes
|
||||||
|
WHERE tablename = 'shared_memories' AND indexname = 'ix_shared_memories_created_by_id'
|
||||||
|
) THEN
|
||||||
|
CREATE INDEX ix_shared_memories_created_by_id ON shared_memories(created_by_id);
|
||||||
|
END IF;
|
||||||
|
END$$;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS shared_memories_vector_index
|
||||||
|
ON shared_memories USING hnsw (embedding public.vector_cosine_ops);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS shared_memories_vector_index;")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_shared_memories_created_by_id;")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_shared_memories_updated_at;")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_shared_memories_search_space_id;")
|
||||||
|
op.execute("DROP TABLE IF EXISTS shared_memories CASCADE;")
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
"""Add GITHUB_MODELS to LiteLLMProvider enum
|
||||||
|
|
||||||
|
Revision ID: 97
|
||||||
|
Revises: 96
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "97"
|
||||||
|
down_revision: str | None = "96"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("COMMIT")
|
||||||
|
op.execute("ALTER TYPE litellmprovider ADD VALUE IF NOT EXISTS 'GITHUB_MODELS'")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
|
|
@ -22,6 +22,7 @@ from app.agents.new_chat.system_prompt import (
|
||||||
build_surfsense_system_prompt,
|
build_surfsense_system_prompt,
|
||||||
)
|
)
|
||||||
from app.agents.new_chat.tools.registry import build_tools_async
|
from app.agents.new_chat.tools.registry import build_tools_async
|
||||||
|
from app.db import ChatVisibility
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -126,6 +127,7 @@ async def create_surfsense_deep_agent(
|
||||||
disabled_tools: list[str] | None = None,
|
disabled_tools: list[str] | None = None,
|
||||||
additional_tools: Sequence[BaseTool] | None = None,
|
additional_tools: Sequence[BaseTool] | None = None,
|
||||||
firecrawl_api_key: str | None = None,
|
firecrawl_api_key: str | None = None,
|
||||||
|
thread_visibility: ChatVisibility | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a SurfSense deep agent with configurable tools and prompts.
|
Create a SurfSense deep agent with configurable tools and prompts.
|
||||||
|
|
@ -228,14 +230,15 @@ async def create_surfsense_deep_agent(
|
||||||
logging.warning(f"Failed to discover available connectors/document types: {e}")
|
logging.warning(f"Failed to discover available connectors/document types: {e}")
|
||||||
|
|
||||||
# Build dependencies dict for the tools registry
|
# Build dependencies dict for the tools registry
|
||||||
|
visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||||
dependencies = {
|
dependencies = {
|
||||||
"search_space_id": search_space_id,
|
"search_space_id": search_space_id,
|
||||||
"db_session": db_session,
|
"db_session": db_session,
|
||||||
"connector_service": connector_service,
|
"connector_service": connector_service,
|
||||||
"firecrawl_api_key": firecrawl_api_key,
|
"firecrawl_api_key": firecrawl_api_key,
|
||||||
"user_id": user_id, # Required for memory tools
|
"user_id": user_id,
|
||||||
"thread_id": thread_id, # For podcast tool
|
"thread_id": thread_id,
|
||||||
# Dynamic connector/document type discovery for knowledge base tool
|
"thread_visibility": visibility,
|
||||||
"available_connectors": available_connectors,
|
"available_connectors": available_connectors,
|
||||||
"available_document_types": available_document_types,
|
"available_document_types": available_document_types,
|
||||||
}
|
}
|
||||||
|
|
@ -255,10 +258,12 @@ async def create_surfsense_deep_agent(
|
||||||
custom_system_instructions=agent_config.system_instructions,
|
custom_system_instructions=agent_config.system_instructions,
|
||||||
use_default_system_instructions=agent_config.use_default_system_instructions,
|
use_default_system_instructions=agent_config.use_default_system_instructions,
|
||||||
citations_enabled=agent_config.citations_enabled,
|
citations_enabled=agent_config.citations_enabled,
|
||||||
|
thread_visibility=thread_visibility,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Use default prompt (with citations enabled)
|
system_prompt = build_surfsense_system_prompt(
|
||||||
system_prompt = build_surfsense_system_prompt()
|
thread_visibility=thread_visibility,
|
||||||
|
)
|
||||||
|
|
||||||
# Create the deep agent with system prompt and checkpointer
|
# Create the deep agent with system prompt and checkpointer
|
||||||
# Note: TodoListMiddleware (write_todos) is included by default in create_deep_agent
|
# Note: TodoListMiddleware (write_todos) is included by default in create_deep_agent
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ PROVIDER_MAP = {
|
||||||
"ALIBABA_QWEN": "openai",
|
"ALIBABA_QWEN": "openai",
|
||||||
"MOONSHOT": "openai",
|
"MOONSHOT": "openai",
|
||||||
"ZHIPU": "openai",
|
"ZHIPU": "openai",
|
||||||
|
"GITHUB_MODELS": "github",
|
||||||
"REPLICATE": "replicate",
|
"REPLICATE": "replicate",
|
||||||
"PERPLEXITY": "perplexity",
|
"PERPLEXITY": "perplexity",
|
||||||
"ANYSCALE": "anyscale",
|
"ANYSCALE": "anyscale",
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ The prompt is composed of three parts:
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.db import ChatVisibility
|
||||||
|
|
||||||
# Default system instructions - can be overridden via NewLLMConfig.system_instructions
|
# Default system instructions - can be overridden via NewLLMConfig.system_instructions
|
||||||
SURFSENSE_SYSTEM_INSTRUCTIONS = """
|
SURFSENSE_SYSTEM_INSTRUCTIONS = """
|
||||||
<system_instruction>
|
<system_instruction>
|
||||||
|
|
@ -22,7 +24,34 @@ Today's date (UTC): {resolved_today}
|
||||||
</system_instruction>
|
</system_instruction>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
SURFSENSE_TOOLS_INSTRUCTIONS = """
|
# Default system instructions for shared (team) threads: team context + message format for attribution
|
||||||
|
_SYSTEM_INSTRUCTIONS_SHARED = """
|
||||||
|
<system_instruction>
|
||||||
|
You are SurfSense, a reasoning and acting AI agent designed to answer questions in this team space using the team's shared knowledge base.
|
||||||
|
|
||||||
|
In this team thread, each message is prefixed with **[DisplayName of the author]**. Use this to attribute and reference the author of anything in the discussion (who asked a question, made a suggestion, or contributed an idea) and to cite who said what in your answers.
|
||||||
|
|
||||||
|
Today's date (UTC): {resolved_today}
|
||||||
|
|
||||||
|
</system_instruction>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_system_instructions(
|
||||||
|
thread_visibility: ChatVisibility | None = None, today: datetime | None = None
|
||||||
|
) -> str:
|
||||||
|
"""Build system instructions based on thread visibility (private vs shared)."""
|
||||||
|
|
||||||
|
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
|
||||||
|
visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||||
|
if visibility == ChatVisibility.SEARCH_SPACE:
|
||||||
|
return _SYSTEM_INSTRUCTIONS_SHARED.format(resolved_today=resolved_today)
|
||||||
|
else:
|
||||||
|
return SURFSENSE_SYSTEM_INSTRUCTIONS.format(resolved_today=resolved_today)
|
||||||
|
|
||||||
|
|
||||||
|
# Tools 0-6 (common to both private and shared prompts)
|
||||||
|
_TOOLS_INSTRUCTIONS_COMMON = """
|
||||||
<tools>
|
<tools>
|
||||||
You have access to the following tools:
|
You have access to the following tools:
|
||||||
|
|
||||||
|
|
@ -138,7 +167,11 @@ You have access to the following tools:
|
||||||
* Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content.
|
* Prioritize showing: diagrams, charts, infographics, key illustrations, or images that help explain the content.
|
||||||
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
* Don't show every image - just the most relevant 1-3 images that enhance understanding.
|
||||||
|
|
||||||
7. save_memory: Save facts, preferences, or context about the user for personalized responses.
|
"""
|
||||||
|
|
||||||
|
# Private (user) memory: tools 7-8 + memory-specific examples
|
||||||
|
_TOOLS_INSTRUCTIONS_MEMORY_PRIVATE = """
|
||||||
|
7. save_memory: Save facts, preferences, or context for personalized responses.
|
||||||
- Use this when the user explicitly or implicitly shares information worth remembering.
|
- Use this when the user explicitly or implicitly shares information worth remembering.
|
||||||
- Trigger scenarios:
|
- Trigger scenarios:
|
||||||
* User says "remember this", "keep this in mind", "note that", or similar
|
* User says "remember this", "keep this in mind", "note that", or similar
|
||||||
|
|
@ -178,6 +211,75 @@ You have access to the following tools:
|
||||||
stating "Based on your memory..." - integrate the context seamlessly.
|
stating "Based on your memory..." - integrate the context seamlessly.
|
||||||
</tools>
|
</tools>
|
||||||
<tool_call_examples>
|
<tool_call_examples>
|
||||||
|
- 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
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Shared (team) memory: tools 7-8 + team memory examples
|
||||||
|
_TOOLS_INSTRUCTIONS_MEMORY_SHARED = """
|
||||||
|
7. save_memory: Save a fact, preference, or context to the team's shared memory for future reference.
|
||||||
|
- Use this when the user or a team member says "remember this", "keep this in mind", or similar in this shared chat.
|
||||||
|
- Use when the team agrees on something to remember (e.g., decisions, conventions).
|
||||||
|
- Someone shares a preference or fact that should be visible to the whole team.
|
||||||
|
- The saved information will be available in future shared conversations in this space.
|
||||||
|
- Args:
|
||||||
|
- content: The fact/preference/context to remember. Phrase it clearly, e.g. "API keys are stored in Vault", "The team prefers weekly demos on Fridays"
|
||||||
|
- category: Type of memory. One of:
|
||||||
|
* "preference": Team or workspace preferences
|
||||||
|
* "fact": Facts the team agreed on (e.g., processes, locations)
|
||||||
|
* "instruction": Standing instructions for the team
|
||||||
|
* "context": Current context (e.g., ongoing projects, goals)
|
||||||
|
- Returns: Confirmation of saved memory; returned context may include who added it (added_by).
|
||||||
|
- IMPORTANT: Only save information that would be genuinely useful for future team conversations in this space.
|
||||||
|
|
||||||
|
8. recall_memory: Recall relevant team memories for this space to provide contextual responses.
|
||||||
|
- Use when you need team context to answer (e.g., "where do we store X?", "what did we decide about Y?").
|
||||||
|
- Use when someone asks about something the team agreed to remember.
|
||||||
|
- Use when team preferences or conventions would improve the response.
|
||||||
|
- Args:
|
||||||
|
- query: Optional search query to find specific memories. If not provided, returns the most recent memories.
|
||||||
|
- category: Optional filter by category ("preference", "fact", "instruction", "context")
|
||||||
|
- top_k: Number of memories to retrieve (default: 5, max: 20)
|
||||||
|
- Returns: Relevant team memories and formatted context (may include added_by). Integrate naturally without saying "Based on team memory...".
|
||||||
|
</tools>
|
||||||
|
<tool_call_examples>
|
||||||
|
- User: "Remember that API keys are stored in Vault"
|
||||||
|
- Call: `save_memory(content="API keys are stored in Vault", category="fact")`
|
||||||
|
|
||||||
|
- User: "Let's remember that the team prefers weekly demos on Fridays"
|
||||||
|
- Call: `save_memory(content="The team prefers weekly demos on Fridays", category="preference")`
|
||||||
|
|
||||||
|
- User: "What did we decide about the release date?"
|
||||||
|
- First recall: `recall_memory(query="release date decision")`
|
||||||
|
- Then answer based on the team memories
|
||||||
|
|
||||||
|
- User: "Where do we document onboarding?"
|
||||||
|
- Call: `recall_memory(query="onboarding documentation")`
|
||||||
|
- Then answer using the recalled team context
|
||||||
|
|
||||||
|
- User: "What have we agreed to remember about our deployment process?"
|
||||||
|
- Call: `recall_memory(query="deployment process", top_k=10)`
|
||||||
|
- Then summarize the relevant team memories
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Examples shared by both private and shared prompts (knowledge base, docs, podcast, links, images, etc.)
|
||||||
|
_TOOLS_INSTRUCTIONS_EXAMPLES_COMMON = """
|
||||||
- User: "What time is the team meeting today?"
|
- User: "What time is the team meeting today?"
|
||||||
- Call: `search_knowledge_base(query="team meeting time today")` (searches ALL sources - calendar, notes, Obsidian, etc.)
|
- Call: `search_knowledge_base(query="team meeting time today")` (searches ALL sources - calendar, notes, Obsidian, etc.)
|
||||||
- DO NOT limit to just calendar - the info might be in notes!
|
- DO NOT limit to just calendar - the info might be in notes!
|
||||||
|
|
@ -209,23 +311,6 @@ You have access to the following tools:
|
||||||
- User: "What's in my Obsidian vault about project ideas?"
|
- User: "What's in my Obsidian vault about project ideas?"
|
||||||
- Call: `search_knowledge_base(query="project ideas", connectors_to_search=["OBSIDIAN_CONNECTOR"])`
|
- Call: `search_knowledge_base(query="project ideas", connectors_to_search=["OBSIDIAN_CONNECTOR"])`
|
||||||
|
|
||||||
- User: "Remember that I prefer TypeScript over JavaScript"
|
|
||||||
- Call: `save_memory(content="User prefers TypeScript over JavaScript for development", category="preference")`
|
|
||||||
|
|
||||||
- User: "I'm a data scientist working on ML pipelines"
|
|
||||||
- Call: `save_memory(content="User is a data scientist working on ML pipelines", category="fact")`
|
|
||||||
|
|
||||||
- User: "Always give me code examples in Python"
|
|
||||||
- Call: `save_memory(content="User wants code examples to be written in Python", category="instruction")`
|
|
||||||
|
|
||||||
- User: "What programming language should I use for this project?"
|
|
||||||
- First recall: `recall_memory(query="programming language preferences")`
|
|
||||||
- Then provide a personalized recommendation based on their preferences
|
|
||||||
|
|
||||||
- User: "What do you know about me?"
|
|
||||||
- Call: `recall_memory(top_k=10)`
|
|
||||||
- Then summarize the stored memories
|
|
||||||
|
|
||||||
- User: "Give me a podcast about AI trends based on what we discussed"
|
- User: "Give me a podcast about AI trends based on what we discussed"
|
||||||
- First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
|
- First search for relevant content, then call: `generate_podcast(source_content="Based on our conversation and search results: [detailed summary of chat + search findings]", podcast_title="AI Trends Podcast")`
|
||||||
|
|
||||||
|
|
@ -315,6 +400,31 @@ You have access to the following tools:
|
||||||
</tool_call_examples>
|
</tool_call_examples>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Reassemble so existing callers see no change (same full prompt)
|
||||||
|
SURFSENSE_TOOLS_INSTRUCTIONS = (
|
||||||
|
_TOOLS_INSTRUCTIONS_COMMON
|
||||||
|
+ _TOOLS_INSTRUCTIONS_MEMORY_PRIVATE
|
||||||
|
+ _TOOLS_INSTRUCTIONS_EXAMPLES_COMMON
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tools_instructions(thread_visibility: ChatVisibility | None = None) -> str:
|
||||||
|
"""Build tools instructions based on thread visibility (private vs shared).
|
||||||
|
|
||||||
|
For private chats: use user-focused memory wording and examples.
|
||||||
|
For shared chats: use team memory wording and examples.
|
||||||
|
"""
|
||||||
|
visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||||
|
memory_block = (
|
||||||
|
_TOOLS_INSTRUCTIONS_MEMORY_SHARED
|
||||||
|
if visibility == ChatVisibility.SEARCH_SPACE
|
||||||
|
else _TOOLS_INSTRUCTIONS_MEMORY_PRIVATE
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
_TOOLS_INSTRUCTIONS_COMMON + memory_block + _TOOLS_INSTRUCTIONS_EXAMPLES_COMMON
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
SURFSENSE_CITATION_INSTRUCTIONS = """
|
SURFSENSE_CITATION_INSTRUCTIONS = """
|
||||||
<citation_instructions>
|
<citation_instructions>
|
||||||
CRITICAL CITATION REQUIREMENTS:
|
CRITICAL CITATION REQUIREMENTS:
|
||||||
|
|
@ -413,6 +523,7 @@ Your goal is to provide helpful, informative answers in a clean, readable format
|
||||||
|
|
||||||
def build_surfsense_system_prompt(
|
def build_surfsense_system_prompt(
|
||||||
today: datetime | None = None,
|
today: datetime | None = None,
|
||||||
|
thread_visibility: ChatVisibility | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Build the SurfSense system prompt with default settings.
|
Build the SurfSense system prompt with default settings.
|
||||||
|
|
@ -424,17 +535,17 @@ def build_surfsense_system_prompt(
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
today: Optional datetime for today's date (defaults to current UTC date)
|
today: Optional datetime for today's date (defaults to current UTC date)
|
||||||
|
thread_visibility: Optional; when provided, used for conditional prompt (e.g. private vs shared memory wording). Defaults to private behavior when None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete system prompt string
|
Complete system prompt string
|
||||||
"""
|
"""
|
||||||
resolved_today = (today or datetime.now(UTC)).astimezone(UTC).date().isoformat()
|
|
||||||
|
|
||||||
return (
|
visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||||
SURFSENSE_SYSTEM_INSTRUCTIONS.format(resolved_today=resolved_today)
|
system_instructions = _get_system_instructions(visibility, today)
|
||||||
+ SURFSENSE_TOOLS_INSTRUCTIONS
|
tools_instructions = _get_tools_instructions(visibility)
|
||||||
+ SURFSENSE_CITATION_INSTRUCTIONS
|
citation_instructions = SURFSENSE_CITATION_INSTRUCTIONS
|
||||||
)
|
return system_instructions + tools_instructions + citation_instructions
|
||||||
|
|
||||||
|
|
||||||
def build_configurable_system_prompt(
|
def build_configurable_system_prompt(
|
||||||
|
|
@ -442,6 +553,7 @@ def build_configurable_system_prompt(
|
||||||
use_default_system_instructions: bool = True,
|
use_default_system_instructions: bool = True,
|
||||||
citations_enabled: bool = True,
|
citations_enabled: bool = True,
|
||||||
today: datetime | None = None,
|
today: datetime | None = None,
|
||||||
|
thread_visibility: ChatVisibility | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Build a configurable SurfSense system prompt based on NewLLMConfig settings.
|
Build a configurable SurfSense system prompt based on NewLLMConfig settings.
|
||||||
|
|
@ -460,6 +572,7 @@ def build_configurable_system_prompt(
|
||||||
citations_enabled: Whether to include citation instructions (True) or
|
citations_enabled: Whether to include citation instructions (True) or
|
||||||
anti-citation instructions (False).
|
anti-citation instructions (False).
|
||||||
today: Optional datetime for today's date (defaults to current UTC date)
|
today: Optional datetime for today's date (defaults to current UTC date)
|
||||||
|
thread_visibility: Optional; when provided, used for conditional prompt (e.g. private vs shared memory wording). Defaults to private behavior when None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Complete system prompt string
|
Complete system prompt string
|
||||||
|
|
@ -473,16 +586,14 @@ def build_configurable_system_prompt(
|
||||||
resolved_today=resolved_today
|
resolved_today=resolved_today
|
||||||
)
|
)
|
||||||
elif use_default_system_instructions:
|
elif use_default_system_instructions:
|
||||||
# Use default instructions
|
visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||||
system_instructions = SURFSENSE_SYSTEM_INSTRUCTIONS.format(
|
system_instructions = _get_system_instructions(visibility, today)
|
||||||
resolved_today=resolved_today
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# No system instructions (edge case)
|
# No system instructions (edge case)
|
||||||
system_instructions = ""
|
system_instructions = ""
|
||||||
|
|
||||||
# Tools instructions are always included
|
# Tools instructions: conditional on thread_visibility (private vs shared memory wording)
|
||||||
tools_instructions = SURFSENSE_TOOLS_INSTRUCTIONS
|
tools_instructions = _get_tools_instructions(thread_visibility)
|
||||||
|
|
||||||
# Citation instructions based on toggle
|
# Citation instructions based on toggle
|
||||||
citation_instructions = (
|
citation_instructions = (
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ This module provides:
|
||||||
- Tool factory for creating search_knowledge_base tools
|
- Tool factory for creating search_knowledge_base tools
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -16,6 +17,7 @@ from langchain_core.tools import StructuredTool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.db import async_session_maker
|
||||||
from app.services.connector_service import ConnectorService
|
from app.services.connector_service import ConnectorService
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -333,7 +335,7 @@ async def search_knowledge_base_async(
|
||||||
Returns:
|
Returns:
|
||||||
Formatted string with search results
|
Formatted string with search results
|
||||||
"""
|
"""
|
||||||
all_documents = []
|
all_documents: list[dict[str, Any]] = []
|
||||||
|
|
||||||
# Resolve date range (default last 2 years)
|
# Resolve date range (default last 2 years)
|
||||||
from app.agents.new_chat.utils import resolve_date_range
|
from app.agents.new_chat.utils import resolve_date_range
|
||||||
|
|
@ -345,323 +347,131 @@ async def search_knowledge_base_async(
|
||||||
|
|
||||||
connectors = _normalize_connectors(connectors_to_search, available_connectors)
|
connectors = _normalize_connectors(connectors_to_search, available_connectors)
|
||||||
|
|
||||||
for connector in connectors:
|
connector_specs: dict[str, tuple[str, bool, bool, dict[str, Any]]] = {
|
||||||
|
"YOUTUBE_VIDEO": ("search_youtube", True, True, {}),
|
||||||
|
"EXTENSION": ("search_extension", True, True, {}),
|
||||||
|
"CRAWLED_URL": ("search_crawled_urls", True, True, {}),
|
||||||
|
"FILE": ("search_files", True, True, {}),
|
||||||
|
"SLACK_CONNECTOR": ("search_slack", True, True, {}),
|
||||||
|
"TEAMS_CONNECTOR": ("search_teams", True, True, {}),
|
||||||
|
"NOTION_CONNECTOR": ("search_notion", True, True, {}),
|
||||||
|
"GITHUB_CONNECTOR": ("search_github", True, True, {}),
|
||||||
|
"LINEAR_CONNECTOR": ("search_linear", True, True, {}),
|
||||||
|
"TAVILY_API": ("search_tavily", False, True, {}),
|
||||||
|
"SEARXNG_API": ("search_searxng", False, True, {}),
|
||||||
|
"LINKUP_API": ("search_linkup", False, False, {"mode": "standard"}),
|
||||||
|
"BAIDU_SEARCH_API": ("search_baidu", False, True, {}),
|
||||||
|
"DISCORD_CONNECTOR": ("search_discord", True, True, {}),
|
||||||
|
"JIRA_CONNECTOR": ("search_jira", True, True, {}),
|
||||||
|
"GOOGLE_CALENDAR_CONNECTOR": ("search_google_calendar", True, True, {}),
|
||||||
|
"AIRTABLE_CONNECTOR": ("search_airtable", True, True, {}),
|
||||||
|
"GOOGLE_GMAIL_CONNECTOR": ("search_google_gmail", True, True, {}),
|
||||||
|
"GOOGLE_DRIVE_FILE": ("search_google_drive", True, True, {}),
|
||||||
|
"CONFLUENCE_CONNECTOR": ("search_confluence", True, True, {}),
|
||||||
|
"CLICKUP_CONNECTOR": ("search_clickup", True, True, {}),
|
||||||
|
"LUMA_CONNECTOR": ("search_luma", True, True, {}),
|
||||||
|
"ELASTICSEARCH_CONNECTOR": ("search_elasticsearch", True, True, {}),
|
||||||
|
"NOTE": ("search_notes", True, True, {}),
|
||||||
|
"BOOKSTACK_CONNECTOR": ("search_bookstack", True, True, {}),
|
||||||
|
"CIRCLEBACK": ("search_circleback", True, True, {}),
|
||||||
|
"OBSIDIAN_CONNECTOR": ("search_obsidian", True, True, {}),
|
||||||
|
# Composio connectors
|
||||||
|
"COMPOSIO_GOOGLE_DRIVE_CONNECTOR": (
|
||||||
|
"search_composio_google_drive",
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
"COMPOSIO_GMAIL_CONNECTOR": ("search_composio_gmail", True, True, {}),
|
||||||
|
"COMPOSIO_GOOGLE_CALENDAR_CONNECTOR": (
|
||||||
|
"search_composio_google_calendar",
|
||||||
|
True,
|
||||||
|
True,
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Keep a conservative cap to avoid overloading DB/external services.
|
||||||
|
max_parallel_searches = 4
|
||||||
|
semaphore = asyncio.Semaphore(max_parallel_searches)
|
||||||
|
|
||||||
|
async def _search_one_connector(connector: str) -> list[dict[str, Any]]:
|
||||||
|
spec = connector_specs.get(connector)
|
||||||
|
if spec is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
method_name, includes_date_range, includes_top_k, extra_kwargs = spec
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"user_query": query,
|
||||||
|
"search_space_id": search_space_id,
|
||||||
|
**extra_kwargs,
|
||||||
|
}
|
||||||
|
if includes_top_k:
|
||||||
|
kwargs["top_k"] = top_k
|
||||||
|
if includes_date_range:
|
||||||
|
kwargs["start_date"] = resolved_start_date
|
||||||
|
kwargs["end_date"] = resolved_end_date
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if connector == "YOUTUBE_VIDEO":
|
# Use isolated session per connector. Shared AsyncSession cannot safely
|
||||||
_, chunks = await connector_service.search_youtube(
|
# run concurrent DB operations.
|
||||||
user_query=query,
|
async with semaphore, async_session_maker() as isolated_session:
|
||||||
search_space_id=search_space_id,
|
isolated_connector_service = ConnectorService(
|
||||||
top_k=top_k,
|
isolated_session, search_space_id
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
)
|
||||||
all_documents.extend(chunks)
|
connector_method = getattr(isolated_connector_service, method_name)
|
||||||
|
_, chunks = await connector_method(**kwargs)
|
||||||
elif connector == "EXTENSION":
|
return chunks
|
||||||
_, chunks = await connector_service.search_extension(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "CRAWLED_URL":
|
|
||||||
_, chunks = await connector_service.search_crawled_urls(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "FILE":
|
|
||||||
_, chunks = await connector_service.search_files(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "SLACK_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_slack(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "TEAMS_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_teams(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "NOTION_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_notion(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "GITHUB_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_github(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "LINEAR_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_linear(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "TAVILY_API":
|
|
||||||
_, chunks = await connector_service.search_tavily(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "SEARXNG_API":
|
|
||||||
_, chunks = await connector_service.search_searxng(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "LINKUP_API":
|
|
||||||
# Keep behavior aligned with researcher: default "standard"
|
|
||||||
_, chunks = await connector_service.search_linkup(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
mode="standard",
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "BAIDU_SEARCH_API":
|
|
||||||
_, chunks = await connector_service.search_baidu(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "DISCORD_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_discord(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "JIRA_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_jira(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "GOOGLE_CALENDAR_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_google_calendar(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "AIRTABLE_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_airtable(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "GOOGLE_GMAIL_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_google_gmail(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "GOOGLE_DRIVE_FILE":
|
|
||||||
_, chunks = await connector_service.search_google_drive(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "CONFLUENCE_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_confluence(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "CLICKUP_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_clickup(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "LUMA_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_luma(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "ELASTICSEARCH_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_elasticsearch(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "NOTE":
|
|
||||||
_, chunks = await connector_service.search_notes(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "BOOKSTACK_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_bookstack(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "CIRCLEBACK":
|
|
||||||
_, chunks = await connector_service.search_circleback(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "OBSIDIAN_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_obsidian(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
# =========================================================
|
|
||||||
# Composio Connectors
|
|
||||||
# =========================================================
|
|
||||||
elif connector == "COMPOSIO_GOOGLE_DRIVE_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_composio_google_drive(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "COMPOSIO_GMAIL_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_composio_gmail(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
elif connector == "COMPOSIO_GOOGLE_CALENDAR_CONNECTOR":
|
|
||||||
_, chunks = await connector_service.search_composio_google_calendar(
|
|
||||||
user_query=query,
|
|
||||||
search_space_id=search_space_id,
|
|
||||||
top_k=top_k,
|
|
||||||
start_date=resolved_start_date,
|
|
||||||
end_date=resolved_end_date,
|
|
||||||
)
|
|
||||||
all_documents.extend(chunks)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error searching connector {connector}: {e}")
|
print(f"Error searching connector {connector}: {e}")
|
||||||
continue
|
return []
|
||||||
|
|
||||||
# Deduplicate by content hash
|
connector_results = await asyncio.gather(
|
||||||
|
*[_search_one_connector(connector) for connector in connectors]
|
||||||
|
)
|
||||||
|
for chunks in connector_results:
|
||||||
|
all_documents.extend(chunks)
|
||||||
|
|
||||||
|
# Deduplicate primarily by document ID. Only fall back to content hashing
|
||||||
|
# when a document has no ID.
|
||||||
seen_doc_ids: set[Any] = set()
|
seen_doc_ids: set[Any] = set()
|
||||||
seen_hashes: set[int] = set()
|
seen_content_hashes: set[int] = set()
|
||||||
deduplicated: list[dict[str, Any]] = []
|
deduplicated: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _content_fingerprint(document: dict[str, Any]) -> int | None:
|
||||||
|
chunks = document.get("chunks")
|
||||||
|
if isinstance(chunks, list):
|
||||||
|
chunk_texts = []
|
||||||
|
for chunk in chunks:
|
||||||
|
if not isinstance(chunk, dict):
|
||||||
|
continue
|
||||||
|
chunk_content = (chunk.get("content") or "").strip()
|
||||||
|
if chunk_content:
|
||||||
|
chunk_texts.append(chunk_content)
|
||||||
|
if chunk_texts:
|
||||||
|
return hash("||".join(chunk_texts))
|
||||||
|
|
||||||
|
flat_content = (document.get("content") or "").strip()
|
||||||
|
if flat_content:
|
||||||
|
return hash(flat_content)
|
||||||
|
return None
|
||||||
|
|
||||||
for doc in all_documents:
|
for doc in all_documents:
|
||||||
doc_id = (doc.get("document", {}) or {}).get("id")
|
doc_id = (doc.get("document", {}) or {}).get("id")
|
||||||
content = (doc.get("content", "") or "").strip()
|
|
||||||
content_hash = hash(content)
|
|
||||||
|
|
||||||
if (doc_id and doc_id in seen_doc_ids) or content_hash in seen_hashes:
|
if doc_id is not None:
|
||||||
|
if doc_id in seen_doc_ids:
|
||||||
|
continue
|
||||||
|
seen_doc_ids.add(doc_id)
|
||||||
|
deduplicated.append(doc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if doc_id:
|
content_hash = _content_fingerprint(doc)
|
||||||
seen_doc_ids.add(doc_id)
|
if content_hash is not None:
|
||||||
seen_hashes.add(content_hash)
|
if content_hash in seen_content_hashes:
|
||||||
|
continue
|
||||||
|
seen_content_hashes.add(content_hash)
|
||||||
|
|
||||||
deduplicated.append(doc)
|
deduplicated.append(doc)
|
||||||
|
|
||||||
return format_documents_for_context(deduplicated)
|
return format_documents_for_context(deduplicated)
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,18 @@ Duplicate request prevention:
|
||||||
- Returns a friendly message if a podcast is already being generated
|
- Returns a friendly message if a podcast is already being generated
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from langchain_core.tools import tool
|
from langchain_core.tools import tool
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
from app.db import Podcast, PodcastStatus
|
from app.db import Podcast, PodcastStatus
|
||||||
|
|
||||||
# Redis connection for tracking active podcast tasks
|
# Redis connection for tracking active podcast tasks
|
||||||
# Defaults to the Celery broker when REDIS_APP_URL is not set
|
# Defaults to the Celery broker when REDIS_APP_URL is not set
|
||||||
REDIS_URL = os.getenv(
|
REDIS_URL = config.REDIS_APP_URL
|
||||||
"REDIS_APP_URL",
|
|
||||||
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
|
||||||
)
|
|
||||||
_redis_client: redis.Redis | None = None
|
_redis_client: redis.Redis | None = None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ from typing import Any
|
||||||
|
|
||||||
from langchain_core.tools import BaseTool
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
|
from app.db import ChatVisibility
|
||||||
|
|
||||||
from .display_image import create_display_image_tool
|
from .display_image import create_display_image_tool
|
||||||
from .generate_image import create_generate_image_tool
|
from .generate_image import create_generate_image_tool
|
||||||
from .knowledge_base import create_search_knowledge_base_tool
|
from .knowledge_base import create_search_knowledge_base_tool
|
||||||
|
|
@ -51,6 +53,10 @@ from .mcp_tool import load_mcp_tools
|
||||||
from .podcast import create_generate_podcast_tool
|
from .podcast import create_generate_podcast_tool
|
||||||
from .scrape_webpage import create_scrape_webpage_tool
|
from .scrape_webpage import create_scrape_webpage_tool
|
||||||
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
||||||
|
from .shared_memory import (
|
||||||
|
create_recall_shared_memory_tool,
|
||||||
|
create_save_shared_memory_tool,
|
||||||
|
)
|
||||||
from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
from .user_memory import create_recall_memory_tool, create_save_memory_tool
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -156,29 +162,42 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
||||||
requires=["db_session"],
|
requires=["db_session"],
|
||||||
),
|
),
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# USER MEMORY TOOLS - Claude-like memory feature
|
# USER MEMORY TOOLS - private or team store by thread_visibility
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Save memory tool - stores facts/preferences about the user
|
|
||||||
ToolDefinition(
|
ToolDefinition(
|
||||||
name="save_memory",
|
name="save_memory",
|
||||||
description="Save facts, preferences, or context about the user for personalized responses",
|
description="Save facts, preferences, or context for personalized or team responses",
|
||||||
factory=lambda deps: create_save_memory_tool(
|
factory=lambda deps: (
|
||||||
|
create_save_shared_memory_tool(
|
||||||
|
search_space_id=deps["search_space_id"],
|
||||||
|
created_by_id=deps["user_id"],
|
||||||
|
db_session=deps["db_session"],
|
||||||
|
)
|
||||||
|
if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE
|
||||||
|
else create_save_memory_tool(
|
||||||
user_id=deps["user_id"],
|
user_id=deps["user_id"],
|
||||||
search_space_id=deps["search_space_id"],
|
search_space_id=deps["search_space_id"],
|
||||||
db_session=deps["db_session"],
|
db_session=deps["db_session"],
|
||||||
|
)
|
||||||
),
|
),
|
||||||
requires=["user_id", "search_space_id", "db_session"],
|
requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
|
||||||
),
|
),
|
||||||
# Recall memory tool - retrieves relevant user memories
|
|
||||||
ToolDefinition(
|
ToolDefinition(
|
||||||
name="recall_memory",
|
name="recall_memory",
|
||||||
description="Recall user memories for personalized and contextual responses",
|
description="Recall relevant memories (personal or team) for context",
|
||||||
factory=lambda deps: create_recall_memory_tool(
|
factory=lambda deps: (
|
||||||
|
create_recall_shared_memory_tool(
|
||||||
|
search_space_id=deps["search_space_id"],
|
||||||
|
db_session=deps["db_session"],
|
||||||
|
)
|
||||||
|
if deps["thread_visibility"] == ChatVisibility.SEARCH_SPACE
|
||||||
|
else create_recall_memory_tool(
|
||||||
user_id=deps["user_id"],
|
user_id=deps["user_id"],
|
||||||
search_space_id=deps["search_space_id"],
|
search_space_id=deps["search_space_id"],
|
||||||
db_session=deps["db_session"],
|
db_session=deps["db_session"],
|
||||||
|
)
|
||||||
),
|
),
|
||||||
requires=["user_id", "search_space_id", "db_session"],
|
requires=["user_id", "search_space_id", "db_session", "thread_visibility"],
|
||||||
),
|
),
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# ADD YOUR CUSTOM TOOLS BELOW
|
# ADD YOUR CUSTOM TOOLS BELOW
|
||||||
|
|
|
||||||
280
surfsense_backend/app/agents/new_chat/tools/shared_memory.py
Normal file
280
surfsense_backend/app/agents/new_chat/tools/shared_memory.py
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
"""Shared (team) memory backend for search-space-scoped AI context."""
|
||||||
|
|
||||||
|
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, SharedMemory, User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_RECALL_TOP_K = 5
|
||||||
|
MAX_MEMORIES_PER_SEARCH_SPACE = 250
|
||||||
|
|
||||||
|
|
||||||
|
async def get_shared_memory_count(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> int:
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SharedMemory).where(SharedMemory.search_space_id == search_space_id)
|
||||||
|
)
|
||||||
|
return len(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_oldest_shared_memory(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
) -> None:
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SharedMemory)
|
||||||
|
.where(SharedMemory.search_space_id == search_space_id)
|
||||||
|
.order_by(SharedMemory.updated_at.asc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
oldest = result.scalars().first()
|
||||||
|
if oldest:
|
||||||
|
await db_session.delete(oldest)
|
||||||
|
await db_session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_uuid(value: str | UUID) -> UUID:
|
||||||
|
if isinstance(value, UUID):
|
||||||
|
return value
|
||||||
|
return UUID(value)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_shared_memory(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
created_by_id: str | UUID,
|
||||||
|
content: str,
|
||||||
|
category: str = "fact",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
category = category.lower() if category else "fact"
|
||||||
|
valid = ["preference", "fact", "instruction", "context"]
|
||||||
|
if category not in valid:
|
||||||
|
category = "fact"
|
||||||
|
try:
|
||||||
|
count = await get_shared_memory_count(db_session, search_space_id)
|
||||||
|
if count >= MAX_MEMORIES_PER_SEARCH_SPACE:
|
||||||
|
await delete_oldest_shared_memory(db_session, search_space_id)
|
||||||
|
embedding = config.embedding_model_instance.embed(content)
|
||||||
|
row = SharedMemory(
|
||||||
|
search_space_id=search_space_id,
|
||||||
|
created_by_id=_to_uuid(created_by_id),
|
||||||
|
memory_text=content,
|
||||||
|
category=MemoryCategory(category),
|
||||||
|
embedding=embedding,
|
||||||
|
)
|
||||||
|
db_session.add(row)
|
||||||
|
await db_session.commit()
|
||||||
|
await db_session.refresh(row)
|
||||||
|
return {
|
||||||
|
"status": "saved",
|
||||||
|
"memory_id": row.id,
|
||||||
|
"memory_text": content,
|
||||||
|
"category": category,
|
||||||
|
"message": f"I'll remember: {content}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to save shared memory: %s", e)
|
||||||
|
await db_session.rollback()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"message": "Failed to save memory. Please try again.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def recall_shared_memory(
|
||||||
|
db_session: AsyncSession,
|
||||||
|
search_space_id: int,
|
||||||
|
query: str | None = None,
|
||||||
|
category: str | None = None,
|
||||||
|
top_k: int = DEFAULT_RECALL_TOP_K,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
top_k = min(max(top_k, 1), 20)
|
||||||
|
try:
|
||||||
|
valid_categories = ["preference", "fact", "instruction", "context"]
|
||||||
|
stmt = select(SharedMemory).where(
|
||||||
|
SharedMemory.search_space_id == search_space_id
|
||||||
|
)
|
||||||
|
if category and category in valid_categories:
|
||||||
|
stmt = stmt.where(SharedMemory.category == MemoryCategory(category))
|
||||||
|
if query:
|
||||||
|
query_embedding = config.embedding_model_instance.embed(query)
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
SharedMemory.embedding.op("<=>")(query_embedding)
|
||||||
|
).limit(top_k)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(SharedMemory.updated_at.desc()).limit(top_k)
|
||||||
|
result = await db_session.execute(stmt)
|
||||||
|
rows = result.scalars().all()
|
||||||
|
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,
|
||||||
|
"created_by_id": str(m.created_by_id) if m.created_by_id else None,
|
||||||
|
}
|
||||||
|
for m in rows
|
||||||
|
]
|
||||||
|
created_by_ids = list(
|
||||||
|
{m["created_by_id"] for m in memory_list if m["created_by_id"]}
|
||||||
|
)
|
||||||
|
created_by_map: dict[str, str] = {}
|
||||||
|
if created_by_ids:
|
||||||
|
uuids = [UUID(uid) for uid in created_by_ids]
|
||||||
|
users_result = await db_session.execute(
|
||||||
|
select(User).where(User.id.in_(uuids))
|
||||||
|
)
|
||||||
|
for u in users_result.scalars().all():
|
||||||
|
created_by_map[str(u.id)] = u.display_name or "A team member"
|
||||||
|
formatted_context = format_shared_memories_for_context(
|
||||||
|
memory_list, created_by_map
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"count": len(memory_list),
|
||||||
|
"memories": memory_list,
|
||||||
|
"formatted_context": formatted_context,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to recall shared memory: %s", e)
|
||||||
|
await db_session.rollback()
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"error": str(e),
|
||||||
|
"memories": [],
|
||||||
|
"formatted_context": "Failed to recall memories.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_shared_memories_for_context(
|
||||||
|
memories: list[dict[str, Any]],
|
||||||
|
created_by_map: dict[str, str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
if not memories:
|
||||||
|
return "No relevant team memories found."
|
||||||
|
created_by_map = created_by_map or {}
|
||||||
|
parts = ["<team_memories>"]
|
||||||
|
for memory in memories:
|
||||||
|
category = memory.get("category", "unknown")
|
||||||
|
text = memory.get("memory_text", "")
|
||||||
|
updated = memory.get("updated_at", "")
|
||||||
|
created_by_id = memory.get("created_by_id")
|
||||||
|
added_by = (
|
||||||
|
created_by_map.get(str(created_by_id), "A team member")
|
||||||
|
if created_by_id is not None
|
||||||
|
else "A team member"
|
||||||
|
)
|
||||||
|
parts.append(
|
||||||
|
f" <memory category='{category}' updated='{updated}' added_by='{added_by}'>{text}</memory>"
|
||||||
|
)
|
||||||
|
parts.append("</team_memories>")
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def create_save_shared_memory_tool(
|
||||||
|
search_space_id: int,
|
||||||
|
created_by_id: str | UUID,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the save_memory tool for shared (team) chats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_space_id: The search space ID
|
||||||
|
created_by_id: The user ID of the person adding the memory
|
||||||
|
db_session: Database session for executing queries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured tool function for saving team memories
|
||||||
|
"""
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def save_memory(
|
||||||
|
content: str,
|
||||||
|
category: str = "fact",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Save a fact, preference, or context to the team's shared memory for future reference.
|
||||||
|
|
||||||
|
Use this tool when:
|
||||||
|
- User or a team member says "remember this", "keep this in mind", or similar in this shared chat
|
||||||
|
- The team agrees on something to remember (e.g., decisions, conventions, where things live)
|
||||||
|
- Someone shares a preference or fact that should be visible to the whole team
|
||||||
|
|
||||||
|
The saved information will be available in future shared conversations in this space.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: The fact/preference/context to remember.
|
||||||
|
Phrase it clearly, e.g., "API keys are stored in Vault",
|
||||||
|
"The team prefers weekly demos on Fridays"
|
||||||
|
category: Type of memory. One of:
|
||||||
|
- "preference": Team or workspace preferences
|
||||||
|
- "fact": Facts the team agreed on (e.g., processes, locations)
|
||||||
|
- "instruction": Standing instructions for the team
|
||||||
|
- "context": Current context (e.g., ongoing projects, goals)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary with the save status and memory details
|
||||||
|
"""
|
||||||
|
return await save_shared_memory(
|
||||||
|
db_session, search_space_id, created_by_id, content, category
|
||||||
|
)
|
||||||
|
|
||||||
|
return save_memory
|
||||||
|
|
||||||
|
|
||||||
|
def create_recall_shared_memory_tool(
|
||||||
|
search_space_id: int,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Factory function to create the recall_memory tool for shared (team) chats.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_space_id: The search space ID
|
||||||
|
db_session: Database session for executing queries
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A configured tool function for recalling team 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 team memories for this space to provide contextual responses.
|
||||||
|
|
||||||
|
Use this tool when:
|
||||||
|
- You need team context to answer (e.g., "where do we store X?", "what did we decide about Y?")
|
||||||
|
- Someone asks about something the team agreed to remember
|
||||||
|
- Team preferences or conventions would improve the response
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Optional search query to find specific memories.
|
||||||
|
If not provided, returns the most recent memories.
|
||||||
|
category: Optional category filter. One of:
|
||||||
|
"preference", "fact", "instruction", "context"
|
||||||
|
top_k: Number of memories to retrieve (default: 5, max: 20)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary containing relevant memories and formatted context
|
||||||
|
"""
|
||||||
|
return await recall_shared_memory(
|
||||||
|
db_session, search_space_id, query, category, top_k
|
||||||
|
)
|
||||||
|
|
||||||
|
return recall_memory
|
||||||
|
|
@ -213,6 +213,17 @@ class Config:
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL")
|
DATABASE_URL = os.getenv("DATABASE_URL")
|
||||||
|
|
||||||
|
# Celery / Redis
|
||||||
|
CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0")
|
||||||
|
CELERY_RESULT_BACKEND = os.getenv(
|
||||||
|
"CELERY_RESULT_BACKEND", "redis://localhost:6379/0"
|
||||||
|
)
|
||||||
|
CELERY_TASK_DEFAULT_QUEUE = os.getenv("CELERY_TASK_DEFAULT_QUEUE", "surfsense")
|
||||||
|
REDIS_APP_URL = os.getenv("REDIS_APP_URL", CELERY_BROKER_URL)
|
||||||
|
CONNECTOR_INDEXING_LOCK_TTL_SECONDS = int(
|
||||||
|
os.getenv("CONNECTOR_INDEXING_LOCK_TTL_SECONDS", str(8 * 60 * 60))
|
||||||
|
)
|
||||||
|
|
||||||
NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL")
|
NEXT_FRONTEND_URL = os.getenv("NEXT_FRONTEND_URL")
|
||||||
# Backend URL to override the http to https in the OAuth redirect URI
|
# Backend URL to override the http to https in the OAuth redirect URI
|
||||||
BACKEND_URL = os.getenv("BACKEND_URL")
|
BACKEND_URL = os.getenv("BACKEND_URL")
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ T = TypeVar("T")
|
||||||
MAX_RETRIES = 5
|
MAX_RETRIES = 5
|
||||||
BASE_RETRY_DELAY = 1.0 # seconds
|
BASE_RETRY_DELAY = 1.0 # seconds
|
||||||
MAX_RETRY_DELAY = 60.0 # seconds (Notion's max request timeout)
|
MAX_RETRY_DELAY = 60.0 # seconds (Notion's max request timeout)
|
||||||
|
MAX_RATE_LIMIT_WAIT_SECONDS = float(
|
||||||
|
getattr(config, "NOTION_MAX_RETRY_AFTER_SECONDS", 30.0)
|
||||||
|
)
|
||||||
|
MAX_TOTAL_RETRY_WAIT_SECONDS = float(
|
||||||
|
getattr(config, "NOTION_MAX_TOTAL_RETRY_WAIT_SECONDS", 120.0)
|
||||||
|
)
|
||||||
|
|
||||||
# Type alias for retry callback function
|
# Type alias for retry callback function
|
||||||
# Signature: async callback(retry_reason, attempt, max_attempts, wait_seconds) -> None
|
# Signature: async callback(retry_reason, attempt, max_attempts, wait_seconds) -> None
|
||||||
|
|
@ -292,6 +298,7 @@ class NotionHistoryConnector:
|
||||||
"""
|
"""
|
||||||
last_exception: APIResponseError | None = None
|
last_exception: APIResponseError | None = None
|
||||||
retry_delay = BASE_RETRY_DELAY
|
retry_delay = BASE_RETRY_DELAY
|
||||||
|
total_wait_time = 0.0
|
||||||
|
|
||||||
for attempt in range(MAX_RETRIES):
|
for attempt in range(MAX_RETRIES):
|
||||||
try:
|
try:
|
||||||
|
|
@ -325,6 +332,15 @@ class NotionHistoryConnector:
|
||||||
wait_time = retry_delay
|
wait_time = retry_delay
|
||||||
else:
|
else:
|
||||||
wait_time = retry_delay
|
wait_time = retry_delay
|
||||||
|
|
||||||
|
# Avoid very long worker sleeps from external Retry-After values.
|
||||||
|
if wait_time > MAX_RATE_LIMIT_WAIT_SECONDS:
|
||||||
|
logger.warning(
|
||||||
|
f"Notion Retry-After ({wait_time}s) exceeds cap "
|
||||||
|
f"({MAX_RATE_LIMIT_WAIT_SECONDS}s). Clamping wait time."
|
||||||
|
)
|
||||||
|
wait_time = MAX_RATE_LIMIT_WAIT_SECONDS
|
||||||
|
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Notion API rate limited (429). "
|
f"Notion API rate limited (429). "
|
||||||
f"Waiting {wait_time}s. Attempt {attempt + 1}/{MAX_RETRIES}"
|
f"Waiting {wait_time}s. Attempt {attempt + 1}/{MAX_RETRIES}"
|
||||||
|
|
@ -348,6 +364,14 @@ class NotionHistoryConnector:
|
||||||
|
|
||||||
# Notify about retry via callback (for user notifications)
|
# Notify about retry via callback (for user notifications)
|
||||||
# Call before sleeping so user sees the message while we wait
|
# Call before sleeping so user sees the message while we wait
|
||||||
|
if total_wait_time + wait_time > MAX_TOTAL_RETRY_WAIT_SECONDS:
|
||||||
|
logger.error(
|
||||||
|
"Notion API retry budget exceeded "
|
||||||
|
f"({total_wait_time + wait_time:.1f}s > "
|
||||||
|
f"{MAX_TOTAL_RETRY_WAIT_SECONDS:.1f}s). Failing fast."
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
if on_retry:
|
if on_retry:
|
||||||
try:
|
try:
|
||||||
await on_retry(
|
await on_retry(
|
||||||
|
|
@ -362,6 +386,7 @@ class NotionHistoryConnector:
|
||||||
|
|
||||||
# Wait before retrying
|
# Wait before retrying
|
||||||
await asyncio.sleep(wait_time)
|
await asyncio.sleep(wait_time)
|
||||||
|
total_wait_time += wait_time
|
||||||
|
|
||||||
# Exponential backoff for next attempt
|
# Exponential backoff for next attempt
|
||||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,7 @@ class LiteLLMProvider(str, Enum):
|
||||||
DATABRICKS = "DATABRICKS"
|
DATABRICKS = "DATABRICKS"
|
||||||
COMETAPI = "COMETAPI"
|
COMETAPI = "COMETAPI"
|
||||||
HUGGINGFACE = "HUGGINGFACE"
|
HUGGINGFACE = "HUGGINGFACE"
|
||||||
|
GITHUB_MODELS = "GITHUB_MODELS"
|
||||||
CUSTOM = "CUSTOM"
|
CUSTOM = "CUSTOM"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -272,19 +273,19 @@ INCENTIVE_TASKS_CONFIG = {
|
||||||
IncentiveTaskType.GITHUB_STAR: {
|
IncentiveTaskType.GITHUB_STAR: {
|
||||||
"title": "Star our GitHub repository",
|
"title": "Star our GitHub repository",
|
||||||
"description": "Show your support by starring SurfSense on GitHub",
|
"description": "Show your support by starring SurfSense on GitHub",
|
||||||
"pages_reward": 100,
|
"pages_reward": 30,
|
||||||
"action_url": "https://github.com/MODSetter/SurfSense",
|
"action_url": "https://github.com/MODSetter/SurfSense",
|
||||||
},
|
},
|
||||||
IncentiveTaskType.REDDIT_FOLLOW: {
|
IncentiveTaskType.REDDIT_FOLLOW: {
|
||||||
"title": "Join our Subreddit",
|
"title": "Join our Subreddit",
|
||||||
"description": "Join the SurfSense community on Reddit",
|
"description": "Join the SurfSense community on Reddit",
|
||||||
"pages_reward": 100,
|
"pages_reward": 30,
|
||||||
"action_url": "https://www.reddit.com/r/SurfSense/",
|
"action_url": "https://www.reddit.com/r/SurfSense/",
|
||||||
},
|
},
|
||||||
IncentiveTaskType.DISCORD_JOIN: {
|
IncentiveTaskType.DISCORD_JOIN: {
|
||||||
"title": "Join our Discord",
|
"title": "Join our Discord",
|
||||||
"description": "Join the SurfSense community on Discord",
|
"description": "Join the SurfSense community on Discord",
|
||||||
"pages_reward": 100,
|
"pages_reward": 40,
|
||||||
"action_url": "https://discord.gg/ejRNvftDp9",
|
"action_url": "https://discord.gg/ejRNvftDp9",
|
||||||
},
|
},
|
||||||
# Future tasks can be configured here:
|
# Future tasks can be configured here:
|
||||||
|
|
@ -801,9 +802,8 @@ class MemoryCategory(str, Enum):
|
||||||
|
|
||||||
class UserMemory(BaseModel, TimestampMixin):
|
class UserMemory(BaseModel, TimestampMixin):
|
||||||
"""
|
"""
|
||||||
Stores facts, preferences, and context about users for personalized AI responses.
|
Private memory: facts, preferences, context per user per search space.
|
||||||
Similar to Claude's memory feature - enables the AI to remember user information
|
Used only for private chats (not shared/team chats).
|
||||||
across conversations.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "user_memories"
|
__tablename__ = "user_memories"
|
||||||
|
|
@ -847,6 +847,40 @@ class UserMemory(BaseModel, TimestampMixin):
|
||||||
search_space = relationship("SearchSpace", back_populates="user_memories")
|
search_space = relationship("SearchSpace", back_populates="user_memories")
|
||||||
|
|
||||||
|
|
||||||
|
class SharedMemory(BaseModel, TimestampMixin):
|
||||||
|
__tablename__ = "shared_memories"
|
||||||
|
|
||||||
|
search_space_id = Column(
|
||||||
|
Integer,
|
||||||
|
ForeignKey("searchspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
created_by_id = Column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("user.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
memory_text = Column(Text, nullable=False)
|
||||||
|
category = Column(
|
||||||
|
SQLAlchemyEnum(MemoryCategory),
|
||||||
|
nullable=False,
|
||||||
|
default=MemoryCategory.fact,
|
||||||
|
)
|
||||||
|
embedding = Column(Vector(config.embedding_model_instance.dimension))
|
||||||
|
updated_at = Column(
|
||||||
|
TIMESTAMP(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
default=lambda: datetime.now(UTC),
|
||||||
|
onupdate=lambda: datetime.now(UTC),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
search_space = relationship("SearchSpace", back_populates="shared_memories")
|
||||||
|
created_by = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
class Document(BaseModel, TimestampMixin):
|
class Document(BaseModel, TimestampMixin):
|
||||||
__tablename__ = "documents"
|
__tablename__ = "documents"
|
||||||
|
|
||||||
|
|
@ -1209,6 +1243,12 @@ class SearchSpace(BaseModel, TimestampMixin):
|
||||||
order_by="UserMemory.updated_at.desc()",
|
order_by="UserMemory.updated_at.desc()",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
)
|
)
|
||||||
|
shared_memories = relationship(
|
||||||
|
"SharedMemory",
|
||||||
|
back_populates="search_space",
|
||||||
|
order_by="SharedMemory.updated_at.desc()",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SearchSourceConnector(BaseModel, TimestampMixin):
|
class SearchSourceConnector(BaseModel, TimestampMixin):
|
||||||
|
|
@ -1258,7 +1298,7 @@ class NewLLMConfig(BaseModel, TimestampMixin):
|
||||||
- Configurable system instructions (defaults to SURFSENSE_SYSTEM_INSTRUCTIONS)
|
- Configurable system instructions (defaults to SURFSENSE_SYSTEM_INSTRUCTIONS)
|
||||||
- Citation toggle (enable/disable citation instructions)
|
- Citation toggle (enable/disable citation instructions)
|
||||||
|
|
||||||
Note: SURFSENSE_TOOLS_INSTRUCTIONS is always used and not configurable.
|
Note: Tools instructions are built by get_tools_instructions(thread_visibility) (personal vs shared memory).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "new_llm_configs"
|
__tablename__ = "new_llm_configs"
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ from app.db import (
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
DocumentRead,
|
DocumentRead,
|
||||||
DocumentsCreate,
|
DocumentsCreate,
|
||||||
|
DocumentStatusBatchResponse,
|
||||||
|
DocumentStatusItemRead,
|
||||||
DocumentStatusSchema,
|
DocumentStatusSchema,
|
||||||
DocumentTitleRead,
|
DocumentTitleRead,
|
||||||
DocumentTitleSearchResponse,
|
DocumentTitleSearchResponse,
|
||||||
|
|
@ -148,6 +150,7 @@ async def create_documents_file_upload(
|
||||||
tuple[Document, str, str]
|
tuple[Document, str, str]
|
||||||
] = [] # (document, temp_path, filename)
|
] = [] # (document, temp_path, filename)
|
||||||
skipped_duplicates = 0
|
skipped_duplicates = 0
|
||||||
|
duplicate_document_ids: list[int] = []
|
||||||
|
|
||||||
# ===== PHASE 1: Create pending documents for all files =====
|
# ===== PHASE 1: Create pending documents for all files =====
|
||||||
# This makes ALL documents visible in the UI immediately with pending status
|
# This makes ALL documents visible in the UI immediately with pending status
|
||||||
|
|
@ -182,6 +185,7 @@ async def create_documents_file_upload(
|
||||||
# True duplicate — content already indexed, skip
|
# True duplicate — content already indexed, skip
|
||||||
os.unlink(temp_path)
|
os.unlink(temp_path)
|
||||||
skipped_duplicates += 1
|
skipped_duplicates += 1
|
||||||
|
duplicate_document_ids.append(existing.id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Existing document is stuck (failed/pending/processing)
|
# Existing document is stuck (failed/pending/processing)
|
||||||
|
|
@ -255,6 +259,7 @@ async def create_documents_file_upload(
|
||||||
return {
|
return {
|
||||||
"message": "Files uploaded for processing",
|
"message": "Files uploaded for processing",
|
||||||
"document_ids": [doc.id for doc in created_documents],
|
"document_ids": [doc.id for doc in created_documents],
|
||||||
|
"duplicate_document_ids": duplicate_document_ids,
|
||||||
"total_files": len(files),
|
"total_files": len(files),
|
||||||
"pending_files": len(files_to_process),
|
"pending_files": len(files_to_process),
|
||||||
"skipped_duplicates": skipped_duplicates,
|
"skipped_duplicates": skipped_duplicates,
|
||||||
|
|
@ -678,6 +683,74 @@ async def search_document_titles(
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/documents/status", response_model=DocumentStatusBatchResponse)
|
||||||
|
async def get_documents_status(
|
||||||
|
search_space_id: int,
|
||||||
|
document_ids: str,
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
|
user: User = Depends(current_active_user),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Batch status endpoint for documents in a search space.
|
||||||
|
|
||||||
|
Returns lightweight status info for the provided document IDs, intended for
|
||||||
|
polling async ETL progress in chat upload flows.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await check_permission(
|
||||||
|
session,
|
||||||
|
user,
|
||||||
|
search_space_id,
|
||||||
|
Permission.DOCUMENTS_READ.value,
|
||||||
|
"You don't have permission to read documents in this search space",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse comma-separated IDs (e.g. "1,2,3")
|
||||||
|
parsed_ids = []
|
||||||
|
for raw_id in document_ids.split(","):
|
||||||
|
value = raw_id.strip()
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
parsed_ids.append(int(value))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid document id: {value}",
|
||||||
|
) from None
|
||||||
|
|
||||||
|
if not parsed_ids:
|
||||||
|
return DocumentStatusBatchResponse(items=[])
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(Document).filter(
|
||||||
|
Document.search_space_id == search_space_id,
|
||||||
|
Document.id.in_(parsed_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
docs = result.scalars().all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
DocumentStatusItemRead(
|
||||||
|
id=doc.id,
|
||||||
|
title=doc.title,
|
||||||
|
document_type=doc.document_type,
|
||||||
|
status=DocumentStatusSchema(
|
||||||
|
state=(doc.status or {}).get("state", "ready"),
|
||||||
|
reason=(doc.status or {}).get("reason"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for doc in docs
|
||||||
|
]
|
||||||
|
return DocumentStatusBatchResponse(items=items)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Failed to fetch document status: {e!s}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/documents/type-counts")
|
@router.get("/documents/type-counts")
|
||||||
async def get_document_type_counts(
|
async def get_document_type_counts(
|
||||||
search_space_id: int | None = None,
|
search_space_id: int | None = None,
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,11 @@ These endpoints support the ThreadHistoryAdapter pattern from assistant-ui:
|
||||||
- PUT /threads/{thread_id} - Update thread (rename, archive)
|
- PUT /threads/{thread_id} - Update thread (rename, archive)
|
||||||
- DELETE /threads/{thread_id} - Delete thread
|
- DELETE /threads/{thread_id} - Delete thread
|
||||||
- POST /threads/{thread_id}/messages - Append message
|
- POST /threads/{thread_id}/messages - Append message
|
||||||
- POST /attachments/process - Process attachments for chat context
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import uuid
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import func, or_
|
from sqlalchemy import func, or_
|
||||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
|
|
@ -1045,12 +1040,13 @@ async def handle_new_chat(
|
||||||
search_space_id=request.search_space_id,
|
search_space_id=request.search_space_id,
|
||||||
chat_id=request.chat_id,
|
chat_id=request.chat_id,
|
||||||
session=session,
|
session=session,
|
||||||
user_id=str(user.id), # Pass user ID for memory tools and session state
|
user_id=str(user.id),
|
||||||
llm_config_id=llm_config_id,
|
llm_config_id=llm_config_id,
|
||||||
attachments=request.attachments,
|
|
||||||
mentioned_document_ids=request.mentioned_document_ids,
|
mentioned_document_ids=request.mentioned_document_ids,
|
||||||
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
||||||
needs_history_bootstrap=thread.needs_history_bootstrap,
|
needs_history_bootstrap=thread.needs_history_bootstrap,
|
||||||
|
thread_visibility=thread.visibility,
|
||||||
|
current_user_display_name=user.display_name or "A team member",
|
||||||
),
|
),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
headers={
|
headers={
|
||||||
|
|
@ -1276,11 +1272,12 @@ async def regenerate_response(
|
||||||
session=session,
|
session=session,
|
||||||
user_id=str(user.id),
|
user_id=str(user.id),
|
||||||
llm_config_id=llm_config_id,
|
llm_config_id=llm_config_id,
|
||||||
attachments=request.attachments,
|
|
||||||
mentioned_document_ids=request.mentioned_document_ids,
|
mentioned_document_ids=request.mentioned_document_ids,
|
||||||
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
mentioned_surfsense_doc_ids=request.mentioned_surfsense_doc_ids,
|
||||||
checkpoint_id=target_checkpoint_id,
|
checkpoint_id=target_checkpoint_id,
|
||||||
needs_history_bootstrap=thread.needs_history_bootstrap,
|
needs_history_bootstrap=thread.needs_history_bootstrap,
|
||||||
|
thread_visibility=thread.visibility,
|
||||||
|
current_user_display_name=user.display_name or "A team member",
|
||||||
):
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
# If we get here, streaming completed successfully
|
# If we get here, streaming completed successfully
|
||||||
|
|
@ -1329,185 +1326,3 @@ async def regenerate_response(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=f"An unexpected error occurred during regeneration: {e!s}",
|
detail=f"An unexpected error occurred during regeneration: {e!s}",
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# Attachment Processing Endpoint
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/attachments/process")
|
|
||||||
async def process_attachment(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
session: AsyncSession = Depends(get_async_session),
|
|
||||||
user: User = Depends(current_active_user),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Process an attachment file and extract its content as markdown.
|
|
||||||
|
|
||||||
This endpoint uses the configured ETL service to parse files and return
|
|
||||||
the extracted content that can be used as context in chat messages.
|
|
||||||
|
|
||||||
Supported file types depend on the configured ETL_SERVICE:
|
|
||||||
- Markdown/Text files: .md, .markdown, .txt (always supported)
|
|
||||||
- Audio files: .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm (if STT configured)
|
|
||||||
- Documents: .pdf, .docx, .doc, .pptx, .xlsx (depends on ETL service)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
JSON with attachment id, name, type, and extracted content
|
|
||||||
"""
|
|
||||||
from app.config import config as app_config
|
|
||||||
|
|
||||||
if not file.filename:
|
|
||||||
raise HTTPException(status_code=400, detail="No filename provided")
|
|
||||||
|
|
||||||
filename = file.filename
|
|
||||||
attachment_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Save file to a temporary location
|
|
||||||
file_ext = os.path.splitext(filename)[1].lower()
|
|
||||||
with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as temp_file:
|
|
||||||
temp_path = temp_file.name
|
|
||||||
content = await file.read()
|
|
||||||
temp_file.write(content)
|
|
||||||
|
|
||||||
extracted_content = ""
|
|
||||||
|
|
||||||
# Process based on file type
|
|
||||||
if file_ext in (".md", ".markdown", ".txt"):
|
|
||||||
# For text/markdown files, read content directly
|
|
||||||
with open(temp_path, encoding="utf-8") as f:
|
|
||||||
extracted_content = f.read()
|
|
||||||
|
|
||||||
elif file_ext in (".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm"):
|
|
||||||
# Audio files - transcribe if STT service is configured
|
|
||||||
if not app_config.STT_SERVICE:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail="Audio transcription is not configured. Please set STT_SERVICE.",
|
|
||||||
)
|
|
||||||
|
|
||||||
stt_service_type = (
|
|
||||||
"local" if app_config.STT_SERVICE.startswith("local/") else "external"
|
|
||||||
)
|
|
||||||
|
|
||||||
if stt_service_type == "local":
|
|
||||||
from app.services.stt_service import stt_service
|
|
||||||
|
|
||||||
result = stt_service.transcribe_file(temp_path)
|
|
||||||
extracted_content = result.get("text", "")
|
|
||||||
else:
|
|
||||||
from litellm import atranscription
|
|
||||||
|
|
||||||
with open(temp_path, "rb") as audio_file:
|
|
||||||
transcription_kwargs = {
|
|
||||||
"model": app_config.STT_SERVICE,
|
|
||||||
"file": audio_file,
|
|
||||||
"api_key": app_config.STT_SERVICE_API_KEY,
|
|
||||||
}
|
|
||||||
if app_config.STT_SERVICE_API_BASE:
|
|
||||||
transcription_kwargs["api_base"] = (
|
|
||||||
app_config.STT_SERVICE_API_BASE
|
|
||||||
)
|
|
||||||
|
|
||||||
transcription_response = await atranscription(
|
|
||||||
**transcription_kwargs
|
|
||||||
)
|
|
||||||
extracted_content = transcription_response.get("text", "")
|
|
||||||
|
|
||||||
if extracted_content:
|
|
||||||
extracted_content = (
|
|
||||||
f"# Transcription of {filename}\n\n{extracted_content}"
|
|
||||||
)
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Document files - use configured ETL service
|
|
||||||
if app_config.ETL_SERVICE == "UNSTRUCTURED":
|
|
||||||
from langchain_unstructured import UnstructuredLoader
|
|
||||||
|
|
||||||
from app.utils.document_converters import convert_document_to_markdown
|
|
||||||
|
|
||||||
loader = UnstructuredLoader(
|
|
||||||
temp_path,
|
|
||||||
mode="elements",
|
|
||||||
post_processors=[],
|
|
||||||
languages=["eng"],
|
|
||||||
include_orig_elements=False,
|
|
||||||
include_metadata=False,
|
|
||||||
strategy="auto",
|
|
||||||
)
|
|
||||||
docs = await loader.aload()
|
|
||||||
extracted_content = await convert_document_to_markdown(docs)
|
|
||||||
|
|
||||||
elif app_config.ETL_SERVICE == "LLAMACLOUD":
|
|
||||||
from llama_cloud_services import LlamaParse
|
|
||||||
from llama_cloud_services.parse.utils import ResultType
|
|
||||||
|
|
||||||
parser = LlamaParse(
|
|
||||||
api_key=app_config.LLAMA_CLOUD_API_KEY,
|
|
||||||
num_workers=1,
|
|
||||||
verbose=False,
|
|
||||||
language="en",
|
|
||||||
result_type=ResultType.MD,
|
|
||||||
)
|
|
||||||
result = await parser.aparse(temp_path)
|
|
||||||
markdown_documents = await result.aget_markdown_documents(
|
|
||||||
split_by_page=False
|
|
||||||
)
|
|
||||||
|
|
||||||
if markdown_documents:
|
|
||||||
extracted_content = "\n\n".join(
|
|
||||||
doc.text for doc in markdown_documents
|
|
||||||
)
|
|
||||||
|
|
||||||
elif app_config.ETL_SERVICE == "DOCLING":
|
|
||||||
from app.services.docling_service import create_docling_service
|
|
||||||
|
|
||||||
docling_service = create_docling_service()
|
|
||||||
result = await docling_service.process_document(temp_path, filename)
|
|
||||||
extracted_content = result.get("content", "")
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"ETL service not configured or unsupported file type: {file_ext}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Clean up temp file
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
os.unlink(temp_path)
|
|
||||||
|
|
||||||
if not extracted_content:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=422,
|
|
||||||
detail=f"Could not extract content from file: {filename}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Determine attachment type (must be one of: "image", "document", "file")
|
|
||||||
# assistant-ui only supports these three types
|
|
||||||
if file_ext in (".png", ".jpg", ".jpeg", ".gif", ".webp"):
|
|
||||||
attachment_type = "image"
|
|
||||||
else:
|
|
||||||
# All other files (including audio, documents, text) are treated as "document"
|
|
||||||
attachment_type = "document"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": attachment_id,
|
|
||||||
"name": filename,
|
|
||||||
"type": attachment_type,
|
|
||||||
"content": extracted_content,
|
|
||||||
"contentLength": len(extracted_content),
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
# Clean up temp file on error
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
os.unlink(temp_path)
|
|
||||||
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to process attachment: {e!s}",
|
|
||||||
) from e
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ Non-OAuth connectors (BookStack, GitHub, etc.) are limited to one per search spa
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
from contextlib import suppress
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -32,6 +32,7 @@ from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
from app.connectors.github_connector import GitHubConnector
|
from app.connectors.github_connector import GitHubConnector
|
||||||
from app.db import (
|
from app.db import (
|
||||||
Permission,
|
Permission,
|
||||||
|
|
@ -70,6 +71,10 @@ from app.tasks.connector_indexers import (
|
||||||
index_slack_messages,
|
index_slack_messages,
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
from app.utils.indexing_locks import (
|
||||||
|
acquire_connector_indexing_lock,
|
||||||
|
release_connector_indexing_lock,
|
||||||
|
)
|
||||||
from app.utils.periodic_scheduler import (
|
from app.utils.periodic_scheduler import (
|
||||||
create_periodic_schedule,
|
create_periodic_schedule,
|
||||||
delete_periodic_schedule,
|
delete_periodic_schedule,
|
||||||
|
|
@ -91,11 +96,9 @@ def get_heartbeat_redis_client() -> redis.Redis:
|
||||||
"""Get or create Redis client for heartbeat tracking."""
|
"""Get or create Redis client for heartbeat tracking."""
|
||||||
global _heartbeat_redis_client
|
global _heartbeat_redis_client
|
||||||
if _heartbeat_redis_client is None:
|
if _heartbeat_redis_client is None:
|
||||||
redis_url = os.getenv(
|
_heartbeat_redis_client = redis.from_url(
|
||||||
"REDIS_APP_URL",
|
config.REDIS_APP_URL, decode_responses=True
|
||||||
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
|
||||||
)
|
)
|
||||||
_heartbeat_redis_client = redis.from_url(redis_url, decode_responses=True)
|
|
||||||
return _heartbeat_redis_client
|
return _heartbeat_redis_client
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1229,10 +1232,19 @@ async def _run_indexing_with_notifications(
|
||||||
from celery.exceptions import SoftTimeLimitExceeded
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
|
|
||||||
notification = None
|
notification = None
|
||||||
|
connector_lock_acquired = False
|
||||||
# Track indexed count for retry notifications and heartbeat
|
# Track indexed count for retry notifications and heartbeat
|
||||||
current_indexed_count = 0
|
current_indexed_count = 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
connector_lock_acquired = acquire_connector_indexing_lock(connector_id)
|
||||||
|
if not connector_lock_acquired:
|
||||||
|
logger.info(
|
||||||
|
f"Skipping indexing for connector {connector_id} "
|
||||||
|
"(another worker already holds Redis connector lock)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Get connector info for notification
|
# Get connector info for notification
|
||||||
connector_result = await session.execute(
|
connector_result = await session.execute(
|
||||||
select(SearchSourceConnector).where(
|
select(SearchSourceConnector).where(
|
||||||
|
|
@ -1558,6 +1570,9 @@ async def _run_indexing_with_notifications(
|
||||||
get_heartbeat_redis_client().delete(heartbeat_key)
|
get_heartbeat_redis_client().delete(heartbeat_key)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Ignore cleanup errors - key will expire anyway
|
pass # Ignore cleanup errors - key will expire anyway
|
||||||
|
if connector_lock_acquired:
|
||||||
|
with suppress(Exception):
|
||||||
|
release_connector_indexing_lock(connector_id)
|
||||||
|
|
||||||
|
|
||||||
async def run_notion_indexing_with_new_session(
|
async def run_notion_indexing_with_new_session(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ from .documents import (
|
||||||
DocumentBase,
|
DocumentBase,
|
||||||
DocumentRead,
|
DocumentRead,
|
||||||
DocumentsCreate,
|
DocumentsCreate,
|
||||||
|
DocumentStatusBatchResponse,
|
||||||
|
DocumentStatusItemRead,
|
||||||
DocumentStatusSchema,
|
DocumentStatusSchema,
|
||||||
DocumentTitleRead,
|
DocumentTitleRead,
|
||||||
DocumentTitleSearchResponse,
|
DocumentTitleSearchResponse,
|
||||||
|
|
@ -105,6 +107,8 @@ __all__ = [
|
||||||
# Document schemas
|
# Document schemas
|
||||||
"DocumentBase",
|
"DocumentBase",
|
||||||
"DocumentRead",
|
"DocumentRead",
|
||||||
|
"DocumentStatusBatchResponse",
|
||||||
|
"DocumentStatusItemRead",
|
||||||
"DocumentStatusSchema",
|
"DocumentStatusSchema",
|
||||||
"DocumentTitleRead",
|
"DocumentTitleRead",
|
||||||
"DocumentTitleSearchResponse",
|
"DocumentTitleSearchResponse",
|
||||||
|
|
|
||||||
|
|
@ -99,3 +99,20 @@ class DocumentTitleSearchResponse(BaseModel):
|
||||||
|
|
||||||
items: list[DocumentTitleRead]
|
items: list[DocumentTitleRead]
|
||||||
has_more: bool
|
has_more: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentStatusItemRead(BaseModel):
|
||||||
|
"""Lightweight document status payload for batch status polling."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
document_type: DocumentType
|
||||||
|
status: DocumentStatusSchema
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentStatusBatchResponse(BaseModel):
|
||||||
|
"""Batch status response for a set of document IDs."""
|
||||||
|
|
||||||
|
items: list[DocumentStatusItemRead]
|
||||||
|
|
|
||||||
|
|
@ -159,15 +159,6 @@ class ChatMessage(BaseModel):
|
||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class ChatAttachment(BaseModel):
|
|
||||||
"""An attachment with its extracted content for chat context."""
|
|
||||||
|
|
||||||
id: str # Unique attachment ID
|
|
||||||
name: str # Original filename
|
|
||||||
type: str # Attachment type: document, image, audio
|
|
||||||
content: str # Extracted markdown content from the file
|
|
||||||
|
|
||||||
|
|
||||||
class NewChatRequest(BaseModel):
|
class NewChatRequest(BaseModel):
|
||||||
"""Request schema for the deep agent chat endpoint."""
|
"""Request schema for the deep agent chat endpoint."""
|
||||||
|
|
||||||
|
|
@ -175,9 +166,6 @@ class NewChatRequest(BaseModel):
|
||||||
user_query: str
|
user_query: str
|
||||||
search_space_id: int
|
search_space_id: int
|
||||||
messages: list[ChatMessage] | None = None # Optional chat history from frontend
|
messages: list[ChatMessage] | None = None # Optional chat history from frontend
|
||||||
attachments: list[ChatAttachment] | None = (
|
|
||||||
None # Optional attachments with extracted content
|
|
||||||
)
|
|
||||||
mentioned_document_ids: list[int] | None = (
|
mentioned_document_ids: list[int] | None = (
|
||||||
None # Optional document IDs mentioned with @ in the chat
|
None # Optional document IDs mentioned with @ in the chat
|
||||||
)
|
)
|
||||||
|
|
@ -201,7 +189,6 @@ class RegenerateRequest(BaseModel):
|
||||||
user_query: str | None = (
|
user_query: str | None = (
|
||||||
None # New user query (for edit). None = reload with same query
|
None # New user query (for edit). None = reload with same query
|
||||||
)
|
)
|
||||||
attachments: list[ChatAttachment] | None = None
|
|
||||||
mentioned_document_ids: list[int] | None = None
|
mentioned_document_ids: list[int] | None = None
|
||||||
mentioned_surfsense_doc_ids: list[int] | None = None
|
mentioned_surfsense_doc_ids: list[int] | None = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,7 @@ PROVIDER_MAP = {
|
||||||
"ALIBABA_QWEN": "openai",
|
"ALIBABA_QWEN": "openai",
|
||||||
"MOONSHOT": "openai",
|
"MOONSHOT": "openai",
|
||||||
"ZHIPU": "openai",
|
"ZHIPU": "openai",
|
||||||
|
"GITHUB_MODELS": "github",
|
||||||
"HUGGINGFACE": "huggingface",
|
"HUGGINGFACE": "huggingface",
|
||||||
"CUSTOM": "custom",
|
"CUSTOM": "custom",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,7 @@ async def validate_llm_config(
|
||||||
"ALIBABA_QWEN": "openai",
|
"ALIBABA_QWEN": "openai",
|
||||||
"MOONSHOT": "openai",
|
"MOONSHOT": "openai",
|
||||||
"ZHIPU": "openai", # GLM needs special handling
|
"ZHIPU": "openai", # GLM needs special handling
|
||||||
|
"GITHUB_MODELS": "github",
|
||||||
}
|
}
|
||||||
provider_prefix = provider_map.get(provider, provider.lower())
|
provider_prefix = provider_map.get(provider, provider.lower())
|
||||||
model_string = f"{provider_prefix}/{model_name}"
|
model_string = f"{provider_prefix}/{model_name}"
|
||||||
|
|
@ -335,6 +336,7 @@ async def get_search_space_llm_instance(
|
||||||
"ALIBABA_QWEN": "openai",
|
"ALIBABA_QWEN": "openai",
|
||||||
"MOONSHOT": "openai",
|
"MOONSHOT": "openai",
|
||||||
"ZHIPU": "openai",
|
"ZHIPU": "openai",
|
||||||
|
"GITHUB_MODELS": "github",
|
||||||
}
|
}
|
||||||
provider_prefix = provider_map.get(
|
provider_prefix = provider_map.get(
|
||||||
llm_config.provider.value, llm_config.provider.value.lower()
|
llm_config.provider.value, llm_config.provider.value.lower()
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,9 @@ def _get_doc_heartbeat_redis():
|
||||||
|
|
||||||
global _doc_heartbeat_redis
|
global _doc_heartbeat_redis
|
||||||
if _doc_heartbeat_redis is None:
|
if _doc_heartbeat_redis is None:
|
||||||
redis_url = os.getenv(
|
_doc_heartbeat_redis = redis.from_url(
|
||||||
"REDIS_APP_URL",
|
config.REDIS_APP_URL, decode_responses=True
|
||||||
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
|
||||||
)
|
)
|
||||||
_doc_heartbeat_redis = redis.from_url(redis_url, decode_responses=True)
|
|
||||||
return _doc_heartbeat_redis
|
return _doc_heartbeat_redis
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,16 +46,10 @@ def get_celery_session_maker():
|
||||||
|
|
||||||
def _clear_generating_podcast(search_space_id: int) -> None:
|
def _clear_generating_podcast(search_space_id: int) -> None:
|
||||||
"""Clear the generating podcast marker from Redis when task completes."""
|
"""Clear the generating podcast marker from Redis when task completes."""
|
||||||
import os
|
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
|
||||||
try:
|
try:
|
||||||
redis_url = os.getenv(
|
client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||||
"REDIS_APP_URL",
|
|
||||||
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
|
||||||
)
|
|
||||||
client = redis.from_url(redis_url, decode_responses=True)
|
|
||||||
key = f"podcast:generating:{search_space_id}"
|
key = f"podcast:generating:{search_space_id}"
|
||||||
client.delete(key)
|
client.delete(key)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,8 @@ from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
from app.celery_app import celery_app
|
from app.celery_app import celery_app
|
||||||
from app.config import config
|
from app.config import config
|
||||||
from app.db import SearchSourceConnector, SearchSourceConnectorType
|
from app.db import Notification, SearchSourceConnector, SearchSourceConnectorType
|
||||||
|
from app.utils.indexing_locks import is_connector_indexing_locked
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -107,6 +108,32 @@ async def _check_and_trigger_schedules():
|
||||||
|
|
||||||
# Trigger indexing for each due connector
|
# Trigger indexing for each due connector
|
||||||
for connector in due_connectors:
|
for connector in due_connectors:
|
||||||
|
# Primary guard: Redis lock indicates a task is currently running.
|
||||||
|
if is_connector_indexing_locked(connector.id):
|
||||||
|
logger.info(
|
||||||
|
f"Skipping periodic indexing for connector {connector.id} "
|
||||||
|
"(Redis lock indicates indexing is already in progress)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip scheduling if a sync for this connector is already in progress.
|
||||||
|
# This prevents duplicate tasks from piling up under slow/rate-limited providers.
|
||||||
|
in_progress_result = await session.execute(
|
||||||
|
select(Notification.id).where(
|
||||||
|
Notification.type == "connector_indexing",
|
||||||
|
Notification.notification_metadata["connector_id"].astext
|
||||||
|
== str(connector.id),
|
||||||
|
Notification.notification_metadata["status"].astext
|
||||||
|
== "in_progress",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if in_progress_result.first():
|
||||||
|
logger.info(
|
||||||
|
f"Skipping periodic indexing for connector {connector.id} "
|
||||||
|
"(already has in-progress indexing notification)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
task = task_map.get(connector.connector_type)
|
task = task_map.get(connector.connector_type)
|
||||||
if task:
|
if task:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ Detection mechanism:
|
||||||
import contextlib
|
import contextlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
|
|
@ -52,11 +51,7 @@ def get_redis_client() -> redis.Redis:
|
||||||
"""Get or create Redis client for heartbeat checking."""
|
"""Get or create Redis client for heartbeat checking."""
|
||||||
global _redis_client
|
global _redis_client
|
||||||
if _redis_client is None:
|
if _redis_client is None:
|
||||||
redis_url = os.getenv(
|
_redis_client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||||
"REDIS_APP_URL",
|
|
||||||
os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"),
|
|
||||||
)
|
|
||||||
_redis_client = redis.from_url(redis_url, decode_responses=True)
|
|
||||||
return _redis_client
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,8 @@ from app.agents.new_chat.llm_config import (
|
||||||
load_agent_config,
|
load_agent_config,
|
||||||
load_llm_config_from_yaml,
|
load_llm_config_from_yaml,
|
||||||
)
|
)
|
||||||
from app.db import Document, SurfsenseDocsDocument
|
from app.db import ChatVisibility, Document, SurfsenseDocsDocument
|
||||||
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
|
from app.prompts import TITLE_GENERATION_PROMPT_TEMPLATE
|
||||||
from app.schemas.new_chat import ChatAttachment
|
|
||||||
from app.services.chat_session_state_service import (
|
from app.services.chat_session_state_service import (
|
||||||
clear_ai_responding,
|
clear_ai_responding,
|
||||||
set_ai_responding,
|
set_ai_responding,
|
||||||
|
|
@ -38,23 +37,6 @@ from app.services.new_streaming_service import VercelStreamingService
|
||||||
from app.utils.content_utils import bootstrap_history_from_db
|
from app.utils.content_utils import bootstrap_history_from_db
|
||||||
|
|
||||||
|
|
||||||
def format_attachments_as_context(attachments: list[ChatAttachment]) -> str:
|
|
||||||
"""Format attachments as context for the agent."""
|
|
||||||
if not attachments:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
context_parts = ["<user_attachments>"]
|
|
||||||
for i, attachment in enumerate(attachments, 1):
|
|
||||||
context_parts.append(
|
|
||||||
f"<attachment index='{i}' name='{attachment.name}' type='{attachment.type}'>"
|
|
||||||
)
|
|
||||||
context_parts.append(f"<![CDATA[{attachment.content}]]>")
|
|
||||||
context_parts.append("</attachment>")
|
|
||||||
context_parts.append("</user_attachments>")
|
|
||||||
|
|
||||||
return "\n".join(context_parts)
|
|
||||||
|
|
||||||
|
|
||||||
def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
def format_mentioned_documents_as_context(documents: list[Document]) -> str:
|
||||||
"""
|
"""
|
||||||
Format mentioned documents as context for the agent.
|
Format mentioned documents as context for the agent.
|
||||||
|
|
@ -203,11 +185,12 @@ async def stream_new_chat(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
llm_config_id: int = -1,
|
llm_config_id: int = -1,
|
||||||
attachments: list[ChatAttachment] | None = None,
|
|
||||||
mentioned_document_ids: list[int] | None = None,
|
mentioned_document_ids: list[int] | None = None,
|
||||||
mentioned_surfsense_doc_ids: list[int] | None = None,
|
mentioned_surfsense_doc_ids: list[int] | None = None,
|
||||||
checkpoint_id: str | None = None,
|
checkpoint_id: str | None = None,
|
||||||
needs_history_bootstrap: bool = False,
|
needs_history_bootstrap: bool = False,
|
||||||
|
thread_visibility: ChatVisibility | None = None,
|
||||||
|
current_user_display_name: str | None = None,
|
||||||
) -> AsyncGenerator[str, None]:
|
) -> AsyncGenerator[str, None]:
|
||||||
"""
|
"""
|
||||||
Stream chat responses from the new SurfSense deep agent.
|
Stream chat responses from the new SurfSense deep agent.
|
||||||
|
|
@ -222,7 +205,6 @@ async def stream_new_chat(
|
||||||
session: The database session
|
session: The database session
|
||||||
user_id: The current user's UUID string (for memory tools and session state)
|
user_id: The current user's UUID string (for memory tools and session state)
|
||||||
llm_config_id: The LLM configuration ID (default: -1 for first global config)
|
llm_config_id: The LLM configuration ID (default: -1 for first global config)
|
||||||
attachments: Optional attachments with extracted content
|
|
||||||
needs_history_bootstrap: If True, load message history from DB (for cloned chats)
|
needs_history_bootstrap: If True, load message history from DB (for cloned chats)
|
||||||
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
|
mentioned_document_ids: Optional list of document IDs mentioned with @ in the chat
|
||||||
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
|
mentioned_surfsense_doc_ids: Optional list of SurfSense doc IDs mentioned with @ in the chat
|
||||||
|
|
@ -295,17 +277,18 @@ async def stream_new_chat(
|
||||||
# Get the PostgreSQL checkpointer for persistent conversation memory
|
# Get the PostgreSQL checkpointer for persistent conversation memory
|
||||||
checkpointer = await get_checkpointer()
|
checkpointer = await get_checkpointer()
|
||||||
|
|
||||||
# Create the deep agent with checkpointer and configurable prompts
|
visibility = thread_visibility or ChatVisibility.PRIVATE
|
||||||
agent = await create_surfsense_deep_agent(
|
agent = await create_surfsense_deep_agent(
|
||||||
llm=llm,
|
llm=llm,
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
db_session=session,
|
db_session=session,
|
||||||
connector_service=connector_service,
|
connector_service=connector_service,
|
||||||
checkpointer=checkpointer,
|
checkpointer=checkpointer,
|
||||||
user_id=user_id, # Pass user ID for memory tools
|
user_id=user_id,
|
||||||
thread_id=chat_id, # Pass chat ID for podcast association
|
thread_id=chat_id,
|
||||||
agent_config=agent_config, # Pass prompt configuration
|
agent_config=agent_config,
|
||||||
firecrawl_api_key=firecrawl_api_key, # Pass Firecrawl API key if configured
|
firecrawl_api_key=firecrawl_api_key,
|
||||||
|
thread_visibility=visibility,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build input with message history
|
# Build input with message history
|
||||||
|
|
@ -313,7 +296,9 @@ async def stream_new_chat(
|
||||||
|
|
||||||
# Bootstrap history for cloned chats (no LangGraph checkpoint exists yet)
|
# Bootstrap history for cloned chats (no LangGraph checkpoint exists yet)
|
||||||
if needs_history_bootstrap:
|
if needs_history_bootstrap:
|
||||||
langchain_messages = await bootstrap_history_from_db(session, chat_id)
|
langchain_messages = await bootstrap_history_from_db(
|
||||||
|
session, chat_id, thread_visibility=visibility
|
||||||
|
)
|
||||||
|
|
||||||
# Clear the flag so we don't bootstrap again on next message
|
# Clear the flag so we don't bootstrap again on next message
|
||||||
from app.db import NewChatThread
|
from app.db import NewChatThread
|
||||||
|
|
@ -355,13 +340,10 @@ async def stream_new_chat(
|
||||||
)
|
)
|
||||||
mentioned_surfsense_docs = list(result.scalars().all())
|
mentioned_surfsense_docs = list(result.scalars().all())
|
||||||
|
|
||||||
# Format the user query with context (attachments + mentioned documents + surfsense docs)
|
# Format the user query with context (mentioned documents + SurfSense docs)
|
||||||
final_query = user_query
|
final_query = user_query
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
||||||
if attachments:
|
|
||||||
context_parts.append(format_attachments_as_context(attachments))
|
|
||||||
|
|
||||||
if mentioned_documents:
|
if mentioned_documents:
|
||||||
context_parts.append(
|
context_parts.append(
|
||||||
format_mentioned_documents_as_context(mentioned_documents)
|
format_mentioned_documents_as_context(mentioned_documents)
|
||||||
|
|
@ -376,6 +358,9 @@ async def stream_new_chat(
|
||||||
context = "\n\n".join(context_parts)
|
context = "\n\n".join(context_parts)
|
||||||
final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
|
final_query = f"{context}\n\n<user_query>{user_query}</user_query>"
|
||||||
|
|
||||||
|
if visibility == ChatVisibility.SEARCH_SPACE and current_user_display_name:
|
||||||
|
final_query = f"**[{current_user_display_name}]:** {final_query}"
|
||||||
|
|
||||||
# if messages:
|
# if messages:
|
||||||
# # Convert frontend messages to LangChain format
|
# # Convert frontend messages to LangChain format
|
||||||
# for msg in messages:
|
# for msg in messages:
|
||||||
|
|
@ -451,39 +436,20 @@ async def stream_new_chat(
|
||||||
last_active_step_id = analyze_step_id
|
last_active_step_id = analyze_step_id
|
||||||
|
|
||||||
# Determine step title and action verb based on context
|
# Determine step title and action verb based on context
|
||||||
if attachments and (mentioned_documents or mentioned_surfsense_docs):
|
if mentioned_documents or mentioned_surfsense_docs:
|
||||||
last_active_step_title = "Analyzing your content"
|
|
||||||
action_verb = "Reading"
|
|
||||||
elif attachments:
|
|
||||||
last_active_step_title = "Reading your content"
|
|
||||||
action_verb = "Reading"
|
|
||||||
elif mentioned_documents or mentioned_surfsense_docs:
|
|
||||||
last_active_step_title = "Analyzing referenced content"
|
last_active_step_title = "Analyzing referenced content"
|
||||||
action_verb = "Analyzing"
|
action_verb = "Analyzing"
|
||||||
else:
|
else:
|
||||||
last_active_step_title = "Understanding your request"
|
last_active_step_title = "Understanding your request"
|
||||||
action_verb = "Processing"
|
action_verb = "Processing"
|
||||||
|
|
||||||
# Build the message with inline context about attachments/documents
|
# Build the message with inline context about referenced documents
|
||||||
processing_parts = []
|
processing_parts = []
|
||||||
|
|
||||||
# Add the user query
|
# Add the user query
|
||||||
query_text = user_query[:80] + ("..." if len(user_query) > 80 else "")
|
query_text = user_query[:80] + ("..." if len(user_query) > 80 else "")
|
||||||
processing_parts.append(query_text)
|
processing_parts.append(query_text)
|
||||||
|
|
||||||
# Add file attachment names inline
|
|
||||||
if attachments:
|
|
||||||
attachment_names = []
|
|
||||||
for attachment in attachments:
|
|
||||||
name = attachment.name
|
|
||||||
if len(name) > 30:
|
|
||||||
name = name[:27] + "..."
|
|
||||||
attachment_names.append(name)
|
|
||||||
if len(attachment_names) == 1:
|
|
||||||
processing_parts.append(f"[{attachment_names[0]}]")
|
|
||||||
else:
|
|
||||||
processing_parts.append(f"[{len(attachment_names)} files]")
|
|
||||||
|
|
||||||
# Add mentioned document names inline
|
# Add mentioned document names inline
|
||||||
if mentioned_documents:
|
if mentioned_documents:
|
||||||
doc_names = []
|
doc_names = []
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,22 @@ def safe_set_chunks(document: Document, chunks: list) -> None:
|
||||||
# Instead of: document.chunks = chunks (DANGEROUS!)
|
# Instead of: document.chunks = chunks (DANGEROUS!)
|
||||||
safe_set_chunks(document, chunks) # Always safe
|
safe_set_chunks(document, chunks) # Always safe
|
||||||
"""
|
"""
|
||||||
|
from sqlalchemy.orm import object_session
|
||||||
from sqlalchemy.orm.attributes import set_committed_value
|
from sqlalchemy.orm.attributes import set_committed_value
|
||||||
|
|
||||||
|
# Keep relationship assignment lazy-load-safe.
|
||||||
set_committed_value(document, "chunks", chunks)
|
set_committed_value(document, "chunks", chunks)
|
||||||
|
|
||||||
|
# Ensure chunk rows are actually persisted.
|
||||||
|
# set_committed_value bypasses normal unit-of-work tracking, so we need to
|
||||||
|
# explicitly attach chunk objects to the current session.
|
||||||
|
session = object_session(document)
|
||||||
|
if session is not None:
|
||||||
|
if document.id is not None:
|
||||||
|
for chunk in chunks:
|
||||||
|
chunk.document_id = document.id
|
||||||
|
session.add_all(chunks)
|
||||||
|
|
||||||
|
|
||||||
def parse_date_flexible(date_str: str) -> datetime:
|
def parse_date_flexible(date_str: str) -> datetime:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
"""
|
"""
|
||||||
Discord connector indexer.
|
Discord connector indexer.
|
||||||
|
|
||||||
Implements 2-phase document status updates for real-time UI feedback:
|
Implements batch indexing: groups up to DISCORD_BATCH_SIZE messages per channel
|
||||||
|
into a single document for efficient indexing and better conversational context.
|
||||||
|
|
||||||
|
Uses 2-phase document status updates for real-time UI feedback:
|
||||||
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
||||||
- Phase 2: Process each document: pending → processing → ready/failed
|
- Phase 2: Process each document: pending → processing → ready/failed
|
||||||
"""
|
"""
|
||||||
|
|
@ -41,6 +44,72 @@ HeartbeatCallbackType = Callable[[int], Awaitable[None]]
|
||||||
# Heartbeat interval in seconds - update notification every 30 seconds
|
# Heartbeat interval in seconds - update notification every 30 seconds
|
||||||
HEARTBEAT_INTERVAL_SECONDS = 30
|
HEARTBEAT_INTERVAL_SECONDS = 30
|
||||||
|
|
||||||
|
# Number of messages to combine into a single document for batch indexing.
|
||||||
|
# Grouping messages improves conversational context in embeddings/chunks and
|
||||||
|
# drastically reduces the number of documents, embedding calls, and DB overhead.
|
||||||
|
DISCORD_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _build_batch_document_string(
|
||||||
|
guild_name: str,
|
||||||
|
guild_id: str,
|
||||||
|
channel_name: str,
|
||||||
|
channel_id: str,
|
||||||
|
messages: list[dict],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Combine multiple Discord messages into a single document string.
|
||||||
|
|
||||||
|
Each message is formatted with its timestamp and author, and all messages
|
||||||
|
are concatenated into a conversation-style document. The chunker will
|
||||||
|
later split this into overlapping windows of ~8-10 consecutive messages,
|
||||||
|
preserving conversational context in each chunk's embedding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
guild_name: Name of the Discord guild
|
||||||
|
guild_id: ID of the Discord guild
|
||||||
|
channel_name: Name of the channel
|
||||||
|
channel_id: ID of the channel
|
||||||
|
messages: List of message dicts with 'author_name', 'created_at', 'content'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted document string with metadata and conversation content
|
||||||
|
"""
|
||||||
|
first_msg_time = messages[0].get("created_at", "Unknown")
|
||||||
|
last_msg_time = messages[-1].get("created_at", "Unknown")
|
||||||
|
|
||||||
|
metadata_lines = [
|
||||||
|
f"GUILD_NAME: {guild_name}",
|
||||||
|
f"GUILD_ID: {guild_id}",
|
||||||
|
f"CHANNEL_NAME: {channel_name}",
|
||||||
|
f"CHANNEL_ID: {channel_id}",
|
||||||
|
f"MESSAGE_COUNT: {len(messages)}",
|
||||||
|
f"FIRST_MESSAGE_TIME: {first_msg_time}",
|
||||||
|
f"LAST_MESSAGE_TIME: {last_msg_time}",
|
||||||
|
]
|
||||||
|
|
||||||
|
conversation_lines = []
|
||||||
|
for msg in messages:
|
||||||
|
author = msg.get("author_name", "Unknown User")
|
||||||
|
timestamp = msg.get("created_at", "Unknown Time")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
conversation_lines.append(f"[{timestamp}] {author}: {content}")
|
||||||
|
|
||||||
|
metadata_sections = [
|
||||||
|
("METADATA", metadata_lines),
|
||||||
|
(
|
||||||
|
"CONTENT",
|
||||||
|
[
|
||||||
|
"FORMAT: markdown",
|
||||||
|
"TEXT_START",
|
||||||
|
"\n".join(conversation_lines),
|
||||||
|
"TEXT_END",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return build_document_metadata_markdown(metadata_sections)
|
||||||
|
|
||||||
|
|
||||||
async def index_discord_messages(
|
async def index_discord_messages(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -55,6 +124,12 @@ async def index_discord_messages(
|
||||||
"""
|
"""
|
||||||
Index Discord messages from the configured guild's channels.
|
Index Discord messages from the configured guild's channels.
|
||||||
|
|
||||||
|
Messages are grouped into batches of DISCORD_BATCH_SIZE per channel,
|
||||||
|
so each document contains up to 100 consecutive messages with full
|
||||||
|
conversational context. This reduces document count, embedding calls,
|
||||||
|
and DB overhead by ~100x while improving search quality through
|
||||||
|
context-aware chunk embeddings.
|
||||||
|
|
||||||
Implements 2-phase document status updates for real-time UI feedback:
|
Implements 2-phase document status updates for real-time UI feedback:
|
||||||
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
||||||
- Phase 2: Process each document: pending → processing → ready/failed
|
- Phase 2: Process each document: pending → processing → ready/failed
|
||||||
|
|
@ -324,6 +399,7 @@ async def index_discord_messages(
|
||||||
documents_skipped = 0
|
documents_skipped = 0
|
||||||
documents_failed = 0
|
documents_failed = 0
|
||||||
duplicate_content_count = 0
|
duplicate_content_count = 0
|
||||||
|
total_messages_collected = 0
|
||||||
skipped_channels: list[str] = []
|
skipped_channels: list[str] = []
|
||||||
|
|
||||||
# Heartbeat tracking - update notification periodically to prevent appearing stuck
|
# Heartbeat tracking - update notification periodically to prevent appearing stuck
|
||||||
|
|
@ -340,10 +416,12 @@ async def index_discord_messages(
|
||||||
)
|
)
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 1: Collect all messages and create pending documents
|
# PHASE 1: Collect messages, group into batches, and create pending documents
|
||||||
# This makes ALL documents visible in the UI immediately with pending status
|
# Messages are grouped into batches of DISCORD_BATCH_SIZE per channel.
|
||||||
|
# Each batch becomes a single document with full conversational context.
|
||||||
|
# All documents are visible in the UI immediately with pending status.
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
messages_to_process = [] # List of dicts with document and message data
|
batches_to_process = [] # List of dicts with document and batch data
|
||||||
new_documents_created = False
|
new_documents_created = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -394,44 +472,35 @@ async def index_discord_messages(
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Process each message as an individual document (like Slack)
|
total_messages_collected += len(formatted_messages)
|
||||||
for msg in formatted_messages:
|
|
||||||
msg_id = msg.get("id", "")
|
|
||||||
msg_user_name = msg.get("author_name", "Unknown User")
|
|
||||||
msg_timestamp = msg.get("created_at", "Unknown Time")
|
|
||||||
msg_text = msg.get("content", "")
|
|
||||||
|
|
||||||
# Format document metadata (similar to Slack)
|
# =======================================================
|
||||||
metadata_sections = [
|
# Group messages into batches of DISCORD_BATCH_SIZE
|
||||||
(
|
# Each batch becomes a single document with conversation context
|
||||||
"METADATA",
|
# =======================================================
|
||||||
[
|
for batch_start in range(
|
||||||
f"GUILD_NAME: {guild_name}",
|
0, len(formatted_messages), DISCORD_BATCH_SIZE
|
||||||
f"GUILD_ID: {guild_id}",
|
):
|
||||||
f"CHANNEL_NAME: {channel_name}",
|
batch = formatted_messages[
|
||||||
f"CHANNEL_ID: {channel_id}",
|
batch_start : batch_start + DISCORD_BATCH_SIZE
|
||||||
f"MESSAGE_TIMESTAMP: {msg_timestamp}",
|
|
||||||
f"MESSAGE_USER_NAME: {msg_user_name}",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"CONTENT",
|
|
||||||
[
|
|
||||||
"FORMAT: markdown",
|
|
||||||
"TEXT_START",
|
|
||||||
msg_text,
|
|
||||||
"TEXT_END",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Build the document string
|
# Build combined document string from all messages in this batch
|
||||||
combined_document_string = build_document_metadata_markdown(
|
combined_document_string = _build_batch_document_string(
|
||||||
metadata_sections
|
guild_name=guild_name,
|
||||||
|
guild_id=guild_id,
|
||||||
|
channel_name=channel_name,
|
||||||
|
channel_id=channel_id,
|
||||||
|
messages=batch,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate unique identifier hash for this Discord message
|
# Generate unique identifier for this batch using
|
||||||
unique_identifier = f"{channel_id}_{msg_id}"
|
# channel_id + first message ID + last message ID
|
||||||
|
first_msg_id = batch[0].get("id", "")
|
||||||
|
last_msg_id = batch[-1].get("id", "")
|
||||||
|
unique_identifier = (
|
||||||
|
f"{channel_id}_{first_msg_id}_{last_msg_id}"
|
||||||
|
)
|
||||||
unique_identifier_hash = generate_unique_identifier_hash(
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
DocumentType.DISCORD_CONNECTOR,
|
DocumentType.DISCORD_CONNECTOR,
|
||||||
unique_identifier,
|
unique_identifier,
|
||||||
|
|
@ -464,7 +533,7 @@ async def index_discord_messages(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Queue existing document for update (will be set to processing in Phase 2)
|
# Queue existing document for update (will be set to processing in Phase 2)
|
||||||
messages_to_process.append(
|
batches_to_process.append(
|
||||||
{
|
{
|
||||||
"document": existing_document,
|
"document": existing_document,
|
||||||
"is_new": False,
|
"is_new": False,
|
||||||
|
|
@ -474,9 +543,15 @@ async def index_discord_messages(
|
||||||
"guild_id": guild_id,
|
"guild_id": guild_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"message_id": msg_id,
|
"first_message_id": first_msg_id,
|
||||||
"message_timestamp": msg_timestamp,
|
"last_message_id": last_msg_id,
|
||||||
"message_user_name": msg_user_name,
|
"first_message_time": batch[0].get(
|
||||||
|
"created_at", "Unknown"
|
||||||
|
),
|
||||||
|
"last_message_time": batch[-1].get(
|
||||||
|
"created_at", "Unknown"
|
||||||
|
),
|
||||||
|
"message_count": len(batch),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
@ -492,7 +567,7 @@ async def index_discord_messages(
|
||||||
|
|
||||||
if duplicate_by_content:
|
if duplicate_by_content:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Discord message {msg_id} in {guild_name}#{channel_name} already indexed by another connector "
|
f"Discord batch ({len(batch)} msgs) in {guild_name}#{channel_name} already indexed by another connector "
|
||||||
f"(existing document ID: {duplicate_by_content.id}, "
|
f"(existing document ID: {duplicate_by_content.id}, "
|
||||||
f"type: {duplicate_by_content.document_type}). Skipping."
|
f"type: {duplicate_by_content.document_type}). Skipping."
|
||||||
)
|
)
|
||||||
|
|
@ -510,7 +585,9 @@ async def index_discord_messages(
|
||||||
"guild_id": guild_id,
|
"guild_id": guild_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"message_id": msg_id,
|
"first_message_id": first_msg_id,
|
||||||
|
"last_message_id": last_msg_id,
|
||||||
|
"message_count": len(batch),
|
||||||
"connector_id": connector_id,
|
"connector_id": connector_id,
|
||||||
},
|
},
|
||||||
content="Pending...", # Placeholder until processed
|
content="Pending...", # Placeholder until processed
|
||||||
|
|
@ -526,7 +603,7 @@ async def index_discord_messages(
|
||||||
session.add(document)
|
session.add(document)
|
||||||
new_documents_created = True
|
new_documents_created = True
|
||||||
|
|
||||||
messages_to_process.append(
|
batches_to_process.append(
|
||||||
{
|
{
|
||||||
"document": document,
|
"document": document,
|
||||||
"is_new": True,
|
"is_new": True,
|
||||||
|
|
@ -536,12 +613,23 @@ async def index_discord_messages(
|
||||||
"guild_id": guild_id,
|
"guild_id": guild_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"message_id": msg_id,
|
"first_message_id": first_msg_id,
|
||||||
"message_timestamp": msg_timestamp,
|
"last_message_id": last_msg_id,
|
||||||
"message_user_name": msg_user_name,
|
"first_message_time": batch[0].get(
|
||||||
|
"created_at", "Unknown"
|
||||||
|
),
|
||||||
|
"last_message_time": batch[-1].get(
|
||||||
|
"created_at", "Unknown"
|
||||||
|
),
|
||||||
|
"message_count": len(batch),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Phase 1: Collected {len(formatted_messages)} messages from channel {channel_name}, "
|
||||||
|
f"grouped into {(len(formatted_messages) + DISCORD_BATCH_SIZE - 1) // DISCORD_BATCH_SIZE} batch(es)"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error processing guild {guild_name}: {e!s}", exc_info=True
|
f"Error processing guild {guild_name}: {e!s}", exc_info=True
|
||||||
|
|
@ -554,17 +642,18 @@ async def index_discord_messages(
|
||||||
# Commit all pending documents - they all appear in UI now
|
# Commit all pending documents - they all appear in UI now
|
||||||
if new_documents_created:
|
if new_documents_created:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Phase 1: Committing {len([m for m in messages_to_process if m['is_new']])} pending documents"
|
f"Phase 1: Committing {len([b for b in batches_to_process if b['is_new']])} pending batch documents "
|
||||||
|
f"({total_messages_collected} total messages across all channels)"
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 2: Process each document one by one
|
# PHASE 2: Process each batch document one by one
|
||||||
# Each document transitions: pending → processing → ready/failed
|
# Each document transitions: pending → processing → ready/failed
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
logger.info(f"Phase 2: Processing {len(messages_to_process)} documents")
|
logger.info(f"Phase 2: Processing {len(batches_to_process)} batch documents")
|
||||||
|
|
||||||
for item in messages_to_process:
|
for item in batches_to_process:
|
||||||
# Send heartbeat periodically
|
# Send heartbeat periodically
|
||||||
if on_heartbeat_callback:
|
if on_heartbeat_callback:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
@ -594,9 +683,11 @@ async def index_discord_messages(
|
||||||
"guild_id": item["guild_id"],
|
"guild_id": item["guild_id"],
|
||||||
"channel_name": item["channel_name"],
|
"channel_name": item["channel_name"],
|
||||||
"channel_id": item["channel_id"],
|
"channel_id": item["channel_id"],
|
||||||
"message_id": item["message_id"],
|
"first_message_id": item["first_message_id"],
|
||||||
"message_timestamp": item["message_timestamp"],
|
"last_message_id": item["last_message_id"],
|
||||||
"message_user_name": item["message_user_name"],
|
"first_message_time": item["first_message_time"],
|
||||||
|
"last_message_time": item["last_message_time"],
|
||||||
|
"message_count": item["message_count"],
|
||||||
"indexed_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
"indexed_at": datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"connector_id": connector_id,
|
"connector_id": connector_id,
|
||||||
}
|
}
|
||||||
|
|
@ -609,12 +700,14 @@ async def index_discord_messages(
|
||||||
# Batch commit every 10 documents (for ready status updates)
|
# Batch commit every 10 documents (for ready status updates)
|
||||||
if documents_indexed % 10 == 0:
|
if documents_indexed % 10 == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Committing batch: {documents_indexed} Discord messages processed so far"
|
f"Committing batch: {documents_indexed} batch documents processed so far"
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing Discord message: {e!s}", exc_info=True)
|
logger.error(
|
||||||
|
f"Error processing Discord batch document: {e!s}", exc_info=True
|
||||||
|
)
|
||||||
# Mark document as failed with reason (visible in UI)
|
# Mark document as failed with reason (visible in UI)
|
||||||
try:
|
try:
|
||||||
document.status = DocumentStatus.failed(str(e))
|
document.status = DocumentStatus.failed(str(e))
|
||||||
|
|
@ -631,7 +724,8 @@ async def index_discord_messages(
|
||||||
|
|
||||||
# Final commit for any remaining documents not yet committed in batches
|
# Final commit for any remaining documents not yet committed in batches
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Final commit: Total {documents_indexed} Discord messages processed"
|
f"Final commit: Total {documents_indexed} batch documents processed "
|
||||||
|
f"(from {total_messages_collected} messages)"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -672,14 +766,18 @@ async def index_discord_messages(
|
||||||
"documents_failed": documents_failed,
|
"documents_failed": documents_failed,
|
||||||
"duplicate_content_count": duplicate_content_count,
|
"duplicate_content_count": duplicate_content_count,
|
||||||
"skipped_channels_count": len(skipped_channels),
|
"skipped_channels_count": len(skipped_channels),
|
||||||
|
"total_messages_collected": total_messages_collected,
|
||||||
|
"batch_size": DISCORD_BATCH_SIZE,
|
||||||
"guild_id": guild_id,
|
"guild_id": guild_id,
|
||||||
"guild_name": guild_name,
|
"guild_name": guild_name,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Discord indexing completed for guild {guild_name}: {documents_indexed} ready, {documents_skipped} skipped, "
|
f"Discord indexing completed for guild {guild_name}: "
|
||||||
f"{documents_failed} failed ({duplicate_content_count} duplicate content)"
|
f"{documents_indexed} batch docs ready (from {total_messages_collected} messages), "
|
||||||
|
f"{documents_skipped} skipped, {documents_failed} failed "
|
||||||
|
f"({duplicate_content_count} duplicate content)"
|
||||||
)
|
)
|
||||||
return documents_indexed, warning_message
|
return documents_indexed, warning_message
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
"""
|
"""
|
||||||
Slack connector indexer.
|
Slack connector indexer.
|
||||||
|
|
||||||
Implements 2-phase document status updates for real-time UI feedback:
|
Implements batch indexing: groups up to SLACK_BATCH_SIZE messages per channel
|
||||||
|
into a single document for efficient indexing and better conversational context.
|
||||||
|
|
||||||
|
Uses 2-phase document status updates for real-time UI feedback:
|
||||||
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
||||||
- Phase 2: Process each document: pending → processing → ready/failed
|
- Phase 2: Process each document: pending → processing → ready/failed
|
||||||
"""
|
"""
|
||||||
|
|
@ -42,6 +45,72 @@ HeartbeatCallbackType = Callable[[int], Awaitable[None]]
|
||||||
# Heartbeat interval in seconds - update notification every 30 seconds
|
# Heartbeat interval in seconds - update notification every 30 seconds
|
||||||
HEARTBEAT_INTERVAL_SECONDS = 30
|
HEARTBEAT_INTERVAL_SECONDS = 30
|
||||||
|
|
||||||
|
# Number of messages to combine into a single document for batch indexing.
|
||||||
|
# Grouping messages improves conversational context in embeddings/chunks and
|
||||||
|
# drastically reduces the number of documents, embedding calls, and DB overhead.
|
||||||
|
SLACK_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _build_batch_document_string(
|
||||||
|
team_name: str,
|
||||||
|
team_id: str,
|
||||||
|
channel_name: str,
|
||||||
|
channel_id: str,
|
||||||
|
messages: list[dict],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Combine multiple Slack messages into a single document string.
|
||||||
|
|
||||||
|
Each message is formatted with its timestamp and author, and all messages
|
||||||
|
are concatenated into a conversation-style document. The chunker will
|
||||||
|
later split this into overlapping windows of ~8-10 consecutive messages,
|
||||||
|
preserving conversational context in each chunk's embedding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_name: Name of the Slack workspace
|
||||||
|
team_id: ID of the Slack workspace
|
||||||
|
channel_name: Name of the channel
|
||||||
|
channel_id: ID of the channel
|
||||||
|
messages: List of formatted message dicts with 'user_name', 'datetime', 'text'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted document string with metadata and conversation content
|
||||||
|
"""
|
||||||
|
first_msg_time = messages[0].get("datetime", "Unknown")
|
||||||
|
last_msg_time = messages[-1].get("datetime", "Unknown")
|
||||||
|
|
||||||
|
metadata_lines = [
|
||||||
|
f"WORKSPACE_NAME: {team_name}",
|
||||||
|
f"WORKSPACE_ID: {team_id}",
|
||||||
|
f"CHANNEL_NAME: {channel_name}",
|
||||||
|
f"CHANNEL_ID: {channel_id}",
|
||||||
|
f"MESSAGE_COUNT: {len(messages)}",
|
||||||
|
f"FIRST_MESSAGE_TIME: {first_msg_time}",
|
||||||
|
f"LAST_MESSAGE_TIME: {last_msg_time}",
|
||||||
|
]
|
||||||
|
|
||||||
|
conversation_lines = []
|
||||||
|
for msg in messages:
|
||||||
|
author = msg.get("user_name", "Unknown User")
|
||||||
|
timestamp = msg.get("datetime", "Unknown Time")
|
||||||
|
content = msg.get("text", "")
|
||||||
|
conversation_lines.append(f"[{timestamp}] {author}: {content}")
|
||||||
|
|
||||||
|
metadata_sections = [
|
||||||
|
("METADATA", metadata_lines),
|
||||||
|
(
|
||||||
|
"CONTENT",
|
||||||
|
[
|
||||||
|
"FORMAT: markdown",
|
||||||
|
"TEXT_START",
|
||||||
|
"\n".join(conversation_lines),
|
||||||
|
"TEXT_END",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return build_document_metadata_markdown(metadata_sections)
|
||||||
|
|
||||||
|
|
||||||
async def index_slack_messages(
|
async def index_slack_messages(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -56,6 +125,16 @@ async def index_slack_messages(
|
||||||
"""
|
"""
|
||||||
Index Slack messages from all accessible channels.
|
Index Slack messages from all accessible channels.
|
||||||
|
|
||||||
|
Messages are grouped into batches of SLACK_BATCH_SIZE per channel,
|
||||||
|
so each document contains up to 100 consecutive messages with full
|
||||||
|
conversational context. This reduces document count, embedding calls,
|
||||||
|
and DB overhead by ~100x while improving search quality through
|
||||||
|
context-aware chunk embeddings.
|
||||||
|
|
||||||
|
Implements 2-phase document status updates for real-time UI feedback:
|
||||||
|
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
||||||
|
- Phase 2: Process each document: pending → processing → ready/failed
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
connector_id: ID of the Slack connector
|
connector_id: ID of the Slack connector
|
||||||
|
|
@ -109,6 +188,10 @@ async def index_slack_messages(
|
||||||
f"Connector with ID {connector_id} not found or is not a Slack connector",
|
f"Connector with ID {connector_id} not found or is not a Slack connector",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Extract workspace info from connector config
|
||||||
|
team_id = connector.config.get("team_id", "")
|
||||||
|
team_name = connector.config.get("team_name", "Unknown Workspace")
|
||||||
|
|
||||||
# Note: Token handling is now done automatically by SlackHistory
|
# Note: Token handling is now done automatically by SlackHistory
|
||||||
# with auto-refresh support. We just need to pass session and connector_id.
|
# with auto-refresh support. We just need to pass session and connector_id.
|
||||||
|
|
||||||
|
|
@ -182,6 +265,8 @@ async def index_slack_messages(
|
||||||
documents_indexed = 0
|
documents_indexed = 0
|
||||||
documents_skipped = 0
|
documents_skipped = 0
|
||||||
documents_failed = 0 # Track messages that failed processing
|
documents_failed = 0 # Track messages that failed processing
|
||||||
|
duplicate_content_count = 0
|
||||||
|
total_messages_collected = 0
|
||||||
skipped_channels = []
|
skipped_channels = []
|
||||||
|
|
||||||
# Heartbeat tracking - update notification periodically to prevent appearing stuck
|
# Heartbeat tracking - update notification periodically to prevent appearing stuck
|
||||||
|
|
@ -194,10 +279,12 @@ async def index_slack_messages(
|
||||||
)
|
)
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 1: Collect all messages from all channels, create pending documents
|
# PHASE 1: Collect messages, group into batches, and create pending documents
|
||||||
# This makes ALL documents visible in the UI immediately with pending status
|
# Messages are grouped into batches of SLACK_BATCH_SIZE per channel.
|
||||||
|
# Each batch becomes a single document with full conversational context.
|
||||||
|
# All documents are visible in the UI immediately with pending status.
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
messages_to_process = [] # List of dicts with document and message data
|
batches_to_process = [] # List of dicts with document and batch data
|
||||||
new_documents_created = False
|
new_documents_created = False
|
||||||
|
|
||||||
for channel_obj in channels:
|
for channel_obj in channels:
|
||||||
|
|
@ -264,40 +351,35 @@ async def index_slack_messages(
|
||||||
documents_skipped += 1
|
documents_skipped += 1
|
||||||
continue # Skip if no valid messages after filtering
|
continue # Skip if no valid messages after filtering
|
||||||
|
|
||||||
for msg in formatted_messages:
|
total_messages_collected += len(formatted_messages)
|
||||||
timestamp = msg.get("datetime", "Unknown Time")
|
|
||||||
msg_ts = msg.get("ts", timestamp) # Get original Slack timestamp
|
|
||||||
msg_user_name = msg.get("user_name", "Unknown User")
|
|
||||||
msg_user_email = msg.get("user_email", "Unknown Email")
|
|
||||||
msg_text = msg.get("text", "")
|
|
||||||
|
|
||||||
# Format document metadata
|
# =======================================================
|
||||||
metadata_sections = [
|
# Group messages into batches of SLACK_BATCH_SIZE
|
||||||
(
|
# Each batch becomes a single document with conversation context
|
||||||
"METADATA",
|
# =======================================================
|
||||||
[
|
for batch_start in range(0, len(formatted_messages), SLACK_BATCH_SIZE):
|
||||||
f"CHANNEL_NAME: {channel_name}",
|
batch = formatted_messages[
|
||||||
f"CHANNEL_ID: {channel_id}",
|
batch_start : batch_start + SLACK_BATCH_SIZE
|
||||||
f"MESSAGE_TIMESTAMP: {timestamp}",
|
|
||||||
f"MESSAGE_USER_NAME: {msg_user_name}",
|
|
||||||
f"MESSAGE_USER_EMAIL: {msg_user_email}",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"CONTENT",
|
|
||||||
["FORMAT: markdown", "TEXT_START", msg_text, "TEXT_END"],
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Build the document string
|
# Build combined document string from all messages in this batch
|
||||||
combined_document_string = build_document_metadata_markdown(
|
combined_document_string = _build_batch_document_string(
|
||||||
metadata_sections
|
team_name=team_name,
|
||||||
|
team_id=team_id,
|
||||||
|
channel_name=channel_name,
|
||||||
|
channel_id=channel_id,
|
||||||
|
messages=batch,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate unique identifier hash for this Slack message
|
# Generate unique identifier for this batch using
|
||||||
unique_identifier = f"{channel_id}_{msg_ts}"
|
# channel_id + first message ts + last message ts
|
||||||
|
first_msg_ts = batch[0].get("timestamp", "")
|
||||||
|
last_msg_ts = batch[-1].get("timestamp", "")
|
||||||
|
unique_identifier = f"{channel_id}_{first_msg_ts}_{last_msg_ts}"
|
||||||
unique_identifier_hash = generate_unique_identifier_hash(
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
DocumentType.SLACK_CONNECTOR, unique_identifier, search_space_id
|
DocumentType.SLACK_CONNECTOR,
|
||||||
|
unique_identifier,
|
||||||
|
search_space_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate content hash
|
# Generate content hash
|
||||||
|
|
@ -318,25 +400,31 @@ async def index_slack_messages(
|
||||||
existing_document.status, DocumentStatus.READY
|
existing_document.status, DocumentStatus.READY
|
||||||
):
|
):
|
||||||
existing_document.status = DocumentStatus.ready()
|
existing_document.status = DocumentStatus.ready()
|
||||||
logger.info(
|
|
||||||
f"Document for Slack message {msg_ts} in channel {channel_name} unchanged. Skipping."
|
|
||||||
)
|
|
||||||
documents_skipped += 1
|
documents_skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Queue existing document for update (will be set to processing in Phase 2)
|
# Queue existing document for update (will be set to processing in Phase 2)
|
||||||
messages_to_process.append(
|
batches_to_process.append(
|
||||||
{
|
{
|
||||||
"document": existing_document,
|
"document": existing_document,
|
||||||
"is_new": False,
|
"is_new": False,
|
||||||
"combined_document_string": combined_document_string,
|
"combined_document_string": combined_document_string,
|
||||||
"content_hash": content_hash,
|
"content_hash": content_hash,
|
||||||
|
"team_name": team_name,
|
||||||
|
"team_id": team_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"msg_ts": msg_ts,
|
"first_message_ts": first_msg_ts,
|
||||||
|
"last_message_ts": last_msg_ts,
|
||||||
|
"first_message_time": batch[0].get(
|
||||||
|
"datetime", "Unknown"
|
||||||
|
),
|
||||||
|
"last_message_time": batch[-1].get(
|
||||||
|
"datetime", "Unknown"
|
||||||
|
),
|
||||||
|
"message_count": len(batch),
|
||||||
"start_date": start_date_str,
|
"start_date": start_date_str,
|
||||||
"end_date": end_date_str,
|
"end_date": end_date_str,
|
||||||
"message_count": len(formatted_messages),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
@ -350,22 +438,27 @@ async def index_slack_messages(
|
||||||
|
|
||||||
if duplicate_by_content:
|
if duplicate_by_content:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Slack message {msg_ts} in channel {channel_name} already indexed by another connector "
|
f"Slack batch ({len(batch)} msgs) in {team_name}#{channel_name} already indexed by another connector "
|
||||||
f"(existing document ID: {duplicate_by_content.id}, "
|
f"(existing document ID: {duplicate_by_content.id}, "
|
||||||
f"type: {duplicate_by_content.document_type}). Skipping."
|
f"type: {duplicate_by_content.document_type}). Skipping."
|
||||||
)
|
)
|
||||||
|
duplicate_content_count += 1
|
||||||
documents_skipped += 1
|
documents_skipped += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Create new document with PENDING status (visible in UI immediately)
|
# Create new document with PENDING status (visible in UI immediately)
|
||||||
document = Document(
|
document = Document(
|
||||||
search_space_id=search_space_id,
|
search_space_id=search_space_id,
|
||||||
title=channel_name,
|
title=f"{team_name}#{channel_name}",
|
||||||
document_type=DocumentType.SLACK_CONNECTOR,
|
document_type=DocumentType.SLACK_CONNECTOR,
|
||||||
document_metadata={
|
document_metadata={
|
||||||
|
"team_name": team_name,
|
||||||
|
"team_id": team_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"msg_ts": msg_ts,
|
"first_message_ts": first_msg_ts,
|
||||||
|
"last_message_ts": last_msg_ts,
|
||||||
|
"message_count": len(batch),
|
||||||
"connector_id": connector_id,
|
"connector_id": connector_id,
|
||||||
},
|
},
|
||||||
content="Pending...", # Placeholder until processed
|
content="Pending...", # Placeholder until processed
|
||||||
|
|
@ -381,23 +474,29 @@ async def index_slack_messages(
|
||||||
session.add(document)
|
session.add(document)
|
||||||
new_documents_created = True
|
new_documents_created = True
|
||||||
|
|
||||||
messages_to_process.append(
|
batches_to_process.append(
|
||||||
{
|
{
|
||||||
"document": document,
|
"document": document,
|
||||||
"is_new": True,
|
"is_new": True,
|
||||||
"combined_document_string": combined_document_string,
|
"combined_document_string": combined_document_string,
|
||||||
"content_hash": content_hash,
|
"content_hash": content_hash,
|
||||||
|
"team_name": team_name,
|
||||||
|
"team_id": team_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"msg_ts": msg_ts,
|
"first_message_ts": first_msg_ts,
|
||||||
|
"last_message_ts": last_msg_ts,
|
||||||
|
"first_message_time": batch[0].get("datetime", "Unknown"),
|
||||||
|
"last_message_time": batch[-1].get("datetime", "Unknown"),
|
||||||
|
"message_count": len(batch),
|
||||||
"start_date": start_date_str,
|
"start_date": start_date_str,
|
||||||
"end_date": end_date_str,
|
"end_date": end_date_str,
|
||||||
"message_count": len(formatted_messages),
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Phase 1: Collected {len(formatted_messages)} messages from channel {channel_name}"
|
f"Phase 1: Collected {len(formatted_messages)} messages from channel {channel_name}, "
|
||||||
|
f"grouped into {(len(formatted_messages) + SLACK_BATCH_SIZE - 1) // SLACK_BATCH_SIZE} batch(es)"
|
||||||
)
|
)
|
||||||
|
|
||||||
except SlackApiError as slack_error:
|
except SlackApiError as slack_error:
|
||||||
|
|
@ -416,17 +515,18 @@ async def index_slack_messages(
|
||||||
# Commit all pending documents - they all appear in UI now
|
# Commit all pending documents - they all appear in UI now
|
||||||
if new_documents_created:
|
if new_documents_created:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Phase 1: Committing {len([m for m in messages_to_process if m['is_new']])} pending documents"
|
f"Phase 1: Committing {len([b for b in batches_to_process if b['is_new']])} pending batch documents "
|
||||||
|
f"({total_messages_collected} total messages across all channels)"
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 2: Process each document one by one
|
# PHASE 2: Process each batch document one by one
|
||||||
# Each document transitions: pending → processing → ready/failed
|
# Each document transitions: pending → processing → ready/failed
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
logger.info(f"Phase 2: Processing {len(messages_to_process)} documents")
|
logger.info(f"Phase 2: Processing {len(batches_to_process)} batch documents")
|
||||||
|
|
||||||
for item in messages_to_process:
|
for item in batches_to_process:
|
||||||
# Send heartbeat periodically
|
# Send heartbeat periodically
|
||||||
if on_heartbeat_callback:
|
if on_heartbeat_callback:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
@ -447,16 +547,22 @@ async def index_slack_messages(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update document to READY with actual content
|
# Update document to READY with actual content
|
||||||
document.title = item["channel_name"]
|
document.title = f"{item['team_name']}#{item['channel_name']}"
|
||||||
document.content = item["combined_document_string"]
|
document.content = item["combined_document_string"]
|
||||||
document.content_hash = item["content_hash"]
|
document.content_hash = item["content_hash"]
|
||||||
document.embedding = doc_embedding
|
document.embedding = doc_embedding
|
||||||
document.document_metadata = {
|
document.document_metadata = {
|
||||||
|
"team_name": item["team_name"],
|
||||||
|
"team_id": item["team_id"],
|
||||||
"channel_name": item["channel_name"],
|
"channel_name": item["channel_name"],
|
||||||
"channel_id": item["channel_id"],
|
"channel_id": item["channel_id"],
|
||||||
|
"first_message_ts": item["first_message_ts"],
|
||||||
|
"last_message_ts": item["last_message_ts"],
|
||||||
|
"first_message_time": item["first_message_time"],
|
||||||
|
"last_message_time": item["last_message_time"],
|
||||||
|
"message_count": item["message_count"],
|
||||||
"start_date": item["start_date"],
|
"start_date": item["start_date"],
|
||||||
"end_date": item["end_date"],
|
"end_date": item["end_date"],
|
||||||
"message_count": item["message_count"],
|
|
||||||
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"connector_id": connector_id,
|
"connector_id": connector_id,
|
||||||
}
|
}
|
||||||
|
|
@ -469,13 +575,13 @@ async def index_slack_messages(
|
||||||
# Batch commit every 10 documents (for ready status updates)
|
# Batch commit every 10 documents (for ready status updates)
|
||||||
if documents_indexed % 10 == 0:
|
if documents_indexed % 10 == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Committing batch: {documents_indexed} Slack messages processed so far"
|
f"Committing batch: {documents_indexed} batch documents processed so far"
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error processing Slack message {item.get('msg_ts', 'Unknown')}: {e!s}",
|
f"Error processing Slack batch document: {e!s}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
# Mark document as failed with reason (visible in UI)
|
# Mark document as failed with reason (visible in UI)
|
||||||
|
|
@ -493,7 +599,10 @@ async def index_slack_messages(
|
||||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||||
|
|
||||||
# Final commit for any remaining documents not yet committed in batches
|
# Final commit for any remaining documents not yet committed in batches
|
||||||
logger.info(f"Final commit: Total {documents_indexed} Slack messages processed")
|
logger.info(
|
||||||
|
f"Final commit: Total {documents_indexed} batch documents processed "
|
||||||
|
f"(from {total_messages_collected} messages)"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info("Successfully committed all Slack document changes to database")
|
logger.info("Successfully committed all Slack document changes to database")
|
||||||
|
|
@ -514,8 +623,12 @@ async def index_slack_messages(
|
||||||
|
|
||||||
# Build warning message if there were issues
|
# Build warning message if there were issues
|
||||||
warning_parts = []
|
warning_parts = []
|
||||||
|
if duplicate_content_count > 0:
|
||||||
|
warning_parts.append(f"{duplicate_content_count} duplicate")
|
||||||
if documents_failed > 0:
|
if documents_failed > 0:
|
||||||
warning_parts.append(f"{documents_failed} failed")
|
warning_parts.append(f"{documents_failed} failed")
|
||||||
|
if skipped_channels:
|
||||||
|
warning_parts.append(f"{len(skipped_channels)} channels skipped")
|
||||||
warning_message = ", ".join(warning_parts) if warning_parts else None
|
warning_message = ", ".join(warning_parts) if warning_parts else None
|
||||||
|
|
||||||
# Log success
|
# Log success
|
||||||
|
|
@ -527,13 +640,20 @@ async def index_slack_messages(
|
||||||
"documents_indexed": documents_indexed,
|
"documents_indexed": documents_indexed,
|
||||||
"documents_skipped": documents_skipped,
|
"documents_skipped": documents_skipped,
|
||||||
"documents_failed": documents_failed,
|
"documents_failed": documents_failed,
|
||||||
|
"duplicate_content_count": duplicate_content_count,
|
||||||
"skipped_channels_count": len(skipped_channels),
|
"skipped_channels_count": len(skipped_channels),
|
||||||
|
"total_messages_collected": total_messages_collected,
|
||||||
|
"batch_size": SLACK_BATCH_SIZE,
|
||||||
|
"team_id": team_id,
|
||||||
|
"team_name": team_name,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Slack indexing completed: {documents_indexed} ready, "
|
f"Slack indexing completed for workspace {team_name}: "
|
||||||
|
f"{documents_indexed} batch docs ready (from {total_messages_collected} messages), "
|
||||||
f"{documents_skipped} skipped, {documents_failed} failed "
|
f"{documents_skipped} skipped, {documents_failed} failed "
|
||||||
|
f"({duplicate_content_count} duplicate content)"
|
||||||
)
|
)
|
||||||
return documents_indexed, warning_message
|
return documents_indexed, warning_message
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
"""
|
"""
|
||||||
Microsoft Teams connector indexer.
|
Microsoft Teams connector indexer.
|
||||||
|
|
||||||
Implements 2-phase document status updates for real-time UI feedback:
|
Implements batch indexing: groups up to TEAMS_BATCH_SIZE messages per channel
|
||||||
|
into a single document for efficient indexing and better conversational context.
|
||||||
|
|
||||||
|
Uses 2-phase document status updates for real-time UI feedback:
|
||||||
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
||||||
- Phase 2: Process each document: pending → processing → ready/failed
|
- Phase 2: Process each document: pending → processing → ready/failed
|
||||||
"""
|
"""
|
||||||
|
|
@ -41,6 +44,72 @@ HeartbeatCallbackType = Callable[[int], Awaitable[None]]
|
||||||
# Heartbeat interval in seconds - update notification every 30 seconds
|
# Heartbeat interval in seconds - update notification every 30 seconds
|
||||||
HEARTBEAT_INTERVAL_SECONDS = 30
|
HEARTBEAT_INTERVAL_SECONDS = 30
|
||||||
|
|
||||||
|
# Number of messages to combine into a single document for batch indexing.
|
||||||
|
# Grouping messages improves conversational context in embeddings/chunks and
|
||||||
|
# drastically reduces the number of documents, embedding calls, and DB overhead.
|
||||||
|
TEAMS_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
|
||||||
|
def _build_batch_document_string(
|
||||||
|
team_name: str,
|
||||||
|
team_id: str,
|
||||||
|
channel_name: str,
|
||||||
|
channel_id: str,
|
||||||
|
messages: list[dict],
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Combine multiple Teams messages into a single document string.
|
||||||
|
|
||||||
|
Each message is formatted with its timestamp and author, and all messages
|
||||||
|
are concatenated into a conversation-style document. The chunker will
|
||||||
|
later split this into overlapping windows of ~8-10 consecutive messages,
|
||||||
|
preserving conversational context in each chunk's embedding.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
team_name: Name of the Microsoft Team
|
||||||
|
team_id: ID of the Microsoft Team
|
||||||
|
channel_name: Name of the channel
|
||||||
|
channel_id: ID of the channel
|
||||||
|
messages: List of formatted message dicts with 'user_name', 'created_datetime', 'content'
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted document string with metadata and conversation content
|
||||||
|
"""
|
||||||
|
first_msg_time = messages[0].get("created_datetime", "Unknown")
|
||||||
|
last_msg_time = messages[-1].get("created_datetime", "Unknown")
|
||||||
|
|
||||||
|
metadata_lines = [
|
||||||
|
f"TEAM_NAME: {team_name}",
|
||||||
|
f"TEAM_ID: {team_id}",
|
||||||
|
f"CHANNEL_NAME: {channel_name}",
|
||||||
|
f"CHANNEL_ID: {channel_id}",
|
||||||
|
f"MESSAGE_COUNT: {len(messages)}",
|
||||||
|
f"FIRST_MESSAGE_TIME: {first_msg_time}",
|
||||||
|
f"LAST_MESSAGE_TIME: {last_msg_time}",
|
||||||
|
]
|
||||||
|
|
||||||
|
conversation_lines = []
|
||||||
|
for msg in messages:
|
||||||
|
author = msg.get("user_name", "Unknown User")
|
||||||
|
timestamp = msg.get("created_datetime", "Unknown Time")
|
||||||
|
content = msg.get("content", "")
|
||||||
|
conversation_lines.append(f"[{timestamp}] {author}: {content}")
|
||||||
|
|
||||||
|
metadata_sections = [
|
||||||
|
("METADATA", metadata_lines),
|
||||||
|
(
|
||||||
|
"CONTENT",
|
||||||
|
[
|
||||||
|
"FORMAT: markdown",
|
||||||
|
"TEXT_START",
|
||||||
|
"\n".join(conversation_lines),
|
||||||
|
"TEXT_END",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
return build_document_metadata_markdown(metadata_sections)
|
||||||
|
|
||||||
|
|
||||||
async def index_teams_messages(
|
async def index_teams_messages(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -55,6 +124,12 @@ async def index_teams_messages(
|
||||||
"""
|
"""
|
||||||
Index Microsoft Teams messages from all accessible teams and channels.
|
Index Microsoft Teams messages from all accessible teams and channels.
|
||||||
|
|
||||||
|
Messages are grouped into batches of TEAMS_BATCH_SIZE per channel,
|
||||||
|
so each document contains up to 100 consecutive messages with full
|
||||||
|
conversational context. This reduces document count, embedding calls,
|
||||||
|
and DB overhead by ~100x while improving search quality through
|
||||||
|
context-aware chunk embeddings.
|
||||||
|
|
||||||
Implements 2-phase document status updates for real-time UI feedback:
|
Implements 2-phase document status updates for real-time UI feedback:
|
||||||
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
- Phase 1: Create all documents with 'pending' status (visible in UI immediately)
|
||||||
- Phase 2: Process each document: pending → processing → ready/failed
|
- Phase 2: Process each document: pending → processing → ready/failed
|
||||||
|
|
@ -184,6 +259,7 @@ async def index_teams_messages(
|
||||||
documents_skipped = 0
|
documents_skipped = 0
|
||||||
documents_failed = 0
|
documents_failed = 0
|
||||||
duplicate_content_count = 0
|
duplicate_content_count = 0
|
||||||
|
total_messages_collected = 0
|
||||||
skipped_channels = []
|
skipped_channels = []
|
||||||
|
|
||||||
# Heartbeat tracking - update notification periodically to prevent appearing stuck
|
# Heartbeat tracking - update notification periodically to prevent appearing stuck
|
||||||
|
|
@ -199,21 +275,21 @@ async def index_teams_messages(
|
||||||
start_datetime = None
|
start_datetime = None
|
||||||
end_datetime = None
|
end_datetime = None
|
||||||
if start_date_str:
|
if start_date_str:
|
||||||
# Parse as naive datetime and make it timezone-aware (UTC)
|
|
||||||
start_datetime = datetime.strptime(start_date_str, "%Y-%m-%d").replace(
|
start_datetime = datetime.strptime(start_date_str, "%Y-%m-%d").replace(
|
||||||
tzinfo=UTC
|
tzinfo=UTC
|
||||||
)
|
)
|
||||||
if end_date_str:
|
if end_date_str:
|
||||||
# Parse as naive datetime, set to end of day, and make it timezone-aware (UTC)
|
|
||||||
end_datetime = datetime.strptime(end_date_str, "%Y-%m-%d").replace(
|
end_datetime = datetime.strptime(end_date_str, "%Y-%m-%d").replace(
|
||||||
hour=23, minute=59, second=59, tzinfo=UTC
|
hour=23, minute=59, second=59, tzinfo=UTC
|
||||||
)
|
)
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 1: Collect all messages and create pending documents
|
# PHASE 1: Collect messages, group into batches, and create pending documents
|
||||||
# This makes ALL documents visible in the UI immediately with pending status
|
# Messages are grouped into batches of TEAMS_BATCH_SIZE per channel.
|
||||||
|
# Each batch becomes a single document with full conversational context.
|
||||||
|
# All documents are visible in the UI immediately with pending status.
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
messages_to_process = [] # List of dicts with document and message data
|
batches_to_process = [] # List of dicts with document and batch data
|
||||||
new_documents_created = False
|
new_documents_created = False
|
||||||
|
|
||||||
for team in teams:
|
for team in teams:
|
||||||
|
|
@ -251,65 +327,72 @@ async def index_teams_messages(
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Process each message
|
# Format messages for batching
|
||||||
|
formatted_messages = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
# Skip deleted messages or empty content
|
# Skip deleted messages or empty content
|
||||||
if msg.get("deletedDateTime"):
|
if msg.get("deletedDateTime"):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Extract message details
|
|
||||||
message_id = msg.get("id", "")
|
|
||||||
created_datetime = msg.get("createdDateTime", "")
|
|
||||||
from_user = msg.get("from", {})
|
from_user = msg.get("from", {})
|
||||||
user_name = from_user.get("user", {}).get(
|
user_name = from_user.get("user", {}).get(
|
||||||
"displayName", "Unknown User"
|
"displayName", "Unknown User"
|
||||||
)
|
)
|
||||||
user_email = from_user.get("user", {}).get(
|
|
||||||
"userPrincipalName", "Unknown Email"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract message content
|
|
||||||
body = msg.get("body", {})
|
body = msg.get("body", {})
|
||||||
content_type = body.get("contentType", "text")
|
|
||||||
msg_text = body.get("content", "")
|
msg_text = body.get("content", "")
|
||||||
|
|
||||||
# Skip empty messages
|
# Skip empty messages
|
||||||
if not msg_text or msg_text.strip() == "":
|
if not msg_text or msg_text.strip() == "":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Format document metadata
|
formatted_messages.append(
|
||||||
metadata_sections = [
|
{
|
||||||
(
|
"message_id": msg.get("id", ""),
|
||||||
"METADATA",
|
"created_datetime": msg.get("createdDateTime", ""),
|
||||||
[
|
"user_name": user_name,
|
||||||
f"TEAM_NAME: {team_name}",
|
"content": msg_text,
|
||||||
f"TEAM_ID: {team_id}",
|
}
|
||||||
f"CHANNEL_NAME: {channel_name}",
|
|
||||||
f"CHANNEL_ID: {channel_id}",
|
|
||||||
f"MESSAGE_TIMESTAMP: {created_datetime}",
|
|
||||||
f"MESSAGE_USER_NAME: {user_name}",
|
|
||||||
f"MESSAGE_USER_EMAIL: {user_email}",
|
|
||||||
f"CONTENT_TYPE: {content_type}",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"CONTENT",
|
|
||||||
[
|
|
||||||
f"FORMAT: {content_type}",
|
|
||||||
"TEXT_START",
|
|
||||||
msg_text,
|
|
||||||
"TEXT_END",
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Build the document string
|
|
||||||
combined_document_string = build_document_metadata_markdown(
|
|
||||||
metadata_sections
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate unique identifier hash for this Teams message
|
if not formatted_messages:
|
||||||
unique_identifier = f"{team_id}_{channel_id}_{message_id}"
|
logger.info(
|
||||||
|
"No valid messages found in channel %s of team %s after filtering.",
|
||||||
|
channel_name,
|
||||||
|
team_name,
|
||||||
|
)
|
||||||
|
documents_skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_messages_collected += len(formatted_messages)
|
||||||
|
|
||||||
|
# =======================================================
|
||||||
|
# Group messages into batches of TEAMS_BATCH_SIZE
|
||||||
|
# Each batch becomes a single document with conversation context
|
||||||
|
# =======================================================
|
||||||
|
for batch_start in range(
|
||||||
|
0, len(formatted_messages), TEAMS_BATCH_SIZE
|
||||||
|
):
|
||||||
|
batch = formatted_messages[
|
||||||
|
batch_start : batch_start + TEAMS_BATCH_SIZE
|
||||||
|
]
|
||||||
|
|
||||||
|
# Build combined document string from all messages in this batch
|
||||||
|
combined_document_string = _build_batch_document_string(
|
||||||
|
team_name=team_name,
|
||||||
|
team_id=team_id,
|
||||||
|
channel_name=channel_name,
|
||||||
|
channel_id=channel_id,
|
||||||
|
messages=batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate unique identifier for this batch using
|
||||||
|
# team_id + channel_id + first message id + last message id
|
||||||
|
first_msg_id = batch[0].get("message_id", "")
|
||||||
|
last_msg_id = batch[-1].get("message_id", "")
|
||||||
|
unique_identifier = (
|
||||||
|
f"{team_id}_{channel_id}_{first_msg_id}_{last_msg_id}"
|
||||||
|
)
|
||||||
unique_identifier_hash = generate_unique_identifier_hash(
|
unique_identifier_hash = generate_unique_identifier_hash(
|
||||||
DocumentType.TEAMS_CONNECTOR,
|
DocumentType.TEAMS_CONNECTOR,
|
||||||
unique_identifier,
|
unique_identifier,
|
||||||
|
|
@ -331,7 +414,6 @@ async def index_teams_messages(
|
||||||
if existing_document:
|
if existing_document:
|
||||||
# Document exists - check if content has changed
|
# Document exists - check if content has changed
|
||||||
if existing_document.content_hash == content_hash:
|
if existing_document.content_hash == content_hash:
|
||||||
# Ensure status is ready (might have been stuck in processing/pending)
|
|
||||||
if not DocumentStatus.is_state(
|
if not DocumentStatus.is_state(
|
||||||
existing_document.status, DocumentStatus.READY
|
existing_document.status, DocumentStatus.READY
|
||||||
):
|
):
|
||||||
|
|
@ -342,7 +424,7 @@ async def index_teams_messages(
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Queue existing document for update (will be set to processing in Phase 2)
|
# Queue existing document for update (will be set to processing in Phase 2)
|
||||||
messages_to_process.append(
|
batches_to_process.append(
|
||||||
{
|
{
|
||||||
"document": existing_document,
|
"document": existing_document,
|
||||||
"is_new": False,
|
"is_new": False,
|
||||||
|
|
@ -352,14 +434,21 @@ async def index_teams_messages(
|
||||||
"team_id": team_id,
|
"team_id": team_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"message_id": message_id,
|
"first_message_id": first_msg_id,
|
||||||
|
"last_message_id": last_msg_id,
|
||||||
|
"first_message_time": batch[0].get(
|
||||||
|
"created_datetime", "Unknown"
|
||||||
|
),
|
||||||
|
"last_message_time": batch[-1].get(
|
||||||
|
"created_datetime", "Unknown"
|
||||||
|
),
|
||||||
|
"message_count": len(batch),
|
||||||
"start_date": start_date_str,
|
"start_date": start_date_str,
|
||||||
"end_date": end_date_str,
|
"end_date": end_date_str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Document doesn't exist by unique_identifier_hash
|
|
||||||
# Check if a document with the same content_hash exists (from another connector)
|
# Check if a document with the same content_hash exists (from another connector)
|
||||||
with session.no_autoflush:
|
with session.no_autoflush:
|
||||||
duplicate_by_content = (
|
duplicate_by_content = (
|
||||||
|
|
@ -370,9 +459,10 @@ async def index_teams_messages(
|
||||||
|
|
||||||
if duplicate_by_content:
|
if duplicate_by_content:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Teams message %s in channel %s already indexed by another connector "
|
"Teams batch (%s msgs) in %s/%s already indexed by another connector "
|
||||||
"(existing document ID: %s, type: %s). Skipping.",
|
"(existing document ID: %s, type: %s). Skipping.",
|
||||||
message_id,
|
len(batch),
|
||||||
|
team_name,
|
||||||
channel_name,
|
channel_name,
|
||||||
duplicate_by_content.id,
|
duplicate_by_content.id,
|
||||||
duplicate_by_content.document_type,
|
duplicate_by_content.document_type,
|
||||||
|
|
@ -391,6 +481,9 @@ async def index_teams_messages(
|
||||||
"team_id": team_id,
|
"team_id": team_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
|
"first_message_id": first_msg_id,
|
||||||
|
"last_message_id": last_msg_id,
|
||||||
|
"message_count": len(batch),
|
||||||
"connector_id": connector_id,
|
"connector_id": connector_id,
|
||||||
},
|
},
|
||||||
content="Pending...", # Placeholder until processed
|
content="Pending...", # Placeholder until processed
|
||||||
|
|
@ -406,7 +499,7 @@ async def index_teams_messages(
|
||||||
session.add(document)
|
session.add(document)
|
||||||
new_documents_created = True
|
new_documents_created = True
|
||||||
|
|
||||||
messages_to_process.append(
|
batches_to_process.append(
|
||||||
{
|
{
|
||||||
"document": document,
|
"document": document,
|
||||||
"is_new": True,
|
"is_new": True,
|
||||||
|
|
@ -416,12 +509,30 @@ async def index_teams_messages(
|
||||||
"team_id": team_id,
|
"team_id": team_id,
|
||||||
"channel_name": channel_name,
|
"channel_name": channel_name,
|
||||||
"channel_id": channel_id,
|
"channel_id": channel_id,
|
||||||
"message_id": message_id,
|
"first_message_id": first_msg_id,
|
||||||
|
"last_message_id": last_msg_id,
|
||||||
|
"first_message_time": batch[0].get(
|
||||||
|
"created_datetime", "Unknown"
|
||||||
|
),
|
||||||
|
"last_message_time": batch[-1].get(
|
||||||
|
"created_datetime", "Unknown"
|
||||||
|
),
|
||||||
|
"message_count": len(batch),
|
||||||
"start_date": start_date_str,
|
"start_date": start_date_str,
|
||||||
"end_date": end_date_str,
|
"end_date": end_date_str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Phase 1: Collected %s messages from %s/%s, "
|
||||||
|
"grouped into %s batch(es)",
|
||||||
|
len(formatted_messages),
|
||||||
|
team_name,
|
||||||
|
channel_name,
|
||||||
|
(len(formatted_messages) + TEAMS_BATCH_SIZE - 1)
|
||||||
|
// TEAMS_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Error processing channel %s in team %s: %s",
|
"Error processing channel %s in team %s: %s",
|
||||||
|
|
@ -441,17 +552,20 @@ async def index_teams_messages(
|
||||||
# Commit all pending documents - they all appear in UI now
|
# Commit all pending documents - they all appear in UI now
|
||||||
if new_documents_created:
|
if new_documents_created:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Phase 1: Committing {len([m for m in messages_to_process if m['is_new']])} pending documents"
|
"Phase 1: Committing %s pending batch documents "
|
||||||
|
"(%s total messages across all channels)",
|
||||||
|
len([b for b in batches_to_process if b["is_new"]]),
|
||||||
|
total_messages_collected,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
# PHASE 2: Process each document one by one
|
# PHASE 2: Process each batch document one by one
|
||||||
# Each document transitions: pending → processing → ready/failed
|
# Each document transitions: pending → processing → ready/failed
|
||||||
# =======================================================================
|
# =======================================================================
|
||||||
logger.info(f"Phase 2: Processing {len(messages_to_process)} documents")
|
logger.info("Phase 2: Processing %s batch documents", len(batches_to_process))
|
||||||
|
|
||||||
for item in messages_to_process:
|
for item in batches_to_process:
|
||||||
# Send heartbeat periodically
|
# Send heartbeat periodically
|
||||||
if on_heartbeat_callback:
|
if on_heartbeat_callback:
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
|
|
@ -481,6 +595,11 @@ async def index_teams_messages(
|
||||||
"team_id": item["team_id"],
|
"team_id": item["team_id"],
|
||||||
"channel_name": item["channel_name"],
|
"channel_name": item["channel_name"],
|
||||||
"channel_id": item["channel_id"],
|
"channel_id": item["channel_id"],
|
||||||
|
"first_message_id": item["first_message_id"],
|
||||||
|
"last_message_id": item["last_message_id"],
|
||||||
|
"first_message_time": item["first_message_time"],
|
||||||
|
"last_message_time": item["last_message_time"],
|
||||||
|
"message_count": item["message_count"],
|
||||||
"start_date": item["start_date"],
|
"start_date": item["start_date"],
|
||||||
"end_date": item["end_date"],
|
"end_date": item["end_date"],
|
||||||
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
|
@ -495,20 +614,25 @@ async def index_teams_messages(
|
||||||
# Batch commit every 10 documents (for ready status updates)
|
# Batch commit every 10 documents (for ready status updates)
|
||||||
if documents_indexed % 10 == 0:
|
if documents_indexed % 10 == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Committing batch: %s Teams messages processed so far",
|
"Committing batch: %s Teams batch documents processed so far",
|
||||||
documents_indexed,
|
documents_indexed,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error processing Teams message: {e!s}", exc_info=True)
|
logger.error(
|
||||||
|
"Error processing Teams batch document: %s",
|
||||||
|
str(e),
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
# Mark document as failed with reason (visible in UI)
|
# Mark document as failed with reason (visible in UI)
|
||||||
try:
|
try:
|
||||||
document.status = DocumentStatus.failed(str(e))
|
document.status = DocumentStatus.failed(str(e))
|
||||||
document.updated_at = get_current_timestamp()
|
document.updated_at = get_current_timestamp()
|
||||||
except Exception as status_error:
|
except Exception as status_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to update document status to failed: {status_error}"
|
"Failed to update document status to failed: %s",
|
||||||
|
str(status_error),
|
||||||
)
|
)
|
||||||
documents_failed += 1
|
documents_failed += 1
|
||||||
continue
|
continue
|
||||||
|
|
@ -518,7 +642,9 @@ async def index_teams_messages(
|
||||||
|
|
||||||
# Final commit for any remaining documents not yet committed in batches
|
# Final commit for any remaining documents not yet committed in batches
|
||||||
logger.info(
|
logger.info(
|
||||||
"Final commit: Total %s Teams messages processed", documents_indexed
|
"Final commit: Total %s Teams batch documents processed (from %s messages)",
|
||||||
|
documents_indexed,
|
||||||
|
total_messages_collected,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
@ -530,8 +656,9 @@ async def index_teams_messages(
|
||||||
or "uniqueviolationerror" in str(e).lower()
|
or "uniqueviolationerror" in str(e).lower()
|
||||||
):
|
):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Duplicate content_hash detected during final commit. "
|
"Duplicate content_hash detected during final commit. "
|
||||||
f"Rolling back and continuing. Error: {e!s}"
|
"Rolling back and continuing. Error: %s",
|
||||||
|
str(e),
|
||||||
)
|
)
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
else:
|
else:
|
||||||
|
|
@ -557,13 +684,16 @@ async def index_teams_messages(
|
||||||
"documents_failed": documents_failed,
|
"documents_failed": documents_failed,
|
||||||
"duplicate_content_count": duplicate_content_count,
|
"duplicate_content_count": duplicate_content_count,
|
||||||
"skipped_channels_count": len(skipped_channels),
|
"skipped_channels_count": len(skipped_channels),
|
||||||
|
"total_messages_collected": total_messages_collected,
|
||||||
|
"batch_size": TEAMS_BATCH_SIZE,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Teams indexing completed: %s ready, %s skipped, %s failed "
|
"Teams indexing completed: %s batch docs ready (from %s messages), "
|
||||||
"(%s duplicate content)",
|
"%s skipped, %s failed (%s duplicate content)",
|
||||||
documents_indexed,
|
documents_indexed,
|
||||||
|
total_messages_collected,
|
||||||
documents_skipped,
|
documents_skipped,
|
||||||
documents_failed,
|
documents_failed,
|
||||||
duplicate_content_count,
|
duplicate_content_count,
|
||||||
|
|
|
||||||
|
|
@ -38,10 +38,22 @@ def safe_set_chunks(document: Document, chunks: list) -> None:
|
||||||
# Instead of: document.chunks = chunks (DANGEROUS!)
|
# Instead of: document.chunks = chunks (DANGEROUS!)
|
||||||
safe_set_chunks(document, chunks) # Always safe
|
safe_set_chunks(document, chunks) # Always safe
|
||||||
"""
|
"""
|
||||||
|
from sqlalchemy.orm import object_session
|
||||||
from sqlalchemy.orm.attributes import set_committed_value
|
from sqlalchemy.orm.attributes import set_committed_value
|
||||||
|
|
||||||
|
# Keep relationship assignment lazy-load-safe.
|
||||||
set_committed_value(document, "chunks", chunks)
|
set_committed_value(document, "chunks", chunks)
|
||||||
|
|
||||||
|
# Ensure chunk rows are actually persisted.
|
||||||
|
# set_committed_value bypasses normal unit-of-work tracking, so we need to
|
||||||
|
# explicitly attach chunk objects to the current session.
|
||||||
|
session = object_session(document)
|
||||||
|
if session is not None:
|
||||||
|
if document.id is not None:
|
||||||
|
for chunk in chunks:
|
||||||
|
chunk.document_id = document.id
|
||||||
|
session.add_all(chunks)
|
||||||
|
|
||||||
|
|
||||||
def get_current_timestamp() -> datetime:
|
def get_current_timestamp() -> datetime:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,17 @@ Message content in new_chat_messages can be stored in various formats:
|
||||||
These utilities help extract and transform content for different use cases.
|
These utilities help extract and transform content for different use cases.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from langchain_core.messages import AIMessage, HumanMessage
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.db import ChatVisibility
|
||||||
|
|
||||||
|
|
||||||
def extract_text_content(content: str | dict | list) -> str:
|
def extract_text_content(content: str | dict | list) -> str:
|
||||||
|
|
@ -38,6 +46,7 @@ def extract_text_content(content: str | dict | list) -> str:
|
||||||
async def bootstrap_history_from_db(
|
async def bootstrap_history_from_db(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
thread_id: int,
|
thread_id: int,
|
||||||
|
thread_visibility: ChatVisibility | None = None,
|
||||||
) -> list[HumanMessage | AIMessage]:
|
) -> list[HumanMessage | AIMessage]:
|
||||||
"""
|
"""
|
||||||
Load message history from database and convert to LangChain format.
|
Load message history from database and convert to LangChain format.
|
||||||
|
|
@ -45,20 +54,28 @@ async def bootstrap_history_from_db(
|
||||||
Used for cloned chats where the LangGraph checkpointer has no state,
|
Used for cloned chats where the LangGraph checkpointer has no state,
|
||||||
but we have messages in the database that should be used as context.
|
but we have messages in the database that should be used as context.
|
||||||
|
|
||||||
|
When thread_visibility is SEARCH_SPACE, user messages are prefixed with
|
||||||
|
the author's display name so the LLM sees who said what.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
session: Database session
|
session: Database session
|
||||||
thread_id: The chat thread ID
|
thread_id: The chat thread ID
|
||||||
|
thread_visibility: When SEARCH_SPACE, user messages get author prefix
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of LangChain messages (HumanMessage/AIMessage)
|
List of LangChain messages (HumanMessage/AIMessage)
|
||||||
"""
|
"""
|
||||||
from app.db import NewChatMessage
|
from app.db import ChatVisibility, NewChatMessage
|
||||||
|
|
||||||
result = await session.execute(
|
is_shared = thread_visibility == ChatVisibility.SEARCH_SPACE
|
||||||
|
stmt = (
|
||||||
select(NewChatMessage)
|
select(NewChatMessage)
|
||||||
.filter(NewChatMessage.thread_id == thread_id)
|
.filter(NewChatMessage.thread_id == thread_id)
|
||||||
.order_by(NewChatMessage.created_at)
|
.order_by(NewChatMessage.created_at)
|
||||||
)
|
)
|
||||||
|
if is_shared:
|
||||||
|
stmt = stmt.options(selectinload(NewChatMessage.author))
|
||||||
|
result = await session.execute(stmt)
|
||||||
db_messages = result.scalars().all()
|
db_messages = result.scalars().all()
|
||||||
|
|
||||||
langchain_messages: list[HumanMessage | AIMessage] = []
|
langchain_messages: list[HumanMessage | AIMessage] = []
|
||||||
|
|
@ -68,6 +85,11 @@ async def bootstrap_history_from_db(
|
||||||
if not text_content:
|
if not text_content:
|
||||||
continue
|
continue
|
||||||
if msg.role == "user":
|
if msg.role == "user":
|
||||||
|
if is_shared:
|
||||||
|
author_name = (
|
||||||
|
msg.author.display_name if msg.author else None
|
||||||
|
) or "A team member"
|
||||||
|
text_content = f"**[{author_name}]:** {text_content}"
|
||||||
langchain_messages.append(HumanMessage(content=text_content))
|
langchain_messages.append(HumanMessage(content=text_content))
|
||||||
elif msg.role == "assistant":
|
elif msg.role == "assistant":
|
||||||
langchain_messages.append(AIMessage(content=text_content))
|
langchain_messages.append(AIMessage(content=text_content))
|
||||||
|
|
|
||||||
46
surfsense_backend/app/utils/indexing_locks.py
Normal file
46
surfsense_backend/app/utils/indexing_locks.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Redis-based connector indexing locks to prevent duplicate sync tasks."""
|
||||||
|
|
||||||
|
import redis
|
||||||
|
|
||||||
|
from app.config import config
|
||||||
|
|
||||||
|
_redis_client: redis.Redis | None = None
|
||||||
|
LOCK_TTL_SECONDS = config.CONNECTOR_INDEXING_LOCK_TTL_SECONDS
|
||||||
|
|
||||||
|
|
||||||
|
def get_indexing_lock_redis_client() -> redis.Redis:
|
||||||
|
"""Get or create Redis client for connector indexing locks."""
|
||||||
|
global _redis_client
|
||||||
|
if _redis_client is None:
|
||||||
|
_redis_client = redis.from_url(config.REDIS_APP_URL, decode_responses=True)
|
||||||
|
return _redis_client
|
||||||
|
|
||||||
|
|
||||||
|
def _get_connector_lock_key(connector_id: int) -> str:
|
||||||
|
"""Generate Redis key for a connector indexing lock."""
|
||||||
|
return f"indexing:connector_lock:{connector_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_connector_indexing_lock(connector_id: int) -> bool:
|
||||||
|
"""Acquire lock for connector indexing. Returns True if acquired."""
|
||||||
|
key = _get_connector_lock_key(connector_id)
|
||||||
|
return bool(
|
||||||
|
get_indexing_lock_redis_client().set(
|
||||||
|
key,
|
||||||
|
"1",
|
||||||
|
nx=True,
|
||||||
|
ex=LOCK_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def release_connector_indexing_lock(connector_id: int) -> None:
|
||||||
|
"""Release lock for connector indexing."""
|
||||||
|
key = _get_connector_lock_key(connector_id)
|
||||||
|
get_indexing_lock_redis_client().delete(key)
|
||||||
|
|
||||||
|
|
||||||
|
def is_connector_indexing_locked(connector_id: int) -> bool:
|
||||||
|
"""Check if connector indexing lock exists."""
|
||||||
|
key = _get_connector_lock_key(connector_id)
|
||||||
|
return bool(get_indexing_lock_redis_client().exists(key))
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.12"
|
version = "0.0.13"
|
||||||
description = "SurfSense Backend"
|
description = "SurfSense Backend"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|
|
||||||
2
surfsense_backend/uv.lock
generated
2
surfsense_backend/uv.lock
generated
|
|
@ -6825,7 +6825,7 @@ wheels = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "surf-new-backend"
|
name = "surf-new-backend"
|
||||||
version = "0.0.12"
|
version = "0.0.13"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "alembic" },
|
{ name = "alembic" },
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_browser_extension",
|
"name": "surfsense_browser_extension",
|
||||||
"displayName": "Surfsense Browser Extension",
|
"displayName": "Surfsense Browser Extension",
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"description": "Extension to collect Browsing History for SurfSense.",
|
"description": "Extension to collect Browsing History for SurfSense.",
|
||||||
"author": "https://github.com/MODSetter",
|
"author": "https://github.com/MODSetter",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ import { trackLoginAttempt, trackLoginFailure, trackLoginSuccess } from "@/lib/p
|
||||||
|
|
||||||
export function LocalLoginForm() {
|
export function LocalLoginForm() {
|
||||||
const t = useTranslations("auth");
|
const t = useTranslations("auth");
|
||||||
const tCommon = useTranslations("common");
|
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
|
@ -58,12 +57,6 @@ export function LocalLoginForm() {
|
||||||
sessionStorage.setItem("login_success_tracked", "true");
|
sessionStorage.setItem("login_success_tracked", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success toast
|
|
||||||
toast.success(t("login_success"), {
|
|
||||||
description: "Redirecting to dashboard",
|
|
||||||
duration: 2000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Small delay to show success message
|
// Small delay to show success message
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push(`/auth/callback?token=${data.access_token}`);
|
router.push(`/auth/callback?token=${data.access_token}`);
|
||||||
|
|
@ -103,7 +96,7 @@ export function LocalLoginForm() {
|
||||||
<form onSubmit={handleSubmit} className="space-y-3 md:space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-3 md:space-y-4">
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{error && error.title && (
|
{error?.title && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,51 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useRef, useState, useEffect } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||||
|
|
||||||
export function getDocumentTypeIcon(type: string, className?: string): React.ReactNode {
|
export function getDocumentTypeIcon(type: string, className?: string): React.ReactNode {
|
||||||
return getConnectorIcon(type, className);
|
return getConnectorIcon(type, className);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDocumentTypeLabel(type: string): string {
|
export function getDocumentTypeLabel(type: string): string {
|
||||||
return type
|
const labelMap: Record<string, string> = {
|
||||||
|
EXTENSION: "Extension",
|
||||||
|
CRAWLED_URL: "Web Page",
|
||||||
|
FILE: "File",
|
||||||
|
SLACK_CONNECTOR: "Slack",
|
||||||
|
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||||
|
NOTION_CONNECTOR: "Notion",
|
||||||
|
YOUTUBE_VIDEO: "YouTube Video",
|
||||||
|
GITHUB_CONNECTOR: "GitHub",
|
||||||
|
LINEAR_CONNECTOR: "Linear",
|
||||||
|
DISCORD_CONNECTOR: "Discord",
|
||||||
|
JIRA_CONNECTOR: "Jira",
|
||||||
|
CONFLUENCE_CONNECTOR: "Confluence",
|
||||||
|
CLICKUP_CONNECTOR: "ClickUp",
|
||||||
|
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||||
|
GOOGLE_GMAIL_CONNECTOR: "Gmail",
|
||||||
|
GOOGLE_DRIVE_FILE: "Google Drive",
|
||||||
|
AIRTABLE_CONNECTOR: "Airtable",
|
||||||
|
LUMA_CONNECTOR: "Luma",
|
||||||
|
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||||
|
BOOKSTACK_CONNECTOR: "BookStack",
|
||||||
|
CIRCLEBACK: "Circleback",
|
||||||
|
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||||
|
SURFSENSE_DOCS: "SurfSense Docs",
|
||||||
|
NOTE: "Note",
|
||||||
|
COMPOSIO_GOOGLE_DRIVE_CONNECTOR: "Composio Google Drive",
|
||||||
|
COMPOSIO_GMAIL_CONNECTOR: "Composio Gmail",
|
||||||
|
COMPOSIO_GOOGLE_CALENDAR_CONNECTOR: "Composio Google Calendar",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
labelMap[type] ||
|
||||||
|
type
|
||||||
.split("_")
|
.split("_")
|
||||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||||
.join(" ");
|
.join(" ")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
|
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,13 @@ import {
|
||||||
Clock,
|
Clock,
|
||||||
FileText,
|
FileText,
|
||||||
FileX,
|
FileX,
|
||||||
Loader2,
|
|
||||||
Network,
|
Network,
|
||||||
Plus,
|
Plus,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
|
||||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||||
import { MarkdownViewer } from "@/components/markdown-viewer";
|
import { MarkdownViewer } from "@/components/markdown-viewer";
|
||||||
|
|
@ -354,11 +353,11 @@ export function DocumentsTableShell({
|
||||||
<Skeleton className="h-4 w-4 rounded" />
|
<Skeleton className="h-4 w-4 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[35%] max-w-0 border-r border-border/40">
|
<TableHead className="w-[40%] max-w-0 border-r border-border/40">
|
||||||
<Skeleton className="h-3 w-20" />
|
<Skeleton className="h-3 w-20" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
{columnVisibility.document_type && (
|
{columnVisibility.document_type && (
|
||||||
<TableHead className="w-[20%] min-w-[120px] max-w-[200px] border-r border-border/40">
|
<TableHead className="w-[15%] min-w-[100px] max-w-[170px] border-r border-border/40">
|
||||||
<Skeleton className="h-3 w-14" />
|
<Skeleton className="h-3 w-14" />
|
||||||
</TableHead>
|
</TableHead>
|
||||||
)}
|
)}
|
||||||
|
|
@ -396,11 +395,11 @@ export function DocumentsTableShell({
|
||||||
<Skeleton className="h-4 w-4 rounded" />
|
<Skeleton className="h-4 w-4 rounded" />
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[35%] py-2.5 max-w-0 border-r border-border/40">
|
<TableCell className="w-[40%] py-2.5 max-w-0 border-r border-border/40">
|
||||||
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
|
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{columnVisibility.document_type && (
|
{columnVisibility.document_type && (
|
||||||
<TableCell className="w-[20%] min-w-[120px] max-w-[200px] py-2.5 border-r border-border/40 overflow-hidden">
|
<TableCell className="w-[15%] min-w-[100px] max-w-[170px] py-2.5 border-r border-border/40 overflow-hidden">
|
||||||
<Skeleton className="h-5 w-24 rounded" />
|
<Skeleton className="h-5 w-24 rounded" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
@ -499,7 +498,7 @@ export function DocumentsTableShell({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-[35%] border-r border-border/40">
|
<TableHead className="w-[40%] border-r border-border/40">
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
sortKey="title"
|
sortKey="title"
|
||||||
currentSortKey={sortKey}
|
currentSortKey={sortKey}
|
||||||
|
|
@ -511,7 +510,7 @@ export function DocumentsTableShell({
|
||||||
</SortableHeader>
|
</SortableHeader>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
{columnVisibility.document_type && (
|
{columnVisibility.document_type && (
|
||||||
<TableHead className="w-[20%] min-w-[120px] max-w-[200px] border-r border-border/40">
|
<TableHead className="w-[15%] min-w-[100px] max-w-[170px] border-r border-border/40">
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
sortKey="document_type"
|
sortKey="document_type"
|
||||||
currentSortKey={sortKey}
|
currentSortKey={sortKey}
|
||||||
|
|
@ -594,7 +593,7 @@ export function DocumentsTableShell({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[35%] py-2.5 max-w-0 border-r border-border/40">
|
<TableCell className="w-[40%] py-2.5 max-w-0 border-r border-border/40">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="block w-full text-left text-sm text-foreground hover:text-foreground transition-colors cursor-pointer bg-transparent border-0 p-0 truncate"
|
className="block w-full text-left text-sm text-foreground hover:text-foreground transition-colors cursor-pointer bg-transparent border-0 p-0 truncate"
|
||||||
|
|
@ -624,7 +623,7 @@ export function DocumentsTableShell({
|
||||||
</button>
|
</button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
{columnVisibility.document_type && (
|
{columnVisibility.document_type && (
|
||||||
<TableCell className="w-[20%] min-w-[120px] max-w-[200px] py-2.5 border-r border-border/40 overflow-hidden">
|
<TableCell className="w-[15%] min-w-[100px] max-w-[170px] py-2.5 border-r border-border/40 overflow-hidden">
|
||||||
<DocumentTypeChip type={doc.document_type} />
|
<DocumentTypeChip type={doc.document_type} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
|
|
@ -773,7 +772,7 @@ export function DocumentsTableShell({
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{viewingLoading ? (
|
{viewingLoading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Spinner size="lg" className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<MarkdownViewer content={viewingContent} />
|
<MarkdownViewer content={viewingContent} />
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,16 @@ export function RowActions({
|
||||||
const isBeingProcessed =
|
const isBeingProcessed =
|
||||||
document.status?.state === "pending" || document.status?.state === "processing";
|
document.status?.state === "pending" || document.status?.state === "processing";
|
||||||
|
|
||||||
|
// FILE documents that failed processing cannot be edited
|
||||||
|
const isFileFailed = document.document_type === "FILE" && document.status?.state === "failed";
|
||||||
|
|
||||||
// SURFSENSE_DOCS are system-managed and should not show delete at all
|
// SURFSENSE_DOCS are system-managed and should not show delete at all
|
||||||
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
|
const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes(
|
||||||
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
document.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Edit and Delete are disabled while processing
|
// Edit is disabled while processing OR for failed FILE documents
|
||||||
const isEditDisabled = isBeingProcessed;
|
const isEditDisabled = isBeingProcessed || isFileFailed;
|
||||||
const isDeleteDisabled = isBeingProcessed;
|
const isDeleteDisabled = isBeingProcessed;
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
import { IconCalendar, IconMailFilled } from "@tabler/icons-react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ExternalLink, Gift, Loader2, Mail, Star } from "lucide-react";
|
import { Check, ExternalLink, Gift, Mail, Star } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
import type { IncentiveTaskInfo } from "@/contracts/types/incentive-tasks.types";
|
||||||
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
import { incentiveTasksApiService } from "@/lib/apis/incentive-tasks-api.service";
|
||||||
import {
|
import {
|
||||||
|
|
@ -144,7 +145,7 @@ export default function MorePagesPage() {
|
||||||
className="gap-1"
|
className="gap-1"
|
||||||
>
|
>
|
||||||
{completeMutation.isPending ? (
|
{completeMutation.isPending ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Spinner size="xs" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Go
|
Go
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@ import { useMessagesElectric } from "@/hooks/use-messages-electric";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
// import { WriteTodosToolUI } from "@/components/tool-ui/write-todos";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
import { createAttachmentAdapter, extractAttachmentContent } from "@/lib/chat/attachment-adapter";
|
|
||||||
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
import { convertToThreadMessage } from "@/lib/chat/message-utils";
|
||||||
import {
|
import {
|
||||||
isPodcastGenerating,
|
isPodcastGenerating,
|
||||||
|
|
@ -216,9 +215,6 @@ export default function NewChatPage() {
|
||||||
|
|
||||||
useMessagesElectric(threadId, handleElectricMessagesUpdate);
|
useMessagesElectric(threadId, handleElectricMessagesUpdate);
|
||||||
|
|
||||||
// Create the attachment adapter for file processing
|
|
||||||
const attachmentAdapter = useMemo(() => createAttachmentAdapter(), []);
|
|
||||||
|
|
||||||
// Extract search_space_id from URL params
|
// Extract search_space_id from URL params
|
||||||
const searchSpaceId = useMemo(() => {
|
const searchSpaceId = useMemo(() => {
|
||||||
const id = params.search_space_id;
|
const id = params.search_space_id;
|
||||||
|
|
@ -409,16 +405,7 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract attachments from message
|
if (!userQuery.trim()) return;
|
||||||
// AppendMessage.attachments contains the processed attachment objects (from adapter.send())
|
|
||||||
const messageAttachments: Array<Record<string, unknown>> = [];
|
|
||||||
if (message.attachments && message.attachments.length > 0) {
|
|
||||||
for (const att of message.attachments) {
|
|
||||||
messageAttachments.push(att as unknown as Record<string, unknown>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userQuery.trim() && messageAttachments.length === 0) return;
|
|
||||||
|
|
||||||
// Check if podcast is already generating
|
// Check if podcast is already generating
|
||||||
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
if (isPodcastGenerating() && looksLikePodcastRequest(userQuery)) {
|
||||||
|
|
@ -485,14 +472,13 @@ export default function NewChatPage() {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: message.content,
|
content: message.content,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
attachments: message.attachments || [],
|
|
||||||
metadata: authorMetadata,
|
metadata: authorMetadata,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
|
||||||
// Track message sent
|
// Track message sent
|
||||||
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
trackChatMessageSent(searchSpaceId, currentThreadId, {
|
||||||
hasAttachments: messageAttachments.length > 0,
|
hasAttachments: false,
|
||||||
hasMentionedDocuments:
|
hasMentionedDocuments:
|
||||||
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
mentionedDocumentIds.surfsense_doc_ids.length > 0 ||
|
||||||
mentionedDocumentIds.document_ids.length > 0,
|
mentionedDocumentIds.document_ids.length > 0,
|
||||||
|
|
@ -512,7 +498,7 @@ export default function NewChatPage() {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist user message with mentioned documents and attachments (don't await, fire and forget)
|
// Persist user message with mentioned documents (don't await, fire and forget)
|
||||||
const persistContent: unknown[] = [...message.content];
|
const persistContent: unknown[] = [...message.content];
|
||||||
|
|
||||||
// Add mentioned documents for persistence
|
// Add mentioned documents for persistence
|
||||||
|
|
@ -527,23 +513,6 @@ export default function NewChatPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add attachments for persistence (so they survive page reload)
|
|
||||||
if (message.attachments && message.attachments.length > 0) {
|
|
||||||
persistContent.push({
|
|
||||||
type: "attachments",
|
|
||||||
items: message.attachments.map((att) => ({
|
|
||||||
id: att.id,
|
|
||||||
name: att.name,
|
|
||||||
type: att.type,
|
|
||||||
contentType: (att as { contentType?: string }).contentType,
|
|
||||||
// Include imageDataUrl for images so they can be displayed after reload
|
|
||||||
imageDataUrl: (att as { imageDataUrl?: string }).imageDataUrl,
|
|
||||||
// Include extractedContent for context (already extracted, no re-processing needed)
|
|
||||||
extractedContent: (att as { extractedContent?: string }).extractedContent,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
appendMessage(currentThreadId, {
|
appendMessage(currentThreadId, {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: persistContent,
|
content: persistContent,
|
||||||
|
|
@ -688,9 +657,6 @@ export default function NewChatPage() {
|
||||||
})
|
})
|
||||||
.filter((m) => m.content.length > 0);
|
.filter((m) => m.content.length > 0);
|
||||||
|
|
||||||
// Extract attachment content to send with the request
|
|
||||||
const attachments = extractAttachmentContent(messageAttachments);
|
|
||||||
|
|
||||||
// Get mentioned document IDs for context (separate fields for backend)
|
// Get mentioned document IDs for context (separate fields for backend)
|
||||||
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
const hasDocumentIds = mentionedDocumentIds.document_ids.length > 0;
|
||||||
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
const hasSurfsenseDocIds = mentionedDocumentIds.surfsense_doc_ids.length > 0;
|
||||||
|
|
@ -715,7 +681,6 @@ export default function NewChatPage() {
|
||||||
user_query: userQuery.trim(),
|
user_query: userQuery.trim(),
|
||||||
search_space_id: searchSpaceId,
|
search_space_id: searchSpaceId,
|
||||||
messages: messageHistory,
|
messages: messageHistory,
|
||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
|
||||||
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
|
mentioned_document_ids: hasDocumentIds ? mentionedDocumentIds.document_ids : undefined,
|
||||||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||||
? mentionedDocumentIds.surfsense_doc_ids
|
? mentionedDocumentIds.surfsense_doc_ids
|
||||||
|
|
@ -1010,7 +975,6 @@ export default function NewChatPage() {
|
||||||
// Extract the original user query BEFORE removing messages (for reload mode)
|
// Extract the original user query BEFORE removing messages (for reload mode)
|
||||||
let userQueryToDisplay = newUserQuery;
|
let userQueryToDisplay = newUserQuery;
|
||||||
let originalUserMessageContent: ThreadMessageLike["content"] | null = null;
|
let originalUserMessageContent: ThreadMessageLike["content"] | null = null;
|
||||||
let originalUserMessageAttachments: ThreadMessageLike["attachments"] | undefined;
|
|
||||||
let originalUserMessageMetadata: ThreadMessageLike["metadata"] | undefined;
|
let originalUserMessageMetadata: ThreadMessageLike["metadata"] | undefined;
|
||||||
|
|
||||||
if (!newUserQuery) {
|
if (!newUserQuery) {
|
||||||
|
|
@ -1018,7 +982,6 @@ export default function NewChatPage() {
|
||||||
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
|
||||||
if (lastUserMessage) {
|
if (lastUserMessage) {
|
||||||
originalUserMessageContent = lastUserMessage.content;
|
originalUserMessageContent = lastUserMessage.content;
|
||||||
originalUserMessageAttachments = lastUserMessage.attachments;
|
|
||||||
originalUserMessageMetadata = lastUserMessage.metadata;
|
originalUserMessageMetadata = lastUserMessage.metadata;
|
||||||
// Extract text for the API request
|
// Extract text for the API request
|
||||||
for (const part of lastUserMessage.content) {
|
for (const part of lastUserMessage.content) {
|
||||||
|
|
@ -1144,7 +1107,6 @@ export default function NewChatPage() {
|
||||||
? [{ type: "text", text: newUserQuery }]
|
? [{ type: "text", text: newUserQuery }]
|
||||||
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }],
|
: originalUserMessageContent || [{ type: "text", text: userQueryToDisplay || "" }],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
attachments: newUserQuery ? undefined : originalUserMessageAttachments,
|
|
||||||
metadata: newUserQuery ? undefined : originalUserMessageMetadata,
|
metadata: newUserQuery ? undefined : originalUserMessageMetadata,
|
||||||
};
|
};
|
||||||
setMessages((prev) => [...prev, userMessage]);
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
|
@ -1391,7 +1353,7 @@ export default function NewChatPage() {
|
||||||
await handleRegenerate(null);
|
await handleRegenerate(null);
|
||||||
}, [handleRegenerate]);
|
}, [handleRegenerate]);
|
||||||
|
|
||||||
// Create external store runtime with attachment support
|
// Create external store runtime
|
||||||
const runtime = useExternalStoreRuntime({
|
const runtime = useExternalStoreRuntime({
|
||||||
messages,
|
messages,
|
||||||
isRunning,
|
isRunning,
|
||||||
|
|
@ -1400,9 +1362,6 @@ export default function NewChatPage() {
|
||||||
onReload,
|
onReload,
|
||||||
convertMessage,
|
convertMessage,
|
||||||
onCancel: cancelRun,
|
onCancel: cancelRun,
|
||||||
adapters: {
|
|
||||||
attachments: attachmentAdapter,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show loading state only when loading an existing thread
|
// Show loading state only when loading an existing thread
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
import { atomWithQuery } from "jotai-tanstack-query";
|
import { atomWithQuery } from "jotai-tanstack-query";
|
||||||
import { userApiService } from "@/lib/apis/user-api.service";
|
import { userApiService } from "@/lib/apis/user-api.service";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken, isPublicRoute } from "@/lib/auth-utils";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
|
|
||||||
export const currentUserAtom = atomWithQuery(() => {
|
export const currentUserAtom = atomWithQuery(() => {
|
||||||
|
const pathname = typeof window !== "undefined" ? window.location.pathname : null;
|
||||||
return {
|
return {
|
||||||
queryKey: cacheKeys.user.current(),
|
queryKey: cacheKeys.user.current(),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
// Only fetch user data when a bearer token is present
|
enabled: !!getBearerToken() && pathname !== null && !isPublicRoute(pathname),
|
||||||
enabled: !!getBearerToken(),
|
queryFn: async () => userApiService.getMe(),
|
||||||
queryFn: async () => {
|
|
||||||
return userApiService.getMe();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
72
surfsense_web/changelog/content/2026-02-09.mdx
Normal file
72
surfsense_web/changelog/content/2026-02-09.mdx
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
---
|
||||||
|
title: "SurfSense v0.0.13 - Public Sharing, Image Generation & Redesigned Documents"
|
||||||
|
description: "SurfSense v0.0.13 introduces public chat sharing with permissions, image generation support, an auto load-balanced model mode, a redesigned Documents page, and numerous bug fixes across connectors and UI."
|
||||||
|
date: "2026-02-09"
|
||||||
|
tags: ["Public Sharing", "Image Generation", "Documents", "UI", "Bug Fixes"]
|
||||||
|
version: "0.0.13"
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's New in v0.0.13
|
||||||
|
|
||||||
|
This update brings **public sharing, image generation**, a redesigned Documents page, and numerous bug fixes.
|
||||||
|
|
||||||
|
### Features & Improvements
|
||||||
|
|
||||||
|
#### Image Generation
|
||||||
|
|
||||||
|
- **Image Generation**: Generate images directly in chat with custom model and provider configurations.
|
||||||
|
|
||||||
|
#### Public Sharing
|
||||||
|
|
||||||
|
- **Public Chat Links**: Share snapshots of chats via public links.
|
||||||
|
- **Sharing Permissions**: Search Space owners control who can create and manage public links.
|
||||||
|
- **Link Management Page**: View and revoke all public chat links from Search Space Settings.
|
||||||
|
|
||||||
|
#### Auto (Load Balanced) Mode
|
||||||
|
|
||||||
|
- **Auto Model Selection**: The default cloud model now automatically picks the lowest-load model for faster responses.
|
||||||
|
|
||||||
|
#### Redesigned Documents Page
|
||||||
|
|
||||||
|
- **Unified Page**: Merged Logs and Documents into one page with real-time statuses.
|
||||||
|
- **Inline Connector Management**: Configure and monitor connectors alongside your documents.
|
||||||
|
|
||||||
|
#### UI & UX Polish
|
||||||
|
|
||||||
|
- **Inbox Refresh**: Cleaner inbox layout for easier scanning.
|
||||||
|
- **Streamlined Login**: Consolidated loading screens into a single unified flow.
|
||||||
|
- **Chat UI Tweaks**: Small refinements for a cleaner, more consistent chat experience.
|
||||||
|
- **Prompt Suggestions**: Empty chat windows now show contextual suggestions.
|
||||||
|
|
||||||
|
#### Documentation
|
||||||
|
|
||||||
|
- **New Connector Docs**: Added docs for Luma, Circleback, Elasticsearch, Bookstack, and Obsidian connectors.
|
||||||
|
|
||||||
|
<Accordion type="multiple" className="w-full not-prose">
|
||||||
|
<AccordionItem value="item-1">
|
||||||
|
<AccordionTrigger>Bug Fixes</AccordionTrigger>
|
||||||
|
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||||
|
<ul className="list-disc space-y-2 pl-4">
|
||||||
|
<li>Fixed cloud scaling issues where document queue congestion occurred under high load</li>
|
||||||
|
<li>Documents now correctly attribute to the uploading user and de-index on disconnect or deletion</li>
|
||||||
|
<li>Fixed common backend errors in indexing and large file handling</li>
|
||||||
|
<li>Fixed Notion indexing failures caused by transcription blocks</li>
|
||||||
|
<li>Chat refresh button now correctly regenerates AI responses</li>
|
||||||
|
<li>Restored the previously disabled Role Editor</li>
|
||||||
|
<li>Fixed Mentions tab appearing empty when document notifications pushed mentions out of the pagination window</li>
|
||||||
|
<li>Bundled git in the Docker image to fix GitHub connector failures with gitingest</li>
|
||||||
|
<li>Fixed Google Calendar default date range errors and aligned backend defaults with the frontend</li>
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">
|
||||||
|
<AccordionTrigger>Technical Improvements</AccordionTrigger>
|
||||||
|
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||||
|
<ul className="list-disc space-y-2 pl-4">
|
||||||
|
<li>Rebuilt the GitHub connector on gitingest for more efficient, lower-cost repository fetching</li>
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
SurfSense is your AI-powered federated search solution, connecting all your knowledge sources in one place.
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { BadgeCheck, Loader2, LogOut } from "lucide-react";
|
import { BadgeCheck, LogOut } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { logout } from "@/lib/auth-utils";
|
||||||
import { cleanupElectric } from "@/lib/electric/client";
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
|
|
@ -98,7 +99,7 @@ export function UserDropdown({
|
||||||
disabled={isLoggingOut}
|
disabled={isLoggingOut}
|
||||||
>
|
>
|
||||||
{isLoggingOut ? (
|
{isLoggingOut ? (
|
||||||
<Loader2 className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<LogOut className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4" />
|
<LogOut className="mr-2 h-3.5 w-3.5 md:h-4 md:w-4" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,377 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AttachmentPrimitive,
|
|
||||||
ComposerPrimitive,
|
|
||||||
MessagePrimitive,
|
|
||||||
useAssistantApi,
|
|
||||||
useAssistantState,
|
|
||||||
} from "@assistant-ui/react";
|
|
||||||
import { FileText, Paperclip, PlusIcon, Upload, XIcon } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { type FC, type PropsWithChildren, useEffect, useRef, useState } from "react";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useDocumentUploadDialog } from "./document-upload-popup";
|
|
||||||
|
|
||||||
const useFileSrc = (file: File | undefined) => {
|
|
||||||
const [src, setSrc] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!file) {
|
|
||||||
setSrc(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(file);
|
|
||||||
setSrc(objectUrl);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
URL.revokeObjectURL(objectUrl);
|
|
||||||
};
|
|
||||||
}, [file]);
|
|
||||||
|
|
||||||
return src;
|
|
||||||
};
|
|
||||||
|
|
||||||
const useAttachmentSrc = () => {
|
|
||||||
const { file, src } = useAssistantState(
|
|
||||||
useShallow(({ attachment }): { file?: File; src?: string } => {
|
|
||||||
if (!attachment || attachment.type !== "image") return {};
|
|
||||||
|
|
||||||
// First priority: use File object if available (for new uploads)
|
|
||||||
if (attachment.file) return { file: attachment.file };
|
|
||||||
|
|
||||||
// Second priority: use stored imageDataUrl (for persisted messages)
|
|
||||||
// This is stored in our custom ChatAttachment interface
|
|
||||||
const customAttachment = attachment as { imageDataUrl?: string };
|
|
||||||
if (customAttachment.imageDataUrl) {
|
|
||||||
return { src: customAttachment.imageDataUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Third priority: try to extract from content array (standard assistant-ui format)
|
|
||||||
if (Array.isArray(attachment.content)) {
|
|
||||||
const contentSrc = attachment.content.filter((c) => c.type === "image")[0]?.image;
|
|
||||||
if (contentSrc) return { src: contentSrc };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return useFileSrc(file) ?? src;
|
|
||||||
};
|
|
||||||
|
|
||||||
type AttachmentPreviewProps = {
|
|
||||||
src: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AttachmentPreview: FC<AttachmentPreviewProps> = ({ src }) => {
|
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
|
||||||
return (
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt="Image Preview"
|
|
||||||
width={1}
|
|
||||||
height={1}
|
|
||||||
className={
|
|
||||||
isLoaded
|
|
||||||
? "aui-attachment-preview-image-loaded block h-auto max-h-[80vh] w-auto max-w-full object-contain"
|
|
||||||
: "aui-attachment-preview-image-loading hidden"
|
|
||||||
}
|
|
||||||
onLoadingComplete={() => setIsLoaded(true)}
|
|
||||||
priority={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AttachmentPreviewDialog: FC<PropsWithChildren> = ({ children }) => {
|
|
||||||
const src = useAttachmentSrc();
|
|
||||||
|
|
||||||
if (!src) return children;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog>
|
|
||||||
<DialogTrigger
|
|
||||||
className="aui-attachment-preview-trigger cursor-pointer transition-colors hover:bg-accent/50"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="aui-attachment-preview-dialog-content p-2 sm:max-w-3xl [&>button]:rounded-full [&>button]:bg-foreground/60 [&>button]:p-1 [&>button]:opacity-100 [&>button]:ring-0! [&_svg]:text-background [&>button]:hover:[&_svg]:text-destructive">
|
|
||||||
<DialogTitle className="aui-sr-only sr-only">Image Attachment Preview</DialogTitle>
|
|
||||||
<div className="aui-attachment-preview relative mx-auto flex max-h-[80dvh] w-full items-center justify-center overflow-hidden bg-background">
|
|
||||||
<AttachmentPreview src={src} />
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AttachmentThumb: FC = () => {
|
|
||||||
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
|
||||||
// Check if actively processing (running AND progress < 100)
|
|
||||||
// When progress is 100, processing is done but waiting for send()
|
|
||||||
const isProcessing = useAssistantState(({ attachment }) => {
|
|
||||||
const status = attachment?.status;
|
|
||||||
if (status?.type !== "running") return false;
|
|
||||||
// If progress is defined and equals 100, processing is complete
|
|
||||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
|
||||||
return progress === undefined || progress < 100;
|
|
||||||
});
|
|
||||||
const src = useAttachmentSrc();
|
|
||||||
|
|
||||||
// Show loading spinner only when actively processing (not when done and waiting for send)
|
|
||||||
if (isProcessing) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center bg-muted">
|
|
||||||
<Spinner size="md" className="text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar className="aui-attachment-tile-avatar h-full w-full rounded-none">
|
|
||||||
<AvatarImage
|
|
||||||
src={src}
|
|
||||||
alt="Attachment preview"
|
|
||||||
className="aui-attachment-tile-image object-cover"
|
|
||||||
/>
|
|
||||||
<AvatarFallback delayMs={isImage ? 200 : 0}>
|
|
||||||
<FileText className="aui-attachment-tile-fallback-icon size-8 text-muted-foreground" />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AttachmentUI: FC = () => {
|
|
||||||
const api = useAssistantApi();
|
|
||||||
const isComposer = api.attachment.source === "composer";
|
|
||||||
|
|
||||||
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
|
||||||
// Check if actively processing (running AND progress < 100)
|
|
||||||
// When progress is 100, processing is done but waiting for send()
|
|
||||||
const isProcessing = useAssistantState(({ attachment }) => {
|
|
||||||
const status = attachment?.status;
|
|
||||||
if (status?.type !== "running") return false;
|
|
||||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
|
||||||
return progress === undefined || progress < 100;
|
|
||||||
});
|
|
||||||
const typeLabel = useAssistantState(({ attachment }) => {
|
|
||||||
const type = attachment?.type;
|
|
||||||
switch (type) {
|
|
||||||
case "image":
|
|
||||||
return "Image";
|
|
||||||
case "document":
|
|
||||||
return "Document";
|
|
||||||
case "file":
|
|
||||||
return "File";
|
|
||||||
default:
|
|
||||||
return "File"; // Default fallback for unknown types
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<AttachmentPrimitive.Root
|
|
||||||
className={cn(
|
|
||||||
"aui-attachment-root relative",
|
|
||||||
isImage && "aui-attachment-root-composer only:[&>#attachment-tile]:size-24"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AttachmentPreviewDialog>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"aui-attachment-tile size-14 cursor-pointer overflow-hidden rounded-[14px] border bg-muted transition-opacity hover:opacity-75",
|
|
||||||
isComposer && "aui-attachment-tile-composer border-foreground/20",
|
|
||||||
isProcessing && "animate-pulse"
|
|
||||||
)}
|
|
||||||
id="attachment-tile"
|
|
||||||
aria-label={isProcessing ? "Processing attachment..." : `${typeLabel} attachment`}
|
|
||||||
>
|
|
||||||
<AttachmentThumb />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
</AttachmentPreviewDialog>
|
|
||||||
{isComposer && !isProcessing && <AttachmentRemove />}
|
|
||||||
</AttachmentPrimitive.Root>
|
|
||||||
<TooltipContent
|
|
||||||
side="top"
|
|
||||||
className="bg-black text-white font-medium shadow-xl px-3 py-1.5 dark:bg-zinc-800 dark:text-zinc-50 border-none"
|
|
||||||
>
|
|
||||||
{isProcessing ? (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Spinner size="xs" />
|
|
||||||
Processing...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<AttachmentPrimitive.Name />
|
|
||||||
)}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AttachmentRemove: FC = () => {
|
|
||||||
return (
|
|
||||||
<AttachmentPrimitive.Remove asChild>
|
|
||||||
<TooltipIconButton
|
|
||||||
tooltip="Remove file"
|
|
||||||
className="aui-attachment-tile-remove absolute top-1.5 right-1.5 size-3.5 rounded-full bg-white text-muted-foreground opacity-100 shadow-sm hover:bg-white! [&_svg]:text-black hover:[&_svg]:text-destructive"
|
|
||||||
side="top"
|
|
||||||
>
|
|
||||||
<XIcon className="aui-attachment-remove-icon size-3 dark:stroke-[2.5px]" />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</AttachmentPrimitive.Remove>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Image attachment with preview thumbnail (click to expand)
|
|
||||||
*/
|
|
||||||
const MessageImageAttachment: FC = () => {
|
|
||||||
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Image");
|
|
||||||
const src = useAttachmentSrc();
|
|
||||||
|
|
||||||
if (!src) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AttachmentPreviewDialog>
|
|
||||||
<div
|
|
||||||
className="relative group cursor-pointer overflow-hidden rounded-xl border border-border/50 bg-muted transition-all hover:border-primary/30 hover:shadow-md"
|
|
||||||
title={`Click to expand: ${attachmentName}`}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={src}
|
|
||||||
alt={attachmentName}
|
|
||||||
width={120}
|
|
||||||
height={90}
|
|
||||||
className="object-cover w-[120px] h-[90px] transition-transform group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
{/* Hover overlay with filename */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<div className="absolute bottom-1.5 left-1.5 right-1.5">
|
|
||||||
<span className="text-[10px] text-white/90 font-medium truncate block">
|
|
||||||
{attachmentName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AttachmentPreviewDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Document/file attachment as chip (similar to mentioned documents)
|
|
||||||
*/
|
|
||||||
const MessageDocumentAttachment: FC = () => {
|
|
||||||
const attachmentName = useAssistantState(({ attachment }) => attachment?.name || "Attachment");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AttachmentPreviewDialog>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-xs font-medium text-primary border border-primary/20 cursor-pointer hover:bg-primary/20 transition-colors"
|
|
||||||
title={attachmentName}
|
|
||||||
>
|
|
||||||
<FileText className="size-3" />
|
|
||||||
<span className="max-w-[150px] truncate">{attachmentName}</span>
|
|
||||||
</span>
|
|
||||||
</AttachmentPreviewDialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attachment component for user messages
|
|
||||||
* Shows image preview for images, chip for documents
|
|
||||||
*/
|
|
||||||
const MessageAttachmentChip: FC = () => {
|
|
||||||
const isImage = useAssistantState(({ attachment }) => attachment?.type === "image");
|
|
||||||
|
|
||||||
if (isImage) {
|
|
||||||
return <MessageImageAttachment />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <MessageDocumentAttachment />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UserMessageAttachments: FC = () => {
|
|
||||||
return <MessagePrimitive.Attachments components={{ Attachment: MessageAttachmentChip }} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComposerAttachments: FC = () => {
|
|
||||||
return (
|
|
||||||
<div className="aui-composer-attachments mb-2 flex w-full flex-row items-center gap-2 overflow-x-auto px-1.5 pt-0.5 pb-1 empty:hidden">
|
|
||||||
<ComposerPrimitive.Attachments components={{ Attachment: AttachmentUI }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ComposerAddAttachment: FC = () => {
|
|
||||||
const chatAttachmentInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const { openDialog } = useDocumentUploadDialog();
|
|
||||||
|
|
||||||
const handleFileUpload = () => {
|
|
||||||
openDialog();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChatAttachment = () => {
|
|
||||||
chatAttachmentInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Prevent event bubbling when file input is clicked
|
|
||||||
const handleFileInputClick = (e: React.MouseEvent<HTMLInputElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<TooltipIconButton
|
|
||||||
tooltip="Upload"
|
|
||||||
side="bottom"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="aui-composer-add-attachment size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
|
||||||
aria-label="Upload"
|
|
||||||
>
|
|
||||||
<PlusIcon className="aui-attachment-add-icon size-5 stroke-[1.5px]" />
|
|
||||||
</TooltipIconButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" className="w-72 bg-background border-border">
|
|
||||||
<DropdownMenuItem onSelect={handleChatAttachment} className="cursor-pointer">
|
|
||||||
<Paperclip className="size-4" />
|
|
||||||
<span>Add attachment to this chat</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleFileUpload} className="cursor-pointer">
|
|
||||||
<Upload className="size-4" />
|
|
||||||
<span>Upload documents to Search Space</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<ComposerPrimitive.AddAttachment asChild>
|
|
||||||
<input
|
|
||||||
ref={chatAttachmentInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
accept="image/*,application/pdf,.doc,.docx,.txt"
|
|
||||||
onClick={handleFileInputClick}
|
|
||||||
/>
|
|
||||||
</ComposerPrimitive.AddAttachment>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -122,10 +122,12 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
|
<span className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal wrap-break-word">
|
||||||
{getConnectorTypeDisplay(connector?.connector_type || "")} Connected !
|
{getConnectorTypeDisplay(connector?.connector_type || "")} Connected !
|
||||||
</span>{" "}
|
|
||||||
<span className="text-xl sm:text-xl font-semibold text-muted-foreground tracking-tight text-wrap whitespace-normal wrap-break-word">
|
|
||||||
{getConnectorDisplayName(connector?.name || "")}
|
|
||||||
</span>
|
</span>
|
||||||
|
{connector?.name?.includes(" - ") && (
|
||||||
|
<span className="text-xl sm:text-xl font-semibold text-muted-foreground tracking-tight text-wrap whitespace-normal wrap-break-word">
|
||||||
|
{getConnectorDisplayName(connector.name)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||||
Configure when to start syncing your data
|
Configure when to start syncing your data
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ export interface InlineMentionEditorRef {
|
||||||
getText: () => string;
|
getText: () => string;
|
||||||
getMentionedDocuments: () => MentionedDocument[];
|
getMentionedDocuments: () => MentionedDocument[];
|
||||||
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
insertDocumentChip: (doc: Pick<Document, "id" | "title" | "document_type">) => void;
|
||||||
|
setDocumentChipStatus: (
|
||||||
|
docId: number,
|
||||||
|
docType: string | undefined,
|
||||||
|
statusLabel: string | null,
|
||||||
|
statusKind?: "pending" | "processing" | "ready" | "failed"
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InlineMentionEditorProps {
|
interface InlineMentionEditorProps {
|
||||||
|
|
@ -46,6 +52,7 @@ interface InlineMentionEditorProps {
|
||||||
const CHIP_DATA_ATTR = "data-mention-chip";
|
const CHIP_DATA_ATTR = "data-mention-chip";
|
||||||
const CHIP_ID_ATTR = "data-mention-id";
|
const CHIP_ID_ATTR = "data-mention-id";
|
||||||
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
|
const CHIP_DOCTYPE_ATTR = "data-mention-doctype";
|
||||||
|
const CHIP_STATUS_ATTR = "data-mention-status";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type guard to check if a node is a chip element
|
* Type guard to check if a node is a chip element
|
||||||
|
|
@ -182,6 +189,11 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
titleSpan.className = "max-w-[120px] truncate";
|
titleSpan.className = "max-w-[120px] truncate";
|
||||||
titleSpan.textContent = doc.title;
|
titleSpan.textContent = doc.title;
|
||||||
titleSpan.title = doc.title;
|
titleSpan.title = doc.title;
|
||||||
|
titleSpan.setAttribute("data-mention-title", "true");
|
||||||
|
|
||||||
|
const statusSpan = document.createElement("span");
|
||||||
|
statusSpan.setAttribute(CHIP_STATUS_ATTR, "true");
|
||||||
|
statusSpan.className = "text-[10px] font-semibold opacity-80 hidden";
|
||||||
|
|
||||||
const removeBtn = document.createElement("button");
|
const removeBtn = document.createElement("button");
|
||||||
removeBtn.type = "button";
|
removeBtn.type = "button";
|
||||||
|
|
@ -207,6 +219,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
|
|
||||||
chip.appendChild(iconSpan);
|
chip.appendChild(iconSpan);
|
||||||
chip.appendChild(titleSpan);
|
chip.appendChild(titleSpan);
|
||||||
|
chip.appendChild(statusSpan);
|
||||||
chip.appendChild(removeBtn);
|
chip.appendChild(removeBtn);
|
||||||
|
|
||||||
return chip;
|
return chip;
|
||||||
|
|
@ -332,6 +345,48 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const setDocumentChipStatus = useCallback(
|
||||||
|
(
|
||||||
|
docId: number,
|
||||||
|
docType: string | undefined,
|
||||||
|
statusLabel: string | null,
|
||||||
|
statusKind: "pending" | "processing" | "ready" | "failed" = "pending"
|
||||||
|
) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
|
const chips = editorRef.current.querySelectorAll<HTMLSpanElement>(
|
||||||
|
`span[${CHIP_DATA_ATTR}="true"]`
|
||||||
|
);
|
||||||
|
for (const chip of chips) {
|
||||||
|
const chipId = getChipId(chip);
|
||||||
|
const chipType = getChipDocType(chip);
|
||||||
|
if (chipId !== docId) continue;
|
||||||
|
if ((docType ?? "UNKNOWN") !== chipType) continue;
|
||||||
|
|
||||||
|
const statusEl = chip.querySelector<HTMLSpanElement>(`span[${CHIP_STATUS_ATTR}="true"]`);
|
||||||
|
if (!statusEl) continue;
|
||||||
|
|
||||||
|
if (!statusLabel) {
|
||||||
|
statusEl.textContent = "";
|
||||||
|
statusEl.className = "text-[10px] font-semibold opacity-80 hidden";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClass =
|
||||||
|
statusKind === "failed"
|
||||||
|
? "text-destructive"
|
||||||
|
: statusKind === "processing"
|
||||||
|
? "text-amber-700"
|
||||||
|
: statusKind === "ready"
|
||||||
|
? "text-emerald-700"
|
||||||
|
: "text-amber-700";
|
||||||
|
statusEl.textContent = statusLabel;
|
||||||
|
statusEl.className = `text-[10px] font-semibold opacity-80 ${statusClass}`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
// Expose methods via ref
|
// Expose methods via ref
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => editorRef.current?.focus(),
|
focus: () => editorRef.current?.focus(),
|
||||||
|
|
@ -339,6 +394,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
getText,
|
getText,
|
||||||
getMentionedDocuments,
|
getMentionedDocuments,
|
||||||
insertDocumentChip,
|
insertDocumentChip,
|
||||||
|
setDocumentChipStatus,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Handle input changes
|
// Handle input changes
|
||||||
|
|
@ -526,7 +582,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-[24px] max-h-32 overflow-y-auto",
|
"min-h-[24px] max-h-32 overflow-y-auto",
|
||||||
"text-sm outline-none",
|
"text-sm outline-none",
|
||||||
"whitespace-pre-wrap break-words",
|
"whitespace-pre-wrap wrap-break-word",
|
||||||
disabled && "opacity-50 cursor-not-allowed",
|
disabled && "opacity-50 cursor-not-allowed",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,15 @@ import {
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
Loader2,
|
FileWarning,
|
||||||
|
Paperclip,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
SquareIcon,
|
SquareIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { type FC, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
|
||||||
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
import { showCommentsGutterAtom } from "@/atoms/chat/current-thread.atom";
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,7 +42,6 @@ import {
|
||||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
|
||||||
import { ComposerAddAttachment, ComposerAttachments } from "@/components/assistant-ui/attachment";
|
|
||||||
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
|
||||||
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
|
||||||
import {
|
import {
|
||||||
|
|
@ -61,20 +62,35 @@ import {
|
||||||
} from "@/components/new-chat/document-mention-picker";
|
} from "@/components/new-chat/document-mention-picker";
|
||||||
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
import type { ThinkingStep } from "@/components/tool-ui/deepagent-thinking";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
import { useCommentsElectric } from "@/hooks/use-comments-electric";
|
||||||
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Placeholder texts that cycle in new chats when input is empty */
|
/** Placeholder texts that cycle in new chats when input is empty */
|
||||||
const CYCLING_PLACEHOLDERS = [
|
const CYCLING_PLACEHOLDERS = [
|
||||||
"Ask SurfSense anything or @mention docs.",
|
"Ask SurfSense anything or @mention docs.",
|
||||||
"Generate a podcast from marketing tips in the company handbook.",
|
"Generate a podcast from my vacation ideas in Notion.",
|
||||||
"Sum up our vacation policy from Drive.",
|
"Sum up last week's meeting notes from Drive in a bulleted list.",
|
||||||
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
|
"Give me a brief overview of the most urgent tickets in Jira and Linear.",
|
||||||
"Create a concise table of today's top ten emails and calendar events.",
|
"Briefly, what are today's top ten important emails and calendar events?",
|
||||||
"Check if this week's Slack messages reference any GitHub issues.",
|
"Check if this week's Slack messages reference any GitHub issues.",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CHAT_UPLOAD_ACCEPT =
|
||||||
|
".pdf,.doc,.docx,.txt,.md,.markdown,.ppt,.pptx,.xls,.xlsx,.xlsm,.xlsb,.csv,.html,.htm,.xml,.rtf,.epub,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.tif,.mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm";
|
||||||
|
|
||||||
|
type UploadState = "pending" | "processing" | "ready" | "failed";
|
||||||
|
|
||||||
|
interface UploadedMentionDoc {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
document_type: Document["document_type"];
|
||||||
|
state: UploadState;
|
||||||
|
reason?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ThreadProps {
|
interface ThreadProps {
|
||||||
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
messageThinkingSteps?: Map<string, ThinkingStep[]>;
|
||||||
header?: React.ReactNode;
|
header?: React.ReactNode;
|
||||||
|
|
@ -230,8 +246,13 @@ const Composer: FC = () => {
|
||||||
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom);
|
||||||
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
const [showDocumentPopover, setShowDocumentPopover] = useState(false);
|
||||||
const [mentionQuery, setMentionQuery] = useState("");
|
const [mentionQuery, setMentionQuery] = useState("");
|
||||||
|
const [uploadedMentionDocs, setUploadedMentionDocs] = useState<
|
||||||
|
Record<number, UploadedMentionDoc>
|
||||||
|
>({});
|
||||||
|
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
|
||||||
const editorRef = useRef<InlineMentionEditorRef>(null);
|
const editorRef = useRef<InlineMentionEditorRef>(null);
|
||||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||||
const { search_space_id, chat_id } = useParams();
|
const { search_space_id, chat_id } = useParams();
|
||||||
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
const setMentionedDocumentIds = useSetAtom(mentionedDocumentIdsAtom);
|
||||||
|
|
@ -357,9 +378,28 @@ const Composer: FC = () => {
|
||||||
[showDocumentPopover]
|
[showDocumentPopover]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const uploadedMentionedDocs = useMemo(
|
||||||
|
() => mentionedDocuments.filter((doc) => uploadedMentionDocs[doc.id]),
|
||||||
|
[mentionedDocuments, uploadedMentionDocs]
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockingUploadedMentions = useMemo(
|
||||||
|
() =>
|
||||||
|
uploadedMentionedDocs.filter((doc) => {
|
||||||
|
const state = uploadedMentionDocs[doc.id]?.state;
|
||||||
|
return state === "pending" || state === "processing" || state === "failed";
|
||||||
|
}),
|
||||||
|
[uploadedMentionedDocs, uploadedMentionDocs]
|
||||||
|
);
|
||||||
|
|
||||||
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (isThreadRunning || isBlockedByOtherUser) {
|
if (
|
||||||
|
isThreadRunning ||
|
||||||
|
isBlockedByOtherUser ||
|
||||||
|
isUploadingDocs ||
|
||||||
|
blockingUploadedMentions.length > 0
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!showDocumentPopover) {
|
if (!showDocumentPopover) {
|
||||||
|
|
@ -375,6 +415,8 @@ const Composer: FC = () => {
|
||||||
showDocumentPopover,
|
showDocumentPopover,
|
||||||
isThreadRunning,
|
isThreadRunning,
|
||||||
isBlockedByOtherUser,
|
isBlockedByOtherUser,
|
||||||
|
isUploadingDocs,
|
||||||
|
blockingUploadedMentions.length,
|
||||||
composerRuntime,
|
composerRuntime,
|
||||||
setMentionedDocuments,
|
setMentionedDocuments,
|
||||||
setMentionedDocumentIds,
|
setMentionedDocumentIds,
|
||||||
|
|
@ -395,6 +437,11 @@ const Composer: FC = () => {
|
||||||
});
|
});
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
setUploadedMentionDocs((prev) => {
|
||||||
|
if (!(docId in prev)) return prev;
|
||||||
|
const { [docId]: _removed, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[setMentionedDocuments, setMentionedDocumentIds]
|
[setMentionedDocuments, setMentionedDocumentIds]
|
||||||
);
|
);
|
||||||
|
|
@ -433,6 +480,139 @@ const Composer: FC = () => {
|
||||||
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
[mentionedDocuments, setMentionedDocuments, setMentionedDocumentIds]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshUploadedDocStatuses = useCallback(
|
||||||
|
async (documentIds: number[]) => {
|
||||||
|
if (!search_space_id || documentIds.length === 0) return;
|
||||||
|
const statusResponse = await documentsApiService.getDocumentsStatus({
|
||||||
|
queryParams: {
|
||||||
|
search_space_id: Number(search_space_id),
|
||||||
|
document_ids: documentIds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setUploadedMentionDocs((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const item of statusResponse.items) {
|
||||||
|
next[item.id] = {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
document_type: item.document_type,
|
||||||
|
state: item.status.state,
|
||||||
|
reason: item.status.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
handleDocumentsMention(
|
||||||
|
statusResponse.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
document_type: item.document_type,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[search_space_id, handleDocumentsMention]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleUploadClick = useCallback(() => {
|
||||||
|
uploadInputRef.current?.click();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUploadInputChange = useCallback(
|
||||||
|
async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files ?? []);
|
||||||
|
event.target.value = "";
|
||||||
|
if (files.length === 0 || !search_space_id) return;
|
||||||
|
|
||||||
|
setIsUploadingDocs(true);
|
||||||
|
try {
|
||||||
|
const uploadResponse = await documentsApiService.uploadDocument({
|
||||||
|
files,
|
||||||
|
search_space_id: Number(search_space_id),
|
||||||
|
});
|
||||||
|
const uploadedIds = uploadResponse.document_ids ?? [];
|
||||||
|
const duplicateIds = uploadResponse.duplicate_document_ids ?? [];
|
||||||
|
const idsToMention = Array.from(new Set([...uploadedIds, ...duplicateIds]));
|
||||||
|
if (idsToMention.length === 0) {
|
||||||
|
toast.warning("No documents were created or matched from selected files.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshUploadedDocStatuses(idsToMention);
|
||||||
|
if (uploadedIds.length > 0 && duplicateIds.length > 0) {
|
||||||
|
toast.success(
|
||||||
|
`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""} and matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""}.`
|
||||||
|
);
|
||||||
|
} else if (uploadedIds.length > 0) {
|
||||||
|
toast.success(`Uploaded ${uploadedIds.length} file${uploadedIds.length > 1 ? "s" : ""}`);
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
`Matched ${duplicateIds.length} existing file${duplicateIds.length > 1 ? "s" : ""} and added mention${duplicateIds.length > 1 ? "s" : ""}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Upload failed";
|
||||||
|
toast.error(`Upload failed: ${message}`);
|
||||||
|
} finally {
|
||||||
|
setIsUploadingDocs(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[search_space_id, refreshUploadedDocStatuses]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll status for uploaded mentioned documents until all are ready or removed.
|
||||||
|
useEffect(() => {
|
||||||
|
const trackedIds = uploadedMentionedDocs.map((doc) => doc.id);
|
||||||
|
const needsPolling = trackedIds.some((id) => {
|
||||||
|
const state = uploadedMentionDocs[id]?.state;
|
||||||
|
return state === "pending" || state === "processing";
|
||||||
|
});
|
||||||
|
if (!needsPolling) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
refreshUploadedDocStatuses(trackedIds).catch((error) => {
|
||||||
|
console.error("[Composer] Failed to refresh uploaded mention statuses:", error);
|
||||||
|
});
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [uploadedMentionedDocs, uploadedMentionDocs, refreshUploadedDocStatuses]);
|
||||||
|
|
||||||
|
// Push upload status directly onto mention chips (instead of separate status rows).
|
||||||
|
useEffect(() => {
|
||||||
|
for (const doc of uploadedMentionedDocs) {
|
||||||
|
const state = uploadedMentionDocs[doc.id]?.state ?? "pending";
|
||||||
|
const statusLabel =
|
||||||
|
state === "ready"
|
||||||
|
? null
|
||||||
|
: state === "failed"
|
||||||
|
? "failed"
|
||||||
|
: state === "processing"
|
||||||
|
? "indexing"
|
||||||
|
: "queued";
|
||||||
|
editorRef.current?.setDocumentChipStatus(doc.id, doc.document_type, statusLabel, state);
|
||||||
|
}
|
||||||
|
}, [uploadedMentionedDocs, uploadedMentionDocs]);
|
||||||
|
|
||||||
|
// Prune upload status entries that are no longer mentioned in the composer.
|
||||||
|
useEffect(() => {
|
||||||
|
const activeIds = new Set(mentionedDocuments.map((doc) => doc.id));
|
||||||
|
setUploadedMentionDocs((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<number, UploadedMentionDoc> = {};
|
||||||
|
for (const [key, value] of Object.entries(prev)) {
|
||||||
|
const id = Number(key);
|
||||||
|
if (activeIds.has(id)) {
|
||||||
|
next[id] = value;
|
||||||
|
} else {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [mentionedDocuments]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
|
||||||
<ChatSessionStatus
|
<ChatSessionStatus
|
||||||
|
|
@ -441,8 +621,7 @@ const Composer: FC = () => {
|
||||||
currentUserId={currentUser?.id ?? null}
|
currentUserId={currentUser?.id ?? null}
|
||||||
members={members ?? []}
|
members={members ?? []}
|
||||||
/>
|
/>
|
||||||
<ComposerPrimitive.AttachmentDropzone className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow data-[dragging=true]:border-ring data-[dragging=true]:border-dashed data-[dragging=true]:bg-accent/50">
|
<div className="aui-composer-attachment-dropzone flex w-full flex-col rounded-2xl border-input bg-muted px-1 pt-2 outline-none transition-shadow">
|
||||||
<ComposerAttachments />
|
|
||||||
{/* Inline editor with @mention support */}
|
{/* Inline editor with @mention support */}
|
||||||
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-3 pt-3 pb-6">
|
||||||
<InlineMentionEditor
|
<InlineMentionEditor
|
||||||
|
|
@ -457,6 +636,14 @@ const Composer: FC = () => {
|
||||||
className="min-h-[24px]"
|
className="min-h-[24px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={uploadInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={CHAT_UPLOAD_ACCEPT}
|
||||||
|
onChange={handleUploadInputChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
{/* Document picker popover (portal to body for proper z-index stacking) */}
|
||||||
{showDocumentPopover &&
|
{showDocumentPopover &&
|
||||||
|
|
@ -483,33 +670,43 @@ const Composer: FC = () => {
|
||||||
/>,
|
/>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
|
<ComposerAction
|
||||||
</ComposerPrimitive.AttachmentDropzone>
|
isBlockedByOtherUser={isBlockedByOtherUser}
|
||||||
|
onUploadClick={handleUploadClick}
|
||||||
|
isUploadingDocs={isUploadingDocs}
|
||||||
|
blockingUploadedMentionsCount={blockingUploadedMentions.length}
|
||||||
|
hasFailedUploadedMentions={blockingUploadedMentions.some(
|
||||||
|
(doc) => uploadedMentionDocs[doc.id]?.state === "failed"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</ComposerPrimitive.Root>
|
</ComposerPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ComposerActionProps {
|
interface ComposerActionProps {
|
||||||
isBlockedByOtherUser?: boolean;
|
isBlockedByOtherUser?: boolean;
|
||||||
|
onUploadClick: () => void;
|
||||||
|
isUploadingDocs: boolean;
|
||||||
|
blockingUploadedMentionsCount: number;
|
||||||
|
hasFailedUploadedMentions: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false }) => {
|
const ComposerAction: FC<ComposerActionProps> = ({
|
||||||
// Check if any attachments are still being processed (running AND progress < 100)
|
isBlockedByOtherUser = false,
|
||||||
// When progress is 100, processing is done but waiting for send()
|
onUploadClick,
|
||||||
const hasProcessingAttachments = useAssistantState(({ composer }) =>
|
isUploadingDocs,
|
||||||
composer.attachments?.some((att) => {
|
blockingUploadedMentionsCount,
|
||||||
const status = att.status;
|
hasFailedUploadedMentions,
|
||||||
if (status?.type !== "running") return false;
|
}) => {
|
||||||
const progress = (status as { type: "running"; progress?: number }).progress;
|
const mentionedDocuments = useAtomValue(mentionedDocumentsAtom);
|
||||||
return progress === undefined || progress < 100;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check if composer text is empty
|
// Check if composer text is empty (chips are represented in mentionedDocuments atom)
|
||||||
const isComposerEmpty = useAssistantState(({ composer }) => {
|
const isComposerTextEmpty = useAssistantState(({ composer }) => {
|
||||||
const text = composer.text?.trim() || "";
|
const text = composer.text?.trim() || "";
|
||||||
return text.length === 0;
|
return text.length === 0;
|
||||||
});
|
});
|
||||||
|
const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0;
|
||||||
|
|
||||||
// Check if a model is configured
|
// Check if a model is configured
|
||||||
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
const { data: userConfigs } = useAtomValue(newLLMConfigsAtom);
|
||||||
|
|
@ -530,25 +727,47 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
}, [preferences, globalConfigs, userConfigs]);
|
}, [preferences, globalConfigs, userConfigs]);
|
||||||
|
|
||||||
const isSendDisabled =
|
const isSendDisabled =
|
||||||
hasProcessingAttachments || isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser;
|
isComposerEmpty ||
|
||||||
|
!hasModelConfigured ||
|
||||||
|
isBlockedByOtherUser ||
|
||||||
|
isUploadingDocs ||
|
||||||
|
blockingUploadedMentionsCount > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
<div className="aui-composer-action-wrapper relative mx-2 mb-2 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ComposerAddAttachment />
|
<TooltipIconButton
|
||||||
|
tooltip={isUploadingDocs ? "Uploading documents..." : "Upload and mention files"}
|
||||||
|
side="bottom"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30"
|
||||||
|
aria-label="Upload files"
|
||||||
|
onClick={onUploadClick}
|
||||||
|
disabled={isUploadingDocs}
|
||||||
|
>
|
||||||
|
{isUploadingDocs ? (
|
||||||
|
<Spinner size="sm" className="text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<Paperclip className="size-4" />
|
||||||
|
)}
|
||||||
|
</TooltipIconButton>
|
||||||
<ConnectorIndicator />
|
<ConnectorIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Show processing indicator when attachments are being processed */}
|
{blockingUploadedMentionsCount > 0 && (
|
||||||
{hasProcessingAttachments && (
|
|
||||||
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||||
<Loader2 className="size-3 animate-spin" />
|
{hasFailedUploadedMentions ? <FileWarning className="size-3" /> : <Spinner size="xs" />}
|
||||||
<span>Processing...</span>
|
<span>
|
||||||
|
{hasFailedUploadedMentions
|
||||||
|
? "Remove or retry failed uploads"
|
||||||
|
: "Waiting for uploaded files to finish indexing"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show warning when no model is configured */}
|
{/* Show warning when no model is configured */}
|
||||||
{!hasModelConfigured && !hasProcessingAttachments && (
|
{!hasModelConfigured && blockingUploadedMentionsCount === 0 && (
|
||||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400 text-xs">
|
||||||
<AlertCircle className="size-3" />
|
<AlertCircle className="size-3" />
|
||||||
<span>Select a model</span>
|
<span>Select a model</span>
|
||||||
|
|
@ -561,10 +780,14 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
|
||||||
tooltip={
|
tooltip={
|
||||||
isBlockedByOtherUser
|
isBlockedByOtherUser
|
||||||
? "Wait for AI to finish responding"
|
? "Wait for AI to finish responding"
|
||||||
|
: hasFailedUploadedMentions
|
||||||
|
? "Remove or retry failed uploads before sending"
|
||||||
|
: blockingUploadedMentionsCount > 0
|
||||||
|
? "Waiting for uploaded files to finish indexing"
|
||||||
|
: isUploadingDocs
|
||||||
|
? "Uploading documents..."
|
||||||
: !hasModelConfigured
|
: !hasModelConfigured
|
||||||
? "Please select a model from the header to start chatting"
|
? "Please select a model from the header to start chatting"
|
||||||
: hasProcessingAttachments
|
|
||||||
? "Wait for attachments to process"
|
|
||||||
: isComposerEmpty
|
: isComposerEmpty
|
||||||
? "Enter a message to send"
|
? "Enter a message to send"
|
||||||
: "Send message"
|
: "Send message"
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { useAtomValue } from "jotai";
|
||||||
import { FileText, PencilIcon } from "lucide-react";
|
import { FileText, PencilIcon } from "lucide-react";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useState } from "react";
|
||||||
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||||
import { UserMessageAttachments } from "@/components/assistant-ui/attachment";
|
|
||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
|
|
||||||
interface AuthorMetadata {
|
interface AuthorMetadata {
|
||||||
|
|
@ -48,9 +47,6 @@ export const UserMessage: FC = () => {
|
||||||
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
|
||||||
const metadata = useAssistantState(({ message }) => message?.metadata);
|
const metadata = useAssistantState(({ message }) => message?.metadata);
|
||||||
const author = metadata?.custom?.author as AuthorMetadata | undefined;
|
const author = metadata?.custom?.author as AuthorMetadata | undefined;
|
||||||
const hasAttachments = useAssistantState(
|
|
||||||
({ message }) => message?.attachments && message.attachments.length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root
|
<MessagePrimitive.Root
|
||||||
|
|
@ -59,11 +55,9 @@ export const UserMessage: FC = () => {
|
||||||
>
|
>
|
||||||
<div className="aui-user-message-content-wrapper col-start-2 min-w-0 flex items-end gap-2">
|
<div className="aui-user-message-content-wrapper col-start-2 min-w-0 flex items-end gap-2">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Display attachments and mentioned documents */}
|
{/* Display mentioned documents */}
|
||||||
{(hasAttachments || (mentionedDocs && mentionedDocs.length > 0)) && (
|
{mentionedDocs && mentionedDocs.length > 0 && (
|
||||||
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
<div className="flex flex-wrap items-end gap-2 mb-2 justify-end">
|
||||||
{/* Attachments (images show as thumbnails, documents as chips) */}
|
|
||||||
<UserMessageAttachments />
|
|
||||||
{/* Mentioned documents as chips */}
|
{/* Mentioned documents as chips */}
|
||||||
{mentionedDocs?.map((doc) => (
|
{mentionedDocs?.map((doc) => (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export function FooterNew() {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "LinkedIn",
|
title: "LinkedIn",
|
||||||
href: "https://www.linkedin.com/in/rohan-verma-sde/",
|
href: "https://www.linkedin.com/company/surfsense/",
|
||||||
icon: IconBrandLinkedin,
|
icon: IconBrandLinkedin,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -34,8 +34,8 @@ const GoogleLogo = ({ className }: { className?: string }) => (
|
||||||
export function HeroSection() {
|
export function HeroSection() {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const heroVariant = useFeatureFlagVariantKey("notebooklm_flag");
|
const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag");
|
||||||
const isNotebookLMVariant = heroVariant === "notebooklm";
|
const isNotebookLMVariant = heroVariant === "superpowers";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -89,25 +89,24 @@ export function HeroSection() {
|
||||||
{isNotebookLMVariant ? (
|
{isNotebookLMVariant ? (
|
||||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||||
<span className="">NotebookLM for Teams</span>
|
<span className="">NotebookLM with Superpowers</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
The AI Workspace{" "}
|
|
||||||
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
<div className="relative mx-auto inline-block w-max filter-[drop-shadow(0px_1px_3px_rgba(27,37,80,0.14))]">
|
||||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||||
<span className="">Built for Teams</span>
|
<span className="">NotebookLM for Teams</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Balancer>
|
</Balancer>
|
||||||
</h2>
|
</h2>
|
||||||
{/* // TODO:aCTUAL DESCRITION */}
|
{/* // TODO:aCTUAL DESCRITION */}
|
||||||
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
|
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
|
||||||
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
|
Connect any AI to your documents and knowledge sources.
|
||||||
your team.
|
</p>
|
||||||
|
<p className="relative z-50 mx-auto mt-0 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
|
||||||
|
Then chat with it in real-time, even alongside your team.
|
||||||
</p>
|
</p>
|
||||||
<div className="mb-10 mt-8 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-20">
|
<div className="mb-10 mt-8 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-20">
|
||||||
<GetStartedButton />
|
<GetStartedButton />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { FileJson, Loader2 } from "lucide-react";
|
import { FileJson } from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { defaultStyles, JsonView } from "react-json-view-lite";
|
import { defaultStyles, JsonView } from "react-json-view-lite";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import "react-json-view-lite/dist/index.css";
|
import "react-json-view-lite/dist/index.css";
|
||||||
|
|
||||||
interface JsonMetadataViewerProps {
|
interface JsonMetadataViewerProps {
|
||||||
|
|
@ -58,7 +59,7 @@ export function JsonMetadataViewer({
|
||||||
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
|
<div className="mt-2 sm:mt-4 p-2 sm:p-4 bg-muted/30 rounded-md text-xs sm:text-sm">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Spinner size="lg" className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<JsonView data={jsonData} style={defaultStyles} />
|
<JsonView data={jsonData} style={defaultStyles} />
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ interface SidebarContextValue {
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
setIsCollapsed: (collapsed: boolean) => void;
|
setIsCollapsed: (collapsed: boolean) => void;
|
||||||
toggleCollapsed: () => void;
|
toggleCollapsed: () => void;
|
||||||
|
sidebarWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
const SidebarContext = createContext<SidebarContextValue | null>(null);
|
||||||
|
|
|
||||||
101
surfsense_web/components/layout/hooks/useSidebarResize.ts
Normal file
101
surfsense_web/components/layout/hooks/useSidebarResize.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const SIDEBAR_WIDTH_COOKIE_NAME = "sidebar_width";
|
||||||
|
const SIDEBAR_WIDTH_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||||
|
|
||||||
|
export const SIDEBAR_MIN_WIDTH = 240;
|
||||||
|
export const SIDEBAR_MAX_WIDTH = 480;
|
||||||
|
|
||||||
|
interface UseSidebarResizeReturn {
|
||||||
|
sidebarWidth: number;
|
||||||
|
handleMouseDown: (e: React.MouseEvent) => void;
|
||||||
|
isDragging: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarResize(defaultWidth = SIDEBAR_MIN_WIDTH): UseSidebarResizeReturn {
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(defaultWidth);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const startXRef = useRef(0);
|
||||||
|
const startWidthRef = useRef(defaultWidth);
|
||||||
|
|
||||||
|
// Initialize from cookie on mount
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const match = document.cookie.match(/(?:^|; )sidebar_width=([^;]+)/);
|
||||||
|
if (match) {
|
||||||
|
const parsed = Number(match[1]);
|
||||||
|
if (!Number.isNaN(parsed) && parsed >= SIDEBAR_MIN_WIDTH && parsed <= SIDEBAR_MAX_WIDTH) {
|
||||||
|
setSidebarWidth(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cookie read errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Persist width to cookie
|
||||||
|
const persistWidth = useCallback((width: number) => {
|
||||||
|
try {
|
||||||
|
document.cookie = `${SIDEBAR_WIDTH_COOKIE_NAME}=${width}; path=/; max-age=${SIDEBAR_WIDTH_COOKIE_MAX_AGE}`;
|
||||||
|
} catch {
|
||||||
|
// Ignore cookie write errors
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startXRef.current = e.clientX;
|
||||||
|
startWidthRef.current = sidebarWidth;
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
|
document.body.style.cursor = "col-resize";
|
||||||
|
document.body.style.userSelect = "none";
|
||||||
|
},
|
||||||
|
[sidebarWidth]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const handleMouseMove = (e: MouseEvent) => {
|
||||||
|
const delta = e.clientX - startXRef.current;
|
||||||
|
const newWidth = Math.min(
|
||||||
|
SIDEBAR_MAX_WIDTH,
|
||||||
|
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
||||||
|
);
|
||||||
|
setSidebarWidth(newWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
setIsDragging(false);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
|
||||||
|
// Persist the final width
|
||||||
|
setSidebarWidth((currentWidth) => {
|
||||||
|
persistWidth(currentWidth);
|
||||||
|
return currentWidth;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
document.body.style.cursor = "";
|
||||||
|
document.body.style.userSelect = "";
|
||||||
|
};
|
||||||
|
}, [isDragging, persistWidth]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sidebarWidth,
|
||||||
|
handleMouseDown,
|
||||||
|
isDragging,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -25,16 +25,14 @@ import { Input } from "@/components/ui/input";
|
||||||
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types";
|
||||||
import { useInbox } from "@/hooks/use-inbox";
|
import { useInbox } from "@/hooks/use-inbox";
|
||||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||||
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
|
||||||
import { logout } from "@/lib/auth-utils";
|
import { logout } from "@/lib/auth-utils";
|
||||||
|
import { deleteThread, fetchThreads, updateThread } from "@/lib/chat/thread-persistence";
|
||||||
import { cleanupElectric } from "@/lib/electric/client";
|
import { cleanupElectric } from "@/lib/electric/client";
|
||||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
import type { ChatItem, NavItem, SearchSpace } from "../types/layout.types";
|
||||||
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||||
import { LayoutShell } from "../ui/shell";
|
import { LayoutShell } from "../ui/shell";
|
||||||
import { AllPrivateChatsSidebar } from "../ui/sidebar/AllPrivateChatsSidebar";
|
|
||||||
import { AllSharedChatsSidebar } from "../ui/sidebar/AllSharedChatsSidebar";
|
|
||||||
|
|
||||||
interface LayoutDataProviderProps {
|
interface LayoutDataProviderProps {
|
||||||
searchSpaceId: string;
|
searchSpaceId: string;
|
||||||
|
|
@ -390,7 +388,13 @@ export function LayoutDataProvider({
|
||||||
(item: NavItem) => {
|
(item: NavItem) => {
|
||||||
// Handle inbox specially - toggle sidebar instead of navigating
|
// Handle inbox specially - toggle sidebar instead of navigating
|
||||||
if (item.url === "#inbox") {
|
if (item.url === "#inbox") {
|
||||||
setIsInboxSidebarOpen((prev) => !prev);
|
setIsInboxSidebarOpen((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
|
}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
router.push(item.url);
|
router.push(item.url);
|
||||||
|
|
@ -490,10 +494,14 @@ export function LayoutDataProvider({
|
||||||
|
|
||||||
const handleViewAllSharedChats = useCallback(() => {
|
const handleViewAllSharedChats = useCallback(() => {
|
||||||
setIsAllSharedChatsSidebarOpen(true);
|
setIsAllSharedChatsSidebarOpen(true);
|
||||||
|
setIsAllPrivateChatsSidebarOpen(false);
|
||||||
|
setIsInboxSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewAllPrivateChats = useCallback(() => {
|
const handleViewAllPrivateChats = useCallback(() => {
|
||||||
setIsAllPrivateChatsSidebarOpen(true);
|
setIsAllPrivateChatsSidebarOpen(true);
|
||||||
|
setIsAllSharedChatsSidebarOpen(false);
|
||||||
|
setIsInboxSidebarOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Delete handlers
|
// Delete handlers
|
||||||
|
|
@ -614,6 +622,16 @@ export function LayoutDataProvider({
|
||||||
isDocked: isInboxDocked,
|
isDocked: isInboxDocked,
|
||||||
onDockedChange: setIsInboxDocked,
|
onDockedChange: setIsInboxDocked,
|
||||||
}}
|
}}
|
||||||
|
allSharedChatsPanel={{
|
||||||
|
open: isAllSharedChatsSidebarOpen,
|
||||||
|
onOpenChange: setIsAllSharedChatsSidebarOpen,
|
||||||
|
searchSpaceId,
|
||||||
|
}}
|
||||||
|
allPrivateChatsPanel={{
|
||||||
|
open: isAllPrivateChatsSidebarOpen,
|
||||||
|
onOpenChange: setIsAllPrivateChatsSidebarOpen,
|
||||||
|
searchSpaceId,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</LayoutShell>
|
</LayoutShell>
|
||||||
|
|
@ -796,20 +814,6 @@ export function LayoutDataProvider({
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* All Shared Chats Sidebar */}
|
|
||||||
<AllSharedChatsSidebar
|
|
||||||
open={isAllSharedChatsSidebarOpen}
|
|
||||||
onOpenChange={setIsAllSharedChatsSidebarOpen}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* All Private Chats Sidebar */}
|
|
||||||
<AllPrivateChatsSidebar
|
|
||||||
open={isAllPrivateChatsSidebarOpen}
|
|
||||||
onOpenChange={setIsAllPrivateChatsSidebarOpen}
|
|
||||||
searchSpaceId={searchSpaceId}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Create Search Space Dialog */}
|
{/* Create Search Space Dialog */}
|
||||||
<CreateSearchSpaceDialog
|
<CreateSearchSpaceDialog
|
||||||
open={isCreateSearchSpaceDialogOpen}
|
open={isCreateSearchSpaceDialogOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Settings, Trash2, Users } from "lucide-react";
|
import { Settings, Trash2, Users } from "lucide-react";
|
||||||
import { useCallback, useRef, useState } from "react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,18 @@ import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { SidebarProvider, useSidebarState } from "../../hooks";
|
import { SidebarProvider, useSidebarState } from "../../hooks";
|
||||||
|
import { useSidebarResize } from "../../hooks/useSidebarResize";
|
||||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
import { Header } from "../header";
|
import { Header } from "../header";
|
||||||
import { IconRail } from "../icon-rail";
|
import { IconRail } from "../icon-rail";
|
||||||
import { InboxSidebar, MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
import {
|
||||||
|
AllPrivateChatsSidebar,
|
||||||
|
AllSharedChatsSidebar,
|
||||||
|
InboxSidebar,
|
||||||
|
MobileSidebar,
|
||||||
|
MobileSidebarTrigger,
|
||||||
|
Sidebar,
|
||||||
|
} from "../sidebar";
|
||||||
|
|
||||||
// Tab-specific data source props
|
// Tab-specific data source props
|
||||||
interface TabDataSource {
|
interface TabDataSource {
|
||||||
|
|
@ -75,6 +83,17 @@ interface LayoutShellProps {
|
||||||
// Inbox props
|
// Inbox props
|
||||||
inbox?: InboxProps;
|
inbox?: InboxProps;
|
||||||
isLoadingChats?: boolean;
|
isLoadingChats?: boolean;
|
||||||
|
// All chats panel props
|
||||||
|
allSharedChatsPanel?: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
searchSpaceId: string;
|
||||||
|
};
|
||||||
|
allPrivateChatsPanel?: {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
searchSpaceId: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LayoutShell({
|
export function LayoutShell({
|
||||||
|
|
@ -112,15 +131,22 @@ export function LayoutShell({
|
||||||
className,
|
className,
|
||||||
inbox,
|
inbox,
|
||||||
isLoadingChats = false,
|
isLoadingChats = false,
|
||||||
|
allSharedChatsPanel,
|
||||||
|
allPrivateChatsPanel,
|
||||||
}: LayoutShellProps) {
|
}: LayoutShellProps) {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
const { isCollapsed, setIsCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||||
|
const {
|
||||||
|
sidebarWidth,
|
||||||
|
handleMouseDown: onResizeMouseDown,
|
||||||
|
isDragging: isResizing,
|
||||||
|
} = useSidebarResize();
|
||||||
|
|
||||||
// Memoize context value to prevent unnecessary re-renders
|
// Memoize context value to prevent unnecessary re-renders
|
||||||
const sidebarContextValue = useMemo(
|
const sidebarContextValue = useMemo(
|
||||||
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed }),
|
() => ({ isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth }),
|
||||||
[isCollapsed, setIsCollapsed, toggleCollapsed]
|
[isCollapsed, setIsCollapsed, toggleCollapsed, sidebarWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mobile layout
|
// Mobile layout
|
||||||
|
|
@ -236,6 +262,9 @@ export function LayoutShell({
|
||||||
setTheme={setTheme}
|
setTheme={setTheme}
|
||||||
className="hidden md:flex border-r shrink-0"
|
className="hidden md:flex border-r shrink-0"
|
||||||
isLoadingChats={isLoadingChats}
|
isLoadingChats={isLoadingChats}
|
||||||
|
sidebarWidth={sidebarWidth}
|
||||||
|
onResizeMouseDown={onResizeMouseDown}
|
||||||
|
isResizing={isResizing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
|
{/* Docked Inbox Sidebar - renders as flex sibling between sidebar and content */}
|
||||||
|
|
@ -275,6 +304,24 @@ export function LayoutShell({
|
||||||
onDockedChange={inbox.onDockedChange}
|
onDockedChange={inbox.onDockedChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* All Shared Chats - slide-out panel */}
|
||||||
|
{allSharedChatsPanel && (
|
||||||
|
<AllSharedChatsSidebar
|
||||||
|
open={allSharedChatsPanel.open}
|
||||||
|
onOpenChange={allSharedChatsPanel.onOpenChange}
|
||||||
|
searchSpaceId={allSharedChatsPanel.searchSpaceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Private Chats - slide-out panel */}
|
||||||
|
{allPrivateChatsPanel && (
|
||||||
|
<AllPrivateChatsSidebar
|
||||||
|
open={allPrivateChatsPanel.open}
|
||||||
|
onOpenChange={allPrivateChatsPanel.onOpenChange}
|
||||||
|
searchSpaceId={allPrivateChatsPanel.searchSpaceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,9 @@ import {
|
||||||
User,
|
User,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +38,7 @@ import {
|
||||||
updateThread,
|
updateThread,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
interface AllPrivateChatsSidebarProps {
|
interface AllPrivateChatsSidebarProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -69,16 +68,11 @@ export function AllPrivateChatsSidebar({
|
||||||
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (e.key === "Escape" && open) {
|
||||||
|
|
@ -89,17 +83,6 @@ export function AllPrivateChatsSidebar({
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: threadsData,
|
data: threadsData,
|
||||||
error: threadsError,
|
error: threadsError,
|
||||||
|
|
@ -214,31 +197,11 @@ export function AllPrivateChatsSidebar({
|
||||||
const activeCount = activeChats.length;
|
const activeCount = activeChats.length;
|
||||||
const archivedCount = archivedChats.length;
|
const archivedCount = archivedChats.length;
|
||||||
|
|
||||||
if (!mounted) return null;
|
return (
|
||||||
|
<SidebarSlideOutPanel
|
||||||
return createPortal(
|
open={open}
|
||||||
<AnimatePresence>
|
onOpenChange={onOpenChange}
|
||||||
{open && (
|
ariaLabel={t("chats") || "Private Chats"}
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 z-70 bg-black/50"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "-100%" }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: "-100%" }}
|
|
||||||
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
|
||||||
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("chats") || "Private Chats"}
|
|
||||||
>
|
>
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -308,10 +271,7 @@ export function AllPrivateChatsSidebar({
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||||
<div
|
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5">
|
||||||
key={`skeleton-${i}`}
|
|
||||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
|
||||||
>
|
|
||||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -452,10 +412,6 @@ export function AllPrivateChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</SidebarSlideOutPanel>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,9 @@ import {
|
||||||
Users,
|
Users,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -40,6 +38,7 @@ import {
|
||||||
updateThread,
|
updateThread,
|
||||||
} from "@/lib/chat/thread-persistence";
|
} from "@/lib/chat/thread-persistence";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
interface AllSharedChatsSidebarProps {
|
interface AllSharedChatsSidebarProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -69,16 +68,11 @@ export function AllSharedChatsSidebar({
|
||||||
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
const [archivingThreadId, setArchivingThreadId] = useState<number | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
const [openDropdownId, setOpenDropdownId] = useState<number | null>(null);
|
||||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
const isSearchMode = !!debouncedSearchQuery.trim();
|
const isSearchMode = !!debouncedSearchQuery.trim();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscape = (e: KeyboardEvent) => {
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
if (e.key === "Escape" && open) {
|
if (e.key === "Escape" && open) {
|
||||||
|
|
@ -89,17 +83,6 @@ export function AllSharedChatsSidebar({
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [open, onOpenChange]);
|
}, [open, onOpenChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
document.body.style.overflow = "hidden";
|
|
||||||
} else {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
document.body.style.overflow = "";
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: threadsData,
|
data: threadsData,
|
||||||
error: threadsError,
|
error: threadsError,
|
||||||
|
|
@ -214,31 +197,11 @@ export function AllSharedChatsSidebar({
|
||||||
const activeCount = activeChats.length;
|
const activeCount = activeChats.length;
|
||||||
const archivedCount = archivedChats.length;
|
const archivedCount = archivedChats.length;
|
||||||
|
|
||||||
if (!mounted) return null;
|
return (
|
||||||
|
<SidebarSlideOutPanel
|
||||||
return createPortal(
|
open={open}
|
||||||
<AnimatePresence>
|
onOpenChange={onOpenChange}
|
||||||
{open && (
|
ariaLabel={t("shared_chats") || "Shared Chats"}
|
||||||
<>
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.2 }}
|
|
||||||
className="fixed inset-0 z-70 bg-black/50"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "-100%" }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: "-100%" }}
|
|
||||||
transition={{ type: "tween", duration: 0.3, ease: "easeOut" }}
|
|
||||||
className="fixed inset-y-0 left-0 z-70 w-80 bg-background shadow-xl flex flex-col pointer-events-auto isolate"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("shared_chats") || "Shared Chats"}
|
|
||||||
>
|
>
|
||||||
<div className="shrink-0 p-4 pb-2 space-y-3">
|
<div className="shrink-0 p-4 pb-2 space-y-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -308,10 +271,7 @@ export function AllSharedChatsSidebar({
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
{[75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
|
||||||
<div
|
<div key={`skeleton-${i}`} className="flex items-center gap-2 rounded-md px-2 py-1.5">
|
||||||
key={`skeleton-${i}`}
|
|
||||||
className="flex items-center gap-2 rounded-md px-2 py-1.5"
|
|
||||||
>
|
|
||||||
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
<Skeleton className="h-4 w-4 shrink-0 rounded" />
|
||||||
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
<Skeleton className="h-4 rounded" style={{ width: `${titleWidth}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -452,10 +412,6 @@ export function AllSharedChatsSidebar({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</SidebarSlideOutPanel>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>,
|
|
||||||
document.body
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import {
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "motion/react";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
@ -53,17 +52,13 @@ import {
|
||||||
isNewMentionMetadata,
|
isNewMentionMetadata,
|
||||||
isPageLimitExceededMetadata,
|
isPageLimitExceededMetadata,
|
||||||
} from "@/contracts/types/inbox.types";
|
} from "@/contracts/types/inbox.types";
|
||||||
import type { InboxItem } from "@/hooks/use-inbox";
|
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import type { InboxItem } from "@/hooks/use-inbox";
|
||||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
import { notificationsApiService } from "@/lib/apis/notifications-api.service";
|
||||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSidebarContextSafe } from "../../hooks";
|
import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel";
|
||||||
|
|
||||||
// Sidebar width constants
|
|
||||||
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
|
||||||
const SIDEBAR_EXPANDED_WIDTH = 240;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get initials from name or email for avatar fallback
|
* Get initials from name or email for avatar fallback
|
||||||
|
|
@ -561,13 +556,6 @@ export function InboxSidebar({
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get sidebar collapsed state from context (provided by LayoutShell)
|
|
||||||
const sidebarContext = useSidebarContextSafe();
|
|
||||||
const isCollapsed = sidebarContext?.isCollapsed ?? false;
|
|
||||||
|
|
||||||
// Calculate the left position for the inbox panel (relative to sidebar)
|
|
||||||
const sidebarWidth = isCollapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH;
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
// Shared content component for both docked and floating modes
|
// Shared content component for both docked and floating modes
|
||||||
|
|
@ -1126,49 +1114,8 @@ export function InboxSidebar({
|
||||||
|
|
||||||
// FLOATING MODE: Render with animation and click-away layer
|
// FLOATING MODE: Render with animation and click-away layer
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<SidebarSlideOutPanel open={open} onOpenChange={onOpenChange} ariaLabel={t("inbox") || "Inbox"}>
|
||||||
{open && (
|
|
||||||
<>
|
|
||||||
{/* Click-away layer - only covers the content area, not the sidebar */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
style={{
|
|
||||||
left: isMobile ? 0 : sidebarWidth,
|
|
||||||
}}
|
|
||||||
className="absolute inset-y-0 right-0"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Clip container - positioned at sidebar edge with overflow hidden */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
left: isMobile ? 0 : sidebarWidth,
|
|
||||||
width: isMobile ? "100%" : 360,
|
|
||||||
}}
|
|
||||||
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ x: "-100%" }}
|
|
||||||
animate={{ x: 0 }}
|
|
||||||
exit={{ x: "-100%" }}
|
|
||||||
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
|
||||||
className={cn(
|
|
||||||
"h-full w-full bg-background flex flex-col pointer-events-auto",
|
|
||||||
"sm:border-r sm:shadow-xl"
|
|
||||||
)}
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label={t("inbox") || "Inbox"}
|
|
||||||
>
|
|
||||||
{inboxContent}
|
{inboxContent}
|
||||||
</motion.div>
|
</SidebarSlideOutPanel>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
|
||||||
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
|
||||||
import { ChatListItem } from "./ChatListItem";
|
import { ChatListItem } from "./ChatListItem";
|
||||||
import { NavSection } from "./NavSection";
|
import { NavSection } from "./NavSection";
|
||||||
|
|
@ -51,6 +52,9 @@ interface SidebarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
isLoadingChats?: boolean;
|
isLoadingChats?: boolean;
|
||||||
disableTooltips?: boolean;
|
disableTooltips?: boolean;
|
||||||
|
sidebarWidth?: number;
|
||||||
|
onResizeMouseDown?: (e: React.MouseEvent) => void;
|
||||||
|
isResizing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
|
|
@ -80,17 +84,29 @@ export function Sidebar({
|
||||||
className,
|
className,
|
||||||
isLoadingChats = false,
|
isLoadingChats = false,
|
||||||
disableTooltips = false,
|
disableTooltips = false,
|
||||||
|
sidebarWidth = SIDEBAR_MIN_WIDTH,
|
||||||
|
onResizeMouseDown,
|
||||||
|
isResizing = false,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-200 overflow-hidden",
|
"relative flex h-full flex-col bg-sidebar text-sidebar-foreground overflow-hidden",
|
||||||
isCollapsed ? "w-[60px]" : "w-[240px]",
|
isCollapsed ? "w-[60px] transition-all duration-200" : "",
|
||||||
|
!isCollapsed && !isResizing ? "transition-all duration-200" : "",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
style={!isCollapsed ? { width: sidebarWidth } : undefined}
|
||||||
>
|
>
|
||||||
|
{/* Resize handle on right border */}
|
||||||
|
{!isCollapsed && onResizeMouseDown && (
|
||||||
|
<div
|
||||||
|
onMouseDown={onResizeMouseDown}
|
||||||
|
className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-border active:bg-border z-10"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{/* Header - search space name or collapse button when collapsed */}
|
{/* Header - search space name or collapse button when collapsed */}
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useSidebarContextSafe } from "../../hooks";
|
||||||
|
|
||||||
|
const SIDEBAR_COLLAPSED_WIDTH = 60;
|
||||||
|
|
||||||
|
interface SidebarSlideOutPanelProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
ariaLabel: string;
|
||||||
|
width?: number;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable slide-out panel that appears from the right edge of the sidebar.
|
||||||
|
* Used by InboxSidebar (floating mode), AllSharedChatsSidebar, and AllPrivateChatsSidebar.
|
||||||
|
*
|
||||||
|
* Must be rendered inside a positioned container (the LayoutShell's relative flex container)
|
||||||
|
* and within the SidebarProvider context.
|
||||||
|
*/
|
||||||
|
export function SidebarSlideOutPanel({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
ariaLabel,
|
||||||
|
width = 360,
|
||||||
|
children,
|
||||||
|
}: SidebarSlideOutPanelProps) {
|
||||||
|
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||||
|
const sidebarContext = useSidebarContextSafe();
|
||||||
|
const isCollapsed = sidebarContext?.isCollapsed ?? false;
|
||||||
|
const sidebarWidth = isCollapsed
|
||||||
|
? SIDEBAR_COLLAPSED_WIDTH
|
||||||
|
: (sidebarContext?.sidebarWidth ?? 240);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{/* Click-away layer - covers the full container including the sidebar */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute inset-0 z-[5]"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clip container - positioned at sidebar edge with overflow hidden */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
left: isMobile ? 0 : sidebarWidth,
|
||||||
|
width: isMobile ? "100%" : width,
|
||||||
|
}}
|
||||||
|
className={cn("absolute z-10 overflow-hidden pointer-events-none", "inset-y-0")}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "-100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "-100%" }}
|
||||||
|
transition={{ type: "tween", duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||||
|
className={cn(
|
||||||
|
"h-full w-full bg-background flex flex-col pointer-events-auto",
|
||||||
|
"sm:border-r sm:shadow-xl"
|
||||||
|
)}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Check, ChevronUp, Languages, Laptop, LogOut, Moon, Settings, Sun } from "lucide-react";
|
||||||
Check,
|
|
||||||
ChevronUp,
|
|
||||||
Languages,
|
|
||||||
Laptop,
|
|
||||||
Loader2,
|
|
||||||
LogOut,
|
|
||||||
Moon,
|
|
||||||
Settings,
|
|
||||||
Sun,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
|
@ -25,6 +15,7 @@ import {
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useLocaleContext } from "@/contexts/LocaleContext";
|
import { useLocaleContext } from "@/contexts/LocaleContext";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -266,7 +257,7 @@ export function SidebarUserProfile({
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
{isLoggingOut ? (
|
{isLoggingOut ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
|
@ -388,7 +379,7 @@ export function SidebarUserProfile({
|
||||||
|
|
||||||
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
{isLoggingOut ? (
|
{isLoggingOut ? (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Spinner size="sm" className="mr-2" />
|
||||||
) : (
|
) : (
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Copy, Loader2 } from "lucide-react";
|
import { Copy } from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
|
import { publicChatApiService } from "@/lib/apis/public-chat-api.service";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
||||||
|
|
@ -61,9 +62,14 @@ export function PublicChatFooter({ shareToken }: PublicChatFooterProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-(--thread-max-width) items-center justify-center px-4 py-4">
|
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2">
|
||||||
<Button size="lg" onClick={handleCopyAndContinue} disabled={isCloning} className="gap-2">
|
<Button
|
||||||
{isCloning ? <Loader2 className="size-4 animate-spin" /> : <Copy className="size-4" />}
|
size="lg"
|
||||||
|
onClick={handleCopyAndContinue}
|
||||||
|
disabled={isCloning}
|
||||||
|
className="gap-2 rounded-full px-6 shadow-lg transition-all duration-200 hover:scale-[1.02] hover:shadow-xl hover:brightness-110 hover:bg-primary"
|
||||||
|
>
|
||||||
|
{isCloning ? <Spinner size="sm" /> : <Copy className="size-4" />}
|
||||||
Copy and continue this chat
|
Copy and continue this chat
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { Navbar } from "@/components/homepage/navbar";
|
import { Navbar } from "@/components/homepage/navbar";
|
||||||
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
import { DisplayImageToolUI } from "@/components/tool-ui/display-image";
|
||||||
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
|
||||||
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
import { LinkPreviewToolUI } from "@/components/tool-ui/link-preview";
|
||||||
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
import { ScrapeWebpageToolUI } from "@/components/tool-ui/scrape-webpage";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { usePublicChat } from "@/hooks/use-public-chat";
|
import { usePublicChat } from "@/hooks/use-public-chat";
|
||||||
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
|
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
|
||||||
import { PublicChatFooter } from "./public-chat-footer";
|
import { PublicChatFooter } from "./public-chat-footer";
|
||||||
|
|
@ -26,7 +26,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
|
||||||
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
<Spinner size="lg" className="text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ import { Separator } from "@/components/ui/separator";
|
||||||
import { Spinner } from "@/components/ui/spinner";
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
IMAGE_GEN_PROVIDERS,
|
|
||||||
getImageGenModelsByProvider,
|
getImageGenModelsByProvider,
|
||||||
|
IMAGE_GEN_PROVIDERS,
|
||||||
} from "@/contracts/enums/image-gen-providers";
|
} from "@/contracts/enums/image-gen-providers";
|
||||||
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
|
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { CheckIcon } from "lucide-react";
|
import { CheckIcon, MinusIcon } from "lucide-react";
|
||||||
import type * as React from "react";
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -11,16 +11,17 @@ function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxP
|
||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary data-[state=indeterminate]:bg-transparent data-[state=indeterminate]:text-foreground data-[state=indeterminate]:border-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator
|
<CheckboxPrimitive.Indicator
|
||||||
data-slot="checkbox-indicator"
|
data-slot="checkbox-indicator"
|
||||||
className="flex items-center justify-center text-current transition-none"
|
className="group flex items-center justify-center text-current transition-none"
|
||||||
>
|
>
|
||||||
<CheckIcon className="size-3.5" />
|
<CheckIcon className="size-3.5 hidden group-data-[state=checked]:block" />
|
||||||
|
<MinusIcon className="size-3.5 hidden group-data-[state=indeterminate]:block" />
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1477,6 +1477,78 @@ export const LLM_MODELS: LLMModel[] = [
|
||||||
provider: "DATABRICKS",
|
provider: "DATABRICKS",
|
||||||
contextWindow: "128K",
|
contextWindow: "128K",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// GitHub Models
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5",
|
||||||
|
label: "GitHub GPT-5",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-4.1",
|
||||||
|
label: "GitHub GPT-4.1",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "1048K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-4o",
|
||||||
|
label: "GitHub GPT-4o",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "128K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "deepseek/DeepSeek-V3-0324",
|
||||||
|
label: "GitHub DeepSeek V3",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "64K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "xai/grok-3",
|
||||||
|
label: "GitHub Grok 3",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "131K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-5-mini",
|
||||||
|
label: "GitHub GPT-5 Mini",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-4.1-mini",
|
||||||
|
label: "GitHub GPT-4.1 Mini",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "1048K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "meta/Llama-4-Scout-17B-16E-Instruct",
|
||||||
|
label: "GitHub Llama 4 Scout",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "512K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-4.1-nano",
|
||||||
|
label: "GitHub GPT-4.1 Nano",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "1048K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/gpt-4o-mini",
|
||||||
|
label: "GitHub GPT-4o Mini",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "128K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "openai/o4-mini",
|
||||||
|
label: "GitHub O4 Mini",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "200K",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "deepseek/DeepSeek-R1",
|
||||||
|
label: "GitHub DeepSeek R1",
|
||||||
|
provider: "GITHUB_MODELS",
|
||||||
|
contextWindow: "64K",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper function to get models by provider
|
// Helper function to get models by provider
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,13 @@ export const LLM_PROVIDERS: LLMProvider[] = [
|
||||||
example: "databricks/databricks-meta-llama-3-3-70b-instruct",
|
example: "databricks/databricks-meta-llama-3-3-70b-instruct",
|
||||||
description: "Databricks Model Serving",
|
description: "Databricks Model Serving",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "GITHUB_MODELS",
|
||||||
|
label: "GitHub Models",
|
||||||
|
example: "openai/gpt-5, meta/llama-3.1-405b-instruct",
|
||||||
|
description: "AI models from GitHub Marketplace",
|
||||||
|
apiBase: "https://models.github.ai/inference",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "CUSTOM",
|
value: "CUSTOM",
|
||||||
label: "Custom Provider",
|
label: "Custom Provider",
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,37 @@ export const uploadDocumentRequest = z.object({
|
||||||
|
|
||||||
export const uploadDocumentResponse = z.object({
|
export const uploadDocumentResponse = z.object({
|
||||||
message: z.literal("Files uploaded for processing"),
|
message: z.literal("Files uploaded for processing"),
|
||||||
|
document_ids: z.array(z.number()),
|
||||||
|
duplicate_document_ids: z.array(z.number()).optional(),
|
||||||
|
total_files: z.number().optional(),
|
||||||
|
pending_files: z.number().optional(),
|
||||||
|
skipped_duplicates: z.number().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch document status
|
||||||
|
*/
|
||||||
|
export const getDocumentsStatusRequest = z.object({
|
||||||
|
queryParams: z.object({
|
||||||
|
search_space_id: z.number(),
|
||||||
|
document_ids: z.array(z.number()).min(1),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const documentStatus = z.object({
|
||||||
|
state: z.enum(["ready", "pending", "processing", "failed"]),
|
||||||
|
reason: z.string().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const documentStatusItem = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
title: z.string(),
|
||||||
|
document_type: documentTypeEnum,
|
||||||
|
status: documentStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getDocumentsStatusResponse = z.object({
|
||||||
|
items: z.array(documentStatusItem),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -261,6 +292,10 @@ export type CreateDocumentRequest = z.infer<typeof createDocumentRequest>;
|
||||||
export type CreateDocumentResponse = z.infer<typeof createDocumentResponse>;
|
export type CreateDocumentResponse = z.infer<typeof createDocumentResponse>;
|
||||||
export type UploadDocumentRequest = z.infer<typeof uploadDocumentRequest>;
|
export type UploadDocumentRequest = z.infer<typeof uploadDocumentRequest>;
|
||||||
export type UploadDocumentResponse = z.infer<typeof uploadDocumentResponse>;
|
export type UploadDocumentResponse = z.infer<typeof uploadDocumentResponse>;
|
||||||
|
export type GetDocumentsStatusRequest = z.infer<typeof getDocumentsStatusRequest>;
|
||||||
|
export type GetDocumentsStatusResponse = z.infer<typeof getDocumentsStatusResponse>;
|
||||||
|
export type DocumentStatus = z.infer<typeof documentStatus>;
|
||||||
|
export type DocumentStatusItem = z.infer<typeof documentStatusItem>;
|
||||||
export type SearchDocumentsRequest = z.infer<typeof searchDocumentsRequest>;
|
export type SearchDocumentsRequest = z.infer<typeof searchDocumentsRequest>;
|
||||||
export type SearchDocumentsResponse = z.infer<typeof searchDocumentsResponse>;
|
export type SearchDocumentsResponse = z.infer<typeof searchDocumentsResponse>;
|
||||||
export type SearchDocumentTitlesRequest = z.infer<typeof searchDocumentTitlesRequest>;
|
export type SearchDocumentTitlesRequest = z.infer<typeof searchDocumentTitlesRequest>;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { z } from "zod";
|
||||||
/**
|
/**
|
||||||
* Incentive task type enum - matches backend IncentiveTaskType
|
* Incentive task type enum - matches backend IncentiveTaskType
|
||||||
*/
|
*/
|
||||||
export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR"]);
|
export const incentiveTaskTypeEnum = z.enum(["GITHUB_STAR", "REDDIT_FOLLOW", "DISCORD_JOIN"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single incentive task info schema
|
* Single incentive task info schema
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export const liteLLMProviderEnum = z.enum([
|
||||||
"DATABRICKS",
|
"DATABRICKS",
|
||||||
"COMETAPI",
|
"COMETAPI",
|
||||||
"HUGGINGFACE",
|
"HUGGINGFACE",
|
||||||
|
"GITHUB_MODELS",
|
||||||
"CUSTOM",
|
"CUSTOM",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
type GetDocumentByChunkRequest,
|
type GetDocumentByChunkRequest,
|
||||||
type GetDocumentRequest,
|
type GetDocumentRequest,
|
||||||
type GetDocumentsRequest,
|
type GetDocumentsRequest,
|
||||||
|
type GetDocumentsStatusRequest,
|
||||||
type GetDocumentTypeCountsRequest,
|
type GetDocumentTypeCountsRequest,
|
||||||
type GetSurfsenseDocsRequest,
|
type GetSurfsenseDocsRequest,
|
||||||
getDocumentByChunkRequest,
|
getDocumentByChunkRequest,
|
||||||
|
|
@ -16,6 +17,8 @@ import {
|
||||||
getDocumentResponse,
|
getDocumentResponse,
|
||||||
getDocumentsRequest,
|
getDocumentsRequest,
|
||||||
getDocumentsResponse,
|
getDocumentsResponse,
|
||||||
|
getDocumentsStatusRequest,
|
||||||
|
getDocumentsStatusResponse,
|
||||||
getDocumentTypeCountsRequest,
|
getDocumentTypeCountsRequest,
|
||||||
getDocumentTypeCountsResponse,
|
getDocumentTypeCountsResponse,
|
||||||
getSurfsenseDocsByChunkResponse,
|
getSurfsenseDocsByChunkResponse,
|
||||||
|
|
@ -130,6 +133,30 @@ class DocumentsApiService {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch document status for async processing tracking
|
||||||
|
*/
|
||||||
|
getDocumentsStatus = async (request: GetDocumentsStatusRequest) => {
|
||||||
|
const parsedRequest = getDocumentsStatusRequest.safeParse(request);
|
||||||
|
|
||||||
|
if (!parsedRequest.success) {
|
||||||
|
console.error("Invalid request:", parsedRequest.error);
|
||||||
|
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||||
|
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { search_space_id, document_ids } = parsedRequest.data.queryParams;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
search_space_id: String(search_space_id),
|
||||||
|
document_ids: document_ids.join(","),
|
||||||
|
});
|
||||||
|
|
||||||
|
return baseApiService.get(
|
||||||
|
`/api/v1/documents/status?${params.toString()}`,
|
||||||
|
getDocumentsStatusResponse
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search documents by title
|
* Search documents by title
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,12 @@ import {
|
||||||
type CreateImageGenConfigRequest,
|
type CreateImageGenConfigRequest,
|
||||||
createImageGenConfigRequest,
|
createImageGenConfigRequest,
|
||||||
createImageGenConfigResponse,
|
createImageGenConfigResponse,
|
||||||
|
deleteImageGenConfigResponse,
|
||||||
|
getGlobalImageGenConfigsResponse,
|
||||||
|
getImageGenConfigsResponse,
|
||||||
type UpdateImageGenConfigRequest,
|
type UpdateImageGenConfigRequest,
|
||||||
updateImageGenConfigRequest,
|
updateImageGenConfigRequest,
|
||||||
updateImageGenConfigResponse,
|
updateImageGenConfigResponse,
|
||||||
deleteImageGenConfigResponse,
|
|
||||||
getImageGenConfigsResponse,
|
|
||||||
getGlobalImageGenConfigsResponse,
|
|
||||||
} from "@/contracts/types/new-llm-config.types";
|
} from "@/contracts/types/new-llm-config.types";
|
||||||
import { ValidationError } from "../error";
|
import { ValidationError } from "../error";
|
||||||
import { baseApiService } from "./base-api.service";
|
import { baseApiService } from "./base-api.service";
|
||||||
|
|
|
||||||
|
|
@ -10,29 +10,54 @@ const REFRESH_TOKEN_KEY = "surfsense_refresh_token";
|
||||||
let isRefreshing = false;
|
let isRefreshing = false;
|
||||||
let refreshPromise: Promise<string | null> | null = null;
|
let refreshPromise: Promise<string | null> | null = null;
|
||||||
|
|
||||||
|
/** Path prefixes for routes that do not require auth (no current-user fetch, no redirect on 401) */
|
||||||
|
const PUBLIC_ROUTE_PREFIXES = [
|
||||||
|
"/login",
|
||||||
|
"/register",
|
||||||
|
"/auth",
|
||||||
|
"/docs",
|
||||||
|
"/public",
|
||||||
|
"/invite",
|
||||||
|
"/contact",
|
||||||
|
"/pricing",
|
||||||
|
"/privacy",
|
||||||
|
"/terms",
|
||||||
|
"/changelog",
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves the current path and redirects to login page
|
* Returns true if the pathname is a public route where we should not run auth checks
|
||||||
* Call this when a 401 response is received
|
* or redirect to login on 401.
|
||||||
|
*/
|
||||||
|
export function isPublicRoute(pathname: string): boolean {
|
||||||
|
if (pathname === "/" || pathname === "") return true;
|
||||||
|
return PUBLIC_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears tokens and optionally redirects to login.
|
||||||
|
* Call this when a 401 response is received.
|
||||||
|
* Only redirects when the current route is protected; on public routes we just clear tokens.
|
||||||
*/
|
*/
|
||||||
export function handleUnauthorized(): void {
|
export function handleUnauthorized(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
// Save the current path (including search params and hash) for redirect after login
|
const pathname = window.location.pathname;
|
||||||
const currentPath = window.location.pathname + window.location.search + window.location.hash;
|
|
||||||
|
|
||||||
// Don't save auth-related paths
|
// Always clear tokens
|
||||||
const excludedPaths = ["/auth", "/auth/callback", "/"];
|
|
||||||
if (!excludedPaths.includes(window.location.pathname)) {
|
|
||||||
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear both tokens
|
|
||||||
localStorage.removeItem(BEARER_TOKEN_KEY);
|
localStorage.removeItem(BEARER_TOKEN_KEY);
|
||||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
|
||||||
// Redirect to home page (which has login options)
|
// Only redirect on protected routes; stay on public pages (e.g. /docs)
|
||||||
|
if (!isPublicRoute(pathname)) {
|
||||||
|
const currentPath = pathname + window.location.search + window.location.hash;
|
||||||
|
const excludedPaths = ["/auth", "/auth/callback", "/"];
|
||||||
|
if (!excludedPaths.includes(pathname)) {
|
||||||
|
localStorage.setItem(REDIRECT_PATH_KEY, currentPath);
|
||||||
|
}
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the stored redirect path and clears it from storage
|
* Gets the stored redirect path and clears it from storage
|
||||||
|
|
@ -179,7 +204,6 @@ export function getAuthHeaders(additionalHeaders?: Record<string, string>): Reco
|
||||||
/**
|
/**
|
||||||
* Attempts to refresh the access token using the stored refresh token.
|
* Attempts to refresh the access token using the stored refresh token.
|
||||||
* Returns the new access token if successful, null otherwise.
|
* Returns the new access token if successful, null otherwise.
|
||||||
* Exported for use by API services.
|
|
||||||
*/
|
*/
|
||||||
export async function refreshAccessToken(): Promise<string | null> {
|
export async function refreshAccessToken(): Promise<string | null> {
|
||||||
// If already refreshing, wait for that request to complete
|
// If already refreshing, wait for that request to complete
|
||||||
|
|
|
||||||
|
|
@ -1,324 +0,0 @@
|
||||||
/**
|
|
||||||
* Attachment adapter for assistant-ui
|
|
||||||
*
|
|
||||||
* This adapter handles file uploads by:
|
|
||||||
* 1. Uploading the file to the backend /attachments/process endpoint
|
|
||||||
* 2. The backend extracts markdown content using the configured ETL service
|
|
||||||
* 3. The extracted content is stored in the attachment and sent with messages
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { AttachmentAdapter, CompleteAttachment, PendingAttachment } from "@assistant-ui/react";
|
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Supported file types for the attachment adapter
|
|
||||||
*
|
|
||||||
* - Text/Markdown: .md, .markdown, .txt
|
|
||||||
* - Audio (if STT configured): .mp3, .mp4, .mpeg, .mpga, .m4a, .wav, .webm
|
|
||||||
* - Documents (depends on ETL service): .pdf, .docx, .doc, .pptx, .xlsx, .html
|
|
||||||
* - Images: .jpg, .jpeg, .png, .gif, .webp
|
|
||||||
*/
|
|
||||||
const ACCEPTED_FILE_TYPES = [
|
|
||||||
// Text/Markdown (always supported)
|
|
||||||
".md",
|
|
||||||
".markdown",
|
|
||||||
".txt",
|
|
||||||
// Audio files
|
|
||||||
".mp3",
|
|
||||||
".mp4",
|
|
||||||
".mpeg",
|
|
||||||
".mpga",
|
|
||||||
".m4a",
|
|
||||||
".wav",
|
|
||||||
".webm",
|
|
||||||
// Document files (depends on ETL service)
|
|
||||||
".pdf",
|
|
||||||
".docx",
|
|
||||||
".doc",
|
|
||||||
".pptx",
|
|
||||||
".xlsx",
|
|
||||||
".html",
|
|
||||||
// Image files
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".png",
|
|
||||||
".gif",
|
|
||||||
".webp",
|
|
||||||
].join(",");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response from the attachment processing endpoint
|
|
||||||
*/
|
|
||||||
interface ProcessAttachmentResponse {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: "document" | "image" | "file";
|
|
||||||
content: string;
|
|
||||||
contentLength: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extended CompleteAttachment with our custom extractedContent field
|
|
||||||
* We store the extracted text in a custom field so we can access it in onNew
|
|
||||||
* For images, we also store the data URL so it can be displayed after persistence
|
|
||||||
*/
|
|
||||||
export interface ChatAttachment extends CompleteAttachment {
|
|
||||||
extractedContent: string;
|
|
||||||
imageDataUrl?: string; // Base64 data URL for images (persists across page reloads)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process a file through the backend ETL service
|
|
||||||
*/
|
|
||||||
async function processAttachment(file: File): Promise<ProcessAttachmentResponse> {
|
|
||||||
const token = getBearerToken();
|
|
||||||
if (!token) {
|
|
||||||
throw new Error("Not authenticated");
|
|
||||||
}
|
|
||||||
|
|
||||||
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append("file", file);
|
|
||||||
|
|
||||||
const response = await fetch(`${backendUrl}/api/v1/attachments/process`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error("[processAttachment] Error response:", errorText);
|
|
||||||
let errorDetail = "Unknown error";
|
|
||||||
try {
|
|
||||||
const errorJson = JSON.parse(errorText);
|
|
||||||
// FastAPI validation errors return detail as array
|
|
||||||
if (Array.isArray(errorJson.detail)) {
|
|
||||||
errorDetail = errorJson.detail
|
|
||||||
.map((err: { msg?: string; loc?: string[] }) => {
|
|
||||||
const field = err.loc?.join(".") || "unknown";
|
|
||||||
return `${field}: ${err.msg || "validation error"}`;
|
|
||||||
})
|
|
||||||
.join("; ");
|
|
||||||
} else if (typeof errorJson.detail === "string") {
|
|
||||||
errorDetail = errorJson.detail;
|
|
||||||
} else {
|
|
||||||
errorDetail = JSON.stringify(errorJson);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
errorDetail = errorText || `HTTP ${response.status}`;
|
|
||||||
}
|
|
||||||
throw new Error(errorDetail);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store processed results for the send() method
|
|
||||||
const processedAttachments = new Map<string, ProcessAttachmentResponse>();
|
|
||||||
|
|
||||||
// Store image data URLs for attachments (so they persist after File objects are lost)
|
|
||||||
const imageDataUrls = new Map<string, string>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a File to a data URL (base64) for images
|
|
||||||
*/
|
|
||||||
async function fileToDataUrl(file: File): Promise<string> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = () => resolve(reader.result as string);
|
|
||||||
reader.onerror = reject;
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create the attachment adapter for assistant-ui
|
|
||||||
*
|
|
||||||
* This adapter:
|
|
||||||
* 1. Accepts file upload
|
|
||||||
* 2. Processes the file through the backend ETL service
|
|
||||||
* 3. Returns the attachment with extracted markdown content
|
|
||||||
*
|
|
||||||
* The content is stored in the attachment and will be sent with the message.
|
|
||||||
*/
|
|
||||||
export function createAttachmentAdapter(): AttachmentAdapter {
|
|
||||||
return {
|
|
||||||
accept: ACCEPTED_FILE_TYPES,
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Async generator that yields pending states while processing
|
|
||||||
* and returns a pending attachment when done.
|
|
||||||
*
|
|
||||||
* IMPORTANT: The generator should return status: { type: "running", progress: 100 }
|
|
||||||
* NOT status: { type: "complete" }. The "complete" status is set by send().
|
|
||||||
* Returning "complete" from the generator will prevent send() from being called!
|
|
||||||
*
|
|
||||||
* This pattern allows the UI to show a loading indicator
|
|
||||||
* while the file is being processed by the backend.
|
|
||||||
* The send() method is called to finalize the attachment.
|
|
||||||
*/
|
|
||||||
async *add(input: File | { file: File }): AsyncGenerator<PendingAttachment, void> {
|
|
||||||
// Handle both direct File and { file: File } patterns
|
|
||||||
const file = input instanceof File ? input : input.file;
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
console.error("[AttachmentAdapter] No file found in input:", input);
|
|
||||||
throw new Error("No file provided");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a unique ID for this attachment
|
|
||||||
const id = crypto.randomUUID();
|
|
||||||
|
|
||||||
// Determine attachment type from file
|
|
||||||
const attachmentType = file.type.startsWith("image/") ? "image" : "document";
|
|
||||||
|
|
||||||
// Yield initial pending state with "running" status (0% progress)
|
|
||||||
// This triggers the loading indicator in the UI
|
|
||||||
yield {
|
|
||||||
id,
|
|
||||||
type: attachmentType,
|
|
||||||
name: file.name,
|
|
||||||
file,
|
|
||||||
status: { type: "running", reason: "uploading", progress: 0 },
|
|
||||||
} as PendingAttachment;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// For images, convert to data URL so we can display them after persistence
|
|
||||||
if (attachmentType === "image") {
|
|
||||||
const dataUrl = await fileToDataUrl(file);
|
|
||||||
imageDataUrls.set(id, dataUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the file through the backend ETL service
|
|
||||||
const result = await processAttachment(file);
|
|
||||||
|
|
||||||
// Verify we have the required fields
|
|
||||||
if (!result.content) {
|
|
||||||
console.error("[AttachmentAdapter] WARNING: No content received from backend!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the processed result for send()
|
|
||||||
processedAttachments.set(id, result);
|
|
||||||
|
|
||||||
// Create the final pending attachment
|
|
||||||
// IMPORTANT: Use "running" status with progress: 100 to indicate processing is done
|
|
||||||
// but attachment is still pending. The "complete" status will be set by send().
|
|
||||||
// Yield the final state to ensure it gets processed by the UI
|
|
||||||
yield {
|
|
||||||
id,
|
|
||||||
type: result.type,
|
|
||||||
name: result.name,
|
|
||||||
file,
|
|
||||||
status: { type: "running", reason: "uploading", progress: 100 },
|
|
||||||
} as PendingAttachment;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[AttachmentAdapter] Failed to process attachment:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when user sends the message.
|
|
||||||
* Converts the pending attachment to a complete attachment.
|
|
||||||
*/
|
|
||||||
async send(pendingAttachment: PendingAttachment): Promise<ChatAttachment> {
|
|
||||||
const result = processedAttachments.get(pendingAttachment.id);
|
|
||||||
const imageDataUrl = imageDataUrls.get(pendingAttachment.id);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
// Clean up stored result
|
|
||||||
processedAttachments.delete(pendingAttachment.id);
|
|
||||||
if (imageDataUrl) {
|
|
||||||
imageDataUrls.delete(pendingAttachment.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: result.id,
|
|
||||||
type: result.type,
|
|
||||||
name: result.name,
|
|
||||||
contentType: "text/markdown",
|
|
||||||
status: { type: "complete" },
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: result.content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
extractedContent: result.content,
|
|
||||||
imageDataUrl, // Store data URL for images so they can be displayed after persistence
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback if no processed result found
|
|
||||||
console.warn(
|
|
||||||
"[AttachmentAdapter] send() - No processed result found for attachment:",
|
|
||||||
pendingAttachment.id
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
id: pendingAttachment.id,
|
|
||||||
type: pendingAttachment.type,
|
|
||||||
name: pendingAttachment.name,
|
|
||||||
contentType: "text/plain",
|
|
||||||
status: { type: "complete" },
|
|
||||||
content: [],
|
|
||||||
extractedContent: "",
|
|
||||||
imageDataUrl, // Still include data URL if available
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async remove() {
|
|
||||||
// No server-side cleanup needed since we don't persist attachments
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract attachment content for chat request
|
|
||||||
*
|
|
||||||
* This function extracts the content from attachments to be sent with the chat request.
|
|
||||||
* Only attachments that have been fully processed (have content) will be included.
|
|
||||||
*/
|
|
||||||
export function extractAttachmentContent(
|
|
||||||
attachments: Array<unknown>
|
|
||||||
): Array<{ id: string; name: string; type: string; content: string }> {
|
|
||||||
return attachments
|
|
||||||
.filter((att): att is ChatAttachment => {
|
|
||||||
if (!att || typeof att !== "object") return false;
|
|
||||||
const a = att as Record<string, unknown>;
|
|
||||||
// Check for our custom extractedContent field first
|
|
||||||
if (typeof a.extractedContent === "string" && a.extractedContent.length > 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Fallback: check if content array has text content
|
|
||||||
if (Array.isArray(a.content)) {
|
|
||||||
const textContent = (a.content as Array<{ type: string; text?: string }>).find(
|
|
||||||
(c) => c.type === "text" && typeof c.text === "string" && c.text.length > 0
|
|
||||||
);
|
|
||||||
return Boolean(textContent);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
.map((att) => {
|
|
||||||
// Get content from extractedContent or from content array
|
|
||||||
let content = "";
|
|
||||||
if (typeof att.extractedContent === "string") {
|
|
||||||
content = att.extractedContent;
|
|
||||||
} else if (Array.isArray(att.content)) {
|
|
||||||
const textContent = (att.content as Array<{ type: string; text?: string }>).find(
|
|
||||||
(c) => c.type === "text"
|
|
||||||
);
|
|
||||||
content = textContent?.text || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: att.id,
|
|
||||||
name: att.name,
|
|
||||||
type: att.type,
|
|
||||||
content,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +1,9 @@
|
||||||
import type { ThreadMessageLike } from "@assistant-ui/react";
|
import type { ThreadMessageLike } from "@assistant-ui/react";
|
||||||
import { z } from "zod";
|
|
||||||
import type { MessageRecord } from "./thread-persistence";
|
import type { MessageRecord } from "./thread-persistence";
|
||||||
|
|
||||||
/**
|
|
||||||
* Zod schema for persisted attachment info
|
|
||||||
*/
|
|
||||||
const PersistedAttachmentSchema = z.object({
|
|
||||||
id: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
type: z.string(),
|
|
||||||
contentType: z.string().optional(),
|
|
||||||
imageDataUrl: z.string().optional(),
|
|
||||||
extractedContent: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const AttachmentsPartSchema = z.object({
|
|
||||||
type: z.literal("attachments"),
|
|
||||||
items: z.array(PersistedAttachmentSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
type PersistedAttachment = z.infer<typeof PersistedAttachmentSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract persisted attachments from message content (type-safe with Zod)
|
|
||||||
*/
|
|
||||||
function extractPersistedAttachments(content: unknown): PersistedAttachment[] {
|
|
||||||
if (!Array.isArray(content)) return [];
|
|
||||||
|
|
||||||
for (const part of content) {
|
|
||||||
const result = AttachmentsPartSchema.safeParse(part);
|
|
||||||
if (result.success) {
|
|
||||||
return result.data.items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert backend message to assistant-ui ThreadMessageLike format
|
* Convert backend message to assistant-ui ThreadMessageLike format
|
||||||
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
|
* Filters out 'thinking-steps' part as it's handled separately via messageThinkingSteps
|
||||||
* Restores attachments for user messages from persisted data
|
|
||||||
*/
|
*/
|
||||||
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||||
let content: ThreadMessageLike["content"];
|
let content: ThreadMessageLike["content"];
|
||||||
|
|
@ -52,7 +15,7 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||||
const filteredContent = msg.content.filter((part: unknown) => {
|
const filteredContent = msg.content.filter((part: unknown) => {
|
||||||
if (typeof part !== "object" || part === null || !("type" in part)) return true;
|
if (typeof part !== "object" || part === null || !("type" in part)) return true;
|
||||||
const partType = (part as { type: string }).type;
|
const partType = (part as { type: string }).type;
|
||||||
// Filter out thinking-steps, mentioned-documents, and attachments
|
// Filter out metadata parts not directly renderable by assistant-ui
|
||||||
return (
|
return (
|
||||||
partType !== "thinking-steps" &&
|
partType !== "thinking-steps" &&
|
||||||
partType !== "mentioned-documents" &&
|
partType !== "mentioned-documents" &&
|
||||||
|
|
@ -67,25 +30,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||||
content = [{ type: "text", text: String(msg.content) }];
|
content = [{ type: "text", text: String(msg.content) }];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore attachments for user messages
|
|
||||||
let attachments: ThreadMessageLike["attachments"];
|
|
||||||
if (msg.role === "user") {
|
|
||||||
const persistedAttachments = extractPersistedAttachments(msg.content);
|
|
||||||
if (persistedAttachments.length > 0) {
|
|
||||||
attachments = persistedAttachments.map((att) => ({
|
|
||||||
id: att.id,
|
|
||||||
name: att.name,
|
|
||||||
type: att.type as "document" | "image" | "file",
|
|
||||||
contentType: att.contentType || "application/octet-stream",
|
|
||||||
status: { type: "complete" as const },
|
|
||||||
content: [],
|
|
||||||
// Custom fields for our ChatAttachment interface
|
|
||||||
imageDataUrl: att.imageDataUrl,
|
|
||||||
extractedContent: att.extractedContent,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build metadata.custom for author display in shared chats
|
// Build metadata.custom for author display in shared chats
|
||||||
const metadata = msg.author_id
|
const metadata = msg.author_id
|
||||||
? {
|
? {
|
||||||
|
|
@ -103,7 +47,6 @@ export function convertToThreadMessage(msg: MessageRecord): ThreadMessageLike {
|
||||||
role: msg.role,
|
role: msg.role,
|
||||||
content,
|
content,
|
||||||
createdAt: new Date(msg.created_at),
|
createdAt: new Date(msg.created_at),
|
||||||
attachments,
|
|
||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
// Helper function to get connector type display name
|
// Helper function to get connector type display name
|
||||||
export const getConnectorTypeDisplay = (type: string): string => {
|
export const getConnectorTypeDisplay = (type: string): string => {
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
|
SERPER_API: "Serper API",
|
||||||
TAVILY_API: "Tavily API",
|
TAVILY_API: "Tavily API",
|
||||||
SEARXNG_API: "SearxNG",
|
SEARXNG_API: "SearxNG",
|
||||||
|
LINKUP_API: "Linkup",
|
||||||
|
BAIDU_SEARCH_API: "Baidu Search",
|
||||||
SLACK_CONNECTOR: "Slack",
|
SLACK_CONNECTOR: "Slack",
|
||||||
|
TEAMS_CONNECTOR: "Microsoft Teams",
|
||||||
NOTION_CONNECTOR: "Notion",
|
NOTION_CONNECTOR: "Notion",
|
||||||
GITHUB_CONNECTOR: "GitHub",
|
GITHUB_CONNECTOR: "GitHub",
|
||||||
LINEAR_CONNECTOR: "Linear",
|
LINEAR_CONNECTOR: "Linear",
|
||||||
JIRA_CONNECTOR: "Jira",
|
JIRA_CONNECTOR: "Jira",
|
||||||
DISCORD_CONNECTOR: "Discord",
|
DISCORD_CONNECTOR: "Discord",
|
||||||
LINKUP_API: "Linkup",
|
|
||||||
CONFLUENCE_CONNECTOR: "Confluence",
|
CONFLUENCE_CONNECTOR: "Confluence",
|
||||||
BOOKSTACK_CONNECTOR: "BookStack",
|
BOOKSTACK_CONNECTOR: "BookStack",
|
||||||
CLICKUP_CONNECTOR: "ClickUp",
|
CLICKUP_CONNECTOR: "ClickUp",
|
||||||
|
|
@ -23,8 +26,10 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
||||||
LUMA_CONNECTOR: "Luma",
|
LUMA_CONNECTOR: "Luma",
|
||||||
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
ELASTICSEARCH_CONNECTOR: "Elasticsearch",
|
||||||
WEBCRAWLER_CONNECTOR: "Web Pages",
|
WEBCRAWLER_CONNECTOR: "Web Pages",
|
||||||
|
YOUTUBE_CONNECTOR: "YouTube",
|
||||||
CIRCLEBACK_CONNECTOR: "Circleback",
|
CIRCLEBACK_CONNECTOR: "Circleback",
|
||||||
OBSIDIAN_CONNECTOR: "Obsidian",
|
OBSIDIAN_CONNECTOR: "Obsidian",
|
||||||
|
MCP_CONNECTOR: "MCP Server",
|
||||||
};
|
};
|
||||||
return typeMap[type] || type;
|
return typeMap[type] || type;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "surfsense_web",
|
"name": "surfsense_web",
|
||||||
"version": "0.0.12",
|
"version": "0.0.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "SurfSense Frontend",
|
"description": "SurfSense Frontend",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue