mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-22 18:25:45 +00:00
Compare commits
6 Commits
text_view
...
proper_bas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92be55c9d7 | ||
|
|
dc8fa4c3cb | ||
|
|
c5aa64e3fb | ||
|
|
f4dea0821f | ||
|
|
1ed4002902 | ||
|
|
952893d7f0 |
111
.github/workflows/multi-tenant-tests.yml
vendored
Normal file
111
.github/workflows/multi-tenant-tests.yml
vendored
Normal 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
|
||||
74
.github/workflows/pr-integration-tests.yml
vendored
74
.github/workflows/pr-integration-tests.yml
vendored
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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:-}
|
||||
|
||||
30
deployment/docker_compose/docker-compose.multi-tenant.yml
Normal file
30
deployment/docker_compose/docker-compose.multi-tenant.yml
Normal 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
6
node_modules/.package-lock.json
generated
vendored
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "danswer",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
9
web/@types/favicon-fetch.d.ts
vendored
9
web/@types/favicon-fetch.d.ts
vendored
@@ -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
1011
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
168
web/src/app/chat/documentSidebar/DocumentSidebar.tsx
Normal file
168
web/src/app/chat/documentSidebar/DocumentSidebar.tsx
Normal 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";
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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't have a default model assigned.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
24
web/src/app/search/WrappedSearch.tsx
Normal file
24
web/src/app/search/WrappedSearch.tsx
Normal 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
213
web/src/app/search/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
web/src/components/InternetSearchIcon.tsx
Normal file
9
web/src/components/InternetSearchIcon.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
169
web/src/components/search/SearchAnswer.tsx
Normal file
169
web/src/components/search/SearchAnswer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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'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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
301
web/src/components/search/SearchResultsDisplay.tsx
Normal file
301
web/src/components/search/SearchResultsDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
856
web/src/components/search/SearchSection.tsx
Normal file
856
web/src/components/search/SearchSection.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
43
web/src/components/search/SearchTypeSelector.tsx
Normal file
43
web/src/components/search/SearchTypeSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user