mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-19 08:45:47 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d52160a6f | ||
|
|
bbe9e9db74 | ||
|
|
7cd76ec404 | ||
|
|
5b5c1166ca | ||
|
|
d9e9c6973d | ||
|
|
91903141cd | ||
|
|
e329b63b89 |
@@ -0,0 +1,31 @@
|
||||
"""mapping for anonymous user path
|
||||
|
||||
Revision ID: a4f6ee863c47
|
||||
Revises: 14a83a331951
|
||||
Create Date: 2025-01-04 14:16:58.697451
|
||||
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a4f6ee863c47"
|
||||
down_revision = "14a83a331951"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"tenant_anonymous_user_path",
|
||||
sa.Column("tenant_id", sa.String(), primary_key=True, nullable=False),
|
||||
sa.Column("anonymous_user_path", sa.String(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("tenant_id"),
|
||||
sa.UniqueConstraint("anonymous_user_path"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("tenant_anonymous_user_path")
|
||||
@@ -3,6 +3,10 @@ from sqlalchemy.orm import Session
|
||||
from ee.onyx.db.external_perm import fetch_external_groups_for_user
|
||||
from ee.onyx.db.user_group import fetch_user_groups_for_documents
|
||||
from ee.onyx.db.user_group import fetch_user_groups_for_user
|
||||
from ee.onyx.external_permissions.post_query_censoring import (
|
||||
DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION,
|
||||
)
|
||||
from ee.onyx.external_permissions.sync_params import DOC_PERMISSIONS_FUNC_MAP
|
||||
from onyx.access.access import (
|
||||
_get_access_for_documents as get_access_for_documents_without_groups,
|
||||
)
|
||||
@@ -10,6 +14,7 @@ from onyx.access.access import _get_acl_for_user as get_acl_for_user_without_gro
|
||||
from onyx.access.models import DocumentAccess
|
||||
from onyx.access.utils import prefix_external_group
|
||||
from onyx.access.utils import prefix_user_group
|
||||
from onyx.db.document import get_document_sources
|
||||
from onyx.db.document import get_documents_by_ids
|
||||
from onyx.db.models import User
|
||||
|
||||
@@ -52,9 +57,20 @@ def _get_access_for_documents(
|
||||
)
|
||||
doc_id_map = {doc.id: doc for doc in documents}
|
||||
|
||||
# Get all sources in one batch
|
||||
doc_id_to_source_map = get_document_sources(
|
||||
db_session=db_session,
|
||||
document_ids=document_ids,
|
||||
)
|
||||
|
||||
access_map = {}
|
||||
for document_id, non_ee_access in non_ee_access_dict.items():
|
||||
document = doc_id_map[document_id]
|
||||
source = doc_id_to_source_map.get(document_id)
|
||||
is_only_censored = (
|
||||
source in DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION
|
||||
and source not in DOC_PERMISSIONS_FUNC_MAP
|
||||
)
|
||||
|
||||
ext_u_emails = (
|
||||
set(document.external_user_emails)
|
||||
@@ -70,7 +86,11 @@ def _get_access_for_documents(
|
||||
|
||||
# If the document is determined to be "public" externally (through a SYNC connector)
|
||||
# then it's given the same access level as if it were marked public within Onyx
|
||||
is_public_anywhere = document.is_public or non_ee_access.is_public
|
||||
# If its censored, then it's public anywhere during the search and then permissions are
|
||||
# applied after the search
|
||||
is_public_anywhere = (
|
||||
document.is_public or non_ee_access.is_public or is_only_censored
|
||||
)
|
||||
|
||||
# To avoid collisions of group namings between connectors, they need to be prefixed
|
||||
access_map[document_id] = DocumentAccess(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
|
||||
import jwt
|
||||
import requests
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
@@ -20,6 +22,7 @@ from ee.onyx.server.seeding import get_seed_config
|
||||
from ee.onyx.utils.secrets import extract_hashed_cookie
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.configs.app_configs import AUTH_TYPE
|
||||
from onyx.configs.app_configs import USER_AUTH_SECRET
|
||||
from onyx.configs.constants import AuthType
|
||||
from onyx.db.models import User
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -118,3 +121,17 @@ async def current_cloud_superuser(
|
||||
detail="Access denied. User must be a cloud superuser to perform this action.",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def generate_anonymous_user_jwt_token(tenant_id: str) -> str:
|
||||
payload = {
|
||||
"tenant_id": tenant_id,
|
||||
# Token does not expire
|
||||
"iat": datetime.utcnow(), # Issued at time
|
||||
}
|
||||
|
||||
return jwt.encode(payload, USER_AUTH_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_anonymous_user_jwt_token(token: str) -> dict:
|
||||
return jwt.decode(token, USER_AUTH_SECRET, algorithms=["HS256"])
|
||||
|
||||
@@ -61,3 +61,5 @@ POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY") or "FooBar"
|
||||
POSTHOG_HOST = os.environ.get("POSTHOG_HOST") or "https://us.i.posthog.com"
|
||||
|
||||
HUBSPOT_TRACKING_URL = os.environ.get("HUBSPOT_TRACKING_URL")
|
||||
|
||||
ANONYMOUS_USER_COOKIE_NAME = "onyx_anonymous_user"
|
||||
|
||||
@@ -10,6 +10,7 @@ from onyx.access.utils import prefix_group_w_source
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.db.models import User__ExternalUserGroupId
|
||||
from onyx.db.users import batch_add_ext_perm_user_if_not_exists
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
@@ -106,3 +107,21 @@ def fetch_external_groups_for_user(
|
||||
User__ExternalUserGroupId.user_id == user_id
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
def fetch_external_groups_for_user_email_and_group_ids(
|
||||
db_session: Session,
|
||||
user_email: str,
|
||||
group_ids: list[str],
|
||||
) -> list[User__ExternalUserGroupId]:
|
||||
user = get_user_by_email(db_session=db_session, email=user_email)
|
||||
if user is None:
|
||||
return []
|
||||
user_id = user.id
|
||||
user_ext_groups = db_session.scalars(
|
||||
select(User__ExternalUserGroupId).where(
|
||||
User__ExternalUserGroupId.user_id == user_id,
|
||||
User__ExternalUserGroupId.external_user_group_id.in_(group_ids),
|
||||
)
|
||||
).all()
|
||||
return list(user_ext_groups)
|
||||
|
||||
84
backend/ee/onyx/external_permissions/post_query_censoring.py
Normal file
84
backend/ee/onyx/external_permissions/post_query_censoring.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from ee.onyx.db.connector_credential_pair import get_all_auto_sync_cc_pairs
|
||||
from ee.onyx.external_permissions.salesforce.postprocessing import (
|
||||
censor_salesforce_chunks,
|
||||
)
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.context.search.pipeline import InferenceChunk
|
||||
from onyx.db.engine import get_session_context_manager
|
||||
from onyx.db.models import User
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION: dict[
|
||||
DocumentSource,
|
||||
# list of chunks to be censored and the user email. returns censored chunks
|
||||
Callable[[list[InferenceChunk], str], list[InferenceChunk]],
|
||||
] = {
|
||||
DocumentSource.SALESFORCE: censor_salesforce_chunks,
|
||||
}
|
||||
|
||||
|
||||
def _get_all_censoring_enabled_sources() -> set[DocumentSource]:
|
||||
"""
|
||||
Returns the set of sources that have censoring enabled.
|
||||
This is based on if the access_type is set to sync and the connector
|
||||
source is included in DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION.
|
||||
|
||||
NOTE: This means if there is a source has a single cc_pair that is sync,
|
||||
all chunks for that source will be censored, even if the connector that
|
||||
indexed that chunk is not sync. This was done to avoid getting the cc_pair
|
||||
for every single chunk.
|
||||
"""
|
||||
with get_session_context_manager() as db_session:
|
||||
enabled_sync_connectors = get_all_auto_sync_cc_pairs(db_session)
|
||||
return {
|
||||
cc_pair.connector.source
|
||||
for cc_pair in enabled_sync_connectors
|
||||
if cc_pair.connector.source in DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION
|
||||
}
|
||||
|
||||
|
||||
# NOTE: This is only called if ee is enabled.
|
||||
def _post_query_chunk_censoring(
|
||||
chunks: list[InferenceChunk],
|
||||
user: User | None,
|
||||
) -> list[InferenceChunk]:
|
||||
"""
|
||||
This function checks all chunks to see if they need to be sent to a censoring
|
||||
function. If they do, it sends them to the censoring function and returns the
|
||||
censored chunks. If they don't, it returns the original chunks.
|
||||
"""
|
||||
if user is None:
|
||||
# if user is None, permissions are not enforced
|
||||
return chunks
|
||||
|
||||
chunks_to_keep = []
|
||||
chunks_to_process: dict[DocumentSource, list[InferenceChunk]] = {}
|
||||
|
||||
sources_to_censor = _get_all_censoring_enabled_sources()
|
||||
for chunk in chunks:
|
||||
# Separate out chunks that require permission post-processing by source
|
||||
if chunk.source_type in sources_to_censor:
|
||||
chunks_to_process.setdefault(chunk.source_type, []).append(chunk)
|
||||
else:
|
||||
chunks_to_keep.append(chunk)
|
||||
|
||||
# For each source, filter out the chunks using the permission
|
||||
# check function for that source
|
||||
# TODO: Use a threadpool/multiprocessing to process the sources in parallel
|
||||
for source, chunks_for_source in chunks_to_process.items():
|
||||
censor_chunks_for_source = DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION[source]
|
||||
try:
|
||||
censored_chunks = censor_chunks_for_source(chunks_for_source, user.email)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
f"Failed to censor chunks for source {source} so throwing out all"
|
||||
f" chunks for this source and continuing: {e}"
|
||||
)
|
||||
continue
|
||||
chunks_to_keep.extend(censored_chunks)
|
||||
|
||||
return chunks_to_keep
|
||||
@@ -0,0 +1,226 @@
|
||||
import time
|
||||
|
||||
from ee.onyx.db.external_perm import fetch_external_groups_for_user_email_and_group_ids
|
||||
from ee.onyx.external_permissions.salesforce.utils import (
|
||||
get_any_salesforce_client_for_doc_id,
|
||||
)
|
||||
from ee.onyx.external_permissions.salesforce.utils import get_objects_access_for_user_id
|
||||
from ee.onyx.external_permissions.salesforce.utils import (
|
||||
get_salesforce_user_id_from_email,
|
||||
)
|
||||
from onyx.configs.app_configs import BLURB_SIZE
|
||||
from onyx.context.search.models import InferenceChunk
|
||||
from onyx.db.engine import get_session_context_manager
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
# Types
|
||||
ChunkKey = tuple[str, int] # (doc_id, chunk_id)
|
||||
ContentRange = tuple[int, int | None] # (start_index, end_index) None means to the end
|
||||
|
||||
|
||||
# NOTE: Used for testing timing
|
||||
def _get_dummy_object_access_map(
|
||||
object_ids: set[str], user_email: str, chunks: list[InferenceChunk]
|
||||
) -> dict[str, bool]:
|
||||
time.sleep(0.15)
|
||||
# return {object_id: True for object_id in object_ids}
|
||||
import random
|
||||
|
||||
return {object_id: random.choice([True, False]) for object_id in object_ids}
|
||||
|
||||
|
||||
def _get_objects_access_for_user_email_from_salesforce(
|
||||
object_ids: set[str],
|
||||
user_email: str,
|
||||
chunks: list[InferenceChunk],
|
||||
) -> dict[str, bool] | None:
|
||||
"""
|
||||
This function wraps the salesforce call as we may want to change how this
|
||||
is done in the future. (E.g. replace it with the above function)
|
||||
"""
|
||||
# This is cached in the function so the first query takes an extra 0.1-0.3 seconds
|
||||
# but subsequent queries for this source are essentially instant
|
||||
first_doc_id = chunks[0].document_id
|
||||
with get_session_context_manager() as db_session:
|
||||
salesforce_client = get_any_salesforce_client_for_doc_id(
|
||||
db_session, first_doc_id
|
||||
)
|
||||
|
||||
# This is cached in the function so the first query takes an extra 0.1-0.3 seconds
|
||||
# but subsequent queries by the same user are essentially instant
|
||||
start_time = time.time()
|
||||
user_id = get_salesforce_user_id_from_email(salesforce_client, user_email)
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
f"Time taken to get Salesforce user ID: {end_time - start_time} seconds"
|
||||
)
|
||||
if user_id is None:
|
||||
return None
|
||||
|
||||
# This is the only query that is not cached in the function
|
||||
# so it takes 0.1-0.2 seconds total
|
||||
object_id_to_access = get_objects_access_for_user_id(
|
||||
salesforce_client, user_id, list(object_ids)
|
||||
)
|
||||
return object_id_to_access
|
||||
|
||||
|
||||
def _extract_salesforce_object_id_from_url(url: str) -> str:
|
||||
return url.split("/")[-1]
|
||||
|
||||
|
||||
def _get_object_ranges_for_chunk(
|
||||
chunk: InferenceChunk,
|
||||
) -> dict[str, list[ContentRange]]:
|
||||
"""
|
||||
Given a chunk, return a dictionary of salesforce object ids and the content ranges
|
||||
for that object id in the current chunk
|
||||
"""
|
||||
if chunk.source_links is None:
|
||||
return {}
|
||||
|
||||
object_ranges: dict[str, list[ContentRange]] = {}
|
||||
end_index = None
|
||||
descending_source_links = sorted(
|
||||
chunk.source_links.items(), key=lambda x: x[0], reverse=True
|
||||
)
|
||||
for start_index, url in descending_source_links:
|
||||
object_id = _extract_salesforce_object_id_from_url(url)
|
||||
if object_id not in object_ranges:
|
||||
object_ranges[object_id] = []
|
||||
object_ranges[object_id].append((start_index, end_index))
|
||||
end_index = start_index
|
||||
return object_ranges
|
||||
|
||||
|
||||
def _create_empty_censored_chunk(uncensored_chunk: InferenceChunk) -> InferenceChunk:
|
||||
"""
|
||||
Create a copy of the unfiltered chunk where potentially sensitive content is removed
|
||||
to be added later if the user has access to each of the sub-objects
|
||||
"""
|
||||
empty_censored_chunk = InferenceChunk(
|
||||
**uncensored_chunk.model_dump(),
|
||||
)
|
||||
empty_censored_chunk.content = ""
|
||||
empty_censored_chunk.blurb = ""
|
||||
empty_censored_chunk.source_links = {}
|
||||
return empty_censored_chunk
|
||||
|
||||
|
||||
def _update_censored_chunk(
|
||||
censored_chunk: InferenceChunk,
|
||||
uncensored_chunk: InferenceChunk,
|
||||
content_range: ContentRange,
|
||||
) -> InferenceChunk:
|
||||
"""
|
||||
Update the filtered chunk with the content and source links from the unfiltered chunk using the content ranges
|
||||
"""
|
||||
start_index, end_index = content_range
|
||||
|
||||
# Update the content of the filtered chunk
|
||||
permitted_content = uncensored_chunk.content[start_index:end_index]
|
||||
permitted_section_start_index = len(censored_chunk.content)
|
||||
censored_chunk.content = permitted_content + censored_chunk.content
|
||||
|
||||
# Update the source links of the filtered chunk
|
||||
if uncensored_chunk.source_links is not None:
|
||||
if censored_chunk.source_links is None:
|
||||
censored_chunk.source_links = {}
|
||||
link_content = uncensored_chunk.source_links[start_index]
|
||||
censored_chunk.source_links[permitted_section_start_index] = link_content
|
||||
|
||||
# Update the blurb of the filtered chunk
|
||||
censored_chunk.blurb = censored_chunk.content[:BLURB_SIZE]
|
||||
|
||||
return censored_chunk
|
||||
|
||||
|
||||
# TODO: Generalize this to other sources
|
||||
def censor_salesforce_chunks(
|
||||
chunks: list[InferenceChunk],
|
||||
user_email: str,
|
||||
# This is so we can provide a mock access map for testing
|
||||
access_map: dict[str, bool] | None = None,
|
||||
) -> list[InferenceChunk]:
|
||||
# object_id -> list[((doc_id, chunk_id), (start_index, end_index))]
|
||||
object_to_content_map: dict[str, list[tuple[ChunkKey, ContentRange]]] = {}
|
||||
|
||||
# (doc_id, chunk_id) -> chunk
|
||||
uncensored_chunks: dict[ChunkKey, InferenceChunk] = {}
|
||||
|
||||
# keep track of all object ids that we have seen to make it easier to get
|
||||
# the access for these object ids
|
||||
object_ids: set[str] = set()
|
||||
|
||||
for chunk in chunks:
|
||||
chunk_key = (chunk.document_id, chunk.chunk_id)
|
||||
# create a dictionary to quickly look up the unfiltered chunk
|
||||
uncensored_chunks[chunk_key] = chunk
|
||||
|
||||
# for each chunk, get a dictionary of object ids and the content ranges
|
||||
# for that object id in the current chunk
|
||||
object_ranges_for_chunk = _get_object_ranges_for_chunk(chunk)
|
||||
for object_id, ranges in object_ranges_for_chunk.items():
|
||||
object_ids.add(object_id)
|
||||
for start_index, end_index in ranges:
|
||||
object_to_content_map.setdefault(object_id, []).append(
|
||||
(chunk_key, (start_index, end_index))
|
||||
)
|
||||
|
||||
# This is so we can provide a mock access map for testing
|
||||
if access_map is None:
|
||||
access_map = _get_objects_access_for_user_email_from_salesforce(
|
||||
object_ids=object_ids,
|
||||
user_email=user_email,
|
||||
chunks=chunks,
|
||||
)
|
||||
if access_map is None:
|
||||
# If the user is not found in Salesforce, access_map will be None
|
||||
# so we should just return an empty list because no chunks will be
|
||||
# censored
|
||||
return []
|
||||
|
||||
censored_chunks: dict[ChunkKey, InferenceChunk] = {}
|
||||
for object_id, content_list in object_to_content_map.items():
|
||||
# if the user does not have access to the object, or the object is not in the
|
||||
# access_map, do not include its content in the filtered chunks
|
||||
if not access_map.get(object_id, False):
|
||||
continue
|
||||
|
||||
# if we got this far, the user has access to the object so we can create or update
|
||||
# the filtered chunk(s) for this object
|
||||
# NOTE: we only create a censored chunk if the user has access to some
|
||||
# part of the chunk
|
||||
for chunk_key, content_range in content_list:
|
||||
if chunk_key not in censored_chunks:
|
||||
censored_chunks[chunk_key] = _create_empty_censored_chunk(
|
||||
uncensored_chunks[chunk_key]
|
||||
)
|
||||
|
||||
uncensored_chunk = uncensored_chunks[chunk_key]
|
||||
censored_chunk = _update_censored_chunk(
|
||||
censored_chunk=censored_chunks[chunk_key],
|
||||
uncensored_chunk=uncensored_chunk,
|
||||
content_range=content_range,
|
||||
)
|
||||
censored_chunks[chunk_key] = censored_chunk
|
||||
|
||||
return list(censored_chunks.values())
|
||||
|
||||
|
||||
# NOTE: This is not used anywhere.
|
||||
def _get_objects_access_for_user_email(
|
||||
object_ids: set[str], user_email: str
|
||||
) -> dict[str, bool]:
|
||||
with get_session_context_manager() as db_session:
|
||||
external_groups = fetch_external_groups_for_user_email_and_group_ids(
|
||||
db_session=db_session,
|
||||
user_email=user_email,
|
||||
# Maybe make a function that adds a salesforce prefix to the group ids
|
||||
group_ids=list(object_ids),
|
||||
)
|
||||
external_group_ids = {group.external_user_group_id for group in external_groups}
|
||||
return {group_id: group_id in external_group_ids for group_id in object_ids}
|
||||
174
backend/ee/onyx/external_permissions/salesforce/utils.py
Normal file
174
backend/ee/onyx/external_permissions/salesforce/utils.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from simple_salesforce import Salesforce
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.connectors.salesforce.sqlite_functions import get_user_id_by_email
|
||||
from onyx.connectors.salesforce.sqlite_functions import init_db
|
||||
from onyx.connectors.salesforce.sqlite_functions import NULL_ID_STRING
|
||||
from onyx.connectors.salesforce.sqlite_functions import update_email_to_id_table
|
||||
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
|
||||
from onyx.db.document import get_cc_pairs_for_document
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
_ANY_SALESFORCE_CLIENT: Salesforce | None = None
|
||||
|
||||
|
||||
def get_any_salesforce_client_for_doc_id(
|
||||
db_session: Session, doc_id: str
|
||||
) -> Salesforce:
|
||||
"""
|
||||
We create a salesforce client for the first cc_pair for the first doc_id where
|
||||
salesforce censoring is enabled. After that we just cache and reuse the same
|
||||
client for all queries.
|
||||
|
||||
We do this to reduce the number of postgres queries we make at query time.
|
||||
|
||||
This may be problematic if they are using multiple cc_pairs for salesforce.
|
||||
E.g. there are 2 different credential sets for 2 different salesforce cc_pairs
|
||||
but only one has the permissions to access the permissions needed for the query.
|
||||
"""
|
||||
global _ANY_SALESFORCE_CLIENT
|
||||
if _ANY_SALESFORCE_CLIENT is None:
|
||||
cc_pairs = get_cc_pairs_for_document(db_session, doc_id)
|
||||
first_cc_pair = cc_pairs[0]
|
||||
credential_json = first_cc_pair.credential.credential_json
|
||||
_ANY_SALESFORCE_CLIENT = Salesforce(
|
||||
username=credential_json["sf_username"],
|
||||
password=credential_json["sf_password"],
|
||||
security_token=credential_json["sf_security_token"],
|
||||
)
|
||||
return _ANY_SALESFORCE_CLIENT
|
||||
|
||||
|
||||
def _query_salesforce_user_id(sf_client: Salesforce, user_email: str) -> str | None:
|
||||
query = f"SELECT Id FROM User WHERE Email = '{user_email}'"
|
||||
result = sf_client.query(query)
|
||||
if len(result["records"]) == 0:
|
||||
return None
|
||||
return result["records"][0]["Id"]
|
||||
|
||||
|
||||
# This contains only the user_ids that we have found in Salesforce.
|
||||
# If we don't know their user_id, we don't store anything in the cache.
|
||||
_CACHED_SF_EMAIL_TO_ID_MAP: dict[str, str] = {}
|
||||
|
||||
|
||||
def get_salesforce_user_id_from_email(
|
||||
sf_client: Salesforce,
|
||||
user_email: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
We cache this so we don't have to query Salesforce for every query and salesforce
|
||||
user IDs never change.
|
||||
Memory usage is fine because we just store 2 small strings per user.
|
||||
|
||||
If the email is not in the cache, we check the local salesforce database for the info.
|
||||
If the user is not found in the local salesforce database, we query Salesforce.
|
||||
Whatever we get back from Salesforce is added to the database.
|
||||
If no user_id is found, we add a NULL_ID_STRING to the database for that email so
|
||||
we don't query Salesforce again (which is slow) but we still check the local salesforce
|
||||
database every query until a user id is found. This is acceptable because the query time
|
||||
is quite fast.
|
||||
If a user_id is created in Salesforce, it will be added to the local salesforce database
|
||||
next time the connector is run. Then that value will be found in this function and cached.
|
||||
|
||||
NOTE: First time this runs, it may be slow if it hasn't already been updated in the local
|
||||
salesforce database. (Around 0.1-0.3 seconds)
|
||||
If it's cached or stored in the local salesforce database, it's fast (<0.001 seconds).
|
||||
"""
|
||||
global _CACHED_SF_EMAIL_TO_ID_MAP
|
||||
if user_email in _CACHED_SF_EMAIL_TO_ID_MAP:
|
||||
if _CACHED_SF_EMAIL_TO_ID_MAP[user_email] is not None:
|
||||
return _CACHED_SF_EMAIL_TO_ID_MAP[user_email]
|
||||
|
||||
db_exists = True
|
||||
try:
|
||||
# Check if the user is already in the database
|
||||
user_id = get_user_id_by_email(user_email)
|
||||
except Exception:
|
||||
init_db()
|
||||
try:
|
||||
user_id = get_user_id_by_email(user_email)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if user is in database: {e}")
|
||||
user_id = None
|
||||
db_exists = False
|
||||
|
||||
# If no entry is found in the database (indicated by user_id being None)...
|
||||
if user_id is None:
|
||||
# ...query Salesforce and store the result in the database
|
||||
user_id = _query_salesforce_user_id(sf_client, user_email)
|
||||
if db_exists:
|
||||
update_email_to_id_table(user_email, user_id)
|
||||
return user_id
|
||||
elif user_id is None:
|
||||
return None
|
||||
elif user_id == NULL_ID_STRING:
|
||||
return None
|
||||
# If the found user_id is real, cache it
|
||||
_CACHED_SF_EMAIL_TO_ID_MAP[user_email] = user_id
|
||||
return user_id
|
||||
|
||||
|
||||
_MAX_RECORD_IDS_PER_QUERY = 200
|
||||
|
||||
|
||||
def get_objects_access_for_user_id(
|
||||
salesforce_client: Salesforce,
|
||||
user_id: str,
|
||||
record_ids: list[str],
|
||||
) -> dict[str, bool]:
|
||||
"""
|
||||
Salesforce has a limit of 200 record ids per query. So we just truncate
|
||||
the list of record ids to 200. We only ever retrieve 50 chunks at a time
|
||||
so this should be fine (unlikely that we retrieve all 50 chunks contain
|
||||
4 unique objects).
|
||||
If we decide this isn't acceptable we can use multiple queries but they
|
||||
should be in parallel so query time doesn't get too long.
|
||||
"""
|
||||
truncated_record_ids = record_ids[:_MAX_RECORD_IDS_PER_QUERY]
|
||||
record_ids_str = "'" + "','".join(truncated_record_ids) + "'"
|
||||
access_query = f"""
|
||||
SELECT RecordId, HasReadAccess
|
||||
FROM UserRecordAccess
|
||||
WHERE RecordId IN ({record_ids_str})
|
||||
AND UserId = '{user_id}'
|
||||
"""
|
||||
result = salesforce_client.query_all(access_query)
|
||||
return {record["RecordId"]: record["HasReadAccess"] for record in result["records"]}
|
||||
|
||||
|
||||
_CC_PAIR_ID_SALESFORCE_CLIENT_MAP: dict[int, Salesforce] = {}
|
||||
_DOC_ID_TO_CC_PAIR_ID_MAP: dict[str, int] = {}
|
||||
|
||||
|
||||
# NOTE: This is not used anywhere.
|
||||
def _get_salesforce_client_for_doc_id(db_session: Session, doc_id: str) -> Salesforce:
|
||||
"""
|
||||
Uses a document id to get the cc_pair that indexed that document and uses the credentials
|
||||
for that cc_pair to create a Salesforce client.
|
||||
Problems:
|
||||
- There may be multiple cc_pairs for a document, and we don't know which one to use.
|
||||
- right now we just use the first one
|
||||
- Building a new Salesforce client for each document is slow.
|
||||
- Memory usage could be an issue as we build these dictionaries.
|
||||
"""
|
||||
if doc_id not in _DOC_ID_TO_CC_PAIR_ID_MAP:
|
||||
cc_pairs = get_cc_pairs_for_document(db_session, doc_id)
|
||||
first_cc_pair = cc_pairs[0]
|
||||
_DOC_ID_TO_CC_PAIR_ID_MAP[doc_id] = first_cc_pair.id
|
||||
|
||||
cc_pair_id = _DOC_ID_TO_CC_PAIR_ID_MAP[doc_id]
|
||||
if cc_pair_id not in _CC_PAIR_ID_SALESFORCE_CLIENT_MAP:
|
||||
cc_pair = get_connector_credential_pair_from_id(cc_pair_id, db_session)
|
||||
if cc_pair is None:
|
||||
raise ValueError(f"CC pair {cc_pair_id} not found")
|
||||
credential_json = cc_pair.credential.credential_json
|
||||
_CC_PAIR_ID_SALESFORCE_CLIENT_MAP[cc_pair_id] = Salesforce(
|
||||
username=credential_json["sf_username"],
|
||||
password=credential_json["sf_password"],
|
||||
security_token=credential_json["sf_security_token"],
|
||||
)
|
||||
|
||||
return _CC_PAIR_ID_SALESFORCE_CLIENT_MAP[cc_pair_id]
|
||||
@@ -8,6 +8,9 @@ from ee.onyx.external_permissions.confluence.group_sync import confluence_group_
|
||||
from ee.onyx.external_permissions.gmail.doc_sync import gmail_doc_sync
|
||||
from ee.onyx.external_permissions.google_drive.doc_sync import gdrive_doc_sync
|
||||
from ee.onyx.external_permissions.google_drive.group_sync import gdrive_group_sync
|
||||
from ee.onyx.external_permissions.post_query_censoring import (
|
||||
DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION,
|
||||
)
|
||||
from ee.onyx.external_permissions.slack.doc_sync import slack_doc_sync
|
||||
from onyx.access.models import DocExternalAccess
|
||||
from onyx.configs.constants import DocumentSource
|
||||
@@ -71,4 +74,7 @@ EXTERNAL_GROUP_SYNC_PERIODS: dict[DocumentSource, int] = {
|
||||
|
||||
|
||||
def check_if_valid_sync_source(source_type: DocumentSource) -> bool:
|
||||
return source_type in DOC_PERMISSIONS_FUNC_MAP
|
||||
return (
|
||||
source_type in DOC_PERMISSIONS_FUNC_MAP
|
||||
or source_type in DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION
|
||||
)
|
||||
|
||||
@@ -7,6 +7,8 @@ from fastapi import HTTPException
|
||||
from fastapi import Request
|
||||
from fastapi import Response
|
||||
|
||||
from ee.onyx.auth.users import decode_anonymous_user_jwt_token
|
||||
from ee.onyx.configs.app_configs import ANONYMOUS_USER_COOKIE_NAME
|
||||
from onyx.auth.api_key import extract_tenant_from_api_key_header
|
||||
from onyx.db.engine import is_valid_schema_name
|
||||
from onyx.redis.redis_pool import retrieve_auth_token_data_from_redis
|
||||
@@ -48,6 +50,16 @@ async def _get_tenant_id_from_request(
|
||||
if tenant_id:
|
||||
return tenant_id
|
||||
|
||||
# Check for anonymous user cookie
|
||||
anonymous_user_cookie = request.cookies.get(ANONYMOUS_USER_COOKIE_NAME)
|
||||
if anonymous_user_cookie:
|
||||
try:
|
||||
anonymous_user_data = decode_anonymous_user_jwt_token(anonymous_user_cookie)
|
||||
return anonymous_user_data.get("tenant_id", POSTGRES_DEFAULT_SCHEMA)
|
||||
except Exception as e:
|
||||
logger.error(f"Error decoding anonymous user cookie: {str(e)}")
|
||||
# Continue and attempt to authenticate
|
||||
|
||||
try:
|
||||
# Look up token data in Redis
|
||||
token_data = await retrieve_auth_token_data_from_redis(request)
|
||||
|
||||
59
backend/ee/onyx/server/tenants/anonymous_user_path.py
Normal file
59
backend/ee/onyx/server/tenants/anonymous_user_path.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.models import TenantAnonymousUserPath
|
||||
|
||||
|
||||
def get_anonymous_user_path(tenant_id: str, db_session: Session) -> str | None:
|
||||
result = db_session.execute(
|
||||
select(TenantAnonymousUserPath).where(
|
||||
TenantAnonymousUserPath.tenant_id == tenant_id
|
||||
)
|
||||
)
|
||||
result_scalar = result.scalar_one_or_none()
|
||||
if result_scalar:
|
||||
return result_scalar.anonymous_user_path
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def modify_anonymous_user_path(
|
||||
tenant_id: str, anonymous_user_path: str, db_session: Session
|
||||
) -> None:
|
||||
# Enforce lowercase path at DB operation level
|
||||
anonymous_user_path = anonymous_user_path.lower()
|
||||
|
||||
existing_entry = (
|
||||
db_session.query(TenantAnonymousUserPath).filter_by(tenant_id=tenant_id).first()
|
||||
)
|
||||
|
||||
if existing_entry:
|
||||
existing_entry.anonymous_user_path = anonymous_user_path
|
||||
|
||||
else:
|
||||
new_entry = TenantAnonymousUserPath(
|
||||
tenant_id=tenant_id, anonymous_user_path=anonymous_user_path
|
||||
)
|
||||
db_session.add(new_entry)
|
||||
|
||||
db_session.commit()
|
||||
|
||||
|
||||
def get_tenant_id_for_anonymous_user_path(
|
||||
anonymous_user_path: str, db_session: Session
|
||||
) -> str | None:
|
||||
result = db_session.execute(
|
||||
select(TenantAnonymousUserPath).where(
|
||||
TenantAnonymousUserPath.anonymous_user_path == anonymous_user_path
|
||||
)
|
||||
)
|
||||
result_scalar = result.scalar_one_or_none()
|
||||
if result_scalar:
|
||||
return result_scalar.tenant_id
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def validate_anonymous_user_path(path: str) -> None:
|
||||
if not path or "/" in path or not path.replace("-", "").isalnum():
|
||||
raise ValueError("Invalid path. Use only letters, numbers, and hyphens.")
|
||||
@@ -3,13 +3,23 @@ from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from fastapi import HTTPException
|
||||
from fastapi import Response
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ee.onyx.auth.users import current_cloud_superuser
|
||||
from ee.onyx.auth.users import generate_anonymous_user_jwt_token
|
||||
from ee.onyx.configs.app_configs import ANONYMOUS_USER_COOKIE_NAME
|
||||
from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY
|
||||
from ee.onyx.server.tenants.access import control_plane_dep
|
||||
from ee.onyx.server.tenants.anonymous_user_path import get_anonymous_user_path
|
||||
from ee.onyx.server.tenants.anonymous_user_path import (
|
||||
get_tenant_id_for_anonymous_user_path,
|
||||
)
|
||||
from ee.onyx.server.tenants.anonymous_user_path import modify_anonymous_user_path
|
||||
from ee.onyx.server.tenants.anonymous_user_path import validate_anonymous_user_path
|
||||
from ee.onyx.server.tenants.billing import fetch_billing_information
|
||||
from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information
|
||||
from ee.onyx.server.tenants.models import AnonymousUserPath
|
||||
from ee.onyx.server.tenants.models import BillingInformation
|
||||
from ee.onyx.server.tenants.models import ImpersonateRequest
|
||||
from ee.onyx.server.tenants.models import ProductGatingRequest
|
||||
@@ -17,9 +27,11 @@ from ee.onyx.server.tenants.provisioning import delete_user_from_control_plane
|
||||
from ee.onyx.server.tenants.user_mapping import get_tenant_id_for_email
|
||||
from ee.onyx.server.tenants.user_mapping import remove_all_users_from_tenant
|
||||
from ee.onyx.server.tenants.user_mapping import remove_users_from_tenant
|
||||
from onyx.auth.users import anonymous_user_enabled
|
||||
from onyx.auth.users import auth_backend
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.auth.users import get_redis_strategy
|
||||
from onyx.auth.users import optional_user
|
||||
from onyx.auth.users import User
|
||||
from onyx.configs.app_configs import WEB_DOMAIN
|
||||
from onyx.db.auth import get_user_count
|
||||
@@ -36,11 +48,79 @@ from onyx.utils.logger import setup_logger
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
|
||||
stripe.api_key = STRIPE_SECRET_KEY
|
||||
|
||||
logger = setup_logger()
|
||||
router = APIRouter(prefix="/tenants")
|
||||
|
||||
|
||||
@router.get("/anonymous-user-path")
|
||||
async def get_anonymous_user_path_api(
|
||||
tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
_: User | None = Depends(current_admin_user),
|
||||
) -> AnonymousUserPath:
|
||||
if tenant_id is None:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
with get_session_with_tenant(tenant_id=None) as db_session:
|
||||
current_path = get_anonymous_user_path(tenant_id, db_session)
|
||||
|
||||
return AnonymousUserPath(anonymous_user_path=current_path)
|
||||
|
||||
|
||||
@router.post("/anonymous-user-path")
|
||||
async def set_anonymous_user_path_api(
|
||||
anonymous_user_path: str,
|
||||
tenant_id: str = Depends(get_current_tenant_id),
|
||||
_: User | None = Depends(current_admin_user),
|
||||
) -> None:
|
||||
try:
|
||||
validate_anonymous_user_path(anonymous_user_path)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
with get_session_with_tenant(tenant_id=None) as db_session:
|
||||
try:
|
||||
modify_anonymous_user_path(tenant_id, anonymous_user_path, db_session)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="The anonymous user path is already in use. Please choose a different path.",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to modify anonymous user path: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="An unexpected error occurred while modifying the anonymous user path",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/anonymous-user")
|
||||
async def login_as_anonymous_user(
|
||||
anonymous_user_path: str,
|
||||
_: User | None = Depends(optional_user),
|
||||
) -> Response:
|
||||
with get_session_with_tenant(tenant_id=None) as db_session:
|
||||
tenant_id = get_tenant_id_for_anonymous_user_path(
|
||||
anonymous_user_path, db_session
|
||||
)
|
||||
if not tenant_id:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
if not anonymous_user_enabled(tenant_id=tenant_id):
|
||||
raise HTTPException(status_code=403, detail="Anonymous user is not enabled")
|
||||
|
||||
token = generate_anonymous_user_jwt_token(tenant_id)
|
||||
|
||||
response = Response()
|
||||
response.set_cookie(
|
||||
key=ANONYMOUS_USER_COOKIE_NAME,
|
||||
value=token,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="strict",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/product-gating")
|
||||
def gate_product(
|
||||
product_gating_request: ProductGatingRequest, _: None = Depends(control_plane_dep)
|
||||
|
||||
@@ -44,3 +44,7 @@ class TenantCreationPayload(BaseModel):
|
||||
class TenantDeletionPayload(BaseModel):
|
||||
tenant_id: str
|
||||
email: str
|
||||
|
||||
|
||||
class AnonymousUserPath(BaseModel):
|
||||
anonymous_user_path: str | None
|
||||
|
||||
@@ -80,6 +80,7 @@ from onyx.db.auth import get_user_db
|
||||
from onyx.db.auth import SQLAlchemyUserAdminDB
|
||||
from onyx.db.engine import get_async_session
|
||||
from onyx.db.engine import get_async_session_with_tenant
|
||||
from onyx.db.engine import get_current_tenant_id
|
||||
from onyx.db.engine import get_session_with_tenant
|
||||
from onyx.db.models import OAuthAccount
|
||||
from onyx.db.models import User
|
||||
@@ -144,11 +145,8 @@ def user_needs_to_be_verified() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def anonymous_user_enabled() -> bool:
|
||||
if MULTI_TENANT:
|
||||
return False
|
||||
|
||||
redis_client = get_redis_client(tenant_id=None)
|
||||
def anonymous_user_enabled(*, tenant_id: str | None = None) -> bool:
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)
|
||||
|
||||
if value is None:
|
||||
@@ -773,9 +771,10 @@ async def current_limited_user(
|
||||
|
||||
async def current_chat_accesssible_user(
|
||||
user: User | None = Depends(optional_user),
|
||||
tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
) -> User | None:
|
||||
return await double_check_user(
|
||||
user, allow_anonymous_access=anonymous_user_enabled()
|
||||
user, allow_anonymous_access=anonymous_user_enabled(tenant_id=tenant_id)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ from ee.onyx.db.connector_credential_pair import get_all_auto_sync_cc_pairs
|
||||
from ee.onyx.db.document import upsert_document_external_perms
|
||||
from ee.onyx.external_permissions.sync_params import DOC_PERMISSION_SYNC_PERIODS
|
||||
from ee.onyx.external_permissions.sync_params import DOC_PERMISSIONS_FUNC_MAP
|
||||
from ee.onyx.external_permissions.sync_params import (
|
||||
DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION,
|
||||
)
|
||||
from onyx.access.models import DocExternalAccess
|
||||
from onyx.background.celery.apps.app_base import task_logger
|
||||
from onyx.configs.app_configs import JOB_TIMEOUT
|
||||
@@ -286,6 +289,8 @@ def connector_permission_sync_generator_task(
|
||||
|
||||
doc_sync_func = DOC_PERMISSIONS_FUNC_MAP.get(source_type)
|
||||
if doc_sync_func is None:
|
||||
if source_type in DOC_SOURCE_TO_CHUNK_CENSORING_FUNCTION:
|
||||
return None
|
||||
raise ValueError(
|
||||
f"No doc sync func found for {source_type} with cc_pair={cc_pair_id}"
|
||||
)
|
||||
|
||||
@@ -955,12 +955,14 @@ def vespa_metadata_sync_task(
|
||||
# the sync might repeat again later
|
||||
mark_document_as_synced(document_id, db_session)
|
||||
|
||||
redis_syncing_key = RedisConnectorCredentialPair.make_redis_syncing_key(
|
||||
document_id
|
||||
)
|
||||
r = get_redis_client(tenant_id=tenant_id)
|
||||
r.delete(redis_syncing_key)
|
||||
# r.hdel(RedisConnectorCredentialPair.SYNCING_HASH, document_id)
|
||||
# this code checks for and removes a per document sync key that is
|
||||
# used to block out the same doc from continualy resyncing
|
||||
# a quick hack that is only needed for production issues
|
||||
# redis_syncing_key = RedisConnectorCredentialPair.make_redis_syncing_key(
|
||||
# document_id
|
||||
# )
|
||||
# r = get_redis_client(tenant_id=tenant_id)
|
||||
# r.delete(redis_syncing_key)
|
||||
|
||||
task_logger.info(f"doc={document_id} action=sync chunks={chunks_affected}")
|
||||
except SoftTimeLimitExceeded:
|
||||
|
||||
@@ -195,7 +195,6 @@ REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD") or ""
|
||||
|
||||
REDIS_AUTH_KEY_PREFIX = "fastapi_users_token:"
|
||||
|
||||
|
||||
# Rate limiting for auth endpoints
|
||||
RATE_LIMIT_WINDOW_SECONDS: int | None = None
|
||||
_rate_limit_window_seconds_str = os.environ.get("RATE_LIMIT_WINDOW_SECONDS")
|
||||
@@ -213,6 +212,7 @@ if _rate_limit_max_requests_str is not None:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
AUTH_RATE_LIMITING_ENABLED = RATE_LIMIT_MAX_REQUESTS and RATE_LIMIT_WINDOW_SECONDS
|
||||
# Used for general redis things
|
||||
REDIS_DB_NUMBER = int(os.environ.get("REDIS_DB_NUMBER", 0))
|
||||
|
||||
|
||||
@@ -4,34 +4,29 @@ from typing import Any
|
||||
from simple_salesforce import Salesforce
|
||||
|
||||
from onyx.configs.app_configs import INDEX_BATCH_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
from onyx.connectors.interfaces import GenerateDocumentsOutput
|
||||
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import LoadConnector
|
||||
from onyx.connectors.interfaces import PollConnector
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnector
|
||||
from onyx.connectors.models import BasicExpertInfo
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import SlimDocument
|
||||
from onyx.connectors.salesforce.doc_conversion import extract_section
|
||||
from onyx.connectors.salesforce.doc_conversion import convert_sf_object_to_doc
|
||||
from onyx.connectors.salesforce.doc_conversion import ID_PREFIX
|
||||
from onyx.connectors.salesforce.salesforce_calls import fetch_all_csvs_in_parallel
|
||||
from onyx.connectors.salesforce.salesforce_calls import get_all_children_of_sf_type
|
||||
from onyx.connectors.salesforce.sqlite_functions import get_affected_parent_ids_by_type
|
||||
from onyx.connectors.salesforce.sqlite_functions import get_child_ids
|
||||
from onyx.connectors.salesforce.sqlite_functions import get_record
|
||||
from onyx.connectors.salesforce.sqlite_functions import init_db
|
||||
from onyx.connectors.salesforce.sqlite_functions import update_sf_db_with_csv
|
||||
from onyx.connectors.salesforce.utils import SalesforceObject
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
_DEFAULT_PARENT_OBJECT_TYPES = ["Account"]
|
||||
_ID_PREFIX = "SALESFORCE_"
|
||||
|
||||
|
||||
class SalesforceConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
@@ -65,46 +60,6 @@ class SalesforceConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
raise ConnectorMissingCredentialError("Salesforce")
|
||||
return self._sf_client
|
||||
|
||||
def _extract_primary_owners(
|
||||
self, sf_object: SalesforceObject
|
||||
) -> list[BasicExpertInfo] | None:
|
||||
object_dict = sf_object.data
|
||||
if not (last_modified_by_id := object_dict.get("LastModifiedById")):
|
||||
return None
|
||||
if not (last_modified_by := get_record(last_modified_by_id)):
|
||||
return None
|
||||
if not (last_modified_by_name := last_modified_by.data.get("Name")):
|
||||
return None
|
||||
primary_owners = [BasicExpertInfo(display_name=last_modified_by_name)]
|
||||
return primary_owners
|
||||
|
||||
def _convert_object_instance_to_document(
|
||||
self, sf_object: SalesforceObject
|
||||
) -> Document:
|
||||
object_dict = sf_object.data
|
||||
salesforce_id = object_dict["Id"]
|
||||
onyx_salesforce_id = f"{_ID_PREFIX}{salesforce_id}"
|
||||
base_url = f"https://{self.sf_client.sf_instance}"
|
||||
extracted_doc_updated_at = time_str_to_utc(object_dict["LastModifiedDate"])
|
||||
extracted_semantic_identifier = object_dict.get("Name", "Unknown Object")
|
||||
|
||||
sections = [extract_section(sf_object, base_url)]
|
||||
for id in get_child_ids(sf_object.id):
|
||||
if not (child_object := get_record(id)):
|
||||
continue
|
||||
sections.append(extract_section(child_object, base_url))
|
||||
|
||||
doc = Document(
|
||||
id=onyx_salesforce_id,
|
||||
sections=sections,
|
||||
source=DocumentSource.SALESFORCE,
|
||||
semantic_identifier=extracted_semantic_identifier,
|
||||
doc_updated_at=extracted_doc_updated_at,
|
||||
primary_owners=self._extract_primary_owners(sf_object),
|
||||
metadata={},
|
||||
)
|
||||
return doc
|
||||
|
||||
def _fetch_from_salesforce(
|
||||
self,
|
||||
start: SecondsSinceUnixEpoch | None = None,
|
||||
@@ -126,6 +81,9 @@ class SalesforceConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
f"Found {len(child_types)} child types for {parent_object_type}"
|
||||
)
|
||||
|
||||
# Always want to make sure user is grabbed for permissioning purposes
|
||||
all_object_types.add("User")
|
||||
|
||||
logger.info(f"Found total of {len(all_object_types)} object types to fetch")
|
||||
logger.debug(f"All object types: {all_object_types}")
|
||||
|
||||
@@ -169,9 +127,6 @@ class SalesforceConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
logger.debug(
|
||||
f"Added {len(new_ids)} new/updated records for {object_type}"
|
||||
)
|
||||
# Remove the csv file after it has been used
|
||||
# to successfully update the db
|
||||
os.remove(csv_path)
|
||||
|
||||
logger.info(f"Found {len(updated_ids)} total updated records")
|
||||
logger.info(
|
||||
@@ -196,7 +151,10 @@ class SalesforceConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
continue
|
||||
|
||||
docs_to_yield.append(
|
||||
self._convert_object_instance_to_document(parent_object)
|
||||
convert_sf_object_to_doc(
|
||||
sf_object=parent_object,
|
||||
sf_instance=self.sf_client.sf_instance,
|
||||
)
|
||||
)
|
||||
docs_processed += 1
|
||||
|
||||
@@ -225,7 +183,7 @@ class SalesforceConnector(LoadConnector, PollConnector, SlimConnector):
|
||||
query_result = self.sf_client.query_all(query)
|
||||
doc_metadata_list.extend(
|
||||
SlimDocument(
|
||||
id=f"{_ID_PREFIX}{instance_dict.get('Id', '')}",
|
||||
id=f"{ID_PREFIX}{instance_dict.get('Id', '')}",
|
||||
perm_sync_data={},
|
||||
)
|
||||
for instance_dict in query_result["records"]
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.cross_connector_utils.miscellaneous_utils import time_str_to_utc
|
||||
from onyx.connectors.models import BasicExpertInfo
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import Section
|
||||
from onyx.connectors.salesforce.sqlite_functions import get_child_ids
|
||||
from onyx.connectors.salesforce.sqlite_functions import get_record
|
||||
from onyx.connectors.salesforce.utils import SalesforceObject
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
ID_PREFIX = "SALESFORCE_"
|
||||
|
||||
# All of these types of keys are handled by specific fields in the doc
|
||||
# conversion process (E.g. URLs) or are not useful for the user (E.g. UUIDs)
|
||||
@@ -103,54 +113,72 @@ def _extract_dict_text(raw_dict: dict) -> str:
|
||||
return natural_language_for_dict
|
||||
|
||||
|
||||
def extract_section(salesforce_object: SalesforceObject, base_url: str) -> Section:
|
||||
def _extract_section(salesforce_object: SalesforceObject, base_url: str) -> Section:
|
||||
return Section(
|
||||
text=_extract_dict_text(salesforce_object.data),
|
||||
link=f"{base_url}/{salesforce_object.id}",
|
||||
)
|
||||
|
||||
|
||||
def _field_value_is_child_object(field_value: dict) -> bool:
|
||||
"""
|
||||
Checks if the field value is a child object.
|
||||
"""
|
||||
return (
|
||||
isinstance(field_value, OrderedDict)
|
||||
and "records" in field_value.keys()
|
||||
and isinstance(field_value["records"], list)
|
||||
and len(field_value["records"]) > 0
|
||||
and "Id" in field_value["records"][0].keys()
|
||||
def _extract_primary_owners(
|
||||
sf_object: SalesforceObject,
|
||||
) -> list[BasicExpertInfo] | None:
|
||||
object_dict = sf_object.data
|
||||
if not (last_modified_by_id := object_dict.get("LastModifiedById")):
|
||||
logger.warning(f"No LastModifiedById found for {sf_object.id}")
|
||||
return None
|
||||
if not (last_modified_by := get_record(last_modified_by_id)):
|
||||
logger.warning(f"No LastModifiedBy found for {last_modified_by_id}")
|
||||
return None
|
||||
|
||||
user_data = last_modified_by.data
|
||||
expert_info = BasicExpertInfo(
|
||||
first_name=user_data.get("FirstName"),
|
||||
last_name=user_data.get("LastName"),
|
||||
email=user_data.get("Email"),
|
||||
display_name=user_data.get("Name"),
|
||||
)
|
||||
|
||||
# Check if all fields are None
|
||||
if all(
|
||||
value is None
|
||||
for value in [
|
||||
expert_info.first_name,
|
||||
expert_info.last_name,
|
||||
expert_info.email,
|
||||
expert_info.display_name,
|
||||
]
|
||||
):
|
||||
logger.warning(f"No identifying information found for user {user_data}")
|
||||
return None
|
||||
|
||||
def _extract_sections(salesforce_object: dict, base_url: str) -> list[Section]:
|
||||
"""
|
||||
This goes through the salesforce_object and extracts the top level fields as a Section.
|
||||
It also goes through the child objects and extracts them as Sections.
|
||||
"""
|
||||
top_level_dict = {}
|
||||
return [expert_info]
|
||||
|
||||
child_object_sections = []
|
||||
for field_name, field_value in salesforce_object.items():
|
||||
# If the field value is not a child object, add it to the top level dict
|
||||
# to turn into text for the top level section
|
||||
if not _field_value_is_child_object(field_value):
|
||||
top_level_dict[field_name] = field_value
|
||||
|
||||
def convert_sf_object_to_doc(
|
||||
sf_object: SalesforceObject,
|
||||
sf_instance: str,
|
||||
) -> Document:
|
||||
object_dict = sf_object.data
|
||||
salesforce_id = object_dict["Id"]
|
||||
onyx_salesforce_id = f"{ID_PREFIX}{salesforce_id}"
|
||||
base_url = f"https://{sf_instance}"
|
||||
extracted_doc_updated_at = time_str_to_utc(object_dict["LastModifiedDate"])
|
||||
extracted_semantic_identifier = object_dict.get("Name", "Unknown Object")
|
||||
|
||||
sections = [_extract_section(sf_object, base_url)]
|
||||
for id in get_child_ids(sf_object.id):
|
||||
if not (child_object := get_record(id)):
|
||||
continue
|
||||
sections.append(_extract_section(child_object, base_url))
|
||||
|
||||
# If the field value is a child object, extract the child objects and add them as sections
|
||||
for record in field_value["records"]:
|
||||
child_object_id = record["Id"]
|
||||
child_object_sections.append(
|
||||
Section(
|
||||
text=f"Child Object(s): {field_name}\n{_extract_dict_text(record)}",
|
||||
link=f"{base_url}/{child_object_id}",
|
||||
)
|
||||
)
|
||||
|
||||
top_level_id = salesforce_object["Id"]
|
||||
top_level_section = Section(
|
||||
text=_extract_dict_text(top_level_dict),
|
||||
link=f"{base_url}/{top_level_id}",
|
||||
doc = Document(
|
||||
id=onyx_salesforce_id,
|
||||
sections=sections,
|
||||
source=DocumentSource.SALESFORCE,
|
||||
semantic_identifier=extracted_semantic_identifier,
|
||||
doc_updated_at=extracted_doc_updated_at,
|
||||
primary_owners=_extract_primary_owners(sf_object),
|
||||
metadata={},
|
||||
)
|
||||
return [top_level_section, *child_object_sections]
|
||||
return doc
|
||||
|
||||
@@ -77,25 +77,28 @@ def _get_all_queryable_fields_of_sf_type(
|
||||
object_description = _get_sf_type_object_json(sf_client, sf_type)
|
||||
fields: list[dict[str, Any]] = object_description["fields"]
|
||||
valid_fields: set[str] = set()
|
||||
compound_field_names: set[str] = set()
|
||||
field_names_to_remove: set[str] = set()
|
||||
for field in fields:
|
||||
if compound_field_name := field.get("compoundFieldName"):
|
||||
compound_field_names.add(compound_field_name)
|
||||
# We do want to get name fields even if they are compound
|
||||
if not field.get("nameField"):
|
||||
field_names_to_remove.add(compound_field_name)
|
||||
if field.get("type", "base64") == "base64":
|
||||
continue
|
||||
if field_name := field.get("name"):
|
||||
valid_fields.add(field_name)
|
||||
|
||||
return list(valid_fields - compound_field_names)
|
||||
return list(valid_fields - field_names_to_remove)
|
||||
|
||||
|
||||
def _check_if_object_type_is_empty(sf_client: Salesforce, sf_type: str) -> bool:
|
||||
def _check_if_object_type_is_empty(
|
||||
sf_client: Salesforce, sf_type: str, time_filter: str
|
||||
) -> bool:
|
||||
"""
|
||||
Send a small query to check if the object type is empty so we don't
|
||||
perform extra bulk queries
|
||||
Use the rest api to check to make sure the query will result in a non-empty response
|
||||
"""
|
||||
try:
|
||||
query = f"SELECT Count() FROM {sf_type} LIMIT 1"
|
||||
query = f"SELECT Count() FROM {sf_type}{time_filter} LIMIT 1"
|
||||
result = sf_client.query(query)
|
||||
if result["totalSize"] == 0:
|
||||
return False
|
||||
@@ -134,7 +137,7 @@ def _bulk_retrieve_from_salesforce(
|
||||
sf_type: str,
|
||||
time_filter: str,
|
||||
) -> tuple[str, list[str] | None]:
|
||||
if not _check_if_object_type_is_empty(sf_client, sf_type):
|
||||
if not _check_if_object_type_is_empty(sf_client, sf_type, time_filter):
|
||||
return sf_type, None
|
||||
|
||||
if existing_csvs := _check_for_existing_csvs(sf_type):
|
||||
|
||||
@@ -40,20 +40,20 @@ def get_db_connection(
|
||||
|
||||
def init_db() -> None:
|
||||
"""Initialize the SQLite database with required tables if they don't exist."""
|
||||
if os.path.exists(get_sqlite_db_path()):
|
||||
return
|
||||
|
||||
# Create database directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(get_sqlite_db_path()), exist_ok=True)
|
||||
|
||||
with get_db_connection("EXCLUSIVE") as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Enable WAL mode for better concurrent access and write performance
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA temp_store=MEMORY")
|
||||
cursor.execute("PRAGMA cache_size=-2000000") # Use 2GB memory for cache
|
||||
db_exists = os.path.exists(get_sqlite_db_path())
|
||||
|
||||
if not db_exists:
|
||||
# Enable WAL mode for better concurrent access and write performance
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA temp_store=MEMORY")
|
||||
cursor.execute("PRAGMA cache_size=-2000000") # Use 2GB memory for cache
|
||||
|
||||
# Main table for storing Salesforce objects
|
||||
cursor.execute(
|
||||
@@ -90,49 +90,69 @@ def init_db() -> None:
|
||||
"""
|
||||
)
|
||||
|
||||
# Always recreate indexes to ensure they exist
|
||||
cursor.execute("DROP INDEX IF EXISTS idx_object_type")
|
||||
cursor.execute("DROP INDEX IF EXISTS idx_parent_id")
|
||||
cursor.execute("DROP INDEX IF EXISTS idx_child_parent")
|
||||
cursor.execute("DROP INDEX IF EXISTS idx_object_type_id")
|
||||
cursor.execute("DROP INDEX IF EXISTS idx_relationship_types_lookup")
|
||||
|
||||
# Create covering indexes for common queries
|
||||
# Create a table for User email to ID mapping if it doesn't exist
|
||||
cursor.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS user_email_map (
|
||||
email TEXT PRIMARY KEY,
|
||||
user_id TEXT, -- Nullable to allow for users without IDs
|
||||
FOREIGN KEY (user_id) REFERENCES salesforce_objects(id)
|
||||
) WITHOUT ROWID
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes if they don't exist (SQLite ignores IF NOT EXISTS for indexes)
|
||||
def create_index_if_not_exists(index_name: str, create_statement: str) -> None:
|
||||
cursor.execute(
|
||||
f"SELECT name FROM sqlite_master WHERE type='index' AND name='{index_name}'"
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
cursor.execute(create_statement)
|
||||
|
||||
create_index_if_not_exists(
|
||||
"idx_object_type",
|
||||
"""
|
||||
CREATE INDEX idx_object_type
|
||||
ON salesforce_objects(object_type, id)
|
||||
WHERE object_type IS NOT NULL
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
create_index_if_not_exists(
|
||||
"idx_parent_id",
|
||||
"""
|
||||
CREATE INDEX idx_parent_id
|
||||
ON relationships(parent_id, child_id)
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
create_index_if_not_exists(
|
||||
"idx_child_parent",
|
||||
"""
|
||||
CREATE INDEX idx_child_parent
|
||||
ON relationships(child_id)
|
||||
WHERE child_id IS NOT NULL
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
# New composite index for fast parent type lookups
|
||||
cursor.execute(
|
||||
create_index_if_not_exists(
|
||||
"idx_relationship_types_lookup",
|
||||
"""
|
||||
CREATE INDEX idx_relationship_types_lookup
|
||||
ON relationship_types(parent_type, child_id, parent_id)
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
# Analyze tables to help query planner
|
||||
cursor.execute("ANALYZE relationships")
|
||||
cursor.execute("ANALYZE salesforce_objects")
|
||||
cursor.execute("ANALYZE relationship_types")
|
||||
cursor.execute("ANALYZE user_email_map")
|
||||
|
||||
# If database already existed but user_email_map needs to be populated
|
||||
cursor.execute("SELECT COUNT(*) FROM user_email_map")
|
||||
if cursor.fetchone()[0] == 0:
|
||||
_update_user_email_map(conn)
|
||||
|
||||
conn.commit()
|
||||
|
||||
@@ -203,7 +223,27 @@ def _update_relationship_tables(
|
||||
raise
|
||||
|
||||
|
||||
def update_sf_db_with_csv(object_type: str, csv_download_path: str) -> list[str]:
|
||||
def _update_user_email_map(conn: sqlite3.Connection) -> None:
|
||||
"""Update the user_email_map table with current User objects.
|
||||
Called internally by update_sf_db_with_csv when User objects are updated.
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO user_email_map (email, user_id)
|
||||
SELECT json_extract(data, '$.Email'), id
|
||||
FROM salesforce_objects
|
||||
WHERE object_type = 'User'
|
||||
AND json_extract(data, '$.Email') IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def update_sf_db_with_csv(
|
||||
object_type: str,
|
||||
csv_download_path: str,
|
||||
delete_csv_after_use: bool = True,
|
||||
) -> list[str]:
|
||||
"""Update the SF DB with a CSV file using SQLite storage."""
|
||||
updated_ids = []
|
||||
|
||||
@@ -249,8 +289,17 @@ def update_sf_db_with_csv(object_type: str, csv_download_path: str) -> list[str]
|
||||
_update_relationship_tables(conn, id, parent_ids)
|
||||
updated_ids.append(id)
|
||||
|
||||
# If we're updating User objects, update the email map
|
||||
if object_type == "User":
|
||||
_update_user_email_map(conn)
|
||||
|
||||
conn.commit()
|
||||
|
||||
if delete_csv_after_use:
|
||||
# Remove the csv file after it has been used
|
||||
# to successfully update the db
|
||||
os.remove(csv_download_path)
|
||||
|
||||
return updated_ids
|
||||
|
||||
|
||||
@@ -329,6 +378,9 @@ def get_affected_parent_ids_by_type(
|
||||
cursor = conn.cursor()
|
||||
|
||||
for batch_ids in updated_ids_batches:
|
||||
batch_ids = list(set(batch_ids) - updated_parent_ids)
|
||||
if not batch_ids:
|
||||
continue
|
||||
id_placeholders = ",".join(["?" for _ in batch_ids])
|
||||
|
||||
for parent_type in parent_types:
|
||||
@@ -384,3 +436,40 @@ def has_at_least_one_object_of_type(object_type: str) -> bool:
|
||||
)
|
||||
count = cursor.fetchone()[0]
|
||||
return count > 0
|
||||
|
||||
|
||||
# NULL_ID_STRING is used to indicate that the user ID was queried but not found
|
||||
# As opposed to None because it has yet to be queried at all
|
||||
NULL_ID_STRING = "N/A"
|
||||
|
||||
|
||||
def get_user_id_by_email(email: str) -> str | None:
|
||||
"""Get the Salesforce User ID for a given email address.
|
||||
|
||||
Args:
|
||||
email: The email address to look up
|
||||
|
||||
Returns:
|
||||
A tuple of (was_found, user_id):
|
||||
- was_found: True if the email exists in the table, False if not found
|
||||
- user_id: The Salesforce User ID if exists, None otherwise
|
||||
"""
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT user_id FROM user_email_map WHERE email = ?", (email,))
|
||||
result = cursor.fetchone()
|
||||
if result is None:
|
||||
return None
|
||||
return result[0]
|
||||
|
||||
|
||||
def update_email_to_id_table(email: str, id: str | None) -> None:
|
||||
"""Update the email to ID map table with a new email and ID."""
|
||||
id_to_use = id or NULL_ID_STRING
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT OR REPLACE INTO user_email_map (email, user_id) VALUES (?, ?)",
|
||||
(email, id_to_use),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -37,6 +37,7 @@ from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.threadpool_concurrency import FunctionCall
|
||||
from onyx.utils.threadpool_concurrency import run_functions_in_parallel
|
||||
from onyx.utils.timing import log_function_time
|
||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -163,6 +164,17 @@ class SearchPipeline:
|
||||
# These chunks are ordered, deduped, and contain no large chunks
|
||||
retrieved_chunks = self._get_chunks()
|
||||
|
||||
# If ee is enabled, censor the chunk sections based on user access
|
||||
# Otherwise, return the retrieved chunks
|
||||
censored_chunks = fetch_ee_implementation_or_noop(
|
||||
"onyx.external_permissions.post_query_censoring",
|
||||
"_post_query_chunk_censoring",
|
||||
retrieved_chunks,
|
||||
)(
|
||||
chunks=retrieved_chunks,
|
||||
user=self.user,
|
||||
)
|
||||
|
||||
above = self.search_query.chunks_above
|
||||
below = self.search_query.chunks_below
|
||||
|
||||
@@ -175,7 +187,7 @@ class SearchPipeline:
|
||||
seen_document_ids = set()
|
||||
|
||||
# This preserves the ordering since the chunks are retrieved in score order
|
||||
for chunk in retrieved_chunks:
|
||||
for chunk in censored_chunks:
|
||||
if chunk.document_id not in seen_document_ids:
|
||||
seen_document_ids.add(chunk.document_id)
|
||||
chunk_requests.append(
|
||||
@@ -225,7 +237,7 @@ class SearchPipeline:
|
||||
# This maintains the original chunks ordering. Note, we cannot simply sort by score here
|
||||
# as reranking flow may wipe the scores for a lot of the chunks.
|
||||
doc_chunk_ranges_map = defaultdict(list)
|
||||
for chunk in retrieved_chunks:
|
||||
for chunk in censored_chunks:
|
||||
# The list of ranges for each document is ordered by score
|
||||
doc_chunk_ranges_map[chunk.document_id].append(
|
||||
ChunkRange(
|
||||
@@ -274,11 +286,11 @@ class SearchPipeline:
|
||||
|
||||
# In case of failed parallel calls to Vespa, at least we should have the initial retrieved chunks
|
||||
doc_chunk_ind_to_chunk.update(
|
||||
{(chunk.document_id, chunk.chunk_id): chunk for chunk in retrieved_chunks}
|
||||
{(chunk.document_id, chunk.chunk_id): chunk for chunk in censored_chunks}
|
||||
)
|
||||
|
||||
# Build the surroundings for all of the initial retrieved chunks
|
||||
for chunk in retrieved_chunks:
|
||||
for chunk in censored_chunks:
|
||||
start_ind = max(0, chunk.chunk_id - above)
|
||||
end_ind = chunk.chunk_id + below
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql.expression import null
|
||||
|
||||
from onyx.configs.constants import DEFAULT_BOOST
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
|
||||
from onyx.db.enums import AccessType
|
||||
from onyx.db.enums import ConnectorCredentialPairStatus
|
||||
from onyx.db.feedback import delete_document_feedback_for_documents__no_commit
|
||||
from onyx.db.models import Connector
|
||||
from onyx.db.models import ConnectorCredentialPair
|
||||
from onyx.db.models import Credential
|
||||
from onyx.db.models import Document as DbDocument
|
||||
@@ -626,6 +628,60 @@ def get_document(
|
||||
return doc
|
||||
|
||||
|
||||
def get_cc_pairs_for_document(
|
||||
db_session: Session,
|
||||
document_id: str,
|
||||
) -> list[ConnectorCredentialPair]:
|
||||
stmt = (
|
||||
select(ConnectorCredentialPair)
|
||||
.join(
|
||||
DocumentByConnectorCredentialPair,
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id
|
||||
== ConnectorCredentialPair.connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id
|
||||
== ConnectorCredentialPair.credential_id,
|
||||
),
|
||||
)
|
||||
.where(DocumentByConnectorCredentialPair.id == document_id)
|
||||
)
|
||||
return list(db_session.execute(stmt).scalars().all())
|
||||
|
||||
|
||||
def get_document_sources(
|
||||
db_session: Session,
|
||||
document_ids: list[str],
|
||||
) -> dict[str, DocumentSource]:
|
||||
"""Gets the sources for a list of document IDs.
|
||||
Returns a dictionary mapping document ID to its source.
|
||||
If a document has multiple sources (multiple CC pairs), returns the first one found.
|
||||
"""
|
||||
stmt = (
|
||||
select(
|
||||
DocumentByConnectorCredentialPair.id,
|
||||
Connector.source,
|
||||
)
|
||||
.join(
|
||||
ConnectorCredentialPair,
|
||||
and_(
|
||||
DocumentByConnectorCredentialPair.connector_id
|
||||
== ConnectorCredentialPair.connector_id,
|
||||
DocumentByConnectorCredentialPair.credential_id
|
||||
== ConnectorCredentialPair.credential_id,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
Connector,
|
||||
ConnectorCredentialPair.connector_id == Connector.id,
|
||||
)
|
||||
.where(DocumentByConnectorCredentialPair.id.in_(document_ids))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
results = db_session.execute(stmt).all()
|
||||
return {doc_id: source for doc_id, source in results}
|
||||
|
||||
|
||||
def fetch_chunk_counts_for_documents(
|
||||
document_ids: list[str],
|
||||
db_session: Session,
|
||||
|
||||
@@ -508,7 +508,6 @@ class Document(Base):
|
||||
last_synced: Mapped[datetime.datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
|
||||
# The following are not attached to User because the account/email may not be known
|
||||
# within Onyx
|
||||
# Something like the document creator
|
||||
@@ -1934,3 +1933,13 @@ class UserTenantMapping(Base):
|
||||
|
||||
email: Mapped[str] = mapped_column(String, nullable=False, primary_key=True)
|
||||
tenant_id: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
|
||||
# This is a mapping from tenant IDs to anonymous user paths
|
||||
class TenantAnonymousUserPath(Base):
|
||||
__tablename__ = "tenant_anonymous_user_path"
|
||||
|
||||
tenant_id: Mapped[str] = mapped_column(String, primary_key=True, nullable=False)
|
||||
anonymous_user_path: Mapped[str] = mapped_column(
|
||||
String, nullable=False, unique=True
|
||||
)
|
||||
|
||||
@@ -23,11 +23,13 @@ class ChunkEmbedding(BaseModel):
|
||||
|
||||
class BaseChunk(BaseModel):
|
||||
chunk_id: int
|
||||
blurb: str # The first sentence(s) of the first Section of the chunk
|
||||
# The first sentence(s) of the first Section of the chunk
|
||||
blurb: str
|
||||
content: str
|
||||
# Holds the link and the offsets into the raw Chunk text
|
||||
source_links: dict[int, str] | None
|
||||
section_continuation: bool # True if this Chunk's start is not at the start of a Section
|
||||
# True if this Chunk's start is not at the start of a Section
|
||||
section_continuation: bool
|
||||
|
||||
|
||||
class DocAwareChunk(BaseChunk):
|
||||
|
||||
@@ -30,6 +30,7 @@ from onyx.auth.users import fastapi_users
|
||||
from onyx.configs.app_configs import APP_API_PREFIX
|
||||
from onyx.configs.app_configs import APP_HOST
|
||||
from onyx.configs.app_configs import APP_PORT
|
||||
from onyx.configs.app_configs import AUTH_RATE_LIMITING_ENABLED
|
||||
from onyx.configs.app_configs import AUTH_TYPE
|
||||
from onyx.configs.app_configs import DISABLE_GENERATIVE_AI
|
||||
from onyx.configs.app_configs import LOG_ENDPOINT_LATENCY
|
||||
@@ -74,9 +75,9 @@ from onyx.server.manage.search_settings import router as search_settings_router
|
||||
from onyx.server.manage.slack_bot import router as slack_bot_management_router
|
||||
from onyx.server.manage.users import router as user_router
|
||||
from onyx.server.middleware.latency_logging import add_latency_logging_middleware
|
||||
from onyx.server.middleware.rate_limiting import close_limiter
|
||||
from onyx.server.middleware.rate_limiting import close_auth_limiter
|
||||
from onyx.server.middleware.rate_limiting import get_auth_rate_limiters
|
||||
from onyx.server.middleware.rate_limiting import setup_limiter
|
||||
from onyx.server.middleware.rate_limiting import setup_auth_limiter
|
||||
from onyx.server.onyx_api.ingestion import router as onyx_api_router
|
||||
from onyx.server.openai_assistants_api.full_openai_assistants_api import (
|
||||
get_full_openai_assistants_api_router,
|
||||
@@ -174,13 +175,14 @@ def include_auth_router_with_prefix(
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
# Set recursion limit
|
||||
if SYSTEM_RECURSION_LIMIT is not None:
|
||||
sys.setrecursionlimit(SYSTEM_RECURSION_LIMIT)
|
||||
logger.notice(f"System recursion limit set to {SYSTEM_RECURSION_LIMIT}")
|
||||
|
||||
SqlEngine.set_app_name(POSTGRES_WEB_APP_NAME)
|
||||
|
||||
SqlEngine.init_engine(
|
||||
pool_size=POSTGRES_API_SERVER_POOL_SIZE,
|
||||
max_overflow=POSTGRES_API_SERVER_POOL_OVERFLOW,
|
||||
@@ -215,13 +217,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator:
|
||||
|
||||
optional_telemetry(record_type=RecordType.VERSION, data={"version": __version__})
|
||||
|
||||
# Set up rate limiter
|
||||
await setup_limiter()
|
||||
if AUTH_RATE_LIMITING_ENABLED:
|
||||
await setup_auth_limiter()
|
||||
|
||||
yield
|
||||
|
||||
# Close rate limiter
|
||||
await close_limiter()
|
||||
if AUTH_RATE_LIMITING_ENABLED:
|
||||
await close_auth_limiter()
|
||||
|
||||
|
||||
def log_http_error(_: Request, exc: Exception) -> JSONResponse:
|
||||
|
||||
@@ -30,7 +30,6 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
|
||||
FENCE_PREFIX = PREFIX + "_fence"
|
||||
TASKSET_PREFIX = PREFIX + "_taskset"
|
||||
|
||||
# SYNCING_HASH = PREFIX + ":vespa_syncing"
|
||||
SYNCING_PREFIX = PREFIX + ":vespa_syncing"
|
||||
|
||||
def __init__(self, tenant_id: str | None, id: int) -> None:
|
||||
@@ -61,6 +60,7 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
|
||||
|
||||
@staticmethod
|
||||
def make_redis_syncing_key(doc_id: str) -> str:
|
||||
"""used to create a key in redis to block a doc from syncing"""
|
||||
return f"{RedisConnectorCredentialPair.SYNCING_PREFIX}:{doc_id}"
|
||||
|
||||
def generate_tasks(
|
||||
@@ -71,9 +71,6 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
|
||||
lock: RedisLock,
|
||||
tenant_id: str | None,
|
||||
) -> tuple[int, int] | None:
|
||||
# an arbitrary number in seconds to prevent the same doc from syncing repeatedly
|
||||
SYNC_EXPIRATION = 24 * 60 * 60
|
||||
|
||||
last_lock_time = time.monotonic()
|
||||
|
||||
async_results = []
|
||||
@@ -102,13 +99,14 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
|
||||
if doc.id in self.skip_docs:
|
||||
continue
|
||||
|
||||
# is the document sync already queued?
|
||||
# if redis_client.hexists(doc.id):
|
||||
# continue
|
||||
# an arbitrary number in seconds to prevent the same doc from syncing repeatedly
|
||||
# SYNC_EXPIRATION = 24 * 60 * 60
|
||||
|
||||
redis_syncing_key = self.make_redis_syncing_key(doc.id)
|
||||
if redis_client.exists(redis_syncing_key):
|
||||
continue
|
||||
# a quick hack that can be uncommented to prevent a doc from resyncing over and over
|
||||
# redis_syncing_key = self.make_redis_syncing_key(doc.id)
|
||||
# if redis_client.exists(redis_syncing_key):
|
||||
# continue
|
||||
# redis_client.set(redis_syncing_key, custom_task_id, ex=SYNC_EXPIRATION)
|
||||
|
||||
# celery's default task id format is "dd32ded3-00aa-4884-8b21-42f8332e7fac"
|
||||
# the key for the result is "celery-task-meta-dd32ded3-00aa-4884-8b21-42f8332e7fac"
|
||||
@@ -122,13 +120,6 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
|
||||
RedisConnectorCredentialPair.get_taskset_key(), custom_task_id
|
||||
)
|
||||
|
||||
# track the doc.id in redis so that we don't resubmit it repeatedly
|
||||
# redis_client.hset(
|
||||
# self.SYNCING_HASH, doc.id, custom_task_id
|
||||
# )
|
||||
|
||||
redis_client.set(redis_syncing_key, custom_task_id, ex=SYNC_EXPIRATION)
|
||||
|
||||
# Priority on sync's triggered by new indexing should be medium
|
||||
result = celery_app.send_task(
|
||||
OnyxCeleryTask.VESPA_METADATA_SYNC_TASK,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import ssl
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
@@ -194,10 +195,6 @@ class RedisPool:
|
||||
redis_pool = RedisPool()
|
||||
|
||||
|
||||
def get_redis_client(*, tenant_id: str | None) -> Redis:
|
||||
return redis_pool.get_client(tenant_id)
|
||||
|
||||
|
||||
# # Usage example
|
||||
# redis_pool = RedisPool()
|
||||
# redis_client = redis_pool.get_client()
|
||||
@@ -207,6 +204,18 @@ def get_redis_client(*, tenant_id: str | None) -> Redis:
|
||||
# value = redis_client.get('key')
|
||||
# print(value.decode()) # Output: 'value'
|
||||
|
||||
|
||||
def get_redis_client(*, tenant_id: str | None) -> Redis:
|
||||
return redis_pool.get_client(tenant_id)
|
||||
|
||||
|
||||
SSL_CERT_REQS_MAP = {
|
||||
"none": ssl.CERT_NONE,
|
||||
"optional": ssl.CERT_OPTIONAL,
|
||||
"required": ssl.CERT_REQUIRED,
|
||||
}
|
||||
|
||||
|
||||
_async_redis_connection: aioredis.Redis | None = None
|
||||
_async_lock = asyncio.Lock()
|
||||
|
||||
@@ -224,15 +233,35 @@ async def get_async_redis_connection() -> aioredis.Redis:
|
||||
async with _async_lock:
|
||||
# Double-check inside the lock to avoid race conditions
|
||||
if _async_redis_connection is None:
|
||||
scheme = "rediss" if REDIS_SSL else "redis"
|
||||
url = f"{scheme}://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB_NUMBER}"
|
||||
# Load env vars or your config variables
|
||||
|
||||
# Create a new Redis connection (or connection pool) from the URL
|
||||
_async_redis_connection = aioredis.from_url(
|
||||
url,
|
||||
password=REDIS_PASSWORD,
|
||||
max_connections=REDIS_POOL_MAX_CONNECTIONS,
|
||||
)
|
||||
connection_kwargs: dict[str, Any] = {
|
||||
"host": REDIS_HOST,
|
||||
"port": REDIS_PORT,
|
||||
"db": REDIS_DB_NUMBER,
|
||||
"password": REDIS_PASSWORD,
|
||||
"max_connections": REDIS_POOL_MAX_CONNECTIONS,
|
||||
"health_check_interval": REDIS_HEALTH_CHECK_INTERVAL,
|
||||
"socket_keepalive": True,
|
||||
"socket_keepalive_options": REDIS_SOCKET_KEEPALIVE_OPTIONS,
|
||||
}
|
||||
|
||||
if REDIS_SSL:
|
||||
ssl_context = ssl.create_default_context()
|
||||
|
||||
if REDIS_SSL_CA_CERTS:
|
||||
ssl_context.load_verify_locations(REDIS_SSL_CA_CERTS)
|
||||
ssl_context.check_hostname = False
|
||||
|
||||
# Map your string to the proper ssl.CERT_* constant
|
||||
ssl_context.verify_mode = SSL_CERT_REQS_MAP.get(
|
||||
REDIS_SSL_CERT_REQS, ssl.CERT_NONE
|
||||
)
|
||||
|
||||
connection_kwargs["ssl"] = ssl_context
|
||||
|
||||
# Create a new Redis connection (or connection pool) with SSL configuration
|
||||
_async_redis_connection = aioredis.Redis(**connection_kwargs)
|
||||
|
||||
# Return the established connection (or pool) for all future operations
|
||||
return _async_redis_connection
|
||||
|
||||
@@ -46,6 +46,8 @@ PUBLIC_ENDPOINT_SPECS = [
|
||||
# oauth
|
||||
("/auth/oauth/authorize", {"GET"}),
|
||||
("/auth/oauth/callback", {"GET"}),
|
||||
# anonymous user on cloud
|
||||
("/tenants/anonymous-user", {"POST"}),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ from onyx.configs.constants import AuthType
|
||||
from onyx.db.api_key import is_api_key_email_address
|
||||
from onyx.db.auth import get_total_users_count
|
||||
from onyx.db.engine import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
from onyx.db.engine import get_current_tenant_id
|
||||
from onyx.db.engine import get_session
|
||||
from onyx.db.models import AccessToken
|
||||
from onyx.db.models import User
|
||||
@@ -525,6 +526,7 @@ def get_current_token_creation(
|
||||
def verify_user_logged_in(
|
||||
user: User | None = Depends(optional_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
) -> UserInfo:
|
||||
# NOTE: this does not use `current_user` / `current_admin_user` because we don't want
|
||||
# to enforce user verification here - the frontend always wants to get the info about
|
||||
@@ -535,7 +537,7 @@ def verify_user_logged_in(
|
||||
if AUTH_TYPE == AuthType.DISABLED:
|
||||
store = get_kv_store()
|
||||
return fetch_no_auth_user(store)
|
||||
if anonymous_user_enabled():
|
||||
if anonymous_user_enabled(tenant_id=tenant_id):
|
||||
store = get_kv_store()
|
||||
return fetch_no_auth_user(store, anonymous_user_enabled=True)
|
||||
|
||||
|
||||
@@ -6,18 +6,19 @@ from fastapi import Request
|
||||
from fastapi_limiter import FastAPILimiter
|
||||
from fastapi_limiter.depends import RateLimiter
|
||||
|
||||
from onyx.configs.app_configs import AUTH_RATE_LIMITING_ENABLED
|
||||
from onyx.configs.app_configs import RATE_LIMIT_MAX_REQUESTS
|
||||
from onyx.configs.app_configs import RATE_LIMIT_WINDOW_SECONDS
|
||||
from onyx.redis.redis_pool import get_async_redis_connection
|
||||
|
||||
|
||||
async def setup_limiter() -> None:
|
||||
async def setup_auth_limiter() -> None:
|
||||
# Use the centralized async Redis connection
|
||||
redis = await get_async_redis_connection()
|
||||
await FastAPILimiter.init(redis)
|
||||
|
||||
|
||||
async def close_limiter() -> None:
|
||||
async def close_auth_limiter() -> None:
|
||||
# This closes the FastAPILimiter connection so we don't leave open connections to Redis.
|
||||
await FastAPILimiter.close()
|
||||
|
||||
@@ -32,14 +33,14 @@ async def rate_limit_key(request: Request) -> str:
|
||||
|
||||
|
||||
def get_auth_rate_limiters() -> List[Callable]:
|
||||
if not (RATE_LIMIT_MAX_REQUESTS and RATE_LIMIT_WINDOW_SECONDS):
|
||||
if not AUTH_RATE_LIMITING_ENABLED:
|
||||
return []
|
||||
|
||||
return [
|
||||
Depends(
|
||||
RateLimiter(
|
||||
times=RATE_LIMIT_MAX_REQUESTS,
|
||||
seconds=RATE_LIMIT_WINDOW_SECONDS,
|
||||
times=RATE_LIMIT_MAX_REQUESTS or 100,
|
||||
seconds=RATE_LIMIT_WINDOW_SECONDS or 60,
|
||||
# Use the custom key function to distinguish users
|
||||
identifier=rate_limit_key,
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from onyx.auth.users import current_user
|
||||
from onyx.auth.users import is_user_admin
|
||||
from onyx.configs.constants import KV_REINDEX_KEY
|
||||
from onyx.configs.constants import NotificationType
|
||||
from onyx.db.engine import get_current_tenant_id
|
||||
from onyx.db.engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.db.notification import create_notification
|
||||
@@ -25,10 +26,8 @@ from onyx.server.settings.store import load_settings
|
||||
from onyx.server.settings.store import store_settings
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
admin_router = APIRouter(prefix="/admin/settings")
|
||||
basic_router = APIRouter(prefix="/settings")
|
||||
|
||||
@@ -44,6 +43,7 @@ def put_settings(
|
||||
def fetch_settings(
|
||||
user: User | None = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
tenant_id: str | None = Depends(get_current_tenant_id),
|
||||
) -> UserSettings:
|
||||
"""Settings and notifications are stuffed into this single endpoint to reduce number of
|
||||
Postgres calls"""
|
||||
|
||||
@@ -50,3 +50,4 @@ class Settings(BaseModel):
|
||||
class UserSettings(Settings):
|
||||
notifications: list[Notification]
|
||||
needs_reindexing: bool
|
||||
tenant_id: str | None = None
|
||||
|
||||
@@ -3,15 +3,18 @@ from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.key_value_store.factory import get_kv_store
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.settings.models import Settings
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
if MULTI_TENANT:
|
||||
# If multi-tenant, anonymous user is always false
|
||||
anonymous_user_enabled = False
|
||||
else:
|
||||
redis_client = get_redis_client(tenant_id=None)
|
||||
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() if MULTI_TENANT else None
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
try:
|
||||
value = redis_client.get(OnyxRedisLocks.ANONYMOUS_USER_ENABLED)
|
||||
if value is not None:
|
||||
assert isinstance(value, bytes)
|
||||
@@ -21,15 +24,20 @@ def load_settings() -> Settings:
|
||||
anonymous_user_enabled = False
|
||||
# Optionally store the default back to Redis
|
||||
redis_client.set(OnyxRedisLocks.ANONYMOUS_USER_ENABLED, "0")
|
||||
except Exception as e:
|
||||
# Log the error and reset to default
|
||||
logger.error(f"Error loading settings from Redis: {str(e)}")
|
||||
anonymous_user_enabled = False
|
||||
|
||||
settings = Settings(anonymous_user_enabled=anonymous_user_enabled)
|
||||
return settings
|
||||
|
||||
|
||||
def store_settings(settings: Settings) -> None:
|
||||
if not MULTI_TENANT and settings.anonymous_user_enabled is not None:
|
||||
# Only non-multi-tenant scenario can set the anonymous user enabled flag
|
||||
redis_client = get_redis_client(tenant_id=None)
|
||||
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get() if MULTI_TENANT else None
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
|
||||
if settings.anonymous_user_enabled is not None:
|
||||
redis_client.set(
|
||||
OnyxRedisLocks.ANONYMOUS_USER_ENABLED,
|
||||
"1" if settings.anonymous_user_enabled else "0",
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
from datetime import datetime
|
||||
|
||||
from ee.onyx.external_permissions.salesforce.postprocessing import (
|
||||
censor_salesforce_chunks,
|
||||
)
|
||||
from onyx.configs.app_configs import BLURB_SIZE
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.context.search.models import InferenceChunk
|
||||
|
||||
|
||||
def create_test_chunk(
|
||||
doc_id: str,
|
||||
chunk_id: int,
|
||||
content: str,
|
||||
source_links: dict[int, str] | None,
|
||||
) -> InferenceChunk:
|
||||
return InferenceChunk(
|
||||
document_id=doc_id,
|
||||
chunk_id=chunk_id,
|
||||
blurb=content[:BLURB_SIZE],
|
||||
content=content,
|
||||
source_links=source_links,
|
||||
section_continuation=False,
|
||||
source_type=DocumentSource.SALESFORCE,
|
||||
semantic_identifier="test_chunk",
|
||||
title="Test Chunk",
|
||||
boost=1,
|
||||
recency_bias=1.0,
|
||||
score=None,
|
||||
hidden=False,
|
||||
metadata={},
|
||||
match_highlights=[],
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def test_validate_salesforce_access_single_object() -> None:
|
||||
"""Test filtering when chunk has a single Salesforce object reference"""
|
||||
section = "This is a test document about a Salesforce object."
|
||||
test_content = section
|
||||
test_chunk = create_test_chunk(
|
||||
doc_id="doc1",
|
||||
chunk_id=1,
|
||||
content=test_content,
|
||||
source_links={0: "https://salesforce.com/object1"},
|
||||
)
|
||||
|
||||
# Test when user has access
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[test_chunk],
|
||||
user_email="test@example.com",
|
||||
access_map={"object1": True},
|
||||
)
|
||||
assert len(filtered_chunks) == 1
|
||||
assert filtered_chunks[0].content == test_content
|
||||
|
||||
# Test when user doesn't have access
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[test_chunk],
|
||||
user_email="test@example.com",
|
||||
access_map={"object1": False},
|
||||
)
|
||||
assert len(filtered_chunks) == 0
|
||||
|
||||
|
||||
def test_validate_salesforce_access_multiple_objects() -> None:
|
||||
"""Test filtering when chunk has multiple Salesforce object references"""
|
||||
section1 = "First part about object1. "
|
||||
section2 = "Second part about object2. "
|
||||
section3 = "Third part about object3."
|
||||
|
||||
test_content = section1 + section2 + section3
|
||||
section1_end = len(section1)
|
||||
section2_end = section1_end + len(section2)
|
||||
|
||||
test_chunk = create_test_chunk(
|
||||
doc_id="doc1",
|
||||
chunk_id=1,
|
||||
content=test_content,
|
||||
source_links={
|
||||
0: "https://salesforce.com/object1",
|
||||
section1_end: "https://salesforce.com/object2",
|
||||
section2_end: "https://salesforce.com/object3",
|
||||
},
|
||||
)
|
||||
|
||||
# Test when user has access to all objects
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[test_chunk],
|
||||
user_email="test@example.com",
|
||||
access_map={
|
||||
"object1": True,
|
||||
"object2": True,
|
||||
"object3": True,
|
||||
},
|
||||
)
|
||||
assert len(filtered_chunks) == 1
|
||||
assert filtered_chunks[0].content == test_content
|
||||
|
||||
# Test when user has access to some objects
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[test_chunk],
|
||||
user_email="test@example.com",
|
||||
access_map={
|
||||
"object1": True,
|
||||
"object2": False,
|
||||
"object3": True,
|
||||
},
|
||||
)
|
||||
assert len(filtered_chunks) == 1
|
||||
assert section1 in filtered_chunks[0].content
|
||||
assert section2 not in filtered_chunks[0].content
|
||||
assert section3 in filtered_chunks[0].content
|
||||
|
||||
# Test when user has no access
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[test_chunk],
|
||||
user_email="test@example.com",
|
||||
access_map={
|
||||
"object1": False,
|
||||
"object2": False,
|
||||
"object3": False,
|
||||
},
|
||||
)
|
||||
assert len(filtered_chunks) == 0
|
||||
|
||||
|
||||
def test_validate_salesforce_access_multiple_chunks() -> None:
|
||||
"""Test filtering when there are multiple chunks with different access patterns"""
|
||||
section1 = "Content about object1"
|
||||
section2 = "Content about object2"
|
||||
|
||||
chunk1 = create_test_chunk(
|
||||
doc_id="doc1",
|
||||
chunk_id=1,
|
||||
content=section1,
|
||||
source_links={0: "https://salesforce.com/object1"},
|
||||
)
|
||||
chunk2 = create_test_chunk(
|
||||
doc_id="doc1",
|
||||
chunk_id=2,
|
||||
content=section2,
|
||||
source_links={0: "https://salesforce.com/object2"},
|
||||
)
|
||||
|
||||
# Test mixed access
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[chunk1, chunk2],
|
||||
user_email="test@example.com",
|
||||
access_map={
|
||||
"object1": True,
|
||||
"object2": False,
|
||||
},
|
||||
)
|
||||
assert len(filtered_chunks) == 1
|
||||
assert filtered_chunks[0].chunk_id == 1
|
||||
assert section1 in filtered_chunks[0].content
|
||||
|
||||
|
||||
def test_validate_salesforce_access_no_source_links() -> None:
|
||||
"""Test handling of chunks with no source links"""
|
||||
section = "Content with no source links"
|
||||
test_chunk = create_test_chunk(
|
||||
doc_id="doc1",
|
||||
chunk_id=1,
|
||||
content=section,
|
||||
source_links=None,
|
||||
)
|
||||
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[test_chunk],
|
||||
user_email="test@example.com",
|
||||
access_map={},
|
||||
)
|
||||
assert len(filtered_chunks) == 0
|
||||
|
||||
|
||||
def test_validate_salesforce_access_blurb_update() -> None:
|
||||
"""Test that blurbs are properly updated based on permitted content"""
|
||||
section = "First part about object1. "
|
||||
long_content = section * 20 # Make it longer than BLURB_SIZE
|
||||
test_chunk = create_test_chunk(
|
||||
doc_id="doc1",
|
||||
chunk_id=1,
|
||||
content=long_content,
|
||||
source_links={0: "https://salesforce.com/object1"},
|
||||
)
|
||||
|
||||
filtered_chunks = censor_salesforce_chunks(
|
||||
chunks=[test_chunk],
|
||||
user_email="test@example.com",
|
||||
access_map={"object1": True},
|
||||
)
|
||||
assert len(filtered_chunks) == 1
|
||||
assert len(filtered_chunks[0].blurb) <= BLURB_SIZE
|
||||
assert filtered_chunks[0].blurb.startswith(section)
|
||||
@@ -13,7 +13,6 @@ const cspHeader = `
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
${
|
||||
process.env.NEXT_PUBLIC_CLOUD_ENABLED === "true"
|
||||
? "upgrade-insecure-requests;"
|
||||
@@ -27,6 +26,16 @@ const nextConfig = {
|
||||
publicRuntimeConfig: {
|
||||
version,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "www.google.com",
|
||||
port: "",
|
||||
pathname: "/s2/favicons/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -44,17 +53,12 @@ const nextConfig = {
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "DENY",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
// Deny all permissions by default
|
||||
value:
|
||||
"accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), cross-origin-isolated=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), navigation-override=(), payment=(), picture-in-picture=(), publickey-credentials-get=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()",
|
||||
},
|
||||
|
||||
307
web/package-lock.json
generated
307
web/package-lock.json
generated
@@ -17,7 +17,9 @@
|
||||
"@phosphor-icons/react": "^2.0.8",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -77,6 +79,7 @@
|
||||
"devDependencies": {
|
||||
"@chromatic-com/playwright": "^0.10.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/chrome": "^0.0.287",
|
||||
"chromatic": "^11.18.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
@@ -2912,6 +2915,85 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz",
|
||||
"integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz",
|
||||
@@ -3063,6 +3145,196 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz",
|
||||
"integrity": "sha512-E0MLLGfOP0l8P/NxgVzfXJ8w3Ch8cdO6UDzJfDChu4EJDy+/WdO5LqpdY8PYnCErkmZH3gZhDL1K7kQ41fAHuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-roving-focus": "1.1.1",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-previous": "1.1.0",
|
||||
"@radix-ui/react-use-size": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz",
|
||||
"integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-context": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
|
||||
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
|
||||
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
|
||||
"integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
|
||||
@@ -4655,6 +4927,17 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chrome": {
|
||||
"version": "0.0.287",
|
||||
"resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.287.tgz",
|
||||
"integrity": "sha512-wWhBNPNXZHwycHKNYnexUcpSbrihVZu++0rdp6GEk5ZgAglenLx+RwdEouh6FrHS0XQiOxSd62yaujM1OoQlZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filesystem": "*",
|
||||
"@types/har-format": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz",
|
||||
@@ -4738,6 +5021,30 @@
|
||||
"@types/estree": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filesystem": {
|
||||
"version": "0.0.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz",
|
||||
"integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/filewriter": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/filewriter": {
|
||||
"version": "0.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz",
|
||||
"integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/har-format": {
|
||||
"version": "1.2.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz",
|
||||
"integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/hast": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
"@phosphor-icons/react": "^2.0.8",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.2",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -79,6 +81,7 @@
|
||||
"devDependencies": {
|
||||
"@chromatic-com/playwright": "^0.10.0",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/chrome": "^0.0.287",
|
||||
"chromatic": "^11.18.1",
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-config-next": "^14.1.0",
|
||||
|
||||
@@ -317,14 +317,14 @@ export const GmailAuthSection = ({
|
||||
return (
|
||||
<>
|
||||
<p className="mb-2 text-sm">
|
||||
<i>Existing credential already setup!</i>
|
||||
<i>Existing credential already set up!</i>
|
||||
</p>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (connectorExists) {
|
||||
setPopup({
|
||||
message:
|
||||
"Cannot revoke access to Gmail while any connector is still setup. Please delete all connectors, then try again.",
|
||||
"Cannot revoke access to Gmail while any connector is still set up. Please delete all connectors, then try again.",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
|
||||
162
web/src/app/admin/settings/AnonymousUserPath.tsx
Normal file
162
web/src/app/admin/settings/AnonymousUserPath.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { useState } from "react";
|
||||
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NEXT_PUBLIC_CLOUD_DOMAIN } from "@/lib/constants";
|
||||
import { ClipboardIcon } from "@/components/icons/icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
|
||||
export function AnonymousUserPath({
|
||||
setPopup,
|
||||
}: {
|
||||
setPopup: (popup: PopupSpec) => void;
|
||||
}) {
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json());
|
||||
const [customPath, setCustomPath] = useState<string | null>(null);
|
||||
|
||||
const {
|
||||
data: anonymousUserPath,
|
||||
error,
|
||||
mutate,
|
||||
isLoading,
|
||||
} = useSWR("/api/tenants/anonymous-user-path", (url) =>
|
||||
fetch(url)
|
||||
.then((res) => {
|
||||
console.log("Response:", res);
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
console.log("Data:", data);
|
||||
return data.anonymous_user_path;
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to fetch anonymous user path:", error);
|
||||
}
|
||||
|
||||
async function handleCustomPathUpdate() {
|
||||
try {
|
||||
if (!customPath) {
|
||||
setPopup({
|
||||
message: "Custom path cannot be empty",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Validate custom path
|
||||
if (!customPath.trim()) {
|
||||
setPopup({
|
||||
message: "Custom path cannot be empty",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(customPath)) {
|
||||
setPopup({
|
||||
message: "Custom path can only contain letters, numbers, and hyphens",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const response = await fetch(
|
||||
`/api/tenants/anonymous-user-path?anonymous_user_path=${encodeURIComponent(
|
||||
customPath
|
||||
)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json();
|
||||
setPopup({
|
||||
message: detail.detail || "Failed to update anonymous user path",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
mutate(); // Revalidate the SWR cache
|
||||
setPopup({
|
||||
message: "Anonymous user path updated successfully!",
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setPopup({
|
||||
message: `Failed to update anonymous user path: ${error}`,
|
||||
type: "error",
|
||||
});
|
||||
console.error("Error updating anonymous user path:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-4 ml-6 max-w-xl p-6 bg-white shadow-lg border border-gray-200 rounded-lg">
|
||||
<h4 className="font-semibold text-lg text-gray-800 mb-3">
|
||||
Anonymous User Access
|
||||
</h4>
|
||||
<p className="text-gray-600 text-sm mb-4">
|
||||
Enable this to allow non-authenticated users to access all documents
|
||||
indexed by public connectors in your workspace.
|
||||
{anonymousUserPath
|
||||
? "Customize the access path for anonymous users."
|
||||
: "Set a custom access path for anonymous users."}{" "}
|
||||
Anonymous users will only be able to view and search public documents,
|
||||
but cannot access private or restricted content. The path will always
|
||||
start with "/anonymous/".
|
||||
</p>
|
||||
{isLoading ? (
|
||||
<ThreeDotsLoader />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 justify-center items-start">
|
||||
<div className="w-full flex-grow flex items-center rounded-md shadow-sm">
|
||||
<span className="inline-flex items-center rounded-l-md border border-r-0 border-gray-300 bg-gray-50 px-3 text-gray-500 sm:text-sm h-10">
|
||||
{NEXT_PUBLIC_CLOUD_DOMAIN}/anonymous/
|
||||
</span>
|
||||
<Input
|
||||
type="text"
|
||||
className="block w-full flex-grow flex-1 rounded-none rounded-r-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm h-10"
|
||||
placeholder="your-custom-path"
|
||||
value={customPath ?? anonymousUserPath ?? ""}
|
||||
onChange={(e) => setCustomPath(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button
|
||||
onClick={handleCustomPathUpdate}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
>
|
||||
Update Path
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-10 px-4"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${NEXT_PUBLIC_CLOUD_DOMAIN}/anonymous/${anonymousUserPath}`
|
||||
);
|
||||
setPopup({
|
||||
message: "Invite link copied!",
|
||||
type: "success",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ClipboardIcon className="h-4 w-4" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import { AnonymousUserPath } from "./AnonymousUserPath";
|
||||
|
||||
export function Checkbox({
|
||||
label,
|
||||
@@ -210,7 +211,6 @@ export function SettingsForm() {
|
||||
<div>
|
||||
{popup}
|
||||
<Title className="mb-4">Workspace Settings</Title>
|
||||
|
||||
<Checkbox
|
||||
label="Auto-scroll"
|
||||
sublabel="If set, the chat window will automatically scroll to the bottom as new lines of text are generated by the AI model."
|
||||
@@ -219,18 +219,17 @@ export function SettingsForm() {
|
||||
handleToggleSettingsField("auto_scroll", e.target.checked)
|
||||
}
|
||||
/>
|
||||
{!NEXT_PUBLIC_CLOUD_ENABLED && (
|
||||
<Checkbox
|
||||
label="Anonymous Users"
|
||||
sublabel="If set, users will not be required to sign in to use Onyx."
|
||||
checked={settings.anonymous_user_enabled}
|
||||
onChange={(e) =>
|
||||
handleToggleSettingsField(
|
||||
"anonymous_user_enabled",
|
||||
e.target.checked
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Anonymous Users"
|
||||
sublabel="If set, users will not be required to sign in to use Onyx."
|
||||
checked={settings.anonymous_user_enabled}
|
||||
onChange={(e) =>
|
||||
handleToggleSettingsField("anonymous_user_enabled", e.target.checked)
|
||||
}
|
||||
/>
|
||||
|
||||
{NEXT_PUBLIC_CLOUD_ENABLED && settings.anonymous_user_enabled && (
|
||||
<AnonymousUserPath setPopup={setPopup} />
|
||||
)}
|
||||
{showConfirmModal && (
|
||||
<Modal
|
||||
@@ -241,7 +240,7 @@ export function SettingsForm() {
|
||||
<h2 className="text-xl font-bold">Enable Anonymous Users</h2>
|
||||
<p>
|
||||
Are you sure you want to enable anonymous users? This will allow
|
||||
anyone to use Danswer without signing in.
|
||||
anyone to use Onyx without signing in.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
@@ -255,7 +254,6 @@ export function SettingsForm() {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{isEnterpriseEnabled && (
|
||||
<>
|
||||
<Title className="mt-8 mb-4">Chat Settings</Title>
|
||||
|
||||
56
web/src/app/anonymous/[id]/AnonymousPage.tsx
Normal file
56
web/src/app/anonymous/[id]/AnonymousPage.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function AnonymousPage({
|
||||
anonymousPath,
|
||||
}: {
|
||||
anonymousPath: string;
|
||||
}) {
|
||||
const loginAsAnonymousUser = async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tenants/anonymous-user?anonymous_user_path=${encodeURIComponent(
|
||||
anonymousPath
|
||||
)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to login as anonymous user", response);
|
||||
throw new Error("Failed to login as anonymous user");
|
||||
}
|
||||
// Redirect to the chat page and force a refresh
|
||||
window.location.href = "/chat";
|
||||
} catch (error) {
|
||||
console.error("Error logging in as anonymous user:", error);
|
||||
redirect("/auth/signup?error=Anonymous");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loginAsAnonymousUser();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
|
||||
<div className="bg-white p-8 rounded-lg shadow-md">
|
||||
<h1 className="text-2xl font-bold mb-4 text-center">
|
||||
Redirecting you to the chat page...
|
||||
</h1>
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-neutral-800"></div>
|
||||
</div>
|
||||
<p className="mt-4 text-gray-600 text-center">
|
||||
Please wait while we set up your anonymous session.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
web/src/app/anonymous/[id]/page.tsx
Normal file
7
web/src/app/anonymous/[id]/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import AnonymousPage from "./AnonymousPage";
|
||||
|
||||
export default async function Page(props: { params: Promise<{ id: string }> }) {
|
||||
const params = await props.params;
|
||||
|
||||
return <AnonymousPage anonymousPath={params.id} />;
|
||||
}
|
||||
102
web/src/app/auth/login/LoginPage.tsx
Normal file
102
web/src/app/auth/login/LoginPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { AuthTypeMetadata } from "@/lib/userSS";
|
||||
import { LoginText } from "./LoginText";
|
||||
import Link from "next/link";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
import { EmailPasswordForm } from "./EmailPasswordForm";
|
||||
import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants";
|
||||
import Title from "@/components/ui/title";
|
||||
|
||||
export default function LoginPanel({
|
||||
authUrl,
|
||||
authTypeMetadata,
|
||||
nextUrl,
|
||||
searchParams,
|
||||
showPageRedirect,
|
||||
}: {
|
||||
authUrl: string | null;
|
||||
authTypeMetadata: AuthTypeMetadata | null;
|
||||
nextUrl: string | null;
|
||||
searchParams:
|
||||
| {
|
||||
[key: string]: string | string[] | undefined;
|
||||
}
|
||||
| undefined;
|
||||
showPageRedirect?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col w-full justify-center">
|
||||
{authUrl && authTypeMetadata && (
|
||||
<>
|
||||
<h2 className="text-center text-xl text-strong font-bold">
|
||||
<LoginText />
|
||||
</h2>
|
||||
|
||||
<SignInButton
|
||||
authorizeUrl={authUrl}
|
||||
authType={authTypeMetadata?.authType}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authTypeMetadata?.authType === "cloud" && (
|
||||
<div className="mt-4 w-full justify-center">
|
||||
<div className="flex items-center w-full my-4">
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
<span className="px-4 text-gray-500">or</span>
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
</div>
|
||||
<EmailPasswordForm shouldVerify={true} nextUrl={nextUrl} />
|
||||
|
||||
<div className="flex mt-4 justify-between">
|
||||
<Link
|
||||
href={`/auth/signup${
|
||||
searchParams?.next ? `?next=${searchParams.next}` : ""
|
||||
}`}
|
||||
className="text-link font-medium"
|
||||
>
|
||||
Create an account
|
||||
</Link>
|
||||
|
||||
{NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && (
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-link font-medium"
|
||||
>
|
||||
Reset Password
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authTypeMetadata?.authType === "basic" && (
|
||||
<>
|
||||
<div className="flex">
|
||||
<Title className="mb-2 mx-auto text-xl text-strong font-bold">
|
||||
<LoginText />
|
||||
</Title>
|
||||
</div>
|
||||
<EmailPasswordForm nextUrl={nextUrl} />
|
||||
<div className="flex flex-col gap-y-2 items-center"></div>
|
||||
</>
|
||||
)}
|
||||
{showPageRedirect && (
|
||||
<p className="text-center mt-4">
|
||||
Don't have an account?{" "}
|
||||
<span
|
||||
onClick={() => {
|
||||
if (typeof window !== "undefined" && window.top) {
|
||||
window.top.location.href = "/auth/register";
|
||||
} else {
|
||||
window.location.href = "/auth/register";
|
||||
}
|
||||
}}
|
||||
className="text-link font-medium cursor-pointer"
|
||||
>
|
||||
Create an account
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export function SignInButton({
|
||||
|
||||
return (
|
||||
<a
|
||||
className="mx-auto mt-6 py-3 w-full text-text-100 bg-accent flex rounded cursor-pointer hover:bg-indigo-800"
|
||||
className="mx-auto mb-4 mt-6 py-3 w-full text-text-100 bg-accent flex rounded cursor-pointer hover:bg-indigo-800"
|
||||
href={finalAuthorizeUrl}
|
||||
>
|
||||
{button}
|
||||
|
||||
@@ -7,20 +7,11 @@ import {
|
||||
AuthTypeMetadata,
|
||||
} from "@/lib/userSS";
|
||||
import { redirect } from "next/navigation";
|
||||
import { SignInButton } from "./SignInButton";
|
||||
import { EmailPasswordForm } from "./EmailPasswordForm";
|
||||
import Title from "@/components/ui/title";
|
||||
import Text from "@/components/ui/text";
|
||||
import Link from "next/link";
|
||||
import { LoginText } from "./LoginText";
|
||||
import { getSecondsUntilExpiration } from "@/lib/time";
|
||||
import AuthFlowContainer from "@/components/auth/AuthFlowContainer";
|
||||
import CardSection from "@/components/admin/CardSection";
|
||||
import { NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED } from "@/lib/constants";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import { useContext } from "react";
|
||||
import LoginPanel from "./LoginPage";
|
||||
|
||||
const Page = async (props: {
|
||||
const LoginPage = async (props: {
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}) => {
|
||||
const searchParams = await props.searchParams;
|
||||
@@ -83,58 +74,15 @@ const Page = async (props: {
|
||||
<HealthCheckBanner />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-full justify-center">
|
||||
{authUrl && authTypeMetadata && (
|
||||
<>
|
||||
<h2 className="text-center text-xl text-strong font-bold">
|
||||
<LoginText />
|
||||
</h2>
|
||||
|
||||
<SignInButton
|
||||
authorizeUrl={authUrl}
|
||||
authType={authTypeMetadata?.authType}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{authTypeMetadata?.authType === "cloud" && (
|
||||
<div className="mt-4 w-full justify-center">
|
||||
<div className="flex items-center w-full my-4">
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
<span className="px-4 text-gray-500">or</span>
|
||||
<div className="flex-grow border-t border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
<EmailPasswordForm shouldVerify={true} nextUrl={nextUrl} />
|
||||
|
||||
<div className="flex mt-4 justify-between">
|
||||
{NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED && (
|
||||
<Link
|
||||
href="/auth/forgot-password"
|
||||
className="text-link font-medium"
|
||||
>
|
||||
Reset Password
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authTypeMetadata?.authType === "basic" && (
|
||||
<>
|
||||
<div className="flex">
|
||||
<Title className="mb-2 mx-auto text-xl text-strong font-bold">
|
||||
<LoginText />
|
||||
</Title>
|
||||
</div>
|
||||
<EmailPasswordForm nextUrl={nextUrl} />
|
||||
<div className="flex flex-col gap-y-2 items-center"></div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<LoginPanel
|
||||
authUrl={authUrl}
|
||||
authTypeMetadata={authTypeMetadata}
|
||||
nextUrl={nextUrl!}
|
||||
searchParams={searchParams}
|
||||
/>
|
||||
</AuthFlowContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
export default LoginPage;
|
||||
|
||||
@@ -13,6 +13,7 @@ import Link from "next/link";
|
||||
import { SignInButton } from "../login/SignInButton";
|
||||
import AuthFlowContainer from "@/components/auth/AuthFlowContainer";
|
||||
import ReferralSourceSelector from "./ReferralSourceSelector";
|
||||
import AuthErrorDisplay from "@/components/auth/AuthErrorDisplay";
|
||||
|
||||
const Page = async (props: {
|
||||
searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
@@ -67,6 +68,7 @@ const Page = async (props: {
|
||||
return (
|
||||
<AuthFlowContainer authState="signup">
|
||||
<HealthCheckBanner />
|
||||
<AuthErrorDisplay searchParams={searchParams} />
|
||||
|
||||
<>
|
||||
<div className="absolute top-10x w-full"></div>
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ChatIntro({ selectedPersona }: { selectedPersona: Persona }) {
|
||||
<div
|
||||
onMouseEnter={() => setHoveredAssistant(true)}
|
||||
onMouseLeave={() => setHoveredAssistant(false)}
|
||||
className="p-4 scale-[.7] cursor-pointer border-dashed rounded-full flex border border-gray-300 border-2 border-dashed"
|
||||
className="mobile:hidden p-4 scale-[.7] cursor-pointer border-dashed rounded-full flex border border-gray-300 border-2 border-dashed"
|
||||
>
|
||||
<AssistantIcon
|
||||
disableToolip
|
||||
@@ -22,7 +22,7 @@ export function ChatIntro({ selectedPersona }: { selectedPersona: Persona }) {
|
||||
assistant={selectedPersona}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute right-full mr-1 w-[300px] top-0">
|
||||
<div className="absolute right-full mr-1 mobile:mr-0 w-[300px] top-0">
|
||||
{hoveredAssistant && (
|
||||
<DisplayAssistantCard selectedPersona={selectedPersona} />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { redirect, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
BackendChatSession,
|
||||
BackendMessage,
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
StreamStopInfo,
|
||||
StreamStopReason,
|
||||
} from "@/lib/search/interfaces";
|
||||
import { Filters } from "@/lib/search/interfaces";
|
||||
import { buildFilters } from "@/lib/search/utils";
|
||||
import { SettingsContext } from "@/components/settings/SettingsProvider";
|
||||
import Dropzone from "react-dropzone";
|
||||
@@ -110,7 +111,7 @@ import AssistantBanner from "../../components/assistants/AssistantBanner";
|
||||
import TextView from "@/components/chat_search/TextView";
|
||||
import AssistantSelector from "@/components/chat_search/AssistantSelector";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { createPostponedAbortSignal } from "next/dist/server/app-render/dynamic-rendering";
|
||||
import { useSendMessageToParent } from "@/lib/extension/utils";
|
||||
|
||||
const TEMP_USER_MESSAGE_ID = -1;
|
||||
const TEMP_ASSISTANT_MESSAGE_ID = -2;
|
||||
@@ -120,10 +121,12 @@ export function ChatPage({
|
||||
toggle,
|
||||
documentSidebarInitialWidth,
|
||||
toggledSidebar,
|
||||
firstMessage,
|
||||
}: {
|
||||
toggle: (toggled?: boolean) => void;
|
||||
documentSidebarInitialWidth?: number;
|
||||
toggledSidebar: boolean;
|
||||
firstMessage?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -140,6 +143,7 @@ export function ChatPage({
|
||||
shouldShowWelcomeModal,
|
||||
refreshChatSessions,
|
||||
} = useChatContext();
|
||||
|
||||
function useScreenSize() {
|
||||
const [screenSize, setScreenSize] = useState({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 0,
|
||||
@@ -199,23 +203,59 @@ export function ChatPage({
|
||||
const modelVersionFromSearchParams = searchParams.get(
|
||||
SEARCH_PARAM_NAMES.STRUCTURED_MODEL
|
||||
);
|
||||
const [showHistorySidebar, setShowHistorySidebar] = useState(false); // State to track if sidebar is open
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.is_anonymous_user) {
|
||||
Cookies.set(
|
||||
SIDEBAR_TOGGLED_COOKIE_NAME,
|
||||
String(!toggledSidebar).toLocaleLowerCase()
|
||||
);
|
||||
toggle(false);
|
||||
}
|
||||
}, [user]);
|
||||
const submittingLogic = (searchParamsString: string) => {
|
||||
const newSearchParams = new URLSearchParams(searchParamsString);
|
||||
console.log("newSearchParams", newSearchParams);
|
||||
console.log("searchParamsString", searchParamsString);
|
||||
const message = newSearchParams.get("user-prompt");
|
||||
newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD);
|
||||
|
||||
filterManager.buildFiltersFromQueryString(
|
||||
newSearchParams.toString(),
|
||||
availableSources,
|
||||
documentSets.map((ds) => ds.name),
|
||||
tags
|
||||
);
|
||||
const fileDescriptorString = newSearchParams.get("files");
|
||||
let overrideFileDescriptors: FileDescriptor[] = [];
|
||||
if (fileDescriptorString) {
|
||||
try {
|
||||
overrideFileDescriptors = JSON.parse(
|
||||
decodeURIComponent(fileDescriptorString)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error parsing file descriptors:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the URL without the send-on-load parameter
|
||||
router.replace(`?${newSearchParams.toString()}`, { scroll: false });
|
||||
|
||||
// Update our local state to reflect the change
|
||||
setSendOnLoad(null);
|
||||
|
||||
// If there's a message, submit it
|
||||
if (message) {
|
||||
setSubmittedMessage(message);
|
||||
onSubmit({ messageOverride: message, overrideFileDescriptors });
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to handle sendOnLoad
|
||||
useEffect(() => {
|
||||
if (sendOnLoad) {
|
||||
const newSearchParams = new URLSearchParams(searchParams.toString());
|
||||
newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD);
|
||||
|
||||
// Update the URL without the send-on-load parameter
|
||||
router.replace(`?${newSearchParams.toString()}`, { scroll: false });
|
||||
|
||||
// Update our local state to reflect the change
|
||||
setSendOnLoad(null);
|
||||
|
||||
// If there's a message, submit it
|
||||
if (message) {
|
||||
onSubmit({ messageOverride: message });
|
||||
}
|
||||
submittingLogic(sendOnLoad);
|
||||
}
|
||||
}, [sendOnLoad, searchParams, router]);
|
||||
|
||||
@@ -302,14 +342,6 @@ export function ChatPage({
|
||||
const noAssistants = liveAssistant == null || liveAssistant == undefined;
|
||||
|
||||
const availableSources = ccPairs.map((ccPair) => ccPair.source);
|
||||
const [finalAvailableSources, finalAvailableDocumentSets] =
|
||||
computeAvailableFilters({
|
||||
selectedPersona: availableAssistants.find(
|
||||
(assistant) => assistant.id === liveAssistant?.id
|
||||
),
|
||||
availableSources: availableSources,
|
||||
availableDocumentSets: documentSets,
|
||||
});
|
||||
|
||||
// always set the model override for the chat session, when an assistant, llm provider, or user preference exists
|
||||
useEffect(() => {
|
||||
@@ -391,6 +423,9 @@ export function ChatPage({
|
||||
|
||||
// this is triggered every time the user switches which chat
|
||||
// session they are using
|
||||
const [chromSentUrls, setchromSentUrls] = useState<string[]>([]);
|
||||
const [selectedChromeUrls, setSelectedChromeUrls] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const priorChatSessionId = chatSessionIdRef.current;
|
||||
const loadedSessionId = loadedIdSessionRef.current;
|
||||
@@ -446,7 +481,7 @@ export function ChatPage({
|
||||
}
|
||||
return;
|
||||
}
|
||||
setIsReady(true);
|
||||
// setIsReady(true);
|
||||
const shouldScrollToBottom =
|
||||
visibleRange.get(existingChatSessionId) === undefined ||
|
||||
visibleRange.get(existingChatSessionId)?.end == 0;
|
||||
@@ -641,10 +676,10 @@ export function ChatPage({
|
||||
currentMessageMap(completeMessageDetail)
|
||||
);
|
||||
|
||||
const [submittedMessage, setSubmittedMessage] = useState("");
|
||||
const [submittedMessage, setSubmittedMessage] = useState(firstMessage || "");
|
||||
|
||||
const [chatState, setChatState] = useState<Map<string | null, ChatState>>(
|
||||
new Map([[chatSessionIdRef.current, "input"]])
|
||||
new Map([[chatSessionIdRef.current, firstMessage ? "loading" : "input"]])
|
||||
);
|
||||
|
||||
const [regenerationState, setRegenerationState] = useState<
|
||||
@@ -788,6 +823,17 @@ export function ChatPage({
|
||||
}
|
||||
}, [defaultAssistantId, availableAssistants, messageHistory.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
submittedMessage &&
|
||||
currentSessionChatState === "loading" &&
|
||||
messageHistory.length == 0
|
||||
) {
|
||||
console.log("TRYING TO LOAD NEW CHAT PAGE", submittedMessage);
|
||||
window.parent.postMessage({ type: "LOAD_NEW_CHAT_PAGE" }, "*");
|
||||
}
|
||||
}, [submittedMessage, currentSessionChatState]);
|
||||
|
||||
const [
|
||||
selectedDocuments,
|
||||
toggleDocumentSelection,
|
||||
@@ -991,6 +1037,34 @@ export function ChatPage({
|
||||
adjustDocumentSidebarWidth(); // Adjust the width on initial render
|
||||
window.addEventListener("resize", adjustDocumentSidebarWidth); // Add resize event listener
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.data.type === "LOAD_NEW_PAGE") {
|
||||
console.log("Received LOAD_NEW_PAGE event:", event.data);
|
||||
const { href } = event.data;
|
||||
const url = new URL(href);
|
||||
// const userPrompt = url.searchParams.get("user-prompt");
|
||||
|
||||
// {
|
||||
// "type": "LOAD_NEW_PAGE",
|
||||
// "href": "http://localhost:3000/chat?send-on-load=true&user-prompt=hi"
|
||||
// }
|
||||
|
||||
console.log(event.data);
|
||||
console.log("url.searchParams", url.searchParams);
|
||||
// if (userPrompt) {
|
||||
submittingLogic(url.searchParams.toString());
|
||||
// setSubmittedMessage(userPrompt);
|
||||
// updateChatState("loading");
|
||||
// }
|
||||
|
||||
// Handle the new page load
|
||||
// This might involve updating the application's route or state
|
||||
// console.log("Loading new page:", href);
|
||||
// Implement your page loading logic here, e.g., updating the URL
|
||||
// router.push(href, undefined, { shallow: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", adjustDocumentSidebarWidth); // Cleanup the event listener
|
||||
};
|
||||
@@ -1068,6 +1142,7 @@ export function ChatPage({
|
||||
alternativeAssistantOverride = null,
|
||||
modelOverRide,
|
||||
regenerationRequest,
|
||||
overrideFileDescriptors,
|
||||
}: {
|
||||
messageIdToResend?: number;
|
||||
messageOverride?: string;
|
||||
@@ -1077,6 +1152,7 @@ export function ChatPage({
|
||||
alternativeAssistantOverride?: Persona | null;
|
||||
modelOverRide?: LlmOverride;
|
||||
regenerationRequest?: RegenerationRequest | null;
|
||||
overrideFileDescriptors?: FileDescriptor[];
|
||||
} = {}) => {
|
||||
let frozenSessionId = currentSessionId();
|
||||
updateCanContinue(false, frozenSessionId);
|
||||
@@ -1103,6 +1179,7 @@ export function ChatPage({
|
||||
|
||||
let currChatSessionId: string;
|
||||
const isNewSession = chatSessionIdRef.current === null;
|
||||
|
||||
const searchParamBasedChatSessionName =
|
||||
searchParams.get(SEARCH_PARAM_NAMES.TITLE) || null;
|
||||
|
||||
@@ -1218,7 +1295,7 @@ export function ChatPage({
|
||||
signal: controller.signal, // Add this line
|
||||
message: currMessage,
|
||||
alternateAssistantId: currentAssistantId,
|
||||
fileDescriptors: currentMessageFiles,
|
||||
fileDescriptors: overrideFileDescriptors || currentMessageFiles,
|
||||
parentMessageId:
|
||||
regenerationRequest?.parentMessage.messageId ||
|
||||
lastSuccessfulMessageId,
|
||||
@@ -1621,7 +1698,6 @@ export function ChatPage({
|
||||
});
|
||||
updateChatState("input", currentSessionId());
|
||||
};
|
||||
const [showHistorySidebar, setShowHistorySidebar] = useState(false); // State to track if sidebar is open
|
||||
|
||||
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
|
||||
const [untoggled, setUntoggled] = useState(false);
|
||||
@@ -1636,6 +1712,9 @@ export function ChatPage({
|
||||
}, 200);
|
||||
};
|
||||
const toggleSidebar = () => {
|
||||
if (user?.is_anonymous_user) {
|
||||
return;
|
||||
}
|
||||
Cookies.set(
|
||||
SIDEBAR_TOGGLED_COOKIE_NAME,
|
||||
String(!toggledSidebar).toLocaleLowerCase()
|
||||
@@ -1803,6 +1882,7 @@ export function ChatPage({
|
||||
end: 0,
|
||||
mostVisibleMessageId: null,
|
||||
};
|
||||
useSendMessageToParent();
|
||||
|
||||
useEffect(() => {
|
||||
if (noAssistants) {
|
||||
@@ -1877,6 +1957,7 @@ export function ChatPage({
|
||||
|
||||
handleSlackChatRedirect();
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.metaKey || event.ctrlKey) {
|
||||
@@ -1945,6 +2026,10 @@ export function ChatPage({
|
||||
});
|
||||
};
|
||||
}
|
||||
if (!user) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
if (noAssistants)
|
||||
return (
|
||||
<>
|
||||
@@ -2027,7 +2112,11 @@ export function ChatPage({
|
||||
|
||||
{retrievalEnabled && documentSidebarToggled && settings?.isMobile && (
|
||||
<div className="md:hidden">
|
||||
<Modal noPadding noScroll>
|
||||
<Modal
|
||||
onOutsideClick={() => setDocumentSidebarToggled(false)}
|
||||
noPadding
|
||||
noScroll
|
||||
>
|
||||
<ChatFilters
|
||||
setPresentingDocument={setPresentingDocument}
|
||||
modal={true}
|
||||
@@ -2234,10 +2323,14 @@ export function ChatPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{documentSidebarInitialWidth !== undefined && isReady ? (
|
||||
<Dropzone onDrop={handleImageUpload} noClick>
|
||||
{true ? (
|
||||
<Dropzone
|
||||
key={currentSessionId()}
|
||||
onDrop={handleImageUpload}
|
||||
noClick
|
||||
>
|
||||
{({ getRootProps }) => (
|
||||
<div className="flex h-full w-full">
|
||||
<div key={10} className="flex h-full w-full">
|
||||
{!settings?.isMobile && (
|
||||
<div
|
||||
style={{ transition: "width 0.30s ease-out" }}
|
||||
@@ -2315,7 +2408,8 @@ export function ChatPage({
|
||||
{messageHistory.length === 0 &&
|
||||
!isFetchingChatMessages &&
|
||||
currentSessionChatState == "input" &&
|
||||
!loadingError && (
|
||||
!loadingError &&
|
||||
!submittedMessage && (
|
||||
<div className="h-full w-[95%] mx-auto mt-12 flex flex-col justify-center items-center">
|
||||
<ChatIntro selectedPersona={liveAssistant} />
|
||||
|
||||
@@ -2332,7 +2426,7 @@ export function ChatPage({
|
||||
currentSessionChatState == "input" &&
|
||||
!loadingError &&
|
||||
allAssistants.length > 1 && (
|
||||
<div className="mx-auto px-4 w-full max-w-[750px] flex flex-col items-center">
|
||||
<div className="mobile:hidden mx-auto px-4 w-full max-w-[750px] flex flex-col items-center">
|
||||
<Separator className="mx-2 w-full my-12" />
|
||||
<div className="text-sm text-black font-medium mb-4">
|
||||
Recent Assistants
|
||||
@@ -2350,8 +2444,9 @@ export function ChatPage({
|
||||
)}
|
||||
|
||||
<div
|
||||
key={currentSessionId()}
|
||||
className={
|
||||
"-ml-4 w-full mx-auto " +
|
||||
"desktop:-ml-4 w-full mx-auto " +
|
||||
"absolute mobile:top-0 desktop:top-12 left-0 " +
|
||||
(settings?.enterpriseSettings
|
||||
?.two_lines_for_chat_header
|
||||
@@ -2762,6 +2857,34 @@ export function ChatPage({
|
||||
</div>
|
||||
)}
|
||||
<ChatInputBar
|
||||
llmOverrideManager={llmOverrideManager}
|
||||
selectChromeUrl={(chromeUrl: string) => {
|
||||
setSelectedChromeUrls([
|
||||
...selectedChromeUrls,
|
||||
chromeUrl,
|
||||
]);
|
||||
setchromSentUrls((chromSentUrls: string[]) =>
|
||||
chromSentUrls.filter(
|
||||
(url) => url !== chromeUrl
|
||||
)
|
||||
);
|
||||
}}
|
||||
selectedChromeUrls={selectedChromeUrls}
|
||||
removeSelectedChromeUrl={(chromeUrl: string) => {
|
||||
setSelectedChromeUrls(
|
||||
selectedChromeUrls.filter(
|
||||
(url) => url !== chromeUrl
|
||||
)
|
||||
);
|
||||
}}
|
||||
chromSentUrls={chromSentUrls}
|
||||
removeChromeSentUrls={(chromSentUrl: string) => {
|
||||
setchromSentUrls(
|
||||
chromSentUrls.filter(
|
||||
(url) => url !== chromSentUrl
|
||||
)
|
||||
);
|
||||
}}
|
||||
removeDocs={() => {
|
||||
clearSelectedDocuments();
|
||||
}}
|
||||
|
||||
@@ -4,14 +4,20 @@ import FunctionalWrapper from "./shared_chat_search/FunctionalWrapper";
|
||||
|
||||
export default function WrappedChat({
|
||||
initiallyToggled,
|
||||
firstMessage,
|
||||
}: {
|
||||
initiallyToggled: boolean;
|
||||
firstMessage?: string;
|
||||
}) {
|
||||
return (
|
||||
<FunctionalWrapper
|
||||
initiallyToggled={initiallyToggled}
|
||||
content={(toggledSidebar, toggle) => (
|
||||
<ChatPage toggle={toggle} toggledSidebar={toggledSidebar} />
|
||||
<ChatPage
|
||||
toggle={toggle}
|
||||
toggledSidebar={toggledSidebar}
|
||||
firstMessage={firstMessage}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -79,7 +79,9 @@ export function ChatDocumentDisplay({
|
||||
document.updated_at || Object.keys(document.metadata).length > 0;
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[400px] opacity-100 ${modal ? "w-[90vw]" : "w-full"}`}
|
||||
className={`desktop:max-w-[400px] opacity-100 ${
|
||||
modal ? "w-[90vw]" : "w-full"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex relative flex-col gap-0.5 rounded-xl mx-2 my-1 ${
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FiPlusCircle, FiPlus, FiInfo, FiX, FiSearch } from "react-icons/fi";
|
||||
import { ChatInputOption } from "./ChatInputOption";
|
||||
import { Persona } from "@/app/admin/assistants/interfaces";
|
||||
|
||||
import { FilterManager } from "@/lib/hooks";
|
||||
import { FilterManager, LlmOverrideManager } from "@/lib/hooks";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import { getFinalLLM } from "@/lib/llm/utils";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
@@ -31,10 +31,71 @@ import { ChatState } from "../types";
|
||||
import UnconfiguredProviderText from "@/components/chat_search/UnconfiguredProviderText";
|
||||
import { useAssistants } from "@/components/context/AssistantsContext";
|
||||
import { XIcon } from "lucide-react";
|
||||
import FiltersDisplay from "./FilterDisplay";
|
||||
import { fetchTitleFromUrl } from "@/lib/sources";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
const SelectedUrlChip = ({
|
||||
url,
|
||||
onRemove,
|
||||
}: {
|
||||
url: string;
|
||||
onRemove: (url: string) => void;
|
||||
}) => (
|
||||
<div className="bg-white border border-gray-200 shadow-sm rounded-lg p-2 flex items-center space-x-2">
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${new URL(url).hostname}`}
|
||||
alt="Website favicon"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p className="text-sm font-medium text-gray-700 truncate">
|
||||
{new URL(url).hostname}
|
||||
</p>
|
||||
<XIcon
|
||||
onClick={() => onRemove(url)}
|
||||
size={16}
|
||||
className="text-text-400 hover:text-text-600 ml-auto cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SentUrlChip = ({
|
||||
url,
|
||||
onRemove,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
url: string;
|
||||
onRemove: (url: string) => void;
|
||||
onClick: () => void;
|
||||
title: string;
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className="bg-white/80 opacity-50 group-hover:opacity-100 border border-gray-200/50 shadow-sm rounded-lg p-2 flex items-center space-x-2 hover:bg-white hover:border-gray-200 transition-all duration-200"
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${
|
||||
new URL(url).hostname
|
||||
}`}
|
||||
alt="Website favicon"
|
||||
className="w-4 h-4 "
|
||||
/>
|
||||
<p className="text-sm font-medium text-gray-600 truncate group-hover:text-gray-700">
|
||||
{title}
|
||||
</p>
|
||||
<XIcon
|
||||
onClick={(e) => {
|
||||
onRemove(url);
|
||||
}}
|
||||
size={16}
|
||||
className="text-text-300 hover:text-text-500 ml-auto transition-colors duration-200"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatInputBarProps {
|
||||
removeDocs: () => void;
|
||||
openModelSettings: () => void;
|
||||
@@ -46,6 +107,7 @@ interface ChatInputBarProps {
|
||||
stopGenerating: () => void;
|
||||
onSubmit: () => void;
|
||||
filterManager: FilterManager;
|
||||
llmOverrideManager: LlmOverrideManager;
|
||||
chatState: ChatState;
|
||||
alternativeAssistant: Persona | null;
|
||||
// assistants
|
||||
@@ -57,9 +119,17 @@ interface ChatInputBarProps {
|
||||
handleFileUpload: (files: File[]) => void;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
toggleFilters?: () => void;
|
||||
chromSentUrls?: string[];
|
||||
removeChromeSentUrls: (chromSentUrl: string) => void;
|
||||
selectedChromeUrls?: string[];
|
||||
removeSelectedChromeUrl: (selectedChromeUrl: string) => void;
|
||||
selectChromeUrl: (chromeUrl: string) => void;
|
||||
}
|
||||
|
||||
export function ChatInputBar({
|
||||
chromSentUrls,
|
||||
selectedChromeUrls,
|
||||
removeSelectedChromeUrl,
|
||||
removeDocs,
|
||||
openModelSettings,
|
||||
showDocs,
|
||||
@@ -69,6 +139,7 @@ export function ChatInputBar({
|
||||
setMessage,
|
||||
stopGenerating,
|
||||
onSubmit,
|
||||
removeChromeSentUrls,
|
||||
filterManager,
|
||||
chatState,
|
||||
|
||||
@@ -82,6 +153,7 @@ export function ChatInputBar({
|
||||
textAreaRef,
|
||||
alternativeAssistant,
|
||||
toggleFilters,
|
||||
selectChromeUrl,
|
||||
}: ChatInputBarProps) {
|
||||
useEffect(() => {
|
||||
const textarea = textAreaRef.current;
|
||||
@@ -217,6 +289,26 @@ export function ChatInputBar({
|
||||
}
|
||||
};
|
||||
|
||||
// We'll store dynamic titles in state, keyed by URL
|
||||
const [fetchedTitles, setFetchedTitles] = useState<Record<string, string>>(
|
||||
{}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chromSentUrls) return;
|
||||
|
||||
chromSentUrls.forEach((url) => {
|
||||
// Already have it? Skip
|
||||
if (fetchedTitles[url]) return;
|
||||
|
||||
fetchTitleFromUrl(url).then((title: string | null) => {
|
||||
if (title) {
|
||||
setFetchedTitles((prev) => ({ ...prev, [url]: title }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [chromSentUrls, fetchedTitles]);
|
||||
|
||||
return (
|
||||
<div id="onyx-chat-input">
|
||||
<div className="flex justify-center mx-auto">
|
||||
@@ -228,6 +320,35 @@ export function ChatInputBar({
|
||||
mx-auto
|
||||
"
|
||||
>
|
||||
{(chromSentUrls || selectedChromeUrls) && (
|
||||
<div className="absolute inset-x-0 top-0 w-fit flex gap-x-1 gap-y-1 flex-wrap transform -translate-y-full">
|
||||
{selectedChromeUrls &&
|
||||
selectedChromeUrls.map((url, index) => (
|
||||
<SelectedUrlChip
|
||||
key={index}
|
||||
url={url}
|
||||
onRemove={removeSelectedChromeUrl}
|
||||
/>
|
||||
))}
|
||||
{chromSentUrls &&
|
||||
chromSentUrls.map((url, index) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const displayTitle = fetchedTitles[url] || parsedUrl.hostname;
|
||||
return (
|
||||
<SentUrlChip
|
||||
key={index}
|
||||
title={displayTitle}
|
||||
onClick={() => {
|
||||
selectChromeUrl(url);
|
||||
}}
|
||||
url={url}
|
||||
onRemove={removeChromeSentUrls}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSuggestions && assistantTagOptions.length > 0 && (
|
||||
<div
|
||||
ref={suggestionsRef}
|
||||
@@ -408,7 +529,7 @@ export function ChatInputBar({
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder="Ask me anything.."
|
||||
placeholder="Ask me anything..."
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
@@ -453,16 +574,6 @@ export function ChatInputBar({
|
||||
onClick={toggleFilters}
|
||||
/>
|
||||
)}
|
||||
{(filterManager.selectedSources.length > 0 ||
|
||||
filterManager.selectedDocumentSets.length > 0 ||
|
||||
filterManager.selectedTags.length > 0 ||
|
||||
filterManager.timeRange) &&
|
||||
toggleFilters && (
|
||||
<FiltersDisplay
|
||||
filterManager={filterManager}
|
||||
toggleFilters={toggleFilters}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2.5 mobile:right-4 desktop:right-10">
|
||||
|
||||
249
web/src/app/chat/input/SimplifiedChatInputBar.tsx
Normal file
249
web/src/app/chat/input/SimplifiedChatInputBar.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { FiPlusCircle } from "react-icons/fi";
|
||||
import { ChatInputOption } from "./ChatInputOption";
|
||||
import { FilterManager } from "@/lib/hooks";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
import {
|
||||
InputBarPreview,
|
||||
InputBarPreviewImageProvider,
|
||||
} from "../files/InputBarPreview";
|
||||
import { SendIcon } from "@/components/icons/icons";
|
||||
import { HorizontalSourceSelector } from "@/components/search/filtering/HorizontalSourceSelector";
|
||||
import { Tag } from "@/lib/types";
|
||||
|
||||
const MAX_INPUT_HEIGHT = 200;
|
||||
|
||||
interface ChatInputBarProps {
|
||||
message: string;
|
||||
setMessage: (message: string) => void;
|
||||
onSubmit: () => void;
|
||||
|
||||
files: FileDescriptor[];
|
||||
setFiles: (files: FileDescriptor[]) => void;
|
||||
handleFileUpload: (files: File[]) => void;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
|
||||
// NEW (optional) - if we want to accept the FilterManager in this component:
|
||||
filterManager?: FilterManager;
|
||||
existingSources: string[];
|
||||
availableDocumentSets: { name: string }[];
|
||||
availableTags: Tag[];
|
||||
}
|
||||
|
||||
export function SimplifiedChatInputBar({
|
||||
message,
|
||||
setMessage,
|
||||
onSubmit,
|
||||
|
||||
files,
|
||||
setFiles,
|
||||
handleFileUpload,
|
||||
textAreaRef,
|
||||
// NEW (optional) - if we want to accept the FilterManager in this component:
|
||||
filterManager,
|
||||
existingSources,
|
||||
availableDocumentSets,
|
||||
availableTags,
|
||||
}: ChatInputBarProps) {
|
||||
useEffect(() => {
|
||||
const textarea = textAreaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${Math.min(
|
||||
textarea.scrollHeight,
|
||||
MAX_INPUT_HEIGHT
|
||||
)}px`;
|
||||
}
|
||||
}, [message, textAreaRef]);
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (items) {
|
||||
const pastedFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].kind === "file") {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) pastedFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (pastedFiles.length > 0) {
|
||||
event.preventDefault();
|
||||
handleFileUpload(pastedFiles);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const text = event.target.value;
|
||||
setMessage(text);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
id="onyx-chat-input"
|
||||
className="
|
||||
w-full
|
||||
relative
|
||||
mx-auto
|
||||
"
|
||||
>
|
||||
<div
|
||||
className="
|
||||
opacity-100
|
||||
w-full
|
||||
h-fit
|
||||
flex
|
||||
flex-col
|
||||
border
|
||||
border-[#E5E7EB]
|
||||
rounded-lg
|
||||
relative
|
||||
text-text-chatbar
|
||||
bg-background-chatbar
|
||||
[&:has(textarea:focus)]::ring-1
|
||||
[&:has(textarea:focus)]::ring-black
|
||||
"
|
||||
>
|
||||
{files.length > 0 && (
|
||||
<div className="flex gap-x-2 px-2 pt-2">
|
||||
<div className="flex gap-x-1 px-2 overflow-visible overflow-x-scroll items-end miniscroll">
|
||||
{files.map((file) => (
|
||||
<div className="flex-none" key={file.id}>
|
||||
{file.type === ChatFileType.IMAGE ? (
|
||||
<InputBarPreviewImageProvider
|
||||
file={file}
|
||||
onDelete={() => {
|
||||
setFiles(
|
||||
files.filter(
|
||||
(fileInFilter) => fileInFilter.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
isUploading={file.isUploading || false}
|
||||
/>
|
||||
) : (
|
||||
<InputBarPreview
|
||||
file={file}
|
||||
onDelete={() => {
|
||||
setFiles(
|
||||
files.filter(
|
||||
(fileInFilter) => fileInFilter.id !== file.id
|
||||
)
|
||||
);
|
||||
}}
|
||||
isUploading={file.isUploading || false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
onPaste={handlePaste}
|
||||
onChange={handleInputChange}
|
||||
ref={textAreaRef}
|
||||
className={`
|
||||
m-0
|
||||
w-full
|
||||
shrink
|
||||
resize-none
|
||||
rounded-lg
|
||||
border-0
|
||||
bg-background-chatbar
|
||||
placeholder:text-text-chatbar-subtle
|
||||
${
|
||||
textAreaRef.current &&
|
||||
textAreaRef.current.scrollHeight > MAX_INPUT_HEIGHT
|
||||
? "overflow-y-auto mt-2"
|
||||
: ""
|
||||
}
|
||||
whitespace-normal
|
||||
break-word
|
||||
overscroll-contain
|
||||
outline-none
|
||||
placeholder-subtle
|
||||
resize-none
|
||||
px-5
|
||||
py-4
|
||||
h-14
|
||||
`}
|
||||
autoFocus
|
||||
style={{ scrollbarWidth: "thin" }}
|
||||
role="textarea"
|
||||
aria-multiline
|
||||
placeholder="Ask me anything..."
|
||||
value={message}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "Enter" &&
|
||||
!event.shiftKey &&
|
||||
!(event.nativeEvent as any).isComposing
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (message) {
|
||||
onSubmit();
|
||||
}
|
||||
}
|
||||
}}
|
||||
suppressContentEditableWarning={true}
|
||||
/>
|
||||
<div className="flex items-center space-x-3 mr-12 px-4 pb-2">
|
||||
<ChatInputOption
|
||||
flexPriority="stiff"
|
||||
name="File"
|
||||
Icon={FiPlusCircle}
|
||||
onClick={() => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true; // Allow multiple files
|
||||
input.onchange = (event: any) => {
|
||||
const selectedFiles = Array.from(
|
||||
event?.target?.files || []
|
||||
) as File[];
|
||||
if (selectedFiles.length > 0) {
|
||||
handleFileUpload(selectedFiles);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
/>
|
||||
|
||||
{filterManager && (
|
||||
<HorizontalSourceSelector
|
||||
timeRange={filterManager.timeRange}
|
||||
setTimeRange={filterManager.setTimeRange}
|
||||
selectedSources={filterManager.selectedSources}
|
||||
setSelectedSources={filterManager.setSelectedSources}
|
||||
selectedDocumentSets={filterManager.selectedDocumentSets}
|
||||
setSelectedDocumentSets={filterManager.setSelectedDocumentSets}
|
||||
selectedTags={filterManager.selectedTags}
|
||||
setSelectedTags={filterManager.setSelectedTags}
|
||||
existingSources={existingSources}
|
||||
availableDocumentSets={availableDocumentSets}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-2 mobile:right-4 desktop:right-4">
|
||||
<button
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
if (message) {
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SendIcon
|
||||
size={28}
|
||||
className={`text-emphasis text-white p-1 rounded-full ${
|
||||
message ? "bg-submit-background" : "bg-disabled-submit-background"
|
||||
} `}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
web/src/app/chat/layout.tsx
Normal file
71
web/src/app/chat/layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { cookies } from "next/headers";
|
||||
import { fetchChatData } from "@/lib/chat/fetchChatData";
|
||||
import { ChatProvider } from "@/components/context/ChatContext";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
}: // searchParams,
|
||||
{
|
||||
children: React.ReactNode;
|
||||
// searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
noStore();
|
||||
const requestCookies = cookies();
|
||||
|
||||
// Ensure searchParams is an object, even if it's empty
|
||||
const safeSearchParams = {};
|
||||
|
||||
const data = await fetchChatData(
|
||||
safeSearchParams as { [key: string]: string }
|
||||
);
|
||||
// const defaultSidebarOff = safeSearchParams.defaultSidebarOff === "true";
|
||||
|
||||
if ("redirect" in data) {
|
||||
redirect(data.redirect);
|
||||
}
|
||||
|
||||
const {
|
||||
user,
|
||||
chatSessions,
|
||||
availableSources,
|
||||
documentSets,
|
||||
tags,
|
||||
llmProviders,
|
||||
folders,
|
||||
toggleSidebar,
|
||||
openedFolders,
|
||||
defaultAssistantId,
|
||||
shouldShowWelcomeModal,
|
||||
ccPairs,
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstantSSRAutoRefresh />
|
||||
{/* {shouldShowWelcomeModal && (
|
||||
<WelcomeModal user={user} requestCookies={requestCookies} />
|
||||
)} */}
|
||||
<ChatProvider
|
||||
value={{
|
||||
chatSessions,
|
||||
availableSources,
|
||||
ccPairs,
|
||||
documentSets,
|
||||
tags,
|
||||
availableDocumentSets: documentSets,
|
||||
availableTags: tags,
|
||||
llmProviders,
|
||||
folders,
|
||||
openedFolders,
|
||||
shouldShowWelcomeModal,
|
||||
defaultAssistantId,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Citation } from "@/components/search/results/Citation";
|
||||
import { WebResultIcon } from "@/components/WebResultIcon";
|
||||
import { LoadedOnyxDocument, OnyxDocument } from "@/lib/search/interfaces";
|
||||
import { getSourceMetadata, SOURCE_METADATA_MAP } from "@/lib/sources";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import React, { memo } from "react";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { SlackIcon } from "@/components/icons/icons";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
|
||||
export const MemoizedAnchor = memo(
|
||||
@@ -66,7 +62,6 @@ export const MemoizedLink = memo((props: any) => {
|
||||
<Citation
|
||||
url={document?.url}
|
||||
icon={document?.icon as React.ReactNode}
|
||||
link={rest?.href}
|
||||
document={document as LoadedOnyxDocument}
|
||||
updatePresentingDocument={updatePresentingDocument}
|
||||
>
|
||||
|
||||
@@ -383,14 +383,14 @@ export const AIMessage = ({
|
||||
<div
|
||||
id="onyx-ai-message"
|
||||
ref={trackedElementRef}
|
||||
className={`py-5 ml-4 px-5 relative flex `}
|
||||
className={`py-5 ml-4 lg:px-5 relative flex `}
|
||||
>
|
||||
<div
|
||||
className={`mx-auto ${
|
||||
shared ? "w-full" : "w-[90%]"
|
||||
} max-w-message-max`}
|
||||
>
|
||||
<div className={`desktop:mr-12 ${!shared && "mobile:ml-0 md:ml-8"}`}>
|
||||
<div className={`lg:mr-12 ${!shared && "mobile:ml-0 md:ml-8"}`}>
|
||||
<div className="flex">
|
||||
<AssistantIcon
|
||||
size="small"
|
||||
@@ -399,7 +399,7 @@ export const AIMessage = ({
|
||||
|
||||
<div className="w-full">
|
||||
<div className="max-w-message-max break-words">
|
||||
<div className="w-full ml-4">
|
||||
<div className="w-full lg:ml-4">
|
||||
<div className="max-w-message-max break-words">
|
||||
{!toolCall || toolCall.tool_name === SEARCH_TOOL_NAME ? (
|
||||
<>
|
||||
@@ -410,6 +410,8 @@ export const AIMessage = ({
|
||||
query={query}
|
||||
finished={toolCall?.tool_result != undefined}
|
||||
handleSearchQueryEdit={handleSearchQueryEdit}
|
||||
docs={docs || []}
|
||||
toggleDocumentSelection={toggleDocumentSelection!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -465,7 +467,7 @@ export const AIMessage = ({
|
||||
)}
|
||||
|
||||
{docs && docs.length > 0 && (
|
||||
<div className="mt-2 -mx-8 w-full mb-4 flex relative">
|
||||
<div className="mobile:hidden mt-2 -mx-8 w-full mb-4 flex relative">
|
||||
<div className="w-full">
|
||||
<div className="px-8 flex gap-x-2">
|
||||
{!settings?.isMobile &&
|
||||
@@ -768,7 +770,7 @@ export const HumanMessage = ({
|
||||
return (
|
||||
<div
|
||||
id="onyx-human-message"
|
||||
className="pt-5 pb-1 px-2 lg:px-5 flex -mr-6 relative"
|
||||
className="pt-5 pb-1 w-full lg:px-5 flex -mr-6 relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
@@ -778,7 +780,7 @@ export const HumanMessage = ({
|
||||
} max-w-[790px]`}
|
||||
>
|
||||
<div className="xl:ml-8">
|
||||
<div className="flex flex-col mr-4">
|
||||
<div className="flex flex-col desktop:mr-4">
|
||||
<FileDisplay alignBubble files={files || []} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
|
||||
@@ -4,12 +4,15 @@ import {
|
||||
} from "@/components/BasicClickable";
|
||||
import { HoverPopup } from "@/components/HoverPopup";
|
||||
import { Hoverable } from "@/components/Hoverable";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { OnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ValidSources } from "@/lib/types";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FiCheck, FiEdit2, FiSearch, FiX } from "react-icons/fi";
|
||||
|
||||
@@ -45,11 +48,15 @@ export function SearchSummary({
|
||||
query,
|
||||
finished,
|
||||
handleSearchQueryEdit,
|
||||
docs,
|
||||
toggleDocumentSelection,
|
||||
}: {
|
||||
index: number;
|
||||
finished: boolean;
|
||||
query: string;
|
||||
handleSearchQueryEdit?: (query: string) => void;
|
||||
docs: OnyxDocument[];
|
||||
toggleDocumentSelection: () => void;
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [finalQuery, setFinalQuery] = useState(query);
|
||||
@@ -87,27 +94,63 @@ export function SearchSummary({
|
||||
}, [query, isEditing]);
|
||||
|
||||
const searchingForDisplay = (
|
||||
<div className={`flex p-1 rounded ${isOverflowed && "cursor-default"}`}>
|
||||
<FiSearch className="flex-none mr-2 my-auto" size={14} />
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<div
|
||||
className={`${!finished && "loading-text"}
|
||||
!text-sm !line-clamp-1 !break-all px-0.5`}
|
||||
ref={searchingForRef}
|
||||
className={`flex items-center w-full rounded ${
|
||||
isOverflowed && "cursor-default"
|
||||
}`}
|
||||
>
|
||||
{finished ? "Searched" : "Searching"} for:{" "}
|
||||
<i>
|
||||
{index === 1
|
||||
? finalQuery.length > 50
|
||||
? `${finalQuery.slice(0, 50)}...`
|
||||
: finalQuery
|
||||
: finalQuery}
|
||||
</i>
|
||||
<FiSearch className="mobile:hidden flex-none mr-2" size={14} />
|
||||
<div
|
||||
className={`${
|
||||
!finished && "loading-text"
|
||||
} text-xs desktop:text-sm mobile:ml-auto !line-clamp-1 !break-all px-0.5 flex-grow`}
|
||||
ref={searchingForRef}
|
||||
>
|
||||
{finished ? "Searched" : "Searching"} for:{" "}
|
||||
<i>
|
||||
{index === 1
|
||||
? finalQuery.length > 50
|
||||
? `${finalQuery.slice(0, 50)}...`
|
||||
: finalQuery
|
||||
: finalQuery}
|
||||
</i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="desktop:hidden">
|
||||
{" "}
|
||||
{docs && (
|
||||
<button
|
||||
className="cursor-pointer mr-2 flex items-center gap-0.5"
|
||||
onClick={() => toggleDocumentSelection()}
|
||||
>
|
||||
{Array.from(new Set(docs.map((doc) => doc.source_type)))
|
||||
.slice(0, 3)
|
||||
.map((sourceType, idx) => (
|
||||
<div key={idx} className="rounded-full">
|
||||
<SourceIcon sourceType={sourceType} iconSize={14} />
|
||||
</div>
|
||||
))}
|
||||
{Array.from(new Set(docs.map((doc) => doc.source_type))).length >
|
||||
3 && (
|
||||
<div className="rounded-full bg-gray-200 w-3.5 h-3.5 flex items-center justify-center">
|
||||
<span className="text-[8px]">
|
||||
+
|
||||
{Array.from(new Set(docs.map((doc) => doc.source_type)))
|
||||
.length - 3}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs underline">View sources</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const editInput = handleSearchQueryEdit ? (
|
||||
<div className="flex w-full mr-3">
|
||||
<div className="mobile:hidden flex w-full mr-3">
|
||||
<div className="my-2 w-full">
|
||||
<input
|
||||
ref={editQueryRef}
|
||||
@@ -155,12 +198,12 @@ export function SearchSummary({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="flex items-center">
|
||||
{isEditing ? (
|
||||
editInput
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm">
|
||||
<div className="mobile:w-full mobile:mr-2 text-sm mobile:flex-grow">
|
||||
{isOverflowed ? (
|
||||
<HoverPopup
|
||||
mainContent={searchingForDisplay}
|
||||
@@ -176,12 +219,13 @@ export function SearchSummary({
|
||||
searchingForDisplay
|
||||
)}
|
||||
</div>
|
||||
|
||||
{handleSearchQueryEdit && (
|
||||
<TooltipProvider delayDuration={1000}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="my-auto hover:bg-hover p-1.5 rounded"
|
||||
className="ml-2 mobile:hidden hover:bg-hover p-1 rounded flex-shrink-0"
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EmphasizedClickable } from "@/components/BasicClickable";
|
||||
import { FiBook } from "react-icons/fi";
|
||||
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
|
||||
import { FiBook, FiSearch } from "react-icons/fi";
|
||||
|
||||
export function SkippedSearch({
|
||||
handleForceSearch,
|
||||
@@ -7,22 +8,32 @@ export function SkippedSearch({
|
||||
handleForceSearch: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex text-sm !pt-0 p-1">
|
||||
<div className="flex mb-auto">
|
||||
<FiBook className="my-auto flex-none mr-2" size={14} />
|
||||
<div className="my-auto cursor-default">
|
||||
<div className="flex w-full text-sm !pt-0 p-1">
|
||||
<div className="flex w-full mb-auto">
|
||||
<FiBook className="mobile:hidden my-auto flex-none mr-2" size={14} />
|
||||
<div className="my-auto flex w-full items-center justify-between cursor-default">
|
||||
<span className="mobile:hidden">
|
||||
The AI decided this query didn't need a search
|
||||
</span>
|
||||
<span className="desktop:hidden">No search</span>
|
||||
{/* <EmphasizedClickable onClick={handleForceSearch}>
|
||||
Force search?
|
||||
</EmphasizedClickable> */}
|
||||
<p className="text-xs desktop:hidden">No search performed</p>
|
||||
<CustomTooltip
|
||||
content="Perform a search for this query"
|
||||
showTick
|
||||
line
|
||||
wrap
|
||||
>
|
||||
<button
|
||||
onClick={handleForceSearch}
|
||||
className="ml-auto mr-4 text-xs desktop:hidden underline-dotted decoration-dotted underline cursor-pointer"
|
||||
>
|
||||
Force search?
|
||||
</button>
|
||||
</CustomTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto my-auto" onClick={handleForceSearch}>
|
||||
<EmphasizedClickable size="sm">
|
||||
<div className="w-24 text-xs">Force Search</div>
|
||||
</EmphasizedClickable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
404
web/src/app/chat/nrf/NRFPage.tsx
Normal file
404
web/src/app/chat/nrf/NRFPage.tsx
Normal file
@@ -0,0 +1,404 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { useUser } from "@/components/user/UserProvider";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SimplifiedChatInputBar } from "../input/SimplifiedChatInputBar";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Shortcut } from "./interfaces";
|
||||
import {
|
||||
MaxShortcutsReachedModal,
|
||||
NewShortCutModal,
|
||||
} from "@/components/extension/Shortcuts";
|
||||
import { Modal } from "@/components/Modal";
|
||||
import { useNightTime } from "@/lib/dateUtils";
|
||||
import { useFilters } from "@/lib/hooks";
|
||||
import { uploadFilesForChat } from "../lib";
|
||||
import { ChatFileType, FileDescriptor } from "../interfaces";
|
||||
import { useChatContext } from "@/components/context/ChatContext";
|
||||
import Dropzone from "react-dropzone";
|
||||
import { useSendMessageToParent } from "@/lib/extension/utils";
|
||||
import {
|
||||
useNRFPreferences,
|
||||
NRFPreferencesProvider,
|
||||
} from "@/components/context/NRFPreferencesContext";
|
||||
import { SettingsPanel } from "../../components/nrf/SettingsPanel";
|
||||
import { ShortcutsDisplay } from "../../components/nrf/ShortcutsDisplay";
|
||||
import LoginPanel from "../../auth/login/LoginPage";
|
||||
import { AuthType } from "@/lib/constants";
|
||||
import { sendSetDefaultNewTabMessage } from "@/lib/extension/utils";
|
||||
import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
|
||||
import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies";
|
||||
|
||||
export default function NRFPage({
|
||||
requestCookies,
|
||||
}: {
|
||||
requestCookies: ReadonlyRequestCookies;
|
||||
}) {
|
||||
const {
|
||||
theme,
|
||||
defaultLightBackgroundUrl,
|
||||
defaultDarkBackgroundUrl,
|
||||
shortcuts: shortCuts,
|
||||
setShortcuts: setShortCuts,
|
||||
setUseOnyxAsNewTab,
|
||||
showShortcuts,
|
||||
} = useNRFPreferences();
|
||||
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
const [message, setMessage] = useState("");
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
// Show modal to confirm turning off Onyx as new tab
|
||||
const [showTurnOffModal, setShowTurnOffModal] = useState<boolean>(false);
|
||||
|
||||
// Settings sidebar open/close go
|
||||
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
|
||||
|
||||
const [editingShortcut, setEditingShortcut] = useState<Shortcut | null>(null);
|
||||
|
||||
// Saved background in localStorage
|
||||
const [backgroundUrl, setBackgroundUrl] = useState<string>(
|
||||
theme === "light" ? defaultLightBackgroundUrl : defaultDarkBackgroundUrl
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBackgroundUrl(
|
||||
theme === "light" ? defaultLightBackgroundUrl : defaultDarkBackgroundUrl
|
||||
);
|
||||
}, [theme, defaultLightBackgroundUrl, defaultDarkBackgroundUrl]);
|
||||
|
||||
const filterManager = useFilters();
|
||||
|
||||
const { isNight } = useNightTime();
|
||||
const { user } = useUser();
|
||||
const { ccPairs, documentSets, tags, llmProviders, shouldShowWelcomeModal } =
|
||||
useChatContext();
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useSendMessageToParent();
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleSettings = () => {
|
||||
setSettingsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
// If user toggles the "Use Onyx" switch to off, prompt a modal
|
||||
const handleUseOnyxToggle = (checked: boolean) => {
|
||||
if (!checked) {
|
||||
setShowTurnOffModal(true);
|
||||
} else {
|
||||
setUseOnyxAsNewTab(true);
|
||||
sendSetDefaultNewTabMessage(true);
|
||||
}
|
||||
};
|
||||
|
||||
const availableSources = ccPairs.map((ccPair) => ccPair.source);
|
||||
|
||||
const [currentMessageFiles, setCurrentMessageFiles] = useState<
|
||||
FileDescriptor[]
|
||||
>([]);
|
||||
|
||||
const handleImageUpload = async (acceptedFiles: File[]) => {
|
||||
const tempFileDescriptors = acceptedFiles.map((file) => ({
|
||||
id: uuidv4(),
|
||||
type: file.type.startsWith("image/")
|
||||
? ChatFileType.IMAGE
|
||||
: ChatFileType.DOCUMENT,
|
||||
isUploading: true,
|
||||
}));
|
||||
|
||||
// only show loading spinner for reasonably large files
|
||||
const totalSize = acceptedFiles.reduce((sum, file) => sum + file.size, 0);
|
||||
if (totalSize > 50 * 1024) {
|
||||
setCurrentMessageFiles((prev) => [...prev, ...tempFileDescriptors]);
|
||||
}
|
||||
|
||||
const removeTempFiles = (prev: FileDescriptor[]) => {
|
||||
return prev.filter(
|
||||
(file) => !tempFileDescriptors.some((newFile) => newFile.id === file.id)
|
||||
);
|
||||
};
|
||||
|
||||
await uploadFilesForChat(acceptedFiles).then(([files, error]) => {
|
||||
if (error) {
|
||||
setCurrentMessageFiles((prev) => removeTempFiles(prev));
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: error,
|
||||
});
|
||||
} else {
|
||||
setCurrentMessageFiles((prev) => [...removeTempFiles(prev), ...files]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Confirm turning off Onyx
|
||||
const confirmTurnOff = () => {
|
||||
setUseOnyxAsNewTab(false);
|
||||
setShowTurnOffModal(false);
|
||||
sendSetDefaultNewTabMessage(false);
|
||||
};
|
||||
|
||||
const [showShortCutModal, setShowShortCutModal] = useState(false);
|
||||
|
||||
const [showMaxShortcutsModal, setShowMaxShortcutsModal] = useState(false);
|
||||
|
||||
const [showLoginModal, setShowLoginModal] = useState<boolean>(!user);
|
||||
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const [fetchingAuth, setFetchingAuth] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// If user is already logged in, no need to fetch auth data
|
||||
if (user) return;
|
||||
|
||||
async function fetchAuthData() {
|
||||
setFetchingAuth(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/type", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch auth type: ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setAuthType(data.auth_type);
|
||||
} catch (err) {
|
||||
console.error("Error fetching auth data:", err);
|
||||
} finally {
|
||||
setFetchingAuth(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchAuthData();
|
||||
}, [user]);
|
||||
|
||||
const onSubmit = async ({
|
||||
messageOverride,
|
||||
}: {
|
||||
messageOverride?: string;
|
||||
} = {}) => {
|
||||
const userMessage = messageOverride || message;
|
||||
|
||||
setMessage("");
|
||||
let filterString = filterManager?.getFilterString();
|
||||
|
||||
if (currentMessageFiles.length > 0) {
|
||||
filterString +=
|
||||
"&files=" + encodeURIComponent(JSON.stringify(currentMessageFiles));
|
||||
}
|
||||
const newHref =
|
||||
"http://localhost:3000/chat?send-on-load=true&user-prompt=" +
|
||||
encodeURIComponent(userMessage) +
|
||||
filterString;
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage({ type: "LOAD_NEW_PAGE", href: newHref }, "*");
|
||||
} else {
|
||||
window.location.href = newHref;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full flex flex-col"
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
backgroundImage: `url(${backgroundUrl})`,
|
||||
backgroundPosition: "center center",
|
||||
backgroundSize: "cover",
|
||||
backgroundRepeat: "no-repeat",
|
||||
overflow: "hidden",
|
||||
transition: "background-image 0.3s ease",
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-4 z-10">
|
||||
<button
|
||||
aria-label="Open settings"
|
||||
onClick={toggleSettings}
|
||||
className="bg-white bg-opacity-70 rounded-full p-2.5 cursor-pointer hover:bg-opacity-80 transition-colors duration-200"
|
||||
>
|
||||
<Menu size={12} className="text-neutral-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Dropzone onDrop={handleImageUpload} noClick>
|
||||
{({ getRootProps }) => (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className="absolute top-20 left-0 w-full h-full flex flex-col"
|
||||
>
|
||||
<div className="pointer-events-auto absolute top-[40%] left-1/2 -translate-x-1/2 -translate-y-1/2 text-center w-[90%] lg:max-w-3xl">
|
||||
<h1
|
||||
className={`pl-2 text-xl text-left w-full mb-4 ${
|
||||
theme === "light" ? "text-neutral-800" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{isNight
|
||||
? "End your day with Onyx"
|
||||
: "Start your day with Onyx"}
|
||||
</h1>
|
||||
|
||||
<SimplifiedChatInputBar
|
||||
onSubmit={onSubmit}
|
||||
handleFileUpload={handleImageUpload}
|
||||
message={message}
|
||||
setMessage={setMessage}
|
||||
files={currentMessageFiles}
|
||||
setFiles={setCurrentMessageFiles}
|
||||
filterManager={filterManager}
|
||||
textAreaRef={textAreaRef}
|
||||
existingSources={availableSources}
|
||||
availableDocumentSets={documentSets}
|
||||
availableTags={tags}
|
||||
/>
|
||||
|
||||
<ShortcutsDisplay
|
||||
shortCuts={shortCuts}
|
||||
showShortcuts={showShortcuts}
|
||||
setEditingShortcut={setEditingShortcut}
|
||||
setShowShortCutModal={setShowShortCutModal}
|
||||
openShortCutModal={() => {
|
||||
if (shortCuts.length >= 6) {
|
||||
setShowMaxShortcutsModal(true);
|
||||
} else {
|
||||
setEditingShortcut(null);
|
||||
setShowShortCutModal(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
{showMaxShortcutsModal && (
|
||||
<MaxShortcutsReachedModal
|
||||
onClose={() => setShowMaxShortcutsModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showShortCutModal && (
|
||||
<NewShortCutModal
|
||||
setPopup={setPopup}
|
||||
onDelete={(shortcut: Shortcut) => {
|
||||
setShortCuts(
|
||||
shortCuts.filter((s: Shortcut) => s.name !== shortcut.name)
|
||||
);
|
||||
setShowShortCutModal(false);
|
||||
}}
|
||||
isOpen={showShortCutModal}
|
||||
onClose={() => {
|
||||
setEditingShortcut(null);
|
||||
setShowShortCutModal(false);
|
||||
}}
|
||||
onAdd={(shortCut: Shortcut) => {
|
||||
if (editingShortcut) {
|
||||
setShortCuts(
|
||||
shortCuts
|
||||
.filter((s) => s.name !== editingShortcut.name)
|
||||
.concat(shortCut)
|
||||
);
|
||||
} else {
|
||||
setShortCuts([...shortCuts, shortCut]);
|
||||
}
|
||||
setShowShortCutModal(false);
|
||||
}}
|
||||
editingShortcut={editingShortcut}
|
||||
/>
|
||||
)}
|
||||
<SettingsPanel
|
||||
settingsOpen={settingsOpen}
|
||||
toggleSettings={toggleSettings}
|
||||
handleUseOnyxToggle={handleUseOnyxToggle}
|
||||
/>
|
||||
|
||||
<Dialog open={showTurnOffModal} onOpenChange={setShowTurnOffModal}>
|
||||
<DialogContent className="w-fit max-w-[95%]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Turn off Onyx new tab page?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You'll see your browser's default new tab page instead.
|
||||
<br />
|
||||
You can turn it back on anytime in your Onyx settings.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowTurnOffModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmTurnOff}>
|
||||
Turn off
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{!user && showLoginModal && (
|
||||
<Modal
|
||||
className="max-w-md mx-auto"
|
||||
onOutsideClick={() => setShowLoginModal(false)}
|
||||
>
|
||||
{fetchingAuth ? (
|
||||
<p className="p-4">Loading login info…</p>
|
||||
) : authType == "basic" ? (
|
||||
<LoginPanel
|
||||
showPageRedirect
|
||||
authUrl={null}
|
||||
authTypeMetadata={{
|
||||
authType: authType as AuthType,
|
||||
autoRedirect: false,
|
||||
requiresVerification: false,
|
||||
anonymousUserEnabled: null,
|
||||
}}
|
||||
nextUrl="/nrf"
|
||||
searchParams={{}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<h2 className="text-center text-xl text-strong font-bold mb-4">
|
||||
Welcome to Onyx
|
||||
</h2>
|
||||
<Button
|
||||
className="bg-accent w-full hover:bg-accent-hover text-white"
|
||||
onClick={() => {
|
||||
if (window.top) {
|
||||
window.top.location.href = "/auth/login";
|
||||
} else {
|
||||
window.location.href = "/auth/login";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{shouldShowWelcomeModal && (
|
||||
<WelcomeModal user={user} requestCookies={requestCookies} />
|
||||
)}
|
||||
|
||||
{popup}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
67
web/src/app/chat/nrf/interfaces.ts
Normal file
67
web/src/app/chat/nrf/interfaces.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export interface Shortcut {
|
||||
name: string;
|
||||
url: string;
|
||||
favicon?: string;
|
||||
}
|
||||
|
||||
// Start of Selection
|
||||
|
||||
// Start of Selection
|
||||
export enum LightBackgroundColors {
|
||||
Red = "#dc2626", // Tailwind Red 600
|
||||
Blue = "#2563eb", // Tailwind Blue 600
|
||||
Green = "#16a34a", // Tailwind Green 600
|
||||
Yellow = "#ca8a04", // Tailwind Yellow 600
|
||||
Purple = "#9333ea", // Tailwind Purple 600
|
||||
Orange = "#ea580c", // Tailwind Orange 600
|
||||
Pink = "#db2777", // Tailwind Pink 600
|
||||
}
|
||||
|
||||
export enum DarkBackgroundColors {
|
||||
Red = "#991b1b", // Tailwind Red 800
|
||||
Blue = "#1e40af", // Tailwind Blue 800
|
||||
Green = "#166534", // Tailwind Green 800
|
||||
Yellow = "#854d0e", // Tailwind Yellow 800
|
||||
Purple = "#5b21b6", // Tailwind Purple 800
|
||||
Orange = "#9a3412", // Tailwind Orange 800
|
||||
Pink = "#9d174d", // Tailwind Pink 800
|
||||
}
|
||||
|
||||
export enum StoredBackgroundColors {
|
||||
RED = "Red",
|
||||
BLUE = "Blue",
|
||||
GREEN = "Green",
|
||||
YELLOW = "Yellow",
|
||||
PURPLE = "Purple",
|
||||
ORANGE = "Orange",
|
||||
PINK = "Pink",
|
||||
}
|
||||
|
||||
export type BackgroundColors = LightBackgroundColors | DarkBackgroundColors;
|
||||
|
||||
export interface Shortcut {
|
||||
name: string;
|
||||
url: string;
|
||||
favicon?: string;
|
||||
}
|
||||
|
||||
export const darkImages = [
|
||||
"https://images.unsplash.com/photo-1692520883599-d543cfe6d43d?q=80&w=2666&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1520330461350-508fab483d6a?q=80&w=2723&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
];
|
||||
|
||||
export const lightImages = [
|
||||
"https://images.unsplash.com/photo-1473830439578-14e9a9e61d55?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1500964757637-c85e8a162699?q=80&w=2703&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
"https://images.unsplash.com/photo-1475924156734-496f6cac6ec1?q=80&w=2670&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
|
||||
];
|
||||
|
||||
// Local storage keys
|
||||
export const SHORTCUTS_KEY = "shortCuts";
|
||||
export const NEW_TAB_PAGE_VIEW_KEY = "newTabPageView";
|
||||
export const USE_ONYX_AS_NEW_TAB_KEY = "useOnyxAsNewTab";
|
||||
|
||||
// Default values
|
||||
export const DEFAULT_LIGHT_BACKGROUND_IMAGE = "onyxBackgroundLight";
|
||||
export const DEFAULT_DARK_BACKGROUND_IMAGE = "onyxBackgroundDark";
|
||||
export const DEFAULT_NEW_TAB_PAGE_VIEW = "chat";
|
||||
20
web/src/app/chat/nrf/page.tsx
Normal file
20
web/src/app/chat/nrf/page.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { unstable_noStore as noStore } from "next/cache";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import { cookies } from "next/headers";
|
||||
import NRFPage from "./NRFPage";
|
||||
import { NRFPreferencesProvider } from "../../../components/context/NRFPreferencesContext";
|
||||
|
||||
export default async function Page() {
|
||||
noStore();
|
||||
const requestCookies = await cookies();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-black">
|
||||
<InstantSSRAutoRefresh />
|
||||
|
||||
<NRFPreferencesProvider>
|
||||
<NRFPage requestCookies={requestCookies} />
|
||||
</NRFPreferencesProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,53 +11,54 @@ export default async function Page(props: {
|
||||
searchParams: Promise<{ [key: string]: string }>;
|
||||
}) {
|
||||
const searchParams = await props.searchParams;
|
||||
noStore();
|
||||
const requestCookies = await cookies();
|
||||
const data = await fetchChatData(searchParams);
|
||||
const firstMessage = searchParams.firstMessage;
|
||||
// noStore();
|
||||
// const requestCookies = await cookies();
|
||||
// const data = await fetchChatData(searchParams);
|
||||
// const defaultSidebarOff = searchParams.defaultSidebarOff === "true";
|
||||
|
||||
if ("redirect" in data) {
|
||||
redirect(data.redirect);
|
||||
}
|
||||
// if ("redirect" in data) {
|
||||
// redirect(data.redirect);
|
||||
// }
|
||||
|
||||
const {
|
||||
user,
|
||||
chatSessions,
|
||||
availableSources,
|
||||
documentSets,
|
||||
tags,
|
||||
llmProviders,
|
||||
folders,
|
||||
toggleSidebar,
|
||||
openedFolders,
|
||||
defaultAssistantId,
|
||||
shouldShowWelcomeModal,
|
||||
ccPairs,
|
||||
} = data;
|
||||
// const {
|
||||
// user,
|
||||
// chatSessions,
|
||||
// availableSources,
|
||||
// documentSets,
|
||||
// tags,
|
||||
// llmProviders,
|
||||
// folders,
|
||||
// toggleSidebar,
|
||||
// openedFolders,
|
||||
// defaultAssistantId,
|
||||
// shouldShowWelcomeModal,
|
||||
// ccPairs,
|
||||
// } = data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstantSSRAutoRefresh />
|
||||
{shouldShowWelcomeModal && (
|
||||
<WelcomeModal user={user} requestCookies={requestCookies} />
|
||||
)}
|
||||
<ChatProvider
|
||||
value={{
|
||||
chatSessions,
|
||||
availableSources,
|
||||
ccPairs,
|
||||
documentSets,
|
||||
tags,
|
||||
availableDocumentSets: documentSets,
|
||||
availableTags: tags,
|
||||
llmProviders,
|
||||
folders,
|
||||
openedFolders,
|
||||
shouldShowWelcomeModal,
|
||||
defaultAssistantId,
|
||||
}}
|
||||
>
|
||||
<WrappedChat initiallyToggled={toggleSidebar} />
|
||||
</ChatProvider>
|
||||
</>
|
||||
);
|
||||
// return (
|
||||
// <>
|
||||
// <InstantSSRAutoRefresh />
|
||||
// {shouldShowWelcomeModal && (
|
||||
// <WelcomeModal user={user} requestCookies={requestCookies} />
|
||||
// )}
|
||||
// <ChatProvider
|
||||
// value={{
|
||||
// chatSessions,
|
||||
// availableSources,
|
||||
// ccPairs,
|
||||
// documentSets,
|
||||
// tags,
|
||||
// availableDocumentSets: documentSets,
|
||||
// availableTags: tags,
|
||||
// llmProviders,
|
||||
// folders,
|
||||
// openedFolders,
|
||||
// shouldShowWelcomeModal,
|
||||
// defaultAssistantId,
|
||||
// }}
|
||||
return <WrappedChat firstMessage={firstMessage} initiallyToggled={false} />;
|
||||
// </ChatProvider>
|
||||
// </>
|
||||
// );
|
||||
}
|
||||
|
||||
176
web/src/app/components/nrf/SettingsPanel.tsx
Normal file
176
web/src/app/components/nrf/SettingsPanel.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React from "react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useNRFPreferences } from "../../../components/context/NRFPreferencesContext";
|
||||
import { darkImages, lightImages } from "../../chat/nrf/interfaces";
|
||||
|
||||
const SidebarSwitch = ({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
label: string;
|
||||
}) => (
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-sm text-gray-300">{label}</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
className="data-[state=checked]:bg-white data-[state=unchecked]:bg-gray-600"
|
||||
circleClassName="data-[state=checked]:bg-neutral-200"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const RadioOption = ({
|
||||
value,
|
||||
label,
|
||||
description,
|
||||
groupValue,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
description: string;
|
||||
groupValue: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => (
|
||||
<div className="flex items-start space-x-2 mb-2">
|
||||
<RadioGroupItem
|
||||
value={value}
|
||||
id={value}
|
||||
className="mt-1 border border-gray-600 data-[state=checked]:border-white data-[state=checked]:bg-white"
|
||||
/>
|
||||
<Label htmlFor={value} className="flex flex-col">
|
||||
<span className="text-sm text-gray-300">{label}</span>
|
||||
{description && (
|
||||
<span className="text-xs text-gray-500">{description}</span>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SettingsPanel = ({
|
||||
settingsOpen,
|
||||
toggleSettings,
|
||||
handleUseOnyxToggle,
|
||||
}: {
|
||||
settingsOpen: boolean;
|
||||
toggleSettings: () => void;
|
||||
handleUseOnyxToggle: (checked: boolean) => void;
|
||||
}) => {
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
defaultLightBackgroundUrl,
|
||||
setDefaultLightBackgroundUrl,
|
||||
defaultDarkBackgroundUrl,
|
||||
setDefaultDarkBackgroundUrl,
|
||||
useOnyxAsNewTab,
|
||||
showShortcuts,
|
||||
setShowShortcuts,
|
||||
} = useNRFPreferences();
|
||||
|
||||
const toggleTheme = (newTheme: string) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const updateBackgroundUrl = (url: string) => {
|
||||
if (theme === "light") {
|
||||
setDefaultLightBackgroundUrl(url);
|
||||
} else {
|
||||
setDefaultDarkBackgroundUrl(url);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed top-0 right-0 w-[360px] h-full bg-[#202124] text-gray-300 overflow-y-auto z-20 transition-transform duration-300 ease-in-out transform"
|
||||
style={{
|
||||
transform: settingsOpen ? "translateX(0)" : "translateX(100%)",
|
||||
boxShadow: "-2px 0 10px rgba(0,0,0,0.3)",
|
||||
}}
|
||||
>
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
Home page settings
|
||||
</h2>
|
||||
<button
|
||||
aria-label="Close"
|
||||
onClick={toggleSettings}
|
||||
className="text-gray-400 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-semibold mb-2">General</h3>
|
||||
<SidebarSwitch
|
||||
checked={useOnyxAsNewTab}
|
||||
onCheckedChange={handleUseOnyxToggle}
|
||||
label="Use Onyx as new tab page"
|
||||
/>
|
||||
|
||||
<SidebarSwitch
|
||||
checked={showShortcuts}
|
||||
onCheckedChange={setShowShortcuts}
|
||||
label="Show bookmarks"
|
||||
/>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-6 mb-2">Theme</h3>
|
||||
<RadioGroup
|
||||
value={theme}
|
||||
onValueChange={toggleTheme}
|
||||
className="space-y-2"
|
||||
>
|
||||
<RadioOption
|
||||
value="light"
|
||||
label="Light theme"
|
||||
description="Light theme"
|
||||
groupValue={theme}
|
||||
onChange={toggleTheme}
|
||||
/>
|
||||
<RadioOption
|
||||
value="dark"
|
||||
label="Dark theme"
|
||||
description="Dark theme"
|
||||
groupValue={theme}
|
||||
onChange={toggleTheme}
|
||||
/>
|
||||
</RadioGroup>
|
||||
|
||||
<h3 className="text-sm font-semibold mt-6 mb-2">Background</h3>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{(theme === "dark" ? darkImages : lightImages).map(
|
||||
(bg: string, index: number) => (
|
||||
<div
|
||||
key={bg}
|
||||
onClick={() => updateBackgroundUrl(bg)}
|
||||
className={`relative ${
|
||||
index === 0 ? "col-span-2 row-span-2" : ""
|
||||
} cursor-pointer rounded-sm overflow-hidden`}
|
||||
style={{
|
||||
paddingBottom: index === 0 ? "100%" : "50%",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{ backgroundImage: `url(${bg})` }}
|
||||
/>
|
||||
{(theme === "light"
|
||||
? defaultLightBackgroundUrl
|
||||
: defaultDarkBackgroundUrl) === bg && (
|
||||
<div className="absolute inset-0 border-2 border-blue-400 rounded" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
web/src/app/components/nrf/ShortcutsDisplay.tsx
Normal file
46
web/src/app/components/nrf/ShortcutsDisplay.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { ShortCut, AddShortCut } from "@/components/extension/Shortcuts";
|
||||
import { Shortcut } from "@/app/chat/nrf/interfaces";
|
||||
|
||||
interface ShortcutsDisplayProps {
|
||||
shortCuts: Shortcut[];
|
||||
showShortcuts: boolean;
|
||||
setEditingShortcut: (shortcut: Shortcut | null) => void;
|
||||
setShowShortCutModal: (show: boolean) => void;
|
||||
openShortCutModal: () => void;
|
||||
}
|
||||
|
||||
export const ShortcutsDisplay: React.FC<ShortcutsDisplayProps> = ({
|
||||
shortCuts,
|
||||
showShortcuts,
|
||||
setEditingShortcut,
|
||||
setShowShortCutModal,
|
||||
openShortCutModal,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
mx-auto flex flex-wrap justify-center gap-x-6 gap-y-4 mt-12
|
||||
transition-all duration-700 ease-in-out
|
||||
${
|
||||
showShortcuts
|
||||
? "opacity-100 max-h-[500px]"
|
||||
: "opacity-0 max-h-0 overflow-hidden pointer-events-none"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{shortCuts.map((shortCut: Shortcut, index: number) => (
|
||||
<ShortCut
|
||||
key={index}
|
||||
onEdit={() => {
|
||||
setEditingShortcut(shortCut);
|
||||
setShowShortCutModal(true);
|
||||
}}
|
||||
shortCut={shortCut}
|
||||
/>
|
||||
))}
|
||||
<AddShortCut openShortCutModal={openShortCutModal} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
0
web/src/app/ee/Hori
Normal file
0
web/src/app/ee/Hori
Normal file
@@ -13,7 +13,6 @@ import { Metadata } from "next";
|
||||
import { buildClientUrl } from "@/lib/utilsSS";
|
||||
import { Inter } from "next/font/google";
|
||||
import { EnterpriseSettings, GatingType } from "./admin/settings/interfaces";
|
||||
import { HeaderTitle } from "@/components/header/HeaderTitle";
|
||||
import { fetchAssistantData } from "@/lib/chat/fetchAssistantdata";
|
||||
import { AppProvider } from "@/components/context/AppProvider";
|
||||
import { PHProvider } from "./providers";
|
||||
|
||||
@@ -33,6 +33,7 @@ export function AssistantIcon({
|
||||
|
||||
return (
|
||||
<CustomTooltip
|
||||
className="hidden lg:block"
|
||||
disabled={disableToolip || !assistant.description}
|
||||
showTick
|
||||
line
|
||||
|
||||
30
web/src/components/auth/AuthErrorDisplay.tsx
Normal file
30
web/src/components/auth/AuthErrorDisplay.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePopup } from "../admin/connectors/Popup";
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
Anonymous: "Your organization does not have anonymous access enabled.",
|
||||
};
|
||||
|
||||
export default function AuthErrorDisplay({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: any;
|
||||
}) {
|
||||
const error = searchParams?.error;
|
||||
const { popup, setPopup } = usePopup();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setPopup({
|
||||
message:
|
||||
ERROR_MESSAGES[error as keyof typeof ERROR_MESSAGES] ||
|
||||
"An error occurred.",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return <>{popup}</>;
|
||||
}
|
||||
@@ -66,10 +66,12 @@ const AssistantSelector = ({
|
||||
const [isTemperatureExpanded, setIsTemperatureExpanded] = useState(false);
|
||||
|
||||
// Initialize selectedTab from localStorage
|
||||
const [selectedTab, setSelectedTab] = useState<number>(() => {
|
||||
const [selectedTab, setSelectedTab] = useState<number | undefined>();
|
||||
useEffect(() => {
|
||||
const storedTab = localStorage.getItem("assistantSelectorSelectedTab");
|
||||
return storedTab !== null ? Number(storedTab) : 0;
|
||||
});
|
||||
const tab = storedTab !== null ? Number(storedTab) : 0;
|
||||
setSelectedTab(tab);
|
||||
}, [localStorage]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
||||
@@ -72,15 +72,20 @@ export const useSidebarVisibility = ({
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setShowDocSidebar(false);
|
||||
if (!mobile) {
|
||||
setShowDocSidebar(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleEvent);
|
||||
document.addEventListener("mouseleave", handleMouseLeave);
|
||||
if (!mobile) {
|
||||
document.addEventListener("mousemove", handleEvent);
|
||||
document.addEventListener("mouseleave", handleMouseLeave);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleEvent);
|
||||
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||
if (!mobile) {
|
||||
document.removeEventListener("mousemove", handleEvent);
|
||||
document.removeEventListener("mouseleave", handleMouseLeave);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showDocSidebar, toggledSidebar, sidebarElementRef, mobile]);
|
||||
|
||||
123
web/src/components/context/NRFPreferencesContext.tsx
Normal file
123
web/src/components/context/NRFPreferencesContext.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
import { darkImages, lightImages, Shortcut } from "@/app/chat/nrf/interfaces";
|
||||
|
||||
function notifyExtensionOfThemeChange(newTheme: string, newBgUrl: string) {
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "PREFERENCES_UPDATED",
|
||||
payload: {
|
||||
theme: newTheme,
|
||||
backgroundUrl: newBgUrl,
|
||||
},
|
||||
},
|
||||
"*"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface NRFPreferencesContextValue {
|
||||
theme: string;
|
||||
setTheme: (t: string) => void;
|
||||
defaultLightBackgroundUrl: string;
|
||||
setDefaultLightBackgroundUrl: (val: string) => void;
|
||||
defaultDarkBackgroundUrl: string;
|
||||
setDefaultDarkBackgroundUrl: (val: string) => void;
|
||||
shortcuts: Shortcut[];
|
||||
setShortcuts: (s: Shortcut[]) => void;
|
||||
useOnyxAsNewTab: boolean;
|
||||
setUseOnyxAsNewTab: (v: boolean) => void;
|
||||
showShortcuts: boolean;
|
||||
setShowShortcuts: (v: boolean) => void;
|
||||
}
|
||||
|
||||
const NRFPreferencesContext = createContext<
|
||||
NRFPreferencesContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
function useLocalStorageState<T>(
|
||||
key: string,
|
||||
defaultValue: T
|
||||
): [T, (value: T) => void] {
|
||||
const [state, setState] = useState<T>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const storedValue = localStorage.getItem(key);
|
||||
return storedValue ? JSON.parse(storedValue) : defaultValue;
|
||||
}
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
const setValue = (value: T) => {
|
||||
setState(value);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
};
|
||||
|
||||
return [state, setValue];
|
||||
}
|
||||
|
||||
export function NRFPreferencesProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [theme, setTheme] = useLocalStorageState<string>("onyxTheme", "dark");
|
||||
const [defaultLightBackgroundUrl, setDefaultLightBackgroundUrl] =
|
||||
useLocalStorageState<string>("lightBgUrl", lightImages[0]);
|
||||
const [defaultDarkBackgroundUrl, setDefaultDarkBackgroundUrl] =
|
||||
useLocalStorageState<string>("darkBgUrl", darkImages[0]);
|
||||
const [shortcuts, setShortcuts] = useLocalStorageState<Shortcut[]>(
|
||||
"shortCuts",
|
||||
[]
|
||||
);
|
||||
const [showShortcuts, setShowShortcuts] = useLocalStorageState<boolean>(
|
||||
"showShortcuts",
|
||||
false
|
||||
);
|
||||
const [useOnyxAsNewTab, setUseOnyxAsNewTab] = useLocalStorageState<boolean>(
|
||||
"useOnyxAsDefaultNewTab",
|
||||
true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme === "dark") {
|
||||
notifyExtensionOfThemeChange(theme, defaultDarkBackgroundUrl);
|
||||
} else {
|
||||
notifyExtensionOfThemeChange(theme, defaultLightBackgroundUrl);
|
||||
}
|
||||
}, [theme, defaultLightBackgroundUrl, defaultDarkBackgroundUrl]);
|
||||
|
||||
return (
|
||||
<NRFPreferencesContext.Provider
|
||||
value={{
|
||||
theme,
|
||||
setTheme,
|
||||
defaultLightBackgroundUrl,
|
||||
setDefaultLightBackgroundUrl,
|
||||
defaultDarkBackgroundUrl,
|
||||
setDefaultDarkBackgroundUrl,
|
||||
shortcuts,
|
||||
setShortcuts,
|
||||
useOnyxAsNewTab,
|
||||
setUseOnyxAsNewTab,
|
||||
showShortcuts,
|
||||
setShowShortcuts,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NRFPreferencesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNRFPreferences() {
|
||||
const context = useContext(NRFPreferencesContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useNRFPreferences must be used within an NRFPreferencesProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
274
web/src/components/extension/Shortcuts.tsx
Normal file
274
web/src/components/extension/Shortcuts.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Shortcut } from "@/app/chat/nrf/interfaces";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PencilIcon, PlusIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { PopupSpec } from "@/components/admin/connectors/Popup";
|
||||
import { Modal } from "../Modal";
|
||||
|
||||
export const validateUrl = (input: string) => {
|
||||
try {
|
||||
new URL(input);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const QuestionMarkIcon = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="w-full h-full text-neutral-50"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<line x1="12" y1="17" x2="12.01" y2="17" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ShortCut = ({
|
||||
shortCut,
|
||||
onEdit,
|
||||
}: {
|
||||
shortCut: Shortcut;
|
||||
onEdit: (shortcut: Shortcut) => void;
|
||||
}) => {
|
||||
const [faviconError, setFaviconError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="w-24 h-24 flex-none rounded-xl shadow-lg relative group transition-all duration-300 ease-in-out hover:scale-105 bg-white/10 backdrop-blur-sm">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(shortCut);
|
||||
}}
|
||||
className="absolute top-1 right-1 p-1 bg-white/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-200"
|
||||
>
|
||||
<PencilIcon className="w-3 h-3 text-white" />
|
||||
</button>
|
||||
<div
|
||||
onClick={() => window.open(shortCut.url, "_blank")}
|
||||
className="w-full h-full flex flex-col items-center justify-center cursor-pointer"
|
||||
>
|
||||
<div className="w-8 h-8 mb-2 relative">
|
||||
{shortCut.favicon && !faviconError ? (
|
||||
<Image
|
||||
src={shortCut.favicon}
|
||||
alt={shortCut.name}
|
||||
width={40}
|
||||
height={40}
|
||||
className="rounded-sm"
|
||||
onError={() => setFaviconError(true)}
|
||||
/>
|
||||
) : (
|
||||
<QuestionMarkIcon />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-white w-full text-center font-semibold text-sm truncate px-2">
|
||||
{shortCut.name}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AddShortCut = ({
|
||||
openShortCutModal,
|
||||
}: {
|
||||
openShortCutModal: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
onClick={openShortCutModal}
|
||||
className="w-24 h-24 flex-none rounded-xl bg-white/10 hover:bg-white/20 backdrop-blur-sm transition-all duration-300 ease-in-out flex flex-col items-center justify-center"
|
||||
>
|
||||
<PlusIcon className="w-8 h-8 text-white mb-2" />
|
||||
<h1 className="text-white text-xs font-medium">New Bookmark</h1>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const NewShortCutModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdd,
|
||||
editingShortcut,
|
||||
onDelete,
|
||||
setPopup,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDelete: (shortcut: Shortcut) => void;
|
||||
onAdd: (shortcut: Shortcut) => void;
|
||||
editingShortcut?: Shortcut | null;
|
||||
setPopup: (popup: PopupSpec) => void;
|
||||
}) => {
|
||||
const [name, setName] = useState(editingShortcut?.name || "");
|
||||
const [url, setUrl] = useState(editingShortcut?.url || "");
|
||||
const [faviconError, setFaviconError] = useState(false);
|
||||
const [isValidUrl, setIsValidUrl] = useState(false);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (isValidUrl) {
|
||||
const faviconUrl = `https://www.google.com/s2/favicons?domain=${
|
||||
new URL(url).hostname
|
||||
}&sz=64`;
|
||||
onAdd({ name, url, favicon: faviconUrl });
|
||||
onClose();
|
||||
} else {
|
||||
console.error("Invalid URL submitted");
|
||||
setPopup({
|
||||
type: "error",
|
||||
message: "Please enter a valid URL",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newUrl = e.target.value;
|
||||
setUrl(newUrl);
|
||||
setIsValidUrl(validateUrl(newUrl));
|
||||
setFaviconError(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setIsValidUrl(validateUrl(url));
|
||||
}, [url]);
|
||||
|
||||
const faviconUrl = isValidUrl
|
||||
? `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}&sz=64`
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95%] sm:max-w-[425px] bg-neutral-900 border-none text-white">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingShortcut ? "Edit Shortcut" : "Add New Shortcut"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingShortcut
|
||||
? "Modify your existing shortcut."
|
||||
: "Create a new shortcut for quick access to your favorite websites."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="w-full space-y-6">
|
||||
<div className="space-y-4 w-full">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label
|
||||
htmlFor="name"
|
||||
className="text-sm font-medium text-neutral-300"
|
||||
>
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-neutral-800 border-neutral-700 text-white"
|
||||
placeholder="Enter shortcut name"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<Label
|
||||
htmlFor="url"
|
||||
className="text-sm font-medium text-neutral-300"
|
||||
>
|
||||
URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={handleUrlChange}
|
||||
className={`bg-neutral-800 border-neutral-700 text-white ${
|
||||
!isValidUrl && url ? "border-red-500" : ""
|
||||
}`}
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
{!isValidUrl && url && (
|
||||
<p className="text-red-500 text-sm">Please enter a valid URL</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Label className="text-sm font-medium text-neutral-300">
|
||||
Favicon Preview:
|
||||
</Label>
|
||||
<div className="w-8 h-8 relative flex items-center justify-center">
|
||||
{isValidUrl && !faviconError ? (
|
||||
<Image
|
||||
src={faviconUrl}
|
||||
alt="Favicon"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-full h-full rounded-sm"
|
||||
onError={() => setFaviconError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8">
|
||||
<QuestionMarkIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
disabled={!isValidUrl || !name}
|
||||
>
|
||||
{editingShortcut ? "Save Changes" : "Add Shortcut"}
|
||||
</Button>
|
||||
{editingShortcut && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => onDelete(editingShortcut)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const MaxShortcutsReachedModal = ({
|
||||
onClose,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
width="max-w-md"
|
||||
title="Maximum Shortcuts Reached"
|
||||
onOutsideClick={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-left text-neutral-900">
|
||||
You've reached the maximum limit of 8 shortcuts. To add a new
|
||||
shortcut, please remove an existing one.
|
||||
</p>
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -119,7 +119,7 @@ export default function LogoWithText({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className="mr-3 my-auto ml-auto"
|
||||
className="mr-3 my-auto ml-auto"
|
||||
onClick={() => {
|
||||
toggleSidebar();
|
||||
if (toggled) {
|
||||
@@ -138,7 +138,7 @@ export default function LogoWithText({
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<TooltipContent className="!border-none">
|
||||
{toggled ? `Unpin sidebar` : "Pin sidebar"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -5,6 +5,7 @@ interface Option {
|
||||
key: string;
|
||||
display: string | JSX.Element;
|
||||
displayName?: string;
|
||||
icon?: JSX.Element;
|
||||
}
|
||||
export function FilterDropdown({
|
||||
options,
|
||||
@@ -65,6 +66,7 @@ export function FilterDropdown({
|
||||
flex-none
|
||||
w-fit
|
||||
text-emphasis
|
||||
items-center
|
||||
gap-x-1
|
||||
${dropdownColor || "bg-background"}
|
||||
hover:bg-hover
|
||||
@@ -80,6 +82,7 @@ export function FilterDropdown({
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{option.icon}
|
||||
{option.display}
|
||||
{isSelected && (
|
||||
<div className="ml-auto my-auto mr-1">
|
||||
|
||||
226
web/src/components/search/filtering/HorizontalSourceSelector.tsx
Normal file
226
web/src/components/search/filtering/HorizontalSourceSelector.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover"; // shadcn popover
|
||||
import { FiBook, FiMap, FiTag, FiCalendar } from "react-icons/fi";
|
||||
import { SourceMetadata } from "@/lib/search/interfaces";
|
||||
import { Calendar } from "@/components/ui/calendar"; // or wherever your Calendar component lives
|
||||
import { FilterDropdown } from "@/components/search/filtering/FilterDropdown";
|
||||
import { listSourceMetadata } from "@/lib/sources";
|
||||
import { getDateRangeString } from "@/lib/dateUtils";
|
||||
import { DateRangePickerValue } from "../../../app/ee/admin/performance/DateRangeSelector";
|
||||
import { Tag } from "@/lib/types";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
export interface SourceSelectorProps {
|
||||
timeRange: DateRangePickerValue | null;
|
||||
setTimeRange: React.Dispatch<
|
||||
React.SetStateAction<DateRangePickerValue | null>
|
||||
>;
|
||||
selectedSources: SourceMetadata[];
|
||||
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
|
||||
selectedDocumentSets: string[];
|
||||
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedTags: Tag[];
|
||||
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
|
||||
existingSources: string[]; // e.g. list of internalName that exist
|
||||
availableDocumentSets: { name: string }[];
|
||||
availableTags: Tag[];
|
||||
}
|
||||
|
||||
export function HorizontalSourceSelector({
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
selectedSources,
|
||||
setSelectedSources,
|
||||
selectedDocumentSets,
|
||||
setSelectedDocumentSets,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
existingSources,
|
||||
availableDocumentSets,
|
||||
availableTags,
|
||||
}: SourceSelectorProps) {
|
||||
const handleSourceSelect = (source: SourceMetadata) => {
|
||||
setSelectedSources((prev: SourceMetadata[]) => {
|
||||
if (prev.map((s) => s.internalName).includes(source.internalName)) {
|
||||
return prev.filter((s) => s.internalName !== source.internalName);
|
||||
} else {
|
||||
return [...prev, source];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDocumentSetSelect = (documentSetName: string) => {
|
||||
setSelectedDocumentSets((prev: string[]) => {
|
||||
if (prev.includes(documentSetName)) {
|
||||
return prev.filter((s) => s !== documentSetName);
|
||||
} else {
|
||||
return [...prev, documentSetName];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: Tag) => {
|
||||
setSelectedTags((prev: Tag[]) => {
|
||||
if (
|
||||
prev.some(
|
||||
(t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value
|
||||
)
|
||||
) {
|
||||
return prev.filter(
|
||||
(t) => !(t.tag_key === tag.tag_key && t.tag_value === tag.tag_value)
|
||||
);
|
||||
} else {
|
||||
return [...prev, tag];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const resetSources = () => {
|
||||
setSelectedSources([]);
|
||||
};
|
||||
|
||||
const resetDocuments = () => {
|
||||
setSelectedDocumentSets([]);
|
||||
};
|
||||
|
||||
const resetTags = () => {
|
||||
setSelectedTags([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center space-x-2">
|
||||
{/* Date Range Popover */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="
|
||||
flex items-center space-x-1 border
|
||||
border-border rounded-lg px-3 py-1.5
|
||||
hover:bg-hover text-sm cursor-pointer
|
||||
bg-background-search-filter
|
||||
"
|
||||
>
|
||||
<FiCalendar size={14} />
|
||||
<span>
|
||||
{timeRange?.from
|
||||
? getDateRangeString(timeRange.from, timeRange.to)
|
||||
: "Date Range"}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="bg-background-search-filter border border-border rounded-md z-[200] p-2"
|
||||
align="start"
|
||||
>
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={
|
||||
timeRange
|
||||
? { from: new Date(timeRange.from), to: new Date(timeRange.to) }
|
||||
: undefined
|
||||
}
|
||||
onSelect={(daterange) => {
|
||||
const initialDate = daterange?.from || new Date();
|
||||
const endDate = daterange?.to || new Date();
|
||||
setTimeRange({
|
||||
from: initialDate,
|
||||
to: endDate,
|
||||
selectValue: timeRange?.selectValue || "",
|
||||
});
|
||||
}}
|
||||
className="rounded-md"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Sources Popover */}
|
||||
{existingSources.length > 0 && (
|
||||
<FilterDropdown
|
||||
icon={<FiMap size={14} />}
|
||||
backgroundColor="bg-background-search-filter"
|
||||
dropdownColor="bg-background-search-filter-dropdown"
|
||||
dropdownWidth="w-40"
|
||||
defaultDisplay="Sources"
|
||||
resetValues={resetSources}
|
||||
width="w-fit"
|
||||
options={listSourceMetadata()
|
||||
.filter((source) => existingSources.includes(source.internalName))
|
||||
.map((source) => ({
|
||||
icon: (
|
||||
<SourceIcon sourceType={source.internalName} iconSize={14} />
|
||||
),
|
||||
key: source.internalName,
|
||||
display: (
|
||||
<span className="flex items-center space-x-2">
|
||||
<span>{source.displayName}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
optionClassName="truncate w-full break-all"
|
||||
selected={selectedSources.map((src) => src.internalName)}
|
||||
handleSelect={(option) => {
|
||||
const s = listSourceMetadata().find(
|
||||
(m) => m.internalName === option.key
|
||||
);
|
||||
if (s) handleSourceSelect(s);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Document Sets Popover */}
|
||||
{availableDocumentSets.length > 0 && (
|
||||
<FilterDropdown
|
||||
icon={<FiBook size={14} />}
|
||||
backgroundColor="bg-background-search-filter"
|
||||
dropdownColor="bg-background-search-filter-dropdown"
|
||||
dropdownWidth="w-40"
|
||||
defaultDisplay="Sets"
|
||||
resetValues={resetDocuments}
|
||||
width="w-fit"
|
||||
options={availableDocumentSets.map((docSet) => ({
|
||||
key: docSet.name,
|
||||
display: <>{docSet.name}</>,
|
||||
}))}
|
||||
optionClassName="truncate w-full break-all"
|
||||
selected={selectedDocumentSets}
|
||||
handleSelect={(option) => handleDocumentSetSelect(option.key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tags Popover */}
|
||||
{availableTags.length > 0 && (
|
||||
<FilterDropdown
|
||||
icon={<FiTag size={14} />}
|
||||
backgroundColor="bg-background-search-filter"
|
||||
dropdownColor="bg-background-search-filter-dropdown"
|
||||
dropdownWidth="w-64"
|
||||
defaultDisplay="Tags"
|
||||
resetValues={resetTags}
|
||||
width="w-fit"
|
||||
options={availableTags.map((tag) => ({
|
||||
key: `${tag.tag_key}=${tag.tag_value}`,
|
||||
display: (
|
||||
<span className="text-sm">
|
||||
{tag.tag_key}
|
||||
<b>=</b>
|
||||
{tag.tag_value}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
optionClassName="truncate w-full break-all"
|
||||
selected={selectedTags.map((t) => `${t.tag_key}=${t.tag_value}`)}
|
||||
handleSelect={(option) => {
|
||||
const [tKey, tValue] = option.key.split("=");
|
||||
const foundTag = availableTags.find(
|
||||
(tg) => tg.tag_key === tKey && tg.tag_value === tValue
|
||||
);
|
||||
if (foundTag) handleTagSelect(foundTag);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,14 +12,12 @@ import { openDocument } from "@/lib/search/utils";
|
||||
|
||||
export function Citation({
|
||||
children,
|
||||
link,
|
||||
document,
|
||||
index,
|
||||
updatePresentingDocument,
|
||||
icon,
|
||||
url,
|
||||
}: {
|
||||
link?: string;
|
||||
children?: JSX.Element | string | null | ReactNode;
|
||||
index?: number;
|
||||
updatePresentingDocument: (document: OnyxDocument) => void;
|
||||
|
||||
@@ -48,6 +48,7 @@ export const CustomTooltip = ({
|
||||
delay = 500,
|
||||
position = "bottom",
|
||||
disabled = false,
|
||||
className,
|
||||
}: {
|
||||
medium?: boolean;
|
||||
content: string | ReactNode;
|
||||
@@ -61,6 +62,7 @@ export const CustomTooltip = ({
|
||||
citation?: boolean;
|
||||
position?: "top" | "bottom";
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
|
||||
@@ -115,7 +117,7 @@ export const CustomTooltip = ({
|
||||
<>
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className="relative inline-block"
|
||||
className={`relative inline-block ${className}`}
|
||||
onMouseEnter={showTooltip}
|
||||
onMouseLeave={hideTooltip}
|
||||
>
|
||||
@@ -125,9 +127,11 @@ export const CustomTooltip = ({
|
||||
!disabled &&
|
||||
createPortal(
|
||||
<div
|
||||
className={`min-w-8 fixed z-[1000] ${
|
||||
citation ? "max-w-[350px]" : "w-40"
|
||||
} ${large ? (medium ? "w-88" : "w-96") : line && "max-w-64 w-auto"}
|
||||
className={`min-w-8 fixed z-[1000]
|
||||
${className}
|
||||
${citation ? "max-w-[350px]" : "w-40"} ${
|
||||
large ? (medium ? "w-88" : "w-96") : line && "max-w-64 w-auto"
|
||||
}
|
||||
transform -translate-x-1/2 text-sm
|
||||
${
|
||||
light
|
||||
|
||||
26
web/src/components/ui/label.tsx
Normal file
26
web/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
44
web/src/components/ui/radio-group.tsx
Normal file
44
web/src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import { Circle } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-neutral-200 border-neutral-900 text-neutral-900 ring-offset-white focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-800 dark:border-neutral-50 dark:text-neutral-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Circle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -7,8 +7,10 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & {
|
||||
circleClassName?: string;
|
||||
}
|
||||
>(({ circleClassName, 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-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800",
|
||||
@@ -19,7 +21,8 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 dark:bg-neutral-950"
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 dark:bg-neutral-950",
|
||||
circleClassName
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME,
|
||||
} from "@/components/resizable/constants";
|
||||
import { hasCompletedWelcomeFlowSS } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
|
||||
import { NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN } from "../constants";
|
||||
import {
|
||||
NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN,
|
||||
NEXT_PUBLIC_ENABLE_CHROME_EXTENSION,
|
||||
} from "../constants";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface FetchChatDataResult {
|
||||
@@ -88,7 +91,7 @@ export async function fetchChatData(searchParams: {
|
||||
const authDisabled = authTypeMetadata?.authType === "disabled";
|
||||
|
||||
// TODO Validate need
|
||||
if (!authDisabled && !user && !authTypeMetadata?.anonymousUserEnabled) {
|
||||
if (!authDisabled && !user) {
|
||||
const headersList = await headers();
|
||||
const fullUrl = headersList.get("x-url") || "/chat";
|
||||
const searchParamsString = new URLSearchParams(
|
||||
@@ -98,7 +101,9 @@ export async function fetchChatData(searchParams: {
|
||||
? `${fullUrl}?${searchParamsString}`
|
||||
: fullUrl;
|
||||
|
||||
return redirect(`/auth/login?next=${encodeURIComponent(redirectUrl)}`);
|
||||
if (!NEXT_PUBLIC_ENABLE_CHROME_EXTENSION) {
|
||||
return redirect(`/auth/login?next=${encodeURIComponent(redirectUrl)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (user && !user.is_verified && authTypeMetadata?.requiresVerification) {
|
||||
|
||||
@@ -91,6 +91,7 @@ export async function fetchSomeChatData(
|
||||
const authDisabled = authTypeMetadata?.authType === "disabled";
|
||||
|
||||
let user: User | null = null;
|
||||
|
||||
if (fetchOptions.includes("user")) {
|
||||
user = results.shift();
|
||||
if (!authDisabled && !user) {
|
||||
|
||||
@@ -15,4 +15,5 @@ export const autoSyncConfigBySource: Record<
|
||||
google_drive: {},
|
||||
gmail: {},
|
||||
slack: {},
|
||||
salesforce: {},
|
||||
};
|
||||
|
||||
@@ -86,3 +86,9 @@ export const NEXT_PUBLIC_TEST_ENV =
|
||||
|
||||
export const NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED =
|
||||
process.env.NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED?.toLowerCase() === "true";
|
||||
|
||||
export const NEXT_PUBLIC_ENABLE_CHROME_EXTENSION =
|
||||
process.env.NEXT_PUBLIC_ENABLE_CHROME_EXTENSION?.toLowerCase() === "true";
|
||||
|
||||
export const NEXT_PUBLIC_CLOUD_DOMAIN =
|
||||
process.env.NEXT_PUBLIC_CLOUD_DOMAIN || "http://127.0.0.1:3000";
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export const useNightTime = () => {
|
||||
const [isNight, setIsNight] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkNightTime = () => {
|
||||
const currentHour = new Date().getHours();
|
||||
setIsNight(currentHour >= 18 || currentHour < 6);
|
||||
};
|
||||
|
||||
checkNightTime();
|
||||
const interval = setInterval(checkNightTime, 60000); // Check every minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return { isNight };
|
||||
};
|
||||
|
||||
export function getXDaysAgo(daysAgo: number) {
|
||||
const today = new Date();
|
||||
|
||||
17
web/src/lib/extension/utils.ts
Normal file
17
web/src/lib/extension/utils.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useEffect } from "react";
|
||||
export function sendSetDefaultNewTabMessage(value: boolean) {
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage({ type: "SET_DEFAULT_NEW_TAB", value }, "*");
|
||||
}
|
||||
}
|
||||
|
||||
export const sendMessageToParent = () => {
|
||||
if (typeof window !== "undefined" && window.parent) {
|
||||
window.parent.postMessage({ type: "ONYX_APP_LOADED" }, "*");
|
||||
}
|
||||
};
|
||||
export const useSendMessageToParent = () => {
|
||||
useEffect(() => {
|
||||
sendMessageToParent();
|
||||
}, []);
|
||||
};
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
DocumentBoostStatus,
|
||||
Tag,
|
||||
UserGroup,
|
||||
ValidSources,
|
||||
} from "@/lib/types";
|
||||
import useSWR, { mutate, useSWRConfig } from "swr";
|
||||
import { errorHandlingFetcher } from "./fetcher";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
|
||||
import { SourceMetadata } from "./search/interfaces";
|
||||
import { Filters, SourceMetadata } from "./search/interfaces";
|
||||
import { destructureValue, structureValue } from "./llm/utils";
|
||||
import { ChatSession } from "@/app/chat/interfaces";
|
||||
import { AllUsersResponse } from "./types";
|
||||
@@ -22,6 +23,8 @@ import {
|
||||
LLMProviderDescriptor,
|
||||
} from "@/app/admin/configuration/llm/interfaces";
|
||||
import { isAnthropic } from "@/app/admin/configuration/llm/interfaces";
|
||||
import { getSourceMetadata } from "./sources";
|
||||
import { buildFilters } from "./search/utils";
|
||||
|
||||
const CREDENTIAL_URL = "/api/manage/admin/credential";
|
||||
|
||||
@@ -120,6 +123,13 @@ export interface FilterManager {
|
||||
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedTags: Tag[];
|
||||
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
|
||||
getFilterString: () => string;
|
||||
buildFiltersFromQueryString: (
|
||||
filterString: string,
|
||||
availableSources: ValidSources[],
|
||||
availableDocumentSets: string[],
|
||||
availableTags: Tag[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export function useFilters(): FilterManager {
|
||||
@@ -130,6 +140,97 @@ export function useFilters(): FilterManager {
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
|
||||
|
||||
const getFilterString = () => {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (timeRange) {
|
||||
params.set("from", timeRange.from.toISOString());
|
||||
params.set("to", timeRange.to.toISOString());
|
||||
}
|
||||
|
||||
if (selectedSources.length > 0) {
|
||||
const sourcesParam = selectedSources
|
||||
.map((source) => encodeURIComponent(source.internalName))
|
||||
.join(",");
|
||||
params.set("sources", sourcesParam);
|
||||
}
|
||||
|
||||
if (selectedDocumentSets.length > 0) {
|
||||
const docSetsParam = selectedDocumentSets
|
||||
.map((ds) => encodeURIComponent(ds))
|
||||
.join(",");
|
||||
params.set("documentSets", docSetsParam);
|
||||
}
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
const tagsParam = selectedTags
|
||||
.map((tag) => encodeURIComponent(tag.tag_value))
|
||||
.join(",");
|
||||
params.set("tags", tagsParam);
|
||||
}
|
||||
|
||||
const queryString = params.toString();
|
||||
return queryString ? `&${queryString}` : "";
|
||||
};
|
||||
|
||||
function buildFiltersFromQueryString(
|
||||
filterString: string,
|
||||
availableSources: ValidSources[],
|
||||
availableDocumentSets: string[],
|
||||
availableTags: Tag[]
|
||||
): void {
|
||||
const params = new URLSearchParams(filterString);
|
||||
|
||||
// Parse the "from" parameter as a DateRangePickerValue
|
||||
let newTimeRange: DateRangePickerValue | null = null;
|
||||
const fromParam = params.get("from");
|
||||
const toParam = params.get("to");
|
||||
if (fromParam && toParam) {
|
||||
const fromDate = new Date(fromParam);
|
||||
const toDate = new Date(toParam);
|
||||
if (!isNaN(fromDate.getTime()) && !isNaN(toDate.getTime())) {
|
||||
newTimeRange = { from: fromDate, to: toDate, selectValue: "" };
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sources
|
||||
const availableSourcesMetadata = availableSources.map(getSourceMetadata);
|
||||
let newSelectedSources: SourceMetadata[] = [];
|
||||
const sourcesParam = params.get("sources");
|
||||
if (sourcesParam) {
|
||||
const sourceNames = sourcesParam.split(",").map(decodeURIComponent);
|
||||
newSelectedSources = availableSourcesMetadata.filter((source) =>
|
||||
sourceNames.includes(source.internalName)
|
||||
);
|
||||
}
|
||||
|
||||
// Parse document sets
|
||||
let newSelectedDocSets: string[] = [];
|
||||
const docSetsParam = params.get("documentSets");
|
||||
if (docSetsParam) {
|
||||
const docSetNames = docSetsParam.split(",").map(decodeURIComponent);
|
||||
newSelectedDocSets = availableDocumentSets.filter((ds) =>
|
||||
docSetNames.includes(ds)
|
||||
);
|
||||
}
|
||||
|
||||
// Parse tags
|
||||
let newSelectedTags: Tag[] = [];
|
||||
const tagsParam = params.get("tags");
|
||||
if (tagsParam) {
|
||||
const tagValues = tagsParam.split(",").map(decodeURIComponent);
|
||||
newSelectedTags = availableTags.filter((tag) =>
|
||||
tagValues.includes(tag.tag_value)
|
||||
);
|
||||
}
|
||||
|
||||
// Update filter manager's values instead of returning
|
||||
setTimeRange(newTimeRange);
|
||||
setSelectedSources(newSelectedSources);
|
||||
setSelectedDocumentSets(newSelectedDocSets);
|
||||
setSelectedTags(newSelectedTags);
|
||||
}
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
setTimeRange,
|
||||
@@ -139,6 +240,8 @@ export function useFilters(): FilterManager {
|
||||
setSelectedDocumentSets,
|
||||
selectedTags,
|
||||
setSelectedTags,
|
||||
getFilterString,
|
||||
buildFiltersFromQueryString,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tag, ValidSources } from "../types";
|
||||
import { getSourceMetadata } from "../sources";
|
||||
import { DocumentSet, Tag, ValidSources } from "../types";
|
||||
import {
|
||||
Filters,
|
||||
LoadedOnyxDocument,
|
||||
|
||||
@@ -388,3 +388,26 @@ export function getSourcesForPersona(persona: Persona): ValidSources[] {
|
||||
});
|
||||
return personaSources;
|
||||
}
|
||||
|
||||
export async function fetchTitleFromUrl(url: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
// If the remote site has no CORS header, this may fail in the browser
|
||||
mode: "cors",
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Non-200 response, treat as a failure
|
||||
return null;
|
||||
}
|
||||
const html = await response.text();
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
// If the site has <title>My Demo Page</title>, we retrieve "My Demo Page"
|
||||
const pageTitle = doc.querySelector("title")?.innerText.trim() ?? null;
|
||||
return pageTitle;
|
||||
} catch (error) {
|
||||
console.error("Error fetching page title:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,6 +343,7 @@ export const validAutoSyncSources = [
|
||||
ValidSources.GoogleDrive,
|
||||
ValidSources.Gmail,
|
||||
ValidSources.Slack,
|
||||
ValidSources.Salesforce,
|
||||
] as const;
|
||||
|
||||
// Create a type from the array elements
|
||||
|
||||
Reference in New Issue
Block a user