Compare commits

..

7 Commits
improve ... old

Author SHA1 Message Date
pablodanswer
0d52160a6f v2 2025-01-07 14:14:56 -08:00
pablodanswer
bbe9e9db74 add chrome extension
minor clean up

additional handling

post rebase fixes

nit

quick bump

finalize

minor cleanup

organizational

Revert changes in backend directory

Revert changes in deployment directory

push misc changes

improve shortcut display + general nrf page layout

minor clean up

quick nit

update chrome

k

build fix

k

update

k
2025-01-07 14:14:45 -08:00
rkuo-danswer
7cd76ec404 comment out the per doc sync hack (#3620)
* comment out the per doc sync hack

* fix commented code

---------

Co-authored-by: Richard Kuo (Danswer) <rkuo@onyx.app>
2025-01-07 19:44:15 +00:00
pablonyx
5b5c1166ca Async Redis (#3618)
* k

* update configs for clarity

* typing

* update
2025-01-07 19:34:57 +00:00
pablonyx
d9e9c6973d Multitenant anonymous (#3595)
* anonymous users for multi tenant setting

* nit

* k
2025-01-07 02:57:20 +00:00
pablonyx
91903141cd Built in tool cache with tool call id (#3617)
* k

* improved

* k

* nit

* nit

* nit
2025-01-07 01:03:52 +00:00
hagen-danswer
e329b63b89 Added Permission Syncing for Salesforce (#3551)
* Added Permission Syncing for Salesforce

* cleanup

* updated connector doc conversion

* finished salesforce permission syncing

* fixed connector to batch Salesforce queries

* tests!

* k

* Added error handling and check for ee and sync type for postprocessing

* comments

* minor touchups

* tested to work!

* done

* my pie

* lil cleanup

* minor comment
2025-01-07 00:37:03 +00:00
90 changed files with 4410 additions and 449 deletions

View File

@@ -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")

View File

@@ -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(

View File

@@ -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"])

View File

@@ -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"

View File

@@ -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)

View 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

View File

@@ -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}

View 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]

View File

@@ -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
)

View File

@@ -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)

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

View File

@@ -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)

View File

@@ -44,3 +44,7 @@ class TenantCreationPayload(BaseModel):
class TenantDeletionPayload(BaseModel):
tenant_id: str
email: str
class AnonymousUserPath(BaseModel):
anonymous_user_path: str | None

View File

@@ -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)
)

View File

@@ -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}"
)

View File

@@ -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:

View File

@@ -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))

View File

@@ -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"]

View File

@@ -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

View File

@@ -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):

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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):

View File

@@ -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:

View File

@@ -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,

View File

@@ -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

View File

@@ -46,6 +46,8 @@ PUBLIC_ENDPOINT_SPECS = [
# oauth
("/auth/oauth/authorize", {"GET"}),
("/auth/oauth/callback", {"GET"}),
# anonymous user on cloud
("/tenants/anonymous-user", {"POST"}),
]

View File

@@ -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)

View File

@@ -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,
)

View File

@@ -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"""

View File

@@ -50,3 +50,4 @@ class Settings(BaseModel):
class UserSettings(Settings):
notifications: list[Notification]
needs_reindexing: bool
tenant_id: str | None = None

View File

@@ -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",

View File

@@ -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)

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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;

View 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 &quot;/anonymous/&quot;.
</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>
);
}

View File

@@ -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>

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

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

View 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&apos;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>
);
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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} />
)}

View File

@@ -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();
}}

View File

@@ -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}
/>
)}
/>
);

View File

@@ -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 ${

View File

@@ -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">

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

View 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>
</>
);
}

View File

@@ -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}
>

View File

@@ -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">

View File

@@ -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);
}}

View File

@@ -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&apos;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>
);
}

View 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&apos;ll see your browser&apos;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>
);
}

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

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

View File

@@ -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>
// </>
// );
}

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

View 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
View File

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

View File

@@ -33,6 +33,7 @@ export function AssistantIcon({
return (
<CustomTooltip
className="hidden lg:block"
disabled={disableToolip || !assistant.description}
showTick
line

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

View File

@@ -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, {

View File

@@ -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]);

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

View 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&apos;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>
);
};

View File

@@ -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>

View File

@@ -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">

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

View File

@@ -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;

View File

@@ -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

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

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

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -15,4 +15,5 @@ export const autoSyncConfigBySource: Record<
google_drive: {},
gmail: {},
slack: {},
salesforce: {},
};

View File

@@ -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";

View File

@@ -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();

View 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();
}, []);
};

View File

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

View File

@@ -1,4 +1,5 @@
import { Tag, ValidSources } from "../types";
import { getSourceMetadata } from "../sources";
import { DocumentSet, Tag, ValidSources } from "../types";
import {
Filters,
LoadedOnyxDocument,

View File

@@ -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;
}
}

View File

@@ -343,6 +343,7 @@ export const validAutoSyncSources = [
ValidSources.GoogleDrive,
ValidSources.Gmail,
ValidSources.Slack,
ValidSources.Salesforce,
] as const;
// Create a type from the array elements