Compare commits

..

6 Commits

Author SHA1 Message Date
pablodanswer
92be55c9d7 k 2024-12-01 17:54:22 -08:00
pablodanswer
dc8fa4c3cb update workflows 2024-12-01 17:50:52 -08:00
pablodanswer
c5aa64e3fb fix tests 2024-12-01 17:47:58 -08:00
pablodanswer
f4dea0821f k 2024-12-01 17:17:58 -08:00
pablodanswer
1ed4002902 nits 2024-12-01 17:11:37 -08:00
pablodanswer
952893d7f0 tests fixed 2024-12-01 17:10:05 -08:00
111 changed files with 3803 additions and 4597 deletions

111
.github/workflows/multi-tenant-tests.yml vendored Normal file
View File

@@ -0,0 +1,111 @@
name: Run Multi-Tenant Integration Tests
on:
workflow_dispatch:
pull_request:
branches:
- main
- "release/**"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
CONFLUENCE_TEST_SPACE_URL: ${{ secrets.CONFLUENCE_TEST_SPACE_URL }}
CONFLUENCE_USER_NAME: ${{ secrets.CONFLUENCE_USER_NAME }}
CONFLUENCE_ACCESS_TOKEN: ${{ secrets.CONFLUENCE_ACCESS_TOKEN }}
jobs:
multi-tenant-integration-tests:
runs-on:
[runs-on, runner=8cpu-linux-x64, ram=16, "run-id=${{ github.run_id }}"]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Pull Required Docker Images
run: |
docker pull danswer/danswer-backend:latest
docker tag danswer/danswer-backend:latest danswer/danswer-backend:test
docker pull danswer/danswer-model-server:latest
docker tag danswer/danswer-model-server:latest danswer/danswer-model-server:test
docker pull danswer/danswer-web-server:latest
docker tag danswer/danswer-web-server:latest danswer/danswer-web-server:test
docker pull danswer/control-tenants-service:latest
docker tag danswer/control-tenants-service:latest danswer/control-tenants-service:test
- name: Build Integration Test Docker Image
uses: ./.github/actions/custom-build-and-push
with:
context: ./backend
file: ./backend/tests/integration/Dockerfile
platforms: linux/amd64
tags: danswer/danswer-integration:test
push: false
load: true
- name: Start Docker Containers for Multi-Tenant Tests
run: |
cd deployment/docker_compose
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
MULTI_TENANT=true \
INTEGRATION_TEST_MODE=true \
AUTH_TYPE=basic \
REQUIRE_EMAIL_VERIFICATION=false \
DISABLE_TELEMETRY=true \
IMAGE_TAG=test \
CONTROL_TENANTS_SERVICE_IMAGE=danswer/control-tenants-service:test \
docker compose -f docker-compose.dev.yml -f docker-compose.multi-tenant.yml -p danswer-stack up -d
- name: Run Multi-Tenant Integration Tests
run: |
echo "Running multi-tenant integration tests..."
docker run --rm --network danswer-stack_default \
--name test-runner \
-e POSTGRES_HOST=relational_db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=postgres \
-e VESPA_HOST=index \
-e REDIS_HOST=cache \
-e API_SERVER_HOST=api_server \
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
-e SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} \
-e TEST_WEB_HOSTNAME=test-runner \
-e AUTH_TYPE=cloud \
-e MULTI_TENANT=true \
danswer/danswer-integration:test \
/app/tests/integration/multitenant_tests
continue-on-error: true
id: run_multitenant_tests
- name: Check Multi-Tenant Test Results
run: |
if [ ${{ steps.run_multitenant_tests.outcome }} == 'failure' ]; then
echo "Integration tests failed. Exiting with error."
exit 1
else
echo "All integration tests passed successfully."
fi
- name: Stop Docker Containers
run: |
cd deployment/docker_compose
docker compose -f docker-compose.dev.yml -p danswer-stack down -v
- name: Upload Logs
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: docker-logs
path: ${{ github.workspace }}/docker-compose.log

View File

@@ -8,7 +8,7 @@ on:
pull_request:
branches:
- main
- 'release/**'
- "release/**"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -16,11 +16,12 @@ env:
CONFLUENCE_TEST_SPACE_URL: ${{ secrets.CONFLUENCE_TEST_SPACE_URL }}
CONFLUENCE_USER_NAME: ${{ secrets.CONFLUENCE_USER_NAME }}
CONFLUENCE_ACCESS_TOKEN: ${{ secrets.CONFLUENCE_ACCESS_TOKEN }}
jobs:
integration-tests:
# See https://runs-on.com/runners/linux/
runs-on: [runs-on,runner=8cpu-linux-x64,ram=16,"run-id=${{ github.run_id }}"]
runs-on:
[runs-on, runner=8cpu-linux-x64, ram=16, "run-id=${{ github.run_id }}"]
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -36,9 +37,9 @@ jobs:
# tag every docker image with "test" so that we can spin up the correct set
# of images during testing
# We don't need to build the Web Docker image since it's not yet used
# in the integration tests. We have a separate action to verify that it builds
# in the integration tests. We have a separate action to verify that it builds
# successfully.
- name: Pull Web Docker image
run: |
@@ -50,7 +51,7 @@ jobs:
# https://runs-on.com/caching/s3-cache-for-github-actions/
# https://runs-on.com/caching/docker/
# https://github.com/moby/buildkit#s3-cache-experimental
# images are built and run locally for testing purposes. Not pushed.
- name: Build Backend Docker image
uses: ./.github/actions/custom-build-and-push
@@ -75,7 +76,7 @@ jobs:
load: true
cache-from: type=s3,prefix=cache/${{ github.repository }}/integration-tests/model-server/,region=${{ env.RUNS_ON_AWS_REGION }},bucket=${{ env.RUNS_ON_S3_BUCKET_CACHE }}
cache-to: type=s3,prefix=cache/${{ github.repository }}/integration-tests/model-server/,region=${{ env.RUNS_ON_AWS_REGION }},bucket=${{ env.RUNS_ON_S3_BUCKET_CACHE }},mode=max
- name: Build integration test Docker image
uses: ./.github/actions/custom-build-and-push
with:
@@ -88,58 +89,7 @@ jobs:
cache-from: type=s3,prefix=cache/${{ github.repository }}/integration-tests/integration/,region=${{ env.RUNS_ON_AWS_REGION }},bucket=${{ env.RUNS_ON_S3_BUCKET_CACHE }}
cache-to: type=s3,prefix=cache/${{ github.repository }}/integration-tests/integration/,region=${{ env.RUNS_ON_AWS_REGION }},bucket=${{ env.RUNS_ON_S3_BUCKET_CACHE }},mode=max
# Start containers for multi-tenant tests
- name: Start Docker containers for multi-tenant tests
run: |
cd deployment/docker_compose
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
MULTI_TENANT=true \
AUTH_TYPE=basic \
REQUIRE_EMAIL_VERIFICATION=false \
DISABLE_TELEMETRY=true \
IMAGE_TAG=test \
docker compose -f docker-compose.dev.yml -p danswer-stack up -d
id: start_docker_multi_tenant
# In practice, `cloud` Auth type would require OAUTH credentials to be set.
- name: Run Multi-Tenant Integration Tests
run: |
echo "Running integration tests..."
docker run --rm --network danswer-stack_default \
--name test-runner \
-e POSTGRES_HOST=relational_db \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=postgres \
-e VESPA_HOST=index \
-e REDIS_HOST=cache \
-e API_SERVER_HOST=api_server \
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
-e SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} \
-e TEST_WEB_HOSTNAME=test-runner \
-e AUTH_TYPE=cloud \
-e MULTI_TENANT=true \
danswer/danswer-integration:test \
/app/tests/integration/multitenant_tests
continue-on-error: true
id: run_multitenant_tests
- name: Check multi-tenant test results
run: |
if [ ${{ steps.run_tests.outcome }} == 'failure' ]; then
echo "Integration tests failed. Exiting with error."
exit 1
else
echo "All integration tests passed successfully."
fi
- name: Stop multi-tenant Docker containers
run: |
cd deployment/docker_compose
docker compose -f docker-compose.dev.yml -p danswer-stack down -v
- name: Start Docker containers
- name: Start Docker containers
run: |
cd deployment/docker_compose
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
@@ -153,12 +103,12 @@ jobs:
- name: Wait for service to be ready
run: |
echo "Starting wait-for-service script..."
docker logs -f danswer-stack-api_server-1 &
start_time=$(date +%s)
timeout=300 # 5 minutes in seconds
while true; do
current_time=$(date +%s)
elapsed_time=$((current_time - start_time))
@@ -229,7 +179,7 @@ jobs:
run: |
cd deployment/docker_compose
docker compose -f docker-compose.dev.yml -p danswer-stack down -v
- name: Upload logs
if: success() || failure()
uses: actions/upload-artifact@v4

View File

@@ -24,8 +24,6 @@ env:
GOOGLE_DRIVE_OAUTH_CREDENTIALS_JSON_STR: ${{ secrets.GOOGLE_DRIVE_OAUTH_CREDENTIALS_JSON_STR }}
GOOGLE_GMAIL_SERVICE_ACCOUNT_JSON_STR: ${{ secrets.GOOGLE_GMAIL_SERVICE_ACCOUNT_JSON_STR }}
GOOGLE_GMAIL_OAUTH_CREDENTIALS_JSON_STR: ${{ secrets.GOOGLE_GMAIL_OAUTH_CREDENTIALS_JSON_STR }}
# Slab
SLAB_BOT_TOKEN: ${{ secrets.SLAB_BOT_TOKEN }}
jobs:
connectors-check:

View File

@@ -73,7 +73,6 @@ RUN apt-get update && \
rm -rf /var/lib/apt/lists/* && \
rm -f /usr/local/lib/python3.11/site-packages/tornado/test/test.key
# Pre-downloading models for setups with limited egress
RUN python -c "from tokenizers import Tokenizer; \
Tokenizer.from_pretrained('nomic-ai/nomic-embed-text-v1')"

View File

@@ -1,27 +0,0 @@
"""add auto scroll to user model
Revision ID: a8c2065484e6
Revises: abe7378b8217
Create Date: 2024-11-22 17:34:09.690295
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a8c2065484e6"
down_revision = "abe7378b8217"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"user",
sa.Column("auto_scroll", sa.Boolean(), nullable=True, server_default=None),
)
def downgrade() -> None:
op.drop_column("user", "auto_scroll")

View File

@@ -23,9 +23,7 @@ def load_no_auth_user_preferences(store: KeyValueStore) -> UserPreferences:
)
return UserPreferences(**preferences_data)
except KvKeyNotFoundError:
return UserPreferences(
chosen_assistants=None, default_model=None, auto_scroll=True
)
return UserPreferences(chosen_assistants=None, default_model=None)
def fetch_no_auth_user(store: KeyValueStore) -> UserInfo:

View File

@@ -605,7 +605,6 @@ def stream_chat_message_objects(
additional_headers=custom_tool_additional_headers,
),
)
tools: list[Tool] = []
for tool_list in tool_dict.values():
tools.extend(tool_list)

View File

@@ -493,6 +493,10 @@ CONTROL_PLANE_API_BASE_URL = os.environ.get(
# JWT configuration
JWT_ALGORITHM = "HS256"
# Super Users
SUPER_USERS = json.loads(os.environ.get("SUPER_USERS", '["pablo@danswer.ai"]'))
SUPER_CLOUD_API_KEY = os.environ.get("SUPER_CLOUD_API_KEY", "api_key")
#####
# API Key Configs

View File

@@ -11,16 +11,11 @@ Connectors come in 3 different flows:
- Load Connector:
- Bulk indexes documents to reflect a point in time. This type of connector generally works by either pulling all
documents via a connector's API or loads the documents from some sort of a dump file.
- Poll Connector:
- Poll connector:
- Incrementally updates documents based on a provided time range. It is used by the background job to pull the latest
changes and additions since the last round of polling. This connector helps keep the document index up to date
without needing to fetch/embed/index every document which would be too slow to do frequently on large sets of
documents.
- Slim Connector:
- This connector should be a lighter weight method of checking all documents in the source to see if they still exist.
- This connector should be identical to the Poll or Load Connector except that it only fetches the IDs of the documents, not the documents themselves.
- This is used by our pruning job which removes old documents from the index.
- The optional start and end datetimes can be ignored.
- Event Based connectors:
- Connectors that listen to events and update documents accordingly.
- Currently not used by the background job, this exists for future design purposes.
@@ -31,14 +26,8 @@ Refer to [interfaces.py](https://github.com/danswer-ai/danswer/blob/main/backend
and this first contributor created Pull Request for a new connector (Shoutout to Dan Brown):
[Reference Pull Request](https://github.com/danswer-ai/danswer/pull/139)
For implementing a Slim Connector, refer to the comments in this PR:
[Slim Connector PR](https://github.com/danswer-ai/danswer/pull/3303/files)
All new connectors should have tests added to the `backend/tests/daily/connectors` directory. Refer to the above PR for an example of adding tests for a new connector.
#### Implementing the new Connector
The connector must subclass one or more of LoadConnector, PollConnector, SlimConnector, or EventConnector.
The connector must subclass one or more of LoadConnector, PollConnector, or EventConnector.
The `__init__` should take arguments for configuring what documents the connector will and where it finds those
documents. For example, if you have a wiki site, it may include the configuration for the team, topic, folder, etc. of

View File

@@ -12,15 +12,12 @@ from dateutil import parser
from danswer.configs.app_configs import INDEX_BATCH_SIZE
from danswer.configs.constants import DocumentSource
from danswer.connectors.interfaces import GenerateDocumentsOutput
from danswer.connectors.interfaces import GenerateSlimDocumentOutput
from danswer.connectors.interfaces import LoadConnector
from danswer.connectors.interfaces import PollConnector
from danswer.connectors.interfaces import SecondsSinceUnixEpoch
from danswer.connectors.interfaces import SlimConnector
from danswer.connectors.models import ConnectorMissingCredentialError
from danswer.connectors.models import Document
from danswer.connectors.models import Section
from danswer.connectors.models import SlimDocument
from danswer.utils.logger import setup_logger
@@ -31,8 +28,6 @@ logger = setup_logger()
SLAB_GRAPHQL_MAX_TRIES = 10
SLAB_API_URL = "https://api.slab.com/v1/graphql"
_SLIM_BATCH_SIZE = 1000
def run_graphql_request(
graphql_query: dict, bot_token: str, max_tries: int = SLAB_GRAPHQL_MAX_TRIES
@@ -163,26 +158,21 @@ def get_slab_url_from_title_id(base_url: str, title: str, page_id: str) -> str:
return urljoin(urljoin(base_url, "posts/"), url_id)
class SlabConnector(LoadConnector, PollConnector, SlimConnector):
class SlabConnector(LoadConnector, PollConnector):
def __init__(
self,
base_url: str,
batch_size: int = INDEX_BATCH_SIZE,
slab_bot_token: str | None = None,
) -> None:
self.base_url = base_url
self.batch_size = batch_size
self._slab_bot_token: str | None = None
self.slab_bot_token = slab_bot_token
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
self._slab_bot_token = credentials["slab_bot_token"]
self.slab_bot_token = credentials["slab_bot_token"]
return None
@property
def slab_bot_token(self) -> str:
if self._slab_bot_token is None:
raise ConnectorMissingCredentialError("Slab")
return self._slab_bot_token
def _iterate_posts(
self, time_filter: Callable[[datetime], bool] | None = None
) -> GenerateDocumentsOutput:
@@ -237,21 +227,3 @@ class SlabConnector(LoadConnector, PollConnector, SlimConnector):
yield from self._iterate_posts(
time_filter=lambda t: start_time <= t <= end_time
)
def retrieve_all_slim_documents(
self,
start: SecondsSinceUnixEpoch | None = None,
end: SecondsSinceUnixEpoch | None = None,
) -> GenerateSlimDocumentOutput:
slim_doc_batch: list[SlimDocument] = []
for post_id in get_all_post_ids(self.slab_bot_token):
slim_doc_batch.append(
SlimDocument(
id=post_id,
)
)
if len(slim_doc_batch) >= _SLIM_BATCH_SIZE:
yield slim_doc_batch
slim_doc_batch = []
if slim_doc_batch:
yield slim_doc_batch

View File

@@ -126,7 +126,6 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
# if specified, controls the assistants that are shown to the user + their order
# if not specified, all assistants are shown
auto_scroll: Mapped[bool] = mapped_column(Boolean, default=True)
chosen_assistants: Mapped[list[int] | None] = mapped_column(
postgresql.JSONB(), nullable=True, default=None
)

View File

@@ -59,12 +59,6 @@ class FileStore(ABC):
Contents of the file and metadata dict
"""
@abstractmethod
def read_file_record(self, file_name: str) -> PGFileStore:
"""
Read the file record by the name
"""
@abstractmethod
def delete_file(self, file_name: str) -> None:
"""

View File

@@ -33,6 +33,7 @@ from danswer.server.documents.models import ConnectorBase
from danswer.utils.logger import setup_logger
from danswer.utils.retry_wrapper import retry_builder
from danswer.utils.variable_functionality import fetch_versioned_implementation
from ee.danswer.configs.app_configs import INTEGRATION_TEST_MODE
logger = setup_logger()
@@ -127,6 +128,9 @@ def seed_initial_documents(
- Indexing the documents into Vespa
- Create a fake index attempt with fake times
"""
if INTEGRATION_TEST_MODE:
return
logger.info("Seeding initial documents")
kv_store = get_kv_store()

View File

@@ -5,7 +5,7 @@ personas:
# this is for DanswerBot to use when tagged in a non-configured channel
# Careful setting specific IDs, this won't autoincrement the next ID value for postgres
- id: 0
name: "Search"
name: "Knowledge"
description: >
Assistant with access to documents from your Connected Sources.
# Default Prompt objects attached to the persona, see prompts.yaml

View File

@@ -45,7 +45,6 @@ class UserPreferences(BaseModel):
visible_assistants: list[int] = []
recent_assistants: list[int] | None = None
default_model: str | None = None
auto_scroll: bool | None = None
class UserInfo(BaseModel):
@@ -80,7 +79,6 @@ class UserInfo(BaseModel):
role=user.role,
preferences=(
UserPreferences(
auto_scroll=user.auto_scroll,
chosen_assistants=user.chosen_assistants,
default_model=user.default_model,
hidden_assistants=user.hidden_assistants,
@@ -130,10 +128,6 @@ class HiddenUpdateRequest(BaseModel):
hidden: bool
class AutoScrollRequest(BaseModel):
auto_scroll: bool | None
class SlackBotCreationRequest(BaseModel):
name: str
enabled: bool

View File

@@ -34,6 +34,7 @@ from danswer.auth.users import optional_user
from danswer.configs.app_configs import AUTH_TYPE
from danswer.configs.app_configs import ENABLE_EMAIL_INVITES
from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
from danswer.configs.app_configs import SUPER_USERS
from danswer.configs.app_configs import VALID_EMAIL_DOMAINS
from danswer.configs.constants import AuthType
from danswer.db.api_key import is_api_key_email_address
@@ -51,7 +52,6 @@ from danswer.db.users import list_users
from danswer.db.users import validate_user_role_update
from danswer.key_value_store.factory import get_kv_store
from danswer.server.manage.models import AllUsersResponse
from danswer.server.manage.models import AutoScrollRequest
from danswer.server.manage.models import UserByEmail
from danswer.server.manage.models import UserInfo
from danswer.server.manage.models import UserPreferences
@@ -63,7 +63,6 @@ from danswer.server.models import MinimalUserSnapshot
from danswer.server.utils import send_user_email_invite
from danswer.utils.logger import setup_logger
from danswer.utils.variable_functionality import fetch_ee_implementation_or_noop
from ee.danswer.configs.app_configs import SUPER_USERS
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
@@ -498,6 +497,7 @@ def verify_user_logged_in(
return fetch_no_auth_user(store)
raise BasicAuthenticationError(detail="User Not Authenticated")
if user.oidc_expiry and user.oidc_expiry < datetime.now(timezone.utc):
raise BasicAuthenticationError(
detail="Access denied. User's OIDC token has expired.",
@@ -581,30 +581,6 @@ def update_user_recent_assistants(
db_session.commit()
@router.patch("/auto-scroll")
def update_user_auto_scroll(
request: AutoScrollRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
if user is None:
if AUTH_TYPE == AuthType.DISABLED:
store = get_kv_store()
no_auth_user = fetch_no_auth_user(store)
no_auth_user.preferences.auto_scroll = request.auto_scroll
set_no_auth_user_preferences(store, no_auth_user.preferences)
return
else:
raise RuntimeError("This should never happen")
db_session.execute(
update(User)
.where(User.id == user.id) # type: ignore
.values(auto_scroll=request.auto_scroll)
)
db_session.commit()
@router.patch("/user/default-model")
def update_user_default_model(
request: ChosenDefaultModelRequest,

View File

@@ -707,18 +707,14 @@ def upload_files_for_chat(
}
@router.get("/file/{file_id:path}")
@router.get("/file/{file_id}")
def fetch_chat_file(
file_id: str,
db_session: Session = Depends(get_session),
_: User | None = Depends(current_user),
) -> Response:
file_store = get_default_file_store(db_session)
file_record = file_store.read_file_record(file_id)
if not file_record:
raise HTTPException(status_code=404, detail="File not found")
media_type = file_record.file_type
file_io = file_store.read_file(file_id, mode="b")
return StreamingResponse(file_io, media_type=media_type)
# NOTE: specifying "image/jpeg" here, but it still works for pngs
# TODO: do this properly
return Response(content=file_io.read(), media_type="image/jpeg")

View File

@@ -79,7 +79,6 @@ class CreateChatMessageRequest(ChunkContext):
message: str
# Files that we should attach to this message
file_descriptors: list[FileDescriptor]
# If no prompt provided, uses the largest prompt of the chat session
# but really this should be explicitly specified, only in the simplified APIs is this inferred
# Use prompt_id 0 to use the system default prompt which is Answer-Question

View File

@@ -2,6 +2,7 @@ from typing import cast
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
@@ -37,6 +38,10 @@ basic_router = APIRouter(prefix="/settings")
def put_settings(
settings: Settings, _: User | None = Depends(current_admin_user)
) -> None:
try:
settings.check_validity()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
store_settings(settings)

View File

@@ -41,10 +41,33 @@ class Notification(BaseModel):
class Settings(BaseModel):
"""General settings"""
chat_page_enabled: bool = True
search_page_enabled: bool = True
default_page: PageType = PageType.SEARCH
maximum_chat_retention_days: int | None = None
gpu_enabled: bool | None = None
product_gating: GatingType = GatingType.NONE
def check_validity(self) -> None:
chat_page_enabled = self.chat_page_enabled
search_page_enabled = self.search_page_enabled
default_page = self.default_page
if chat_page_enabled is False and search_page_enabled is False:
raise ValueError(
"One of `search_page_enabled` and `chat_page_enabled` must be True."
)
if default_page == PageType.CHAT and chat_page_enabled is False:
raise ValueError(
"The default page cannot be 'chat' if the chat page is disabled."
)
if default_page == PageType.SEARCH and search_page_enabled is False:
raise ValueError(
"The default page cannot be 'search' if the search page is disabled."
)
class UserSettings(Settings):
notifications: list[Notification]

View File

@@ -1,72 +1,23 @@
from functools import lru_cache
import requests
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Request
from fastapi import status
from jwt import decode as jwt_decode
from jwt import InvalidTokenError
from jwt import PyJWTError
from sqlalchemy import func
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from danswer.auth.users import current_admin_user
from danswer.configs.app_configs import AUTH_TYPE
from danswer.configs.app_configs import SUPER_CLOUD_API_KEY
from danswer.configs.app_configs import SUPER_USERS
from danswer.configs.constants import AuthType
from danswer.db.models import User
from danswer.utils.logger import setup_logger
from ee.danswer.configs.app_configs import JWT_PUBLIC_KEY_URL
from ee.danswer.configs.app_configs import SUPER_CLOUD_API_KEY
from ee.danswer.configs.app_configs import SUPER_USERS
from ee.danswer.db.saml import get_saml_account
from ee.danswer.server.seeding import get_seed_config
from ee.danswer.utils.secrets import extract_hashed_cookie
logger = setup_logger()
@lru_cache()
def get_public_key() -> str | None:
if JWT_PUBLIC_KEY_URL is None:
logger.error("JWT_PUBLIC_KEY_URL is not set")
return None
response = requests.get(JWT_PUBLIC_KEY_URL)
response.raise_for_status()
return response.text
async def verify_jwt_token(token: str, async_db_session: AsyncSession) -> User | None:
try:
public_key_pem = get_public_key()
if public_key_pem is None:
logger.error("Failed to retrieve public key")
return None
payload = jwt_decode(
token,
public_key_pem,
algorithms=["RS256"],
audience=None,
)
email = payload.get("email")
if email:
result = await async_db_session.execute(
select(User).where(func.lower(User.email) == func.lower(email))
)
return result.scalars().first()
except InvalidTokenError:
logger.error("Invalid JWT token")
get_public_key.cache_clear()
except PyJWTError as e:
logger.error(f"JWT decoding error: {str(e)}")
get_public_key.cache_clear()
return None
def verify_auth_setting() -> None:
# All the Auth flows are valid for EE version
logger.notice(f"Using Auth Type: {AUTH_TYPE.value}")
@@ -87,13 +38,6 @@ async def optional_user_(
)
user = saml_account.user if saml_account else None
# If user is still None, check for JWT in Authorization header
if user is None and JWT_PUBLIC_KEY_URL is not None:
auth_header = request.headers.get("Authorization")
if auth_header and auth_header.startswith("Bearer "):
token = auth_header[len("Bearer ") :].strip()
user = await verify_jwt_token(token, async_db_session)
return user

View File

@@ -1,4 +1,3 @@
import json
import os
# Applicable for OIDC Auth
@@ -21,10 +20,4 @@ OPENAI_DEFAULT_API_KEY = os.environ.get("OPENAI_DEFAULT_API_KEY")
ANTHROPIC_DEFAULT_API_KEY = os.environ.get("ANTHROPIC_DEFAULT_API_KEY")
COHERE_DEFAULT_API_KEY = os.environ.get("COHERE_DEFAULT_API_KEY")
# JWT Public Key URL
JWT_PUBLIC_KEY_URL: str | None = os.getenv("JWT_PUBLIC_KEY_URL", None)
# Super Users
SUPER_USERS = json.loads(os.environ.get("SUPER_USERS", '["pablo@danswer.ai"]'))
SUPER_CLOUD_API_KEY = os.environ.get("SUPER_CLOUD_API_KEY", "api_key")
INTEGRATION_TEST_MODE = os.environ.get("INTEGRATION_TEST_MODE")

View File

@@ -113,6 +113,10 @@ async def refresh_access_token(
def put_settings(
settings: EnterpriseSettings, _: User | None = Depends(current_admin_user)
) -> None:
try:
settings.check_validity()
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
store_settings(settings)

View File

@@ -157,6 +157,7 @@ def _seed_personas(db_session: Session, personas: list[CreatePersonaRequest]) ->
def _seed_settings(settings: Settings) -> None:
logger.notice("Seeding Settings")
try:
settings.check_validity()
store_base_settings(settings)
logger.notice("Successfully seeded Settings")
except ValueError as e:

View File

@@ -163,92 +163,47 @@ SUPPORTED_EMBEDDING_MODELS = [
dim=1024,
index_name="danswer_chunk_cohere_embed_english_v3_0",
),
SupportedEmbeddingModel(
name="cohere/embed-english-v3.0",
dim=1024,
index_name="danswer_chunk_embed_english_v3_0",
),
SupportedEmbeddingModel(
name="cohere/embed-english-light-v3.0",
dim=384,
index_name="danswer_chunk_cohere_embed_english_light_v3_0",
),
SupportedEmbeddingModel(
name="cohere/embed-english-light-v3.0",
dim=384,
index_name="danswer_chunk_embed_english_light_v3_0",
),
SupportedEmbeddingModel(
name="openai/text-embedding-3-large",
dim=3072,
index_name="danswer_chunk_openai_text_embedding_3_large",
),
SupportedEmbeddingModel(
name="openai/text-embedding-3-large",
dim=3072,
index_name="danswer_chunk_text_embedding_3_large",
),
SupportedEmbeddingModel(
name="openai/text-embedding-3-small",
dim=1536,
index_name="danswer_chunk_openai_text_embedding_3_small",
),
SupportedEmbeddingModel(
name="openai/text-embedding-3-small",
dim=1536,
index_name="danswer_chunk_text_embedding_3_small",
),
SupportedEmbeddingModel(
name="google/text-embedding-004",
dim=768,
index_name="danswer_chunk_google_text_embedding_004",
),
SupportedEmbeddingModel(
name="google/text-embedding-004",
dim=768,
index_name="danswer_chunk_text_embedding_004",
),
SupportedEmbeddingModel(
name="google/textembedding-gecko@003",
dim=768,
index_name="danswer_chunk_google_textembedding_gecko_003",
),
SupportedEmbeddingModel(
name="google/textembedding-gecko@003",
dim=768,
index_name="danswer_chunk_textembedding_gecko_003",
),
SupportedEmbeddingModel(
name="voyage/voyage-large-2-instruct",
dim=1024,
index_name="danswer_chunk_voyage_large_2_instruct",
),
SupportedEmbeddingModel(
name="voyage/voyage-large-2-instruct",
dim=1024,
index_name="danswer_chunk_large_2_instruct",
),
SupportedEmbeddingModel(
name="voyage/voyage-light-2-instruct",
dim=384,
index_name="danswer_chunk_voyage_light_2_instruct",
),
SupportedEmbeddingModel(
name="voyage/voyage-light-2-instruct",
dim=384,
index_name="danswer_chunk_light_2_instruct",
),
# Self-hosted models
SupportedEmbeddingModel(
name="nomic-ai/nomic-embed-text-v1",
dim=768,
index_name="danswer_chunk_nomic_ai_nomic_embed_text_v1",
),
SupportedEmbeddingModel(
name="nomic-ai/nomic-embed-text-v1",
dim=768,
index_name="danswer_chunk_nomic_embed_text_v1",
),
SupportedEmbeddingModel(
name="intfloat/e5-base-v2",
dim=768,

View File

@@ -1,88 +0,0 @@
import json
import os
import time
from pathlib import Path
import pytest
from danswer.configs.constants import DocumentSource
from danswer.connectors.models import Document
from danswer.connectors.slab.connector import SlabConnector
def load_test_data(file_name: str = "test_slab_data.json") -> dict[str, str]:
current_dir = Path(__file__).parent
with open(current_dir / file_name, "r") as f:
return json.load(f)
@pytest.fixture
def slab_connector() -> SlabConnector:
connector = SlabConnector(
base_url="https://onyx-test.slab.com/",
)
connector.load_credentials(
{
"slab_bot_token": os.environ["SLAB_BOT_TOKEN"],
}
)
return connector
@pytest.mark.xfail(
reason=(
"Need a test account with a slab subscription to run this test."
"Trial only lasts 14 days."
)
)
def test_slab_connector_basic(slab_connector: SlabConnector) -> None:
all_docs: list[Document] = []
target_test_doc_id = "jcp6cohu"
target_test_doc: Document | None = None
for doc_batch in slab_connector.poll_source(0, time.time()):
for doc in doc_batch:
all_docs.append(doc)
if doc.id == target_test_doc_id:
target_test_doc = doc
assert len(all_docs) == 6
assert target_test_doc is not None
desired_test_data = load_test_data()
assert (
target_test_doc.semantic_identifier == desired_test_data["semantic_identifier"]
)
assert target_test_doc.source == DocumentSource.SLAB
assert target_test_doc.metadata == {}
assert target_test_doc.primary_owners is None
assert target_test_doc.secondary_owners is None
assert target_test_doc.title is None
assert target_test_doc.from_ingestion_api is False
assert target_test_doc.additional_info is None
assert len(target_test_doc.sections) == 1
section = target_test_doc.sections[0]
# Need to replace the weird apostrophe with a normal one
assert section.text.replace("\u2019", "'") == desired_test_data["section_text"]
assert section.link == desired_test_data["link"]
@pytest.mark.xfail(
reason=(
"Need a test account with a slab subscription to run this test."
"Trial only lasts 14 days."
)
)
def test_slab_connector_slim(slab_connector: SlabConnector) -> None:
# Get all doc IDs from the full connector
all_full_doc_ids = set()
for doc_batch in slab_connector.load_from_state():
all_full_doc_ids.update([doc.id for doc in doc_batch])
# Get all doc IDs from the slim connector
all_slim_doc_ids = set()
for slim_doc_batch in slab_connector.retrieve_all_slim_documents():
all_slim_doc_ids.update([doc.id for doc in slim_doc_batch])
# The set of full doc IDs should be always be a subset of the slim doc IDs
assert all_full_doc_ids.issubset(all_slim_doc_ids)

View File

@@ -1,5 +0,0 @@
{
"section_text": "Learn about Posts\nWelcome\nThis is a post, where you can edit, share, and collaborate in real time with your team. We'd love to show you how it works!\nReading and editing\nClick the mode button to toggle between read and edit modes. You can only make changes to a post when editing.\nOrganize your posts\nWhen in edit mode, you can add topics to a post, which will keep it organized for the right 👀 to see.\nSmart mentions\nMentions are references to users, posts, topics and third party tools that show details on hover. Paste in a link for automatic conversion.\nLook back in time\nYou are ready to begin writing. You can always bring back this tour in the help menu.\nGreat job!\nYou are ready to begin writing. You can always bring back this tour in the help menu.\n\n",
"link": "https://onyx-test.slab.com/posts/learn-about-posts-jcp6cohu",
"semantic_identifier": "Learn about Posts"
}

View File

@@ -1,62 +0,0 @@
import mimetypes
from typing import cast
from typing import IO
from typing import List
from typing import Tuple
import requests
from danswer.file_store.models import FileDescriptor
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.constants import GENERAL_HEADERS
from tests.integration.common_utils.test_models import DATestUser
class FileManager:
@staticmethod
def upload_files(
files: List[Tuple[str, IO]],
user_performing_action: DATestUser | None = None,
) -> Tuple[List[FileDescriptor], str]:
headers = (
user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS
)
headers.pop("Content-Type", None)
files_param = []
for filename, file_obj in files:
mime_type, _ = mimetypes.guess_type(filename)
if mime_type is None:
mime_type = "application/octet-stream"
files_param.append(("files", (filename, file_obj, mime_type)))
response = requests.post(
f"{API_SERVER_URL}/chat/file",
files=files_param,
headers=headers,
)
if not response.ok:
return (
cast(List[FileDescriptor], []),
f"Failed to upload files - {response.json().get('detail', 'Unknown error')}",
)
response_json = response.json()
return response_json.get("files", cast(List[FileDescriptor], [])), ""
@staticmethod
def fetch_uploaded_file(
file_id: str,
user_performing_action: DATestUser | None = None,
) -> bytes:
response = requests.get(
f"{API_SERVER_URL}/chat/file/{file_id}",
headers=user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS,
)
response.raise_for_status()
return response.content

View File

@@ -1,84 +0,0 @@
from datetime import datetime
from datetime import timedelta
import jwt
import requests
from danswer.server.manage.models import AllUsersResponse
from danswer.server.models import FullUserSnapshot
from danswer.server.models import InvitedUserSnapshot
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.constants import GENERAL_HEADERS
from tests.integration.common_utils.test_models import DATestUser
def generate_auth_token() -> str:
payload = {
"iss": "control_plane",
"exp": datetime.utcnow() + timedelta(minutes=5),
"iat": datetime.utcnow(),
"scope": "tenant:create",
}
token = jwt.encode(payload, "", algorithm="HS256")
return token
class TenantManager:
@staticmethod
def create(
tenant_id: str | None = None,
initial_admin_email: str | None = None,
referral_source: str | None = None,
) -> dict[str, str]:
body = {
"tenant_id": tenant_id,
"initial_admin_email": initial_admin_email,
"referral_source": referral_source,
}
token = generate_auth_token()
headers = {
"Authorization": f"Bearer {token}",
"X-API-KEY": "",
"Content-Type": "application/json",
}
response = requests.post(
url=f"{API_SERVER_URL}/tenants/create",
json=body,
headers=headers,
)
response.raise_for_status()
return response.json()
@staticmethod
def get_all_users(
user_performing_action: DATestUser | None = None,
) -> AllUsersResponse:
response = requests.get(
url=f"{API_SERVER_URL}/manage/users",
headers=user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS,
)
response.raise_for_status()
data = response.json()
return AllUsersResponse(
accepted=[FullUserSnapshot(**user) for user in data["accepted"]],
invited=[InvitedUserSnapshot(**user) for user in data["invited"]],
accepted_pages=data["accepted_pages"],
invited_pages=data["invited_pages"],
)
@staticmethod
def verify_user_in_tenant(
user: DATestUser, user_performing_action: DATestUser | None = None
) -> None:
all_users = TenantManager.get_all_users(user_performing_action)
for accepted_user in all_users.accepted:
if accepted_user.email == user.email and accepted_user.id == user.id:
return
raise ValueError(f"User {user.email} not found in tenant")

View File

@@ -211,7 +211,6 @@ def reset_postgres_multitenant() -> None:
for schema in tenant_schemas:
schema_name = schema[0]
cur.execute(f'DROP SCHEMA "{schema_name}" CASCADE')
cur.close()
conn.close()

View File

@@ -4,7 +4,6 @@ from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.chat import ChatSessionManager
from tests.integration.common_utils.managers.document import DocumentManager
from tests.integration.common_utils.managers.llm_provider import LLMProviderManager
from tests.integration.common_utils.managers.tenant import TenantManager
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestAPIKey
from tests.integration.common_utils.test_models import DATestCCPair
@@ -13,66 +12,49 @@ from tests.integration.common_utils.test_models import DATestUser
def test_multi_tenant_access_control(reset_multitenant: None) -> None:
# Create Tenant 1 and its Admin User
TenantManager.create("tenant_dev1", "test1@test.com", "Data Plane Registration")
test_user1: DATestUser = UserManager.create(name="test1", email="test1@test.com")
assert UserManager.verify_role(test_user1, UserRole.ADMIN)
# Create Tenants and Admin Users
test_user1: DATestUser = UserManager.create(name="test1", email="test_1@test.com")
test_user2: DATestUser = UserManager.create(name="test2", email="test_2@test.com")
# Create Tenant 2 and its Admin User
TenantManager.create("tenant_dev2", "test2@test.com", "Data Plane Registration")
test_user2: DATestUser = UserManager.create(name="test2", email="test2@test.com")
assert UserManager.verify_role(test_user1, UserRole.ADMIN)
assert UserManager.verify_role(test_user2, UserRole.ADMIN)
# Create connectors for Tenant 1
# Create connectors and seed documents for Tenant 1
cc_pair_1: DATestCCPair = CCPairManager.create_from_scratch(
user_performing_action=test_user1,
)
api_key_1: DATestAPIKey = APIKeyManager.create(
user_performing_action=test_user1,
user_performing_action=test_user1
)
api_key_1: DATestAPIKey = APIKeyManager.create(user_performing_action=test_user1)
api_key_1.headers.update(test_user1.headers)
LLMProviderManager.create(user_performing_action=test_user1)
# Seed documents for Tenant 1
cc_pair_1.documents = []
doc1_tenant1 = DocumentManager.seed_doc_with_content(
cc_pair=cc_pair_1,
content="Tenant 1 Document Content",
api_key=api_key_1,
)
doc2_tenant1 = DocumentManager.seed_doc_with_content(
cc_pair=cc_pair_1,
content="Tenant 1 Document Content",
api_key=api_key_1,
)
cc_pair_1.documents.extend([doc1_tenant1, doc2_tenant1])
docs_tenant1 = [
DocumentManager.seed_doc_with_content(
cc_pair=cc_pair_1, content="Tenant 1 Document Content", api_key=api_key_1
)
for _ in range(2)
]
cc_pair_1.documents.extend(docs_tenant1)
# Create connectors for Tenant 2
# Create connectors and seed documents for Tenant 2
cc_pair_2: DATestCCPair = CCPairManager.create_from_scratch(
user_performing_action=test_user2,
)
api_key_2: DATestAPIKey = APIKeyManager.create(
user_performing_action=test_user2,
user_performing_action=test_user2
)
api_key_2: DATestAPIKey = APIKeyManager.create(user_performing_action=test_user2)
api_key_2.headers.update(test_user2.headers)
LLMProviderManager.create(user_performing_action=test_user2)
# Seed documents for Tenant 2
cc_pair_2.documents = []
doc1_tenant2 = DocumentManager.seed_doc_with_content(
cc_pair=cc_pair_2,
content="Tenant 2 Document Content",
api_key=api_key_2,
)
doc2_tenant2 = DocumentManager.seed_doc_with_content(
cc_pair=cc_pair_2,
content="Tenant 2 Document Content",
api_key=api_key_2,
)
cc_pair_2.documents.extend([doc1_tenant2, doc2_tenant2])
docs_tenant2 = [
DocumentManager.seed_doc_with_content(
cc_pair=cc_pair_2, content="Tenant 2 Document Content", api_key=api_key_2
)
for _ in range(2)
]
cc_pair_2.documents.extend(docs_tenant2)
tenant1_doc_ids = {doc1_tenant1.id, doc2_tenant1.id}
tenant2_doc_ids = {doc1_tenant2.id, doc2_tenant2.id}
tenant1_doc_ids = {doc.id for doc in docs_tenant1}
tenant2_doc_ids = {doc.id for doc in docs_tenant2}
# Create chat sessions for each user
chat_session1: DATestChatSession = ChatSessionManager.create(
@@ -82,69 +64,63 @@ def test_multi_tenant_access_control(reset_multitenant: None) -> None:
user_performing_action=test_user2
)
# User 1 sends a message and gets a response
# Test access for Tenant 1
response1 = ChatSessionManager.send_message(
chat_session_id=chat_session1.id,
message="What is in Tenant 1's documents?",
user_performing_action=test_user1,
)
# Assert that the search tool was used
assert response1.tool_name == "run_search"
response_doc_ids = {doc["document_id"] for doc in response1.tool_result or []}
response1_doc_ids = {doc["document_id"] for doc in response1.tool_result or []}
assert tenant1_doc_ids.issubset(
response_doc_ids
response1_doc_ids
), "Not all Tenant 1 document IDs are in the response"
assert not response_doc_ids.intersection(
assert not response1_doc_ids.intersection(
tenant2_doc_ids
), "Tenant 2 document IDs should not be in the response"
# Assert that the contents are correct
), "Tenant 2's document IDs should not be in the response"
for doc in response1.tool_result or []:
assert doc["content"] == "Tenant 1 Document Content"
# User 2 sends a message and gets a response
# Test access for Tenant 2
response2 = ChatSessionManager.send_message(
chat_session_id=chat_session2.id,
message="What is in Tenant 2's documents?",
user_performing_action=test_user2,
)
# Assert that the search tool was used
assert response2.tool_name == "run_search"
# Assert that the tool_result contains Tenant 2's documents
response_doc_ids = {doc["document_id"] for doc in response2.tool_result or []}
response2_doc_ids = {doc["document_id"] for doc in response2.tool_result or []}
assert tenant2_doc_ids.issubset(
response_doc_ids
response2_doc_ids
), "Not all Tenant 2 document IDs are in the response"
assert not response_doc_ids.intersection(
assert not response2_doc_ids.intersection(
tenant1_doc_ids
), "Tenant 1 document IDs should not be in the response"
# Assert that the contents are correct
), "Tenant 1's document IDs should not be in the response"
for doc in response2.tool_result or []:
assert doc["content"] == "Tenant 2 Document Content"
# User 1 tries to access Tenant 2's documents
response_cross = ChatSessionManager.send_message(
# Test cross-tenant access attempts
response_cross1 = ChatSessionManager.send_message(
chat_session_id=chat_session1.id,
message="What is in Tenant 2's documents?",
user_performing_action=test_user1,
)
# Assert that the search tool was used
assert response_cross.tool_name == "run_search"
# Assert that the tool_result is empty or does not contain Tenant 2's documents
response_doc_ids = {doc["document_id"] for doc in response_cross.tool_result or []}
# Ensure none of Tenant 2's document IDs are in the response
assert not response_doc_ids.intersection(tenant2_doc_ids)
assert response_cross1.tool_name == "run_search"
response_cross1_doc_ids = {
doc["document_id"] for doc in response_cross1.tool_result or []
}
assert not response_cross1_doc_ids.intersection(
tenant2_doc_ids
), "Tenant 2's document IDs should not be in the response"
# User 2 tries to access Tenant 1's documents
response_cross2 = ChatSessionManager.send_message(
chat_session_id=chat_session2.id,
message="What is in Tenant 1's documents?",
user_performing_action=test_user2,
)
# Assert that the search tool was used
assert response_cross2.tool_name == "run_search"
# Assert that the tool_result is empty or does not contain Tenant 1's documents
response_doc_ids = {doc["document_id"] for doc in response_cross2.tool_result or []}
# Ensure none of Tenant 1's document IDs are in the response
assert not response_doc_ids.intersection(tenant1_doc_ids)
response_cross2_doc_ids = {
doc["document_id"] for doc in response_cross2.tool_result or []
}
assert not response_cross2_doc_ids.intersection(
tenant1_doc_ids
), "Tenant 1's document IDs should not be in the response"

View File

@@ -1,41 +0,0 @@
from danswer.configs.constants import DocumentSource
from danswer.db.enums import AccessType
from danswer.db.models import UserRole
from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.connector import ConnectorManager
from tests.integration.common_utils.managers.credential import CredentialManager
from tests.integration.common_utils.managers.tenant import TenantManager
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestUser
# Test flow from creating tenant to registering as a user
def test_tenant_creation(reset_multitenant: None) -> None:
TenantManager.create("tenant_dev", "test@test.com", "Data Plane Registration")
test_user: DATestUser = UserManager.create(name="test", email="test@test.com")
assert UserManager.verify_role(test_user, UserRole.ADMIN)
test_credential = CredentialManager.create(
name="admin_test_credential",
source=DocumentSource.FILE,
curator_public=False,
user_performing_action=test_user,
)
test_connector = ConnectorManager.create(
name="admin_test_connector",
source=DocumentSource.FILE,
access_type=AccessType.PRIVATE,
user_performing_action=test_user,
)
test_cc_pair = CCPairManager.create(
connector_id=test_connector.id,
credential_id=test_credential.id,
name="admin_test_cc_pair",
access_type=AccessType.PRIVATE,
user_performing_action=test_user,
)
CCPairManager.verify(cc_pair=test_cc_pair, user_performing_action=test_user)

View File

@@ -130,7 +130,6 @@ services:
restart: always
environment:
- ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-}
- JWT_PUBLIC_KEY_URL=${JWT_PUBLIC_KEY_URL:-} # used for JWT authentication of users via API
# Gen AI Settings (Needed by DanswerBot)
- GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-}
- QA_TIMEOUT=${QA_TIMEOUT:-}

View File

@@ -0,0 +1,30 @@
version: "3"
services:
control-tenants-service:
image: danswer/control-tenants-service:test
environment:
- POSTGRES_HOST=relational_db
- POSTGRES_PORT=5432
ports:
- "8082:8082"
depends_on:
relational_db:
condition: service_healthy
relational_db:
image: postgres:15.2-alpine
restart: always
ports:
- "5434:5432"
volumes:
- db_volume:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
command: ["postgres", "-c", "log_statement=all"]
volumes:
db_volume:

6
node_modules/.package-lock.json generated vendored
View File

@@ -1,6 +0,0 @@
{
"name": "danswer",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,9 +0,0 @@
declare module "favicon-fetch" {
interface FaviconFetchOptions {
uri: string;
}
function faviconFetch(options: FaviconFetchOptions): string | null;
export default faviconFetch;
}

1011
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,13 +17,11 @@
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1",
"@phosphor-icons/react": "^2.0.8",
"@radix-ui/react-checkbox": "^1.1.2",
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-switch": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.3",
"@sentry/nextjs": "^8.34.0",
@@ -39,7 +37,6 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"favicon-fetch": "^1.0.0",
"formik": "^2.2.9",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
@@ -70,7 +67,6 @@
"tailwindcss-animate": "^1.0.7",
"typescript": "5.0.3",
"uuid": "^9.0.1",
"vaul": "^1.1.1",
"yup": "^1.4.0"
},
"devDependencies": {

View File

@@ -24,6 +24,13 @@ import {
TextFormField,
} from "@/components/admin/connectors/Field";
import {
Card,
CardHeader,
CardTitle,
CardContent,
CardFooter,
} from "@/components/ui/card";
import { usePopup } from "@/components/admin/connectors/Popup";
import { getDisplayNameForModel, useCategories } from "@/lib/hooks";
import { DocumentSetSelectable } from "@/components/documentSet/DocumentSetSelectable";

View File

@@ -83,7 +83,7 @@ const EditRow = ({
</div>
</TooltipTrigger>
{!documentSet.is_up_to_date && (
<TooltipContent width="max-w-sm">
<TooltipContent maxWidth="max-w-sm">
<div className="flex break-words break-keep whitespace-pre-wrap items-start">
<InfoIcon className="mr-2 mt-0.5" />
Cannot update while syncing! Wait for the sync to finish, then

View File

@@ -28,8 +28,7 @@ import { Modal } from "@/components/Modal";
import { useRouter } from "next/navigation";
import CardSection from "@/components/admin/CardSection";
import { combineSearchSettings } from "./utils";
import { CardDescription } from "@/components/ui/card";
export default function EmbeddingForm() {
const { formStep, nextFormStep, prevFormStep } = useEmbeddingFormContext();
const { popup, setPopup } = usePopup();
@@ -223,14 +222,15 @@ export default function EmbeddingForm() {
};
const updateSearch = async () => {
const searchSettings = combineSearchSettings(
selectedProvider,
advancedEmbeddingDetails,
rerankingDetails,
selectedProvider.provider_type?.toLowerCase() as EmbeddingProvider | null
);
const values: SavedSearchSettings = {
...rerankingDetails,
...advancedEmbeddingDetails,
...selectedProvider,
provider_type:
selectedProvider.provider_type?.toLowerCase() as EmbeddingProvider | null,
};
const response = await updateSearchSettings(searchSettings);
const response = await updateSearchSettings(values);
if (response.ok) {
return true;
} else {
@@ -247,35 +247,39 @@ export default function EmbeddingForm() {
if (!selectedProvider) {
return;
}
let searchSettings: SavedSearchSettings;
let newModel: SavedSearchSettings;
// We use a spread operation to merge properties from multiple objects into a single object.
// Advanced embedding details may update default values.
// Do NOT modify the order unless you are positive the new hierarchy is correct.
if (selectedProvider.provider_type != null) {
// This is a cloud model
searchSettings = combineSearchSettings(
selectedProvider,
advancedEmbeddingDetails,
rerankingDetails,
selectedProvider.provider_type
?.toLowerCase()
.split(" ")[0] as EmbeddingProvider | null
);
newModel = {
...selectedProvider,
...advancedEmbeddingDetails,
...rerankingDetails,
provider_type:
(selectedProvider.provider_type
?.toLowerCase()
.split(" ")[0] as EmbeddingProvider) || null,
};
} else {
// This is a locally hosted model
searchSettings = combineSearchSettings(
selectedProvider,
advancedEmbeddingDetails,
rerankingDetails,
null
);
newModel = {
...selectedProvider,
...advancedEmbeddingDetails,
...rerankingDetails,
provider_type: null,
};
}
searchSettings.index_name = null;
newModel.index_name = null;
const response = await fetch(
"/api/search-settings/set-new-search-settings",
{
method: "POST",
body: JSON.stringify(searchSettings),
body: JSON.stringify(newModel),
headers: {
"Content-Type": "application/json",
},

View File

@@ -1,16 +1,3 @@
import {
CloudEmbeddingProvider,
HostedEmbeddingModel,
} from "@/components/embedding/interfaces";
import {
AdvancedSearchConfiguration,
SavedSearchSettings,
} from "../interfaces";
import { EmbeddingProvider } from "@/components/embedding/interfaces";
import { RerankingDetails } from "../interfaces";
export const deleteSearchSettings = async (search_settings_id: number) => {
const response = await fetch(`/api/search-settings/delete-search-settings`, {
method: "DELETE",
@@ -55,20 +42,3 @@ export const testEmbedding = async ({
return testResponse;
};
// We use a spread operation to merge properties from multiple objects into a single object.
// Advanced embedding details may update default values.
// Do NOT modify the order unless you are positive the new hierarchy is correct.
export const combineSearchSettings = (
selectedProvider: CloudEmbeddingProvider | HostedEmbeddingModel,
advancedEmbeddingDetails: AdvancedSearchConfiguration,
rerankingDetails: RerankingDetails,
provider_type: EmbeddingProvider | null
): SavedSearchSettings => {
return {
...selectedProvider,
...advancedEmbeddingDetails,
...rerankingDetails,
provider_type: provider_type,
};
};

View File

@@ -175,6 +175,29 @@ export function SettingsForm() {
{ fieldName, newValue: checked },
];
// If we're disabling a page, check if we need to update the default page
if (
!checked &&
(fieldName === "search_page_enabled" || fieldName === "chat_page_enabled")
) {
const otherPageField =
fieldName === "search_page_enabled"
? "chat_page_enabled"
: "search_page_enabled";
const otherPageEnabled = settings && settings[otherPageField];
if (
otherPageEnabled &&
settings?.default_page ===
(fieldName === "search_page_enabled" ? "search" : "chat")
) {
updates.push({
fieldName: "default_page",
newValue: fieldName === "search_page_enabled" ? "chat" : "search",
});
}
}
updateSettingField(updates);
}
@@ -195,17 +218,42 @@ export function SettingsForm() {
return (
<div>
{popup}
<Title className="mb-4">Workspace Settings</Title>
<Title className="mb-4">Page Visibility</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."
checked={settings.auto_scroll}
label="Search Page Enabled?"
sublabel="If set, then the 'Search' page will be accessible to all users and will show up as an option on the top navbar. If unset, then this page will not be available."
checked={settings.search_page_enabled}
onChange={(e) =>
handleToggleSettingsField("auto_scroll", e.target.checked)
handleToggleSettingsField("search_page_enabled", e.target.checked)
}
/>
<Checkbox
label="Chat Page Enabled?"
sublabel="If set, then the 'Chat' page will be accessible to all users and will show up as an option on the top navbar. If unset, then this page will not be available."
checked={settings.chat_page_enabled}
onChange={(e) =>
handleToggleSettingsField("chat_page_enabled", e.target.checked)
}
/>
<Selector
label="Default Page"
subtext="The page that users will be redirected to after logging in. Can only be set to a page that is enabled."
options={[
{ value: "search", name: "Search" },
{ value: "chat", name: "Chat" },
]}
selected={settings.default_page}
onSelect={(value) => {
value &&
updateSettingField([
{ fieldName: "default_page", newValue: value },
]);
}}
/>
{isEnterpriseEnabled && (
<>
<Title className="mb-4">Chat Settings</Title>

View File

@@ -5,12 +5,14 @@ export enum GatingType {
}
export interface Settings {
chat_page_enabled: boolean;
search_page_enabled: boolean;
default_page: "search" | "chat";
maximum_chat_retention_days: number | null;
notifications: Notification[];
needs_reindexing: boolean;
gpu_enabled: boolean;
product_gating: GatingType;
auto_scroll: boolean;
}
export enum NotificationType {
@@ -52,7 +54,6 @@ export interface EnterpriseSettings {
custom_popup_header: string | null;
custom_popup_content: string | null;
enable_consent_screen: boolean | null;
auto_scroll: boolean;
}
export interface CombinedSettings {

View File

@@ -5,6 +5,7 @@ import { usePopup } from "@/components/admin/connectors/Popup";
import { basicLogin, basicSignup } from "@/lib/user";
import { Button } from "@/components/ui/button";
import { Form, Formik } from "formik";
import { useRouter } from "next/navigation";
import * as Yup from "yup";
import { requestEmailVerification } from "../lib";
import { useState } from "react";
@@ -21,8 +22,10 @@ export function EmailPasswordForm({
referralSource?: string;
nextUrl?: string | null;
}) {
const router = useRouter();
const { popup, setPopup } = usePopup();
const [isWorking, setIsWorking] = useState(false);
return (
<>
{isWorking && <Spinner />}
@@ -66,13 +69,9 @@ export function EmailPasswordForm({
if (loginResponse.ok) {
if (isSignup && shouldVerify) {
await requestEmailVerification(values.email);
// Use window.location.href to force a full page reload,
// ensuring app re-initializes with the new state (including
// server-side provider values)
window.location.href = "/auth/waiting-on-verification";
router.push("/auth/waiting-on-verification");
} else {
// See above comment
window.location.href = nextUrl ? encodeURI(nextUrl) : "/";
router.push(nextUrl ? encodeURI(nextUrl) : "/");
}
} else {
setIsWorking(false);

View File

@@ -8,6 +8,7 @@ import {
ChatFileType,
ChatSession,
ChatSessionSharedStatus,
DocumentsResponse,
FileDescriptor,
FileChatDisplay,
Message,
@@ -59,7 +60,7 @@ import { useDocumentSelection } from "./useDocumentSelection";
import { LlmOverride, useFilters, useLlmOverride } from "@/lib/hooks";
import { computeAvailableFilters } from "@/lib/filters";
import { ChatState, FeedbackType, RegenerationState } from "./types";
import { ChatFilters } from "./documentSidebar/ChatFilters";
import { DocumentSidebar } from "./documentSidebar/DocumentSidebar";
import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader";
import { FeedbackModal } from "./modal/FeedbackModal";
import { ShareChatSessionModal } from "./modal/ShareChatSessionModal";
@@ -70,7 +71,6 @@ import { StarterMessages } from "../../components/assistants/StarterMessage";
import {
AnswerPiecePacket,
DanswerDocument,
FinalContextDocs,
StreamStopInfo,
StreamStopReason,
} from "@/lib/search/interfaces";
@@ -105,10 +105,14 @@ import BlurBackground from "./shared_chat_search/BlurBackground";
import { NoAssistantModal } from "@/components/modals/NoAssistantModal";
import { useAssistants } from "@/components/context/AssistantsContext";
import { Separator } from "@/components/ui/separator";
import {
Card,
CardContent,
CardDescription,
CardHeader,
} from "@/components/ui/card";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
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";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@@ -128,9 +132,8 @@ export function ChatPage({
const {
chatSessions,
ccPairs,
tags,
documentSets,
availableSources,
availableDocumentSets,
llmProviders,
folders,
openedFolders,
@@ -139,36 +142,6 @@ export function ChatPage({
shouldShowWelcomeModal,
refreshChatSessions,
} = useChatContext();
function useScreenSize() {
const [screenSize, setScreenSize] = useState({
width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0,
});
useEffect(() => {
const handleResize = () => {
setScreenSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return screenSize;
}
const { height: screenHeight } = useScreenSize();
const getContainerHeight = () => {
if (autoScrollEnabled) return undefined;
if (screenHeight < 600) return "20vh";
if (screenHeight < 1200) return "30vh";
return "40vh";
};
// handle redirect if chat page is disabled
// NOTE: this must be done here, in a client component since
@@ -176,11 +149,9 @@ export function ChatPage({
// available in server-side components
const settings = useContext(SettingsContext);
const enterpriseSettings = settings?.enterpriseSettings;
const [documentSidebarToggled, setDocumentSidebarToggled] = useState(false);
const [filtersToggled, setFiltersToggled] = useState(false);
const [userSettingsToggled, setUserSettingsToggled] = useState(false);
if (settings?.settings?.chat_page_enabled === false) {
router.push("/search");
}
const { assistants: availableAssistants, finalAssistants } = useAssistants();
@@ -188,13 +159,16 @@ export function ChatPage({
!shouldShowWelcomeModal
);
const { user, isAdmin, isLoadingUser } = useUser();
const { user, isAdmin, isLoadingUser, refreshUser } = useUser();
const slackChatId = searchParams.get("slackChatId");
const existingChatIdRaw = searchParams.get("chatId");
const [sendOnLoad, setSendOnLoad] = useState<string | null>(
searchParams.get(SEARCH_PARAM_NAMES.SEND_ON_LOAD)
);
const currentPersonaId = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
const modelVersionFromSearchParams = searchParams.get(
SEARCH_PARAM_NAMES.STRUCTURED_MODEL
);
@@ -280,9 +254,6 @@ export function ChatPage({
const [alternativeAssistant, setAlternativeAssistant] =
useState<Persona | null>(null);
const [presentingDocument, setPresentingDocument] =
useState<DanswerDocument | null>(null);
const {
visibleAssistants: assistants,
recentAssistants,
@@ -290,7 +261,7 @@ export function ChatPage({
refreshRecentAssistants,
} = useAssistants();
const liveAssistant: Persona | undefined =
const liveAssistant =
alternativeAssistant ||
selectedAssistant ||
recentAssistants[0] ||
@@ -298,20 +269,8 @@ export function ChatPage({
availableAssistants[0];
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(() => {
if (noAssistants) return;
const personaDefault = getLLMProviderOverrideForPersona(
liveAssistant,
llmProviders
@@ -398,7 +357,9 @@ export function ChatPage({
textAreaRef.current?.focus();
// only clear things if we're going from one chat session to another
const isChatSessionSwitch = existingChatSessionId !== priorChatSessionId;
const isChatSessionSwitch =
chatSessionIdRef.current !== null &&
existingChatSessionId !== priorChatSessionId;
if (isChatSessionSwitch) {
// de-select documents
clearSelectedDocuments();
@@ -488,13 +449,12 @@ export function ChatPage({
}
if (shouldScrollToBottom) {
if (!hasPerformedInitialScroll && autoScrollEnabled) {
if (!hasPerformedInitialScroll) {
clientScrollToBottom();
} else if (isChatSessionSwitch && autoScrollEnabled) {
} else if (isChatSessionSwitch) {
clientScrollToBottom(true);
}
}
setIsFetchingChatMessages(false);
// if this is a seeded chat, then kick off the AI message generation
@@ -799,7 +759,7 @@ export function ChatPage({
useEffect(() => {
async function fetchMaxTokens() {
const response = await fetch(
`/api/chat/max-selected-document-tokens?persona_id=${liveAssistant?.id}`
`/api/chat/max-selected-document-tokens?persona_id=${liveAssistant.id}`
);
if (response.ok) {
const maxTokens = (await response.json()).max_tokens as number;
@@ -873,13 +833,11 @@ export function ChatPage({
0
)}px`;
if (autoScrollEnabled) {
scrollableDivRef?.current.scrollBy({
left: 0,
top: Math.max(heightDifference, 0),
behavior: "smooth",
});
}
scrollableDivRef?.current.scrollBy({
left: 0,
top: Math.max(heightDifference, 0),
behavior: "smooth",
});
}
previousHeight.current = newHeight;
}
@@ -926,7 +884,6 @@ export function ChatPage({
endDivRef.current.scrollIntoView({
behavior: fast ? "auto" : "smooth",
});
setHasPerformedInitialScroll(true);
}
}, 50);
@@ -1078,9 +1035,7 @@ export function ChatPage({
}
setAlternativeGeneratingAssistant(alternativeAssistantOverride);
clientScrollToBottom();
let currChatSessionId: string;
const isNewSession = chatSessionIdRef.current === null;
const searchParamBasedChatSessionName =
@@ -1326,8 +1281,8 @@ export function ChatPage({
if (Object.hasOwn(packet, "answer_piece")) {
answer += (packet as AnswerPiecePacket).answer_piece;
} else if (Object.hasOwn(packet, "final_context_docs")) {
documents = (packet as FinalContextDocs).final_context_docs;
} else if (Object.hasOwn(packet, "top_documents")) {
documents = (packet as DocumentsResponse).top_documents;
retrievalType = RetrievalType.Search;
if (documents && documents.length > 0) {
// point to the latest message (we don't know the messageId yet, which is why
@@ -1424,7 +1379,8 @@ export function ChatPage({
type: error ? "error" : "assistant",
retrievalType,
query: finalMessage?.rephrased_query || query,
documents: documents,
documents:
finalMessage?.context_docs?.top_documents || documents,
citations: finalMessage?.citations || {},
files: finalMessage?.files || aiMessageImages || [],
toolCall: finalMessage?.tool_call || toolCall,
@@ -1643,19 +1599,14 @@ export function ChatPage({
mobile: settings?.isMobile,
});
const autoScrollEnabled =
user?.preferences?.auto_scroll == null
? settings?.enterpriseSettings?.auto_scroll || false
: user?.preferences?.auto_scroll!;
useScrollonStream({
chatState: currentSessionChatState,
scrollableDivRef,
scrollDist,
endDivRef,
debounceNumber,
waitForScrollRef,
mobile: settings?.isMobile,
enableAutoScroll: autoScrollEnabled,
});
// Virtualization + Scrolling related effects and functions
@@ -1805,13 +1756,6 @@ export function ChatPage({
liveAssistant
);
});
useEffect(() => {
if (!retrievalEnabled) {
setDocumentSidebarToggled(false);
}
}, [retrievalEnabled]);
const [stackTraceModalContent, setStackTraceModalContent] = useState<
string | null
>(null);
@@ -1820,6 +1764,58 @@ export function ChatPage({
const [settingsToggled, setSettingsToggled] = useState(false);
const currentPersona = alternativeAssistant || liveAssistant;
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key.toLowerCase()) {
case "e":
event.preventDefault();
toggleSidebar();
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
const [sharedChatSession, setSharedChatSession] =
useState<ChatSession | null>();
const [deletingChatSession, setDeletingChatSession] =
useState<ChatSession | null>();
const showDeleteModal = (chatSession: ChatSession) => {
setDeletingChatSession(chatSession);
};
const showShareModal = (chatSession: ChatSession) => {
setSharedChatSession(chatSession);
};
const [documentSelection, setDocumentSelection] = useState(false);
const toggleDocumentSelectionAspects = () => {
setDocumentSelection((documentSelection) => !documentSelection);
setShowDocSidebar(false);
};
interface RegenerationRequest {
messageId: number;
parentMessage: Message;
}
function createRegenerator(regenerationRequest: RegenerationRequest) {
// Returns new function that only needs `modelOverRide` to be specified when called
return async function (modelOverRide: LlmOverride) {
return await onSubmit({
modelOverRide,
messageIdToResend: regenerationRequest.parentMessage.messageId,
regenerationRequest,
});
};
}
useEffect(() => {
const handleSlackChatRedirect = async () => {
if (!slackChatId) return;
@@ -1855,94 +1851,18 @@ export function ChatPage({
handleSlackChatRedirect();
}, [searchParams, router]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key.toLowerCase()) {
case "e":
event.preventDefault();
toggleSidebar();
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router]);
const [sharedChatSession, setSharedChatSession] =
useState<ChatSession | null>();
const [deletingChatSession, setDeletingChatSession] =
useState<ChatSession | null>();
const showDeleteModal = (chatSession: ChatSession) => {
setDeletingChatSession(chatSession);
};
const showShareModal = (chatSession: ChatSession) => {
setSharedChatSession(chatSession);
};
const [documentSelection, setDocumentSelection] = useState(false);
// const toggleDocumentSelectionAspects = () => {
// setDocumentSelection((documentSelection) => !documentSelection);
// setShowDocSidebar(false);
// };
const toggleDocumentSidebar = () => {
if (!documentSidebarToggled) {
setFiltersToggled(false);
setDocumentSidebarToggled(true);
} else if (!filtersToggled) {
setDocumentSidebarToggled(false);
} else {
setFiltersToggled(false);
}
};
const toggleFilters = () => {
if (!documentSidebarToggled) {
setFiltersToggled(true);
setDocumentSidebarToggled(true);
} else if (filtersToggled) {
setDocumentSidebarToggled(false);
} else {
setFiltersToggled(true);
}
};
interface RegenerationRequest {
messageId: number;
parentMessage: Message;
}
function createRegenerator(regenerationRequest: RegenerationRequest) {
// Returns new function that only needs `modelOverRide` to be specified when called
return async function (modelOverRide: LlmOverride) {
return await onSubmit({
modelOverRide,
messageIdToResend: regenerationRequest.parentMessage.messageId,
regenerationRequest,
});
};
}
if (noAssistants)
return (
<>
<HealthCheckBanner />
<NoAssistantModal isAdmin={isAdmin} />
</>
);
return (
<>
<HealthCheckBanner />
{showApiKeyModal && !shouldShowWelcomeModal && (
{showApiKeyModal && !shouldShowWelcomeModal ? (
<ApiKeyModal
hide={() => setShowApiKeyModal(false)}
setPopup={setPopup}
/>
) : (
noAssistants && <NoAssistantModal isAdmin={isAdmin} />
)}
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
@@ -1950,7 +1870,6 @@ export function ChatPage({
{popup}
<ChatPopup />
{currentFeedback && (
<FeedbackModal
feedbackType={currentFeedback[0]}
@@ -1967,47 +1886,16 @@ export function ChatPage({
/>
)}
{(settingsToggled || userSettingsToggled) && (
{settingsToggled && (
<SetDefaultModelModal
setPopup={setPopup}
setLlmOverride={llmOverrideManager.setGlobalDefault}
defaultModel={user?.preferences.default_model!}
llmProviders={llmProviders}
onClose={() => {
setUserSettingsToggled(false);
setSettingsToggled(false);
}}
onClose={() => setSettingsToggled(false)}
/>
)}
{retrievalEnabled && documentSidebarToggled && settings?.isMobile && (
<div className="md:hidden">
<Modal noPadding noScroll>
<ChatFilters
setPresentingDocument={setPresentingDocument}
modal={true}
filterManager={filterManager}
ccPairs={ccPairs}
tags={tags}
documentSets={documentSets}
ref={innerSidebarElementRef}
showFilters={filtersToggled}
closeSidebar={() => {
setDocumentSidebarToggled(false);
}}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
initialWidth={400}
isOpen={true}
/>
</Modal>
</div>
)}
{deletingChatSession && (
<DeleteEntityModal
entityType="chat"
@@ -2030,13 +1918,6 @@ export function ChatPage({
/>
)}
{presentingDocument && (
<TextView
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
)}
{stackTraceModalContent && (
<ExceptionTraceModal
onOutsideClick={() => setStackTraceModalContent(null)}
@@ -2115,51 +1996,6 @@ export function ChatPage({
</div>
</div>
</div>
{!settings?.isMobile && retrievalEnabled && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
fixed
right-0
z-[1000]
bg-background
h-screen
transition-all
bg-opacity-80
duration-300
ease-in-out
bg-transparent
transition-all
bg-opacity-80
duration-300
ease-in-out
h-full
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
>
<ChatFilters
setPresentingDocument={setPresentingDocument}
modal={false}
filterManager={filterManager}
ccPairs={ccPairs}
tags={tags}
documentSets={documentSets}
ref={innerSidebarElementRef}
showFilters={filtersToggled}
closeSidebar={() => setDocumentSidebarToggled(false)}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
initialWidth={400}
isOpen={documentSidebarToggled}
/>
</div>
)}
<BlurBackground
visible={!untoggled && (showDocSidebar || toggledSidebar)}
@@ -2169,12 +2005,9 @@ export function ChatPage({
ref={masterFlexboxRef}
className="flex h-full w-full overflow-x-hidden"
>
<div className="flex h-full relative px-2 flex-col w-full">
<div className="flex h-full flex-col w-full">
{liveAssistant && (
<FunctionalHeader
toggleUserSettings={() => setUserSettingsToggled(true)}
liveAssistant={liveAssistant}
onAssistantChange={onAssistantChange}
sidebarToggled={toggledSidebar}
reset={() => setMessage("")}
page="chat"
@@ -2185,8 +2018,6 @@ export function ChatPage({
}
toggleSidebar={toggleSidebar}
currentChatSession={selectedChatSession}
documentSidebarToggled={documentSidebarToggled}
llmOverrideManager={llmOverrideManager}
/>
)}
@@ -2208,7 +2039,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
${toggledSidebar ? "w-[250px]" : "w-[0px]"}
`}
></div>
)}
@@ -2218,55 +2049,9 @@ export function ChatPage({
{...getRootProps()}
>
<div
className={`w-full h-[calc(100vh-160px)] flex flex-col default-scrollbar overflow-y-auto overflow-x-hidden relative`}
className={`w-full h-full flex flex-col default-scrollbar overflow-y-auto overflow-x-hidden relative`}
ref={scrollableDivRef}
>
{liveAssistant && onAssistantChange && (
<div className="z-20 fixed top-4 pointer-events-none left-0 w-full flex justify-center overflow-visible">
{!settings?.isMobile && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
overflow-y-hidden
transition-all
pointer-events-none
duration-300
ease-in-out
h-full
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
`}
></div>
)}
<AssistantSelector
isMobile={settings?.isMobile!}
liveAssistant={liveAssistant}
onAssistantChange={onAssistantChange}
llmOverrideManager={llmOverrideManager}
/>
{!settings?.isMobile && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
overflow-y-hidden
transition-all
duration-300
ease-in-out
h-full
pointer-events-none
${
documentSidebarToggled && retrievalEnabled
? "w-[400px]"
: "w-[0px]"
}
`}
></div>
)}
</div>
)}
{/* ChatBanner is a custom banner that displays a admin-specified message at
the top of the chat page. Oly used in the EE version of the app. */}
@@ -2274,7 +2059,7 @@ export function ChatPage({
!isFetchingChatMessages &&
currentSessionChatState == "input" &&
!loadingError && (
<div className="h-full w-[95%] mx-auto mt-12 flex flex-col justify-center items-center">
<div className="h-full mt-12 flex flex-col justify-center items-center">
<ChatIntro selectedPersona={liveAssistant} />
<StarterMessages
@@ -2296,7 +2081,6 @@ export function ChatPage({
Recent Assistants
</div>
<AssistantBanner
mobile={settings?.isMobile}
recentAssistants={recentAssistants}
liveAssistant={liveAssistant}
allAssistants={allAssistants}
@@ -2438,17 +2222,6 @@ export function ChatPage({
}
>
<AIMessage
setPresentingDocument={
setPresentingDocument
}
index={i}
selectedMessageForDocDisplay={
selectedMessageForDocDisplay
}
documentSelectionToggled={
documentSidebarToggled &&
!filtersToggled
}
continueGenerating={
i == messageHistory.length - 1 &&
currentCanContinue()
@@ -2485,19 +2258,9 @@ export function ChatPage({
}}
isActive={messageHistory.length - 1 == i}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={() => {
if (
!documentSidebarToggled ||
(documentSidebarToggled &&
selectedMessageForDocDisplay ===
message.messageId)
) {
toggleDocumentSidebar();
}
setSelectedMessageForDocDisplay(
message.messageId
);
}}
toggleDocumentSelection={
toggleDocumentSelectionAspects
}
docs={message.documents}
currentPersona={liveAssistant}
alternativeAssistant={
@@ -2505,6 +2268,7 @@ export function ChatPage({
}
messageId={message.messageId}
content={message.message}
// content={message.message}
files={message.files}
query={
messageHistory[i]?.query || undefined
@@ -2690,15 +2454,6 @@ export function ChatPage({
/>
</div>
)}
{messageHistory.length > 0 && (
<div
style={{
height: !autoScrollEnabled
? getContainerHeight()
: undefined,
}}
/>
)}
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
<div ref={endPaddingRef} className="h-[95px]" />
@@ -2722,15 +2477,6 @@ export function ChatPage({
</div>
)}
<ChatInputBar
removeDocs={() => {
clearSelectedDocuments();
}}
removeFilters={() => {
filterManager.setSelectedSources([]);
filterManager.setSelectedTags([]);
filterManager.setSelectedDocumentSets([]);
setDocumentSidebarToggled(false);
}}
showConfigureAPIKey={() =>
setShowApiKeyModal(true)
}
@@ -2753,9 +2499,6 @@ export function ChatPage({
llmOverrideManager={llmOverrideManager}
files={currentMessageFiles}
setFiles={setCurrentMessageFiles}
toggleFilters={
retrievalEnabled ? toggleFilters : undefined
}
handleFileUpload={handleImageUpload}
textAreaRef={textAreaRef}
chatSessionId={chatSessionIdRef.current!}
@@ -2786,23 +2529,6 @@ export function ChatPage({
</div>
</div>
</div>
{!settings?.isMobile && (
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
overflow-y-hidden
transition-all
duration-300
ease-in-out
${
documentSidebarToggled && retrievalEnabled
? "w-[400px]"
: "w-[0px]"
}
`}
></div>
)}
</div>
)}
</Dropzone>
@@ -2811,11 +2537,7 @@ export function ChatPage({
<div
style={{ transition: "width 0.30s ease-out" }}
className={`flex-none bg-transparent transition-all bg-opacity-80 duration-300 epase-in-out h-full
${
toggledSidebar && !settings?.isMobile
? "w-[250px] "
: "w-[0px]"
}`}
${toggledSidebar ? "w-[250px] " : "w-[0px]"}`}
/>
<div className="my-auto">
<DanswerInitializingLoader />
@@ -2826,8 +2548,20 @@ export function ChatPage({
</div>
<FixedLogo backgroundToggled={toggledSidebar || showDocSidebar} />
</div>
{/* Right Sidebar - DocumentSidebar */}
</div>
<DocumentSidebar
initialWidth={350}
ref={innerSidebarElementRef}
closeSidebar={() => setDocumentSelection(false)}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
isLoading={isFetchingChatMessages}
isOpen={documentSelection}
/>
</>
);
}

View File

@@ -1,132 +1,133 @@
import { HoverPopup } from "@/components/HoverPopup";
import { SourceIcon } from "@/components/SourceIcon";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { DanswerDocument } from "@/lib/search/interfaces";
import { FiTag } from "react-icons/fi";
import { FiInfo, FiRadio } from "react-icons/fi";
import { DocumentSelector } from "./DocumentSelector";
import { buildDocumentSummaryDisplay } from "@/components/search/DocumentDisplay";
import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge";
import { MetadataBadge } from "@/components/MetadataBadge";
import { WebResultIcon } from "@/components/WebResultIcon";
import { Dispatch, SetStateAction } from "react";
import {
DocumentMetadataBlock,
buildDocumentSummaryDisplay,
} from "@/components/search/DocumentDisplay";
import { InternetSearchIcon } from "@/components/InternetSearchIcon";
interface DocumentDisplayProps {
closeSidebar: () => void;
document: DanswerDocument;
modal?: boolean;
queryEventId: number | null;
isAIPick: boolean;
isSelected: boolean;
handleSelect: (documentId: string) => void;
setPopup: (popupSpec: PopupSpec | null) => void;
tokenLimitReached: boolean;
setPresentingDocument: Dispatch<SetStateAction<DanswerDocument | null>>;
}
export function DocumentMetadataBlock({
modal,
document,
}: {
modal?: boolean;
document: DanswerDocument;
}) {
const MAX_METADATA_ITEMS = 3;
const metadataEntries = Object.entries(document.metadata);
return (
<div className="flex items-center overflow-hidden">
{document.updated_at && (
<DocumentUpdatedAtBadge updatedAt={document.updated_at} modal={modal} />
)}
{metadataEntries.length > 0 && (
<>
<div className="mx-1 h-4 border-l border-border" />
<div className="flex items-center overflow-hidden">
{metadataEntries
.slice(0, MAX_METADATA_ITEMS)
.map(([key, value], index) => (
<MetadataBadge
key={index}
icon={FiTag}
value={`${key}=${value}`}
/>
))}
{metadataEntries.length > MAX_METADATA_ITEMS && (
<span className="ml-1 text-xs text-gray-500">...</span>
)}
</div>
</>
)}
</div>
);
}
export function ChatDocumentDisplay({
closeSidebar,
document,
modal,
queryEventId,
isAIPick,
isSelected,
handleSelect,
setPopup,
tokenLimitReached,
setPresentingDocument,
}: DocumentDisplayProps) {
const isInternet = document.is_internet;
// Consider reintroducing null scored docs in the future
if (document.score === null) {
return null;
}
const handleViewFile = async () => {
if (document.link) {
window.open(document.link, "_blank");
} else {
closeSidebar();
setTimeout(async () => {
setPresentingDocument(document);
}, 100);
}
};
return (
<div className={`opacity-100 ${modal ? "w-[90vw]" : "w-full"}`}>
<div
className={`flex relative flex-col gap-0.5 rounded-xl mx-2 my-1 ${
isSelected ? "bg-gray-200" : "hover:bg-background-125"
}`}
>
<button
onClick={handleViewFile}
className="cursor-pointer text-left flex flex-col px-2 py-1.5"
<div
key={document.semantic_identifier}
className={`p-2 w-[325px] justify-start rounded-md ${
isSelected ? "bg-background-200" : "bg-background-125"
} text-sm mx-3`}
>
<div className="flex relative justify-start overflow-y-visible">
<a
href={document.link}
target="_blank"
className={
"rounded-lg flex font-bold flex-shrink truncate" +
(document.link ? "" : "pointer-events-none")
}
rel="noreferrer"
>
<div className="line-clamp-1 mb-1 flex h-6 items-center gap-2 text-xs">
{document.is_internet || document.source_type === "web" ? (
<WebResultIcon url={document.link} />
) : (
<SourceIcon sourceType={document.source_type} iconSize={18} />
{isInternet ? (
<InternetSearchIcon url={document.link} />
) : (
<SourceIcon sourceType={document.source_type} iconSize={18} />
)}
<p className="overflow-hidden text-left text-ellipsis mx-2 my-auto text-sm">
{document.semantic_identifier || document.document_id}
</p>
</a>
{document.score !== null && (
<div className="my-auto">
{isAIPick && (
<div className="w-4 h-4 my-auto mr-1 flex flex-col">
<HoverPopup
mainContent={<FiRadio className="text-gray-500 my-auto" />}
popupContent={
<div className="text-xs text-gray-300 w-36 flex">
<div className="flex mx-auto">
<div className="w-3 h-3 flex flex-col my-auto mr-1">
<FiInfo className="my-auto" />
</div>
<div className="my-auto">The AI liked this doc!</div>
</div>
</div>
}
direction="bottom"
style="dark"
/>
</div>
)}
<div className="line-clamp-1 text-text-900 text-sm font-semibold">
{(document.semantic_identifier || document.document_id).length >
(modal ? 30 : 40)
? `${(document.semantic_identifier || document.document_id)
.slice(0, modal ? 30 : 40)
.trim()}...`
: document.semantic_identifier || document.document_id}
<div
className={`
text-xs
text-emphasis
bg-hover
rounded
p-0.5
w-fit
my-auto
select-none
my-auto
mr-2`}
>
{Math.abs(document.score).toFixed(2)}
</div>
</div>
<DocumentMetadataBlock modal={modal} document={document} />
<div className="line-clamp-3 pt-2 text-sm font-normal leading-snug text-gray-600">
{buildDocumentSummaryDisplay(
document.match_highlights,
document.blurb
)}
</div>
<div className="absolute top-2 right-2">
{!isInternet && (
<DocumentSelector
isSelected={isSelected}
handleSelect={() => handleSelect(document.document_id)}
isDisabled={tokenLimitReached && !isSelected}
/>
)}
</div>
</button>
)}
{!isInternet && (
<DocumentSelector
isSelected={isSelected}
handleSelect={() => handleSelect(document.document_id)}
isDisabled={tokenLimitReached && !isSelected}
/>
)}
</div>
<div>
<div className="mt-1">
<DocumentMetadataBlock document={document} />
</div>
</div>
<p className="line-clamp-3 pl-1 pt-2 mb-1 text-start break-words">
{buildDocumentSummaryDisplay(document.match_highlights, document.blurb)}
test
</p>
<div className="mb-2">
{/*
// TODO: find a way to include this
{queryEventId && (
<DocumentFeedbackBlock
documentId={document.document_id}
queryId={queryEventId}
setPopup={setPopup}
/>
)} */}
</div>
</div>
);

View File

@@ -1,197 +0,0 @@
import { DanswerDocument } from "@/lib/search/interfaces";
import { ChatDocumentDisplay } from "./ChatDocumentDisplay";
import { usePopup } from "@/components/admin/connectors/Popup";
import { removeDuplicateDocs } from "@/lib/documentUtils";
import { Message } from "../interfaces";
import {
Dispatch,
ForwardedRef,
forwardRef,
SetStateAction,
useEffect,
useState,
} from "react";
import { FilterManager } from "@/lib/hooks";
import { CCPairBasicInfo, DocumentSet, Tag } from "@/lib/types";
import { SourceSelector } from "../shared_chat_search/SearchFilters";
import { XIcon } from "@/components/icons/icons";
interface ChatFiltersProps {
filterManager: FilterManager;
closeSidebar: () => void;
selectedMessage: Message | null;
selectedDocuments: DanswerDocument[] | null;
toggleDocumentSelection: (document: DanswerDocument) => void;
clearSelectedDocuments: () => void;
selectedDocumentTokens: number;
maxTokens: number;
initialWidth: number;
isOpen: boolean;
modal: boolean;
ccPairs: CCPairBasicInfo[];
tags: Tag[];
documentSets: DocumentSet[];
showFilters: boolean;
setPresentingDocument: Dispatch<SetStateAction<DanswerDocument | null>>;
}
export const ChatFilters = forwardRef<HTMLDivElement, ChatFiltersProps>(
(
{
closeSidebar,
modal,
selectedMessage,
selectedDocuments,
filterManager,
toggleDocumentSelection,
clearSelectedDocuments,
selectedDocumentTokens,
maxTokens,
initialWidth,
isOpen,
ccPairs,
tags,
setPresentingDocument,
documentSets,
showFilters,
},
ref: ForwardedRef<HTMLDivElement>
) => {
const { popup, setPopup } = usePopup();
const [delayedSelectedDocumentCount, setDelayedSelectedDocumentCount] =
useState(0);
useEffect(() => {
const timer = setTimeout(
() => {
setDelayedSelectedDocumentCount(selectedDocuments?.length || 0);
},
selectedDocuments?.length == 0 ? 1000 : 0
);
return () => clearTimeout(timer);
}, [selectedDocuments]);
const selectedDocumentIds =
selectedDocuments?.map((document) => document.document_id) || [];
const currentDocuments = selectedMessage?.documents || null;
const dedupedDocuments = removeDuplicateDocs(currentDocuments || []);
const tokenLimitReached = selectedDocumentTokens > maxTokens - 75;
const hasSelectedDocuments = selectedDocumentIds.length > 0;
return (
<div
id="danswer-chat-sidebar"
className={`relative py-2 max-w-full ${
!modal ? "border-l h-full border-sidebar-border" : ""
}`}
onClick={(e) => {
if (e.target === e.currentTarget) {
closeSidebar();
}
}}
>
<div
className={`ml-auto h-full relative sidebar transition-all duration-300
${
isOpen
? "opacity-100 translate-x-0"
: "opacity-0 translate-x-[10%]"
}`}
style={{
width: modal ? undefined : initialWidth,
}}
>
<div className="flex flex-col h-full">
{popup}
<div className="p-4 flex justify-between items-center">
<h2 className="text-xl font-bold text-text-900">
{showFilters ? "Filters" : "Sources"}
</h2>
<button
onClick={closeSidebar}
className="text-sm text-primary-600 mr-2 hover:text-primary-800 transition-colors duration-200 ease-in-out"
>
<XIcon className="w-4 h-4" />
</button>
</div>
<div className="border-b border-divider-history-sidebar-bar mx-3" />
<div className="overflow-y-auto -mx-1 sm:mx-0 flex-grow gap-y-0 default-scrollbar dark-scrollbar flex flex-col">
{showFilters ? (
<SourceSelector
modal={modal}
tagsOnLeft={true}
filtersUntoggled={false}
{...filterManager}
availableDocumentSets={documentSets}
existingSources={ccPairs.map((ccPair) => ccPair.source)}
availableTags={tags}
/>
) : (
<>
{dedupedDocuments.length > 0 ? (
dedupedDocuments.map((document, ind) => (
<div
key={document.document_id}
className={`${
ind === dedupedDocuments.length - 1
? ""
: "border-b border-border-light w-full"
}`}
>
<ChatDocumentDisplay
setPresentingDocument={setPresentingDocument}
closeSidebar={closeSidebar}
modal={modal}
document={document}
isSelected={selectedDocumentIds.includes(
document.document_id
)}
handleSelect={(documentId) => {
toggleDocumentSelection(
dedupedDocuments.find(
(doc) => doc.document_id === documentId
)!
);
}}
tokenLimitReached={tokenLimitReached}
/>
</div>
))
) : (
<div className="mx-3" />
)}
</>
)}
</div>
</div>
{!showFilters && (
<div
className={`sticky bottom-4 w-full left-0 flex justify-center transition-opacity duration-300 ${
hasSelectedDocuments
? "opacity-100"
: "opacity-0 pointer-events-none"
}`}
>
<button
className="text-sm font-medium py-2 px-4 rounded-full transition-colors bg-gray-900 text-white"
onClick={clearSelectedDocuments}
>
{`Remove ${
delayedSelectedDocumentCount > 0
? delayedSelectedDocumentCount
: ""
} Source${delayedSelectedDocumentCount > 1 ? "s" : ""}`}
</button>
</div>
)}
</div>
</div>
);
}
);
ChatFilters.displayName = "ChatFilters";

View File

@@ -0,0 +1,168 @@
import { DanswerDocument } from "@/lib/search/interfaces";
import Text from "@/components/ui/text";
import { ChatDocumentDisplay } from "./ChatDocumentDisplay";
import { usePopup } from "@/components/admin/connectors/Popup";
import { removeDuplicateDocs } from "@/lib/documentUtils";
import { Message } from "../interfaces";
import { ForwardedRef, forwardRef } from "react";
import { Separator } from "@/components/ui/separator";
interface DocumentSidebarProps {
closeSidebar: () => void;
selectedMessage: Message | null;
selectedDocuments: DanswerDocument[] | null;
toggleDocumentSelection: (document: DanswerDocument) => void;
clearSelectedDocuments: () => void;
selectedDocumentTokens: number;
maxTokens: number;
isLoading: boolean;
initialWidth: number;
isOpen: boolean;
}
export const DocumentSidebar = forwardRef<HTMLDivElement, DocumentSidebarProps>(
(
{
closeSidebar,
selectedMessage,
selectedDocuments,
toggleDocumentSelection,
clearSelectedDocuments,
selectedDocumentTokens,
maxTokens,
isLoading,
initialWidth,
isOpen,
},
ref: ForwardedRef<HTMLDivElement>
) => {
const { popup, setPopup } = usePopup();
const selectedDocumentIds =
selectedDocuments?.map((document) => document.document_id) || [];
const currentDocuments = selectedMessage?.documents || null;
const dedupedDocuments = removeDuplicateDocs(currentDocuments || []);
// NOTE: do not allow selection if less than 75 tokens are left
// this is to prevent the case where they are able to select the doc
// but it basically is unused since it's truncated right at the very
// start of the document (since title + metadata + misc overhead) takes up
// space
const tokenLimitReached = selectedDocumentTokens > maxTokens - 75;
return (
<div
id="danswer-chat-sidebar"
className={`fixed inset-0 transition-opacity duration-300 z-50 bg-black/80 ${
isOpen ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
onClick={(e) => {
if (e.target === e.currentTarget) {
closeSidebar();
}
}}
>
<div
className={`ml-auto rounded-l-lg relative border-l bg-text-100 sidebar z-50 absolute right-0 h-screen transition-all duration-300 ${
isOpen ? "opacity-100 translate-x-0" : "opacity-0 translate-x-[10%]"
}`}
ref={ref}
style={{
width: initialWidth,
}}
>
<div className="pb-6 flex-initial overflow-y-hidden flex flex-col h-screen">
{popup}
<div className="pl-3 mx-2 pr-6 mt-3 flex text-text-800 flex-col text-2xl text-emphasis flex font-semibold">
{dedupedDocuments.length} Document
{dedupedDocuments.length > 1 ? "s" : ""}
<p className="text-sm font-semibold flex flex-wrap gap-x-2 text-text-600 mt-1">
Select to add to continuous context
<a
href="https://docs.danswer.dev/introduction"
className="underline cursor-pointer hover:text-strong"
>
Learn more
</a>
</p>
</div>
<Separator className="mb-0 mt-4 pb-2" />
{currentDocuments ? (
<div className="overflow-y-auto flex-grow dark-scrollbar flex relative flex-col">
{dedupedDocuments.length > 0 ? (
dedupedDocuments.map((document, ind) => (
<div
key={document.document_id}
className={`${
ind === dedupedDocuments.length - 1
? "mb-5"
: "border-b border-border-light mb-3"
}`}
>
<ChatDocumentDisplay
document={document}
setPopup={setPopup}
queryEventId={null}
isAIPick={false}
isSelected={selectedDocumentIds.includes(
document.document_id
)}
handleSelect={(documentId) => {
toggleDocumentSelection(
dedupedDocuments.find(
(document) => document.document_id === documentId
)!
);
}}
tokenLimitReached={tokenLimitReached}
/>
</div>
))
) : (
<div className="mx-3">
<Text>No documents found for the query.</Text>
</div>
)}
</div>
) : (
!isLoading && (
<div className="ml-4 mr-3">
<Text>
When you run ask a question, the retrieved documents will
show up here!
</Text>
</div>
)
)}
</div>
<div className="absolute left-0 bottom-0 w-full bg-gradient-to-b from-neutral-100/0 via-neutral-100/40 backdrop-blur-xs to-neutral-100 h-[100px]" />
<div className="sticky bottom-4 w-full left-0 justify-center flex gap-x-4">
<button
className="bg-[#84e49e] text-xs p-2 rounded text-text-800"
onClick={() => closeSidebar()}
>
Save Changes
</button>
<button
className="bg-error text-xs p-2 rounded text-text-200"
onClick={() => {
clearSelectedDocuments();
closeSidebar();
}}
>
Delete Context
</button>
</div>
</div>
</div>
);
}
);
DocumentSidebar.displayName = "DocumentSidebar";

View File

@@ -1,9 +1,13 @@
import React, { useContext, useEffect, useRef, useState } from "react";
import { FiPlusCircle, FiPlus, FiInfo, FiX, FiSearch } from "react-icons/fi";
import { FiPlusCircle, FiPlus, FiInfo, FiX } from "react-icons/fi";
import { ChatInputOption } from "./ChatInputOption";
import { Persona } from "@/app/admin/assistants/interfaces";
import { InputPrompt } from "@/app/admin/prompt-library/interfaces";
import { FilterManager, LlmOverrideManager } from "@/lib/hooks";
import {
FilterManager,
getDisplayNameForModel,
LlmOverrideManager,
} from "@/lib/hooks";
import { SelectedFilterDisplay } from "./SelectedFilterDisplay";
import { useChatContext } from "@/components/context/ChatContext";
import { getFinalLLM } from "@/lib/llm/utils";
@@ -14,10 +18,15 @@ import {
} from "../files/InputBarPreview";
import {
AssistantsIconSkeleton,
CpuIconSkeleton,
FileIcon,
SendIcon,
StopGeneratingIcon,
} from "@/components/icons/icons";
import { IconType } from "react-icons";
import Popup from "../../../components/popup/Popup";
import { LlmTab } from "../modal/configuration/LlmTab";
import { AssistantsTab } from "../modal/configuration/AssistantsTab";
import { DanswerDocument } from "@/lib/search/interfaces";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import {
@@ -31,18 +40,10 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
import { ChatState } from "../types";
import UnconfiguredProviderText from "@/components/chat_search/UnconfiguredProviderText";
import { useAssistants } from "@/components/context/AssistantsContext";
import AnimatedToggle from "@/components/search/SearchBar";
import { Popup } from "@/components/admin/connectors/Popup";
import { AssistantsTab } from "../modal/configuration/AssistantsTab";
import { IconType } from "react-icons";
import { LlmTab } from "../modal/configuration/LlmTab";
import { XIcon } from "lucide-react";
const MAX_INPUT_HEIGHT = 200;
export function ChatInputBar({
removeFilters,
removeDocs,
openModelSettings,
showDocs,
showConfigureAPIKey,
@@ -67,10 +68,7 @@ export function ChatInputBar({
alternativeAssistant,
chatSessionId,
inputPrompts,
toggleFilters,
}: {
removeFilters: () => void;
removeDocs: () => void;
showConfigureAPIKey: () => void;
openModelSettings: () => void;
chatState: ChatState;
@@ -92,7 +90,6 @@ export function ChatInputBar({
handleFileUpload: (files: File[]) => void;
textAreaRef: React.RefObject<HTMLTextAreaElement>;
chatSessionId?: string;
toggleFilters?: () => void;
}) {
useEffect(() => {
const textarea = textAreaRef.current;
@@ -373,9 +370,9 @@ export function ChatInputBar({
</div>
)}
{/* <div>
<div>
<SelectedFilterDisplay filterManager={filterManager} />
</div> */}
</div>
<UnconfiguredProviderText showConfigureAPIKey={showConfigureAPIKey} />
@@ -432,21 +429,16 @@ export function ChatInputBar({
)}
{(selectedDocuments.length > 0 || 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">
<div className="flex gap-x-1 px-2 overflow-y-auto overflow-x-scroll items-end miniscroll">
{selectedDocuments.length > 0 && (
<button
onClick={showDocs}
className="flex-none relative overflow-visible flex items-center gap-x-2 h-10 px-3 rounded-lg bg-background-150 hover:bg-background-200 transition-colors duration-300 cursor-pointer max-w-[150px]"
className="flex-none flex cursor-pointer hover:bg-background-200 transition-colors duration-300 h-10 p-1 items-center gap-x-1 rounded-lg bg-background-150 max-w-[100px]"
>
<FileIcon size={20} />
<span className="text-sm whitespace-nowrap overflow-hidden text-ellipsis">
<FileIcon size={24} />
<p className="text-xs">
{selectedDocuments.length} selected
</span>
<XIcon
onClick={removeDocs}
size={16}
className="text-text-400 hover:text-text-600 ml-auto"
/>
</p>
</button>
)}
{files.map((file) => (
@@ -537,6 +529,72 @@ export function ChatInputBar({
suppressContentEditableWarning={true}
/>
<div className="flex items-center space-x-3 mr-12 px-4 pb-2">
<Popup
removePadding
content={(close) => (
<AssistantsTab
llmProviders={llmProviders}
selectedAssistant={selectedAssistant}
onSelect={(assistant) => {
setSelectedAssistant(assistant);
close();
}}
/>
)}
flexPriority="shrink"
position="top"
mobilePosition="top-right"
>
<ChatInputOption
toggle
flexPriority="shrink"
name={
selectedAssistant ? selectedAssistant.name : "Assistants"
}
Icon={AssistantsIconSkeleton as IconType}
/>
</Popup>
<Popup
tab
content={(close, ref) => (
<LlmTab
currentAssistant={alternativeAssistant || selectedAssistant}
openModelSettings={openModelSettings}
currentLlm={
llmOverrideManager.llmOverride.modelName ||
(selectedAssistant
? selectedAssistant.llm_model_version_override ||
llmOverrideManager.globalDefault.modelName ||
llmName
: llmName)
}
close={close}
ref={ref}
llmOverrideManager={llmOverrideManager}
chatSessionId={chatSessionId}
/>
)}
position="top"
>
<ChatInputOption
flexPriority="second"
toggle
name={
settings?.isMobile
? undefined
: getDisplayNameForModel(
llmOverrideManager.llmOverride.modelName ||
(selectedAssistant
? selectedAssistant.llm_model_version_override ||
llmOverrideManager.globalDefault.modelName ||
llmName
: llmName)
)
}
Icon={CpuIconSkeleton}
/>
</Popup>
<ChatInputOption
flexPriority="stiff"
name="File"
@@ -556,14 +614,6 @@ export function ChatInputBar({
input.click();
}}
/>
{toggleFilters && (
<ChatInputOption
flexPriority="stiff"
name="Filters"
Icon={FiSearch}
onClick={toggleFilters}
/>
)}
</div>
<div className="absolute bottom-2.5 mobile:right-4 desktop:right-10">

View File

@@ -2,7 +2,6 @@ import {
AnswerPiecePacket,
DanswerDocument,
Filters,
FinalContextDocs,
StreamStopInfo,
} from "@/lib/search/interfaces";
import { handleSSEStream } from "@/lib/search/streamingUtils";
@@ -103,7 +102,6 @@ export type PacketType =
| ToolCallMetadata
| BackendMessage
| AnswerPiecePacket
| FinalContextDocs
| DocumentsResponse
| FileChatDisplay
| StreamingError
@@ -149,6 +147,7 @@ export async function* sendMessage({
}): AsyncGenerator<PacketType, void, unknown> {
const documentsAreSelected =
selectedDocumentIds && selectedDocumentIds.length > 0;
const body = JSON.stringify({
alternate_assistant_id: alternateAssistantId,
chat_session_id: chatSessionId,
@@ -640,15 +639,14 @@ export async function useScrollonStream({
endDivRef,
debounceNumber,
mobile,
enableAutoScroll,
}: {
chatState: ChatState;
scrollableDivRef: RefObject<HTMLDivElement>;
waitForScrollRef: RefObject<boolean>;
scrollDist: MutableRefObject<number>;
endDivRef: RefObject<HTMLDivElement>;
debounceNumber: number;
mobile?: boolean;
enableAutoScroll?: boolean;
}) {
const mobileDistance = 900; // distance that should "engage" the scroll
const desktopDistance = 500; // distance that should "engage" the scroll
@@ -661,10 +659,6 @@ export async function useScrollonStream({
const previousScroll = useRef<number>(0);
useEffect(() => {
if (!enableAutoScroll) {
return;
}
if (chatState != "input" && scrollableDivRef && scrollableDivRef.current) {
const newHeight: number = scrollableDivRef.current?.scrollTop!;
const heightDifference = newHeight - previousScroll.current;
@@ -722,7 +716,7 @@ export async function useScrollonStream({
// scroll on end of stream if within distance
useEffect(() => {
if (scrollableDivRef?.current && chatState == "input" && enableAutoScroll) {
if (scrollableDivRef?.current && chatState == "input") {
if (scrollDist.current < distance - 50) {
scrollableDivRef?.current?.scrollBy({
left: 0,

View File

@@ -1,58 +1,8 @@
import { Citation } from "@/components/search/results/Citation";
import { WebResultIcon } from "@/components/WebResultIcon";
import { LoadedDanswerDocument } from "@/lib/search/interfaces";
import { getSourceMetadata } from "@/lib/sources";
import { ValidSources } from "@/lib/types";
import React, { memo } from "react";
import isEqual from "lodash/isEqual";
export const MemoizedAnchor = memo(
({ docs, updatePresentingDocument, children }: any) => {
const value = children?.toString();
if (value?.startsWith("[") && value?.endsWith("]")) {
const match = value.match(/\[(\d+)\]/);
if (match) {
const index = parseInt(match[1], 10) - 1;
const associatedDoc = docs && docs[index];
const url = associatedDoc?.link
? new URL(associatedDoc.link).origin + "/favicon.ico"
: "";
const getIcon = (sourceType: ValidSources, link: string) => {
return getSourceMetadata(sourceType).icon({ size: 18 });
};
const icon =
associatedDoc?.source_type === "web" ? (
<WebResultIcon url={associatedDoc.link} />
) : (
getIcon(
associatedDoc?.source_type || "web",
associatedDoc?.link || ""
)
);
return (
<MemoizedLink
updatePresentingDocument={updatePresentingDocument}
document={{ ...associatedDoc, icon, url }}
>
{children}
</MemoizedLink>
);
}
}
return (
<MemoizedLink updatePresentingDocument={updatePresentingDocument}>
{children}
</MemoizedLink>
);
}
);
export const MemoizedLink = memo((props: any) => {
const { node, document, updatePresentingDocument, ...rest } = props;
const { node, ...rest } = props;
const value = rest.children;
if (value?.toString().startsWith("*")) {
@@ -60,39 +10,24 @@ export const MemoizedLink = memo((props: any) => {
<div className="flex-none bg-background-800 inline-block rounded-full h-3 w-3 ml-2" />
);
} else if (value?.toString().startsWith("[")) {
return <Citation link={rest?.href}>{rest.children}</Citation>;
} else {
return (
<Citation
url={document?.url}
icon={document?.icon as React.ReactNode}
link={rest?.href}
document={document as LoadedDanswerDocument}
updatePresentingDocument={updatePresentingDocument}
<a
onMouseDown={() =>
rest.href ? window.open(rest.href, "_blank") : undefined
}
className="cursor-pointer text-link hover:text-link-hover"
>
{rest.children}
</Citation>
</a>
);
}
return (
<a
onMouseDown={() => rest.href && window.open(rest.href, "_blank")}
className="cursor-pointer text-link hover:text-link-hover"
>
{rest.children}
</a>
);
});
export const MemoizedParagraph = memo(
function MemoizedParagraph({ children }: any) {
return <p className="text-default">{children}</p>;
},
(prevProps, nextProps) => {
const areEqual = isEqual(prevProps.children, nextProps.children);
return areEqual;
}
);
export const MemoizedParagraph = memo(({ ...props }: any) => {
return <p {...props} className="text-default" />;
});
MemoizedAnchor.displayName = "MemoizedAnchor";
MemoizedLink.displayName = "MemoizedLink";
MemoizedParagraph.displayName = "MemoizedParagraph";

View File

@@ -8,24 +8,14 @@ import {
FiGlobe,
} from "react-icons/fi";
import { FeedbackType } from "../types";
import React, {
memo,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import {
DanswerDocument,
FilteredDanswerDocument,
LoadedDanswerDocument,
} from "@/lib/search/interfaces";
import { SearchSummary } from "./SearchSummary";
import { SourceIcon } from "@/components/SourceIcon";
import { SkippedSearch } from "./SkippedSearch";
import remarkGfm from "remark-gfm";
import { CopyButton } from "@/components/CopyButton";
@@ -46,6 +36,8 @@ import "prismjs/themes/prism-tomorrow.css";
import "./custom-code-styles.css";
import { Persona } from "@/app/admin/assistants/interfaces";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { Citation } from "@/components/search/results/Citation";
import { DocumentMetadataBlock } from "@/components/search/DocumentDisplay";
import { LikeFeedback, DislikeFeedback } from "@/components/icons/icons";
import {
@@ -60,18 +52,16 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useMouseTracking } from "./hooks";
import { InternetSearchIcon } from "@/components/InternetSearchIcon";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import GeneratingImageDisplay from "../tools/GeneratingImageDisplay";
import RegenerateOption from "../RegenerateOption";
import { LlmOverride } from "@/lib/hooks";
import { ContinueGenerating } from "./ContinueMessage";
import { MemoizedAnchor, MemoizedParagraph } from "./MemoizedTextComponents";
import { MemoizedLink, MemoizedParagraph } from "./MemoizedTextComponents";
import { extractCodeText } from "./codeUtils";
import ToolResult from "../../../components/tools/ToolResult";
import CsvContent from "../../../components/tools/CSVContent";
import SourceCard, {
SeeMoreBlock,
} from "@/components/chat_search/sources/SourceCard";
const TOOLS_WITH_CUSTOM_HANDLING = [
SEARCH_TOOL_NAME,
@@ -165,7 +155,6 @@ function FileDisplay({
export const AIMessage = ({
regenerate,
overriddenModel,
selectedMessageForDocDisplay,
continueGenerating,
shared,
isActive,
@@ -173,7 +162,6 @@ export const AIMessage = ({
alternativeAssistant,
docs,
messageId,
documentSelectionToggled,
content,
files,
selectedDocuments,
@@ -190,11 +178,7 @@ export const AIMessage = ({
currentPersona,
otherMessagesCanSwitchTo,
onMessageSelection,
setPresentingDocument,
index,
}: {
index?: number;
selectedMessageForDocDisplay?: number | null;
shared?: boolean;
isActive?: boolean;
continueGenerating?: () => void;
@@ -207,7 +191,6 @@ export const AIMessage = ({
currentPersona: Persona;
messageId: number | null;
content: string | JSX.Element;
documentSelectionToggled?: boolean;
files?: FileDescriptor[];
query?: string;
citedDocuments?: [string, DanswerDocument][] | null;
@@ -221,7 +204,6 @@ export const AIMessage = ({
retrievalDisabled?: boolean;
overriddenModel?: string;
regenerate?: (modelOverRide: LlmOverride) => Promise<void>;
setPresentingDocument?: (document: DanswerDocument) => void;
}) => {
const toolCallGenerating = toolCall && !toolCall.tool_result;
const processContent = (content: string | JSX.Element) => {
@@ -305,36 +287,18 @@ export const AIMessage = ({
});
}
const paragraphCallback = useCallback(
(props: any) => <MemoizedParagraph>{props.children}</MemoizedParagraph>,
[]
);
const anchorCallback = useCallback(
(props: any) => (
<MemoizedAnchor
updatePresentingDocument={setPresentingDocument}
docs={docs}
>
{props.children}
</MemoizedAnchor>
),
[docs]
);
const currentMessageInd = messageId
? otherMessagesCanSwitchTo?.indexOf(messageId)
: undefined;
const uniqueSources: ValidSources[] = Array.from(
new Set((docs || []).map((doc) => doc.source_type))
).slice(0, 3);
const markdownComponents = useMemo(
() => ({
a: anchorCallback,
p: paragraphCallback,
code: ({ node, className, children }: any) => {
a: MemoizedLink,
p: MemoizedParagraph,
code: ({ node, className, children, ...props }: any) => {
const codeText = extractCodeText(
node,
finalContent as string,
@@ -348,7 +312,7 @@ export const AIMessage = ({
);
},
}),
[anchorCallback, paragraphCallback, finalContent]
[finalContent]
);
const renderedMarkdown = useMemo(() => {
@@ -369,11 +333,12 @@ export const AIMessage = ({
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1;
return (
<div
id="danswer-ai-message"
ref={trackedElementRef}
className={`py-5 ml-4 px-5 relative flex `}
className={"py-5 ml-4 px-5 relative flex "}
>
<div
className={`mx-auto ${
@@ -398,7 +363,6 @@ export const AIMessage = ({
!retrievalDisabled && (
<div className="mb-1">
<SearchSummary
index={index || 0}
query={query}
finished={toolCall?.tool_result != undefined}
hasDocs={hasDocs || false}
@@ -459,31 +423,6 @@ export const AIMessage = ({
/>
)}
{docs && docs.length > 0 && (
<div className="mt-2 -mx-8 w-full mb-4 flex relative">
<div className="w-full">
<div className="px-8 flex gap-x-2">
{!settings?.isMobile &&
docs.length > 0 &&
docs
.slice(0, 2)
.map((doc, ind) => (
<SourceCard doc={doc} key={ind} />
))}
<SeeMoreBlock
documentSelectionToggled={
(documentSelectionToggled &&
selectedMessageForDocDisplay === messageId) ||
false
}
toggleDocumentSelection={toggleDocumentSelection}
uniqueSources={uniqueSources}
/>
</div>
</div>
</div>
)}
{content || files ? (
<>
<FileDisplay files={files || []} />
@@ -499,6 +438,81 @@ export const AIMessage = ({
) : isComplete ? null : (
<></>
)}
{isComplete && docs && docs.length > 0 && (
<div className="mt-2 -mx-8 w-full mb-4 flex relative">
<div className="w-full">
<div className="px-8 flex gap-x-2">
{!settings?.isMobile &&
filteredDocs.length > 0 &&
filteredDocs.slice(0, 2).map((doc, ind) => (
<div
key={doc.document_id}
className={`w-[200px] rounded-lg flex-none transition-all duration-500 hover:bg-background-125 bg-text-100 px-4 pb-2 pt-1 border-b
`}
>
<a
href={doc.link || undefined}
target="_blank"
className="text-sm flex w-full pt-1 gap-x-1.5 overflow-hidden justify-between font-semibold text-text-700"
rel="noreferrer"
>
<Citation link={doc.link} index={ind + 1} />
<p className="shrink truncate ellipsis break-all">
{doc.semantic_identifier ||
doc.document_id}
</p>
<div className="ml-auto flex-none">
{doc.is_internet ? (
<InternetSearchIcon url={doc.link} />
) : (
<SourceIcon
sourceType={doc.source_type}
iconSize={18}
/>
)}
</div>
</a>
<div className="flex overscroll-x-scroll mt-.5">
<DocumentMetadataBlock document={doc} />
</div>
<div className="line-clamp-3 text-xs break-words pt-1">
{doc.blurb}
</div>
</div>
))}
<div
onClick={() => {
if (messageId) {
onMessageSelection?.(messageId);
}
toggleDocumentSelection?.();
}}
key={-1}
className="cursor-pointer w-[200px] rounded-lg flex-none transition-all duration-500 hover:bg-background-125 bg-text-100 px-4 py-2 border-b"
>
<div className="text-sm flex justify-between font-semibold text-text-700">
<p className="line-clamp-1">See context</p>
<div className="flex gap-x-1">
{uniqueSources.map((sourceType, ind) => {
return (
<div key={ind} className="flex-none">
<SourceIcon
sourceType={sourceType}
iconSize={18}
/>
</div>
);
})}
</div>
</div>
<div className="line-clamp-3 text-xs break-words pt-1">
See more
</div>
</div>
</div>
</div>
</div>
)}
</div>
{handleFeedback &&

View File

@@ -41,7 +41,6 @@ export function ShowHideDocsButton({
}
export function SearchSummary({
index,
query,
hasDocs,
finished,
@@ -49,7 +48,6 @@ export function SearchSummary({
handleShowRetrieved,
handleSearchQueryEdit,
}: {
index: number;
finished: boolean;
query: string;
hasDocs: boolean;
@@ -100,14 +98,7 @@ export function SearchSummary({
!text-sm !line-clamp-1 !break-all px-0.5`}
ref={searchingForRef}
>
{finished ? "Searched" : "Searching"} for:{" "}
<i>
{index === 1
? finalQuery.length > 50
? `${finalQuery.slice(0, 50)}...`
: finalQuery
: finalQuery}
</i>
{finished ? "Searched" : "Searching"} for: <i> {finalQuery}</i>
</div>
</div>
);

View File

@@ -53,7 +53,7 @@ export const FeedbackModal = ({
: predefinedNegativeFeedbackOptions;
return (
<Modal onOutsideClick={onClose} width="w-full max-w-3xl">
<Modal onOutsideClick={onClose} width="max-w-3xl">
<>
<h2 className="text-2xl text-emphasis font-bold mb-4 flex">
<div className="mr-1 my-auto">

View File

@@ -1,4 +1,4 @@
import { Dispatch, SetStateAction, useContext, useEffect, useRef } from "react";
import { Dispatch, SetStateAction, useEffect, useRef } from "react";
import { Modal } from "@/components/Modal";
import Text from "@/components/ui/text";
import { getDisplayNameForModel, LlmOverride } from "@/lib/hooks";
@@ -9,10 +9,6 @@ import { setUserDefaultModel } from "@/lib/users/UserSettings";
import { useRouter } from "next/navigation";
import { PopupSpec } from "@/components/admin/connectors/Popup";
import { useUser } from "@/components/user/UserProvider";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/admin/connectors/Field";
import { SettingsContext } from "@/components/settings/SettingsProvider";
export function SetDefaultModelModal({
setPopup,
@@ -27,7 +23,7 @@ export function SetDefaultModelModal({
onClose: () => void;
defaultModel: string | null;
}) {
const { refreshUser, user, updateUserAutoScroll } = useUser();
const { refreshUser } = useUser();
const containerRef = useRef<HTMLDivElement>(null);
const messageRef = useRef<HTMLDivElement>(null);
@@ -125,41 +121,16 @@ export function SetDefaultModelModal({
const defaultProvider = llmProviders.find(
(llmProvider) => llmProvider.is_default_provider
);
const settings = useContext(SettingsContext);
const autoScroll = settings?.enterpriseSettings?.auto_scroll;
const checked =
user?.preferences?.auto_scroll === null
? autoScroll
: user?.preferences?.auto_scroll;
return (
<Modal onOutsideClick={onClose} width="rounded-lg bg-white max-w-xl">
<>
<div className="flex mb-4">
<h2 className="text-2xl text-emphasis font-bold flex my-auto">
User settings
Set Default Model
</h2>
</div>
<div className="flex flex-col gap-y-2">
<div className="flex items-center gap-x-2">
<Switch
checked={checked}
onCheckedChange={(checked) => {
updateUserAutoScroll(checked);
}}
/>
<Label className="text-sm">Enable auto-scroll</Label>
</div>
</div>
<Separator />
<h3 className="text-lg text-emphasis font-bold">
Default model for assistants
</h3>
<Text className="mb-4">
Choose a Large Language Model (LLM) to serve as the default for
assistants that don&apos;t have a default model assigned.

View File

@@ -32,7 +32,6 @@ export default async function Page(props: {
defaultAssistantId,
shouldShowWelcomeModal,
userInputPrompts,
ccPairs,
} = data;
return (
@@ -45,9 +44,6 @@ export default async function Page(props: {
value={{
chatSessions,
availableSources,
ccPairs,
documentSets,
tags,
availableDocumentSets: documentSets,
availableTags: tags,
llmProviders,

View File

@@ -113,7 +113,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
{page == "chat" && (
<div className="mx-3 mt-4 gap-y-1 flex-col text-text-history-sidebar-button flex gap-x-1.5 items-center items-center">
<Link
className=" w-full p-2 bg-white border-border border rounded items-center hover:bg-background-200 cursor-pointer transition-all duration-150 flex gap-x-2"
className="w-full p-2 bg-white border-border border rounded items-center hover:bg-background-200 cursor-pointer transition-all duration-150 flex gap-x-2"
href={
`/${page}` +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA &&

View File

@@ -17,8 +17,6 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader";
import { Persona } from "@/app/admin/assistants/interfaces";
import { Button } from "@/components/ui/button";
import { DanswerDocument } from "@/lib/search/interfaces";
import TextView from "@/components/chat_search/TextView";
function BackToDanswerButton() {
const router = useRouter();
@@ -43,9 +41,6 @@ export function SharedChatDisplay({
persona: Persona;
}) {
const [isReady, setIsReady] = useState(false);
const [presentingDocument, setPresentingDocument] =
useState<DanswerDocument | null>(null);
useEffect(() => {
Prism.highlightAll();
setIsReady(true);
@@ -68,70 +63,61 @@ export function SharedChatDisplay({
);
return (
<>
{presentingDocument && (
<TextView
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
)}
<div className="w-full h-[100dvh] overflow-hidden">
<div className="flex max-h-full overflow-hidden pb-[72px]">
<div className="flex w-full overflow-hidden overflow-y-scroll">
<div className="w-full h-full flex-col flex max-w-message-max mx-auto">
<div className="px-5 pt-8">
<h1 className="text-3xl text-strong font-bold">
{chatSession.description ||
`Chat ${chatSession.chat_session_id}`}
</h1>
<p className="text-emphasis">
{humanReadableFormat(chatSession.time_created)}
</p>
<div className="w-full h-[100dvh] overflow-hidden">
<div className="flex max-h-full overflow-hidden pb-[72px]">
<div className="flex w-full overflow-hidden overflow-y-scroll">
<div className="w-full h-full flex-col flex max-w-message-max mx-auto">
<div className="px-5 pt-8">
<h1 className="text-3xl text-strong font-bold">
{chatSession.description ||
`Chat ${chatSession.chat_session_id}`}
</h1>
<p className="text-emphasis">
{humanReadableFormat(chatSession.time_created)}
</p>
<Separator />
</div>
{isReady ? (
<div className="w-full pb-16">
{messages.map((message) => {
if (message.type === "user") {
return (
<HumanMessage
shared
key={message.messageId}
content={message.message}
files={message.files}
/>
);
} else {
return (
<AIMessage
shared
setPresentingDocument={setPresentingDocument}
currentPersona={persona}
key={message.messageId}
messageId={message.messageId}
content={message.message}
files={message.files || []}
citedDocuments={getCitedDocumentsFromMessage(message)}
isComplete
/>
);
}
})}
</div>
) : (
<div className="grow flex-0 h-screen w-full flex items-center justify-center">
<div className="mb-[33vh]">
<DanswerInitializingLoader />
</div>
</div>
)}
<Separator />
</div>
{isReady ? (
<div className="w-full pb-16">
{messages.map((message) => {
if (message.type === "user") {
return (
<HumanMessage
shared
key={message.messageId}
content={message.message}
files={message.files}
/>
);
} else {
return (
<AIMessage
shared
currentPersona={persona}
key={message.messageId}
messageId={message.messageId}
content={message.message}
files={message.files || []}
citedDocuments={getCitedDocumentsFromMessage(message)}
isComplete
/>
);
}
})}
</div>
) : (
<div className="grow flex-0 h-screen w-full flex items-center justify-center">
<div className="mb-[33vh]">
<DanswerInitializingLoader />
</div>
</div>
)}
</div>
</div>
<BackToDanswerButton />
</div>
</>
<BackToDanswerButton />
</div>
);
}

View File

@@ -1,635 +0,0 @@
import React, { useState } from "react";
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
import { SourceMetadata } from "@/lib/search/interfaces";
import {
GearIcon,
InfoIcon,
MinusIcon,
PlusCircleIcon,
PlusIcon,
defaultTailwindCSS,
} from "@/components/icons/icons";
import { HoverPopup } from "@/components/HoverPopup";
import {
FiBook,
FiBookmark,
FiFilter,
FiMap,
FiTag,
FiX,
} from "react-icons/fi";
import { DateRangeSelector } from "@/components/search/DateRangeSelector";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
import { listSourceMetadata } from "@/lib/sources";
import { SourceIcon } from "@/components/SourceIcon";
import { TagFilter } from "@/components/search/filtering/TagFilter";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { PopoverContent } from "@radix-ui/react-popover";
import { CalendarIcon } from "lucide-react";
import { buildDateString, getTimeAgoString } from "@/lib/dateUtils";
import { Separator } from "@/components/ui/separator";
import { FilterDropdown } from "@/components/search/filtering/FilterDropdown";
const SectionTitle = ({ children }: { children: string }) => (
<div className="font-bold text-xs mt-2 flex">{children}</div>
);
export interface SourceSelectorProps {
timeRange: DateRangePickerValue | null;
setTimeRange: React.Dispatch<
React.SetStateAction<DateRangePickerValue | null>
>;
showDocSidebar?: boolean;
selectedSources: SourceMetadata[];
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
selectedDocumentSets: string[];
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
availableDocumentSets: DocumentSet[];
existingSources: ValidSources[];
availableTags: Tag[];
toggleFilters: () => void;
filtersUntoggled: boolean;
tagsOnLeft: boolean;
}
export function SourceSelector({
timeRange,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
availableDocumentSets,
existingSources,
availableTags,
showDocSidebar,
toggleFilters,
filtersUntoggled,
tagsOnLeft,
}: SourceSelectorProps) {
const handleSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
if (
prev.map((source) => source.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];
}
});
};
let allSourcesSelected = selectedSources.length > 0;
const toggleAllSources = () => {
if (allSourcesSelected) {
setSelectedSources([]);
} else {
const allSources = listSourceMetadata().filter((source) =>
existingSources.includes(source.internalName)
);
setSelectedSources(allSources);
}
};
return (
<div
className={`hidden ${
showDocSidebar ? "4xl:block" : "!block"
} duration-1000 flex ease-out transition-all transform origin-top-right`}
>
<button onClick={() => toggleFilters()} className="flex text-emphasis">
<h2 className="font-bold my-auto">Filters</h2>
<FiFilter className="my-auto ml-2" size="16" />
</button>
{!filtersUntoggled && (
<>
<Separator />
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer">
<div className="flex justify-between items-center">
<SectionTitle>Time Range</SectionTitle>
{true && (
<button
onClick={(e) => {
e.stopPropagation();
setTimeRange(null);
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
)}
</div>
<p className="text-sm text-default mt-2">
{getTimeAgoString(timeRange?.from!) || "Select a time range"}
</p>
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
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>
{availableTags.length > 0 && (
<>
<div className="mt-4 mb-2">
<SectionTitle>Tags</SectionTitle>
</div>
<TagFilter
showTagsOnLeft={true}
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
</>
)}
{existingSources.length > 0 && (
<div className="mt-4">
<div className="flex w-full gap-x-2 items-center">
<div className="font-bold text-xs mt-2 flex items-center gap-x-2">
<p>Sources</p>
<input
type="checkbox"
checked={allSourcesSelected}
onChange={toggleAllSources}
className="my-auto form-checkbox h-3 w-3 text-primary border-background-900 rounded"
/>
</div>
</div>
<div className="px-1">
{listSourceMetadata()
.filter((source) =>
existingSources.includes(source.internalName)
)
.map((source) => (
<div
key={source.internalName}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 my-1.5 rounded-lg px-2 select-none " +
(selectedSources
.map((source) => source.internalName)
.includes(source.internalName)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleSelect(source)}
>
<SourceIcon
sourceType={source.internalName}
iconSize={16}
/>
<span className="ml-2 text-sm text-default">
{source.displayName}
</span>
</div>
))}
</div>
</div>
)}
{availableDocumentSets.length > 0 && (
<>
<div className="mt-4">
<SectionTitle>Knowledge Sets</SectionTitle>
</div>
<div className="px-1">
{availableDocumentSets.map((documentSet) => (
<div key={documentSet.name} className="my-1.5 flex">
<div
key={documentSet.name}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 rounded-lg px-2 " +
(selectedDocumentSets.includes(documentSet.name)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<HoverPopup
mainContent={
<div className="flex my-auto mr-2">
<InfoIcon className={defaultTailwindCSS} />
</div>
}
popupContent={
<div className="text-sm w-64">
<div className="flex font-medium">Description</div>
<div className="mt-1">
{documentSet.description}
</div>
</div>
}
classNameModifications="-ml-2"
/>
<span className="text-sm">{documentSet.name}</span>
</div>
</div>
))}
</div>
</>
)}
</>
)}
</div>
);
}
export function SelectedBubble({
children,
onClick,
}: {
children: string | JSX.Element;
onClick: () => void;
}) {
return (
<div
className={
"flex cursor-pointer items-center border border-border " +
"py-1 my-1.5 rounded-lg px-2 w-fit hover:bg-hover"
}
onClick={onClick}
>
{children}
<FiX className="ml-2" size={14} />
</div>
);
}
export function HorizontalFilters({
timeRange,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
availableDocumentSets,
existingSources,
}: SourceSelectorProps) {
const handleSourceSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
const prevSourceNames = prev.map((source) => source.internalName);
if (prevSourceNames.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 allSources = listSourceMetadata();
const availableSources = allSources.filter((source) =>
existingSources.includes(source.internalName)
);
return (
<div>
<div className="flex gap-x-3">
<div className="w-64">
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
</div>
<FilterDropdown
options={availableSources.map((source) => {
return {
key: source.displayName,
display: (
<>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm">{source.displayName}</span>
</>
),
};
})}
selected={selectedSources.map((source) => source.displayName)}
handleSelect={(option) =>
handleSourceSelect(
allSources.find((source) => source.displayName === option.key)!
)
}
icon={
<div className="my-auto mr-2 w-[16px] h-[16px]">
<FiMap size={16} />
</div>
}
defaultDisplay="All Sources"
/>
<FilterDropdown
options={availableDocumentSets.map((documentSet) => {
return {
key: documentSet.name,
display: (
<>
<div className="my-auto">
<FiBookmark />
</div>
<span className="ml-2 text-sm">{documentSet.name}</span>
</>
),
};
})}
selected={selectedDocumentSets}
handleSelect={(option) => handleDocumentSetSelect(option.key)}
icon={
<div className="my-auto mr-2 w-[16px] h-[16px]">
<FiBook size={16} />
</div>
}
defaultDisplay="All Document Sets"
/>
</div>
<div className="flex pb-4 mt-2 h-12">
<div className="flex flex-wrap gap-x-2">
{timeRange && timeRange.selectValue && (
<SelectedBubble onClick={() => setTimeRange(null)}>
<div className="text-sm flex">{timeRange.selectValue}</div>
</SelectedBubble>
)}
{existingSources.length > 0 &&
selectedSources.map((source) => (
<SelectedBubble
key={source.internalName}
onClick={() => handleSourceSelect(source)}
>
<>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm">{source.displayName}</span>
</>
</SelectedBubble>
))}
{selectedDocumentSets.length > 0 &&
selectedDocumentSets.map((documentSetName) => (
<SelectedBubble
key={documentSetName}
onClick={() => handleDocumentSetSelect(documentSetName)}
>
<>
<div>
<FiBookmark />
</div>
<span className="ml-2 text-sm">{documentSetName}</span>
</>
</SelectedBubble>
))}
</div>
</div>
</div>
);
}
export function HorizontalSourceSelector({
timeRange,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
availableDocumentSets,
existingSources,
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-nowrap space-x-2">
<Popover>
<PopoverTrigger asChild>
<div
className={`
border
max-w-36
border-border
rounded-lg
max-h-96
overflow-y-scroll
overscroll-contain
px-3
text-sm
py-1.5
select-none
cursor-pointer
w-fit
gap-x-1
hover:bg-hover
flex
items-center
bg-background-search-filter
`}
>
<CalendarIcon className="h-4 w-4" />
{timeRange?.from ? getTimeAgoString(timeRange.from) : "Since"}
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
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>
{existingSources.length > 0 && (
<FilterDropdown
backgroundColor="bg-background-search-filter"
options={listSourceMetadata()
.filter((source) => existingSources.includes(source.internalName))
.map((source) => ({
key: source.internalName,
display: (
<>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm">{source.displayName}</span>
</>
),
}))}
selected={selectedSources.map((source) => source.internalName)}
handleSelect={(option) =>
handleSourceSelect(
listSourceMetadata().find((s) => s.internalName === option.key)!
)
}
icon={<FiMap size={16} />}
defaultDisplay="Sources"
dropdownColor="bg-background-search-filter-dropdown"
width="w-fit ellipsis truncate"
resetValues={resetSources}
dropdownWidth="w-40"
optionClassName="truncate w-full break-all ellipsis"
/>
)}
{availableDocumentSets.length > 0 && (
<FilterDropdown
backgroundColor="bg-background-search-filter"
options={availableDocumentSets.map((documentSet) => ({
key: documentSet.name,
display: <>{documentSet.name}</>,
}))}
selected={selectedDocumentSets}
handleSelect={(option) => handleDocumentSetSelect(option.key)}
icon={<FiBook size={16} />}
defaultDisplay="Sets"
resetValues={resetDocuments}
width="w-fit max-w-24 text-ellipsis truncate"
dropdownColor="bg-background-search-filter-dropdown"
dropdownWidth="max-w-36 w-fit"
optionClassName="truncate w-full break-all"
/>
)}
{availableTags.length > 0 && (
<FilterDropdown
backgroundColor="bg-background-search-filter"
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>
),
}))}
selected={selectedTags.map(
(tag) => `${tag.tag_key}=${tag.tag_value}`
)}
handleSelect={(option) => {
const [tag_key, tag_value] = option.key.split("=");
const selectedTag = availableTags.find(
(tag) => tag.tag_key === tag_key && tag.tag_value === tag_value
);
if (selectedTag) {
handleTagSelect(selectedTag);
}
}}
icon={<FiTag size={16} />}
defaultDisplay="Tags"
resetValues={resetTags}
dropdownColor="bg-background-search-filter-dropdown"
width="w-fit max-w-24 ellipsis truncate"
dropdownWidth="max-w-80 w-fit"
optionClassName="truncate w-full break-all ellipsis"
/>
)}
</div>
);
}

View File

@@ -21,7 +21,9 @@ export default function FixedLogo({
return (
<>
<Link
href="/chat"
href={
settings && settings.default_page === "chat" ? "/chat" : "/search"
}
className="fixed cursor-pointer flex z-40 left-2.5 top-2"
>
<div className="max-w-[200px] mobile:hidden flex items-center gap-x-1 my-auto">
@@ -47,7 +49,7 @@ export default function FixedLogo({
</div>
</Link>
<div className="mobile:hidden fixed left-2.5 bottom-4">
{/* <FiSidebar className="text-text-mobile-sidebar" /> */}
<FiSidebar className="text-text-mobile-sidebar" />
</div>
</>
);

View File

@@ -1,7 +1,90 @@
"use client";
import React, { ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import React, { ReactNode, useContext, useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { ChatIcon, SearchIcon } from "@/components/icons/icons";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import KeyboardSymbol from "@/lib/browserUtilities";
const ToggleSwitch = () => {
const commandSymbol = KeyboardSymbol();
const pathname = usePathname();
const router = useRouter();
const settings = useContext(SettingsContext);
const [activeTab, setActiveTab] = useState(() => {
return pathname == "/search" ? "search" : "chat";
});
const [isInitialLoad, setIsInitialLoad] = useState(true);
useEffect(() => {
const newTab = pathname === "/search" ? "search" : "chat";
setActiveTab(newTab);
localStorage.setItem("activeTab", newTab);
setIsInitialLoad(false);
}, [pathname]);
const handleTabChange = (tab: string) => {
setActiveTab(tab);
localStorage.setItem("activeTab", tab);
if (settings?.isMobile && window) {
window.location.href = tab;
} else {
router.push(tab === "search" ? "/search" : "/chat");
}
};
return (
<div className="bg-background-toggle mobile:mt-8 flex rounded-full p-1">
<div
className={`absolute mobile:mt-8 top-1 bottom-1 ${
activeTab === "chat" ? "w-[45%]" : "w-[50%]"
} bg-white rounded-full shadow ${
isInitialLoad ? "" : "transition-transform duration-300 ease-in-out"
} ${activeTab === "chat" ? "translate-x-[115%]" : "translate-x-[1%]"}`}
/>
<button
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-300 ease-in-out flex items-center relative z-10 ${
activeTab === "search"
? "text-text-application-toggled"
: "text-text-application-untoggled hover:text-text-application-untoggled-hover"
}`}
onClick={() => handleTabChange("search")}
>
<SearchIcon size={16} className="mr-2" />
<div className="flex items-center">
Search
<div className="ml-2 flex content-center">
<span className="leading-none pb-[1px] my-auto">
{commandSymbol}
</span>
<span className="my-auto">S</span>
</div>
</div>
</button>
<button
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors duration-300 ease-in-out flex items-center relative z-10 ${
activeTab === "chat"
? "text-text-application-toggled"
: "text-text-application-untoggled hover:text-text-application-untoggled-hover"
}`}
onClick={() => handleTabChange("chat")}
>
<ChatIcon size={16} className="mr-2" />
<div className="items-end flex">
Chat
<div className="ml-2 flex content-center">
<span className="leading-none pb-[1px] my-auto">
{commandSymbol}
</span>
<span className="my-auto">D</span>
</div>
</div>
</button>
</div>
);
};
export default function FunctionalWrapper({
initiallyToggled,
@@ -45,6 +128,12 @@ export default function FunctionalWrapper({
window.removeEventListener("keydown", handleKeyDown);
};
}, [router]);
const combinedSettings = useContext(SettingsContext);
const settings = combinedSettings?.settings;
const chatBannerPresent =
combinedSettings?.enterpriseSettings?.custom_header_content;
const twoLines =
combinedSettings?.enterpriseSettings?.two_lines_for_chat_header;
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
@@ -56,7 +145,24 @@ export default function FunctionalWrapper({
return (
<>
{" "}
{(!settings ||
(settings.search_page_enabled && settings.chat_page_enabled)) && (
<div
className={`mobile:hidden z-30 flex fixed ${
chatBannerPresent ? (twoLines ? "top-20" : "top-14") : "top-4"
} left-1/2 transform -translate-x-1/2`}
>
<div
style={{ transition: "width 0.30s ease-out" }}
className={`flex-none overflow-y-hidden bg-background-100 transition-all bg-opacity-80 duration-300 ease-in-out h-full
${toggledSidebar ? "w-[250px] " : "w-[0px]"}`}
/>
<div className="relative">
<ToggleSwitch />
</div>
</div>
)}
<div className="overscroll-y-contain overflow-y-scroll overscroll-contain left-0 top-0 w-full h-svh">
{content(toggledSidebar, toggle)}
</div>

View File

@@ -1,294 +0,0 @@
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
import { SourceMetadata } from "@/lib/search/interfaces";
import { InfoIcon, defaultTailwindCSS } from "@/components/icons/icons";
import { HoverPopup } from "@/components/HoverPopup";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
import { SourceIcon } from "@/components/SourceIcon";
import { Checkbox } from "@/components/ui/checkbox";
import { TagFilter } from "@/components/search/filtering/TagFilter";
import { CardContent } from "@/components/ui/card";
import { useEffect } from "react";
import { useState } from "react";
import { listSourceMetadata } from "@/lib/sources";
import { Calendar } from "@/components/ui/calendar";
import { getDateRangeString } from "@/lib/dateUtils";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ToolTipDetails } from "@/components/admin/connectors/Field";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipProvider } from "@radix-ui/react-tooltip";
const SectionTitle = ({
children,
modal,
}: {
children: string;
modal?: boolean;
}) => (
<div className={`mt-4 pb-2 ${modal ? "w-[80vw]" : "w-full"}`}>
<p className="text-sm font-semibold">{children}</p>
</div>
);
export interface SourceSelectorProps {
timeRange: DateRangePickerValue | null;
setTimeRange: React.Dispatch<
React.SetStateAction<DateRangePickerValue | null>
>;
showDocSidebar?: boolean;
selectedSources: SourceMetadata[];
setSelectedSources: React.Dispatch<React.SetStateAction<SourceMetadata[]>>;
selectedDocumentSets: string[];
setSelectedDocumentSets: React.Dispatch<React.SetStateAction<string[]>>;
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
availableDocumentSets: DocumentSet[];
existingSources: ValidSources[];
availableTags: Tag[];
filtersUntoggled: boolean;
modal?: boolean;
tagsOnLeft: boolean;
}
export function SourceSelector({
timeRange,
filtersUntoggled,
setTimeRange,
selectedSources,
setSelectedSources,
selectedDocumentSets,
setSelectedDocumentSets,
selectedTags,
setSelectedTags,
availableDocumentSets,
existingSources,
modal,
availableTags,
}: SourceSelectorProps) {
const handleSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
if (
prev.map((source) => source.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];
}
});
};
let allSourcesSelected = selectedSources.length > 0;
const toggleAllSources = () => {
if (allSourcesSelected) {
setSelectedSources([]);
} else {
const allSources = listSourceMetadata().filter((source) =>
existingSources.includes(source.internalName)
);
setSelectedSources(allSources);
}
};
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const calendar = document.querySelector(".rdp");
if (calendar && !calendar.contains(event.target as Node)) {
setIsCalendarOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<div>
{!filtersUntoggled && (
<CardContent className=" space-y-2">
<div>
<div className="flex py-2 mt-2 justify-start gap-x-2 items-center">
<p className="text-sm font-semibold">Time Range</p>
{timeRange && (
<button
onClick={(e) => {
e.stopPropagation();
setTimeRange(null);
}}
className="text-sm text-gray-500 hover:text-gray-700"
>
Clear
</button>
)}
</div>
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`w-full justify-start text-left font-normal`}
>
<span>
{getDateRangeString(timeRange?.from!, timeRange?.to!) ||
"Select a time range"}
</span>
</Button>
</PopoverTrigger>
<PopoverContent className="z-[10000] w-auto p-0" align="start">
<Calendar
mode="range"
selected={
timeRange
? {
from: new Date(timeRange.from),
to: new Date(timeRange.to),
}
: undefined
}
onSelect={(daterange) => {
const today = new Date();
const initialDate = daterange?.from
? new Date(
Math.min(daterange.from.getTime(), today.getTime())
)
: today;
const endDate = daterange?.to
? new Date(
Math.min(daterange.to.getTime(), today.getTime())
)
: today;
setTimeRange({
from: initialDate,
to: endDate,
selectValue: timeRange?.selectValue || "",
});
}}
className="rounded-md"
/>
</PopoverContent>
</Popover>
</div>
{availableTags.length > 0 && (
<div>
<SectionTitle modal={modal}>Tags</SectionTitle>
<TagFilter
modal={modal}
showTagsOnLeft={true}
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
</div>
)}
{existingSources.length > 0 && (
<div>
<SectionTitle modal={modal}>Sources</SectionTitle>
<div className="space-y-0">
{existingSources.length > 1 && (
<div className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2">
<Checkbox
id="select-all-sources"
checked={allSourcesSelected}
onCheckedChange={toggleAllSources}
/>
<label
htmlFor="select-all-sources"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Select All
</label>
</div>
)}
{listSourceMetadata()
.filter((source) =>
existingSources.includes(source.internalName)
)
.map((source) => (
<div
key={source.internalName}
className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2"
onClick={() => handleSelect(source)}
>
<Checkbox
checked={selectedSources
.map((s) => s.internalName)
.includes(source.internalName)}
/>
<SourceIcon
sourceType={source.internalName}
iconSize={16}
/>
<span className="text-sm">{source.displayName}</span>
</div>
))}
</div>
</div>
)}
{availableDocumentSets.length > 0 && (
<div>
<SectionTitle modal={modal}>Knowledge Sets</SectionTitle>
<div className="space-y-2">
{availableDocumentSets.map((documentSet) => (
<div
key={documentSet.name}
className="flex items-center space-x-2 cursor-pointer hover:bg-background-200 rounded-md p-2"
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<Checkbox
checked={selectedDocumentSets.includes(documentSet.name)}
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<InfoIcon
className={`${defaultTailwindCSS} h-4 w-4`}
/>
</TooltipTrigger>
<TooltipContent>
<div className="text-sm w-64">
<div className="font-medium">Description</div>
<div className="mt-1">
{documentSet.description}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span className="text-sm">{documentSet.name}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
)}
</div>
);
}

View File

@@ -55,7 +55,6 @@ export function WhitelabelingForm() {
<div>
<Formik
initialValues={{
auto_scroll: enterpriseSettings?.auto_scroll || false,
application_name: enterpriseSettings?.application_name || null,
use_custom_logo: enterpriseSettings?.use_custom_logo || false,
use_custom_logotype: enterpriseSettings?.use_custom_logotype || false,
@@ -72,7 +71,6 @@ export function WhitelabelingForm() {
enterpriseSettings?.enable_consent_screen || false,
}}
validationSchema={Yup.object().shape({
auto_scroll: Yup.boolean().nullable(),
application_name: Yup.string().nullable(),
use_custom_logo: Yup.boolean().required(),
use_custom_logotype: Yup.boolean().required(),

View File

@@ -1,5 +1,15 @@
import { fetchSettingsSS } from "@/components/settings/lib";
import { redirect } from "next/navigation";
export default async function Page() {
redirect("/chat");
const settings = await fetchSettingsSS();
if (!settings) {
redirect("/search");
}
if (settings.settings.default_page === "search") {
redirect("/search");
} else {
redirect("/chat");
}
}

View File

@@ -0,0 +1,24 @@
"use client";
import { SearchSection } from "@/components/search/SearchSection";
import FunctionalWrapper from "../chat/shared_chat_search/FunctionalWrapper";
export default function WrappedSearch({
searchTypeDefault,
initiallyToggled,
}: {
searchTypeDefault: string;
initiallyToggled: boolean;
}) {
return (
<FunctionalWrapper
initiallyToggled={initiallyToggled}
content={(toggledSidebar, toggle) => (
<SearchSection
toggle={toggle}
toggledSidebar={toggledSidebar}
defaultSearchType={searchTypeDefault}
/>
)}
/>
);
}

213
web/src/app/search/page.tsx Normal file
View File

@@ -0,0 +1,213 @@
import {
AuthTypeMetadata,
getAuthTypeMetadataSS,
getCurrentUserSS,
} from "@/lib/userSS";
import { redirect } from "next/navigation";
import { HealthCheckBanner } from "@/components/health/healthcheck";
import { fetchSS } from "@/lib/utilsSS";
import { CCPairBasicInfo, DocumentSet, Tag, User } from "@/lib/types";
import { cookies } from "next/headers";
import { SearchType } from "@/lib/search/interfaces";
import { Persona } from "../admin/assistants/interfaces";
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { personaComparator } from "../admin/assistants/lib";
import { FullEmbeddingModelResponse } from "@/components/embedding/interfaces";
import { ChatPopup } from "../chat/ChatPopup";
import {
FetchAssistantsResponse,
fetchAssistantsSS,
} from "@/lib/assistants/fetchAssistantsSS";
import { ChatSession } from "../chat/interfaces";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "@/components/resizable/constants";
import {
AGENTIC_SEARCH_TYPE_COOKIE_NAME,
NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN,
DISABLE_LLM_DOC_RELEVANCE,
} from "@/lib/constants";
import WrappedSearch from "./WrappedSearch";
import { SearchProvider } from "@/components/context/SearchContext";
import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "../admin/configuration/llm/interfaces";
import { headers } from "next/headers";
import {
hasCompletedWelcomeFlowSS,
WelcomeModal,
} from "@/components/initialSetup/welcome/WelcomeModalWrapper";
export default async function Home(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
// Disable caching so we always get the up to date connector / document set / persona info
// importantly, this prevents users from adding a connector, going back to the main page,
// and then getting hit with a "No Connectors" popup
noStore();
const requestCookies = await cookies();
const tasks = [
getAuthTypeMetadataSS(),
getCurrentUserSS(),
fetchSS("/manage/indexing-status"),
fetchSS("/manage/document-set"),
fetchAssistantsSS(),
fetchSS("/query/valid-tags"),
fetchSS("/query/user-searches"),
fetchLLMProvidersSS(),
];
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let results: (
| User
| Response
| AuthTypeMetadata
| FullEmbeddingModelResponse
| FetchAssistantsResponse
| LLMProviderDescriptor[]
| null
)[] = [null, null, null, null, null, null, null, null];
try {
results = await Promise.all(tasks);
} catch (e) {
console.log(`Some fetch failed for the main search page - ${e}`);
}
const authTypeMetadata = results[0] as AuthTypeMetadata | null;
const user = results[1] as User | null;
const ccPairsResponse = results[2] as Response | null;
const documentSetsResponse = results[3] as Response | null;
const [initialAssistantsList, assistantsFetchError] =
results[4] as FetchAssistantsResponse;
const tagsResponse = results[5] as Response | null;
const queryResponse = results[6] as Response | null;
const llmProviders = (results[7] || []) as LLMProviderDescriptor[];
const authDisabled = authTypeMetadata?.authType === "disabled";
if (!authDisabled && !user) {
const headersList = await headers();
const fullUrl = headersList.get("x-url") || "/search";
const searchParamsString = new URLSearchParams(
searchParams as unknown as Record<string, string>
).toString();
const redirectUrl = searchParamsString
? `${fullUrl}?${searchParamsString}`
: fullUrl;
return redirect(`/auth/login?next=${encodeURIComponent(redirectUrl)}`);
}
if (user && !user.is_verified && authTypeMetadata?.requiresVerification) {
return redirect("/auth/waiting-on-verification");
}
let ccPairs: CCPairBasicInfo[] = [];
if (ccPairsResponse?.ok) {
ccPairs = await ccPairsResponse.json();
} else {
console.log(`Failed to fetch connectors - ${ccPairsResponse?.status}`);
}
let documentSets: DocumentSet[] = [];
if (documentSetsResponse?.ok) {
documentSets = await documentSetsResponse.json();
} else {
console.log(
`Failed to fetch document sets - ${documentSetsResponse?.status}`
);
}
let querySessions: ChatSession[] = [];
if (queryResponse?.ok) {
querySessions = (await queryResponse.json()).sessions;
} else {
console.log(`Failed to fetch chat sessions - ${queryResponse?.text()}`);
}
let assistants: Persona[] = initialAssistantsList;
if (assistantsFetchError) {
console.log(`Failed to fetch assistants - ${assistantsFetchError}`);
} else {
// remove those marked as hidden by an admin
assistants = assistants.filter((assistant) => assistant.is_visible);
// hide personas with no retrieval
assistants = assistants.filter((assistant) => assistant.num_chunks !== 0);
// sort them in priority order
assistants.sort(personaComparator);
}
let tags: Tag[] = [];
if (tagsResponse?.ok) {
tags = (await tagsResponse.json()).tags;
} else {
console.log(`Failed to fetch tags - ${tagsResponse?.status}`);
}
// needs to be done in a non-client side component due to nextjs
const storedSearchType = requestCookies.get("searchType")?.value as
| string
| undefined;
const searchTypeDefault: SearchType =
storedSearchType !== undefined &&
SearchType.hasOwnProperty(storedSearchType)
? (storedSearchType as SearchType)
: SearchType.SEMANTIC; // default to semantic
const hasAnyConnectors = ccPairs.length > 0;
const shouldShowWelcomeModal =
!llmProviders.length &&
!hasCompletedWelcomeFlowSS(requestCookies) &&
!hasAnyConnectors &&
(!user || user.role === "admin");
const shouldDisplayNoSourcesModal =
(!user || user.role === "admin") &&
ccPairs.length === 0 &&
!shouldShowWelcomeModal;
const sidebarToggled = requestCookies.get(SIDEBAR_TOGGLED_COOKIE_NAME);
const agenticSearchToggle = requestCookies.get(
AGENTIC_SEARCH_TYPE_COOKIE_NAME
);
const toggleSidebar = sidebarToggled
? sidebarToggled.value.toLocaleLowerCase() == "true" || false
: NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN;
const agenticSearchEnabled = agenticSearchToggle
? agenticSearchToggle.value.toLocaleLowerCase() == "true" || false
: false;
return (
<>
<HealthCheckBanner />
<InstantSSRAutoRefresh />
{shouldShowWelcomeModal && (
<WelcomeModal user={user} requestCookies={requestCookies} />
)}
{/* ChatPopup is a custom popup that displays a admin-specified message on initial user visit.
Only used in the EE version of the app. */}
<ChatPopup />
<SearchProvider
value={{
querySessions,
ccPairs,
documentSets,
assistants,
tags,
agenticSearchEnabled,
disabledAgentic: DISABLE_LLM_DOC_RELEVANCE,
initiallyToggled: toggleSidebar,
shouldShowWelcomeModal,
shouldDisplayNoSources: shouldDisplayNoSourcesModal,
}}
>
<WrappedSearch
initiallyToggled={toggleSidebar}
searchTypeDefault={searchTypeDefault}
/>
</SearchProvider>
</>
);
}

View File

@@ -0,0 +1,9 @@
export function InternetSearchIcon({ url }: { url: string }) {
return (
<img
className="rounded-full w-[18px] h-[18px]"
src={`https://www.google.com/s2/favicons?sz=128&domain=${url}`}
alt="favicon"
/>
);
}

View File

@@ -1,11 +1,9 @@
export function MetadataBadge({
icon,
value,
flexNone,
}: {
icon?: React.FC<{ size?: number; className?: string }>;
value: string | JSX.Element;
flexNone?: boolean;
}) {
return (
<div
@@ -20,13 +18,9 @@ export function MetadataBadge({
w-fit
my-auto
select-none
${flexNone ? "flex-none" : ""}`}
`}
>
{icon &&
icon({
size: 12,
className: flexNone ? "flex-none" : "mr-0.5 my-auto",
})}
{icon && icon({ size: 12, className: "mr-0.5 my-auto" })}
<div className="my-auto flex">{value}</div>
</div>
);

View File

@@ -1,11 +1,11 @@
"use client";
import { Separator } from "@/components/ui/separator";
import { FiX } from "react-icons/fi";
import { IconProps, XIcon } from "./icons/icons";
import { useRef } from "react";
import { isEventWithinRef } from "@/lib/contains";
import ReactDOM from "react-dom";
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
interface ModalProps {
icon?: ({ size, className }: IconProps) => JSX.Element;
@@ -18,8 +18,6 @@ interface ModalProps {
hideDividerForTitle?: boolean;
hideCloseButton?: boolean;
noPadding?: boolean;
height?: string;
noScroll?: boolean;
}
export function Modal({
@@ -30,11 +28,9 @@ export function Modal({
width,
titleSize,
hideDividerForTitle,
height,
noPadding,
icon,
hideCloseButton,
noScroll,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [isMounted, setIsMounted] = useState(false);
@@ -60,10 +56,8 @@ export function Modal({
const modalContent = (
<div
onMouseDown={handleMouseDown}
className={cn(
`fixed inset-0 bg-black bg-opacity-25 backdrop-blur-sm h-full
flex items-center justify-center z-[9999] transition-opacity duration-300 ease-in-out`
)}
className={`fixed inset-0 bg-black bg-opacity-25 backdrop-blur-sm h-full
flex items-center justify-center z-[9999] transition-opacity duration-300 ease-in-out`}
>
<div
ref={modalRef}
@@ -99,7 +93,8 @@ export function Modal({
</button>
</div>
)}
<div className="w-full overflow-y-hidden flex flex-col h-full justify-stretch">
<div className="w-full flex flex-col h-full justify-stretch">
{title && (
<>
<div className="flex mb-4">
@@ -115,14 +110,7 @@ export function Modal({
{!hideDividerForTitle && <Separator />}
</>
)}
<div
className={cn(
noScroll ? "overflow-auto" : "overflow-x-hidden",
height || "max-h-[60vh]"
)}
>
{children}
</div>
<div className="max-h-[60vh] overflow-y-scroll">{children}</div>
</div>
</div>
</div>

View File

@@ -1,65 +0,0 @@
import { useState, useEffect } from "react";
import faviconFetch from "favicon-fetch";
import { SourceIcon } from "./SourceIcon";
const CACHE_DURATION = 24 * 60 * 60 * 1000;
export async function getFaviconUrl(url: string): Promise<string | null> {
const getCachedFavicon = () => {
const cachedData = localStorage.getItem(`favicon_${url}`);
if (cachedData) {
const { favicon, timestamp } = JSON.parse(cachedData);
if (Date.now() - timestamp < CACHE_DURATION) {
return favicon;
}
}
return null;
};
const cachedFavicon = getCachedFavicon();
if (cachedFavicon) {
return cachedFavicon;
}
const newFaviconUrl = await faviconFetch({ uri: url });
if (newFaviconUrl) {
localStorage.setItem(
`favicon_${url}`,
JSON.stringify({ favicon: newFaviconUrl, timestamp: Date.now() })
);
return newFaviconUrl;
}
return null;
}
export function SearchResultIcon({ url }: { url: string }) {
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
useEffect(() => {
getFaviconUrl(url).then((favicon) => {
if (favicon) {
setFaviconUrl(favicon);
}
});
}, [url]);
if (!faviconUrl) {
return <SourceIcon sourceType="web" iconSize={18} />;
}
return (
<div className="rounded-full w-[18px] h-[18px] overflow-hidden bg-gray-200">
<img
height={18}
width={18}
className="rounded-full w-full h-full object-cover"
src={faviconUrl}
alt="favicon"
onError={(e) => {
e.currentTarget.onerror = null;
}}
/>
</div>
);
}

View File

@@ -9,7 +9,7 @@ import { checkUserIsNoAuthUser, logout } from "@/lib/user";
import { Popover } from "./popover/Popover";
import { LOGOUT_DISABLED } from "@/lib/constants";
import { SettingsContext } from "./settings/SettingsProvider";
import { BellIcon, LightSettingsIcon, UserIcon } from "./icons/icons";
import { BellIcon, LightSettingsIcon } from "./icons/icons";
import { pageType } from "@/app/chat/sessionSidebar/types";
import { NavigationItem, Notification } from "@/app/admin/settings/interfaces";
import DynamicFaIcon, { preloadIcons } from "./icons/DynamicFaIcon";
@@ -56,13 +56,7 @@ const DropdownOption: React.FC<DropdownOptionProps> = ({
}
};
export function UserDropdown({
page,
toggleUserSettings,
}: {
page?: pageType;
toggleUserSettings?: () => void;
}) {
export function UserDropdown({ page }: { page?: pageType }) {
const { user, isCurator } = useUser();
const [userInfoVisible, setUserInfoVisible] = useState(false);
const userInfoRef = useRef<HTMLDivElement>(null);
@@ -244,13 +238,6 @@ export function UserDropdown({
)
)}
{toggleUserSettings && (
<DropdownOption
onClick={toggleUserSettings}
icon={<UserIcon className="h-5 w-5 my-auto mr-2" />}
label="User Settings"
/>
)}
<DropdownOption
onClick={() => {
setUserInfoVisible(true);

View File

@@ -1,16 +0,0 @@
import { SourceIcon } from "./SourceIcon";
export function WebResultIcon({ url }: { url: string }) {
const hostname = new URL(url).hostname;
return hostname == "https://docs.danswer.dev" ? (
<img
className="my-0 py-0"
src={`https://www.google.com/s2/favicons?domain=${hostname}`}
alt="favicon"
height={18}
width={18}
/>
) : (
<SourceIcon sourceType="web" iconSize={18} />
);
}

View File

@@ -40,7 +40,14 @@ export function AdminSidebar({ collections }: { collections: Collection[] }) {
<nav className="space-y-2">
<div className="w-full justify-center mb-4 flex">
<div className="w-52">
<Link className="flex flex-col" href="/chat">
<Link
className="flex flex-col"
href={
settings && settings.default_page === "chat"
? "/chat"
: "/search"
}
>
<div className="max-w-[200px] w-full flex gap-x-1 my-auto">
<div className="flex-none mb-auto">
<Logo />
@@ -66,7 +73,7 @@ export function AdminSidebar({ collections }: { collections: Collection[] }) {
</div>
</div>
<div className="flex w-full justify-center">
<Link href="/chat">
<Link href={settings.default_page == "chat" ? "/chat" : "/search"}>
<button className="text-sm flex items-center block w-52 py-2.5 flex px-2 text-left text-text-back-button bg-background-back-button hover:bg-opacity-80 cursor-pointer rounded">
<BackIcon className="my-auto" size={18} />
<p className="ml-1 break-words line-clamp-2 ellipsis leading-none">

View File

@@ -13,9 +13,7 @@ export default function AssistantBanner({
liveAssistant,
allAssistants,
onAssistantChange,
mobile = false,
}: {
mobile?: boolean;
recentAssistants: Persona[];
liveAssistant: Persona | undefined;
allAssistants: Persona[];
@@ -37,15 +35,13 @@ export default function AssistantBanner({
)
)
// Take first 4
.slice(0, mobile ? 2 : 4)
.slice(0, 4)
.map((assistant) => (
<TooltipProvider key={assistant.id}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={`${
mobile ? "w-full" : "w-36 mx-3"
} flex py-1.5 scale-[1.] rounded-full border border-background-150 justify-center items-center gap-x-2 py-1 px-3 hover:bg-background-125 transition-colors cursor-pointer`}
className="flex w-36 mx-3 py-1.5 scale-[1.] rounded-full border border-background-150 justify-center items-center gap-x-2 py-1 px-3 hover:bg-background-125 transition-colors cursor-pointer"
onClick={() => onAssistantChange(assistant)}
>
<AssistantIcon

View File

@@ -1,85 +1,77 @@
import { Persona } from "@/app/admin/assistants/interfaces";
import { AssistantTools } from "@/app/assistants/ToolsDisplay";
import { Bubble } from "@/components/Bubble";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { getDisplayNameForModel } from "@/lib/hooks";
import { useSortable } from "@dnd-kit/sortable";
import React, { useState } from "react";
import { FiBookmark, FiImage, FiSearch } from "react-icons/fi";
import { FiBookmark } from "react-icons/fi";
import { MdDragIndicator } from "react-icons/md";
import { Badge } from "../ui/badge";
export const AssistantCard = ({
assistant,
isSelected,
onSelect,
llmName,
}: {
assistant: Persona;
isSelected: boolean;
onSelect: (assistant: Persona) => void;
llmName: string;
}) => {
const renderBadgeContent = (tool: { name: string }) => {
switch (tool.name) {
case "SearchTool":
return (
<>
<FiSearch className="h-3 w-3 my-auto" />
<span>Search</span>
</>
);
case "ImageGenerationTool":
return (
<>
<FiImage className="h-3 w-3 my-auto" />
<span>Image Gen</span>
</>
);
default:
return tool.name;
}
};
const [hovering, setHovering] = useState(false);
return (
<div
onClick={() => onSelect(assistant)}
className={`
flex flex-col overflow-hidden w-full rounded-xl px-3 py-4
p-4
cursor-pointer
${isSelected ? "bg-background-125" : "hover:bg-background-100"}
border
${isSelected ? "bg-hover" : "hover:bg-hover-light"}
shadow-md
rounded-lg
border-border
grow
flex items-center
overflow-hidden
`}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
<div className="flex items-center gap-4">
<AssistantIcon size="xs" assistant={assistant} />
<div className="overflow-hidden text-ellipsis break-words flex-grow">
<div className="flex items-start justify-start gap-2">
<span className="line-clamp-1 text-sm text-black font-semibold leading-tight">
{assistant.name}
</span>
{assistant.tools.map((tool, index) => (
<Badge key={index} size="xs" variant="secondary" className="ml-1">
<div className="flex items-center gap-1">
{renderBadgeContent(tool)}
</div>
</Badge>
))}
<div className="w-full">
<div className="flex items-center mb-2">
<AssistantIcon assistant={assistant} />
<div className="ml-2 ellipsis truncate font-bold text-sm text-emphasis">
{assistant.name}
</div>
<span className="line-clamp-2 text-xs text-text-700">
{assistant.description}
</span>
</div>
<div className="text-xs text-wrap text-subtle mb-2 mt-2 line-clamp-3 py-1">
{assistant.description}
</div>
<div className="mt-2 flex flex-col gap-y-1">
{assistant.document_sets.length > 0 && (
<div className="text-xs text-subtle flex flex-wrap gap-2">
<p className="my-auto font-medium">Document Sets:</p>
{assistant.document_sets.map((set) => (
<Bubble key={set.id} isSelected={false}>
<div className="flex flex-row gap-1">
<FiBookmark className="mr-1 my-auto" />
{set.name}
</div>
</Bubble>
))}
</div>
)}
<div className="text-xs text-subtle">
<span className="font-semibold">Default model:</span>{" "}
{getDisplayNameForModel(
assistant.llm_model_version_override || llmName
)}
</div>
<AssistantTools hovered={hovering} assistant={assistant} />
</div>
</div>
{assistant.document_sets.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{assistant.document_sets.map((set) => (
<Bubble key={set.id} isSelected={false}>
<div className="flex items-center gap-1 text-xs">
<FiBookmark className="text-text-500" />
{set.name}
</div>
</Bubble>
))}
</div>
)}
</div>
);
};

View File

@@ -1,347 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useChatContext } from "@/components/context/ChatContext";
import { useUser } from "@/components/user/UserProvider";
import { Persona } from "@/app/admin/assistants/interfaces";
import { FiChevronDown } from "react-icons/fi";
import { destructureValue, getFinalLLM } from "@/lib/llm/utils";
import { updateModelOverrideForChatSession } from "@/app/chat/lib";
import { debounce } from "lodash";
import { LlmList } from "@/components/llm/LLMList";
import { checkPersonaRequiresImageGeneration } from "@/app/admin/assistants/lib";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { DraggableAssistantCard } from "@/components/assistants/AssistantCards";
import { updateUserAssistantList } from "@/lib/assistants/updateAssistantPreferences";
import Text from "@/components/ui/text";
import { getDisplayNameForModel, LlmOverrideManager } from "@/lib/hooks";
import { Tab } from "@headlessui/react";
import { AssistantIcon } from "../assistants/AssistantIcon";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { restrictToParentElement } from "@dnd-kit/modifiers";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "../ui/drawer";
import { truncateString } from "@/lib/utils";
const AssistantSelector = ({
liveAssistant,
onAssistantChange,
chatSessionId,
llmOverrideManager,
isMobile,
}: {
liveAssistant: Persona;
onAssistantChange: (assistant: Persona) => void;
chatSessionId?: string;
llmOverrideManager?: LlmOverrideManager;
isMobile: boolean;
}) => {
const { finalAssistants } = useAssistants();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { llmProviders } = useChatContext();
const { user } = useUser();
const [assistants, setAssistants] = useState<Persona[]>(finalAssistants);
const [isTemperatureExpanded, setIsTemperatureExpanded] = useState(false);
const [localTemperature, setLocalTemperature] = useState<number>(
llmOverrideManager?.temperature || 0
);
// Initialize selectedTab from localStorage
const [selectedTab, setSelectedTab] = useState<number>(() => {
const storedTab = localStorage.getItem("assistantSelectorSelectedTab");
return storedTab !== null ? Number(storedTab) : 0;
});
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = assistants.findIndex(
(item) => item.id.toString() === active.id
);
const newIndex = assistants.findIndex(
(item) => item.id.toString() === over.id
);
const updatedAssistants = arrayMove(assistants, oldIndex, newIndex);
setAssistants(updatedAssistants);
await updateUserAssistantList(updatedAssistants.map((a) => a.id));
}
};
const debouncedSetTemperature = useCallback(
(value: number) => {
const debouncedFunction = debounce((value: number) => {
llmOverrideManager?.setTemperature(value);
}, 300);
return debouncedFunction(value);
},
[llmOverrideManager]
);
const handleTemperatureChange = (value: number) => {
setLocalTemperature(value);
debouncedSetTemperature(value);
};
// Handle tab change and update localStorage
const handleTabChange = (index: number) => {
setSelectedTab(index);
localStorage.setItem("assistantSelectorSelectedTab", index.toString());
};
// Get the user's default model
const userDefaultModel = user?.preferences.default_model;
const [_, currentLlm] = getFinalLLM(
llmProviders,
liveAssistant,
llmOverrideManager?.llmOverride ?? null
);
const requiresImageGeneration =
checkPersonaRequiresImageGeneration(liveAssistant);
const content = (
<>
<Tab.Group selectedIndex={selectedTab} onChange={handleTabChange}>
<Tab.List className="flex p-1 space-x-1 bg-gray-100 rounded-t-md">
<Tab
className={({ selected }) =>
`w-full py-2.5 text-sm leading-5 font-medium rounded-md
${
selected
? "bg-white text-gray-700 shadow"
: "text-gray-500 hover:bg-white/[0.12] hover:text-gray-700"
}`
}
>
Assistant
</Tab>
<Tab
className={({ selected }) =>
`w-full py-2.5 text-sm leading-5 font-medium rounded-md
${
selected
? "bg-white text-gray-700 shadow"
: "text-gray-500 hover:bg-white/[0.12] hover:text-gray-700"
}`
}
>
Model
</Tab>
</Tab.List>
<Tab.Panels>
<Tab.Panel className="p-3">
<div className="mb-4">
<h3 className="text-center text-lg font-semibold text-gray-800">
Choose an Assistant
</h3>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
>
<SortableContext
items={assistants.map((a) => a.id.toString())}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{assistants.map((assistant) => (
<DraggableAssistantCard
key={assistant.id.toString()}
assistant={assistant}
isSelected={liveAssistant.id === assistant.id}
onSelect={(assistant) => {
onAssistantChange(assistant);
setIsOpen(false);
}}
llmName={
assistant.llm_model_version_override ??
userDefaultModel ??
currentLlm
}
/>
))}
</div>
</SortableContext>
</DndContext>
</Tab.Panel>
<Tab.Panel className="p-3">
<div className="mb-4">
<h3 className="text-center text-lg font-semibold text-gray-800 ">
Choose a Model
</h3>
</div>
<LlmList
currentAssistant={liveAssistant}
requiresImageGeneration={requiresImageGeneration}
llmProviders={llmProviders}
currentLlm={currentLlm}
userDefault={userDefaultModel}
includeUserDefault={true}
onSelect={(value: string | null) => {
if (value == null) return;
const { modelName, name, provider } = destructureValue(value);
llmOverrideManager?.setLlmOverride({
name,
provider,
modelName,
});
if (chatSessionId) {
updateModelOverrideForChatSession(chatSessionId, value);
}
setIsOpen(false);
}}
/>
<div className="mt-4">
<button
className="flex items-center text-sm font-medium transition-colors duration-200"
onClick={() => setIsTemperatureExpanded(!isTemperatureExpanded)}
>
<span className="mr-2 text-xs text-primary">
{isTemperatureExpanded ? "▼" : "►"}
</span>
<span>Temperature</span>
</button>
{isTemperatureExpanded && (
<>
<Text className="mt-2 mb-8">
Adjust the temperature of the LLM. Higher temperatures will
make the LLM generate more creative and diverse responses,
while lower temperature will make the LLM generate more
conservative and focused responses.
</Text>
<div className="relative w-full">
<input
type="range"
onChange={(e) =>
handleTemperatureChange(parseFloat(e.target.value))
}
className="w-full p-2 border border-border rounded-md"
min="0"
max="2"
step="0.01"
value={localTemperature}
/>
<div
className="absolute text-sm"
style={{
left: `${(localTemperature || 0) * 50}%`,
transform: `translateX(-${Math.min(
Math.max((localTemperature || 0) * 50, 10),
90
)}%)`,
top: "-1.5rem",
}}
>
{localTemperature}
</div>
</div>
</>
)}
</div>
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</>
);
useEffect(() => {
if (!isMobile) {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [isMobile]);
return (
<div className="pointer-events-auto relative" ref={dropdownRef}>
<div className="flex justify-center">
<div
onClick={() => {
setIsOpen(!isOpen);
// Get selectedTab from localStorage when opening
const storedTab = localStorage.getItem(
"assistantSelectorSelectedTab"
);
setSelectedTab(storedTab !== null ? Number(storedTab) : 0);
}}
className="flex items-center gap-x-2 justify-between px-6 py-3 text-sm font-medium text-white bg-black rounded-full shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer"
>
<div className="flex gap-x-2 items-center">
<AssistantIcon assistant={liveAssistant} size="xs" />
<span className="font-bold">{liveAssistant.name}</span>
</div>
<div className="flex items-center">
<span className="mr-2 text-xs">
{truncateString(getDisplayNameForModel(currentLlm), 30)}
</span>
<FiChevronDown
className={`w-5 h-5 text-white transition-transform duration-300 transform ${
isOpen ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</div>
</div>
</div>
{isMobile ? (
<Drawer open={isOpen} onOpenChange={setIsOpen}>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Assistant Selector</DrawerTitle>
</DrawerHeader>
{content}
</DrawerContent>
</Drawer>
) : (
isOpen && (
<div className="absolute z-10 w-96 mt-2 origin-top-center left-1/2 transform -translate-x-1/2 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{content}
</div>
)
)}
</div>
);
};
export default AssistantSelector;

View File

@@ -11,8 +11,7 @@ import { pageType } from "@/app/chat/sessionSidebar/types";
import { useRouter } from "next/navigation";
import { ChatBanner } from "@/app/chat/ChatBanner";
import LogoType from "../header/LogoType";
import { Persona } from "@/app/admin/assistants/interfaces";
import { LlmOverrideManager } from "@/lib/hooks";
import { useUser } from "../user/UserProvider";
export default function FunctionalHeader({
page,
@@ -21,23 +20,13 @@ export default function FunctionalHeader({
toggleSidebar = () => null,
reset = () => null,
sidebarToggled,
liveAssistant,
onAssistantChange,
llmOverrideManager,
documentSidebarToggled,
toggleUserSettings,
}: {
reset?: () => void;
page: pageType;
sidebarToggled?: boolean;
documentSidebarToggled?: boolean;
currentChatSession?: ChatSession | null | undefined;
setSharingModalVisible?: (value: SetStateAction<boolean>) => void;
toggleSidebar?: () => void;
liveAssistant?: Persona;
onAssistantChange?: (assistant: Persona) => void;
llmOverrideManager?: LlmOverrideManager;
toggleUserSettings?: () => void;
}) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -74,15 +63,14 @@ export default function FunctionalHeader({
router.push(newChatUrl);
};
return (
<div className="left-0 sticky top-0 z-20 w-full relative flex">
<div className="mt-2 cursor-pointer text-text-700 relative flex w-full">
<div className="left-0 bg-transparent sticky top-0 z-20 w-full relative flex">
<div className="mt-2 mx-2.5 cursor-pointer text-text-700 relative flex w-full">
<LogoType
assistantId={currentChatSession?.persona_id}
page={page}
toggleSidebar={toggleSidebar}
handleNewChat={handleNewChat}
/>
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
@@ -97,7 +85,6 @@ export default function FunctionalHeader({
${sidebarToggled ? "w-[250px]" : "w-[0px]"}
`}
/>
<div className="w-full mobile:-mx-20 desktop:px-4">
<ChatBanner />
</div>
@@ -121,7 +108,7 @@ export default function FunctionalHeader({
)}
<div className="mobile:hidden flex my-auto">
<UserDropdown page={page} toggleUserSettings={toggleUserSettings} />
<UserDropdown />
</div>
<Link
className="desktop:hidden my-auto"
@@ -137,37 +124,12 @@ export default function FunctionalHeader({
<NewChatIcon size={20} />
</div>
</Link>
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
mobile:hidden
flex-none
mx-auto
overflow-y-hidden
transition-all
duration-300
ease-in-out
h-full
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
/>
</div>
{page != "assistants" && (
<div
className={`
h-20 absolute top-0 z-10 w-full sm:w-[90%] lg:w-[70%]
bg-gradient-to-b via-50% z-[-1] from-background via-background to-background/10 flex
transition-all duration-300 ease-in-out
${
documentSidebarToggled
? "left-[200px] transform -translate-x-[calc(50%+100px)]"
: "left-1/2 transform -translate-x-1/2"
}
`}
/>
)}
</div>
{page != "assistants" && (
<div className="h-20 left-0 absolute top-0 z-10 w-full bg-gradient-to-b via-50% z-[-1] from-background via-background to-background/10 flex" />
)}
</div>
);
}

View File

@@ -1,173 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Download, XIcon, ZoomIn, ZoomOut } from "lucide-react";
import { DanswerDocument } from "@/lib/search/interfaces";
import { MinimalMarkdown } from "./MinimalMarkdown";
interface TextViewProps {
presentingDocument: DanswerDocument;
onClose: () => void;
}
export default function TextView({
presentingDocument,
onClose,
}: TextViewProps) {
const [zoom, setZoom] = useState(100);
const [fileContent, setFileContent] = useState<string>("");
const [fileUrl, setFileUrl] = useState<string>("");
const [fileName, setFileName] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [fileType, setFileType] = useState<string>("application/octet-stream");
const isMarkdownFormat = (mimeType: string): boolean => {
const markdownFormats = [
"text/markdown",
"text/x-markdown",
"text/plain",
"text/x-rst",
"text/x-org",
];
return markdownFormats.some((format) => mimeType.startsWith(format));
};
const isSupportedIframeFormat = (mimeType: string): boolean => {
const supportedFormats = [
"application/pdf",
"image/png",
"image/jpeg",
"image/gif",
"image/svg+xml",
];
return supportedFormats.some((format) => mimeType.startsWith(format));
};
const fetchFile = useCallback(async () => {
setIsLoading(true);
const fileId = presentingDocument.document_id.split("__")[1];
try {
const response = await fetch(
`/api/chat/file/${encodeURIComponent(fileId)}`,
{
method: "GET",
}
);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
setFileUrl(url);
setFileName(presentingDocument.semantic_identifier || "document");
const contentType =
response.headers.get("Content-Type") || "application/octet-stream";
setFileType(contentType);
if (isMarkdownFormat(blob.type)) {
const text = await blob.text();
setFileContent(text);
}
} catch (error) {
console.error("Error fetching file:", error);
} finally {
setTimeout(() => {
setIsLoading(false);
}, 1000);
}
}, [presentingDocument]);
useEffect(() => {
fetchFile();
}, [fetchFile]);
const handleDownload = () => {
const link = document.createElement("a");
link.href = fileUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 25, 200));
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 25, 100));
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent
hideCloseIcon
className="max-w-5xl w-[90vw] flex flex-col justify-between gap-y-0 h-full max-h-[80vh] p-0"
>
<DialogHeader className="px-4 mb-0 pt-2 pb-3 flex flex-row items-center justify-between border-b">
<DialogTitle className="text-lg font-medium truncate">
{fileName}
</DialogTitle>
<div className="flex items-center space-x-2">
<Button variant="ghost" size="icon" onClick={handleZoomOut}>
<ZoomOut className="h-4 w-4" />
<span className="sr-only">Zoom Out</span>
</Button>
<span className="text-sm">{zoom}%</span>
<Button variant="ghost" size="icon" onClick={handleZoomIn}>
<ZoomIn className="h-4 w-4" />
<span className="sr-only">Zoom In</span>
</Button>
<Button variant="ghost" size="icon" onClick={handleDownload}>
<Download className="h-4 w-4" />
<span className="sr-only">Download</span>
</Button>
<Button variant="ghost" size="icon" onClick={() => onClose()}>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
</DialogHeader>
<div className="mt-0 rounded-b-lg flex-1 overflow-hidden">
<div className="flex items-center justify-center w-full h-full">
{isLoading ? (
<div className="flex flex-col items-center justify-center h-full">
<div className="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-primary"></div>
<p className="mt-6 text-lg font-medium text-muted-foreground">
Loading document...
</p>
</div>
) : (
<div
className={`w-full h-full transform origin-center transition-transform duration-300 ease-in-out`}
style={{ transform: `scale(${zoom / 100})` }}
>
{isSupportedIframeFormat(fileType) ? (
<iframe
src={`${fileUrl}#toolbar=0`}
className="w-full h-full border-none"
title="File Viewer"
/>
) : isMarkdownFormat(fileType) ? (
<div className="w-full h-full p-6 overflow-y-scroll overflow-x-hidden">
<MinimalMarkdown
content={fileContent}
className="w-full pb-4 h-full text-lg text-wrap break-words"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-lg font-medium text-muted-foreground">
This file format is not supported for preview.
</p>
<Button className="mt-4" onClick={handleDownload}>
Download File
</Button>
</div>
)}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,74 +0,0 @@
import { WebResultIcon } from "@/components/WebResultIcon";
import { SourceIcon } from "@/components/SourceIcon";
import { DanswerDocument } from "@/lib/search/interfaces";
import { truncateString } from "@/lib/utils";
export default function SourceCard({ doc }: { doc: DanswerDocument }) {
return (
<a
key={doc.document_id}
href={doc.link || undefined}
target="_blank"
rel="noopener"
className="flex flex-col gap-0.5 rounded-sm px-3 py-2.5 hover:bg-background-125 bg-background-100 w-[200px]"
>
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center gap-2 text-sm">
{doc.is_internet || doc.source_type === "web" ? (
<WebResultIcon url={doc.link} />
) : (
<SourceIcon sourceType={doc.source_type} iconSize={18} />
)}
<p>{truncateString(doc.semantic_identifier || doc.document_id, 12)}</p>
</div>
<div className="line-clamp-2 text-sm font-semibold"></div>
<div className="line-clamp-2 text-sm font-normal leading-snug text-text-700">
{doc.blurb}
</div>
</a>
);
}
interface SeeMoreBlockProps {
documentSelectionToggled: boolean;
toggleDocumentSelection?: () => void;
uniqueSources: DanswerDocument["source_type"][];
}
export function SeeMoreBlock({
documentSelectionToggled,
toggleDocumentSelection,
uniqueSources,
}: SeeMoreBlockProps) {
return (
<div
onClick={toggleDocumentSelection}
className={`
${documentSelectionToggled ? "border-border-100 border" : ""}
cursor-pointer w-[150px] rounded-sm flex-none transition-all duration-500 hover:bg-background-125 bg-text-100 px-3 py-2.5
`}
>
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center justify-between text-sm">
<p className="mr-0 flex-shrink-0">
{documentSelectionToggled ? "Hide sources" : "See context"}
</p>
<div className="flex -space-x-3 flex-shrink-0 overflow-hidden">
{uniqueSources.map((sourceType, ind) => (
<div
key={ind}
className="inline-block bg-background-100 rounded-full p-0.5"
style={{ zIndex: uniqueSources.length - ind }}
>
<div className="bg-background-100 rounded-full">
<SourceIcon sourceType={sourceType} iconSize={20} />
</div>
</div>
))}
</div>
</div>
<div className="line-clamp-2 text-sm font-semibold"></div>
<div className="line-clamp-2 text-sm font-normal leading-snug text-text-700">
See more
</div>
</div>
);
}

View File

@@ -1,13 +1,7 @@
"use client";
import React, { createContext, useContext, useState } from "react";
import {
CCPairBasicInfo,
DocumentSet,
Tag,
User,
ValidSources,
} from "@/lib/types";
import { DocumentSet, Tag, User, ValidSources } from "@/lib/types";
import { ChatSession } from "@/app/chat/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { LLMProviderDescriptor } from "@/app/admin/configuration/llm/interfaces";
@@ -18,9 +12,6 @@ import { personaComparator } from "@/app/admin/assistants/lib";
interface ChatContextProps {
chatSessions: ChatSession[];
availableSources: ValidSources[];
ccPairs: CCPairBasicInfo[];
tags: Tag[];
documentSets: DocumentSet[];
availableDocumentSets: DocumentSet[];
availableTags: Tag[];
llmProviders: LLMProviderDescriptor[];

View File

@@ -8,9 +8,9 @@ import { ChatSession } from "@/app/chat/interfaces";
interface SearchContextProps {
querySessions: ChatSession[];
ccPairs: CCPairBasicInfo[];
tags: Tag[];
documentSets: DocumentSet[];
assistants: Persona[];
tags: Tag[];
agenticSearchEnabled: boolean;
disabledAgentic: boolean;
initiallyToggled: boolean;

View File

@@ -81,9 +81,7 @@ import cohereIcon from "../../../public/Cohere.svg";
import voyageIcon from "../../../public/Voyage.png";
import googleIcon from "../../../public/Google.webp";
import xenforoIcon from "../../../public/Xenforo.svg";
import { FaGithub, FaRobot } from "react-icons/fa";
import { isConstructSignatureDeclaration } from "typescript";
import { cn } from "@/lib/utils";
import { FaRobot } from "react-icons/fa";
export interface IconProps {
size?: number;
@@ -476,6 +474,13 @@ export const XSquareIcon = ({
return <XSquare size={size} className={className} />;
};
export const GlobeIcon = ({
size = 16,
className = defaultTailwindCSSBlue,
}: IconProps) => {
return <FiGlobe size={size} className={className} />;
};
export const FileIcon = ({
size = 16,
className = defaultTailwindCSSBlue,
@@ -1029,16 +1034,9 @@ export const GithubIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => (
<FaGithub size={size} className={cn(className, "text-black")} />
<LogoIcon size={size} className={className} src="/Github.png" />
);
export const GlobeIcon = ({
size = 16,
className = defaultTailwindCSSBlue,
}: IconProps) => {
return <FiGlobe size={size} className={className} />;
};
export const GmailIcon = ({
size = 16,
className = defaultTailwindCSS,
@@ -2700,28 +2698,3 @@ export const DownloadCSVIcon = ({
</svg>
);
};
export const UserIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return (
<svg
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
xmlns="http://www.w3.org/2000/svg"
width="200"
height="200"
viewBox="0 0 24 24"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19.618 21.25c0-3.602-4.016-6.53-7.618-6.53c-3.602 0-7.618 2.928-7.618 6.53M12 11.456a4.353 4.353 0 1 0 0-8.706a4.353 4.353 0 0 0 0 8.706"
/>
</svg>
);
};

View File

@@ -1,15 +1,10 @@
import React from "react";
import { getDisplayNameForModel } from "@/lib/hooks";
import {
checkLLMSupportsImageInput,
destructureValue,
structureValue,
} from "@/lib/llm/utils";
import { checkLLMSupportsImageInput, structureValue } from "@/lib/llm/utils";
import {
getProviderIcon,
LLMProviderDescriptor,
} from "@/app/admin/configuration/llm/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
interface LlmListProps {
llmProviders: LLMProviderDescriptor[];
@@ -19,19 +14,15 @@ interface LlmListProps {
scrollable?: boolean;
hideProviderIcon?: boolean;
requiresImageGeneration?: boolean;
includeUserDefault?: boolean;
currentAssistant?: Persona;
}
export const LlmList: React.FC<LlmListProps> = ({
currentAssistant,
llmProviders,
currentLlm,
onSelect,
userDefault,
scrollable,
requiresImageGeneration,
includeUserDefault = false,
}) => {
const llmOptionsByProvider: {
[provider: string]: {
@@ -77,6 +68,21 @@ export const LlmList: React.FC<LlmListProps> = ({
: "max-h-[300px]"
} bg-background-175 flex flex-col gap-y-1 overflow-y-scroll`}
>
{userDefault && (
<button
type="button"
key={-1}
className={`w-full py-1.5 px-2 text-sm ${
currentLlm == null
? "bg-background-200"
: "bg-background hover:bg-background-100"
} text-left rounded`}
onClick={() => onSelect(null)}
>
User Default (currently {getDisplayNameForModel(userDefault)})
</button>
)}
{llmOptions.map(({ name, icon, value }, index) => {
if (!requiresImageGeneration || checkLLMSupportsImageInput(name)) {
return (
@@ -92,25 +98,6 @@ export const LlmList: React.FC<LlmListProps> = ({
>
{icon({ size: 16 })}
{getDisplayNameForModel(name)}
{(() => {
if (
currentAssistant?.llm_model_version_override === name &&
userDefault &&
name === destructureValue(userDefault).modelName
) {
return " (assistant + user default)";
} else if (
currentAssistant?.llm_model_version_override === name
) {
return " (assistant)";
} else if (
userDefault &&
name === destructureValue(userDefault).modelName
) {
return " (user default)";
}
return "";
})()}
</button>
);
}

View File

@@ -1,9 +1,8 @@
"use client";
import React from "react";
import {
DanswerDocument,
DocumentRelevance,
LoadedDanswerDocument,
SearchDanswerDocument,
} from "@/lib/search/interfaces";
import { DocumentFeedbackBlock } from "./DocumentFeedbackBlock";
@@ -12,22 +11,19 @@ import { PopupSpec } from "../admin/connectors/Popup";
import { DocumentUpdatedAtBadge } from "./DocumentUpdatedAtBadge";
import { SourceIcon } from "../SourceIcon";
import { MetadataBadge } from "../MetadataBadge";
import { BookIcon, GlobeIcon, LightBulbIcon, SearchIcon } from "../icons/icons";
import { BookIcon, LightBulbIcon } from "../icons/icons";
import { FaStar } from "react-icons/fa";
import { FiTag } from "react-icons/fi";
import { SettingsContext } from "../settings/SettingsProvider";
import { CustomTooltip, TooltipGroup } from "../tooltip/CustomTooltip";
import { WarningCircle } from "@phosphor-icons/react";
import TextView from "../chat_search/TextView";
import { SearchResultIcon } from "../SearchResultIcon";
export const buildDocumentSummaryDisplay = (
matchHighlights: string[],
blurb: string
) => {
if (!matchHighlights || matchHighlights.length === 0) {
// console.log("no match highlights", matchHighlights);
if (matchHighlights.length === 0) {
return blurb;
}
@@ -189,12 +185,6 @@ export const DocumentDisplay = ({
const relevance_explanation =
document.relevance_explanation ?? additional_relevance?.content;
const settings = useContext(SettingsContext);
const [presentingDocument, setPresentingDocument] =
useState<DanswerDocument | null>(null);
const handleViewFile = async () => {
setPresentingDocument(document);
};
return (
<div
@@ -226,22 +216,19 @@ export const DocumentDisplay = ({
}`}
>
<div className="flex relative">
<button
type="button"
className={`rounded-lg flex font-bold text-link max-w-full`}
onClick={() => {
if (document.link) {
window.open(document.link, "_blank");
} else {
handleViewFile();
}
}}
<a
className={`rounded-lg flex font-bold text-link max-w-full ${
document.link ? "" : "pointer-events-none"
}`}
href={document.link}
target="_blank"
rel="noopener noreferrer"
>
<SourceIcon sourceType={document.source_type} iconSize={22} />
<p className="truncate text-wrap break-all ml-2 my-auto line-clamp-1 text-base max-w-full">
{document.semantic_identifier || document.document_id}
</p>
</button>
</a>
<div className="ml-auto flex items-center">
<TooltipGroup>
{isHovered && messageId && (
@@ -264,11 +251,7 @@ export const DocumentDisplay = ({
>
<CustomTooltip showTick line content="Toggle content">
<LightBulbIcon
className={`${
settings?.isMobile && alternativeToggled
? "text-green-600"
: "text-blue-600"
} my-auto ml-2 h-4 w-4 cursor-pointer`}
className={`${settings?.isMobile && alternativeToggled ? "text-green-600" : "text-blue-600"} my-auto ml-2 h-4 w-4 cursor-pointer`}
/>
</CustomTooltip>
</button>
@@ -280,13 +263,6 @@ export const DocumentDisplay = ({
<DocumentMetadataBlock document={document} />
</div>
{presentingDocument && (
<TextView
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
)}
<p
style={{ transition: "height 0.30s ease-in-out" }}
className="pl-1 pt-2 pb-3 break-words text-wrap"
@@ -314,14 +290,11 @@ export const AgenticDocumentDisplay = ({
setPopup,
}: DocumentDisplayProps) => {
const [isHovered, setIsHovered] = useState(false);
const [presentingDocument, setPresentingDocument] =
useState<DanswerDocument | null>(null);
const [alternativeToggled, setAlternativeToggled] = useState(false);
const relevance_explanation =
document.relevance_explanation ?? additional_relevance?.content;
return (
<div
key={document.semantic_identifier}
@@ -335,29 +308,22 @@ export const AgenticDocumentDisplay = ({
}}
>
<div
className={`collapsible ${
!hide && "collapsible-closed overflow-y-auto border-transparent"
}`}
className={`collapsible ${!hide && "collapsible-closed overflow-y-auto border-transparent"}`}
>
<div className="flex relative">
<button
type="button"
<a
className={`rounded-lg flex font-bold text-link max-w-full ${
document.link ? "" : "pointer-events-none"
}`}
onClick={() => {
if (document.link) {
window.open(document.link, "_blank");
} else {
setPresentingDocument(document);
}
}}
href={document.link}
target="_blank"
rel="noopener noreferrer"
>
<SourceIcon sourceType={document.source_type} iconSize={22} />
<p className="truncate text-wrap break-all ml-2 my-auto line-clamp-1 text-base max-w-full">
{document.semantic_identifier || document.document_id}
</p>
</button>
</a>
<div className="ml-auto items-center flex">
<TooltipGroup>
@@ -390,12 +356,6 @@ export const AgenticDocumentDisplay = ({
<div className="mt-1">
<DocumentMetadataBlock document={document} />
</div>
{presentingDocument && (
<TextView
presentingDocument={presentingDocument}
onClose={() => setPresentingDocument(null)}
/>
)}
<div className="pt-2 break-words flex gap-x-2">
<p
@@ -420,38 +380,3 @@ export const AgenticDocumentDisplay = ({
</div>
);
};
export function CompactDocumentCard({
document,
icon,
url,
}: {
document: LoadedDanswerDocument;
icon?: React.ReactNode;
url?: string;
}) {
return (
<div className="max-w-[250px] pb-0 pt-0 mt-0 flex gap-y-0 flex-col content-start items-start gap-0 ">
<h3 className="text-sm font-semibold flex items-center gap-x-1 text-text-900 pt-0 mt-0 truncate w-full">
{icon}
{(document.semantic_identifier || document.document_id).slice(0, 40)}
{(document.semantic_identifier || document.document_id).length > 40 &&
"..."}
</h3>
{document.blurb && (
<p className="text-xs mb-0 text-gray-600 line-clamp-2">
{document.blurb}
</p>
)}
{document.updated_at && (
<div className=" flex mt-0 pt-0 items-center justify-between w-full ">
{!isNaN(new Date(document.updated_at).getTime()) && (
<span className="text-xs text-gray-500">
Updated {new Date(document.updated_at).toLocaleDateString()}
</span>
)}
</div>
)}
</div>
);
}

View File

@@ -1,17 +1,6 @@
import { timeAgo } from "@/lib/time";
import { MetadataBadge } from "../MetadataBadge";
export function DocumentUpdatedAtBadge({
updatedAt,
modal,
}: {
updatedAt: string;
modal?: boolean;
}) {
return (
<MetadataBadge
flexNone={modal}
value={(modal ? "" : "Updated ") + timeAgo(updatedAt)}
/>
);
export function DocumentUpdatedAtBadge({ updatedAt }: { updatedAt: string }) {
return <MetadataBadge value={"Updated " + timeAgo(updatedAt)} />;
}

View File

@@ -0,0 +1,169 @@
"use client";
import {
Dispatch,
SetStateAction,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { HoverableIcon } from "../Hoverable";
import {
DislikeFeedbackIcon,
LikeFeedbackIcon,
ToggleDown,
} from "../icons/icons";
import { FeedbackType } from "@/app/chat/types";
import { searchState } from "./SearchSection";
import { SettingsContext } from "../settings/SettingsProvider";
import { AnswerSection } from "./results/AnswerSection";
import { Quote, SearchResponse } from "@/lib/search/interfaces";
import { QuotesSection } from "./results/QuotesSection";
export default function SearchAnswer({
searchAnswerExpanded,
setSearchAnswerExpanded,
isFetching,
dedupedQuotes,
searchResponse,
setCurrentFeedback,
searchState,
}: {
searchAnswerExpanded: boolean;
setSearchAnswerExpanded: Dispatch<SetStateAction<boolean>>;
isFetching: boolean;
dedupedQuotes: Quote[];
searchResponse: SearchResponse;
searchState: searchState;
setCurrentFeedback: Dispatch<SetStateAction<[FeedbackType, number] | null>>;
}) {
const [searchAnswerOverflowing, setSearchAnswerOverflowing] = useState(false);
const { quotes, answer, error } = searchResponse;
const answerContainerRef = useRef<HTMLDivElement>(null);
const handleFeedback = (feedbackType: FeedbackType, messageId: number) => {
setCurrentFeedback([feedbackType, messageId]);
};
const settings = useContext(SettingsContext);
useEffect(() => {
const checkOverflow = () => {
if (answerContainerRef.current) {
const isOverflowing =
answerContainerRef.current.scrollHeight >
answerContainerRef.current.clientHeight;
setSearchAnswerOverflowing(isOverflowing);
}
};
checkOverflow();
window.addEventListener("resize", checkOverflow);
return () => {
window.removeEventListener("resize", checkOverflow);
};
}, [answer, quotes]);
return (
<div
ref={answerContainerRef}
className={`my-4 ${
searchAnswerExpanded ? "min-h-[16rem]" : "h-[16rem]"
} ${
!searchAnswerExpanded && searchAnswerOverflowing && "overflow-y-hidden"
} p-4 border-2 border-search-answer-border rounded-lg relative`}
>
<div>
<div className="flex gap-x-2">
<h2 className="text-emphasis font-bold my-auto mb-1">AI Answer</h2>
{searchState == "generating" && (
<div key={"generating"} className="relative inline-block">
<span className="loading-text">Generating Response...</span>
</div>
)}
{searchState == "citing" && (
<div key={"citing"} className="relative inline-block">
<span className="loading-text">Extracting Quotes...</span>
</div>
)}
{searchState == "searching" && (
<div key={"Reading"} className="relative inline-block">
<span className="loading-text">Searching...</span>
</div>
)}
{searchState == "reading" && (
<div key={"Reading"} className="relative inline-block">
<span className="loading-text">
Reading{settings?.isMobile ? "" : " Documents"}
...
</span>
</div>
)}
{searchState == "analyzing" && (
<div key={"Generating"} className="relative inline-block">
<span className="loading-text">
Running
{settings?.isMobile ? "" : " Analysis"}...
</span>
</div>
)}
</div>
<div className={`pt-1 h-auto border-t border-border w-full`}>
<AnswerSection
answer={answer}
quotes={quotes}
error={error}
isFetching={isFetching}
/>
</div>
<div className="w-full">
{quotes !== null && quotes.length > 0 && answer && (
<QuotesSection quotes={dedupedQuotes} isFetching={isFetching} />
)}
{searchResponse.messageId !== null && (
<div className="absolute right-3 flex bottom-3">
<HoverableIcon
icon={<LikeFeedbackIcon />}
onClick={() =>
handleFeedback("like", searchResponse?.messageId as number)
}
/>
<HoverableIcon
icon={<DislikeFeedbackIcon />}
onClick={() =>
handleFeedback("dislike", searchResponse?.messageId as number)
}
/>
</div>
)}
</div>
</div>
{!searchAnswerExpanded && searchAnswerOverflowing && (
<div className="absolute bottom-0 left-0 w-full h-[100px] bg-gradient-to-b from-background/5 via-background/60 to-background/90"></div>
)}
{!searchAnswerExpanded && searchAnswerOverflowing && (
<div className="w-full h-12 absolute items-center content-center flex left-0 px-4 bottom-0">
<button
onClick={() => setSearchAnswerExpanded(true)}
className="flex gap-x-1 items-center justify-center hover:bg-background-100 cursor-pointer max-w-sm text-sm mx-auto w-full bg-background border py-2 rounded-full"
>
Show more
<ToggleDown />
</button>
</div>
)}
</div>
);
}

View File

@@ -17,12 +17,6 @@ interface FullSearchBarProps {
showingSidebar: boolean;
}
import {
TooltipProvider,
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { useRef } from "react";
import { SendIcon } from "../icons/icons";
import { Separator } from "@/components/ui/separator";
@@ -34,65 +28,61 @@ import { CCPairBasicInfo, DocumentSet, Tag } from "@/lib/types";
export const AnimatedToggle = ({
isOn,
handleToggle,
direction = "top",
}: {
isOn: boolean;
handleToggle: () => void;
direction?: "bottom" | "top";
}) => {
const commandSymbol = KeyboardSymbol();
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<CustomTooltip
light
large
content={
<div className="bg-white my-auto p-6 rounded-lg w-full">
<h2 className="text-xl text-text-800 font-bold mb-2">
Agentic Search
</h2>
<p className="text-text-700 text-sm mb-4">
Our most powerful search, have an AI agent guide you to pinpoint
exactly what you&apos;re looking for.
</p>
<Separator />
<h2 className="text-xl text-text-800 font-bold mb-2">Fast Search</h2>
<p className="text-text-700 text-sm mb-4">
Get quality results immediately, best suited for instant access to
your documents.
</p>
<p className="mt-2 flex text-xs">Shortcut: ({commandSymbol}/)</p>
</div>
}
>
<div
ref={containerRef}
className="my-auto ml-auto flex justify-end items-center cursor-pointer"
onClick={handleToggle}
>
<div ref={contentRef} className="flex items-center">
{/* Toggle switch */}
<div
ref={containerRef}
className="my-auto ml-auto flex justify-end items-center cursor-pointer"
onClick={handleToggle}
className={`
w-10 h-6 flex items-center rounded-full p-1 transition-all duration-300 ease-in-out
${isOn ? "bg-toggled-background" : "bg-untoggled-background"}
`}
>
<div ref={contentRef} className="flex items-center">
<div
className={`
w-10 h-6 flex items-center rounded-full p-1 transition-all duration-300 ease-in-out
${isOn ? "bg-toggled-background" : "bg-untoggled-background"}
`}
>
<div
className={`
bg-white w-4 h-4 rounded-full shadow-md transform transition-all duration-300 ease-in-out
${isOn ? "translate-x-4" : ""}
`}
></div>
</div>
<p className="ml-2 text-sm">Pro</p>
</div>
<div
className={`
bg-white w-4 h-4 rounded-full shadow-md transform transition-all duration-300 ease-in-out
${isOn ? "translate-x-4" : ""}
`}
></div>
</div>
</TooltipTrigger>
<TooltipContent side={direction} backgroundColor="bg-background-200">
<div className="bg-white my-auto p-6 rounded-lg max-w-sm">
<h2 className="text-xl text-text-800 font-bold mb-2">
Agentic Search
</h2>
<p className="text-text-700 text-sm mb-4">
Our most powerful search, have an AI agent guide you to pinpoint
exactly what you&apos;re looking for.
</p>
<Separator />
<h2 className="text-xl text-text-800 font-bold mb-2">
Fast Search
</h2>
<p className="text-text-700 text-sm mb-4">
Get quality results immediately, best suited for instant access to
your documents.
</p>
<p className="mt-2 flex text-xs">Shortcut: ({commandSymbol}/)</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<p className="ml-2 text-sm">Agentic</p>
</div>
</div>
</CustomTooltip>
);
};

View File

@@ -0,0 +1,301 @@
"use client";
import {
DocumentRelevance,
SearchDanswerDocument,
SearchDefaultOverrides,
SearchResponse,
} from "@/lib/search/interfaces";
import { usePopup } from "../admin/connectors/Popup";
import { AlertIcon, MagnifyingIcon, UndoIcon } from "../icons/icons";
import { AgenticDocumentDisplay, DocumentDisplay } from "./DocumentDisplay";
import { searchState } from "./SearchSection";
import { useEffect, useState } from "react";
import {
Tooltip,
TooltipProvider,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import KeyboardSymbol from "@/lib/browserUtilities";
import { DISABLE_LLM_DOC_RELEVANCE } from "@/lib/constants";
const getSelectedDocumentIds = (
documents: SearchDanswerDocument[],
selectedIndices: number[]
) => {
const selectedDocumentIds = new Set<string>();
selectedIndices.forEach((ind) => {
selectedDocumentIds.add(documents[ind].document_id);
});
return selectedDocumentIds;
};
export const SearchResultsDisplay = ({
agenticResults,
searchResponse,
contentEnriched,
disabledAgentic,
isFetching,
defaultOverrides,
performSweep,
searchState,
sweep,
}: {
searchState: searchState;
disabledAgentic?: boolean;
contentEnriched?: boolean;
agenticResults?: boolean | null;
performSweep: () => void;
sweep?: boolean;
searchResponse: SearchResponse | null;
isFetching: boolean;
defaultOverrides: SearchDefaultOverrides;
comments: any;
}) => {
const commandSymbol = KeyboardSymbol();
const { popup, setPopup } = usePopup();
const [showAll, setShowAll] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key.toLowerCase()) {
case "o":
event.preventDefault();
performSweep();
if (agenticResults) {
setShowAll((showAll) => !showAll);
}
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [performSweep, agenticResults]);
if (!searchResponse) {
return null;
}
const { answer, quotes, documents, error, messageId } = searchResponse;
if (isFetching && disabledAgentic) {
return (
<div className="mt-4">
<div className="font-bold flex justify-between text-emphasis border-b mb-3 pb-1 border-border text-lg">
<p>Results</p>
</div>
</div>
);
}
if (isFetching && !answer && !documents) {
return null;
}
if (documents != null && documents.length == 0 && searchState == "input") {
return (
<div className="text-base gap-x-1.5 flex flex-col">
<div className="flex gap-x-2 items-center font-semibold">
<AlertIcon size={16} />
No documents were found!
</div>
<p>
Have you set up a connector? Your data may not have loaded properly.
</p>
</div>
);
}
if (
answer === null &&
(documents === null || documents.length === 0) &&
!isFetching
) {
return (
<div className="mt-4">
{error && (
<div className="text-error text-sm">
<div className="flex">
<AlertIcon size={16} className="text-error my-auto mr-1" />
<p className="italic">{error || "No documents were found!"}</p>
</div>
</div>
)}
</div>
);
}
const selectedDocumentIds = getSelectedDocumentIds(
documents || [],
searchResponse.selectedDocIndices || []
);
const relevantDocs = documents
? documents.filter((doc) => {
return (
showAll ||
(searchResponse &&
searchResponse.additional_relevance &&
searchResponse.additional_relevance[doc.document_id] &&
searchResponse.additional_relevance[doc.document_id].relevant) ||
doc.is_relevant
);
})
: [];
const getUniqueDocuments = (
documents: SearchDanswerDocument[]
): SearchDanswerDocument[] => {
const seenIds = new Set<string>();
return documents.filter((doc) => {
if (!seenIds.has(doc.document_id)) {
seenIds.add(doc.document_id);
return true;
}
return false;
});
};
const uniqueDocuments = getUniqueDocuments(documents || []);
return (
<>
{popup}
{documents && documents.length > 0 && (
<div className="mt-4">
<div className="font-bold flex h-12 justify-between text-emphasis border-b mb-3 pb-1 border-border text-lg">
<p>Results</p>
{!DISABLE_LLM_DOC_RELEVANCE &&
(contentEnriched || searchResponse.additional_relevance) && (
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
performSweep();
if (agenticResults) {
setShowAll((showAll) => !showAll);
}
}}
className={`flex items-center justify-center animate-fade-in-up rounded-lg p-1 text-xs transition-all duration-300 w-20 h-8 ${
!sweep
? "bg-background-agentic-toggled text-text-agentic-toggled"
: "bg-background-agentic-untoggled text-text-agentic-untoggled"
}`}
style={{
transform: sweep
? "rotateZ(180deg)"
: "rotateZ(0deg)",
}}
>
<div
className={`flex items-center ${
sweep ? "rotate-180" : ""
}`}
>
<span></span>
{!sweep
? agenticResults
? "Show All"
: "Focus"
: agenticResults
? "Focus"
: "Show All"}
<span className="ml-1">
{!sweep ? (
<MagnifyingIcon className="h-4 w-4" />
) : (
<UndoIcon className="h-4 w-4" />
)}
</span>
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<div className="flex">{commandSymbol}O</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{agenticResults &&
relevantDocs &&
contentEnriched &&
relevantDocs.length == 0 &&
!showAll && (
<p className="flex text-lg font-bold">
No high quality results found by agentic search.
</p>
)}
{uniqueDocuments.map((document, ind) => {
const relevance: DocumentRelevance | null =
searchResponse.additional_relevance
? searchResponse.additional_relevance[document.document_id]
: null;
return agenticResults ? (
<AgenticDocumentDisplay
additional_relevance={relevance}
contentEnriched={contentEnriched}
index={ind}
hide={showAll || relevance?.relevant || document.is_relevant}
key={`${document.document_id}-${ind}`}
document={document}
documentRank={ind + 1}
messageId={messageId}
isSelected={selectedDocumentIds.has(document.document_id)}
setPopup={setPopup}
/>
) : (
<DocumentDisplay
additional_relevance={relevance}
contentEnriched={contentEnriched}
index={ind}
hide={sweep && !document.is_relevant && !relevance?.relevant}
key={`${document.document_id}-${ind}`}
document={document}
documentRank={ind + 1}
messageId={messageId}
isSelected={selectedDocumentIds.has(document.document_id)}
setPopup={setPopup}
/>
);
})}
</div>
)}
<div className="h-[100px]" />
</>
);
};
export function AgenticDisclaimer({
forceNonAgentic,
}: {
forceNonAgentic: () => void;
}) {
return (
<div className="ml-auto mx-12 flex transition-all duration-300 animate-fade-in flex-col gap-y-2">
<p className="text-sm">
Please note that agentic quries can take substantially longer than
non-agentic queries. You can click <i>non-agentic</i> to re-submit your
query without agentic capabilities.
</p>
<button
onClick={forceNonAgentic}
className="p-2 bg-background-900 mr-auto text-text-200 rounded-lg text-xs my-auto"
>
Non-agentic
</button>
</div>
);
}

View File

@@ -0,0 +1,856 @@
"use client";
import { useCallback, useContext, useEffect, useRef, useState } from "react";
import { FullSearchBar } from "./SearchBar";
import { SearchResultsDisplay } from "./SearchResultsDisplay";
import { SourceSelector } from "./filtering/Filters";
import {
Quote,
SearchResponse,
FlowType,
SearchType,
SearchDefaultOverrides,
SearchRequestOverrides,
ValidQuestionResponse,
Relevance,
SearchDanswerDocument,
SourceMetadata,
} from "@/lib/search/interfaces";
import { searchRequestStreamed } from "@/lib/search/streamingQa";
import { CancellationToken, cancellable } from "@/lib/search/cancellable";
import { useFilters, useObjectState } from "@/lib/hooks";
import { Persona } from "@/app/admin/assistants/interfaces";
import { computeAvailableFilters } from "@/lib/filters";
import { useRouter, useSearchParams } from "next/navigation";
import { SettingsContext } from "../settings/SettingsProvider";
import { HistorySidebar } from "@/app/chat/sessionSidebar/HistorySidebar";
import { ChatSession, SearchSession } from "@/app/chat/interfaces";
import FunctionalHeader from "../chat_search/Header";
import { useSidebarVisibility } from "../chat_search/hooks";
import { SIDEBAR_TOGGLED_COOKIE_NAME } from "../resizable/constants";
import { AGENTIC_SEARCH_TYPE_COOKIE_NAME } from "@/lib/constants";
import Cookies from "js-cookie";
import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo";
import { usePopup } from "../admin/connectors/Popup";
import { FeedbackType } from "@/app/chat/types";
import { FeedbackModal } from "@/app/chat/modal/FeedbackModal";
import { deleteChatSession, handleChatFeedback } from "@/app/chat/lib";
import SearchAnswer from "./SearchAnswer";
import { DeleteEntityModal } from "../modals/DeleteEntityModal";
import { ApiKeyModal } from "../llm/ApiKeyModal";
import { useSearchContext } from "../context/SearchContext";
import { useUser } from "../user/UserProvider";
import UnconfiguredProviderText from "../chat_search/UnconfiguredProviderText";
import { DateRangePickerValue } from "@/app/ee/admin/performance/DateRangeSelector";
import { Tag } from "@/lib/types";
import { isEqual } from "lodash";
export type searchState =
| "input"
| "searching"
| "reading"
| "analyzing"
| "summarizing"
| "generating"
| "citing";
const SEARCH_DEFAULT_OVERRIDES_START: SearchDefaultOverrides = {
forceDisplayQA: false,
offset: 0,
};
interface SearchSectionProps {
toggle: () => void;
defaultSearchType: SearchType;
toggledSidebar: boolean;
}
export const SearchSection = ({
toggle,
toggledSidebar,
defaultSearchType,
}: SearchSectionProps) => {
const {
querySessions,
ccPairs,
documentSets,
assistants,
tags,
shouldShowWelcomeModal,
agenticSearchEnabled,
disabledAgentic,
shouldDisplayNoSources,
} = useSearchContext();
const [query, setQuery] = useState<string>("");
const [comments, setComments] = useState<any>(null);
const [contentEnriched, setContentEnriched] = useState(false);
const [searchResponse, setSearchResponse] = useState<SearchResponse>({
suggestedSearchType: null,
suggestedFlowType: null,
answer: null,
quotes: null,
documents: null,
selectedDocIndices: null,
error: null,
messageId: null,
});
const [showApiKeyModal, setShowApiKeyModal] = useState(true);
const [agentic, setAgentic] = useState(agenticSearchEnabled);
const toggleAgentic = useCallback(() => {
Cookies.set(
AGENTIC_SEARCH_TYPE_COOKIE_NAME,
String(!agentic).toLocaleLowerCase()
);
setAgentic((agentic) => !agentic);
}, [agentic]);
const toggleSidebar = useCallback(() => {
Cookies.set(
SIDEBAR_TOGGLED_COOKIE_NAME,
String(!toggledSidebar).toLocaleLowerCase()
),
{
path: "/",
};
toggle();
}, [toggledSidebar, toggle]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key.toLowerCase()) {
case "/":
toggleAgentic();
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [toggleAgentic]);
const [isFetching, setIsFetching] = useState(false);
// Search Type
const selectedSearchType = defaultSearchType;
// If knowledge assistant exists, use it. Otherwise, use first available assistant for search.
const selectedPersona = assistants.find((assistant) => assistant.id === 0)
? 0
: assistants[0]?.id;
// Used for search state display
const [analyzeStartTime, setAnalyzeStartTime] = useState<number>(0);
// Filters
const filterManager = useFilters();
const availableSources = ccPairs.map((ccPair) => ccPair.source);
const [finalAvailableSources, finalAvailableDocumentSets] =
computeAvailableFilters({
selectedPersona: assistants.find(
(assistant) => assistant.id === selectedPersona
),
availableSources: availableSources,
availableDocumentSets: documentSets,
});
const searchParams = useSearchParams();
const existingSearchessionId = searchParams.get("searchId");
useEffect(() => {
if (existingSearchessionId == null) {
return;
}
function extractFirstMessageByType(
chatSession: SearchSession,
messageType: "user" | "assistant"
): string | null {
const userMessage = chatSession?.messages.find(
(msg) => msg.message_type === messageType
);
return userMessage ? userMessage.message : null;
}
async function initialSessionFetch() {
const response = await fetch(
`/api/query/search-session/${existingSearchessionId}`
);
const searchSession = (await response.json()) as SearchSession;
const userMessage = extractFirstMessageByType(searchSession, "user");
const assistantMessage = extractFirstMessageByType(
searchSession,
"assistant"
);
if (userMessage) {
setQuery(userMessage);
const danswerDocs: SearchResponse = {
documents: searchSession.documents,
suggestedSearchType: null,
answer: assistantMessage || "Search response not found",
quotes: null,
selectedDocIndices: null,
error: null,
messageId: searchSession.messages[0].message_id,
suggestedFlowType: null,
additional_relevance: undefined,
};
setIsFetching(false);
setFirstSearch(false);
setSearchResponse(danswerDocs);
setContentEnriched(true);
}
}
initialSessionFetch();
}, [existingSearchessionId]);
// Overrides for default behavior that only last a single query
const [defaultOverrides, setDefaultOverrides] =
useState<SearchDefaultOverrides>(SEARCH_DEFAULT_OVERRIDES_START);
const newSearchState = (
currentSearchState: searchState,
newSearchState: searchState
) => {
if (currentSearchState != "input") {
return newSearchState;
}
return "input";
};
// Helpers
const initialSearchResponse: SearchResponse = {
answer: null,
quotes: null,
documents: null,
suggestedSearchType: null,
suggestedFlowType: null,
selectedDocIndices: null,
error: null,
messageId: null,
additional_relevance: undefined,
};
// Streaming updates
const updateCurrentAnswer = (answer: string) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
answer,
}));
if (analyzeStartTime) {
const elapsedTime = Date.now() - analyzeStartTime;
const nextInterval = Math.ceil(elapsedTime / 1500) * 1500;
setTimeout(() => {
setSearchState((searchState) =>
newSearchState(searchState, "generating")
);
}, nextInterval - elapsedTime);
}
};
const updateQuotes = (quotes: Quote[]) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
quotes,
}));
setSearchState((searchState) => "citing");
};
const updateDocs = (documents: SearchDanswerDocument[]) => {
if (agentic) {
setTimeout(() => {
setSearchState((searchState) => newSearchState(searchState, "reading"));
}, 1500);
setTimeout(() => {
setAnalyzeStartTime(Date.now());
setSearchState((searchState) => {
const newState = newSearchState(searchState, "analyzing");
if (newState === "analyzing") {
setAnalyzeStartTime(Date.now());
}
return newState;
});
}, 4500);
}
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
documents,
}));
if (disabledAgentic) {
setIsFetching(false);
setSearchState((searchState) => "citing");
}
if (documents.length == 0) {
setSearchState("input");
}
};
const updateSuggestedSearchType = (suggestedSearchType: SearchType) =>
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
suggestedSearchType,
}));
const updateSuggestedFlowType = (suggestedFlowType: FlowType) =>
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
suggestedFlowType,
}));
const updateSelectedDocIndices = (docIndices: number[]) =>
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
selectedDocIndices: docIndices,
}));
const updateError = (error: FlowType) => {
resetInput(true);
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
error,
}));
};
const updateMessageAndThreadId = (
messageId: number,
chat_session_id: string
) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
messageId,
}));
router.refresh();
setIsFetching(false);
setSearchState((searchState) => "input");
};
const updateDocumentRelevance = (relevance: Relevance) => {
setSearchResponse((prevState) => ({
...(prevState || initialSearchResponse),
additional_relevance: relevance,
}));
setContentEnriched(true);
setIsFetching(false);
if (disabledAgentic) {
setSearchState("input");
} else {
setSearchState("analyzing");
}
};
const updateComments = (comments: any) => {
setComments(comments);
};
const finishedSearching = () => {
if (disabledAgentic) {
setSearchState("input");
}
};
const { user } = useUser();
const [searchAnswerExpanded, setSearchAnswerExpanded] = useState(false);
const resetInput = (finalized?: boolean) => {
setSweep(false);
setFirstSearch(false);
setComments(null);
setSearchState(finalized ? "input" : "searching");
setSearchAnswerExpanded(false);
};
interface SearchDetails {
query: string;
sources: SourceMetadata[];
agentic: boolean;
documentSets: string[];
timeRange: DateRangePickerValue | null;
tags: Tag[];
persona: Persona;
}
const [previousSearch, setPreviousSearch] = useState<null | SearchDetails>(
null
);
const [agenticResults, setAgenticResults] = useState<boolean | null>(null);
const currentSearch = (overrideMessage?: string): SearchDetails => {
return {
query: overrideMessage || query,
sources: filterManager.selectedSources,
agentic: agentic!,
documentSets: filterManager.selectedDocumentSets,
timeRange: filterManager.timeRange,
tags: filterManager.selectedTags,
persona: assistants.find(
(assistant) => assistant.id === selectedPersona
) as Persona,
};
};
const isSearchChanged = () => {
return !isEqual(currentSearch(), previousSearch);
};
let lastSearchCancellationToken = useRef<CancellationToken | null>(null);
const onSearch = async ({
searchType,
agentic,
offset,
overrideMessage,
}: SearchRequestOverrides = {}) => {
if ((overrideMessage || query) == "") {
return;
}
setAgenticResults(agentic!);
resetInput();
setContentEnriched(false);
if (lastSearchCancellationToken.current) {
lastSearchCancellationToken.current.cancel();
}
lastSearchCancellationToken.current = new CancellationToken();
setIsFetching(true);
setSearchResponse(initialSearchResponse);
setPreviousSearch(currentSearch(overrideMessage));
const searchFnArgs = {
query: overrideMessage || query,
sources: filterManager.selectedSources,
agentic: agentic,
documentSets: filterManager.selectedDocumentSets,
timeRange: filterManager.timeRange,
tags: filterManager.selectedTags,
persona: assistants.find(
(assistant) => assistant.id === selectedPersona
) as Persona,
updateCurrentAnswer: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateCurrentAnswer,
}),
updateQuotes: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateQuotes,
}),
updateDocs: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateDocs,
}),
updateSuggestedSearchType: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateSuggestedSearchType,
}),
updateSuggestedFlowType: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateSuggestedFlowType,
}),
updateSelectedDocIndices: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateSelectedDocIndices,
}),
updateError: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateError,
}),
updateMessageAndThreadId: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateMessageAndThreadId,
}),
updateDocStatus: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateMessageAndThreadId,
}),
updateDocumentRelevance: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateDocumentRelevance,
}),
updateComments: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: updateComments,
}),
finishedSearching: cancellable({
cancellationToken: lastSearchCancellationToken.current,
fn: finishedSearching,
}),
selectedSearchType: searchType ?? selectedSearchType,
offset: offset ?? defaultOverrides.offset,
};
await Promise.all([searchRequestStreamed(searchFnArgs)]);
};
// handle redirect if search page is disabled
// NOTE: this must be done here, in a client component since
// settings are passed in via Context and therefore aren't
// available in server-side components
const router = useRouter();
const settings = useContext(SettingsContext);
if (settings?.settings?.search_page_enabled === false) {
router.push("/chat");
}
const sidebarElementRef = useRef<HTMLDivElement>(null);
const innerSidebarElementRef = useRef<HTMLDivElement>(null);
const [showDocSidebar, setShowDocSidebar] = useState(false);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
switch (event.key.toLowerCase()) {
case "e":
event.preventDefault();
toggleSidebar();
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [router, toggleSidebar]);
useEffect(() => {
if (settings?.isMobile) {
router.push("/chat");
}
}, [settings?.isMobile, router]);
const handleTransitionEnd = (e: React.TransitionEvent<HTMLDivElement>) => {
if (e.propertyName === "opacity" && !firstSearch) {
const target = e.target as HTMLDivElement;
target.style.display = "none";
}
};
const [sweep, setSweep] = useState(false);
const performSweep = () => {
setSweep((sweep) => !sweep);
};
const [firstSearch, setFirstSearch] = useState(true);
const [searchState, setSearchState] = useState<searchState>("input");
const [deletingChatSession, setDeletingChatSession] =
useState<ChatSession | null>();
const showDeleteModal = (chatSession: ChatSession) => {
setDeletingChatSession(chatSession);
};
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
const [untoggled, setUntoggled] = useState(false);
const explicitlyUntoggle = () => {
setShowDocSidebar(false);
setUntoggled(true);
setTimeout(() => {
setUntoggled(false);
}, 200);
};
useSidebarVisibility({
toggledSidebar,
sidebarElementRef,
showDocSidebar,
setShowDocSidebar,
mobile: settings?.isMobile,
});
const { answer, quotes, documents, error, messageId } = searchResponse;
const dedupedQuotes: Quote[] = [];
const seen = new Set<string>();
if (quotes) {
quotes.forEach((quote) => {
if (!seen.has(quote.document_id)) {
dedupedQuotes.push(quote);
seen.add(quote.document_id);
}
});
}
const [currentFeedback, setCurrentFeedback] = useState<
[FeedbackType, number] | null
>(null);
const onFeedback = async (
messageId: number,
feedbackType: FeedbackType,
feedbackDetails: string,
predefinedFeedback: string | undefined
) => {
const response = await handleChatFeedback(
messageId,
feedbackType,
feedbackDetails,
predefinedFeedback
);
if (response.ok) {
setPopup({
message: "Thanks for your feedback!",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to submit feedback - ${errorMsg}`,
type: "error",
});
}
};
const chatBannerPresent = settings?.enterpriseSettings?.custom_header_content;
const { popup, setPopup } = usePopup();
const shouldUseAgenticDisplay =
agenticResults &&
(searchResponse.documents || []).some(
(document) =>
searchResponse.additional_relevance &&
searchResponse.additional_relevance[document.document_id] !== undefined
);
return (
<>
<div className="flex relative pr-[8px] h-full text-default">
{popup}
{!shouldDisplayNoSources &&
showApiKeyModal &&
!shouldShowWelcomeModal && (
<ApiKeyModal
setPopup={setPopup}
hide={() => setShowApiKeyModal(false)}
/>
)}
{deletingChatSession && (
<DeleteEntityModal
entityType="search"
entityName={deletingChatSession.name}
onClose={() => setDeletingChatSession(null)}
onSubmit={async () => {
const response = await deleteChatSession(deletingChatSession.id);
if (response.ok) {
setDeletingChatSession(null);
// go back to the main page
router.push("/search");
} else {
const responseJson = await response.json();
setPopup({ message: responseJson.detail, type: "error" });
}
router.refresh();
}}
/>
)}
{currentFeedback && (
<FeedbackModal
feedbackType={currentFeedback[0]}
onClose={() => setCurrentFeedback(null)}
onSubmit={({ message, predefinedFeedback }) => {
onFeedback(
currentFeedback[1],
currentFeedback[0],
message,
predefinedFeedback
);
setCurrentFeedback(null);
}}
/>
)}
<div
ref={sidebarElementRef}
className={`
flex-none
fixed
left-0
z-30
bg-background-100
h-screen
transition-all
bg-opacity-80
duration-300
ease-in-out
${
!untoggled && (showDocSidebar || toggledSidebar)
? "opacity-100 w-[250px] translate-x-0"
: "opacity-0 w-[200px] pointer-events-none -translate-x-10"
}
`}
>
<div className="w-full relative">
<HistorySidebar
showDeleteModal={showDeleteModal}
explicitlyUntoggle={explicitlyUntoggle}
reset={() => setQuery("")}
page="search"
ref={innerSidebarElementRef}
toggleSidebar={toggleSidebar}
toggled={toggledSidebar}
existingChats={querySessions}
/>
</div>
</div>
<div className="absolute default-scrollbar h-screen overflow-y-auto overflow-x-hidden left-0 w-full top-0">
<FunctionalHeader
sidebarToggled={toggledSidebar}
reset={() => setQuery("")}
toggleSidebar={toggleSidebar}
page="search"
/>
<div className="w-full flex">
<div
style={{ transition: "width 0.30s ease-out" }}
className={`
flex-none
overflow-y-hidden
bg-background-100
h-full
transition-all
bg-opacity-80
duration-300
ease-in-out
${toggledSidebar ? "w-[250px]" : "w-[0px]"}
`}
/>
{
<div
className={`desktop:px-24 w-full ${
chatBannerPresent && "mt-10"
} pt-10 relative max-w-[2000px] xl:max-w-[1430px] mx-auto`}
>
<div className="absolute z-10 mobile:px-4 mobile:max-w-searchbar-max mobile:w-[90%] top-12 desktop:left-4 hidden 2xl:block mobile:left-1/2 mobile:transform mobile:-translate-x-1/2 desktop:w-52 3xl:w-64">
{!settings?.isMobile &&
(ccPairs.length > 0 || documentSets.length > 0) && (
<SourceSelector
{...filterManager}
showDocSidebar={toggledSidebar}
availableDocumentSets={finalAvailableDocumentSets}
existingSources={finalAvailableSources}
availableTags={tags}
/>
)}
</div>
<div className="absolute left-0 hidden 2xl:block w-52 3xl:w-64"></div>
<div className="max-w-searchbar-max w-[90%] mx-auto">
{settings?.isMobile && (
<div className="mt-6">
{!(agenticResults && isFetching) || disabledAgentic ? (
<SearchResultsDisplay
searchState={searchState}
disabledAgentic={disabledAgentic}
contentEnriched={contentEnriched}
comments={comments}
sweep={sweep}
agenticResults={agenticResults && !disabledAgentic}
performSweep={performSweep}
searchResponse={searchResponse}
isFetching={isFetching}
defaultOverrides={defaultOverrides}
/>
) : (
<></>
)}
</div>
)}
<div
className={`mobile:fixed mobile:left-1/2 mobile:transform mobile:-translate-x-1/2 mobile:max-w-search-bar-max mobile:w-[90%] mobile:z-100 mobile:bottom-12`}
>
<div
className={`transition-all duration-500 ease-in-out overflow-hidden
${
firstSearch
? "opacity-100 max-h-[500px]"
: "opacity-0 max-h-0"
}`}
onTransitionEnd={handleTransitionEnd}
>
<div className="mt-48 mb-8 flex justify-center items-center">
<div className="w-message-xs 2xl:w-message-sm 3xl:w-message">
<div className="flex">
<div className="text-3xl font-bold font-strong text-strong mx-auto">
Unlock Knowledge
</div>
</div>
</div>
</div>
</div>
<UnconfiguredProviderText
noSources={shouldDisplayNoSources}
showConfigureAPIKey={() => setShowApiKeyModal(true)}
/>
<FullSearchBar
disabled={!isSearchChanged()}
toggleAgentic={
disabledAgentic ? undefined : toggleAgentic
}
showingSidebar={toggledSidebar}
agentic={agentic}
query={query}
setQuery={setQuery}
onSearch={async (agentic?: boolean) => {
setDefaultOverrides(SEARCH_DEFAULT_OVERRIDES_START);
await onSearch({ agentic, offset: 0 });
}}
finalAvailableDocumentSets={finalAvailableDocumentSets}
finalAvailableSources={finalAvailableSources}
filterManager={filterManager}
documentSets={documentSets}
ccPairs={ccPairs}
tags={tags}
/>
</div>
{!firstSearch && (
<SearchAnswer
isFetching={isFetching}
dedupedQuotes={dedupedQuotes}
searchResponse={searchResponse}
setSearchAnswerExpanded={setSearchAnswerExpanded}
searchAnswerExpanded={searchAnswerExpanded}
setCurrentFeedback={setCurrentFeedback}
searchState={searchState}
/>
)}
{!settings?.isMobile && (
<div className="mt-6">
{!(agenticResults && isFetching) || disabledAgentic ? (
<SearchResultsDisplay
searchState={searchState}
disabledAgentic={disabledAgentic}
contentEnriched={contentEnriched}
comments={comments}
sweep={sweep}
agenticResults={
shouldUseAgenticDisplay && !disabledAgentic
}
performSweep={performSweep}
searchResponse={searchResponse}
isFetching={isFetching}
defaultOverrides={defaultOverrides}
/>
) : (
<></>
)}
</div>
)}
</div>
</div>
}
</div>
</div>
</div>
<FixedLogo backgroundToggled={toggledSidebar || showDocSidebar} />
</>
);
};

View File

@@ -0,0 +1,43 @@
import { SearchType } from "@/lib/search/interfaces";
const defaultStyle =
"py-1 px-2 border rounded border-gray-700 cursor-pointer font-bold ";
interface Props {
selectedSearchType: SearchType;
setSelectedSearchType: (searchType: SearchType) => void;
}
export const SearchTypeSelector: React.FC<Props> = ({
selectedSearchType,
setSelectedSearchType,
}) => {
return (
<div className="flex text-xs">
<div
className={
defaultStyle +
(selectedSearchType === SearchType.SEMANTIC
? "bg-blue-500"
: "bg-gray-800 hover:bg-gray-600")
}
onClick={() => setSelectedSearchType(SearchType.SEMANTIC)}
>
AI Search
</div>
<div
className={
defaultStyle +
"ml-2 " +
(selectedSearchType === SearchType.KEYWORD
? "bg-blue-500"
: "bg-gray-800 hover:bg-gray-600")
}
onClick={() => setSelectedSearchType(SearchType.KEYWORD)}
>
Keyword Search
</div>
</div>
);
};

View File

@@ -33,7 +33,6 @@ import {
getDateRangeString,
getTimeAgoString,
} from "@/lib/dateUtils";
import { Separator } from "@/components/ui/separator";
const SectionTitle = ({ children }: { children: string }) => (
<div className="font-bold text-xs mt-2 flex">{children}</div>
@@ -54,9 +53,6 @@ export interface SourceSelectorProps {
availableDocumentSets: DocumentSet[];
existingSources: ValidSources[];
availableTags: Tag[];
toggleFilters?: () => void;
filtersUntoggled?: boolean;
tagsOnLeft?: boolean;
}
export function SourceSelector({
@@ -72,9 +68,6 @@ export function SourceSelector({
existingSources,
availableTags,
showDocSidebar,
toggleFilters,
filtersUntoggled,
tagsOnLeft,
}: SourceSelectorProps) {
const handleSelect = (source: SourceMetadata) => {
setSelectedSources((prev: SourceMetadata[]) => {
@@ -117,155 +110,138 @@ export function SourceSelector({
showDocSidebar ? "4xl:block" : "!block"
} duration-1000 flex ease-out transition-all transform origin-top-right`}
>
<button
onClick={() => toggleFilters && toggleFilters()}
className="flex text-emphasis"
>
<div className="mb-4 pb-2 flex border-b border-border text-emphasis">
<h2 className="font-bold my-auto">Filters</h2>
<FiFilter className="my-auto ml-2" size="16" />
</button>
{!filtersUntoggled && (
</div>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer">
<SectionTitle>Time Range</SectionTitle>
<p className="text-sm text-default mt-2">
{timeRange?.from
? getDateRangeString(timeRange.from, timeRange.to)
: "Since"}
</p>
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background border-border border rounded-md z-[200] p-0"
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>
{availableTags.length > 0 && (
<>
<Separator />
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer">
<SectionTitle>Time Range</SectionTitle>
<p className="text-sm text-default mt-2">
{getDateRangeString(timeRange?.from!, timeRange?.to!) ||
"Select a time range"}
</p>
</div>
</PopoverTrigger>
<PopoverContent
className="bg-background-search-filter border-border border rounded-md z-[200] p-0"
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>
<div className="mt-4 mb-2">
<SectionTitle>Tags</SectionTitle>
</div>
<TagFilter
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
/>
</>
)}
{availableTags.length > 0 && (
<>
<div className="mt-4 mb-2">
<SectionTitle>Tags</SectionTitle>
</div>
<TagFilter
showTagsOnLeft={true}
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
{existingSources.length > 0 && (
<div className="mt-4">
<div className="flex w-full gap-x-2 items-center">
<div className="font-bold text-xs mt-2 flex items-center gap-x-2">
<p>Sources</p>
<input
type="checkbox"
checked={allSourcesSelected}
onChange={toggleAllSources}
className="my-auto form-checkbox h-3 w-3 text-primary border-background-900 rounded"
/>
</>
)}
</div>
</div>
<div className="px-1">
{listSourceMetadata()
.filter((source) => existingSources.includes(source.internalName))
.map((source) => (
<div
key={source.internalName}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 my-1.5 rounded-lg px-2 select-none " +
(selectedSources
.map((source) => source.internalName)
.includes(source.internalName)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleSelect(source)}
>
<SourceIcon sourceType={source.internalName} iconSize={16} />
<span className="ml-2 text-sm text-default">
{source.displayName}
</span>
</div>
))}
</div>
</div>
)}
{existingSources.length > 0 && (
<div className="mt-4">
<div className="flex w-full gap-x-2 items-center">
<div className="font-bold text-xs mt-2 flex items-center gap-x-2">
<p>Sources</p>
<input
type="checkbox"
checked={allSourcesSelected}
onChange={toggleAllSources}
className="my-auto form-checkbox h-3 w-3 text-primary border-background-900 rounded"
{availableDocumentSets.length > 0 && (
<>
<div className="mt-4">
<SectionTitle>Knowledge Sets</SectionTitle>
</div>
<div className="px-1">
{availableDocumentSets.map((documentSet) => (
<div key={documentSet.name} className="my-1.5 flex">
<div
key={documentSet.name}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 rounded-lg px-2 " +
(selectedDocumentSets.includes(documentSet.name)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<HoverPopup
mainContent={
<div className="flex my-auto mr-2">
<InfoIcon className={defaultTailwindCSS} />
</div>
}
popupContent={
<div className="text-sm w-64">
<div className="flex font-medium">Description</div>
<div className="mt-1">{documentSet.description}</div>
</div>
}
classNameModifications="-ml-2"
/>
<span className="text-sm">{documentSet.name}</span>
</div>
</div>
<div className="px-1">
{listSourceMetadata()
.filter((source) =>
existingSources.includes(source.internalName)
)
.map((source) => (
<div
key={source.internalName}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 my-1.5 rounded-lg px-2 select-none " +
(selectedSources
.map((source) => source.internalName)
.includes(source.internalName)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleSelect(source)}
>
<SourceIcon
sourceType={source.internalName}
iconSize={16}
/>
<span className="ml-2 text-sm text-default">
{source.displayName}
</span>
</div>
))}
</div>
</div>
)}
{availableDocumentSets.length > 0 && (
<>
<div className="mt-4">
<SectionTitle>Knowledge Sets</SectionTitle>
</div>
<div className="px-1">
{availableDocumentSets.map((documentSet) => (
<div key={documentSet.name} className="my-1.5 flex">
<div
key={documentSet.name}
className={
"flex cursor-pointer w-full items-center " +
"py-1.5 rounded-lg px-2 " +
(selectedDocumentSets.includes(documentSet.name)
? "bg-hover"
: "hover:bg-hover-light")
}
onClick={() => handleDocumentSetSelect(documentSet.name)}
>
<HoverPopup
mainContent={
<div className="flex my-auto mr-2">
<InfoIcon className={defaultTailwindCSS} />
</div>
}
popupContent={
<div className="text-sm w-64">
<div className="flex font-medium">Description</div>
<div className="mt-1">
{documentSet.description}
</div>
</div>
}
classNameModifications="-ml-2"
/>
<span className="text-sm">{documentSet.name}</span>
</div>
</div>
))}
</div>
</>
)}
))}
</div>
</>
)}
</div>

View File

@@ -6,17 +6,13 @@ import debounce from "lodash/debounce";
import { getValidTags } from "@/lib/tags/tagUtils";
export function TagFilter({
modal,
tags,
selectedTags,
setSelectedTags,
showTagsOnLeft = false,
}: {
modal?: boolean;
tags: Tag[];
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
showTagsOnLeft?: boolean;
}) {
const [filterValue, setFilterValue] = useState("");
const [tagOptionsAreVisible, setTagOptionsAreVisible] = useState(false);
@@ -76,12 +72,10 @@ export function TagFilter({
};
return (
<div className="relative w-full ">
<div className="relative">
<input
ref={inputRef}
className={` border border-border py-0.5 px-2 rounded text-sm h-8 ${
modal ? "w-[80vw]" : "w-full"
}`}
className="w-full border border-border py-0.5 px-2 rounded text-sm h-8"
placeholder="Find a tag"
value={filterValue}
onChange={handleFilterChange}
@@ -112,13 +106,7 @@ export function TagFilter({
</div>
)}
{tagOptionsAreVisible && (
<div
className={` absolute z-[100] ${
showTagsOnLeft
? "left-0 top-0 translate-y-[2rem]"
: "right-0 translate-x-[105%] top-0"
} z-40`}
>
<div className="absolute top-0 right-0 transform translate-x-[105%] z-40">
<div
ref={popupRef}
className="p-2 border border-border rounded shadow-lg w-72 bg-background"

View File

@@ -1,85 +1,55 @@
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
import { ReactNode } from "react";
import { CompactDocumentCard } from "../DocumentDisplay";
import { LoadedDanswerDocument } from "@/lib/search/interfaces";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
// NOTE: This is the preivous version of the citations which works just fine
export function Citation({
children,
link,
document,
index,
updatePresentingDocument,
icon,
url,
}: {
link?: string;
children?: JSX.Element | string | null | ReactNode;
index?: number;
updatePresentingDocument: (documentIndex: LoadedDanswerDocument) => void;
document: LoadedDanswerDocument;
icon?: React.ReactNode;
url?: string;
}) {
const innerText = children
? children?.toString().split("[")[1].split("]")[0]
: index;
if (link) {
if (link != "") {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div
onMouseDown={() => {
if (!link) {
updatePresentingDocument(document);
} else {
window.open(link, "_blank");
}
}}
className="inline-flex items-center ml-1 cursor-pointer transition-all duration-200 ease-in-out"
<CustomTooltip
citation
content={<div className="inline-block p-0 m-0 truncate">{link}</div>}
>
<a
onMouseDown={() => (link ? window.open(link, "_blank") : undefined)}
className="cursor-pointer inline ml-1 align-middle"
>
<span className="group relative -top-1 text-sm text-gray-500 dark:text-gray-400 selection:bg-indigo-300 selection:text-black dark:selection:bg-indigo-900 dark:selection:text-white">
<span
className="inline-flex bg-background-200 group-hover:bg-background-300 items-center justify-center h-3.5 min-w-3.5 px-1 text-center text-xs rounded-full border-1 border-gray-400 ring-1 ring-gray-400 divide-gray-300 dark:divide-gray-700 dark:ring-gray-700 dark:border-gray-700 transition duration-150"
data-number="3"
>
<span className="relative min-w-[1.4rem] text-center no-underline -top-0.5 px-1.5 py-0.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-full border border-gray-300 hover:bg-gray-200 hover:text-gray-900 shadow-sm no-underline">
{innerText}
</span>
</div>
</TooltipTrigger>
<TooltipContent width="mb-2 max-w-lg" className="bg-background">
<CompactDocumentCard url={url} icon={icon} document={document} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
{innerText}
</span>
</span>
</a>
</CustomTooltip>
);
} else {
return (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div
onMouseDown={() => {
if (!link) {
updatePresentingDocument(document);
} else {
window.open(link, "_blank");
}
}}
className="inline-flex items-center ml-1 cursor-pointer transition-all duration-200 ease-in-out"
<CustomTooltip content={<div>This doc doesn&apos;t have a link!</div>}>
<div className="inline-block cursor-help leading-none inline ml-1 align-middle">
<span className="group relative -top-1 text-gray-500 dark:text-gray-400 selection:bg-indigo-300 selection:text-black dark:selection:bg-indigo-900 dark:selection:text-white">
<span
className="inline-flex bg-background-200 group-hover:bg-background-300 items-center justify-center h-3.5 min-w-3.5 flex-none px-1 text-center text-xs rounded-full border-1 border-gray-400 ring-1 ring-gray-400 divide-gray-300 dark:divide-gray-700 dark:ring-gray-700 dark:border-gray-700 transition duration-150"
data-number="3"
>
<span className="relative min-w-[1.4rem] pchatno-underline -top-0.5 px-1.5 py-0.5 text-xs font-medium text-gray-700 bg-gray-100 rounded-full border border-gray-300 hover:bg-gray-200 hover:text-gray-900 shadow-sm no-underline">
{innerText}
</span>
</div>
</TooltipTrigger>
<TooltipContent width="mb-2 max-w-lg" backgroundColor="bg-background">
<CompactDocumentCard url={url} icon={icon} document={document} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
{innerText}
</span>
</span>
</div>
</CustomTooltip>
);
}
}

View File

@@ -6,6 +6,8 @@ import {
TriangleAlertIcon,
} from "@/components/icons/icons";
import { useState } from "react";
import { Grid } from "react-loader-spinner";
import { searchState } from "../SearchSection";
export type StatusOptions = "in-progress" | "failed" | "warning" | "success";

View File

@@ -43,9 +43,11 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
if (!results[0].ok) {
if (results[0].status === 403 || results[0].status === 401) {
settings = {
auto_scroll: true,
product_gating: GatingType.NONE,
gpu_enabled: false,
chat_page_enabled: true,
search_page_enabled: true,
default_page: "search",
maximum_chat_retention_days: null,
notifications: [],
needs_reindexing: false,

View File

@@ -131,7 +131,7 @@ export const CustomTooltip = ({
transform -translate-x-1/2 text-sm
${
light
? "text-text-800 bg-background-200"
? "text-gray-800 bg-background-200"
: "text-white bg-background-800"
}
rounded-lg shadow-lg`}

View File

@@ -50,13 +50,12 @@ function Badge({
...props
}: BadgeProps & {
icon?: React.ElementType;
size?: "sm" | "md" | "xs";
size?: "sm" | "md";
circle?: boolean;
}) {
const sizeClasses = {
sm: "px-2.5 py-0.5 text-xs",
md: "px-3 py-1 text-sm",
xs: "px-1.5 py-0.25 text-[.5rem]", // Made xs smaller
};
return (
@@ -65,20 +64,10 @@ function Badge({
{...props}
>
{Icon && (
<Icon
className={cn(
"mr-1",
size === "sm" ? "h-3 w-3" : size === "xs" ? "h-2 w-2" : "h-4 w-4"
)}
/>
<Icon className={cn("mr-1", size === "sm" ? "h-3 w-3" : "h-4 w-4")} />
)}
{circle && (
<div
className={cn(
"mr-2 rounded-full bg-current opacity-80",
size === "xs" ? "h-2 w-2" : "h-2.5 w-2.5"
)}
/>
<div className="h-2.5 w-2.5 mr-2 rounded-full bg-current opacity-80" />
)}
{props.children}
</div>

View File

@@ -1,30 +0,0 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-neutral-200 border-neutral-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-neutral-800 dark:border-neutral-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

Some files were not shown because too many files have changed in this diff Show More