Merge remote-tracking branch 'upstream/main' into feature/elasticsearch-connector

This commit is contained in:
Anish Sarkar 2025-10-06 22:25:43 +05:30
commit a7e0bad42a
100 changed files with 4874 additions and 1352 deletions

View file

@ -0,0 +1,47 @@
"""Add COMETAPI to LiteLLMProvider enum
Revision ID: 22
Revises: 21
"""
from collections.abc import Sequence
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "22"
down_revision: str | None = "21"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Add COMETAPI to LiteLLMProvider enum."""
# Add COMETAPI to the enum if it doesn't already exist
op.execute(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_enum
WHERE enumtypid = 'litellmprovider'::regtype
AND enumlabel = 'COMETAPI'
) THEN
ALTER TYPE litellmprovider ADD VALUE 'COMETAPI';
END IF;
END$$;
"""
)
def downgrade() -> None:
"""Remove COMETAPI from LiteLLMProvider enum.
Note: PostgreSQL doesn't support removing enum values directly.
This would require recreating the enum type and updating all dependent objects.
For safety, this downgrade is a no-op.
"""
# PostgreSQL doesn't support removing enum values directly
# This would require a complex migration recreating the enum
pass

View file

@ -102,6 +102,7 @@ class LiteLLMProvider(str, Enum):
NLPCLOUD = "NLPCLOUD"
ALEPH_ALPHA = "ALEPH_ALPHA"
PETALS = "PETALS"
COMETAPI = "COMETAPI"
CUSTOM = "CUSTOM"

View file

@ -22,6 +22,7 @@ from app.schemas import (
DocumentsCreate,
DocumentUpdate,
DocumentWithChunksRead,
PaginatedResponse,
)
from app.services.task_logging_service import TaskLoggingService
from app.tasks.document_processors import (
@ -154,15 +155,36 @@ async def create_documents_file_upload(
) from e
@router.get("/documents/", response_model=list[DocumentRead])
@router.get("/documents/", response_model=PaginatedResponse[DocumentRead])
async def read_documents(
skip: int = 0,
limit: int = 300,
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
List documents owned by the current user, with optional filtering and pagination.
Args:
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'.
page: Zero-based page index used when 'skip' is not provided.
page_size: Number of items per page (default: 50). Use -1 to return all remaining items after the offset.
search_space_id: If provided, restrict results to a specific search space.
session: Database session (injected).
user: Current authenticated user (injected).
Returns:
PaginatedResponse[DocumentRead]: Paginated list of documents visible to the user.
Notes:
- If both 'skip' and 'page' are provided, 'skip' is used.
- Results are scoped to documents owned by the current user.
"""
try:
from sqlalchemy import func
query = (
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
)
@ -171,7 +193,33 @@ async def read_documents(
if search_space_id is not None:
query = query.filter(Document.search_space_id == search_space_id)
result = await session.execute(query.offset(skip).limit(limit))
# Get total count
count_query = (
select(func.count())
.select_from(Document)
.join(SearchSpace)
.filter(SearchSpace.user_id == user.id)
)
if search_space_id is not None:
count_query = count_query.filter(
Document.search_space_id == search_space_id
)
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# Calculate offset
offset = 0
if skip is not None:
offset = skip
elif page is not None:
offset = page * page_size
# Get paginated results
if page_size == -1:
result = await session.execute(query.offset(offset))
else:
result = await session.execute(query.offset(offset).limit(page_size))
db_documents = result.scalars().all()
# Convert database objects to API-friendly format
@ -189,13 +237,106 @@ async def read_documents(
)
)
return api_documents
return PaginatedResponse(items=api_documents, total=total)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch documents: {e!s}"
) from e
@router.get("/documents/search/", response_model=PaginatedResponse[DocumentRead])
async def search_documents(
title: str,
skip: int | None = None,
page: int | None = None,
page_size: int = 50,
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
"""
Search documents by title substring, optionally filtered by search_space_id.
Args:
title: Case-insensitive substring to match against document titles. Required.
skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'. Default: None.
page: Zero-based page index used when 'skip' is not provided. Default: None.
page_size: Number of items per page. Use -1 to return all remaining items after the offset. Default: 50.
search_space_id: Filter results to a specific search space. Default: None.
session: Database session (injected).
user: Current authenticated user (injected).
Returns:
PaginatedResponse[DocumentRead]: Paginated list of documents matching the query and filter.
Notes:
- Title matching uses ILIKE (case-insensitive).
- If both 'skip' and 'page' are provided, 'skip' is used.
"""
try:
from sqlalchemy import func
query = (
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
)
if search_space_id is not None:
query = query.filter(Document.search_space_id == search_space_id)
# Only search by title (case-insensitive)
query = query.filter(Document.title.ilike(f"%{title}%"))
# Get total count
count_query = (
select(func.count())
.select_from(Document)
.join(SearchSpace)
.filter(SearchSpace.user_id == user.id)
)
if search_space_id is not None:
count_query = count_query.filter(
Document.search_space_id == search_space_id
)
count_query = count_query.filter(Document.title.ilike(f"%{title}%"))
total_result = await session.execute(count_query)
total = total_result.scalar() or 0
# Calculate offset
offset = 0
if skip is not None:
offset = skip
elif page is not None:
offset = page * page_size
# Get paginated results
if page_size == -1:
result = await session.execute(query.offset(offset))
else:
result = await session.execute(query.offset(offset).limit(page_size))
db_documents = result.scalars().all()
# Convert database objects to API-friendly format
api_documents = []
for doc in db_documents:
api_documents.append(
DocumentRead(
id=doc.id,
title=doc.title,
document_type=doc.document_type,
document_metadata=doc.document_metadata,
content=doc.content,
created_at=doc.created_at,
search_space_id=doc.search_space_id,
)
)
return PaginatedResponse(items=api_documents, total=total)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to search documents: {e!s}"
) from e
@router.get("/documents/{document_id}", response_model=DocumentRead)
async def read_document(
document_id: int,

View file

@ -16,6 +16,7 @@ from .documents import (
DocumentWithChunksRead,
ExtensionDocumentContent,
ExtensionDocumentMetadata,
PaginatedResponse,
)
from .llm_config import LLMConfigBase, LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate
@ -68,6 +69,7 @@ __all__ = [
"LogFilter",
"LogRead",
"LogUpdate",
"PaginatedResponse",
"PodcastBase",
"PodcastCreate",
"PodcastGenerateRequest",

View file

@ -1,4 +1,5 @@
from datetime import datetime
from typing import TypeVar
from pydantic import BaseModel, ConfigDict
@ -6,6 +7,8 @@ from app.db import DocumentType
from .chunks import ChunkRead
T = TypeVar("T")
class ExtensionDocumentMetadata(BaseModel):
BrowsingSessionId: str
@ -53,3 +56,8 @@ class DocumentWithChunksRead(DocumentRead):
chunks: list[ChunkRead] = []
model_config = ConfigDict(from_attributes=True)
class PaginatedResponse[T](BaseModel):
items: list[T]
total: int

View file

@ -1,6 +1,6 @@
import logging
from langchain_community.chat_models import ChatLiteLLM
from langchain_litellm import ChatLiteLLM
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
@ -81,6 +81,7 @@ async def get_user_llm_instance(
"MISTRAL": "mistral",
"AZURE_OPENAI": "azure",
"OPENROUTER": "openrouter",
"COMETAPI": "cometapi",
# Add more mappings as needed
}
provider_prefix = provider_map.get(

View file

@ -21,7 +21,6 @@ dependencies = [
"langchain-unstructured>=0.1.6",
"langgraph>=0.3.29",
"linkup-sdk>=0.2.4",
"litellm>=1.61.4,<1.70.0",
"llama-cloud-services>=0.6.25",
"markdownify>=0.14.1",
"notion-client>=2.3.0",
@ -42,6 +41,8 @@ dependencies = [
"uvicorn[standard]>=0.34.0",
"validators>=0.34.0",
"youtube-transcript-api>=1.0.3",
"litellm>=1.77.5",
"langchain-litellm>=0.2.3",
"elasticsearch>=9.1.1",
]

View file

@ -1259,6 +1259,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/84/9c2917a70ed570ddbfd1d32ac23200c1d011e36c332e59950d2f6d204941/fastavro-1.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1bc2824e9969c04ab6263d269a1e0e5d40b9bd16ade6b70c29d6ffbc4f3cc102", size = 3387171, upload-time = "2025-05-18T04:55:32.531Z" },
]
[[package]]
name = "fastuuid"
version = "0.13.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/80/3c16a1edad2e6cd82fbd15ac998cc1b881f478bf1f80ca717d941c441874/fastuuid-0.13.5.tar.gz", hash = "sha256:d4976821ab424d41542e1ea39bc828a9d454c3f8a04067c06fca123c5b95a1a1", size = 18255 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/21/36/434f137c5970cac19e57834e1f7680e85301619d49891618c00666700c61/fastuuid-0.13.5-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:35fe8045e866bc6846f8de6fa05acb1de0c32478048484a995e96d31e21dff2a", size = 494638 },
{ url = "https://files.pythonhosted.org/packages/ca/3c/083de2ac007b2b305523b9c006dba5051e5afd87a626ef1a39f76e2c6b82/fastuuid-0.13.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:02a460333f52d731a006d18a52ef6fcb2d295a1f5b1a5938d30744191b2f77b7", size = 253138 },
{ url = "https://files.pythonhosted.org/packages/73/5e/630cffa1c8775db526e39e9e4c5c7db0c27be0786bb21ba82c912ae19f63/fastuuid-0.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:74b0e4f8c307b9f477a5d7284db4431ce53a3c1e3f4173db7a97db18564a6202", size = 244521 },
{ url = "https://files.pythonhosted.org/packages/4d/51/55d78705f4fbdadf88fb40f382f508d6c7a4941ceddd7825fafebb4cc778/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6955a99ef455c2986f3851f4e0ccc35dec56ac1a7720f2b92e88a75d6684512e", size = 271557 },
{ url = "https://files.pythonhosted.org/packages/6a/2b/1b89e90a8635e5587ccdbbeb169c590672ce7637880f2c047482a0359950/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f10c77b826738c1a27dcdaa92ea4dc1ec9d869748a99e1fde54f1379553d4854", size = 272334 },
{ url = "https://files.pythonhosted.org/packages/0c/06/4c8207894eeb30414999e5c3f66ac039bc4003437eb4060d8a1bceb4cc6f/fastuuid-0.13.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb25dccbeb249d16d5e664f65f17ebec05136821d5ef462c4110e3f76b86fb86", size = 290594 },
{ url = "https://files.pythonhosted.org/packages/50/69/96d221931a31d77a47cc2487bdfacfb3091edfc2e7a04b1795df1aec05df/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a5becc646a3eeafb76ce0a6783ba190cd182e3790a8b2c78ca9db2b5e87af952", size = 452835 },
{ url = "https://files.pythonhosted.org/packages/25/ef/bf045f0a47dcec96247497ef3f7a31d86ebc074330e2dccc34b8dbc0468a/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:69b34363752d06e9bb0dbdf02ae391ec56ac948c6f2eb00be90dad68e80774b9", size = 468225 },
{ url = "https://files.pythonhosted.org/packages/30/46/4817ab5a3778927155a4bde92540d4c4fa996161ec8b8e080c8928b0984e/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57d0768afcad0eab8770c9b8cf904716bd3c547e8b9a4e755ee8a673b060a3a3", size = 444907 },
{ url = "https://files.pythonhosted.org/packages/80/27/ab284117ce4dc9b356a7196bdbf220510285f201d27f1f078592cdc8187b/fastuuid-0.13.5-cp312-cp312-win32.whl", hash = "sha256:8ac6c6f5129d52eaa6ef9ea4b6e2f7c69468a053f3ab8e439661186b9c06bb85", size = 145415 },
{ url = "https://files.pythonhosted.org/packages/f4/0c/f970a4222773b248931819f8940800b760283216ca3dda173ed027e94bdd/fastuuid-0.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:ad630e97715beefef07ec37c9c162336e500400774e2c1cbe1a0df6f80d15b9a", size = 150840 },
{ url = "https://files.pythonhosted.org/packages/4f/62/74fc53f6e04a4dc5b36c34e4e679f85a4c14eec800dcdb0f2c14b5442217/fastuuid-0.13.5-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ea17dfd35e0e91920a35d91e65e5f9c9d1985db55ac4ff2f1667a0f61189cefa", size = 494678 },
{ url = "https://files.pythonhosted.org/packages/09/ba/f28b9b7045738a8bfccfb9cd6aff4b91fce2669e6b383a48b0694ee9b3ff/fastuuid-0.13.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:be6ad91e5fefbcc2a4b478858a2715e386d405834ea3ae337c3b6b95cc0e47d6", size = 253162 },
{ url = "https://files.pythonhosted.org/packages/b1/18/13fac89cb4c9f0cd7e81a9154a77ecebcc95d2b03477aa91d4d50f7227ee/fastuuid-0.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ea6df13a306aab3e0439d58c312ff1e6f4f07f09f667579679239b4a6121f64a", size = 244546 },
{ url = "https://files.pythonhosted.org/packages/04/bf/9691167804d59411cc4269841df949f6dd5e76452ab10dcfcd1dbe04c5bc/fastuuid-0.13.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2354c1996d3cf12dc2ba3752e2c4d6edc46e1a38c63893146777b1939f3062d4", size = 271528 },
{ url = "https://files.pythonhosted.org/packages/a9/b5/7a75a03d1c7aa0b6d573032fcca39391f0aef7f2caabeeb45a672bc0bd3c/fastuuid-0.13.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6cf9b7469fc26d1f9b1c43ac4b192e219e85b88fdf81d71aa755a6c08c8a817", size = 272292 },
{ url = "https://files.pythonhosted.org/packages/c0/db/fa0f16cbf76e6880599533af4ef01bb586949c5320612e9d884eff13e603/fastuuid-0.13.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92ba539170097b9047551375f1ca09d8d2b4aefcc79eeae3e1c43fe49b42072e", size = 290466 },
{ url = "https://files.pythonhosted.org/packages/1e/02/6b8c45bfbc8500994dd94edba7f59555f9683c4d8c9a164ae1d25d03c7c7/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:dbb81d05617bc2970765c1ad82db7e8716f6a2b7a361a14b83de5b9240ade448", size = 452838 },
{ url = "https://files.pythonhosted.org/packages/27/12/85d95a84f265b888e8eb9f9e2b5aaf331e8be60c0a7060146364b3544b6a/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:d973bd6bf9d754d3cca874714ac0a6b22a47f239fb3d3c8687569db05aac3471", size = 468149 },
{ url = "https://files.pythonhosted.org/packages/ad/da/dd9a137e9ea707e883c92470113a432233482ec9ad3e9b99c4defc4904e6/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e725ceef79486423f05ee657634d4b4c1ca5fb2c8a94e0708f5d6356a83f2a83", size = 444933 },
{ url = "https://files.pythonhosted.org/packages/12/f4/ab363d7f4ac3989691e2dc5ae2d8391cfb0b4169e52ef7fa0ac363e936f0/fastuuid-0.13.5-cp313-cp313-win32.whl", hash = "sha256:a1c430a332ead0b2674f1ef71b17f43b8139ec5a4201182766a21f131a31e021", size = 145462 },
{ url = "https://files.pythonhosted.org/packages/aa/8a/52eb77d9c294a54caa0d2d8cc9f906207aa6d916a22de963687ab6db8b86/fastuuid-0.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:241fdd362fd96e6b337db62a65dd7cb3dfac20adf854573247a47510e192db6f", size = 150923 },
]
[[package]]
name = "filelock"
version = "3.18.0"
@ -2235,6 +2265,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/f0/31db18b7b8213266aed926ce89b5bdd84ccde7ee2edf4cab14e3dd2bfcf1/langchain_core-0.3.65-py3-none-any.whl", hash = "sha256:80e8faf6e9f331f8ef728f3fe793549f1d3fb244fcf9e1bdcecab6a6f4669394", size = 438052, upload-time = "2025-06-10T20:08:27.393Z" },
]
[[package]]
name = "langchain-litellm"
version = "0.2.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "litellm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/8f/08032033cd4bdff1d177d6a9e3a1021e47c4c63fd1d8c564af6f3c7e9f8d/langchain_litellm-0.2.3.tar.gz", hash = "sha256:0e11687373ae6a99efee5a04d3a76de4fab0e1459edc0e84adb6f60ca76ebf79", size = 10829 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/26/271b1dad80b39a0e9df7ab13f63fa3fad52ce8288ddf73dec32a2212219f/langchain_litellm-0.2.3-py3-none-any.whl", hash = "sha256:422254b8742893aed6380f5ee73e6ae77869b218758edd0888d14ebd2c439352", size = 11571 },
]
[[package]]
name = "langchain-text-splitters"
version = "0.3.8"
@ -2404,11 +2447,12 @@ wheels = [
[[package]]
name = "litellm"
version = "1.69.3"
version = "1.77.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "click" },
{ name = "fastuuid" },
{ name = "httpx" },
{ name = "importlib-metadata" },
{ name = "jinja2" },
@ -2419,9 +2463,9 @@ dependencies = [
{ name = "tiktoken" },
{ name = "tokenizers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/25/2ce407b0ef7e3a2b05300336b7264ec781872c5e60e6ead7a10c9410adcd/litellm-1.69.3.tar.gz", hash = "sha256:748fe9dffea743bf683c9e28e4632c14894863e62c3fbf057560ea7324d89390", size = 7493451, upload-time = "2025-05-15T14:09:44.491Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/a3/85fc92d998ec9645c9fac108618681ef411ca4b338cc7544d6b3aad57699/litellm-1.77.5.tar.gz", hash = "sha256:8e8a83b49c4a6ae044b1a1c01adfbdef72b0031b86f1463dd743e267fa1d7b99", size = 10351819, upload-time = "2025-05-15T14:09:44.491Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/aa/c0/d1538ca18a32adb2669312342e11066e3ddb7dffdf328c1fad3027f78b3e/litellm-1.69.3-py3-none-any.whl", hash = "sha256:31f17024d06824aa8c1798e2c6ac44b69d4f721cefbc3bcd0d53a4568831075e", size = 7761394, upload-time = "2025-05-15T14:09:41.413Z" },
{ url = "https://files.pythonhosted.org/packages/94/4c/89553f7e375ef39497d86f2266a0cdb37371a07e9e0aa8949f33c15a4198/litellm-1.77.5-py3-none-any.whl", hash = "sha256:07f53964c08d555621d4376cc42330458301ae889bfb6303155dcabc51095fbf", size = 9165458, upload-time = "2025-05-15T14:09:41.413Z" },
]
[[package]]
@ -3351,7 +3395,7 @@ wheels = [
[[package]]
name = "openai"
version = "1.75.0"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@ -3363,9 +3407,9 @@ dependencies = [
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/b1/318f5d4c482f19c5fcbcde190801bfaaaec23413cda0b88a29f6897448ff/openai-1.75.0.tar.gz", hash = "sha256:fb3ea907efbdb1bcfd0c44507ad9c961afd7dce3147292b54505ecfd17be8fd1", size = 429492, upload-time = "2025-04-16T16:49:29.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1a/dd/4d4d46a06943e37c95b6e388237e1e38d1e9aab264ff070f86345d60b7a4/openai-2.1.0.tar.gz", hash = "sha256:47f3463a5047340a989b4c0cd5378054acfca966ff61a96553b22f098e3270a2", size = 572998, upload-time = "2025-04-16T16:49:29.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/9a/f34f163294345f123673ed03e77c33dee2534f3ac1f9d18120384457304d/openai-1.75.0-py3-none-any.whl", hash = "sha256:fe6f932d2ded3b429ff67cc9ad118c71327db32eb9d32dd723de3acfca337125", size = 646972, upload-time = "2025-04-16T16:49:27.196Z" },
{ url = "https://files.pythonhosted.org/packages/68/83/88f64fc8f037885efa8a629d1215f5bc1f037453bab4d4f823b5533319eb/openai-2.1.0-py3-none-any.whl", hash = "sha256:33172e8c06a4576144ba4137a493807a9ca427421dcabc54ad3aa656daf757d3", size = 964939, upload-time = "2025-04-16T16:49:27.196Z" },
]
[[package]]
@ -5320,6 +5364,7 @@ dependencies = [
{ name = "google-auth-oauthlib" },
{ name = "kokoro" },
{ name = "langchain-community" },
{ name = "langchain-litellm" },
{ name = "langchain-unstructured" },
{ name = "langgraph" },
{ name = "linkup-sdk" },
@ -5367,10 +5412,11 @@ requires-dist = [
{ name = "google-auth-oauthlib", specifier = ">=1.2.1" },
{ name = "kokoro", specifier = ">=0.9.4" },
{ name = "langchain-community", specifier = ">=0.3.17" },
{ name = "langchain-litellm", specifier = ">=0.2.3" },
{ name = "langchain-unstructured", specifier = ">=0.1.6" },
{ name = "langgraph", specifier = ">=0.3.29" },
{ name = "linkup-sdk", specifier = ">=0.2.4" },
{ name = "litellm", specifier = ">=1.61.4,<1.70.0" },
{ name = "litellm", specifier = ">=1.77.5" },
{ name = "llama-cloud-services", specifier = ">=0.6.25" },
{ name = "markdownify", specifier = ">=0.14.1" },
{ name = "notion-client", specifier = ">=2.3.0" },

View file

@ -1,3 +1,5 @@
NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING
# Contact Form Vars - OPTIONAL
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres

View file

@ -0,0 +1,12 @@
import React from "react";
import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
const page = () => {
return (
<div>
<ContactFormGridWithDetails />
</div>
);
};
export default page;

View file

@ -0,0 +1,14 @@
"use client";
import { Footer } from "@/components/homepage/footer";
import { Navbar } from "@/components/homepage/navbar";
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
return (
<main className="min-h-screen bg-gradient-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 />
{children}
<Footer />
</main>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { IconBrandGoogleFilled } from "@tabler/icons-react";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { Logo } from "@/components/Logo";
import { AmbientBackground } from "./AmbientBackground";

View file

@ -1,5 +1,5 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

View file

@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Loader2 } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";

View file

@ -0,0 +1,21 @@
"use client";
import { CTAHomepage } from "@/components/homepage/cta";
import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
import { FeaturesCards } from "@/components/homepage/features-card";
import { Footer } from "@/components/homepage/footer";
import { HeroSection } from "@/components/homepage/hero-section";
import ExternalIntegrations from "@/components/homepage/integrations";
import { Navbar } from "@/components/homepage/navbar";
export default function HomePage() {
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<HeroSection />
<FeaturesCards />
<FeaturesBentoGrid />
<ExternalIntegrations />
<CTAHomepage />
</main>
);
}

View file

@ -0,0 +1,12 @@
import React from "react";
import PricingBasic from "@/components/pricing/pricing-section";
const page = () => {
return (
<div>
<PricingBasic />
</div>
);
};
export default page;

View file

@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";

View file

@ -0,0 +1,61 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/app/db";
import { usersTable } from "@/app/db/schema";
// Define validation schema matching the database schema
const contactSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.string().email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().default(""),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Validate the request body
const validatedData = contactSchema.parse(body);
// Insert into database
const result = await db
.insert(usersTable)
.values({
name: validatedData.name,
email: validatedData.email,
company: validatedData.company,
message: validatedData.message,
})
.returning();
return NextResponse.json(
{
success: true,
message: "Contact form submitted successfully",
data: result[0],
},
{ status: 201 }
);
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{
success: false,
message: "Validation error",
errors: error.errors,
},
{ status: 400 }
);
}
console.error("Error submitting contact form:", error);
return NextResponse.json(
{
success: false,
message: "Failed to submit contact form",
},
{ status: 500 }
);
}
}

View file

@ -1,7 +1,6 @@
"use client";
import { format } from "date-fns";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import {
Calendar,
CheckCircle,
@ -14,6 +13,7 @@ import {
Tag,
Trash2,
} from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";

View file

@ -1,8 +1,8 @@
"use client";
import { format } from "date-fns";
import { motion } from "framer-motion";
import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@ -41,7 +41,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
import { cn } from "@/lib/utils";
// Helper function to format date with time

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect } from "react";
import { toast } from "sonner";
@ -20,7 +20,7 @@ import {
} from "@/components/ui/card";
import { Form } from "@/components/ui/form";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useConnectorEditPage } from "@/hooks/useConnectorEditPage";
import { useConnectorEditPage } from "@/hooks/use-connector-edit-page";
// Import Utils, Types, Hook, and Components
import { getConnectorTypeDisplay } from "@/lib/connectors/utils";

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input";
import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
} from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const apiConnectorFormSchema = z.object({

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@ -21,7 +21,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
} from "@/hooks/use-search-source-connectors";
export default function AirtableConnectorPage() {
const router = useRouter();

View file

@ -22,7 +22,7 @@ import {
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const clickupConnectorFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const confluenceConnectorFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const discordConnectorFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -39,7 +39,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
// Assuming useSearchSourceConnectors hook exists and works similarly
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod for GitHub PAT entry step
const githubPatFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
@ -23,7 +23,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
} from "@/hooks/use-search-source-connectors";
export default function GoogleCalendarConnectorPage() {
const router = useRouter();

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
@ -23,7 +23,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
} from "@/hooks/use-search-source-connectors";
export default function GoogleGmailConnectorPage() {
const router = useRouter();

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const jiraConnectorFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const linearConnectorFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -30,7 +30,7 @@ import {
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const linkupApiFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Key, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
@ -33,7 +33,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
} from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const lumaConnectorFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const notionConnectorFormSchema = z.object({

View file

@ -6,7 +6,7 @@ import {
IconChevronDown,
IconChevronRight,
} from "@tabler/icons-react";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import { AnimatePresence, motion, type Variants } from "motion/react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useState } from "react";

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -30,7 +30,7 @@ import {
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const serperApiFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const slackConnectorFormSchema = z.object({

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion } from "framer-motion";
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
@ -30,7 +30,7 @@ import {
import { Input } from "@/components/ui/input";
import { EnumConnectorName } from "@/contracts/enums/connector";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
// Define the form schema with Zod
const tavilyApiFormSchema = z.object({

View file

@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import React, { useMemo, useRef } from "react";
import {
AlertDialog,

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
import { motion } from "motion/react";
import React from "react";
import { DocumentViewer } from "@/components/document-viewer";
import { Button } from "@/components/ui/button";

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination";

View file

@ -1,8 +1,8 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useEffect, useId, useMemo, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { useDocuments } from "@/hooks/use-documents";
@ -26,10 +26,6 @@ export default function DocumentsTable() {
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
const { documents, loading, error, refreshDocuments, deleteDocument } =
useDocuments(searchSpaceId);
const [data, setData] = useState<Document[]>([]);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 250);
const [activeTypes, setActiveTypes] = useState<string[]>([]);
@ -45,26 +41,41 @@ export default function DocumentsTable() {
const [sortDesc, setSortDesc] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
useEffect(() => {
if (documents) setData(documents as Document[]);
}, [documents]);
// Use server-side pagination and search
const { documents, total, loading, error, fetchDocuments, searchDocuments, deleteDocument } =
useDocuments(searchSpaceId, {
page: pageIndex,
pageSize: pageSize,
});
const filtered = useMemo(() => {
let result = data;
if (debouncedSearch.trim()) {
const q = debouncedSearch.toLowerCase();
result = result.filter((d) => d.title.toLowerCase().includes(q));
// Refetch when pagination changes or when search/filters change
useEffect(() => {
if (searchSpaceId) {
if (debouncedSearch.trim()) {
// Use search endpoint if there's a search query
searchDocuments?.(debouncedSearch, pageIndex, pageSize);
} else {
// Use regular fetch if no search
fetchDocuments?.(pageIndex, pageSize);
}
}
}, [pageIndex, pageSize, debouncedSearch, searchSpaceId, fetchDocuments, searchDocuments]);
// Client-side filtering for document types only
// Note: This could also be moved to the backend for better performance
const filtered = useMemo(() => {
let result = documents || [];
if (activeTypes.length > 0) {
result = result.filter((d) => activeTypes.includes(d.document_type));
}
return result;
}, [data, debouncedSearch, activeTypes]);
}, [documents, activeTypes]);
const total = filtered.length;
// Display filtered results
const displayDocs = filtered;
const displayTotal = activeTypes.length > 0 ? filtered.length : total;
const pageStart = pageIndex * pageSize;
const pageEnd = Math.min(pageStart + pageSize, total);
const pageDocs = filtered.slice(pageStart, pageEnd);
const pageEnd = Math.min(pageStart + pageSize, displayTotal);
const onToggleType = (type: string, checked: boolean) => {
setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type)));
@ -75,6 +86,14 @@ export default function DocumentsTable() {
setColumnVisibility((prev) => ({ ...prev, [id]: checked }));
};
const refreshCurrentView = useCallback(async () => {
if (debouncedSearch.trim()) {
await searchDocuments?.(debouncedSearch, pageIndex, pageSize);
} else {
await fetchDocuments?.(pageIndex, pageSize);
}
}, [debouncedSearch, pageIndex, pageSize, searchDocuments, fetchDocuments]);
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error("No rows selected");
@ -86,7 +105,8 @@ export default function DocumentsTable() {
if (okCount === selectedIds.size)
toast.success(`Successfully deleted ${okCount} document(s)`);
else toast.error("Some documents could not be deleted");
await refreshDocuments?.();
// Refetch the current page with appropriate method
await refreshCurrentView();
setSelectedIds(new Set());
} catch (e) {
console.error(e);
@ -113,8 +133,8 @@ export default function DocumentsTable() {
className="w-full px-6 py-4"
>
<DocumentsFilters
allDocuments={data}
visibleDocuments={pageDocs}
allDocuments={documents || []}
visibleDocuments={displayDocs}
selectedIds={selectedIds}
onSearch={setSearch}
searchValue={search}
@ -126,12 +146,10 @@ export default function DocumentsTable() {
/>
<DocumentsTableShell
documents={pageDocs}
documents={displayDocs}
loading={!!loading}
error={!!error}
onRefresh={async () => {
await (refreshDocuments?.() ?? Promise.resolve());
}}
onRefresh={refreshCurrentView}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
columnVisibility={columnVisibility}
@ -150,17 +168,17 @@ export default function DocumentsTable() {
<PaginationControls
pageIndex={pageIndex}
pageSize={pageSize}
total={total}
total={displayTotal}
onPageSizeChange={(s) => {
setPageSize(s);
setPageIndex(0);
}}
onFirst={() => setPageIndex(0)}
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
onNext={() => setPageIndex((i) => (pageEnd < total ? i + 1 : i))}
onLast={() => setPageIndex(Math.max(0, Math.ceil(total / pageSize) - 1))}
onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))}
onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / pageSize) - 1))}
canPrev={pageIndex > 0}
canNext={pageEnd < total}
canNext={pageEnd < displayTotal}
id={id}
/>
</motion.div>

View file

@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { useDropzone } from "react-dropzone";

View file

@ -2,8 +2,8 @@
import { IconBrandYoutube } from "@tabler/icons-react";
import { type Tag, TagInput } from "emblor";
import { motion, type Variants } from "framer-motion";
import { Loader2 } from "lucide-react";
import { motion, type Variants } from "motion/react";
import { useParams, useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";

View file

@ -15,7 +15,6 @@ import {
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import { AnimatePresence, motion, type Variants } from "framer-motion";
import {
Activity,
AlertCircle,
@ -42,6 +41,7 @@ import {
X,
Zap,
} from "lucide-react";
import { AnimatePresence, motion, type Variants } from "motion/react";
import { useParams } from "next/navigation";
import React, { useContext, useId, useMemo, useRef, useState } from "react";
import { toast } from "sonner";

View file

@ -5,8 +5,8 @@ import { useParams, useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import type { ResearchMode } from "@/components/chat";
import ChatInterface from "@/components/chat/ChatInterface";
import { useChatAPI, useChatState } from "@/hooks/use-chat";
import type { Document } from "@/hooks/use-documents";
import { useChatAPI, useChatState } from "@/hooks/useChat";
export default function ResearcherPage() {
const { search_space_id, chat_id } = useParams();

View file

@ -1,8 +1,8 @@
"use client";
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";

View file

@ -1,7 +1,7 @@
"use client";
import { motion, type Variants } from "framer-motion";
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
import { motion, type Variants } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";

View file

@ -1,6 +1,6 @@
"use client";
import { motion } from "framer-motion";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { SearchSpaceForm } from "@/components/search-space-form";

View file

@ -0,0 +1,13 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
// Configure postgres client for Vercel serverless environment
const client = postgres(process.env.DATABASE_URL!, {
max: 1, // Limit connections for serverless (Vercel)
idle_timeout: 20, // Close idle connections after 20 seconds
max_lifetime: 60 * 30, // Close connections after 30 minutes
connect_timeout: 10, // Connection timeout in seconds
});
export const db = drizzle({ client, schema });

View file

@ -0,0 +1,9 @@
import { integer, pgTable, text, varchar } from "drizzle-orm/pg-core";
export const usersTable = pgTable("users", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: 255 }).notNull(),
email: varchar({ length: 255 }).notNull().unique(),
company: varchar({ length: 255 }).notNull(),
message: text().default(""),
});

View file

@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";

View file

@ -1,15 +0,0 @@
"use client";
import { Footer } from "@/components/Footer";
import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
import { Navbar } from "@/components/Navbar";
export default function HomePage() {
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<Navbar />
<ModernHeroWithGradients />
<Footer />
</main>
);
}

View file

@ -3,43 +3,55 @@ import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://www.surfsense.net/",
url: "https://www.surfsense.com/",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: "https://www.surfsense.net/privacy",
url: "https://www.surfsense.com/contact",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: "https://www.surfsense.com/pricing",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 0.9,
},
{
url: "https://www.surfsense.com/privacy",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/terms",
url: "https://www.surfsense.com/terms",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs",
url: "https://www.surfsense.com/docs",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/installation",
url: "https://www.surfsense.com/docs/installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/docker-installation",
url: "https://www.surfsense.com/docs/docker-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
url: "https://www.surfsense.net/docs/manual-installation",
url: "https://www.surfsense.com/docs/manual-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,

View file

@ -1,541 +0,0 @@
"use client";
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc } from "@tabler/icons-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { Logo } from "./Logo";
export function ModernHeroWithGradients() {
return (
<div className="relative h-full min-h-[50rem] w-full bg-gray-50 dark:bg-black">
<div className="relative z-20 mx-auto w-full px-4 py-6 md:px-8 lg:px-4">
<div className="relative my-12 overflow-hidden rounded-3xl bg-white py-16 shadow-sm dark:bg-gray-900/80 dark:shadow-lg dark:shadow-purple-900/10 md:py-48 mx-auto w-full max-w-[95%] xl:max-w-[98%]">
<TopLines />
<BottomLines />
<SideLines />
<TopGradient />
<BottomGradient />
<DarkModeGradient />
<div className="relative z-20 flex flex-col items-center justify-center overflow-hidden rounded-3xl p-4 md:p-12 lg:p-16">
<div className="flex justify-center w-full mb-4">
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://trendshift.io/api/badge/repositories/13606"
alt="MODSetter%2FSurfSense | Trendshift"
style={{ width: "250px", height: "55px" }}
width={250}
height={55}
/>
</Link>
</div>
<Link
href="/docs"
className="flex items-center gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-4 py-1 text-center text-sm text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10"
>
<IconFileTypeDoc className="h-4 w-4 text-gray-800 dark:text-white" />
<span>Documentation</span>
</Link>
{/* Import the Logo component or define it in this file */}
<div className="flex items-center justify-center gap-4 mt-10 mb-2">
<div className="h-16 w-16">
<Logo className="rounded-md" />
</div>
<h1 className="bg-gradient-to-b from-gray-800 to-gray-600 bg-clip-text py-4 text-center text-3xl text-transparent dark:from-white dark:to-purple-300 md:text-5xl lg:text-8xl">
SurfSense
</h1>
</div>
<p className="mx-auto max-w-3xl py-6 text-center text-base text-gray-600 dark:text-neutral-300 md:text-lg lg:text-xl">
A Customizable AI Research Agent just like NotebookLM or Perplexity, but connected to
external sources such as Search Engines, Slack, Linear, Jira, ClickUp, Confluence,
Notion, YouTube, GitHub, Discord and more.
</p>
<div className="flex flex-col items-center gap-6 py-6 sm:flex-row">
<Link
href="https://discord.gg/ejRNvftDp9"
className="w-48 gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-5 py-3 text-center text-sm font-medium text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10 flex items-center justify-center"
>
<IconBrandDiscord className="h-5 w-5 mr-2" />
<span>Discord</span>
</Link>
<Link
href="https://github.com/MODSetter/SurfSense"
className="w-48 gap-1 rounded-full border border-transparent bg-gray-800 px-5 py-3 text-center text-sm font-medium text-white shadow-sm hover:bg-gray-700 dark:bg-gradient-to-r dark:from-purple-700 dark:to-indigo-800 dark:text-white dark:hover:from-purple-600 dark:hover:to-indigo-700 flex items-center justify-center"
>
<IconBrandGithub className="h-5 w-5 mr-2" />
<span>GitHub</span>
</Link>
</div>
</div>
</div>
</div>
</div>
);
}
const TopLines = () => {
return (
<svg
width="166"
height="298"
viewBox="0 0 166 298"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="aspect-square pointer-events-none absolute inset-x-0 top-0 h-[100px] w-full md:h-[200px]"
>
<title>Top Lines</title>
<line
y1="-0.5"
x2="406"
y2="-0.5"
transform="matrix(0 1 1 0 1 -108)"
stroke="url(#paint0_linear_254_143)"
/>
<line
y1="-0.5"
x2="406"
y2="-0.5"
transform="matrix(0 1 1 0 34 -108)"
stroke="url(#paint1_linear_254_143)"
/>
<line
y1="-0.5"
x2="406"
y2="-0.5"
transform="matrix(0 1 1 0 67 -108)"
stroke="url(#paint2_linear_254_143)"
/>
<line
y1="-0.5"
x2="406"
y2="-0.5"
transform="matrix(0 1 1 0 100 -108)"
stroke="url(#paint3_linear_254_143)"
/>
<line
y1="-0.5"
x2="406"
y2="-0.5"
transform="matrix(0 1 1 0 133 -108)"
stroke="url(#paint4_linear_254_143)"
/>
<line
y1="-0.5"
x2="406"
y2="-0.5"
transform="matrix(0 1 1 0 166 -108)"
stroke="url(#paint5_linear_254_143)"
/>
<defs>
<linearGradient
id="paint0_linear_254_143"
x1="-7.42412e-06"
y1="0.500009"
x2="405"
y2="0.500009"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint1_linear_254_143"
x1="-7.42412e-06"
y1="0.500009"
x2="405"
y2="0.500009"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint2_linear_254_143"
x1="-7.42412e-06"
y1="0.500009"
x2="405"
y2="0.500009"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint3_linear_254_143"
x1="-7.42412e-06"
y1="0.500009"
x2="405"
y2="0.500009"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint4_linear_254_143"
x1="-7.42412e-06"
y1="0.500009"
x2="405"
y2="0.500009"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint5_linear_254_143"
x1="-7.42412e-06"
y1="0.500009"
x2="405"
y2="0.500009"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
};
const BottomLines = () => {
return (
<svg
width="445"
height="418"
viewBox="0 0 445 418"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="aspect-square pointer-events-none absolute inset-x-0 -bottom-20 z-20 h-[150px] w-full md:h-[300px]"
>
<title>Bottom Lines</title>
<line x1="139.5" y1="418" x2="139.5" y2="12" stroke="url(#paint0_linear_0_1)" />
<line x1="172.5" y1="418" x2="172.5" y2="12" stroke="url(#paint1_linear_0_1)" />
<line x1="205.5" y1="418" x2="205.5" y2="12" stroke="url(#paint2_linear_0_1)" />
<line x1="238.5" y1="418" x2="238.5" y2="12" stroke="url(#paint3_linear_0_1)" />
<line x1="271.5" y1="418" x2="271.5" y2="12" stroke="url(#paint4_linear_0_1)" />
<line x1="304.5" y1="418" x2="304.5" y2="12" stroke="url(#paint5_linear_0_1)" />
<path
d="M1 149L109.028 235.894C112.804 238.931 115 243.515 115 248.361V417"
stroke="url(#paint6_linear_0_1)"
strokeOpacity="0.1"
strokeWidth="1.5"
/>
<path
d="M444 149L335.972 235.894C332.196 238.931 330 243.515 330 248.361V417"
stroke="url(#paint7_linear_0_1)"
strokeOpacity="0.1"
strokeWidth="1.5"
/>
<defs>
<linearGradient
id="paint0_linear_0_1"
x1="140.5"
y1="418"
x2="140.5"
y2="13"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint1_linear_0_1"
x1="173.5"
y1="418"
x2="173.5"
y2="13"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint2_linear_0_1"
x1="206.5"
y1="418"
x2="206.5"
y2="13"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint3_linear_0_1"
x1="239.5"
y1="418"
x2="239.5"
y2="13"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint4_linear_0_1"
x1="272.5"
y1="418"
x2="272.5"
y2="13"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint5_linear_0_1"
x1="305.5"
y1="418"
x2="305.5"
y2="13"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="gray" className="dark:stop-color-white" />
<stop offset="1" stopOpacity="0" />
</linearGradient>
<linearGradient
id="paint6_linear_0_1"
x1="115"
y1="390.591"
x2="-59.1703"
y2="205.673"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
<stop
offset="1"
stopColor="#E8E8E8"
stopOpacity="0"
className="dark:stop-color-[#F8F8F8]"
/>
</linearGradient>
<linearGradient
id="paint7_linear_0_1"
x1="330"
y1="390.591"
x2="504.17"
y2="205.673"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
<stop
offset="1"
stopColor="#E8E8E8"
stopOpacity="0"
className="dark:stop-color-[#F8F8F8]"
/>
</linearGradient>
</defs>
</svg>
);
};
const SideLines = () => {
return (
<svg
width="1382"
height="370"
viewBox="0 0 1382 370"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="pointer-events-none absolute inset-0 z-30 h-full w-full"
>
<title>Side Lines</title>
<path
d="M268 115L181.106 6.97176C178.069 3.19599 173.485 1 168.639 1H0"
stroke="url(#paint0_linear_337_46)"
strokeOpacity="0.1"
strokeWidth="1.5"
/>
<path
d="M1114 115L1200.89 6.97176C1203.93 3.19599 1208.52 1 1213.36 1H1382"
stroke="url(#paint1_linear_337_46)"
strokeOpacity="0.1"
strokeWidth="1.5"
/>
<path
d="M268 255L181.106 363.028C178.069 366.804 173.485 369 168.639 369H0"
stroke="url(#paint2_linear_337_46)"
strokeOpacity="0.1"
strokeWidth="1.5"
/>
<path
d="M1114 255L1200.89 363.028C1203.93 366.804 1208.52 369 1213.36 369H1382"
stroke="url(#paint3_linear_337_46)"
strokeOpacity="0.1"
strokeWidth="1.5"
/>
<defs>
<linearGradient
id="paint0_linear_337_46"
x1="26.4087"
y1="1.00001"
x2="211.327"
y2="175.17"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
<stop
offset="1"
stopColor="#E8E8E8"
stopOpacity="0"
className="dark:stop-color-[#F8F8F8]"
/>
</linearGradient>
<linearGradient
id="paint1_linear_337_46"
x1="1355.59"
y1="1.00001"
x2="1170.67"
y2="175.17"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
<stop
offset="1"
stopColor="#E8E8E8"
stopOpacity="0"
className="dark:stop-color-[#F8F8F8]"
/>
</linearGradient>
<linearGradient
id="paint2_linear_337_46"
x1="26.4087"
y1="369"
x2="211.327"
y2="194.83"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
<stop
offset="1"
stopColor="#E8E8E8"
stopOpacity="0"
className="dark:stop-color-[#F8F8F8]"
/>
</linearGradient>
<linearGradient
id="paint3_linear_337_46"
x1="1355.59"
y1="369"
x2="1170.67"
y2="194.83"
gradientUnits="userSpaceOnUse"
>
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
<stop
offset="1"
stopColor="#E8E8E8"
stopOpacity="0"
className="dark:stop-color-[#F8F8F8]"
/>
</linearGradient>
</defs>
</svg>
);
};
const BottomGradient = ({ className }: { className?: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="851"
height="595"
viewBox="0 0 851 595"
fill="none"
className={cn(
"pointer-events-none absolute -right-80 bottom-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
className
)}
>
<title>Bottom Gradient</title>
<path
d="M118.499 0H532.468L635.375 38.6161L665 194.625L562.093 346H0L24.9473 121.254L118.499 0Z"
fill="url(#paint0_radial_254_132)"
/>
<defs>
<radialGradient
id="paint0_radial_254_132"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(412.5 346) rotate(-91.153) scale(397.581 423.744)"
>
<stop stopColor="#AAD3E9" />
<stop offset="0.25" stopColor="#7FB8D4" />
<stop offset="0.573634" stopColor="#5A9BB8" />
<stop offset="1" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
);
};
const TopGradient = ({ className }: { className?: string }) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1007"
height="997"
viewBox="0 0 1007 997"
fill="none"
className={cn(
"pointer-events-none absolute -left-96 top-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
className
)}
>
<title>Top Gradient</title>
<path
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
fill="url(#paint0_radial_254_135)"
/>
<path
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
fill="url(#paint1_radial_254_135)"
/>
<defs>
<radialGradient
id="paint0_radial_254_135"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(77.0001 15.8894) rotate(90.3625) scale(869.41 413.353)"
>
<stop stopColor="#AAD3E9" />
<stop offset="0.25" stopColor="#7FB8D4" />
<stop offset="0.573634" stopColor="#5A9BB8" />
<stop offset="1" stopOpacity="0" />
</radialGradient>
<radialGradient
id="paint1_radial_254_135"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(127.5 -31) rotate(1.98106) scale(679.906 715.987)"
>
<stop stopColor="#AAD3E9" />
<stop offset="0.283363" stopColor="#7FB8D4" />
<stop offset="0.573634" stopColor="#5A9BB8" />
<stop offset="1" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
);
};
const DarkModeGradient = () => {
return (
<div className="hidden dark:block">
<div className="absolute -left-48 -top-48 h-[800px] w-[800px] rounded-full bg-purple-900/20 blur-[180px]"></div>
<div className="absolute -right-48 -bottom-48 h-[800px] w-[800px] rounded-full bg-indigo-900/20 blur-[180px]"></div>
<div className="absolute left-1/2 top-1/2 h-[400px] w-[400px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-purple-800/10 blur-[120px]"></div>
</div>
);
};

View file

@ -1,286 +0,0 @@
"use client";
import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
import Link from "next/link";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Logo } from "./Logo";
import { ThemeTogglerComponent } from "./theme/theme-toggle";
import { Button } from "./ui/button";
interface NavbarProps {
navItems: {
name: string;
link: string;
}[];
visible: boolean;
}
export const Navbar = () => {
const navItems = [
{
name: "Docs",
link: "/docs",
},
// {
// name: "Product",
// link: "/#product",
// },
// {
// name: "Pricing",
// link: "/#pricing",
// },
];
const ref = useRef<HTMLDivElement>(null);
const { scrollY } = useScroll({
target: ref,
offset: ["start start", "end start"],
});
const [visible, setVisible] = useState<boolean>(false);
useMotionValueEvent(scrollY, "change", (latest) => {
if (latest > 100) {
setVisible(true);
} else {
setVisible(false);
}
});
return (
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
<DesktopNav visible={visible} navItems={navItems} />
<MobileNav visible={visible} navItems={navItems} />
</motion.div>
);
};
const DesktopNav = ({ navItems, visible }: NavbarProps) => {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const handleGoogleLogin = () => {
// Redirect to the login page
window.location.href = "/login";
};
return (
<motion.div
onMouseLeave={() => setHoveredIndex(null)}
animate={{
backdropFilter: "blur(16px)",
background: visible
? "rgba(var(--background-rgb), 0.8)"
: "rgba(var(--background-rgb), 0.6)",
width: visible ? "38%" : "80%",
height: visible ? "48px" : "64px",
y: visible ? 8 : 0,
}}
initial={{
width: "80%",
height: "64px",
background: "rgba(var(--background-rgb), 0.6)",
}}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
className={cn(
"hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]",
visible ? "border dark:border-white/10 border-gray-300/30" : "border-0"
)}
style={
{
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
} as React.CSSProperties
}
>
<div className="flex flex-row items-center gap-2">
<Logo className="h-8 w-8 rounded-md" />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</div>
<div className="flex items-center gap-4">
<motion.div
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
animate={{
scale: visible ? 0.9 : 1,
}}
>
{navItems.map((navItem, idx) => (
<motion.div
key={`nav-item-${navItem.name}`}
onHoverStart={() => setHoveredIndex(idx)}
className="relative"
>
<Link
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
href={navItem.link}
>
<span className="relative z-10">{navItem.name}</span>
{hoveredIndex === idx && (
<motion.div
layoutId="menu-hover"
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: 1.1,
background:
"var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
}}
exit={{
opacity: 0,
scale: 0.8,
transition: {
duration: 0.2,
},
}}
transition={{
type: "spring",
bounce: 0.4,
duration: 0.4,
}}
/>
)}
</Link>
</motion.div>
))}
</motion.div>
<ThemeTogglerComponent />
<AnimatePresence mode="popLayout" initial={false}>
{!visible && (
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{
scale: 1,
opacity: 1,
transition: {
type: "spring",
stiffness: 400,
damping: 25,
},
}}
exit={{
scale: 0.8,
opacity: 0,
transition: {
duration: 0.2,
},
}}
>
<Button
onClick={handleGoogleLogin}
variant="outline"
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
>
<IconUser className="h-4 w-4" />
<span>Sign in</span>
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
);
};
const MobileNav = ({ navItems, visible }: NavbarProps) => {
const [open, setOpen] = useState(false);
const handleGoogleLogin = () => {
// Redirect to the login page
window.location.href = "./login";
};
return (
<motion.div
animate={{
backdropFilter: "blur(16px)",
background: visible
? "rgba(var(--background-rgb), 0.8)"
: "rgba(var(--background-rgb), 0.6)",
width: visible ? "80%" : "90%",
y: visible ? 0 : 8,
borderRadius: open ? "24px" : "full",
padding: "8px 16px",
}}
initial={{
width: "80%",
background: "rgba(var(--background-rgb), 0.6)",
}}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
className={cn(
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
)}
style={
{
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
} as React.CSSProperties
}
>
<div className="flex flex-row justify-between items-center w-full">
<Logo className="h-8 w-8 rounded-md" />
<div className="flex items-center gap-2">
<ThemeTogglerComponent />
{open ? (
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
) : (
<IconMenu2
className="dark:text-white/90 text-gray-800"
onClick={() => setOpen(!open)}
/>
)}
</div>
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{
opacity: 0,
y: -20,
}}
animate={{
opacity: 1,
y: 0,
}}
exit={{
opacity: 0,
y: -20,
}}
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
>
{navItems.map((navItem: { link: string; name: string }) => (
<Link
key={`link-${navItem.name}`}
href={navItem.link}
onClick={() => setOpen(false)}
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
>
<motion.span className="block">{navItem.name}</motion.span>
</Link>
))}
<Button
onClick={handleGoogleLogin}
variant="outline"
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
>
<IconUser className="h-4 w-4" />
<span>Sign in</span>
</Button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};

View file

@ -1,6 +1,6 @@
"use client";
import { useInView } from "framer-motion";
import { useInView } from "motion/react";
import { Manrope } from "next/font/google";
import { useEffect, useMemo, useReducer, useRef } from "react";
import { RoughNotation, RoughNotationGroup } from "react-rough-notation";
@ -85,7 +85,6 @@ const initialState: HighlightState = {
export function AnimatedEmptyState() {
const ref = useRef<HTMLDivElement>(null);
const isInView = useInView(ref);
const { state: sidebarState } = useSidebar();
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
highlightReducer,
initialState

View file

@ -1,233 +1,30 @@
"use client";
import { ChevronDown, ChevronUp, ExternalLink, FileText, Loader2 } from "lucide-react";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useDocumentByChunk } from "@/hooks/use-document-by-chunk";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { SheetTrigger } from "@/components/ui/sheet";
import { SourceDetailSheet } from "./SourceDetailSheet";
export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => {
const chunkId = Number(node?.id);
const sourceType = node?.metadata?.source_type;
const [isOpen, setIsOpen] = useState(false);
const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk();
const chunksContainerRef = useRef<HTMLDivElement>(null);
const highlightedChunkRef = useRef<HTMLDivElement>(null);
const [summaryOpen, setSummaryOpen] = useState(false);
// Check if this is a source type that should render directly from node
const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API";
const handleOpenChange = async (open: boolean) => {
setIsOpen(open);
if (open && chunkId && !isDirectRenderSource) {
await fetchDocumentByChunk(chunkId);
} else if (!open && !isDirectRenderSource) {
clearDocument();
}
};
useEffect(() => {
// Scroll to highlighted chunk when document loads
if (document && highlightedChunkRef.current && chunksContainerRef.current) {
setTimeout(() => {
highlightedChunkRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 100);
}
}, [document]);
const handleUrlClick = (e: React.MouseEvent, url: string) => {
e.preventDefault();
e.stopPropagation();
window.open(url, "_blank", "noopener,noreferrer");
};
const formatDocumentType = (type: string) => {
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
};
return (
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
<SourceDetailSheet
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType={sourceType}
title={node?.metadata?.title || node?.metadata?.group_name || "Source"}
description={node?.text}
url={node?.url}
>
<SheetTrigger asChild>
<span className="text-[10px] font-bold bg-slate-500 hover:bg-slate-600 text-white rounded-full w-4 h-4 inline-flex items-center justify-center align-super cursor-pointer transition-colors">
{index + 1}
</span>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:max-w-5xl lg:max-w-7xl">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center gap-3 text-lg">
{getConnectorIcon(sourceType)}
{document?.title || node?.metadata?.title || node?.metadata?.group_name || "Source"}
</SheetTitle>
<SheetDescription className="text-base mt-2">
{document
? formatDocumentType(document.document_type)
: sourceType && formatDocumentType(sourceType)}
</SheetDescription>
</SheetHeader>
{!isDirectRenderSource && loading && (
<div className="flex items-center justify-center h-64 px-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{!isDirectRenderSource && error && (
<div className="flex items-center justify-center h-64 px-6">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Direct render for TAVILY_API and LINEAR_API */}
{isDirectRenderSource && (
<ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4">
{/* External Link */}
{node?.url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, node.url)}
className="w-full py-3"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
</div>
)}
{/* Source Information */}
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
<h3 className="text-base font-semibold mb-4">Source Information</h3>
<div className="text-sm text-muted-foreground mb-3 font-medium">
{node?.metadata?.title || "Untitled"}
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{node?.text || "No content available"}
</div>
</div>
</div>
</ScrollArea>
)}
{/* API-fetched document content */}
{!isDirectRenderSource && document && (
<ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4">
{/* Document Metadata */}
{document.document_metadata && Object.keys(document.document_metadata).length > 0 && (
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
<h3 className="text-base font-semibold mb-4">Document Information</h3>
<dl className="grid grid-cols-1 gap-3 text-sm">
{Object.entries(document.document_metadata).map(([key, value]) => (
<div key={key} className="flex gap-3">
<dt className="font-medium text-muted-foreground capitalize min-w-0 flex-shrink-0">
{key.replace(/_/g, " ")}:
</dt>
<dd className="text-foreground break-words">{String(value)}</dd>
</div>
))}
</dl>
</div>
)}
{/* External Link */}
{node?.url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, node.url)}
className="w-full py-3"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
</div>
)}
{/* Chunks */}
<div className="space-y-6" ref={chunksContainerRef}>
<div className="mb-4">
{/* Header row: header and button side by side */}
<div className="flex flex-row items-center gap-4">
<h3 className="text-base font-semibold mb-2 md:mb-0">Document Content</h3>
{document.content && (
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleTrigger className="flex items-center gap-2 py-2 px-3 font-medium border rounded-md bg-muted hover:bg-muted/80 transition-colors">
<span>Summary</span>
{summaryOpen ? (
<ChevronUp className="h-4 w-4 transition-transform" />
) : (
<ChevronDown className="h-4 w-4 transition-transform" />
)}
</CollapsibleTrigger>
</Collapsible>
)}
</div>
{/* Expanded summary content: always full width, below the row */}
{document.content && (
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleContent className="pt-2 w-full">
<div className="p-6 bg-muted/50 rounded-lg border">
<MarkdownViewer content={document.content} />
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
{document.chunks.map((chunk, idx) => (
<div
key={chunk.id}
ref={chunk.id === chunkId ? highlightedChunkRef : null}
className={cn(
"p-6 rounded-lg border transition-all duration-300",
chunk.id === chunkId
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/20"
: "bg-background border-border hover:bg-muted/50 hover:border-muted-foreground/20"
)}
>
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Chunk {idx + 1} of {document.chunks.length}
</span>
{chunk.id === chunkId && (
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">
Referenced Chunk
</span>
)}
</div>
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
<MarkdownViewer content={chunk.content} className="max-w-fit" />
</div>
</div>
))}
</div>
</div>
</ScrollArea>
)}
</SheetContent>
</Sheet>
</SourceDetailSheet>
);
};

View file

@ -27,7 +27,7 @@ import {
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { type Document, useDocuments } from "@/hooks/use-documents";
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors";
import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors";
const DocumentSelector = React.memo(
({
@ -40,10 +40,10 @@ const DocumentSelector = React.memo(
const { search_space_id } = useParams();
const [isOpen, setIsOpen] = useState(false);
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(
Number(search_space_id),
true
);
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(Number(search_space_id), {
lazy: true,
pageSize: -1, // Fetch all documents with large page size
});
const handleOpenChange = useCallback(
(open: boolean) => {

View file

@ -9,12 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { SourceDetailSheet } from "./SourceDetailSheet";
interface Source {
id: string;
title: string;
description: string;
url: string;
sourceType: string;
}
interface SourceGroup {
@ -48,6 +50,9 @@ function getSourceIcon(type: string) {
function SourceCard({ source }: { source: Source }) {
const hasUrl = source.url && source.url.trim() !== "";
const chunkId = Number(source.id);
const sourceType = source.sourceType;
const [isOpen, setIsOpen] = useState(false);
// Clean up the description for better display
const cleanDescription = source.description
@ -55,31 +60,54 @@ function SourceCard({ source }: { source: Source }) {
.replace(/\n+/g, " ")
.trim();
const handleUrlClick = (e: React.MouseEvent, url: string) => {
e.preventDefault();
e.stopPropagation();
window.open(url, "_blank", "noopener,noreferrer");
};
return (
<Card className="border-muted hover:border-muted-foreground/20 transition-colors">
<CardHeader className="pb-3 pt-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-medium leading-tight line-clamp-2">
{source.title}
</CardTitle>
{hasUrl && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-muted"
onClick={() => window.open(source.url, "_blank")}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
</div>
</CardHeader>
<CardContent className="pt-0 pb-3">
<CardDescription className="text-xs line-clamp-3 leading-relaxed text-muted-foreground">
{cleanDescription}
</CardDescription>
</CardContent>
</Card>
<SourceDetailSheet
open={isOpen}
onOpenChange={setIsOpen}
chunkId={chunkId}
sourceType={sourceType}
title={source.title}
description={source.description}
url={source.url}
>
<SheetTrigger asChild>
<Card className="border-muted hover:border-muted-foreground/20 transition-colors cursor-pointer">
<CardHeader className="pb-3 pt-3">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-sm font-medium leading-tight line-clamp-2 flex-1">
{source.title}
</CardTitle>
<div className="flex items-center gap-1 flex-shrink-0">
<Badge variant="secondary" className="text-[10px] h-5 px-2 font-mono">
#{chunkId}
</Badge>
{hasUrl && (
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-muted"
onClick={(e) => handleUrlClick(e, source.url)}
>
<ExternalLink className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 pb-3">
<CardDescription className="text-xs line-clamp-3 leading-relaxed text-muted-foreground">
{cleanDescription}
</CardDescription>
</CardContent>
</Card>
</SheetTrigger>
</SourceDetailSheet>
);
}
@ -126,6 +154,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
title: node.metadata.title,
description: node.text,
url: node.url || "",
sourceType: sourceType,
})),
});
}

View file

@ -0,0 +1,244 @@
"use client";
import { ChevronDown, ChevronUp, ExternalLink, Loader2 } from "lucide-react";
import type React from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { useDocumentByChunk } from "@/hooks/use-document-by-chunk";
import { cn } from "@/lib/utils";
interface SourceDetailSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
chunkId: number;
sourceType: string;
title: string;
description?: string;
url?: string;
children?: ReactNode;
}
const formatDocumentType = (type: string) => {
return type
.split("_")
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
.join(" ");
};
export function SourceDetailSheet({
open,
onOpenChange,
chunkId,
sourceType,
title,
description,
url,
children,
}: SourceDetailSheetProps) {
const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk();
const chunksContainerRef = useRef<HTMLDivElement>(null);
const highlightedChunkRef = useRef<HTMLDivElement>(null);
const [summaryOpen, setSummaryOpen] = useState(false);
// Check if this is a source type that should render directly from node
const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API";
useEffect(() => {
if (open && chunkId && !isDirectRenderSource) {
fetchDocumentByChunk(chunkId);
} else if (!open && !isDirectRenderSource) {
clearDocument();
}
}, [open, chunkId, isDirectRenderSource, fetchDocumentByChunk, clearDocument]);
useEffect(() => {
// Scroll to highlighted chunk when document loads
if (document && highlightedChunkRef.current && chunksContainerRef.current) {
setTimeout(() => {
highlightedChunkRef.current?.scrollIntoView({
behavior: "smooth",
block: "start",
});
}, 100);
}
}, [document]);
const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => {
e.preventDefault();
e.stopPropagation();
window.open(clickUrl, "_blank", "noopener,noreferrer");
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
{children}
<SheetContent side="right" className="w-full sm:max-w-5xl lg:max-w-7xl">
<SheetHeader className="px-6 py-4 border-b">
<SheetTitle className="flex items-center gap-3 text-lg">
{getConnectorIcon(sourceType)}
{document?.title || title}
</SheetTitle>
<SheetDescription className="text-base mt-2">
{document
? formatDocumentType(document.document_type)
: sourceType && formatDocumentType(sourceType)}
</SheetDescription>
</SheetHeader>
{!isDirectRenderSource && loading && (
<div className="flex items-center justify-center h-64 px-6">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{!isDirectRenderSource && error && (
<div className="flex items-center justify-center h-64 px-6">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{/* Direct render for TAVILY_API and LINKUP_API */}
{isDirectRenderSource && (
<ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4">
{/* External Link */}
{url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, url)}
className="w-full py-3"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
</div>
)}
{/* Source Information */}
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
<h3 className="text-base font-semibold mb-4">Source Information</h3>
<div className="text-sm text-muted-foreground mb-3 font-medium">
{title || "Untitled"}
</div>
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
{description || "No content available"}
</div>
</div>
</div>
</ScrollArea>
)}
{/* API-fetched document content */}
{!isDirectRenderSource && document && (
<ScrollArea className="h-[calc(100vh-10rem)]">
<div className="px-6 py-4">
{/* Document Metadata */}
{document.document_metadata && Object.keys(document.document_metadata).length > 0 && (
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
<h3 className="text-base font-semibold mb-4">Document Information</h3>
<dl className="grid grid-cols-1 gap-3 text-sm">
{Object.entries(document.document_metadata).map(([key, value]) => (
<div key={key} className="flex gap-3">
<dt className="font-medium text-muted-foreground capitalize min-w-0 flex-shrink-0">
{key.replace(/_/g, " ")}:
</dt>
<dd className="text-foreground break-words">{String(value)}</dd>
</div>
))}
</dl>
</div>
)}
{/* External Link */}
{url && (
<div className="mb-8">
<Button
size="default"
variant="outline"
onClick={(e) => handleUrlClick(e, url)}
className="w-full py-3"
>
<ExternalLink className="mr-2 h-4 w-4" />
Open in Browser
</Button>
</div>
)}
{/* Chunks */}
<div className="space-y-6" ref={chunksContainerRef}>
<div className="mb-4">
{/* Header row: header and button side by side */}
<div className="flex flex-row items-center gap-4">
<h3 className="text-base font-semibold mb-2 md:mb-0">Document Content</h3>
{document.content && (
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleTrigger className="flex items-center gap-2 py-2 px-3 font-medium border rounded-md bg-muted hover:bg-muted/80 transition-colors">
<span>Summary</span>
{summaryOpen ? (
<ChevronUp className="h-4 w-4 transition-transform" />
) : (
<ChevronDown className="h-4 w-4 transition-transform" />
)}
</CollapsibleTrigger>
</Collapsible>
)}
</div>
{/* Expanded summary content: always full width, below the row */}
{document.content && (
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
<CollapsibleContent className="pt-2 w-full">
<div className="p-6 bg-muted/50 rounded-lg border">
<MarkdownViewer content={document.content} />
</div>
</CollapsibleContent>
</Collapsible>
)}
</div>
{document.chunks.map((chunk, idx) => (
<div
key={chunk.id}
ref={chunk.id === chunkId ? highlightedChunkRef : null}
className={cn(
"p-6 rounded-lg border transition-all duration-300",
chunk.id === chunkId
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/20"
: "bg-background border-border hover:bg-muted/50 hover:border-muted-foreground/20"
)}
>
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">
Chunk {idx + 1} of {document.chunks.length}
</span>
{chunk.id === chunkId && (
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">
Referenced Chunk
</span>
)}
</div>
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
<MarkdownViewer content={chunk.content} className="max-w-fit" />
</div>
</div>
))}
</div>
</div>
</ScrollArea>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,358 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { IconMailFilled } from "@tabler/icons-react";
import { motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import type React from "react";
import { useId, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { cn } from "@/lib/utils";
// Define validation schema matching the database schema
const contactFormSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
email: z.string().email("Invalid email address").max(255, "Email is too long"),
company: z.string().min(1, "Company is required").max(255, "Company name is too long"),
message: z.string().optional().default(""),
});
type ContactFormData = z.infer<typeof contactFormSchema>;
export function ContactFormGridWithDetails() {
const [isSubmitting, setIsSubmitting] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm<ContactFormData>({
resolver: zodResolver(contactFormSchema),
});
const onSubmit = async (data: ContactFormData) => {
setIsSubmitting(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok) {
toast.success("Message sent successfully!", {
description: "We will get back to you as soon as possible.",
});
reset();
} else {
toast.error("Failed to send message", {
description: result.message || "Please try again later.",
});
}
} catch (error) {
console.error("Error submitting form:", error);
toast.error("Something went wrong", {
description: "Please try again later.",
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-10 px-4 py-10 md:px-6 md:py-20 lg:grid-cols-2">
<div className="relative flex flex-col items-center overflow-hidden lg:items-start">
<div className="flex items-start justify-start">
<FeatureIconContainer className="flex items-center justify-center overflow-hidden">
<IconMailFilled className="h-6 w-6 text-blue-500" />
</FeatureIconContainer>
</div>
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-left text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
Contact
</h2>
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 md:text-left dark:text-neutral-400">
We'd love to Hear From You.
</p>
<div className="mt-10 hidden flex-col items-center gap-4 md:flex-row lg:flex">
<Link
href="mailto:rohan@surfsense.com"
className="text-sm text-neutral-500 dark:text-neutral-400"
>
rohan@surfsense.com
</Link>
<div className="h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-400" />
<Link
href="https://cal.com/mod-surfsense"
className="text-sm text-neutral-500 dark:text-neutral-400"
>
https://cal.com/mod-surfsense
</Link>
</div>
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 -translate-x-10 items-center justify-center [perspective:800px] [transform-style:preserve-3d] sm:-translate-x-0 lg:-translate-x-32">
<Pin className="h-30 w-85 top-0 left-0" />
<Image
src="/contact/world.svg"
width={500}
height={500}
alt="world map"
className="[transform:rotateX(45deg)_translateZ(0px)] dark:invert dark:filter"
/>
</div>
</div>
<form
onSubmit={handleSubmit(onSubmit)}
className="relative mx-auto flex w-full max-w-2xl flex-col items-start gap-4 overflow-hidden rounded-3xl bg-gradient-to-b from-gray-100 to-gray-200 p-4 sm:p-10 dark:from-neutral-900 dark:to-neutral-950"
>
<Grid size={20} />
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="name"
>
Full name
</label>
<input
id="name"
type="text"
placeholder="John Doe"
{...register("name")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.name ? "border-red-500" : "border-transparent"
)}
/>
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="email"
>
Email Address
</label>
<input
id="email"
type="email"
placeholder="john.doe@example.com"
{...register("email")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.email ? "border-red-500" : "border-transparent"
)}
/>
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="company"
>
Company
</label>
<input
id="company"
type="text"
placeholder="Example Inc."
{...register("company")}
className={cn(
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.company ? "border-red-500" : "border-transparent"
)}
/>
{errors.company && <p className="mt-1 text-xs text-red-500">{errors.company.message}</p>}
</div>
<div className="relative z-20 mb-4 w-full">
<label
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
htmlFor="message"
>
Message <span className="text-neutral-400 text-xs font-normal">(optional)</span>
</label>
<textarea
id="message"
rows={5}
placeholder="Type your message here"
{...register("message")}
className={cn(
"shadow-input w-full rounded-md border bg-white pt-4 pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
errors.message ? "border-red-500" : "border-transparent"
)}
/>
{errors.message && <p className="mt-1 text-xs text-red-500">{errors.message.message}</p>}
</div>
<button
type="submit"
disabled={isSubmitting}
className="relative z-10 flex items-center justify-center rounded-md border border-transparent bg-neutral-800 px-4 py-2 text-sm font-medium text-white shadow-[0px_1px_0px_0px_#FFFFFF20_inset] transition duration-200 hover:bg-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
>
{isSubmitting ? "Submitting..." : "Submit"}
</button>
</form>
</div>
);
}
const Pin = ({ className }: { className?: string }) => {
return (
<motion.div
style={{ transform: "translateZ(1px)" }}
className={cn(
"pointer-events-none absolute z-[60] flex h-40 w-96 items-center justify-center opacity-100 transition duration-500",
className
)}
>
<div className="h-full w-full">
<div className="absolute inset-x-0 top-0 z-20 mx-auto inline-block w-fit rounded-lg bg-neutral-200 px-2 py-1 text-xs font-normal text-neutral-700 dark:bg-neutral-800 dark:text-white">
We are here
<span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-blue-400/0 via-blue-400/90 to-blue-400/0 transition-opacity duration-500"></span>
</div>
<div
style={{
perspective: "800px",
transform: "rotateX(70deg) translateZ(0px)",
}}
className="absolute top-1/2 left-1/2 mt-4 ml-[0.09375rem] -translate-x-1/2 -translate-y-1/2"
>
<>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5, 0],
scale: 1,
}}
transition={{ duration: 6, repeat: Infinity, delay: 0 }}
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
></motion.div>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5, 0],
scale: 1,
}}
transition={{ duration: 6, repeat: Infinity, delay: 2 }}
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
></motion.div>
<motion.div
initial={{ opacity: 0, scale: 0 }}
animate={{
opacity: [0, 1, 0.5, 0],
scale: 1,
}}
transition={{ duration: 6, repeat: Infinity, delay: 4 }}
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
></motion.div>
</>
</div>
<>
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500 blur-[2px]" />
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500" />
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[4px] w-[4px] translate-x-[1.5px] translate-y-[14px] rounded-full bg-blue-600 blur-[3px]" />
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[2px] w-[2px] translate-x-[0.5px] translate-y-[14px] rounded-full bg-blue-300" />
</>
</div>
</motion.div>
);
};
export const FeatureIconContainer = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => {
return (
<div
className={cn(
"relative h-14 w-14 rounded-md bg-gradient-to-b from-gray-50 to-neutral-200 p-[4px] dark:from-neutral-800 dark:to-neutral-950",
className
)}
>
<div
className={cn(
"relative z-20 h-full w-full rounded-[5px] bg-gray-50 dark:bg-neutral-800",
className
)}
>
{children}
</div>
<div className="absolute inset-x-0 bottom-0 z-30 mx-auto h-4 w-full rounded-full bg-neutral-600 opacity-50 blur-lg"></div>
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-500 to-transparent"></div>
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-600 to-transparent dark:h-[8px] dark:blur-sm"></div>
</div>
);
};
export const Grid = ({ pattern, size }: { pattern?: number[][]; size?: number }) => {
const p = pattern ?? [
[9, 3],
[8, 5],
[10, 2],
[7, 4],
[9, 6],
];
return (
<div className="pointer-events-none absolute top-0 left-1/2 -mt-2 -ml-20 h-full w-full [mask-image:linear-gradient(white,transparent)]">
<div className="absolute inset-0 bg-gradient-to-r from-zinc-900/30 to-zinc-900/30 opacity-10 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-zinc-900/30 dark:to-zinc-900/30">
<GridPattern
width={size ?? 20}
height={size ?? 20}
x="-12"
y="4"
squares={p}
className="absolute inset-0 h-full w-full fill-black/100 stroke-black/100 mix-blend-overlay dark:fill-white/100 dark:stroke-white/100"
/>
</div>
</div>
);
};
export function GridPattern({ width, height, x, y, squares, ...props }: any) {
const patternId = useId();
return (
<svg aria-hidden="true" {...props}>
<defs>
<pattern
id={patternId}
width={width}
height={height}
patternUnits="userSpaceOnUse"
x={x}
y={y}
>
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
</pattern>
</defs>
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${patternId})`} />
{squares && (
<svg x={x} y={y} className="overflow-visible" aria-label="Grid squares">
<title>Grid squares</title>
{squares.map(([x, y]: any, idx: number) => (
<rect
strokeWidth="0"
key={`${x}-${y}-${idx}`}
width={width + 1}
height={height + 1}
x={x * width}
y={y * height}
/>
))}
</svg>
)}
</svg>
);
}

View file

@ -0,0 +1,110 @@
"use client";
import { IconMessageCircleQuestion } from "@tabler/icons-react";
import Link from "next/link";
import type React from "react";
import { cn } from "@/lib/utils";
export function CTAHomepage() {
return (
<section className="w-full grid grid-cols-1 md:grid-cols-3 my-20 md:my-20 justify-start relative z-20 max-w-7xl mx-auto bg-gradient-to-br from-gray-100 to-white dark:from-neutral-900 dark:to-neutral-950">
<GridLineHorizontal className="top-0" offset="200px" />
<GridLineHorizontal className="bottom-0 top-auto" offset="200px" />
<GridLineVertical className="left-0" offset="80px" />
<GridLineVertical className="left-auto right-0" offset="80px" />
<div className="md:col-span-2 p-8 md:p-14">
<h2 className="text-left text-neutral-500 dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
Transform how your team{" "}
<span className="font-bold text-black dark:text-white">discovers and collaborates</span>
</h2>
<p className="text-left text-neutral-500 mt-4 max-w-lg dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
Unite your <span className="text-sky-700">team's knowledge</span> in one collaborative
space with <span className="text-indigo-700">intelligent search</span>.
</p>
<div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4">
<Link href="/contact">
<button
type="button"
className="mt-8 flex space-x-2 items-center group text-base px-4 py-2 rounded-lg text-black dark:text-white border border-neutral-200 dark:border-neutral-800 shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset]"
>
<span>Talk to us</span>
<IconMessageCircleQuestion className="text-black dark:text-white group-hover:translate-x-1 stroke-[1px] h-3 w-3 mt-0.5 transition-transform duration-200" />
</button>
</Link>
</div>
</div>
{/* <div className="border-t md:border-t-0 md:border-l border-dashed p-8 md:p-14">
<p className="text-base text-neutral-700 dark:text-neutral-200">
&quot;SurfSense has revolutionized how our team shares and discovers knowledge.
Everyone can contribute and find what they need instantly. True collaboration at scale.&quot;
</p>
<div className="flex flex-col text-sm items-start mt-4 gap-1">
<p className="font-bold text-neutral-800 dark:text-neutral-200">
Sarah Chen
</p>
<p className="text-neutral-500 dark:text-neutral-400">
Research Lead
</p>
</div>
</div> */}
</section>
);
}
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "1px",
"--width": "5px",
"--fade-stop": "90%",
"--offset": offset || "200px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.2)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"absolute w-[calc(100%+var(--offset))] h-[var(--height)] left-[calc(var(--offset)/2*-1)]",
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
);
};
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "5px",
"--width": "1px",
"--fade-stop": "90%",
"--offset": offset || "150px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.2)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"absolute h-[calc(100%+var(--offset))] w-[var(--width)] top-[calc(var(--offset)/2*-1)]",
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
);
};

View file

@ -0,0 +1,448 @@
import { IconMessage, IconMicrophone, IconSearch, IconUsers } from "@tabler/icons-react";
import Image from "next/image";
import React from "react";
import { BentoGrid, BentoGridItem } from "@/components/ui/bento-grid";
export function FeaturesBentoGrid() {
return (
<BentoGrid className="max-w-7xl my-8 mx-auto md:auto-rows-[20rem]">
{items.map((item, i) => (
<BentoGridItem
key={i}
title={item.title}
description={item.description}
header={item.header}
className={item.className}
icon={item.icon}
/>
))}
</BentoGrid>
);
}
const CitationIllustration = () => (
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-blue-950/20 dark:via-purple-950/20 dark:to-pink-950/20 p-4">
<svg viewBox="0 0 400 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
<title>Citation feature illustration showing clickable source reference</title>
{/* Background chat message */}
<g>
{/* Chat bubble */}
<rect
x="20"
y="30"
width="200"
height="60"
rx="12"
className="fill-white dark:fill-neutral-800"
opacity="0.9"
/>
{/* Text lines */}
<line
x1="35"
y1="50"
x2="150"
y2="50"
className="stroke-neutral-400 dark:stroke-neutral-600"
strokeWidth="3"
strokeLinecap="round"
/>
<line
x1="35"
y1="65"
x2="180"
y2="65"
className="stroke-neutral-400 dark:stroke-neutral-600"
strokeWidth="3"
strokeLinecap="round"
/>
{/* Citation badge with glow */}
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
<feMerge>
<feMergeNode in="coloredBlur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Clickable citation */}
<g className="cursor-pointer" filter="url(#glow)">
<rect
x="185"
y="57"
width="28"
height="20"
rx="6"
className="fill-blue-500 dark:fill-blue-600"
/>
<text
x="199"
y="70"
fontSize="12"
fontWeight="bold"
className="fill-white"
textAnchor="middle"
>
[1]
</text>
</g>
</g>
{/* Connecting line with animation effect */}
<g>
<path
d="M 199 77 Q 240 90, 260 110"
className="stroke-blue-500 dark:stroke-blue-400"
strokeWidth="2"
strokeDasharray="4,4"
fill="none"
opacity="0.6"
>
<animate
attributeName="stroke-dashoffset"
from="8"
to="0"
dur="1s"
repeatCount="indefinite"
/>
</path>
{/* Arrow head */}
<polygon
points="258,113 262,110 260,106"
className="fill-blue-500 dark:fill-blue-400"
opacity="0.6"
/>
</g>
{/* Citation popup card */}
<g>
{/* Card shadow */}
<rect
x="245"
y="113"
width="145"
height="75"
rx="10"
className="fill-black"
opacity="0.1"
transform="translate(2, 2)"
/>
{/* Main card */}
<rect
x="245"
y="113"
width="145"
height="75"
rx="10"
className="fill-white dark:fill-neutral-800 stroke-blue-500 dark:stroke-blue-400"
strokeWidth="2"
/>
{/* Card header */}
<rect
x="245"
y="113"
width="145"
height="25"
rx="10"
className="fill-blue-100 dark:fill-blue-900/50"
/>
<line
x1="245"
y1="138"
x2="390"
y2="138"
className="stroke-blue-200 dark:stroke-blue-800"
strokeWidth="1"
/>
{/* Header text */}
<text
x="317.5"
y="128"
fontSize="9"
fontWeight="600"
className="fill-blue-700 dark:fill-blue-300"
textAnchor="middle"
>
Referenced Chunk
</text>
{/* Content lines */}
<line
x1="255"
y1="150"
x2="365"
y2="150"
className="stroke-neutral-600 dark:stroke-neutral-400"
strokeWidth="2.5"
strokeLinecap="round"
/>
<line
x1="255"
y1="162"
x2="340"
y2="162"
className="stroke-neutral-500 dark:stroke-neutral-500"
strokeWidth="2.5"
strokeLinecap="round"
/>
<line
x1="255"
y1="174"
x2="380"
y2="174"
className="stroke-neutral-400 dark:stroke-neutral-600"
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
{/* Sparkle effects */}
<g className="opacity-60">
{/* Sparkle 1 */}
<circle cx="195" cy="45" r="2" className="fill-yellow-400">
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
</circle>
<circle cx="195" cy="45" r="1" className="fill-white">
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
</circle>
{/* Sparkle 2 */}
<circle cx="370" cy="125" r="2" className="fill-purple-400">
<animate
attributeName="opacity"
values="0;1;0"
dur="2.5s"
begin="0.5s"
repeatCount="indefinite"
/>
</circle>
<circle cx="370" cy="125" r="1" className="fill-white">
<animate
attributeName="opacity"
values="0;1;0"
dur="2.5s"
begin="0.5s"
repeatCount="indefinite"
/>
</circle>
{/* Sparkle 3 */}
<circle cx="250" cy="95" r="1.5" className="fill-blue-400">
<animate
attributeName="opacity"
values="0;1;0"
dur="3s"
begin="1s"
repeatCount="indefinite"
/>
</circle>
</g>
{/* AI Sparkle icon in corner */}
<g transform="translate(25, 100)">
<path
d="M 0,0 L 3,-8 L 6,0 L 14,3 L 6,6 L 3,14 L 0,6 L -8,3 Z"
className="fill-purple-500 dark:fill-purple-400"
opacity="0.7"
>
<animateTransform
attributeName="transform"
type="rotate"
from="0 3 3"
to="360 3 3"
dur="8s"
repeatCount="indefinite"
/>
</path>
</g>
</svg>
</div>
);
const CollaborationIllustration = () => (
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
<div
role="img"
aria-label="Illustration of a realtime collaboration in a text editor."
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none"
>
<div className="relative flex h-fit w-fit flex-col items-start">
<div className="w-full text-2xl sm:text-3xl lg:text-4xl leading-tight text-neutral-700 dark:text-neutral-300">
<span className="flex items-stretch flex-wrap">
{/* <span>Real-time </span> */}
<span className="relative bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 px-1">
Real-time
</span>
<span className="relative z-10 inline-flex items-stretch justify-start">
<span className="absolute h-full w-0.5 rounded-b-sm bg-blue-500"></span>
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-blue-500">
Sarah
</span>
</span>
<span>collabo</span>
<span>orat</span>
<span className="relative z-10 inline-flex items-stretch justify-start">
<span className="absolute h-full w-0.5 rounded-b-sm bg-purple-600 dark:bg-purple-500"></span>
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-purple-600 dark:bg-purple-500">
Josh
</span>
</span>
<span>ion</span>
</span>
</div>
</div>
{/* Bottom gradient fade */}
<div className="absolute -right-4 bottom-0 -left-4 h-24 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
{/* Right gradient fade */}
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
</div>
</div>
);
const AnnotationIllustration = () => (
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
<div
role="img"
aria-label="Illustration of a text editor with annotation comments."
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none md:left-1/2"
>
<div className="relative flex h-fit w-fit flex-col items-start justify-center gap-3.5">
{/* Text above the comment box */}
<div className="absolute left-0 h-fit -translate-x-full pr-7 text-3xl sm:text-4xl lg:text-5xl tracking-tight whitespace-nowrap text-neutral-400 dark:text-neutral-600">
<span className="relative">
Add context with
<div className="absolute inset-0 bg-gradient-to-r from-white dark:from-black via-white dark:via-black to-transparent"></div>
</span>{" "}
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
comments
</span>
</div>
{/* Comment card */}
<div className="flex flex-col items-start gap-4 rounded-xl bg-neutral-100 dark:bg-neutral-900/50 px-6 py-5 text-xl sm:text-2xl lg:text-3xl max-w-md">
<div className="truncate leading-normal text-neutral-600 dark:text-neutral-400">
<span>Let's discuss this tomorrow!</span>
</div>
{/* Reaction icons */}
<div className="flex items-center gap-3 opacity-30">
{/* @ icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
>
<title>Mention icon</title>
<g
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
<path d="M11.998 15.6a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2Z" />
<path d="M15.602 8.4v4.44c0 1.326 1.026 2.52 2.28 2.52a2.544 2.544 0 0 0 2.52-2.52V12a8.4 8.4 0 1 0-3.36 6.72" />
</g>
</svg>
{/* Emoji icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="none"
viewBox="0 0 24 24"
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
>
<title>Emoji icon</title>
<g
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.8"
>
<path d="M12.002 20.4a8.4 8.4 0 1 0 0-16.8 8.4 8.4 0 0 0 0 16.8Z" />
<path d="M9 13.8s.9 1.8 3 1.8 3-1.8 3-1.8M9.6 9.6h.008M14.398 9.6h.009M9.597 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6ZM14.402 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6Z" />
</g>
</svg>
{/* Attachment icon */}
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
>
<title>Attachment icon</title>
<path
d="M16.8926 14.0829L12.425 18.4269C10.565 20.2353 7.47136 20.2353 5.61136 18.4269C3.75976 16.6269 3.75976 13.6029 5.61136 11.8029L12.4886 5.11529C13.7294 3.90809 15.7934 3.90689 17.0354 5.11169C18.2714 6.31169 18.2738 8.33009 17.039 9.53249L10.1462 16.2189C9.83929 16.5093 9.43285 16.6711 9.01036 16.6711C8.58786 16.6711 8.18142 16.5093 7.87456 16.2189C7.72623 16.0757 7.60817 15.9042 7.52737 15.7146C7.44656 15.525 7.40466 15.321 7.40416 15.1149C7.40416 14.7009 7.57216 14.3037 7.87456 14.0109L12.4178 9.59849"
stroke="currentColor"
strokeWidth="1.8"
/>
</svg>
</div>
</div>
</div>
{/* Bottom gradient fade */}
<div className="absolute -right-4 bottom-0 -left-4 h-20 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
{/* Right gradient fade */}
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
</div>
</div>
);
const AudioCommentIllustration = () => (
<div className="relative flex w-full h-full min-h-[6rem] overflow-hidden rounded-xl">
<Image
src="/homepage/comments-audio.webp"
alt="Audio Comment Illustration"
fill
className="object-cover"
/>
</div>
);
const items = [
{
title: "Find, Ask, Act",
description:
"Get instant information, detailed updates, and cited answers across company and personal knowledge.",
header: <CitationIllustration />,
className: "md:col-span-2",
icon: <IconSearch className="h-4 w-4 text-neutral-500" />,
},
{
title: "Work Together in Real Time",
description:
"Transform your company docs into multiplayer spaces with live edits, synced content, and presence.",
header: <CollaborationIllustration />,
className: "md:col-span-1",
icon: <IconUsers className="h-4 w-4 text-neutral-500" />,
},
{
title: "Collaborate Beyond Text",
description:
"Create podcasts and multimedia your team can comment on, share, and refine together.",
header: <AudioCommentIllustration />,
className: "md:col-span-1",
icon: <IconMicrophone className="h-4 w-4 text-neutral-500" />,
},
{
title: "Context Where It Counts",
description: "Add comments directly to your chats and docs for clear, in-the-moment feedback.",
header: <AnnotationIllustration />,
className: "md:col-span-2",
icon: <IconMessage className="h-4 w-4 text-neutral-500" />,
},
];

View file

@ -0,0 +1,84 @@
import { Sliders, Users, Workflow } from "lucide-react";
import type { ReactNode } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
export function FeaturesCards() {
return (
<section className="py-2 md:py-8 dark:bg-transparent">
<div className="@container mx-auto max-w-7xl">
<div className="text-center">
<h2 className="text-balance text-4xl font-semibold lg:text-5xl">
Your Team's AI-Powered Knowledge Hub
</h2>
<p className="mt-4">
Powerful features designed to enhance collaboration, boost productivity, and streamline
your workflow.
</p>
</div>
<div className="@min-4xl:max-w-full @min-4xl:grid-cols-3 mx-auto mt-8 grid max-w-sm gap-6 *:text-center md:mt-16">
<Card className="group shadow-black-950/5">
<CardHeader className="pb-3">
<CardDecorator>
<Workflow className="size-6" aria-hidden />
</CardDecorator>
<h3 className="mt-6 font-medium">Streamlined Workflow</h3>
</CardHeader>
<CardContent>
<p className="text-sm">
Centralize all your knowledge and resources in one intelligent workspace. Find what
you need instantly and accelerate decision-making.
</p>
</CardContent>
</Card>
<Card className="group shadow-black-950/5">
<CardHeader className="pb-3">
<CardDecorator>
<Users className="size-6" aria-hidden />
</CardDecorator>
<h3 className="mt-6 font-medium">Seamless Collaboration</h3>
</CardHeader>
<CardContent>
<p className="text-sm">
Work together effortlessly with real-time collaboration tools that keep your entire
team aligned.
</p>
</CardContent>
</Card>
<Card className="group shadow-black-950/5">
<CardHeader className="pb-3">
<CardDecorator>
<Sliders className="size-6" aria-hidden />
</CardDecorator>
<h3 className="mt-6 font-medium">Fully Customizable</h3>
</CardHeader>
<CardContent>
<p className="text-sm">
Choose from 100+ leading LLMs and seamlessly call any model on demand.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
);
}
const CardDecorator = ({ children }: { children: ReactNode }) => (
<div
aria-hidden
className="relative mx-auto size-36 [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
>
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
{children}
</div>
</div>
);

View file

@ -22,7 +22,7 @@ export function Footer() {
];
return (
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 w-full relative overflow-hidden">
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
<div className="flex flex-col items-center justify-center w-full relative">
<div className="mr-0 md:mr-4 md:flex mb-4">

View file

@ -0,0 +1,319 @@
"use client";
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import Link from "next/link";
import React, { useEffect, useRef, useState } from "react";
import Balancer from "react-wrap-balancer";
import { cn } from "@/lib/utils";
export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null);
const parentRef = useRef<HTMLDivElement>(null);
return (
<div
ref={parentRef}
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-20 md:px-8 md:py-40"
>
<BackgroundGrids />
<CollisionMechanism
beamOptions={{
initialX: -400,
translateX: 600,
duration: 7,
repeatDelay: 3,
}}
containerRef={containerRef}
parentRef={parentRef}
/>
<CollisionMechanism
beamOptions={{
initialX: -200,
translateX: 800,
duration: 4,
repeatDelay: 3,
}}
containerRef={containerRef}
parentRef={parentRef}
/>
<CollisionMechanism
beamOptions={{
initialX: 200,
translateX: 1200,
duration: 5,
repeatDelay: 3,
}}
containerRef={containerRef}
parentRef={parentRef}
/>
<CollisionMechanism
containerRef={containerRef}
parentRef={parentRef}
beamOptions={{
initialX: 400,
translateX: 1400,
duration: 6,
repeatDelay: 3,
}}
/>
<h2 className="relative z-50 mx-auto mb-4 mt-4 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
<Balancer>
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="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
<span className="">Built for Teams</span>
</div>
</div>
</Balancer>
</h2>
{/* // 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">
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
your team.
</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">
<Link
href="/contact"
className="group relative z-20 flex h-10 w-full cursor-pointer items-center justify-center space-x-2 rounded-lg bg-black p-px px-4 py-2 text-center text-sm font-semibold leading-6 text-white no-underline transition duration-200 sm:w-52 dark:bg-white dark:text-black"
>
Start Free Trial
</Link>
<Link
href="/pricing"
className="shadow-input group relative z-20 flex h-10 w-full cursor-pointer items-center justify-center space-x-2 rounded-lg bg-white p-px px-4 py-2 text-sm font-semibold leading-6 text-black no-underline transition duration-200 hover:-translate-y-0.5 sm:w-52 dark:bg-neutral-800 dark:text-white"
>
Explore
</Link>
</div>
<div
ref={containerRef}
className="relative mx-auto max-w-7xl rounded-[32px] border border-neutral-200/50 bg-neutral-100 p-2 backdrop-blur-lg md:p-4 dark:border-neutral-700 dark:bg-neutral-800/50"
>
<div className="rounded-[24px] border border-neutral-200 bg-white p-2 dark:border-neutral-700 dark:bg-black">
{/* Light mode image */}
<Image
src="/homepage/temp_hero_light.png"
alt="header"
width={1920}
height={1080}
className="rounded-[20px] block dark:hidden"
/>
{/* Dark mode image */}
<Image
src="/homepage/temp_hero_dark.png"
alt="header"
width={1920}
height={1080}
className="rounded-[20px] hidden dark:block"
/>
</div>
</div>
</div>
);
}
const BackgroundGrids = () => {
return (
<div className="pointer-events-none absolute inset-0 z-0 grid h-full w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full bg-gradient-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
<div className="relative h-full w-full">
<GridLineVertical className="left-0" />
<GridLineVertical className="left-auto right-0" />
</div>
</div>
);
};
const CollisionMechanism = React.forwardRef<
HTMLDivElement,
{
containerRef: React.RefObject<HTMLDivElement | null>;
parentRef: React.RefObject<HTMLDivElement | null>;
beamOptions?: {
initialX?: number;
translateX?: number;
initialY?: number;
translateY?: number;
rotate?: number;
className?: string;
duration?: number;
delay?: number;
repeatDelay?: number;
};
}
>(({ parentRef, containerRef, beamOptions = {} }, ref) => {
const beamRef = useRef<HTMLDivElement>(null);
const [collision, setCollision] = useState<{
detected: boolean;
coordinates: { x: number; y: number } | null;
}>({ detected: false, coordinates: null });
const [beamKey, setBeamKey] = useState(0);
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
useEffect(() => {
const checkCollision = () => {
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
const beamRect = beamRef.current.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
const parentRect = parentRef.current.getBoundingClientRect();
if (beamRect.bottom >= containerRect.top) {
const relativeX = beamRect.left - parentRect.left + beamRect.width / 2;
const relativeY = beamRect.bottom - parentRect.top;
setCollision({
detected: true,
coordinates: { x: relativeX, y: relativeY },
});
setCycleCollisionDetected(true);
if (beamRef.current) {
beamRef.current.style.opacity = "0";
}
}
}
};
const animationInterval = setInterval(checkCollision, 50);
return () => clearInterval(animationInterval);
}, [cycleCollisionDetected, containerRef]);
useEffect(() => {
if (collision.detected && collision.coordinates) {
setTimeout(() => {
setCollision({ detected: false, coordinates: null });
setCycleCollisionDetected(false);
// Set beam opacity to 0
if (beamRef.current) {
beamRef.current.style.opacity = "1";
}
}, 2000);
// Reset the beam animation after a delay
setTimeout(() => {
setBeamKey((prevKey) => prevKey + 1);
}, 2000);
}
}, [collision]);
return (
<>
<motion.div
key={beamKey}
ref={beamRef}
animate="animate"
initial={{
translateY: beamOptions.initialY || "-200px",
translateX: beamOptions.initialX || "0px",
rotate: beamOptions.rotate || -45,
}}
variants={{
animate: {
translateY: beamOptions.translateY || "800px",
translateX: beamOptions.translateX || "700px",
rotate: beamOptions.rotate || -45,
},
}}
transition={{
duration: beamOptions.duration || 8,
repeat: Infinity,
repeatType: "loop",
ease: "linear",
delay: beamOptions.delay || 0,
repeatDelay: beamOptions.repeatDelay || 0,
}}
className={cn(
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-gradient-to-t from-orange-500 via-yellow-500 to-transparent",
beamOptions.className
)}
/>
<AnimatePresence>
{collision.detected && collision.coordinates && (
<Explosion
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
className=""
style={{
left: `${collision.coordinates.x + 20}px`,
top: `${collision.coordinates.y}px`,
transform: "translate(-50%, -50%)",
}}
/>
)}
</AnimatePresence>
</>
);
});
CollisionMechanism.displayName = "CollisionMechanism";
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
const spans = Array.from({ length: 20 }, (_, index) => ({
id: index,
initialX: 0,
initialY: 0,
directionX: Math.floor(Math.random() * 80 - 40),
directionY: Math.floor(Math.random() * -50 - 10),
}));
return (
<div {...props} className={cn("absolute z-50 h-2 w-2", props.className)}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0] }}
exit={{ opacity: 0 }}
transition={{ duration: 1, ease: "easeOut" }}
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-gradient-to-r from-transparent via-orange-500 to-transparent blur-sm"
></motion.div>
{spans.map((span) => (
<motion.span
key={span.id}
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
transition={{ duration: Math.random() * 1.5 + 0.5, ease: "easeOut" }}
className="absolute h-1 w-1 rounded-full bg-gradient-to-b from-orange-500 to-yellow-500"
/>
))}
</div>
);
};
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
return (
<div
style={
{
"--background": "#ffffff",
"--color": "rgba(0, 0, 0, 0.2)",
"--height": "5px",
"--width": "1px",
"--fade-stop": "90%",
"--offset": offset || "150px", //-100px if you want to keep the line inside
"--color-dark": "rgba(255, 255, 255, 0.3)",
maskComposite: "exclude",
} as React.CSSProperties
}
className={cn(
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-[var(--width)]",
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
"[background-size:var(--width)_var(--height)]",
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
"[mask-composite:exclude]",
"z-30",
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
className
)}
></div>
);
};

View file

@ -0,0 +1,185 @@
"use client";
import React, { useEffect, useState } from "react";
interface Integration {
name: string;
icon: string;
}
const INTEGRATIONS: Integration[] = [
// Search
{ name: "Tavily", icon: "https://www.tavily.com/images/logo.svg" },
{
name: "LinkUp",
icon: "https://framerusercontent.com/images/7zeIm6t3f1HaSltkw8upEvsD80.png?scale-down-to=512",
},
// Communication
{ name: "Slack", icon: "https://cdn.simpleicons.org/slack/4A154B" },
{ name: "Discord", icon: "https://cdn.simpleicons.org/discord/5865F2" },
{ name: "Gmail", icon: "https://cdn.simpleicons.org/gmail/EA4335" },
// Project Management
{ name: "Linear", icon: "https://cdn.simpleicons.org/linear/5E6AD2" },
{ name: "Jira", icon: "https://cdn.simpleicons.org/jira/0052CC" },
{ name: "ClickUp", icon: "https://cdn.simpleicons.org/clickup/7B68EE" },
{ name: "Airtable", icon: "https://cdn.simpleicons.org/airtable/18BFFF" },
// Documentation & Knowledge
{ name: "Confluence", icon: "https://cdn.simpleicons.org/confluence/172B4D" },
{ name: "Notion", icon: "https://cdn.simpleicons.org/notion/000000/ffffff" },
// Cloud Storage
{ name: "Google Drive", icon: "https://cdn.simpleicons.org/googledrive/4285F4" },
{ name: "Dropbox", icon: "https://cdn.simpleicons.org/dropbox/0061FF" },
{
name: "Amazon S3",
icon: "https://upload.wikimedia.org/wikipedia/commons/b/bc/Amazon-S3-Logo.svg",
},
// Development
{ name: "GitHub", icon: "https://cdn.simpleicons.org/github/181717/ffffff" },
// Productivity
{ name: "Google Calendar", icon: "https://cdn.simpleicons.org/googlecalendar/4285F4" },
{ name: "Luma", icon: "https://images.lumacdn.com/social-images/default-social-202407.png" },
// Media
{ name: "YouTube", icon: "https://cdn.simpleicons.org/youtube/FF0000" },
];
function SemiCircleOrbit({ radius, centerX, centerY, count, iconSize, startIndex }: any) {
return (
<>
{/* Semi-circle glow background */}
<div className="absolute inset-0 flex justify-center items-start overflow-visible">
<div
className="
w-[800px] h-[800px] rounded-full
bg-[radial-gradient(circle_at_center,rgba(0,0,0,0.15),transparent_70%)]
dark:bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.15),transparent_70%)]
blur-3xl
pointer-events-none
"
style={{
zIndex: 0,
transform: "translateY(-20%)",
}}
/>
</div>
{/* Orbit icons */}
{Array.from({ length: count }).map((_, index) => {
const actualIndex = startIndex + index;
// Skip if we've run out of integrations
if (actualIndex >= INTEGRATIONS.length) return null;
const angle = (index / (count - 1)) * 180;
const x = radius * Math.cos((angle * Math.PI) / 180);
const y = radius * Math.sin((angle * Math.PI) / 180);
const integration = INTEGRATIONS[actualIndex];
// Tooltip positioning — above or below based on angle
const tooltipAbove = angle > 90;
return (
<div
key={index}
className="absolute flex flex-col items-center group"
style={{
left: `${centerX + x - iconSize / 2}px`,
top: `${centerY - y - iconSize / 2}px`,
zIndex: 5,
}}
>
<img
src={integration.icon}
alt={integration.name}
width={iconSize}
height={iconSize}
className="object-contain cursor-pointer transition-transform hover:scale-110"
style={{ minWidth: iconSize, minHeight: iconSize }} // fix accidental shrink
/>
{/* Tooltip */}
<div
className={`absolute ${
tooltipAbove ? "bottom-[calc(100%+8px)]" : "top-[calc(100%+8px)]"
} hidden group-hover:block w-auto min-w-max rounded-lg bg-black px-3 py-1.5 text-xs text-white shadow-lg text-center whitespace-nowrap`}
>
{integration.name}
<div
className={`absolute left-1/2 -translate-x-1/2 w-3 h-3 rotate-45 bg-black ${
tooltipAbove ? "top-full" : "bottom-full"
}`}
></div>
</div>
</div>
);
})}
</>
);
}
export default function ExternalIntegrations() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const updateSize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
updateSize();
window.addEventListener("resize", updateSize);
return () => window.removeEventListener("resize", updateSize);
}, []);
const baseWidth = Math.min(size.width * 0.8, 700);
const centerX = baseWidth / 2;
const centerY = baseWidth * 0.5;
const iconSize =
size.width < 480
? Math.max(24, baseWidth * 0.05)
: size.width < 768
? Math.max(28, baseWidth * 0.06)
: Math.max(32, baseWidth * 0.07);
return (
<section className="py-12 relative min-h-screen w-full overflow-visible">
<div className="relative flex flex-col items-center text-center z-10">
<h1 className="my-6 text-4xl font-bold lg:text-7xl">Integrations</h1>
<p className="mb-12 max-w-2xl text-gray-600 dark:text-gray-400 lg:text-xl">
Integrate with your team's most important tools
</p>
<div
className="relative overflow-visible"
style={{ width: baseWidth, height: baseWidth * 0.7, paddingBottom: "100px" }}
>
<SemiCircleOrbit
radius={baseWidth * 0.22}
centerX={centerX}
centerY={centerY}
count={5}
iconSize={iconSize}
startIndex={0}
/>
<SemiCircleOrbit
radius={baseWidth * 0.36}
centerX={centerX}
centerY={centerY}
count={6}
iconSize={iconSize}
startIndex={5}
/>
<SemiCircleOrbit
radius={baseWidth * 0.5}
centerX={centerX}
centerY={centerY}
count={8}
iconSize={iconSize}
startIndex={11}
/>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,180 @@
"use client";
import { IconBrandDiscord, IconBrandGithub, IconMenu2, IconX } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import Link from "next/link";
import React, { useEffect, useState } from "react";
import { Logo } from "@/components/Logo";
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
import { cn } from "@/lib/utils";
export const Navbar = () => {
const [isScrolled, setIsScrolled] = useState(false);
const navItems = [
{ name: "Home", link: "/" },
{ name: "Pricing", link: "/pricing" },
{ name: "Sign In", link: "/login" },
{ name: "Docs", link: "/docs" },
];
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div className="fixed top-1 left-0 right-0 z-[60] w-full">
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
<MobileNav navItems={navItems} isScrolled={isScrolled} />
</div>
);
};
const DesktopNav = ({ navItems, isScrolled }: any) => {
const [hovered, setHovered] = useState<number | null>(null);
return (
<motion.div
onMouseLeave={() => {
setHovered(null);
}}
className={cn(
"mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full px-4 py-2 lg:flex transition-all duration-300",
isScrolled
? "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50"
: "bg-transparent border border-transparent"
)}
>
<div className="flex flex-row items-center gap-2">
<Logo className="h-8 w-8 rounded-md" />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</div>
<div className="hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2">
{navItems.map((navItem: any, idx: number) => (
<Link
onMouseEnter={() => setHovered(idx)}
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
key={`link=${idx}`}
href={navItem.link}
>
{hovered === idx && (
<motion.div
layoutId="hovered"
className="absolute inset-0 h-full w-full rounded-full bg-gray-100 dark:bg-neutral-800"
/>
)}
<span className="relative z-20">{navItem.name}</span>
</Link>
))}
</div>
<div className="flex items-center gap-2">
<Link
href="https://discord.gg/ejRNvftDp9"
target="_blank"
rel="noopener noreferrer"
className="hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
>
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
</Link>
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
className="hidden rounded-full px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center gap-1.5"
>
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">8.3k</span>
</Link>
<ThemeTogglerComponent />
<Link
href="/contact"
className="hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"
>
Book a call
</Link>
</div>
</motion.div>
);
};
const MobileNav = ({ navItems, isScrolled }: any) => {
const [open, setOpen] = useState(false);
return (
<>
<motion.div
animate={{ borderRadius: open ? "4px" : "2rem" }}
key={String(open)}
className={cn(
"mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-all duration-300",
isScrolled
? "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50"
: "bg-transparent border border-transparent"
)}
>
<div className="flex w-full flex-row items-center justify-between">
<div className="flex flex-row items-center gap-2">
<Logo className="h-8 w-8 rounded-md" />
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
</div>
{open ? (
<IconX className="text-black dark:text-white" onClick={() => setOpen(!open)} />
) : (
<IconMenu2 className="text-black dark:text-white" onClick={() => setOpen(!open)} />
)}
</div>
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-x-0 top-16 z-20 flex w-full flex-col items-start justify-start gap-4 rounded-lg bg-white/80 backdrop-blur-md border border-white/20 shadow-lg px-4 py-8 dark:bg-neutral-950/80 dark:border-neutral-800/50"
>
{navItems.map((navItem: any, idx: number) => (
<Link
key={`link=${idx}`}
href={navItem.link}
className="relative text-neutral-600 dark:text-neutral-300"
>
<motion.span className="block">{navItem.name} </motion.span>
</Link>
))}
<div className="flex w-full items-center gap-2 pt-2">
<Link
href="https://discord.gg/your-server"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
>
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
</Link>
<Link
href="https://github.com/MODSetter/SurfSense"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
>
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">
8.3k
</span>
</Link>
</div>
<button
type="button"
className="w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black"
>
Book a call
</button>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</>
);
};

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { AlertCircle, Bot, Plus, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
@ -17,29 +17,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
import { type CreateLLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs";
const LLM_PROVIDERS = [
{ value: "OPENAI", label: "OpenAI", example: "gpt-4o, gpt-4, gpt-3.5-turbo" },
{
value: "ANTHROPIC",
label: "Anthropic",
example: "claude-3-5-sonnet-20241022, claude-3-opus-20240229",
},
{ value: "GROQ", label: "Groq", example: "llama3-70b-8192, mixtral-8x7b-32768" },
{ value: "COHERE", label: "Cohere", example: "command-r-plus, command-r" },
{ value: "HUGGINGFACE", label: "HuggingFace", example: "microsoft/DialoGPT-medium" },
{ value: "AZURE_OPENAI", label: "Azure OpenAI", example: "gpt-4, gpt-35-turbo" },
{ value: "GOOGLE", label: "Google", example: "gemini-pro, gemini-pro-vision" },
{ value: "AWS_BEDROCK", label: "AWS Bedrock", example: "anthropic.claude-v2" },
{ value: "OLLAMA", label: "Ollama", example: "llama2, codellama" },
{ value: "MISTRAL", label: "Mistral", example: "mistral-large-latest, mistral-medium" },
{ value: "TOGETHER_AI", label: "Together AI", example: "togethercomputer/llama-2-70b-chat" },
{ value: "REPLICATE", label: "Replicate", example: "meta/llama-2-70b-chat" },
{ value: "OPENROUTER", label: "OpenRouter", example: "anthropic/claude-opus-4.1, openai/gpt-5" },
{ value: "CUSTOM", label: "Custom Provider", example: "your-custom-model" },
];
interface AddProviderStepProps {
onConfigCreated?: () => void;
onConfigDeleted?: () => void;

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { ArrowRight, Bot, Brain, CheckCircle, Sparkles, Zap } from "lucide-react";
import { motion } from "motion/react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";

View file

@ -0,0 +1,222 @@
"use client";
import NumberFlow from "@number-flow/react";
import confetti from "canvas-confetti";
import { Check, Star } from "lucide-react";
import { motion } from "motion/react";
import Link from "next/link";
import { useRef, useState } from "react";
import { buttonVariants } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
interface PricingPlan {
name: string;
price: string;
yearlyPrice: string;
period: string;
features: string[];
description: string;
buttonText: string;
href: string;
isPopular: boolean;
}
interface PricingProps {
plans: PricingPlan[];
title?: string;
description?: string;
}
export function Pricing({
plans,
title = "Simple, Transparent Pricing",
description = "Choose the plan that works for you\nAll plans include access to our platform, lead generation tools, and dedicated support.",
}: PricingProps) {
const [isMonthly, setIsMonthly] = useState(true);
const isDesktop = useMediaQuery("(min-width: 768px)");
const switchRef = useRef<HTMLButtonElement>(null);
const handleToggle = (checked: boolean) => {
setIsMonthly(!checked);
if (checked && switchRef.current) {
const rect = switchRef.current.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
confetti({
particleCount: 50,
spread: 60,
origin: {
x: x / window.innerWidth,
y: y / window.innerHeight,
},
colors: [
"hsl(var(--primary))",
"hsl(var(--accent))",
"hsl(var(--secondary))",
"hsl(var(--muted))",
],
ticks: 200,
gravity: 1.2,
decay: 0.94,
startVelocity: 30,
shapes: ["circle"],
});
}
};
return (
<div className="container py-20">
<div className="text-center space-y-4 mb-12">
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h2>
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
</div>
<div className="flex justify-center mb-10">
<label
className="relative inline-flex items-center cursor-pointer"
htmlFor="billing-toggle"
>
<Label>
<Switch
ref={switchRef as any}
checked={!isMonthly}
onCheckedChange={handleToggle}
className="relative"
/>
</Label>
</label>
<span className="ml-2 font-semibold">
Annual billing <span className="text-primary">(Save 20%)</span>
</span>
</div>
<div
className={cn(
"grid grid-cols-1 gap-4",
plans.length === 2 ? "md:grid-cols-2 max-w-5xl mx-auto" : "md:grid-cols-3"
)}
>
{plans.map((plan, index) => (
<motion.div
key={index}
initial={{ y: 50, opacity: 1 }}
whileInView={
isDesktop
? plans.length === 2
? {
y: plan.isPopular ? -20 : 0,
opacity: 1,
scale: plan.isPopular ? 1.0 : 0.96,
}
: {
y: plan.isPopular ? -20 : 0,
opacity: 1,
x: index === 2 ? -30 : index === 0 ? 30 : 0,
scale: index === 0 || index === 2 ? 0.94 : 1.0,
}
: {}
}
viewport={{ once: true }}
transition={{
duration: 1.6,
type: "spring",
stiffness: 100,
damping: 30,
delay: 0.4,
opacity: { duration: 0.5 },
}}
className={cn(
`rounded-2xl border-[1px] p-6 bg-background text-center lg:flex lg:flex-col lg:justify-center relative`,
plan.isPopular ? "border-primary border-2" : "border-border",
"flex flex-col",
!plan.isPopular && "mt-5",
plans.length === 3 && (index === 0 || index === 2)
? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-[10deg]"
: plans.length === 2 && !plan.isPopular
? "z-0"
: "z-10",
plans.length === 3 && index === 0 && "origin-right",
plans.length === 3 && index === 2 && "origin-left"
)}
>
{plan.isPopular && (
<div className="absolute top-0 right-0 bg-primary py-0.5 px-2 rounded-bl-xl rounded-tr-xl flex items-center">
<Star className="text-primary-foreground h-4 w-4 fill-current" />
<span className="text-primary-foreground ml-1 font-sans font-semibold">
Popular
</span>
</div>
)}
<div className="flex-1 flex flex-col">
<p className="text-base font-semibold text-muted-foreground">{plan.name}</p>
<div className="mt-6 flex items-center justify-center gap-x-2">
<span className="text-5xl font-bold tracking-tight text-foreground">
{isNaN(Number(plan.price)) ? (
<span>{isMonthly ? plan.price : plan.yearlyPrice}</span>
) : (
<NumberFlow
value={isMonthly ? Number(plan.price) : Number(plan.yearlyPrice)}
format={{
style: "currency",
currency: "USD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}}
transformTiming={{
duration: 500,
easing: "ease-out",
}}
willChange
className="font-variant-numeric: tabular-nums"
/>
)}
</span>
{plan.period && plan.period !== "Next 3 months" && (
<span className="text-sm font-semibold leading-6 tracking-wide text-muted-foreground">
/ {plan.period}
</span>
)}
</div>
<p className="text-xs leading-5 text-muted-foreground">
{isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"}
</p>
<ul className="mt-5 gap-2 flex flex-col">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<Check className="h-4 w-4 text-primary mt-1 flex-shrink-0" />
<span className="text-left">{feature}</span>
</li>
))}
</ul>
<hr className="w-full my-4" />
<Link
href={plan.href}
className={cn(
buttonVariants({
variant: "outline",
}),
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
plan.isPopular
? "bg-primary text-primary-foreground"
: "bg-background text-foreground"
)}
>
{plan.buttonText}
</Link>
<p className="mt-6 text-xs leading-5 text-muted-foreground">{plan.description}</p>
</div>
</motion.div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,53 @@
"use client";
import { Pricing } from "@/components/pricing";
const demoPlans = [
{
name: "COMMUNITY",
price: "0",
yearlyPrice: "0",
period: "forever",
features: [
"Supports 100+ LLMs",
"Supports local Ollama or vLLM setups",
"6000+ Embedding Models",
"50+ File extensions supported.",
"Podcasts support with local TTS providers.",
"Connects with 15+ external sources.",
"Cross-Browser Extension for dynamic webpages including authenticated content",
"Upcoming: Mergeable MindMaps",
"Upcoming: Note Management",
],
description: "Open source version with powerful features",
buttonText: "Get Started",
href: "/docs",
isPopular: true,
},
{
name: "ENTERPRISE",
price: "Contact Us",
yearlyPrice: "Contact Us",
period: "",
features: [
"Everything in Community",
"Priority Support",
"Access Controls",
"Collaboration and multiplayer features",
"Video generation",
"Advanced security features",
],
description: "For large organizations with specific needs",
buttonText: "Contact Sales",
href: "/contact",
isPopular: false,
},
];
function PricingBasic() {
return (
<Pricing plans={demoPlans} title="SurfSense Pricing" description="Choose that works for you" />
);
}
export default PricingBasic;

View file

@ -1,8 +1,8 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { motion, type Variants } from "framer-motion";
import { MoveLeftIcon, Plus, Search, Trash2 } from "lucide-react";
import { motion, type Variants } from "motion/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";

View file

@ -1,6 +1,5 @@
"use client";
import { motion } from "framer-motion";
import {
AlertCircle,
Bot,
@ -13,6 +12,7 @@ import {
Settings2,
Zap,
} from "lucide-react";
import { motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";

View file

@ -1,6 +1,5 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import {
AlertCircle,
Bot,
@ -15,6 +14,7 @@ import {
Settings2,
Trash2,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
@ -37,95 +37,9 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
import { type CreateLLMConfig, type LLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs";
const LLM_PROVIDERS = [
{
value: "OPENAI",
label: "OpenAI",
example: "gpt-4o, gpt-4, gpt-3.5-turbo",
description: "Most popular and versatile AI models",
},
{
value: "ANTHROPIC",
label: "Anthropic",
example: "claude-3-5-sonnet-20241022, claude-3-opus-20240229",
description: "Constitutional AI with strong reasoning",
},
{
value: "GROQ",
label: "Groq",
example: "llama3-70b-8192, mixtral-8x7b-32768",
description: "Ultra-fast inference speeds",
},
{
value: "COHERE",
label: "Cohere",
example: "command-r-plus, command-r",
description: "Enterprise-focused language models",
},
{
value: "HUGGINGFACE",
label: "HuggingFace",
example: "microsoft/DialoGPT-medium",
description: "Open source model hub",
},
{
value: "AZURE_OPENAI",
label: "Azure OpenAI",
example: "gpt-4, gpt-35-turbo",
description: "Enterprise OpenAI through Azure",
},
{
value: "GOOGLE",
label: "Google",
example: "gemini-pro, gemini-pro-vision",
description: "Google's Gemini AI models",
},
{
value: "AWS_BEDROCK",
label: "AWS Bedrock",
example: "anthropic.claude-v2",
description: "AWS managed AI service",
},
{
value: "OLLAMA",
label: "Ollama",
example: "llama2, codellama",
description: "Run models locally",
},
{
value: "MISTRAL",
label: "Mistral",
example: "mistral-large-latest, mistral-medium",
description: "European AI excellence",
},
{
value: "TOGETHER_AI",
label: "Together AI",
example: "togethercomputer/llama-2-70b-chat",
description: "Decentralized AI platform",
},
{
value: "REPLICATE",
label: "Replicate",
example: "meta/llama-2-70b-chat",
description: "Run models via API",
},
{
value: "OPENROUTER",
label: "OpenRouter",
example: "anthropic/claude-opus-4.1, openai/gpt-5",
description: "API gateway and LLM marketplace that provides unified access ",
},
{
value: "CUSTOM",
label: "Custom Provider",
example: "your-custom-model",
description: "Your own model endpoint",
},
];
export function ModelConfigManager() {
const {
llmConfigs,

View file

@ -1,7 +1,7 @@
"use client";
import { motion } from "framer-motion";
import { MoonIcon, SunIcon } from "lucide-react";
import { motion } from "motion/react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";

View file

@ -0,0 +1,54 @@
import { cn } from "@/lib/utils";
export const BentoGrid = ({
className,
children,
}: {
className?: string;
children?: React.ReactNode;
}) => {
return (
<div
className={cn(
"mx-auto grid max-w-7xl grid-cols-1 gap-4 md:auto-rows-[18rem] md:grid-cols-3",
className
)}
>
{children}
</div>
);
};
export const BentoGridItem = ({
className,
title,
description,
header,
icon,
}: {
className?: string;
title?: string | React.ReactNode;
description?: string | React.ReactNode;
header?: React.ReactNode;
icon?: React.ReactNode;
}) => {
return (
<div
className={cn(
"group/bento shadow-input row-span-1 flex flex-col justify-between space-y-4 rounded-xl border border-neutral-200 bg-white p-4 transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-black dark:shadow-none",
className
)}
>
{header}
<div className="transition duration-200 group-hover/bento:translate-x-2">
{icon}
<div className="mt-2 mb-2 font-sans font-bold text-neutral-600 dark:text-neutral-200">
{title}
</div>
<div className="font-sans text-xs font-normal text-neutral-600 dark:text-neutral-300">
{description}
</div>
</div>
</div>
);
};

View file

@ -1,5 +1,5 @@
"use client";
import { motion, type SpringOptions, useSpring, useTransform } from "framer-motion";
import { motion, type SpringOptions, useSpring, useTransform } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";

View file

@ -0,0 +1,29 @@
"use client";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "@/lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch };

View file

@ -8,7 +8,7 @@ import {
useMotionValue,
useSpring,
useTransform,
} from "framer-motion";
} from "motion/react";
import type React from "react";
import { useRef } from "react";

View file

@ -0,0 +1,99 @@
export interface LLMProvider {
value: string;
label: string;
example: string;
description: string;
}
export const LLM_PROVIDERS: LLMProvider[] = [
{
value: "OPENAI",
label: "OpenAI",
example: "gpt-4o, gpt-4, gpt-3.5-turbo",
description: "Industry-leading GPT models with broad capabilities",
},
{
value: "ANTHROPIC",
label: "Anthropic",
example: "claude-3-5-sonnet-20241022, claude-3-opus-20240229",
description: "Claude models with strong reasoning and long context windows",
},
{
value: "GROQ",
label: "Groq",
example: "llama3-70b-8192, mixtral-8x7b-32768",
description: "Lightning-fast inference with custom LPU hardware",
},
{
value: "COHERE",
label: "Cohere",
example: "command-r-plus, command-r",
description: "Enterprise NLP models optimized for business applications",
},
{
value: "HUGGINGFACE",
label: "HuggingFace",
example: "microsoft/DialoGPT-medium",
description: "Access thousands of open-source models",
},
{
value: "AZURE_OPENAI",
label: "Azure OpenAI",
example: "gpt-4, gpt-35-turbo",
description: "OpenAI models with Microsoft Azure enterprise features",
},
{
value: "GOOGLE",
label: "Google",
example: "gemini-pro, gemini-pro-vision",
description: "Gemini models with multimodal capabilities",
},
{
value: "AWS_BEDROCK",
label: "AWS Bedrock",
example: "anthropic.claude-v2",
description: "Fully managed foundation models on AWS infrastructure",
},
{
value: "OLLAMA",
label: "Ollama",
example: "llama2, codellama",
description: "Run open-source models locally on your machine",
},
{
value: "MISTRAL",
label: "Mistral",
example: "mistral-large-latest, mistral-medium",
description: "High-performance open-source models from Europe",
},
{
value: "TOGETHER_AI",
label: "Together AI",
example: "togethercomputer/llama-2-70b-chat",
description: "Scalable cloud platform for open-source models",
},
{
value: "REPLICATE",
label: "Replicate",
example: "meta/llama-2-70b-chat",
description: "Cloud API for running machine learning models",
},
{
value: "OPENROUTER",
label: "OpenRouter",
example: "anthropic/claude-opus-4.1, openai/gpt-5",
description: "Unified API gateway for multiple LLM providers",
},
{
value: "COMETAPI",
label: "CometAPI",
example: "gpt-5-mini, claude-sonnet-4-5",
description: "Access 500+ AI models through one unified API",
},
{
value: "CUSTOM",
label: "Custom Provider",
example: "your-custom-model",
description: "Connect to your own custom model endpoint",
},
];

View file

@ -0,0 +1,11 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./drizzle",
schema: "./app/db/schema.ts",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL!,
},
});

View file

@ -1,3 +1,3 @@
export * from "./use-document-by-chunk";
export * from "./use-logs";
export * from "./useSearchSourceConnectors";
export * from "./use-search-source-connectors";

View file

@ -14,7 +14,7 @@ import {
import {
type SearchSourceConnector,
useSearchSourceConnectors,
} from "@/hooks/useSearchSourceConnectors";
} from "@/hooks/use-search-source-connectors";
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
const router = useRouter();

View file

@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { normalizeListResponse } from "@/lib/pagination";
export interface Document {
id: number;
@ -30,43 +31,76 @@ export type DocumentType =
| "AIRTABLE_CONNECTOR"
| "LUMA_CONNECTOR";
export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
export interface UseDocumentsOptions {
page?: number;
pageSize?: number;
lazy?: boolean;
}
export function useDocuments(searchSpaceId: number, options?: UseDocumentsOptions | boolean) {
// Support both old boolean API and new options API for backward compatibility
const opts = typeof options === "boolean" ? { lazy: options } : options || {};
const { page, pageSize = 300, lazy = false } = opts;
const [documents, setDocuments] = useState<Document[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(!lazy); // Don't show loading initially for lazy mode
const [error, setError] = useState<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false); // Memoization flag
const fetchDocuments = useCallback(async () => {
if (isLoaded && lazy) return; // Avoid redundant calls in lazy mode
const fetchDocuments = useCallback(
async (fetchPage?: number, fetchPageSize?: number) => {
if (isLoaded && lazy) return; // Avoid redundant calls in lazy mode
try {
setLoading(true);
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?search_space_id=${searchSpaceId}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
method: "GET",
try {
setLoading(true);
// Build query params
const params = new URLSearchParams({
search_space_id: searchSpaceId.toString(),
});
// Use passed parameters or fall back to state/options
const effectivePage = fetchPage !== undefined ? fetchPage : page;
const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize;
if (effectivePage !== undefined) {
params.append("page", effectivePage.toString());
}
if (effectivePageSize !== undefined) {
params.append("page_size", effectivePageSize.toString());
}
);
if (!response.ok) {
toast.error("Failed to fetch documents");
throw new Error("Failed to fetch documents");
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
method: "GET",
}
);
if (!response.ok) {
toast.error("Failed to fetch documents");
throw new Error("Failed to fetch documents");
}
const data = await response.json();
const normalized = normalizeListResponse<Document>(data);
setDocuments(normalized.items);
setTotal(normalized.total);
setError(null);
setIsLoaded(true);
} catch (err: any) {
setError(err.message || "Failed to fetch documents");
console.error("Error fetching documents:", err);
} finally {
setLoading(false);
}
const data = await response.json();
setDocuments(data);
setError(null);
setIsLoaded(true);
} catch (err: any) {
setError(err.message || "Failed to fetch documents");
console.error("Error fetching documents:", err);
} finally {
setLoading(false);
}
}, [searchSpaceId, isLoaded, lazy]);
},
[searchSpaceId, page, pageSize, isLoaded, lazy]
);
useEffect(() => {
if (!lazy && searchSpaceId) {
@ -80,6 +114,64 @@ export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
await fetchDocuments();
}, [fetchDocuments]);
// Function to search documents by title
const searchDocuments = useCallback(
async (searchQuery: string, fetchPage?: number, fetchPageSize?: number) => {
if (!searchQuery.trim()) {
// If search is empty, fetch all documents
return fetchDocuments(fetchPage, fetchPageSize);
}
try {
setLoading(true);
// Build query params
const params = new URLSearchParams({
search_space_id: searchSpaceId.toString(),
title: searchQuery,
});
// Use passed parameters or fall back to state/options
const effectivePage = fetchPage !== undefined ? fetchPage : page;
const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize;
if (effectivePage !== undefined) {
params.append("page", effectivePage.toString());
}
if (effectivePageSize !== undefined) {
params.append("page_size", effectivePageSize.toString());
}
const response = await fetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/search/?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
},
method: "GET",
}
);
if (!response.ok) {
toast.error("Failed to search documents");
throw new Error("Failed to search documents");
}
const data = await response.json();
const normalized = normalizeListResponse<Document>(data);
setDocuments(normalized.items);
setTotal(normalized.total);
setError(null);
} catch (err: any) {
setError(err.message || "Failed to search documents");
console.error("Error searching documents:", err);
} finally {
setLoading(false);
}
},
[searchSpaceId, page, pageSize, fetchDocuments]
);
// Function to delete a document
const deleteDocument = useCallback(
async (documentId: number) => {
@ -114,10 +206,12 @@ export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
return {
documents,
total,
loading,
error,
isLoaded,
fetchDocuments, // Manual fetch function for lazy mode
searchDocuments, // Search function
refreshDocuments,
deleteDocument,
};

View file

@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
/**
* Custom hook that tracks if a media query matches
* @param query - The media query string to match (e.g., "(min-width: 768px)")
* @returns boolean - True if the media query matches, false otherwise
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
// Check if we're in the browser (handle SSR)
if (typeof window === "undefined") {
return;
}
const mediaQuery = window.matchMedia(query);
// Set initial value
setMatches(mediaQuery.matches);
// Create event listener
const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
// Add event listener
mediaQuery.addEventListener("change", handler);
// Cleanup
return () => {
mediaQuery.removeEventListener("change", handler);
};
}, [query]);
return matches;
}

View file

@ -0,0 +1,33 @@
// Helper to normalize list responses from the API
// Supports shapes: Array<T>, { items: T[]; total: number }, and tuple [T[], total]
export type ListResponse<T> = {
items: T[];
total: number;
};
export function normalizeListResponse<T>(payload: any): ListResponse<T> {
try {
// Case 1: already in desired shape
if (payload && Array.isArray(payload.items)) {
const total = typeof payload.total === "number" ? payload.total : payload.items.length;
return { items: payload.items as T[], total };
}
// Case 2: tuple [items, total]
if (Array.isArray(payload) && payload.length === 2 && Array.isArray(payload[0])) {
const items = (payload[0] ?? []) as T[];
const rawTotal = payload[1];
const total = typeof rawTotal === "number" ? rawTotal : items.length;
return { items, total };
}
// Case 3: plain array
if (Array.isArray(payload)) {
return { items: payload as T[], total: (payload as T[]).length };
}
} catch (e) {
// fallthrough to default
}
return { items: [], total: 0 };
}

View file

@ -12,12 +12,17 @@
"debug": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
"debug:browser": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
"debug:server": "cross-env NODE_OPTIONS=--inspect=0.0.0.0:9229 next dev --turbopack",
"postinstall": "fumadocs-mdx"
"postinstall": "fumadocs-mdx",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@ai-sdk/react": "^1.2.12",
"@hookform/resolvers": "^4.1.3",
"@llamaindex/chat-ui": "^0.5.17",
"@number-flow/react": "^0.5.10",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
@ -32,6 +37,7 @@
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
@ -41,18 +47,23 @@
"@types/mdx": "^2.0.13",
"@types/react-syntax-highlighter": "^15.5.13",
"ai": "^4.3.19",
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.5",
"emblor": "^1.4.8",
"framer-motion": "^12.23.9",
"fumadocs-core": "^15.6.6",
"fumadocs-mdx": "^11.7.1",
"fumadocs-ui": "^15.6.6",
"geist": "^1.4.2",
"lucide-react": "^0.477.0",
"motion": "^12.23.22",
"next": "^15.4.4",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"postgres": "^3.4.7",
"react": "^19.1.0",
"react-day-picker": "^9.8.1",
"react-dom": "^19.1.0",
@ -63,6 +74,7 @@
"react-markdown": "^10.1.0",
"react-rough-notation": "^1.0.5",
"react-syntax-highlighter": "^15.6.1",
"react-wrap-balancer": "^1.1.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
@ -76,13 +88,17 @@
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20.19.9",
"@types/pg": "^8.15.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.31.5",
"eslint": "^9.32.0",
"eslint-config-next": "15.2.0",
"tailwindcss": "^4.1.11",
"tsx": "^4.20.6",
"typescript": "^5.8.3"
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB