Compare commits

..

1 Commits

Author SHA1 Message Date
pablodanswer
c32d428eb7 k 2024-10-26 16:20:18 -07:00
93 changed files with 876 additions and 1471 deletions

View File

@@ -1,136 +0,0 @@
name: Build and Push Cloud Web Image on Tag
# Identical to the web container build, but with correct image tag and build args
on:
push:
tags:
- '*'
env:
REGISTRY_IMAGE: danswer/danswer-cloud-web-server
LATEST_TAG: ${{ contains(github.ref_name, 'latest') }}
jobs:
build:
runs-on:
- runs-on
- runner=${{ matrix.platform == 'linux/amd64' && '8cpu-linux-x64' || '8cpu-linux-arm64' }}
- run-id=${{ github.run_id }}
- tag=platform-${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
tags: |
type=raw,value=${{ env.REGISTRY_IMAGE }}:${{ github.ref_name }}
type=raw,value=${{ env.LATEST_TAG == 'true' && format('{0}:latest', env.REGISTRY_IMAGE) || '' }}
- 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: Build and push by digest
id: build
uses: docker/build-push-action@v5
with:
context: ./web
file: ./web/Dockerfile
platforms: ${{ matrix.platform }}
push: true
build-args: |
DANSWER_VERSION=${{ github.ref_name }}
NEXT_PUBLIC_CLOUD_ENABLED=true
NEXT_PUBLIC_POSTHOG_KEY=${{ secrets.POSTHOG_KEY }}
NEXT_PUBLIC_POSTHOG_HOST=${{ secrets.POSTHOG_HOST }}
NEXT_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}
# needed due to weird interactions with the builds for different platforms
no-cache: true
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
needs:
- build
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }}
# trivy has their own rate limiting issues causing this action to flake
# we worked around it by hardcoding to different db repos in env
# can re-enable when they figure it out
# https://github.com/aquasecurity/trivy/discussions/7538
# https://github.com/aquasecurity/trivy-action/issues/389
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
env:
TRIVY_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-db:2'
TRIVY_JAVA_DB_REPOSITORY: 'public.ecr.aws/aquasecurity/trivy-java-db:1'
with:
image-ref: docker.io/${{ env.REGISTRY_IMAGE }}:${{ github.ref_name }}
severity: 'CRITICAL,HIGH'

View File

@@ -1,34 +1,30 @@
name: Backport on Merge
# Note this workflow does not trigger the builds, be sure to manually tag the branches to trigger the builds
on:
pull_request:
types: [closed] # Later we check for merge so only PRs that go in can get backported
permissions:
contents: write
actions: write
actions: write # Allows the workflow to trigger other workflows on push
jobs:
backport:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.YUHONG_GH_ACTIONS }}
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v3
with:
ssh-key: "${{ secrets.RKUO_DEPLOY_KEY }}"
fetch-depth: 0
fetch-depth: 0 # Fetch all history for all branches and tags
- name: Set up Git user
- name: Set up Git
run: |
git config user.name "Richard Kuo [bot]"
git config user.email "rkuo[bot]@danswer.ai"
git fetch --prune
git config --global user.name "yuhongsun96"
git config --global user.email "yuhongsun96@gmail.com"
# Configure Git to use the PAT for authentication
git remote set-url origin https://yuhongsun96:${{ secrets.YUHONG_GH_ACTIONS }}@github.com/${{ github.repository }}.git
- name: Check for Backport Checkbox
id: checkbox-check
run: |
@@ -51,14 +47,8 @@ jobs:
# Fetch latest tags for beta and stable
LATEST_BETA_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*-beta.[0-9]*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$" | grep -v -- "-cloud" | sort -Vr | head -n 1)
LATEST_STABLE_TAG=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$" | sort -Vr | head -n 1)
# Handle case where no beta tags exist
if [[ -z "$LATEST_BETA_TAG" ]]; then
NEW_BETA_TAG="v1.0.0-beta.1"
else
NEW_BETA_TAG=$(echo $LATEST_BETA_TAG | awk -F '[.-]' '{print $1 "." $2 "." $3 "-beta." ($NF+1)}')
fi
# Increment latest beta tag
NEW_BETA_TAG=$(echo $LATEST_BETA_TAG | awk -F '[.-]' '{print $1 "." $2 "." $3 "-beta." ($NF+1)}')
# Increment latest stable tag
NEW_STABLE_TAG=$(echo $LATEST_STABLE_TAG | awk -F '.' '{print $1 "." $2 "." ($3+1)}')
echo "latest_beta_tag=$LATEST_BETA_TAG" >> $GITHUB_OUTPUT
@@ -80,45 +70,29 @@ jobs:
run: |
set -e
echo "Backporting to beta ${{ steps.list-branches.outputs.beta }} and stable ${{ steps.list-branches.outputs.stable }}"
# Echo the merge commit SHA
echo "Merge commit SHA: ${{ github.event.pull_request.merge_commit_sha }}"
# Fetch all history for all branches and tags
git fetch --prune
# Reset and prepare the beta branch
# Checkout the beta branch
git checkout ${{ steps.list-branches.outputs.beta }}
echo "Last 5 commits on beta branch:"
git log -n 5 --pretty=format:"%H"
echo "" # Newline for formatting
# Cherry-pick the merge commit from the merged PR
git cherry-pick -m 1 ${{ github.event.pull_request.merge_commit_sha }} || {
echo "Cherry-pick to beta failed due to conflicts."
exit 1
}
# Create new beta branch/tag
# Create new beta tag
git tag ${{ steps.list-branches.outputs.new_beta_tag }}
# Push the changes and tag to the beta branch using PAT
git push origin ${{ steps.list-branches.outputs.beta }}
# Push the changes and tag to the beta branch
git push origin ${{ steps.list-branches.outputs.beta }} --force
git push origin ${{ steps.list-branches.outputs.new_beta_tag }}
# Reset and prepare the stable branch
# Checkout the stable branch
git checkout ${{ steps.list-branches.outputs.stable }}
echo "Last 5 commits on stable branch:"
git log -n 5 --pretty=format:"%H"
echo "" # Newline for formatting
# Cherry-pick the merge commit from the merged PR
git cherry-pick -m 1 ${{ github.event.pull_request.merge_commit_sha }} || {
echo "Cherry-pick to stable failed due to conflicts."
exit 1
}
# Create new stable branch/tag
# Create new stable tag
git tag ${{ steps.list-branches.outputs.new_stable_tag }}
# Push the changes and tag to the stable branch using PAT
git push origin ${{ steps.list-branches.outputs.stable }}
git push origin ${{ steps.list-branches.outputs.new_stable_tag }}
# Push the changes and tag to the stable branch
git push origin ${{ steps.list-branches.outputs.stable }} --force
git push origin ${{ steps.list-branches.outputs.new_stable_tag }}

View File

@@ -9,7 +9,7 @@ from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.sql import text
from shared_configs.configs import MULTI_TENANT
from danswer.configs.app_configs import MULTI_TENANT
from danswer.db.engine import build_connection_string
from danswer.db.models import Base
from celery.backends.database.session import ResultModelBase # type: ignore

View File

@@ -1,74 +0,0 @@
"""remove rt
Revision ID: 949b4a92a401
Revises: 1b10e1fda030
Create Date: 2024-10-26 13:06:06.937969
"""
from alembic import op
from sqlalchemy.orm import Session
# Import your models and constants
from danswer.db.models import (
Connector,
ConnectorCredentialPair,
Credential,
IndexAttempt,
)
from danswer.configs.constants import DocumentSource
# revision identifiers, used by Alembic.
revision = "949b4a92a401"
down_revision = "1b10e1fda030"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Deletes all RequestTracker connectors and associated data
bind = op.get_bind()
session = Session(bind=bind)
connectors_to_delete = (
session.query(Connector)
.filter(Connector.source == DocumentSource.REQUESTTRACKER)
.all()
)
connector_ids = [connector.id for connector in connectors_to_delete]
if connector_ids:
cc_pairs_to_delete = (
session.query(ConnectorCredentialPair)
.filter(ConnectorCredentialPair.connector_id.in_(connector_ids))
.all()
)
cc_pair_ids = [cc_pair.id for cc_pair in cc_pairs_to_delete]
if cc_pair_ids:
session.query(IndexAttempt).filter(
IndexAttempt.connector_credential_pair_id.in_(cc_pair_ids)
).delete(synchronize_session=False)
session.query(ConnectorCredentialPair).filter(
ConnectorCredentialPair.id.in_(cc_pair_ids)
).delete(synchronize_session=False)
credential_ids = [cc_pair.credential_id for cc_pair in cc_pairs_to_delete]
if credential_ids:
session.query(Credential).filter(Credential.id.in_(credential_ids)).delete(
synchronize_session=False
)
session.query(Connector).filter(Connector.id.in_(connector_ids)).delete(
synchronize_session=False
)
session.commit()
def downgrade() -> None:
# No-op downgrade as we cannot restore deleted data
pass

View File

@@ -49,7 +49,6 @@ from httpx_oauth.oauth2 import BaseOAuth2
from httpx_oauth.oauth2 import OAuth2Token
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy import text
from sqlalchemy.orm import attributes
from sqlalchemy.orm import Session
@@ -61,7 +60,9 @@ from danswer.configs.app_configs import AUTH_TYPE
from danswer.configs.app_configs import DISABLE_AUTH
from danswer.configs.app_configs import DISABLE_VERIFICATION
from danswer.configs.app_configs import EMAIL_FROM
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.app_configs import REQUIRE_EMAIL_VERIFICATION
from danswer.configs.app_configs import SECRET_JWT_KEY
from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
from danswer.configs.app_configs import SMTP_PASS
from danswer.configs.app_configs import SMTP_PORT
@@ -94,7 +95,6 @@ from danswer.utils.telemetry import optional_telemetry
from danswer.utils.telemetry import RecordType
from danswer.utils.variable_functionality import fetch_versioned_implementation
from shared_configs.configs import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
@@ -295,6 +295,29 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
return user
async def on_after_login(
self,
user: User,
request: Request | None = None,
response: Response | None = None,
) -> None:
if response is None or not MULTI_TENANT:
return
tenant_id = get_tenant_id_for_email(user.email)
tenant_token = jwt.encode(
{"tenant_id": tenant_id}, SECRET_JWT_KEY, algorithm="HS256"
)
response.set_cookie(
key="tenant_details",
value=tenant_token,
httponly=True,
secure=WEB_DOMAIN.startswith("https"),
samesite="lax",
)
async def oauth_callback(
self: "BaseUserManager[models.UOAP, models.ID]",
oauth_name: str,
@@ -367,10 +390,6 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
}
user = await self.user_db.create(user_dict)
# Explicitly set the Postgres schema for this session to ensure
# OAuth account creation happens in the correct tenant schema
await db_session.execute(text(f'SET search_path = "{tenant_id}"'))
user = await self.user_db.add_oauth_account(
user, oauth_account_dict
)
@@ -508,22 +527,8 @@ cookie_transport = CookieTransport(
)
# This strategy is used to add tenant_id to the JWT token
class TenantAwareJWTStrategy(JWTStrategy):
async def write_token(self, user: User) -> str:
tenant_id = get_tenant_id_for_email(user.email)
data = {
"sub": str(user.id),
"aud": self.token_audience,
"tenant_id": tenant_id,
}
return generate_jwt(
data, self.encode_key, self.lifetime_seconds, algorithm=self.algorithm
)
def get_jwt_strategy() -> JWTStrategy:
return TenantAwareJWTStrategy(
return JWTStrategy(
secret=USER_AUTH_SECRET,
lifetime_seconds=SESSION_EXPIRE_TIME_SECONDS,
)

View File

@@ -78,7 +78,6 @@ tasks_to_schedule = [
},
]
# Build the celery beat schedule dynamically
beat_schedule = {}

View File

@@ -162,8 +162,8 @@ def on_worker_init(sender: Any, **kwargs: Any) -> None:
for key in r.scan_iter(RedisConnectorIndexing.FENCE_PREFIX + "*"):
r.delete(key)
for key in r.scan_iter(RedisConnectorStop.FENCE_PREFIX + "*"):
r.delete(key)
for key in r.scan_iter(RedisConnectorStop.FENCE_PREFIX + "*"):
r.delete(key)
# @worker_process_init.connect

View File

@@ -81,7 +81,7 @@ def check_for_connector_deletion_task(self: Task, *, tenant_id: str | None) -> N
"Soft time limit exceeded, task is being terminated gracefully."
)
except Exception:
task_logger.exception(f"Unexpected exception: tenant={tenant_id}")
task_logger.exception("Unexpected exception")
finally:
if lock_beat.owned():
lock_beat.release()

View File

@@ -24,6 +24,7 @@ from danswer.background.indexing.job_client import SimpleJobClient
from danswer.background.indexing.run_indexing import run_indexing_entrypoint
from danswer.background.indexing.run_indexing import RunIndexingCallbackInterface
from danswer.configs.app_configs import DISABLE_INDEX_UPDATE_ON_SWAP
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.constants import CELERY_INDEXING_LOCK_TIMEOUT
from danswer.configs.constants import CELERY_VESPA_SYNC_BEAT_LOCK_TIMEOUT
from danswer.configs.constants import DANSWER_REDIS_FUNCTION_LOCK_PREFIX
@@ -55,7 +56,6 @@ from danswer.utils.logger import setup_logger
from danswer.utils.variable_functionality import global_version
from shared_configs.configs import INDEXING_MODEL_SERVER_HOST
from shared_configs.configs import INDEXING_MODEL_SERVER_PORT
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
@@ -175,7 +175,7 @@ def check_for_indexing(self: Task, *, tenant_id: str | None) -> int | None:
)
if attempt_id:
task_logger.info(
f"Indexing queued: cc_pair={cc_pair.id} index_attempt={attempt_id}"
f"Indexing queued: cc_pair_id={cc_pair.id} index_attempt_id={attempt_id}"
)
tasks_created += 1
except SoftTimeLimitExceeded:
@@ -183,7 +183,7 @@ def check_for_indexing(self: Task, *, tenant_id: str | None) -> int | None:
"Soft time limit exceeded, task is being terminated gracefully."
)
except Exception:
task_logger.exception(f"Unexpected exception: tenant={tenant_id}")
task_logger.exception("Unexpected exception")
finally:
if lock_beat.owned():
lock_beat.release()
@@ -366,12 +366,7 @@ def try_creating_indexing_task(
r.set(rci.fence_key, fence_value.model_dump_json())
except Exception:
r.delete(rci.fence_key)
task_logger.exception(
f"Unexpected exception: "
f"tenant={tenant_id} "
f"cc_pair={cc_pair.id} "
f"search_settings={search_settings.id}"
)
task_logger.exception("Unexpected exception")
return None
finally:
if lock.owned():
@@ -475,9 +470,10 @@ def connector_indexing_task(
# read related data and evaluate/print task progress
fence_value = cast(bytes, r.get(rci.fence_key))
if fence_value is None:
raise ValueError(
task_logger.info(
f"connector_indexing_task: fence_value not found: fence={rci.fence_key}"
)
raise RuntimeError(f"Fence not found: fence={rci.fence_key}")
try:
fence_json = fence_value.decode("utf-8")

View File

@@ -79,13 +79,13 @@ def check_for_pruning(self: Task, *, tenant_id: str | None) -> None:
if not tasks_created:
continue
task_logger.info(f"Pruning queued: cc_pair={cc_pair.id}")
task_logger.info(f"Pruning queued: cc_pair_id={cc_pair.id}")
except SoftTimeLimitExceeded:
task_logger.info(
"Soft time limit exceeded, task is being terminated gracefully."
)
except Exception:
task_logger.exception(f"Unexpected exception: tenant={tenant_id}")
task_logger.exception("Unexpected exception")
finally:
if lock_beat.owned():
lock_beat.release()
@@ -201,7 +201,7 @@ def try_creating_prune_generator_task(
# set this only after all tasks have been added
r.set(rcp.fence_key, 1)
except Exception:
task_logger.exception(f"Unexpected exception: cc_pair={cc_pair.id}")
task_logger.exception("Unexpected exception")
return None
finally:
if lock.owned():
@@ -300,7 +300,7 @@ def connector_pruning_generator_task(
rcp.documents_to_prune = set(doc_ids_to_remove)
task_logger.info(
f"RedisConnectorPruning.generate_tasks starting. cc_pair={cc_pair.id}"
f"RedisConnectorPruning.generate_tasks starting. cc_pair_id={cc_pair_id}"
)
tasks_generated = rcp.generate_tasks(
self.app, db_session, r, None, tenant_id
@@ -310,7 +310,7 @@ def connector_pruning_generator_task(
task_logger.info(
f"RedisConnectorPruning.generate_tasks finished. "
f"cc_pair={cc_pair.id} tasks_generated={tasks_generated}"
f"cc_pair={cc_pair_id} tasks_generated={tasks_generated}"
)
r.set(rcp.generator_complete_key, tasks_generated)

View File

@@ -1,40 +0,0 @@
import httpx
from tenacity import retry
from tenacity import retry_if_exception_type
from tenacity import stop_after_delay
from tenacity import wait_random_exponential
from danswer.document_index.interfaces import DocumentIndex
from danswer.document_index.interfaces import VespaDocumentFields
class RetryDocumentIndex:
"""A wrapper class to help with specific retries against Vespa involving
read timeouts.
wait_random_exponential implements full jitter as per this article:
https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/"""
MAX_WAIT = 30
# STOP_AFTER + MAX_WAIT should be slightly less (5?) than the celery soft_time_limit
STOP_AFTER = 70
def __init__(self, index: DocumentIndex):
self.index: DocumentIndex = index
@retry(
retry=retry_if_exception_type(httpx.ReadTimeout),
wait=wait_random_exponential(multiplier=1, max=MAX_WAIT),
stop=stop_after_delay(STOP_AFTER),
)
def delete_single(self, doc_id: str) -> int:
return self.index.delete_single(doc_id)
@retry(
retry=retry_if_exception_type(httpx.ReadTimeout),
wait=wait_random_exponential(multiplier=1, max=MAX_WAIT),
stop=stop_after_delay(STOP_AFTER),
)
def update_single(self, doc_id: str, fields: VespaDocumentFields) -> int:
return self.index.update_single(doc_id, fields)

View File

@@ -1,14 +1,9 @@
from http import HTTPStatus
import httpx
from celery import shared_task
from celery import Task
from celery.exceptions import SoftTimeLimitExceeded
from tenacity import RetryError
from danswer.access.access import get_access_for_document
from danswer.background.celery.apps.app_base import task_logger
from danswer.background.celery.tasks.shared.RetryDocumentIndex import RetryDocumentIndex
from danswer.db.document import delete_document_by_connector_credential_pair__no_commit
from danswer.db.document import delete_documents_complete__no_commit
from danswer.db.document import get_document
@@ -25,17 +20,12 @@ from danswer.server.documents.models import ConnectorCredentialPairIdentifier
DOCUMENT_BY_CC_PAIR_CLEANUP_MAX_RETRIES = 3
# 5 seconds more than RetryDocumentIndex STOP_AFTER+MAX_WAIT
LIGHT_SOFT_TIME_LIMIT = 105
LIGHT_TIME_LIMIT = LIGHT_SOFT_TIME_LIMIT + 15
@shared_task(
name="document_by_cc_pair_cleanup_task",
soft_time_limit=LIGHT_SOFT_TIME_LIMIT,
time_limit=LIGHT_TIME_LIMIT,
max_retries=DOCUMENT_BY_CC_PAIR_CLEANUP_MAX_RETRIES,
bind=True,
soft_time_limit=45,
time_limit=60,
max_retries=DOCUMENT_BY_CC_PAIR_CLEANUP_MAX_RETRIES,
)
def document_by_cc_pair_cleanup_task(
self: Task,
@@ -59,7 +49,7 @@ def document_by_cc_pair_cleanup_task(
connector / credential pair from the access list
(6) delete all relevant entries from postgres
"""
task_logger.info(f"tenant={tenant_id} doc={document_id}")
task_logger.info(f"tenant_id={tenant_id} document_id={document_id}")
try:
with get_session_with_tenant(tenant_id) as db_session:
@@ -67,19 +57,17 @@ def document_by_cc_pair_cleanup_task(
chunks_affected = 0
curr_ind_name, sec_ind_name = get_both_index_names(db_session)
doc_index = get_default_document_index(
document_index = get_default_document_index(
primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name
)
retry_index = RetryDocumentIndex(doc_index)
count = get_document_connector_count(db_session, document_id)
if count == 1:
# count == 1 means this is the only remaining cc_pair reference to the doc
# delete it from vespa and the db
action = "delete"
chunks_affected = retry_index.delete_single(document_id)
chunks_affected = document_index.delete_single(document_id)
delete_documents_complete__no_commit(
db_session=db_session,
document_ids=[document_id],
@@ -109,7 +97,9 @@ def document_by_cc_pair_cleanup_task(
)
# update Vespa. OK if doc doesn't exist. Raises exception otherwise.
chunks_affected = retry_index.update_single(document_id, fields=fields)
chunks_affected = document_index.update_single(
document_id, fields=fields
)
# there are still other cc_pair references to the doc, so just resync to Vespa
delete_document_by_connector_credential_pair__no_commit(
@@ -128,41 +118,19 @@ def document_by_cc_pair_cleanup_task(
db_session.commit()
task_logger.info(
f"tenant={tenant_id} "
f"doc={document_id} "
f"tenant_id={tenant_id} "
f"document_id={document_id} "
f"action={action} "
f"refcount={count} "
f"chunks={chunks_affected}"
)
except SoftTimeLimitExceeded:
task_logger.info(
f"SoftTimeLimitExceeded exception. tenant={tenant_id} doc={document_id}"
f"SoftTimeLimitExceeded exception. tenant_id={tenant_id} doc_id={document_id}"
)
return False
except Exception as ex:
if isinstance(ex, RetryError):
task_logger.info(f"Retry failed: {ex.last_attempt.attempt_number}")
# only set the inner exception if it is of type Exception
e_temp = ex.last_attempt.exception()
if isinstance(e_temp, Exception):
e = e_temp
else:
e = ex
if isinstance(e, httpx.HTTPStatusError):
if e.response.status_code == HTTPStatus.BAD_REQUEST:
task_logger.exception(
f"Non-retryable HTTPStatusError: "
f"tenant={tenant_id} "
f"doc={document_id} "
f"status={e.response.status_code}"
)
return False
task_logger.exception(
f"Unexpected exception: tenant={tenant_id} doc={document_id}"
)
except Exception as e:
task_logger.exception("Unexpected exception")
if self.request.retries < DOCUMENT_BY_CC_PAIR_CLEANUP_MAX_RETRIES:
# Still retrying. Exponential backoff from 2^4 to 2^6 ... i.e. 16, 32, 64
@@ -173,7 +141,7 @@ def document_by_cc_pair_cleanup_task(
# eventually gets fixed out of band via stale document reconciliation
task_logger.info(
f"Max retries reached. Marking doc as dirty for reconciliation: "
f"tenant={tenant_id} doc={document_id}"
f"tenant_id={tenant_id} document_id={document_id}"
)
with get_session_with_tenant(tenant_id):
mark_document_as_modified(document_id, db_session)

View File

@@ -4,7 +4,6 @@ from datetime import timezone
from http import HTTPStatus
from typing import cast
import httpx
import redis
from celery import Celery
from celery import shared_task
@@ -14,7 +13,6 @@ from celery.result import AsyncResult
from celery.states import READY_STATES
from redis import Redis
from sqlalchemy.orm import Session
from tenacity import RetryError
from danswer.access.access import get_access_for_document
from danswer.background.celery.apps.app_base import task_logger
@@ -31,9 +29,6 @@ from danswer.background.celery.tasks.shared.RedisConnectorDeletionFenceData impo
from danswer.background.celery.tasks.shared.RedisConnectorIndexingFenceData import (
RedisConnectorIndexingFenceData,
)
from danswer.background.celery.tasks.shared.RetryDocumentIndex import RetryDocumentIndex
from danswer.background.celery.tasks.shared.tasks import LIGHT_SOFT_TIME_LIMIT
from danswer.background.celery.tasks.shared.tasks import LIGHT_TIME_LIMIT
from danswer.configs.app_configs import JOB_TIMEOUT
from danswer.configs.constants import CELERY_VESPA_SYNC_BEAT_LOCK_TIMEOUT
from danswer.configs.constants import DanswerCeleryQueues
@@ -157,7 +152,7 @@ def check_for_vespa_sync_task(self: Task, *, tenant_id: str | None) -> None:
"Soft time limit exceeded, task is being terminated gracefully."
)
except Exception:
task_logger.exception(f"Unexpected exception: tenant={tenant_id}")
task_logger.exception("Unexpected exception")
finally:
if lock_beat.owned():
lock_beat.release()
@@ -814,22 +809,22 @@ def monitor_vespa_sync(self: Task, tenant_id: str | None) -> bool:
@shared_task(
name="vespa_metadata_sync_task",
bind=True,
soft_time_limit=LIGHT_SOFT_TIME_LIMIT,
time_limit=LIGHT_TIME_LIMIT,
soft_time_limit=45,
time_limit=60,
max_retries=3,
)
def vespa_metadata_sync_task(
self: Task, document_id: str, tenant_id: str | None
) -> bool:
task_logger.info(f"document_id={document_id}")
try:
with get_session_with_tenant(tenant_id) as db_session:
curr_ind_name, sec_ind_name = get_both_index_names(db_session)
doc_index = get_default_document_index(
document_index = get_default_document_index(
primary_index_name=curr_ind_name, secondary_index_name=sec_ind_name
)
retry_index = RetryDocumentIndex(doc_index)
doc = get_document(document_id, db_session)
if not doc:
return False
@@ -851,43 +846,19 @@ def vespa_metadata_sync_task(
)
# update Vespa. OK if doc doesn't exist. Raises exception otherwise.
chunks_affected = retry_index.update_single(document_id, fields)
chunks_affected = document_index.update_single(document_id, fields=fields)
# update db last. Worst case = we crash right before this and
# the sync might repeat again later
mark_document_as_synced(document_id, db_session)
task_logger.info(
f"tenant={tenant_id} doc={document_id} action=sync chunks={chunks_affected}"
f"document_id={document_id} action=sync chunks={chunks_affected}"
)
except SoftTimeLimitExceeded:
task_logger.info(
f"SoftTimeLimitExceeded exception. tenant={tenant_id} doc={document_id}"
)
except Exception as ex:
if isinstance(ex, RetryError):
task_logger.warning(f"Retry failed: {ex.last_attempt.attempt_number}")
# only set the inner exception if it is of type Exception
e_temp = ex.last_attempt.exception()
if isinstance(e_temp, Exception):
e = e_temp
else:
e = ex
if isinstance(e, httpx.HTTPStatusError):
if e.response.status_code == HTTPStatus.BAD_REQUEST:
task_logger.exception(
f"Non-retryable HTTPStatusError: "
f"tenant={tenant_id} "
f"doc={document_id} "
f"status={e.response.status_code}"
)
return False
task_logger.exception(
f"Unexpected exception: tenant={tenant_id} doc={document_id}"
)
task_logger.info(f"SoftTimeLimitExceeded exception. doc_id={document_id}")
except Exception as e:
task_logger.exception("Unexpected exception")
# Exponential backoff from 2^4 to 2^6 ... i.e. 16, 32, 64
countdown = 2 ** (self.request.retries + 4)

View File

@@ -41,19 +41,6 @@ personas:
icon_color: "#6FB1FF"
display_priority: 1
is_visible: true
starter_messages:
- name: "General Information"
description: "Ask about available information"
message: "Hello! I'm interested in learning more about the information available here. Could you give me an overview of the types of data or documents that might be accessible?"
- name: "Specific Topic Search"
description: "Search for specific information"
message: "Hi! I'd like to learn more about a specific topic. Could you help me find relevant documents and information?"
- name: "Recent Updates"
description: "Inquire about latest additions"
message: "Hello! I'm curious about any recent updates or additions to the knowledge base. Can you tell me what new information has been added lately?"
- name: "Cross-referencing Information"
description: "Connect information from different sources"
message: "Hi! I'm working on a project that requires connecting information from multiple sources. How can I effectively cross-reference data across different documents or categories?"
- id: 1
name: "General"
@@ -70,19 +57,6 @@ personas:
icon_color: "#FF6F6F"
display_priority: 0
is_visible: true
starter_messages:
- name: "Open Discussion"
description: "Start an open-ended conversation"
message: "Hi! Can you help me write a professional email?"
- name: "Problem Solving"
description: "Get help with a challenge"
message: "Hello! I need help managing my daily tasks better. Do you have any simple tips?"
- name: "Learn Something New"
description: "Explore a new topic"
message: "Hi! Could you explain what project management is in simple terms?"
- name: "Creative Brainstorming"
description: "Generate creative ideas"
message: "Hello! I need to brainstorm some team building activities. Do you have any fun suggestions?"
- id: 2
name: "Paraphrase"
@@ -99,19 +73,7 @@ personas:
icon_color: "#6FFF8D"
display_priority: 2
is_visible: false
starter_messages:
- name: "Document Search"
description: "Find exact information"
message: "Hi! Could you help me find information about our team structure and reporting lines from our internal documents?"
- name: "Process Verification"
description: "Find exact quotes"
message: "Hello! I need to understand our project approval process. Could you find the exact steps from our documentation?"
- name: "Technical Documentation"
description: "Search technical details"
message: "Hi there! I'm looking for information about our deployment procedures. Can you find the specific steps from our technical guides?"
- name: "Policy Reference"
description: "Check official policies"
message: "Hello! Could you help me find our official guidelines about client communication? I need the exact wording from our documentation."
- id: 3
name: "Art"
@@ -124,21 +86,8 @@ personas:
llm_filter_extraction: false
recency_bias: "no_decay"
document_sets: []
icon_shape: 234124
icon_shape: 234124
icon_color: "#9B59B6"
image_generation: true
image_generation: true
display_priority: 3
is_visible: true
starter_messages:
- name: "Landscape"
description: "Generate a landscape image"
message: "Create an image of a serene mountain lake at sunset, with snow-capped peaks reflected in the calm water and a small wooden cabin on the shore."
- name: "Character"
description: "Generate a character image"
message: "Generate an image of a futuristic robot with glowing blue eyes, sleek metallic body, and intricate circuitry visible through transparent panels on its chest and arms."
- name: "Abstract"
description: "Create an abstract image"
message: "Create an abstract image representing the concept of time, using swirling clock hands, fragmented hourglasses, and streaks of light to convey the passage of moments and eras."
- name: "Urban Scene"
description: "Generate an urban landscape"
message: "Generate an image of a bustling futuristic cityscape at night, with towering skyscrapers, flying vehicles, holographic advertisements, and a mix of neon and bioluminescent lighting."

View File

@@ -437,7 +437,7 @@ CUSTOM_ANSWER_VALIDITY_CONDITIONS = json.loads(
os.environ.get("CUSTOM_ANSWER_VALIDITY_CONDITIONS", "[]")
)
VESPA_REQUEST_TIMEOUT = int(os.environ.get("VESPA_REQUEST_TIMEOUT") or "15")
VESPA_REQUEST_TIMEOUT = int(os.environ.get("VESPA_REQUEST_TIMEOUT") or "5")
SYSTEM_RECURSION_LIMIT = int(os.environ.get("SYSTEM_RECURSION_LIMIT") or "1000")
@@ -461,12 +461,20 @@ AZURE_DALLE_API_BASE = os.environ.get("AZURE_DALLE_API_BASE")
AZURE_DALLE_DEPLOYMENT_NAME = os.environ.get("AZURE_DALLE_DEPLOYMENT_NAME")
# Cloud configuration
# Multi-tenancy configuration
MULTI_TENANT = os.environ.get("MULTI_TENANT", "").lower() == "true"
# Use managed Vespa (Vespa Cloud). If set, must also set VESPA_CLOUD_URL, VESPA_CLOUD_CERT_PATH and VESPA_CLOUD_KEY_PATH
MANAGED_VESPA = os.environ.get("MANAGED_VESPA", "").lower() == "true"
ENABLE_EMAIL_INVITES = os.environ.get("ENABLE_EMAIL_INVITES", "").lower() == "true"
# Security and authentication
SECRET_JWT_KEY = os.environ.get(
"SECRET_JWT_KEY", ""
) # Used for encryption of the JWT token for user's tenant context
DATA_PLANE_SECRET = os.environ.get(
"DATA_PLANE_SECRET", ""
) # Used for secure communication between the control and data plane

View File

@@ -31,6 +31,9 @@ DISABLED_GEN_AI_MSG = (
"You can still use Danswer as a search engine."
)
# Prefix used for all tenant ids
TENANT_ID_PREFIX = "tenant_"
# Postgres connection constants for application_name
POSTGRES_WEB_APP_NAME = "web"
POSTGRES_INDEXER_APP_NAME = "indexer"

View File

@@ -44,6 +44,8 @@ class BookstackConnector(LoadConnector, PollConnector):
start: SecondsSinceUnixEpoch | None = None,
end: SecondsSinceUnixEpoch | None = None,
) -> tuple[list[Document], int]:
doc_batch: list[Document] = []
params = {
"count": str(batch_size),
"offset": str(start_ind),
@@ -61,7 +63,8 @@ class BookstackConnector(LoadConnector, PollConnector):
)
batch = bookstack_client.get(endpoint, params=params).get("data", [])
doc_batch = [transformer(bookstack_client, item) for item in batch]
for item in batch:
doc_batch.append(transformer(bookstack_client, item))
return doc_batch, len(batch)

View File

@@ -210,7 +210,6 @@ if __name__ == "__main__":
"clickup_team_id": os.environ["clickup_team_id"],
}
)
latest_docs = clickup_connector.load_from_state()
for doc in latest_docs:

View File

@@ -11,10 +11,6 @@ from danswer.connectors.models import BasicExpertInfo
from danswer.utils.text_processing import is_valid_email
T = TypeVar("T")
U = TypeVar("U")
def datetime_to_utc(dt: datetime) -> datetime:
if dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None:
dt = dt.replace(tzinfo=timezone.utc)
@@ -53,6 +49,10 @@ def get_experts_stores_representations(
return [owner for owner in reps if owner is not None]
T = TypeVar("T")
U = TypeVar("U")
def process_in_batches(
objects: list[T], process_function: Callable[[T], U], batch_size: int
) -> Iterator[list[U]]:

View File

@@ -34,6 +34,7 @@ from danswer.connectors.mediawiki.wiki import MediaWikiConnector
from danswer.connectors.models import InputType
from danswer.connectors.notion.connector import NotionConnector
from danswer.connectors.productboard.connector import ProductboardConnector
from danswer.connectors.requesttracker.connector import RequestTrackerConnector
from danswer.connectors.salesforce.connector import SalesforceConnector
from danswer.connectors.sharepoint.connector import SharepointConnector
from danswer.connectors.slab.connector import SlabConnector
@@ -76,6 +77,7 @@ def identify_connector_class(
DocumentSource.SLAB: SlabConnector,
DocumentSource.NOTION: NotionConnector,
DocumentSource.ZULIP: ZulipConnector,
DocumentSource.REQUESTTRACKER: RequestTrackerConnector,
DocumentSource.GURU: GuruConnector,
DocumentSource.LINEAR: LinearConnector,
DocumentSource.HUBSPOT: HubSpotConnector,

View File

@@ -24,9 +24,6 @@ from danswer.connectors.models import Document
from danswer.connectors.models import Section
from danswer.utils.logger import setup_logger
logger = setup_logger()
# List of directories/Files to exclude
exclude_patterns = [
"logs",
@@ -34,6 +31,7 @@ exclude_patterns = [
".gitlab/",
".pre-commit-config.yaml",
]
logger = setup_logger()
def _batch_gitlab_objects(

View File

@@ -19,14 +19,13 @@ from danswer.connectors.models import Section
from danswer.file_processing.html_utils import parse_html_page_basic
from danswer.utils.logger import setup_logger
logger = setup_logger()
# Potential Improvements
# 1. Support fetching per collection via collection token (configured at connector creation)
GURU_API_BASE = "https://api.getguru.com/api/v1/"
GURU_QUERY_ENDPOINT = GURU_API_BASE + "search/query"
GURU_CARDS_URL = "https://app.getguru.com/card/"
logger = setup_logger()
def unixtime_to_guru_time_str(unix_time: SecondsSinceUnixEpoch) -> str:

View File

@@ -18,7 +18,6 @@ from danswer.connectors.models import Document
from danswer.connectors.models import Section
from danswer.utils.logger import setup_logger
logger = setup_logger()
_NUM_RETRIES = 5

View File

@@ -22,7 +22,6 @@ from danswer.connectors.models import Document
from danswer.connectors.models import Section
from danswer.utils.logger import setup_logger
logger = setup_logger()
@@ -231,7 +230,5 @@ if __name__ == "__main__":
print("All docs", all_docs)
current = datetime.datetime.now().timestamp()
one_day_ago = current - 30 * 24 * 60 * 60 # 30 days
latest_docs = list(test_connector.poll_source(one_day_ago, current))
print("Latest docs", latest_docs)

View File

@@ -134,14 +134,9 @@ class NotionConnector(LoadConnector, PollConnector):
f"This is likely due to the block not being shared "
f"with the Danswer integration. Exact exception:\n\n{e}"
)
else:
logger.exception(
f"Error fetching blocks with status code {res.status_code}: {res.json()}"
)
# This can occasionally happen, the reason is unknown and cannot be reproduced on our internal Notion
# Assuming this will not be a critical loss of data
return None
return None
logger.exception(f"Error fetching blocks - {res.json()}")
raise e
return res.json()
@retry(tries=3, delay=1, backoff=2)

View File

@@ -1,124 +1,153 @@
# from datetime import datetime
# from datetime import timezone
# from logging import DEBUG as LOG_LVL_DEBUG
# from typing import Any
# from typing import List
# from typing import Optional
# from rt.rest1 import ALL_QUEUES
# from rt.rest1 import Rt
# 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 PollConnector
# from danswer.connectors.interfaces import SecondsSinceUnixEpoch
# from danswer.connectors.models import ConnectorMissingCredentialError
# from danswer.connectors.models import Document
# from danswer.connectors.models import Section
# from danswer.utils.logger import setup_logger
# logger = setup_logger()
# class RequestTrackerError(Exception):
# pass
# class RequestTrackerConnector(PollConnector):
# def __init__(
# self,
# batch_size: int = INDEX_BATCH_SIZE,
# ) -> None:
# self.batch_size = batch_size
# def txn_link(self, tid: int, txn: int) -> str:
# return f"{self.rt_base_url}/Ticket/Display.html?id={tid}&txn={txn}"
# def build_doc_sections_from_txn(
# self, connection: Rt, ticket_id: int
# ) -> List[Section]:
# Sections: List[Section] = []
# get_history_resp = connection.get_history(ticket_id)
# if get_history_resp is None:
# raise RequestTrackerError(f"Ticket {ticket_id} cannot be found")
# for tx in get_history_resp:
# Sections.append(
# Section(
# link=self.txn_link(ticket_id, int(tx["id"])),
# text="\n".join(
# [
# f"{k}:\n{v}\n" if k != "Attachments" else ""
# for (k, v) in tx.items()
# ]
# ),
# )
# )
# return Sections
# def load_credentials(self, credentials: dict[str, Any]) -> Optional[dict[str, Any]]:
# self.rt_username = credentials.get("requesttracker_username")
# self.rt_password = credentials.get("requesttracker_password")
# self.rt_base_url = credentials.get("requesttracker_base_url")
# return None
# # This does not include RT file attachments yet.
# def _process_tickets(
# self, start: datetime, end: datetime
# ) -> GenerateDocumentsOutput:
# if any([self.rt_username, self.rt_password, self.rt_base_url]) is None:
# raise ConnectorMissingCredentialError("requesttracker")
# Rt0 = Rt(
# f"{self.rt_base_url}/REST/1.0/",
# self.rt_username,
# self.rt_password,
# )
# Rt0.login()
# d0 = start.strftime("%Y-%m-%d %H:%M:%S")
# d1 = end.strftime("%Y-%m-%d %H:%M:%S")
# tickets = Rt0.search(
# Queue=ALL_QUEUES,
# raw_query=f"Updated > '{d0}' AND Updated < '{d1}'",
# )
# doc_batch: List[Document] = []
# for ticket in tickets:
# ticket_keys_to_omit = ["id", "Subject"]
# tid: int = int(ticket["numerical_id"])
# ticketLink: str = f"{self.rt_base_url}/Ticket/Display.html?id={tid}"
# logger.info(f"Processing ticket {tid}")
# doc = Document(
# id=ticket["id"],
# # Will add title to the first section later in processing
# sections=[Section(link=ticketLink, text="")]
# + self.build_doc_sections_from_txn(Rt0, tid),
# source=DocumentSource.REQUESTTRACKER,
# semantic_identifier=ticket["Subject"],
# metadata={
# key: value
# for key, value in ticket.items()
# if key not in ticket_keys_to_omit
# },
# )
# doc_batch.append(doc)
# if len(doc_batch) >= self.batch_size:
# yield doc_batch
# doc_batch = []
# if doc_batch:
# yield doc_batch
# def poll_source(
# self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
# ) -> GenerateDocumentsOutput:
# # Keep query short, only look behind 1 day at maximum
# one_day_ago: float = end - (24 * 60 * 60)
# _start: float = start if start > one_day_ago else one_day_ago
# start_datetime = datetime.fromtimestamp(_start, tz=timezone.utc)
# end_datetime = datetime.fromtimestamp(end, tz=timezone.utc)
# yield from self._process_tickets(start_datetime, end_datetime)
# if __name__ == "__main__":
# import time
# import os
# from dotenv import load_dotenv
# load_dotenv()
# logger.setLevel(LOG_LVL_DEBUG)
# rt_connector = RequestTrackerConnector()
# rt_connector.load_credentials(
# {
# "requesttracker_username": os.getenv("RT_USERNAME"),
# "requesttracker_password": os.getenv("RT_PASSWORD"),
# "requesttracker_base_url": os.getenv("RT_BASE_URL"),
# }
# )
# current = time.time()
# one_day_ago = current - (24 * 60 * 60) # 1 days
# latest_docs = rt_connector.poll_source(one_day_ago, current)
# for doc in latest_docs:
# print(doc)
from datetime import datetime
from datetime import timezone
from logging import DEBUG as LOG_LVL_DEBUG
from typing import Any
from typing import List
from typing import Optional
from rt.rest1 import ALL_QUEUES
from rt.rest1 import Rt
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 PollConnector
from danswer.connectors.interfaces import SecondsSinceUnixEpoch
from danswer.connectors.models import ConnectorMissingCredentialError
from danswer.connectors.models import Document
from danswer.connectors.models import Section
from danswer.utils.logger import setup_logger
logger = setup_logger()
class RequestTrackerError(Exception):
pass
class RequestTrackerConnector(PollConnector):
def __init__(
self,
batch_size: int = INDEX_BATCH_SIZE,
) -> None:
self.batch_size = batch_size
def txn_link(self, tid: int, txn: int) -> str:
return f"{self.rt_base_url}/Ticket/Display.html?id={tid}&txn={txn}"
def build_doc_sections_from_txn(
self, connection: Rt, ticket_id: int
) -> List[Section]:
Sections: List[Section] = []
get_history_resp = connection.get_history(ticket_id)
if get_history_resp is None:
raise RequestTrackerError(f"Ticket {ticket_id} cannot be found")
for tx in get_history_resp:
Sections.append(
Section(
link=self.txn_link(ticket_id, int(tx["id"])),
text="\n".join(
[
f"{k}:\n{v}\n" if k != "Attachments" else ""
for (k, v) in tx.items()
]
),
)
)
return Sections
def load_credentials(self, credentials: dict[str, Any]) -> Optional[dict[str, Any]]:
self.rt_username = credentials.get("requesttracker_username")
self.rt_password = credentials.get("requesttracker_password")
self.rt_base_url = credentials.get("requesttracker_base_url")
return None
# This does not include RT file attachments yet.
def _process_tickets(
self, start: datetime, end: datetime
) -> GenerateDocumentsOutput:
if any([self.rt_username, self.rt_password, self.rt_base_url]) is None:
raise ConnectorMissingCredentialError("requesttracker")
Rt0 = Rt(
f"{self.rt_base_url}/REST/1.0/",
self.rt_username,
self.rt_password,
)
Rt0.login()
d0 = start.strftime("%Y-%m-%d %H:%M:%S")
d1 = end.strftime("%Y-%m-%d %H:%M:%S")
tickets = Rt0.search(
Queue=ALL_QUEUES,
raw_query=f"Updated > '{d0}' AND Updated < '{d1}'",
)
doc_batch: List[Document] = []
for ticket in tickets:
ticket_keys_to_omit = ["id", "Subject"]
tid: int = int(ticket["numerical_id"])
ticketLink: str = f"{self.rt_base_url}/Ticket/Display.html?id={tid}"
logger.info(f"Processing ticket {tid}")
doc = Document(
id=ticket["id"],
# Will add title to the first section later in processing
sections=[Section(link=ticketLink, text="")]
+ self.build_doc_sections_from_txn(Rt0, tid),
source=DocumentSource.REQUESTTRACKER,
semantic_identifier=ticket["Subject"],
metadata={
key: value
for key, value in ticket.items()
if key not in ticket_keys_to_omit
},
)
doc_batch.append(doc)
if len(doc_batch) >= self.batch_size:
yield doc_batch
doc_batch = []
if doc_batch:
yield doc_batch
def poll_source(
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
) -> GenerateDocumentsOutput:
# Keep query short, only look behind 1 day at maximum
one_day_ago: float = end - (24 * 60 * 60)
_start: float = start if start > one_day_ago else one_day_ago
start_datetime = datetime.fromtimestamp(_start, tz=timezone.utc)
end_datetime = datetime.fromtimestamp(end, tz=timezone.utc)
yield from self._process_tickets(start_datetime, end_datetime)
if __name__ == "__main__":
import time
import os
from dotenv import load_dotenv
load_dotenv()
logger.setLevel(LOG_LVL_DEBUG)
rt_connector = RequestTrackerConnector()
rt_connector.load_credentials(
{
"requesttracker_username": os.getenv("RT_USERNAME"),
"requesttracker_password": os.getenv("RT_PASSWORD"),
"requesttracker_base_url": os.getenv("RT_BASE_URL"),
}
)
current = time.time()
one_day_ago = current - (24 * 60 * 60) # 1 days
latest_docs = rt_connector.poll_source(one_day_ago, current)
for doc in latest_docs:
print(doc)

View File

@@ -25,7 +25,6 @@ from danswer.connectors.models import Section
from danswer.file_processing.extract_file_text import extract_file_text
from danswer.utils.logger import setup_logger
logger = setup_logger()

View File

@@ -20,13 +20,10 @@ from danswer.connectors.models import Document
from danswer.connectors.models import Section
from danswer.utils.logger import setup_logger
logger = setup_logger()
# Fairly generous retry because it's not understood why occasionally GraphQL requests fail even with timeout > 1 min
SLAB_GRAPHQL_MAX_TRIES = 10
SLAB_API_URL = "https://api.slab.com/v1/graphql"
logger = setup_logger()
def run_graphql_request(

View File

@@ -441,7 +441,6 @@ if __name__ == "__main__":
current = time.time()
one_day_ago = current - 24 * 60 * 60 # 1 day
document_batches = connector.poll_source(one_day_ago, current)
print(next(document_batches))

View File

@@ -16,7 +16,6 @@ from danswer.connectors.slack.connector import filter_channels
from danswer.connectors.slack.utils import get_message_link
from danswer.utils.logger import setup_logger
logger = setup_logger()

View File

@@ -57,10 +57,7 @@ async def get_user_count() -> int:
# Need to override this because FastAPI Users doesn't give flexibility for backend field creation logic in OAuth flow
class SQLAlchemyUserAdminDB(SQLAlchemyUserDatabase):
async def create(
self,
create_dict: Dict[str, Any],
) -> UP:
async def create(self, create_dict: Dict[str, Any]) -> UP:
user_count = await get_user_count()
if user_count == 0 or create_dict["email"] in get_default_admin_user_emails():
create_dict["role"] = UserRole.ADMIN

View File

@@ -25,6 +25,7 @@ from sqlalchemy.orm import sessionmaker
from danswer.configs.app_configs import LOG_POSTGRES_CONN_COUNTS
from danswer.configs.app_configs import LOG_POSTGRES_LATENCY
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.app_configs import POSTGRES_API_SERVER_POOL_OVERFLOW
from danswer.configs.app_configs import POSTGRES_API_SERVER_POOL_SIZE
from danswer.configs.app_configs import POSTGRES_DB
@@ -34,13 +35,12 @@ from danswer.configs.app_configs import POSTGRES_POOL_PRE_PING
from danswer.configs.app_configs import POSTGRES_POOL_RECYCLE
from danswer.configs.app_configs import POSTGRES_PORT
from danswer.configs.app_configs import POSTGRES_USER
from danswer.configs.app_configs import USER_AUTH_SECRET
from danswer.configs.app_configs import SECRET_JWT_KEY
from danswer.configs.constants import POSTGRES_UNKNOWN_APP_NAME
from danswer.configs.constants import TENANT_ID_PREFIX
from danswer.utils.logger import setup_logger
from shared_configs.configs import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
from shared_configs.configs import TENANT_ID_PREFIX
logger = setup_logger()
@@ -263,20 +263,17 @@ def get_current_tenant_id(request: Request) -> str:
CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
return tenant_id
token = request.cookies.get("fastapiusersauth")
token = request.cookies.get("tenant_details")
if not token:
current_value = CURRENT_TENANT_ID_CONTEXTVAR.get()
# If no token is present, use the default schema or handle accordingly
return current_value
try:
payload = jwt.decode(
token,
USER_AUTH_SECRET,
audience=["fastapi-users:auth"],
algorithms=["HS256"],
)
tenant_id = payload.get("tenant_id", POSTGRES_DEFAULT_SCHEMA)
payload = jwt.decode(token, SECRET_JWT_KEY, algorithms=["HS256"])
tenant_id = payload.get("tenant_id")
if not tenant_id:
return CURRENT_TENANT_ID_CONTEXTVAR.get()
if not is_valid_schema_name(tenant_id):
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)

View File

@@ -1,5 +1,6 @@
from sqlalchemy.orm import Session
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.constants import KV_REINDEX_KEY
from danswer.db.connector_credential_pair import get_connector_credential_pairs
from danswer.db.connector_credential_pair import resync_cc_pair
@@ -14,7 +15,6 @@ from danswer.db.search_settings import get_secondary_search_settings
from danswer.db.search_settings import update_search_settings_status
from danswer.key_value_store.factory import get_kv_store
from danswer.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()

View File

@@ -1,9 +1,9 @@
from sqlalchemy.orm import Session
from danswer.configs.app_configs import MULTI_TENANT
from danswer.db.search_settings import get_current_search_settings
from danswer.document_index.interfaces import DocumentIndex
from danswer.document_index.vespa.index import VespaIndex
from shared_configs.configs import MULTI_TENANT
def get_default_document_index(

View File

@@ -17,6 +17,7 @@ import httpx # type: ignore
import requests # type: ignore
from danswer.configs.app_configs import DOCUMENT_INDEX_NAME
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.chat_configs import DOC_TIME_DECAY
from danswer.configs.chat_configs import NUM_RETURNED_HITS
from danswer.configs.chat_configs import TITLE_CONTENT_RATIO
@@ -72,7 +73,6 @@ from danswer.search.models import IndexFilters
from danswer.search.models import InferenceChunkUncleaned
from danswer.utils.batching import batch_generator
from danswer.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
from shared_configs.model_server_models import Embedding

View File

@@ -57,6 +57,7 @@ def _does_document_exist(
chunk. This checks for whether the chunk exists already in the index"""
doc_url = f"{DOCUMENT_ID_ENDPOINT.format(index_name=index_name)}/{doc_chunk_id}"
doc_fetch_response = http_client.get(doc_url)
if doc_fetch_response.status_code == 404:
return False

View File

@@ -29,7 +29,6 @@ VESPA_APPLICATION_ENDPOINT = f"{VESPA_CONFIG_SERVER_URL}/application/v2"
# main search application
VESPA_APP_CONTAINER_URL = VESPA_CLOUD_URL or f"http://{VESPA_HOST}:{VESPA_PORT}"
# danswer_chunk below is defined in vespa/app_configs/schemas/danswer_chunk.sd
DOCUMENT_ID_ENDPOINT = (
f"{VESPA_APP_CONTAINER_URL}/document/v1/default/{{index_name}}/docid"

View File

@@ -8,6 +8,7 @@ from redis.client import Redis
from sqlalchemy import text
from sqlalchemy.orm import Session
from danswer.configs.app_configs import MULTI_TENANT
from danswer.db.engine import get_sqlalchemy_engine
from danswer.db.engine import is_valid_schema_name
from danswer.db.models import KVStore
@@ -17,7 +18,6 @@ from danswer.key_value_store.interface import KvKeyNotFoundError
from danswer.redis.redis_pool import get_redis_client
from danswer.utils.logger import setup_logger
from shared_configs.configs import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
logger = setup_logger()

View File

@@ -32,6 +32,7 @@ from danswer.configs.app_configs import APP_PORT
from danswer.configs.app_configs import AUTH_TYPE
from danswer.configs.app_configs import DISABLE_GENERATIVE_AI
from danswer.configs.app_configs import LOG_ENDPOINT_LATENCY
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.app_configs import OAUTH_CLIENT_ID
from danswer.configs.app_configs import OAUTH_CLIENT_SECRET
from danswer.configs.app_configs import POSTGRES_API_SERVER_POOL_OVERFLOW
@@ -93,7 +94,6 @@ from danswer.utils.variable_functionality import fetch_versioned_implementation
from danswer.utils.variable_functionality import global_version
from danswer.utils.variable_functionality import set_is_ee_based_on_env_variable
from shared_configs.configs import CORS_ALLOWED_ORIGIN
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import SENTRY_DSN
@@ -184,7 +184,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator:
# If we are multi-tenant, we need to only set up initial public tables
with Session(engine) as db_session:
setup_danswer(db_session, None)
setup_danswer(db_session)
else:
setup_multitenant_danswer()

View File

@@ -129,19 +129,7 @@ def stream_answer_objects(
persona = temporary_persona if temporary_persona else chat_session.persona
try:
llm, fast_llm = get_llms_for_persona(persona=persona)
except ValueError as e:
logger.error(
f"Failed to initialize LLMs for persona '{persona.name}': {str(e)}"
)
if "No LLM provider" in str(e):
raise ValueError(
"Please configure a Generative AI model to use this feature."
) from e
raise ValueError(
"Failed to initialize the AI model. Please check your configuration and try again."
) from e
llm, fast_llm = get_llms_for_persona(persona=persona)
llm_tokenizer = get_tokenizer(
model_name=llm.config.model_name,

View File

@@ -102,8 +102,6 @@ class TenantRedis(redis.Redis):
"reacquire",
"create_lock",
"startswith",
"sadd",
"srem",
] # Regular methods that need simple prefixing
if item == "scan_iter":

View File

@@ -1,5 +1,6 @@
from sqlalchemy.orm import Session
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.chat_configs import BASE_RECENCY_DECAY
from danswer.configs.chat_configs import CONTEXT_CHUNKS_ABOVE
from danswer.configs.chat_configs import CONTEXT_CHUNKS_BELOW
@@ -30,7 +31,6 @@ from danswer.utils.logger import setup_logger
from danswer.utils.threadpool_concurrency import FunctionCall
from danswer.utils.threadpool_concurrency import run_functions_in_parallel
from danswer.utils.timing import log_function_time
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()

View File

@@ -39,7 +39,6 @@ logger = setup_logger()
def _create_indexable_chunks(
preprocessed_docs: list[dict],
tenant_id: str | None,
) -> tuple[list[Document], list[DocMetadataAwareIndexChunk]]:
ids_to_documents = {}
chunks = []
@@ -81,7 +80,7 @@ def _create_indexable_chunks(
mini_chunk_embeddings=[],
),
title_embedding=preprocessed_doc["title_embedding"],
tenant_id=tenant_id,
tenant_id=None,
access=default_public_access,
document_sets=set(),
boost=DEFAULT_BOOST,
@@ -91,7 +90,7 @@ def _create_indexable_chunks(
return list(ids_to_documents.values()), chunks
def seed_initial_documents(db_session: Session, tenant_id: str | None) -> None:
def seed_initial_documents(db_session: Session) -> None:
"""
Seed initial documents so users don't have an empty index to start
@@ -178,7 +177,7 @@ def seed_initial_documents(db_session: Session, tenant_id: str | None) -> None:
)
processed_docs = json.load(open(initial_docs_path))
docs, chunks = _create_indexable_chunks(processed_docs, tenant_id)
docs, chunks = _create_indexable_chunks(processed_docs)
index_doc_batch_prepare(
document_batch=docs,
@@ -199,7 +198,6 @@ def seed_initial_documents(db_session: Session, tenant_id: str | None) -> None:
# Retries here because the index may take a few seconds to become ready
# as we just sent over the Vespa schema and there is a slight delay
index_with_retries = retry_builder()(document_index.index)
index_with_retries(chunks=chunks)

View File

@@ -30,7 +30,6 @@ from danswer.file_store.file_store import get_default_file_store
from danswer.file_store.models import ChatFileType
from danswer.llm.answering.prompts.utils import build_dummy_prompt
from danswer.server.features.persona.models import CreatePersonaRequest
from danswer.server.features.persona.models import ImageGenerationToolStatus
from danswer.server.features.persona.models import PersonaSharedNotificationData
from danswer.server.features.persona.models import PersonaSnapshot
from danswer.server.features.persona.models import PromptTemplateResponse
@@ -228,16 +227,6 @@ def delete_persona(
)
@basic_router.get("/image-generation-tool")
def get_image_generation_tool(
_: User
| None = Depends(current_user), # User param not used but kept for consistency
db_session: Session = Depends(get_session),
) -> ImageGenerationToolStatus: # Use bool instead of str for boolean values
is_available = is_image_generation_available(db_session=db_session)
return ImageGenerationToolStatus(is_available=is_available)
@basic_router.get("")
def list_personas(
user: User | None = Depends(current_user),

View File

@@ -124,7 +124,3 @@ class PromptTemplateResponse(BaseModel):
class PersonaSharedNotificationData(BaseModel):
persona_id: int
class ImageGenerationToolStatus(BaseModel):
is_available: bool

View File

@@ -57,7 +57,6 @@ class UserInfo(BaseModel):
oidc_expiry: datetime | None = None
current_token_created_at: datetime | None = None
current_token_expiry_length: int | None = None
organization_name: str | None = None
@classmethod
def from_model(
@@ -65,7 +64,6 @@ class UserInfo(BaseModel):
user: User,
current_token_created_at: datetime | None = None,
expiry_length: int | None = None,
organization_name: str | None = None,
) -> "UserInfo":
return cls(
id=str(user.id),
@@ -82,7 +80,6 @@ class UserInfo(BaseModel):
visible_assistants=user.visible_assistants,
)
),
organization_name=organization_name,
# set to None if TRACK_EXTERNAL_IDP_EXPIRY is False so that we avoid cases
# where they previously had this set + used OIDC, and now they switched to
# basic auth are now constantly getting redirected back to the login page

View File

@@ -30,10 +30,10 @@ from danswer.auth.schemas import UserStatus
from danswer.auth.users import current_admin_user
from danswer.auth.users import current_curator_or_admin_user
from danswer.auth.users import current_user
from danswer.auth.users import get_tenant_id_for_email
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 MULTI_TENANT
from danswer.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
from danswer.configs.app_configs import VALID_EMAIL_DOMAINS
from danswer.configs.constants import AuthType
@@ -66,7 +66,6 @@ from ee.danswer.db.user_group import remove_curator_status__no_commit
from ee.danswer.server.tenants.billing import register_tenant_users
from ee.danswer.server.tenants.provisioning import add_users_to_tenant
from ee.danswer.server.tenants.provisioning import remove_users_from_tenant
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
@@ -494,13 +493,10 @@ def verify_user_logged_in(
token_created_at = (
None if MULTI_TENANT else get_current_token_creation(user, db_session)
)
organization_name = get_tenant_id_for_email(user.email)
user_info = UserInfo.from_model(
user,
current_token_created_at=token_created_at,
expiry_length=SESSION_EXPIRE_TIME_SECONDS,
organization_name=organization_name,
)
return user_info

View File

@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
from danswer.chat.load_yamls import load_chat_yamls
from danswer.configs.app_configs import DISABLE_INDEX_UPDATE_ON_SWAP
from danswer.configs.app_configs import MANAGED_VESPA
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.constants import KV_REINDEX_KEY
from danswer.configs.constants import KV_SEARCH_SETTINGS
from danswer.configs.model_configs import FAST_GEN_AI_MODEL_VERSION
@@ -51,7 +52,6 @@ from danswer.utils.logger import setup_logger
from shared_configs.configs import ALT_INDEX_SUFFIX
from shared_configs.configs import MODEL_SERVER_HOST
from shared_configs.configs import MODEL_SERVER_PORT
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import SUPPORTED_EMBEDDING_MODELS
from shared_configs.model_server_models import SupportedEmbeddingModel
@@ -59,7 +59,7 @@ from shared_configs.model_server_models import SupportedEmbeddingModel
logger = setup_logger()
def setup_danswer(db_session: Session, tenant_id: str | None) -> None:
def setup_danswer(db_session: Session) -> None:
"""
Setup Danswer for a particular tenant. In the Single Tenant case, it will set it up for the default schema
on server startup. In the MT case, it will be called when the tenant is created.
@@ -148,7 +148,7 @@ def setup_danswer(db_session: Session, tenant_id: str | None) -> None:
# update multipass indexing setting based on GPU availability
update_default_multipass_indexing(db_session)
seed_initial_documents(db_session, tenant_id)
seed_initial_documents(db_session)
def translate_saved_search_settings(db_session: Session) -> None:

View File

@@ -4,14 +4,10 @@ from collections.abc import MutableMapping
from logging.handlers import RotatingFileHandler
from typing import Any
from shared_configs.configs import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.configs import DEV_LOGGING_ENABLED
from shared_configs.configs import LOG_FILE_NAME
from shared_configs.configs import LOG_LEVEL
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
from shared_configs.configs import SLACK_CHANNEL_ID
from shared_configs.configs import TENANT_ID_PREFIX
logging.addLevelName(logging.INFO + 5, "NOTICE")
@@ -70,18 +66,6 @@ class DanswerLoggingAdapter(logging.LoggerAdapter):
if cc_pair_id is not None:
msg = f"[CC Pair: {cc_pair_id}] {msg}"
# Add tenant information if it differs from default
# This will always be the case for authenticated API requests
if MULTI_TENANT:
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
if tenant_id != POSTGRES_DEFAULT_SCHEMA:
# Strip tenant_ prefix and take first 8 chars for cleaner logs
tenant_display = tenant_id.removeprefix(TENANT_ID_PREFIX)
short_tenant = (
tenant_display[:8] if len(tenant_display) > 8 else tenant_display
)
msg = f"[t:{short_tenant}] {msg}"
# For Slack Bot, logs the channel relevant to the request
channel_id = self.extra.get(SLACK_CHANNEL_ID) if self.extra else None
if channel_id:

View File

@@ -1,78 +1,39 @@
import re
import xml.etree.ElementTree as ET
from typing import Set
from urllib.parse import urljoin
from datetime import datetime
from urllib import robotparser
import requests
from usp.tree import sitemap_tree_for_homepage # type: ignore
from danswer.utils.logger import setup_logger
logger = setup_logger()
def _get_sitemap_locations_from_robots(base_url: str) -> Set[str]:
"""Extract sitemap URLs from robots.txt"""
sitemap_urls: set = set()
try:
robots_url = urljoin(base_url, "/robots.txt")
resp = requests.get(robots_url, timeout=10)
if resp.status_code == 200:
for line in resp.text.splitlines():
if line.lower().startswith("sitemap:"):
sitemap_url = line.split(":", 1)[1].strip()
sitemap_urls.add(sitemap_url)
except Exception as e:
logger.warning(f"Error fetching robots.txt: {e}")
return sitemap_urls
def test_url(rp: robotparser.RobotFileParser | None, url: str) -> bool:
if not rp:
return True
else:
return rp.can_fetch("*", url)
def _extract_urls_from_sitemap(sitemap_url: str) -> Set[str]:
"""Extract URLs from a sitemap XML file"""
urls: set[str] = set()
try:
resp = requests.get(sitemap_url, timeout=10)
if resp.status_code != 200:
return urls
root = ET.fromstring(resp.content)
# Handle both regular sitemaps and sitemap indexes
# Remove namespace for easier parsing
namespace = re.match(r"\{.*\}", root.tag)
ns = namespace.group(0) if namespace else ""
if root.tag == f"{ns}sitemapindex":
# This is a sitemap index
for sitemap in root.findall(f".//{ns}loc"):
if sitemap.text:
sub_urls = _extract_urls_from_sitemap(sitemap.text)
urls.update(sub_urls)
else:
# This is a regular sitemap
for url in root.findall(f".//{ns}loc"):
if url.text:
urls.add(url.text)
except Exception as e:
logger.warning(f"Error processing sitemap {sitemap_url}: {e}")
return urls
def init_robots_txt(site: str) -> robotparser.RobotFileParser:
ts = datetime.now().timestamp()
robots_url = f"{site}/robots.txt?ts={ts}"
rp = robotparser.RobotFileParser()
rp.set_url(robots_url)
rp.read()
return rp
def list_pages_for_site(site: str) -> list[str]:
"""Get list of pages from a site's sitemaps"""
site = site.rstrip("/")
all_urls = set()
rp: robotparser.RobotFileParser | None = None
try:
rp = init_robots_txt(site)
except Exception:
logger.warning("Failed to load robots.txt")
# Try both common sitemap locations
sitemap_paths = ["/sitemap.xml", "/sitemap_index.xml"]
for path in sitemap_paths:
sitemap_url = urljoin(site, path)
all_urls.update(_extract_urls_from_sitemap(sitemap_url))
tree = sitemap_tree_for_homepage(site)
# Check robots.txt for additional sitemaps
sitemap_locations = _get_sitemap_locations_from_robots(site)
for sitemap_url in sitemap_locations:
all_urls.update(_extract_urls_from_sitemap(sitemap_url))
pages = [page.url for page in tree.all_pages() if test_url(rp, page.url)]
pages = list(dict.fromkeys(pages))
return list(all_urls)
return pages

View File

@@ -1,6 +1,7 @@
from danswer.background.celery.apps.primary import celery_app
from danswer.background.task_utils import build_celery_task_wrapper
from danswer.configs.app_configs import JOB_TIMEOUT
from danswer.configs.app_configs import MULTI_TENANT
from danswer.db.chat import delete_chat_sessions_older_than
from danswer.db.engine import get_session_with_tenant
from danswer.server.settings.store import load_settings
@@ -29,7 +30,6 @@ from ee.danswer.external_permissions.permission_sync import (
)
from ee.danswer.server.reporting.usage_export_generation import create_new_usage_report
from shared_configs.configs import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()

View File

@@ -5,6 +5,7 @@ from danswer.auth.users import auth_backend
from danswer.auth.users import create_danswer_oauth_router
from danswer.auth.users import fastapi_users
from danswer.configs.app_configs import AUTH_TYPE
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.app_configs import OAUTH_CLIENT_ID
from danswer.configs.app_configs import OAUTH_CLIENT_SECRET
from danswer.configs.app_configs import USER_AUTH_SECRET
@@ -42,7 +43,6 @@ from ee.danswer.server.token_rate_limits.api import (
)
from ee.danswer.server.user_group.api import router as user_group_router
from ee.danswer.utils.encryption import test_encryption
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()

View File

@@ -8,10 +8,10 @@ from fastapi import HTTPException
from fastapi import Request
from fastapi import Response
from danswer.configs.app_configs import USER_AUTH_SECRET
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.app_configs import SECRET_JWT_KEY
from danswer.db.engine import is_valid_schema_name
from shared_configs.configs import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
@@ -21,18 +21,15 @@ def add_tenant_id_middleware(app: FastAPI, logger: logging.LoggerAdapter) -> Non
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
try:
logger.info(f"Request route: {request.url.path}")
if not MULTI_TENANT:
tenant_id = POSTGRES_DEFAULT_SCHEMA
else:
token = request.cookies.get("fastapiusersauth")
token = request.cookies.get("tenant_details")
if token:
try:
payload = jwt.decode(
token,
USER_AUTH_SECRET,
audience=["fastapi-users:auth"],
algorithms=["HS256"],
token, SECRET_JWT_KEY, algorithms=["HS256"]
)
tenant_id = payload.get("tenant_id", POSTGRES_DEFAULT_SCHEMA)
if not is_valid_schema_name(tenant_id):
@@ -52,6 +49,8 @@ def add_tenant_id_middleware(app: FastAPI, logger: logging.LoggerAdapter) -> Non
tenant_id = POSTGRES_DEFAULT_SCHEMA
CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
logger.info(f"Middleware set current_tenant_id to: {tenant_id}")
response = await call_next(request)
return response

View File

@@ -5,6 +5,7 @@ from fastapi import HTTPException
from danswer.auth.users import current_admin_user
from danswer.auth.users import User
from danswer.configs.app_configs import MULTI_TENANT
from danswer.configs.app_configs import WEB_DOMAIN
from danswer.db.engine import get_session_with_tenant
from danswer.db.notification import create_notification
@@ -24,7 +25,6 @@ from ee.danswer.server.tenants.provisioning import ensure_schema_exists
from ee.danswer.server.tenants.provisioning import run_alembic_migrations
from ee.danswer.server.tenants.provisioning import user_owns_a_tenant
from shared_configs.configs import CURRENT_TENANT_ID_CONTEXTVAR
from shared_configs.configs import MULTI_TENANT
stripe.api_key = STRIPE_SECRET_KEY
@@ -59,7 +59,7 @@ def create_tenant(
run_alembic_migrations(tenant_id)
with get_session_with_tenant(tenant_id) as db_session:
setup_danswer(db_session, tenant_id)
setup_danswer(db_session)
add_users_to_tenant([email], tenant_id)

View File

@@ -61,6 +61,7 @@ requests==2.32.2
requests-oauthlib==1.3.1
retry==0.9.2 # This pulls in py which is in CVE-2022-42969, must remove py from image
rfc3986==1.5.0
rt==3.1.2
simple-salesforce==1.12.6
slack-sdk==3.20.2
SQLAlchemy[mypy]==2.0.15
@@ -78,6 +79,7 @@ asana==5.0.8
zenpy==2.0.41
dropbox==11.36.2
boto3-stubs[s3]==1.34.133
ultimate_sitemap_parser==0.5
stripe==10.12.0
urllib3==2.2.3
mistune==0.8.4

View File

@@ -1,10 +1,8 @@
# This file is purely for development use, not included in any builds
import os
import sys
from time import sleep
import requests
from requests.exceptions import RequestException
# makes it so `PYTHONPATH=.` is not required when running this script
parent_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -17,58 +15,22 @@ from danswer.utils.logger import setup_logger # noqa: E402
logger = setup_logger()
def wipe_vespa_index() -> bool:
"""
Wipes the Vespa index by deleting all documents.
"""
def wipe_vespa_index() -> None:
continuation = None
should_continue = True
RETRIES = 3
while should_continue:
params = {"selection": "true", "cluster": DOCUMENT_INDEX_NAME}
if continuation:
params["continuation"] = continuation
params = {**params, "continuation": continuation}
response = requests.delete(DOCUMENT_ID_ENDPOINT, params=params)
response.raise_for_status()
for attempt in range(RETRIES):
try:
response = requests.delete(DOCUMENT_ID_ENDPOINT, params=params)
response.raise_for_status()
response_json = response.json()
print(response_json)
response_json = response.json()
logger.info(f"Response: {response_json}")
continuation = response_json.get("continuation")
should_continue = bool(continuation)
break # Exit the retry loop if the request is successful
except RequestException:
logger.exception("Request failed")
sleep(2**attempt) # Exponential backoff
else:
logger.error(f"Max retries ({RETRIES}) exceeded. Exiting.")
return False
return True
def main() -> int:
"""
Main function to execute the script.
"""
try:
succeeded = wipe_vespa_index()
except Exception:
logger.exception("wipe_vespa_index exceptioned.")
return 1
if not succeeded:
logger.info("Vespa index wipe failed.")
return 0
logger.info("Vespa index wiped successfully.")
return 1
continuation = response_json.get("continuation")
should_continue = bool(continuation)
if __name__ == "__main__":
sys.exit(main())
wipe_vespa_index()

View File

@@ -129,17 +129,12 @@ else:
CORS_ALLOWED_ORIGIN = ["*"]
# Multi-tenancy configuration
MULTI_TENANT = os.environ.get("MULTI_TENANT", "").lower() == "true"
POSTGRES_DEFAULT_SCHEMA = os.environ.get("POSTGRES_DEFAULT_SCHEMA") or "public"
CURRENT_TENANT_ID_CONTEXTVAR = contextvars.ContextVar(
"current_tenant_id", default=POSTGRES_DEFAULT_SCHEMA
)
# Prefix used for all tenant ids
TENANT_ID_PREFIX = "tenant_"
SUPPORTED_EMBEDDING_MODELS = [
# Cloud-based models

View File

@@ -70,14 +70,18 @@ class UserManager:
cookies = response.cookies.get_dict()
session_cookie = cookies.get("fastapiusersauth")
tenant_details_cookie = cookies.get("tenant_details")
if not session_cookie:
raise Exception("Failed to login")
print(f"Logged in as {test_user.email}")
# Set cookies in the headers
test_user.headers["Cookie"] = f"fastapiusersauth={session_cookie}; "
# Set both cookies in the headers
test_user.headers["Cookie"] = (
f"fastapiusersauth={session_cookie}; "
f"tenant_details={tenant_details_cookie}"
)
return test_user
@staticmethod

View File

@@ -110,7 +110,7 @@ def test_web_pruning(reset: None, vespa_client: vespa_fixture) -> None:
test_filename = os.path.realpath(__file__)
test_directory = os.path.dirname(test_filename)
with tempfile.TemporaryDirectory() as temp_dir:
port = 8889
port = 8888
website_src = os.path.join(test_directory, "website")
website_tgt = os.path.join(temp_dir, "website")

View File

@@ -9,15 +9,12 @@ from pytest_mock import MockFixture
from danswer.connectors.mediawiki import wiki
# These tests are disabled for now
@pytest.fixture
def site() -> pywikibot.Site:
return pywikibot.Site("en", "wikipedia")
@pytest.mark.skip(reason="Test disabled")
def test_pywikibot_timestamp_to_utc_datetime() -> None:
timestamp_without_tzinfo = pywikibot.Timestamp(2023, 12, 27, 15, 38, 49)
timestamp_min_timezone = timestamp_without_tzinfo.astimezone(datetime.timezone.min)
@@ -83,7 +80,6 @@ class MockPage(pywikibot.Page):
)
@pytest.mark.skip(reason="Test disabled")
def test_get_doc_from_page(site: pywikibot.Site) -> None:
test_page = MockPage(site, "Test Page", _has_categories=True)
doc = wiki.get_doc_from_page(test_page, site, wiki.DocumentSource.MEDIAWIKI)
@@ -107,7 +103,6 @@ def test_get_doc_from_page(site: pywikibot.Site) -> None:
assert doc.id == f"MEDIAWIKI_{test_page.pageid}_{test_page.full_url()}"
@pytest.mark.skip(reason="Test disabled")
def test_mediawiki_connector_recurse_depth() -> None:
"""Test that the recurse_depth parameter is parsed correctly.
@@ -137,7 +132,6 @@ def test_mediawiki_connector_recurse_depth() -> None:
assert connector.recurse_depth == recurse_depth
@pytest.mark.skip(reason="Test disabled")
def test_load_from_state_calls_poll_source_with_nones(mocker: MockFixture) -> None:
connector = wiki.MediaWikiConnector("wikipedia.org", [], [], 0, "test")
poll_source = mocker.patch.object(connector, "poll_source")

View File

@@ -1,4 +1,4 @@
FROM node:20-alpine AS base
FROM --platform=linux/amd64 node:20-alpine AS base
LABEL com.danswer.maintainer="founders@danswer.ai"
LABEL com.danswer.description="This image is the web/frontend container of Danswer which \
@@ -66,10 +66,7 @@ ARG NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
ENV NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
ARG NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
RUN npx next build
RUN npx next build --no-lint
# Step 2. Production image, copy all the files and run next
FROM base AS runner
@@ -134,8 +131,6 @@ ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
ENV NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
ARG NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_DSN=${NEXT_PUBLIC_SENTRY_DSN}
# Note: Don't expose ports here, Compose will handle that for us if necessary.

View File

@@ -21,9 +21,10 @@ const nextConfig = {
// - With both configured: Only unhandled errors are captured (no performance/session tracking)
// Determine if Sentry should be enabled
const sentryEnabled = Boolean(
process.env.SENTRY_AUTH_TOKEN && process.env.NEXT_PUBLIC_SENTRY_DSN
);
const sentryEnabled = false;
// Boolean(
// process.env.SENTRY_AUTH_TOKEN && process.env.NEXT_PUBLIC_SENTRY_DSN
// );
// Sentry webpack plugin options
const sentryWebpackPluginOptions = {

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -45,7 +45,12 @@ import { FullLLMProvider } from "../configuration/llm/interfaces";
import CollapsibleSection from "./CollapsibleSection";
import { SuccessfulPersonaUpdateRedirectType } from "./enums";
import { Persona, StarterMessage } from "./interfaces";
import { buildFinalPrompt, createPersona, updatePersona } from "./lib";
import {
buildFinalPrompt,
createPersona,
providersContainImageGeneratingSupport,
updatePersona,
} from "./lib";
import { Popover } from "@/components/popover/Popover";
import {
CameraIcon,
@@ -101,7 +106,7 @@ export function AssistantEditor({
shouldAddAssistantToUserPreferences?: boolean;
admin?: boolean;
}) {
const { refreshAssistants, isImageGenerationAvailable } = useAssistants();
const { refreshAssistants } = useAssistants();
const router = useRouter();
const { popup, setPopup } = usePopup();
@@ -133,13 +138,42 @@ export function AssistantEditor({
const [isIconDropdownOpen, setIsIconDropdownOpen] = useState(false);
const [finalPrompt, setFinalPrompt] = useState<string | null>("");
const [finalPromptError, setFinalPromptError] = useState<string>("");
const [removePersonaImage, setRemovePersonaImage] = useState(false);
const triggerFinalPromptUpdate = async (
systemPrompt: string,
taskPrompt: string,
retrievalDisabled: boolean
) => {
const response = await buildFinalPrompt(
systemPrompt,
taskPrompt,
retrievalDisabled
);
if (response.ok) {
setFinalPrompt((await response.json()).final_prompt_template);
}
};
const isUpdate = existingPersona !== undefined && existingPersona !== null;
const existingPrompt = existingPersona?.prompts[0] ?? null;
useEffect(() => {
if (isUpdate && existingPrompt) {
triggerFinalPromptUpdate(
existingPrompt.system_prompt,
existingPrompt.task_prompt,
existingPersona.num_chunks === 0
);
}
}, [isUpdate, existingPrompt, existingPersona?.num_chunks]);
const defaultProvider = llmProviders.find(
(llmProvider) => llmProvider.is_default_provider
);
const defaultProviderName = defaultProvider?.provider;
const defaultModelName = defaultProvider?.default_model_name;
const providerDisplayNameToProviderName = new Map<string, string>();
llmProviders.forEach((llmProvider) => {
@@ -280,6 +314,14 @@ export function AssistantEditor({
}
)}
onSubmit={async (values, formikHelpers) => {
if (finalPromptError) {
setPopup({
type: "error",
message: "Cannot submit while there are errors in the form",
});
return;
}
if (
values.llm_model_provider_override &&
!values.llm_model_version_override
@@ -600,7 +642,13 @@ export function AssistantEditor({
placeholder="e.g. 'You are a professional email writing assistant that always uses a polite enthusiastic tone, emphasizes action items, and leaves blanks for the human to fill in when you have unknowns'"
onChange={(e) => {
setFieldValue("system_prompt", e.target.value);
triggerFinalPromptUpdate(
e.target.value,
values.task_prompt,
searchToolEnabled()
);
}}
error={finalPromptError}
/>
<div>
@@ -727,8 +775,7 @@ export function AssistantEditor({
<TooltipTrigger asChild>
<div
className={`w-fit ${
!currentLLMSupportsImageOutput ||
!isImageGenerationAvailable
!currentLLMSupportsImageOutput
? "opacity-70 cursor-not-allowed"
: ""
}`}
@@ -740,14 +787,11 @@ export function AssistantEditor({
onChange={() => {
toggleToolInValues(imageGenerationTool.id);
}}
disabled={
!currentLLMSupportsImageOutput ||
!isImageGenerationAvailable
}
disabled={!currentLLMSupportsImageOutput}
/>
</div>
</TooltipTrigger>
{!currentLLMSupportsImageOutput ? (
{!currentLLMSupportsImageOutput && (
<TooltipContent side="top" align="center">
<p className="bg-background-900 max-w-[200px] mb-1 text-sm rounded-lg p-1.5 text-white">
To use Image Generation, select GPT-4o or another
@@ -755,15 +799,6 @@ export function AssistantEditor({
this Assistant.
</p>
</TooltipContent>
) : (
!isImageGenerationAvailable && (
<TooltipContent side="top" align="center">
<p className="bg-background-900 max-w-[200px] mb-1 text-sm rounded-lg p-1.5 text-white">
Image Generation requires an OpenAI or Azure
Dalle configuration.
</p>
</TooltipContent>
)
)}
</Tooltip>
</TooltipProvider>
@@ -989,6 +1024,11 @@ export function AssistantEditor({
placeholder="e.g. 'Remember to reference all of the points mentioned in my message to you and focus on identifying action items that can move things forward'"
onChange={(e) => {
setFieldValue("task_prompt", e.target.value);
triggerFinalPromptUpdate(
values.system_prompt,
e.target.value,
searchToolEnabled()
);
}}
explanationText="Learn about prompting in our docs!"
explanationLink="https://docs.danswer.dev/guides/assistants"
@@ -1002,10 +1042,6 @@ export function AssistantEditor({
Starter Messages (Optional){" "}
</div>
</div>
<SubLabel>
Add pre-defined messages to help users get started. Only
the first 4 will be displayed.
</SubLabel>
<FieldArray
name="starter_messages"
render={(

View File

@@ -8,47 +8,12 @@ import { credentialTemplates } from "@/lib/connectors/credentials";
import Link from "next/link";
import { useUser } from "@/components/user/UserProvider";
import { useContext } from "react";
import { User } from "@/lib/types";
function BackButton({
isAdmin,
isCurator,
user,
}: {
isAdmin: boolean;
isCurator: boolean;
user: User | null;
}) {
const buttonText = isAdmin ? "Admin Page" : "Curator Page";
if (!isAdmin && !isCurator) {
console.error(
`User is neither admin nor curator, defaulting to curator view. Found user:\n ${JSON.stringify(
user,
null,
2
)}`
);
}
return (
<div className="mx-3 mt-6 flex-col flex items-center">
<Link
href={"/admin/add-connector"}
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"
>
<SettingsIcon className="flex-none " />
<p className="my-auto flex items-center text-sm">{buttonText}</p>
</Link>
</div>
);
}
export default function Sidebar() {
const { formStep, setFormStep, connector, allowAdvanced, allowCreate } =
useFormContext();
const combinedSettings = useContext(SettingsContext);
const { isCurator, isAdmin, user } = useUser();
const { isLoadingUser, isAdmin } = useUser();
if (!combinedSettings) {
return null;
}
@@ -90,7 +55,17 @@ export default function Sidebar() {
</div>
</div>
<BackButton isAdmin={isAdmin} isCurator={isCurator} user={user} />
<div className="mx-3 mt-6 gap-y-1 flex-col flex gap-x-1.5 items-center items-center">
<Link
href={"/admin/add-connector"}
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"
>
<SettingsIcon className="flex-none " />
<p className="my-auto flex items-center text-sm">
{isAdmin ? "Admin Page" : "Curator Page"}
</p>
</Link>
</div>
<div className="h-full flex">
<div className="mx-auto w-full max-w-2xl px-4 py-8">

View File

@@ -24,8 +24,8 @@ export default async function GalleryPage({
chatSessions,
folders,
openedFolders,
toggleSidebar,
shouldShowWelcomeModal,
toggleSidebar,
} = data;
return (

View File

@@ -1,10 +1,10 @@
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
import { fetchChatData } from "@/lib/chat/fetchChatData";
import { unstable_noStore as noStore } from "next/cache";
import { redirect } from "next/navigation";
import WrappedAssistantsMine from "./WrappedAssistantsMine";
import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
import { AssistantsProvider } from "@/components/context/AssistantsContext";
export default async function GalleryPage({
searchParams,
@@ -24,8 +24,8 @@ export default async function GalleryPage({
chatSessions,
folders,
openedFolders,
toggleSidebar,
shouldShowWelcomeModal,
toggleSidebar,
} = data;
return (

View File

@@ -14,7 +14,7 @@ export const POST = async (request: NextRequest) => {
// Delete cookies only if cloud is enabled (jwt auth)
if (NEXT_PUBLIC_CLOUD_ENABLED) {
const cookiesToDelete = ["fastapiusersauth"];
const cookiesToDelete = ["fastapiusersauth", "tenant_details"];
const cookieOptions = {
path: "/",
secure: process.env.NODE_ENV === "production",

View File

@@ -1,44 +1,96 @@
import { getSourceMetadataForSources } from "@/lib/sources";
import { ValidSources } from "@/lib/types";
import { Persona } from "../admin/assistants/interfaces";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { useState } from "react";
import { DisplayAssistantCard } from "@/components/assistants/AssistantCards";
import { Divider } from "@tremor/react";
import { FiBookmark, FiInfo } from "react-icons/fi";
import { HoverPopup } from "@/components/HoverPopup";
export function ChatIntro({ selectedPersona }: { selectedPersona: Persona }) {
const [hoveredAssistant, setHoveredAssistant] = useState(false);
export function ChatIntro({
availableSources,
selectedPersona,
}: {
availableSources: ValidSources[];
selectedPersona: Persona;
}) {
const availableSourceMetadata = getSourceMetadataForSources(availableSources);
return (
<>
<div className="mobile:w-[90%] mobile:px-4 w-message-xs 2xl:w-message-sm 3xl:w-message">
<div className="relative flex w-fit mx-auto justify-center">
<div className="absolute z-10 -left-20 top-1/2 -translate-y-1/2">
<div className="relative">
<div
onMouseEnter={() => setHoveredAssistant(true)}
onMouseLeave={() => setHoveredAssistant(false)}
className="p-4 scale-[.8] cursor-pointer border-dashed rounded-full flex border border-border border-2 border-dashed"
style={{
borderStyle: "dashed",
borderWidth: "1.5px",
borderSpacing: "4px",
}}
>
<AssistantIcon
disableToolip
size={"large"}
assistant={selectedPersona}
/>
</div>
<div className="absolute right-full mr-2 w-[300px] top-0">
{hoveredAssistant && (
<DisplayAssistantCard selectedPersona={selectedPersona} />
)}
<div className="flex justify-center items-center h-full">
<div className="mobile:w-[90%] mobile:px-4 w-message-xs 2xl:w-message-sm 3xl:w-message">
<div className="flex">
<div className="mx-auto">
<div className="m-auto text-3xl font-strong font-bold text-strong w-fit">
{selectedPersona?.name || "How can I help you today?"}
</div>
{selectedPersona && (
<div className="mt-1">{selectedPersona.description}</div>
)}
</div>
</div>
<div className="text-3xl line-clamp-2 text-text-800 font-base font-semibold text-strong">
{selectedPersona?.name || "How can I help you today?"}
</div>
{selectedPersona && selectedPersona.num_chunks !== 0 && (
<>
<Divider />
<div>
{selectedPersona.document_sets.length > 0 && (
<div className="mt-2">
<p className="font-bold mb-1 mt-4 text-emphasis">
Knowledge Sets:{" "}
</p>
<div className="flex flex-wrap gap-2">
{selectedPersona.document_sets.map((documentSet) => (
<div key={documentSet.id} className="w-fit">
<HoverPopup
mainContent={
<span className="flex w-fit p-1 rounded border border-border text-xs font-medium cursor-default">
<div className="mr-1 my-auto">
<FiBookmark />
</div>
{documentSet.name}
</span>
}
popupContent={
<div className="flex py-1 w-96">
<FiInfo className="my-auto mr-2" />
<div className="text-sm">
{documentSet.description}
</div>
</div>
}
direction="top"
/>
</div>
))}
</div>
</div>
)}
{availableSources.length > 0 && (
<div className="mt-1">
<p className="font-bold mb-1 mt-4 text-emphasis">
Connected Sources:{" "}
</p>
<div className={`flex flex-wrap gap-2`}>
{availableSourceMetadata.map((sourceMetadata) => (
<span
key={sourceMetadata.internalName}
className="flex w-fit p-1 rounded border border-border text-xs font-medium cursor-default"
>
<div className="mr-1 my-auto">
{sourceMetadata.icon({})}
</div>
<div className="my-auto">
{sourceMetadata.displayName}
</div>
</span>
))}
</div>
</div>
)}
</div>
</>
)}
</div>
</div>
</>

View File

@@ -103,7 +103,6 @@ import { ApiKeyModal } from "@/components/llm/ApiKeyModal";
import BlurBackground from "./shared_chat_search/BlurBackground";
import { NoAssistantModal } from "@/components/modals/NoAssistantModal";
import { useAssistants } from "@/components/context/AssistantsContext";
import { Divider } from "@tremor/react";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@@ -136,40 +135,12 @@ export function ChatPage({
const { assistants: availableAssistants, finalAssistants } = useAssistants();
const [showApiKeyModal, setShowApiKeyModal] = useState(
!shouldShowWelcomeModal
);
const [showApiKeyModal, setShowApiKeyModal] = useState(true);
const { user, isAdmin, isLoadingUser } = useUser();
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
);
// Effect to handle sendOnLoad
useEffect(() => {
if (sendOnLoad) {
const newSearchParams = new URLSearchParams(searchParams.toString());
newSearchParams.delete(SEARCH_PARAM_NAMES.SEND_ON_LOAD);
// Update the URL without the send-on-load parameter
router.replace(`?${newSearchParams.toString()}`, { scroll: false });
// Update our local state to reflect the change
setSendOnLoad(null);
// If there's a message, submit it
if (message) {
onSubmit({ messageOverride: message });
}
}
}, [sendOnLoad, searchParams, router]);
const existingChatSessionId = existingChatIdRaw ? existingChatIdRaw : null;
@@ -225,7 +196,7 @@ export function ChatPage({
};
const llmOverrideManager = useLlmOverride(
modelVersionFromSearchParams || (user?.preferences.default_model ?? null),
user?.preferences.default_model ?? null,
selectedChatSession,
defaultTemperature
);
@@ -742,6 +713,12 @@ export function ChatPage({
}, [liveAssistant]);
const filterManager = useFilters();
const [finalAvailableSources, finalAvailableDocumentSets] =
computeAvailableFilters({
selectedPersona: selectedAssistant,
availableSources,
availableDocumentSets,
});
const [currentFeedback, setCurrentFeedback] = useState<
[FeedbackType, number] | null
@@ -1876,9 +1853,6 @@ export function ChatPage({
{sharedChatSession && (
<ShareChatSessionModal
assistantId={liveAssistant?.id}
message={message}
modelOverride={llmOverrideManager.llmOverride}
chatSessionId={sharedChatSession.id}
existingSharedStatus={sharedChatSession.shared_status}
onClose={() => setSharedChatSession(null)}
@@ -1893,9 +1867,6 @@ export function ChatPage({
)}
{sharingModalVisible && chatSessionIdRef.current !== null && (
<ShareChatSessionModal
message={message}
assistantId={liveAssistant?.id}
modelOverride={llmOverrideManager.llmOverride}
chatSessionId={chatSessionIdRef.current}
existingSharedStatus={chatSessionSharedStatus}
onClose={() => setSharingModalVisible(false)}
@@ -1997,7 +1968,7 @@ export function ChatPage({
{...getRootProps()}
>
<div
className={`w-full h-full flex flex-col overflow-y-auto include-scrollbar overflow-x-hidden relative`}
className={`w-full h-full flex flex-col overflow-y-auto include-scrollbar overflow-x-hidden relative`}
ref={scrollableDivRef}
>
{/* ChatBanner is a custom banner that displays a admin-specified message at
@@ -2007,51 +1978,11 @@ export function ChatPage({
!isFetchingChatMessages &&
currentSessionChatState == "input" &&
!loadingError && (
<div className="h-full flex flex-col justify-center items-center">
<ChatIntro selectedPersona={liveAssistant} />
<div
key={-4}
className={`
mx-auto
px-4
w-full
max-w-[750px]
flex
flex-wrap
justify-center
mt-2
h-40
items-start
mb-6`}
>
{currentPersona?.starter_messages &&
currentPersona.starter_messages.length >
0 && (
<>
<Divider className="mx-2" />
{currentPersona.starter_messages
.slice(0, 4)
.map((starterMessage, i) => (
<div key={i} className="w-1/2">
<StarterMessage
starterMessage={starterMessage}
onClick={() =>
onSubmit({
messageOverride:
starterMessage.message,
})
}
/>
</div>
))}
</>
)}
</div>
</div>
<ChatIntro
availableSources={finalAvailableSources}
selectedPersona={liveAssistant}
/>
)}
<div
className={
"-ml-4 w-full mx-auto " +
@@ -2418,6 +2349,45 @@ export function ChatPage({
/>
</div>
)}
{currentPersona &&
currentPersona.starter_messages &&
currentPersona.starter_messages.length > 0 &&
selectedAssistant &&
messageHistory.length === 0 &&
!isFetchingChatMessages && (
<div
key={-4}
className={`
mx-auto
px-4
w-searchbar-xs
2xl:w-searchbar-sm
3xl:w-searchbar
grid
gap-4
grid-cols-1
grid-rows-1
mt-4
md:grid-cols-2
mb-6`}
>
{currentPersona.starter_messages.map(
(starterMessage, i) => (
<div key={i} className="w-full">
<StarterMessage
starterMessage={starterMessage}
onClick={() =>
onSubmit({
messageOverride:
starterMessage.message,
})
}
/>
</div>
)
)}
</div>
)}
{/* 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]" />

View File

@@ -1,29 +1,21 @@
import { StarterMessage as StarterMessageType } from "../admin/assistants/interfaces";
import { StarterMessage } from "../admin/assistants/interfaces";
export function StarterMessage({
starterMessage,
onClick,
}: {
starterMessage: StarterMessageType;
starterMessage: StarterMessage;
onClick: () => void;
}) {
return (
<div
className="mb-4 mx-2 group relative overflow-hidden rounded-xl border border-border bg-gradient-to-br from-white to-background p-4 shadow-sm transition-all duration-300 hover:shadow-md hover:scale-[1.005] cursor-pointer"
className={
"py-2 px-3 rounded border border-border bg-white cursor-pointer hover:bg-hover-light h-full"
}
onClick={onClick}
>
<div className="absolute inset-0 bg-gradient-to-r from-blue-100 to-purple-100 opacity-0 group-hover:opacity-20 transition-opacity duration-300" />
<h3
className="text-base flex items-center font-medium text-text-800 group-hover:text-text-900 transition-colors duration-300
line-clamp-2 gap-x-2 overflow-hidden"
>
{starterMessage.name}
</h3>
<div className={`overflow-hidden transition-all duration-300 max-h-20}`}>
<p className="text-sm text-text-600 mt-2">
{starterMessage.description}
</p>
</div>
<p className="font-medium text-emphasis">{starterMessage.name}</p>
<p className="text-subtle text-sm">{starterMessage.description}</p>
</div>
);
}

View File

@@ -5,10 +5,6 @@ import { Spinner } from "@/components/Spinner";
import { ChatSessionSharedStatus } from "../interfaces";
import { FiCopy } from "react-icons/fi";
import { CopyButton } from "@/components/CopyButton";
import { SEARCH_PARAM_NAMES } from "../searchParams";
import { usePopup } from "@/components/admin/connectors/Popup";
import { destructureValue, structureValue } from "@/lib/llm/utils";
import { LlmOverride } from "@/lib/hooks";
function buildShareLink(chatSessionId: string) {
const baseUrl = `${window.location.protocol}//${window.location.host}`;
@@ -30,34 +26,6 @@ async function generateShareLink(chatSessionId: string) {
return null;
}
async function generateCloneLink(
message?: string,
assistantId?: number,
modelOverride?: LlmOverride
) {
const baseUrl = `${window.location.protocol}//${window.location.host}`;
const model = modelOverride
? structureValue(
modelOverride.name,
modelOverride.provider,
modelOverride.modelName
)
: null;
return `${baseUrl}/chat${
message
? `?${SEARCH_PARAM_NAMES.USER_PROMPT}=${encodeURIComponent(message)}`
: ""
}${
assistantId
? `${message ? "&" : "?"}${SEARCH_PARAM_NAMES.PERSONA_ID}=${assistantId}`
: ""
}${
model
? `${message || assistantId ? "&" : "?"}${SEARCH_PARAM_NAMES.STRUCTURED_MODEL}=${encodeURIComponent(model)}`
: ""
}${message ? `&${SEARCH_PARAM_NAMES.SEND_ON_LOAD}=true` : ""}`;
}
async function deleteShareLink(chatSessionId: string) {
const response = await fetch(`/api/chat/chat-session/${chatSessionId}`, {
method: "PATCH",
@@ -75,162 +43,117 @@ export function ShareChatSessionModal({
existingSharedStatus,
onShare,
onClose,
message,
assistantId,
modelOverride,
}: {
chatSessionId: string;
existingSharedStatus: ChatSessionSharedStatus;
onShare?: (shared: boolean) => void;
onClose: () => void;
message?: string;
assistantId?: number;
modelOverride?: LlmOverride;
}) {
const [linkGenerating, setLinkGenerating] = useState(false);
const [shareLink, setShareLink] = useState<string>(
existingSharedStatus === ChatSessionSharedStatus.Public
? buildShareLink(chatSessionId)
: ""
);
const { popup, setPopup } = usePopup();
return (
<>
{popup}
<Modal onOutsideClick={onClose} width="max-w-3xl">
<>
<div className="flex mb-4">
<h2 className="text-2xl text-emphasis font-bold flex my-auto">
Share link to Chat
</h2>
</div>
<Modal onOutsideClick={onClose} width="max-w-3xl">
<>
<div className="flex mb-4">
<h2 className="text-2xl text-emphasis font-bold flex my-auto">
Share link to Chat
</h2>
</div>
<div className="flex mt-2">
{shareLink ? (
<div>
<Text>
This chat session is currently shared. Anyone at your
organization can view the message history using the following
link:
</Text>
{linkGenerating && <Spinner />}
<div className="flex mt-2">
<CopyButton content={shareLink} />
<a
href={shareLink}
target="_blank"
className="underline text-link mt-1 ml-1 text-sm my-auto"
rel="noreferrer"
>
{shareLink}
</a>
</div>
<div className="flex mt-2">
{shareLink ? (
<div>
<Text>
This chat session is currently shared. Anyone at your
organization can view the message history using the following
link:
</Text>
<Divider />
<Text className="mb-4">
Click the button below to make the chat private again.
</Text>
<Button
onClick={async () => {
const success = await deleteShareLink(chatSessionId);
if (success) {
setShareLink("");
onShare && onShare(false);
} else {
alert("Failed to delete share link");
}
}}
size="xs"
color="red"
<div className="flex mt-2">
<CopyButton content={shareLink} />
<a
href={shareLink}
target="_blank"
className="underline text-link mt-1 ml-1 text-sm my-auto"
rel="noreferrer"
>
Delete Share Link
</Button>
{shareLink}
</a>
</div>
) : (
<div>
<Callout title="Warning" color="yellow" className="mb-4">
Ensure that all content in the chat is safe to share with the
whole organization. The content of the retrieved documents
will not be visible, but the names of cited documents as well
as the AI and human messages will be visible.
</Callout>
<div className="flex w-full justify-between">
<Button
icon={FiCopy}
onClick={async () => {
// NOTE: for "insecure" non-https setup, the `navigator.clipboard.writeText` may fail
// as the browser may not allow the clipboard to be accessed.
try {
const shareLink =
await generateShareLink(chatSessionId);
if (!shareLink) {
alert("Failed to generate share link");
} else {
setShareLink(shareLink);
onShare && onShare(true);
navigator.clipboard.writeText(shareLink);
}
} catch (e) {
console.error(e);
}
}}
size="xs"
color="green"
>
Generate and Copy Share Link
</Button>
</div>
</div>
)}
</div>
<Divider className="my-4" />
<div className="mb-4">
<Callout title="Clone Chat" color="blue">
Generate a link to clone this chat session with the current query.
This allows others to start a new chat with the same initial
message and settings.
</Callout>
</div>
<div className="flex w-full justify-between">
<Button
icon={FiCopy}
onClick={async () => {
// NOTE: for "insecure" non-https setup, the `navigator.clipboard.writeText` may fail
// as the browser may not allow the clipboard to be accessed.
try {
const cloneLink = await generateCloneLink(
message,
assistantId,
modelOverride
);
if (!cloneLink) {
setPopup({
message: "Failed to generate clone link",
type: "error",
});
<Divider />
<Text className="mb-4">
Click the button below to make the chat private again.
</Text>
<Button
onClick={async () => {
setLinkGenerating(true);
const success = await deleteShareLink(chatSessionId);
if (success) {
setShareLink("");
onShare && onShare(false);
} else {
navigator.clipboard.writeText(cloneLink);
setPopup({
message: "Link copied to clipboard!",
type: "success",
});
alert("Failed to delete share link");
}
} catch (e) {
console.error(e);
alert("Failed to generate or copy link.");
}
}}
size="xs"
color="blue"
>
Generate and Copy Clone Link
</Button>
</div>
</>
</Modal>
</>
setLinkGenerating(false);
}}
size="xs"
color="red"
>
Delete Share Link
</Button>
</div>
) : (
<div>
<Callout title="Warning" color="yellow" className="mb-4">
Ensure that all content in the chat is safe to share with the
whole organization. The content of the retrieved documents will
not be visible, but the names of cited documents as well as the
AI and human messages will be visible.
</Callout>
<Button
icon={FiCopy}
onClick={async () => {
setLinkGenerating(true);
// NOTE: for "inescure" non-https setup, the `navigator.clipboard.writeText` may fail
// as the browser may not allow the clipboard to be accessed.
try {
const shareLink = await generateShareLink(chatSessionId);
if (!shareLink) {
alert("Failed to generate share link");
} else {
setShareLink(shareLink);
onShare && onShare(true);
navigator.clipboard.writeText(shareLink);
}
} catch (e) {
console.error(e);
}
setLinkGenerating(false);
}}
size="xs"
color="green"
>
Generate and Copy Share Link
</Button>
</div>
)}
</div>
</>
</Modal>
);
}

View File

@@ -5,6 +5,7 @@ import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrap
import { ChatProvider } from "@/components/context/ChatContext";
import { fetchChatData } from "@/lib/chat/fetchChatData";
import WrappedChat from "./WrappedChat";
import { AssistantsProvider } from "@/components/context/AssistantsContext";
export default async function Page({
searchParams,

View File

@@ -9,7 +9,6 @@ export const SEARCH_PARAM_NAMES = {
TEMPERATURE: "temperature",
MODEL_VERSION: "model-version",
SYSTEM_PROMPT: "system-prompt",
STRUCTURED_MODEL: "structured-model",
// user message
USER_PROMPT: "user-prompt",
SUBMIT_ON_LOAD: "submit-on-load",
@@ -17,7 +16,6 @@ export const SEARCH_PARAM_NAMES = {
TITLE: "title",
// for seeding chats
SEEDED: "seeded",
SEND_ON_LOAD: "send-on-load",
};
export function shouldSubmitOnLoad(searchParams: ReadonlyURLSearchParams) {

View File

@@ -19,16 +19,15 @@ import { fetchAssistantData } from "@/lib/chat/fetchAssistantdata";
import { AppProvider } from "@/components/context/AppProvider";
import { PHProvider } from "./providers";
import { default as dynamicImport } from "next/dynamic";
import { getCurrentUserSS } from "@/lib/userSS";
const PostHogPageView = dynamicImport(() => import("./PostHogPageView"), {
ssr: false,
});
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
preload: false, // Add this to prevent build-time font loading issues
adjustFontFallback: true, // Add this to ensure smooth fallback
});
export async function generateMetadata(): Promise<Metadata> {
@@ -58,11 +57,7 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
const [combinedSettings, assistantsData, user] = await Promise.all([
fetchSettingsSS(),
fetchAssistantData(),
getCurrentUserSS(),
]);
const combinedSettings = await fetchSettingsSS();
const productGating =
combinedSettings?.settings.product_gating ?? GatingType.NONE;
@@ -170,12 +165,11 @@ export default async function RootLayout({
);
}
const { assistants, hasAnyConnectors, hasImageCompatibleModel } =
assistantsData;
const data = await fetchAssistantData();
const { assistants, hasAnyConnectors, hasImageCompatibleModel } = data;
return getPageContent(
<AppProvider
user={user}
settings={combinedSettings}
assistants={assistants}
hasAnyConnectors={hasAnyConnectors}

View File

@@ -10,10 +10,16 @@ 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 {
WelcomeModal,
hasCompletedWelcomeFlowSS,
} from "@/components/initialSetup/welcome/WelcomeModalWrapper";
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 { NoSourcesModal } from "@/components/initialSetup/search/NoSourcesModal";
import { NoCompleteSourcesModal } from "@/components/initialSetup/search/NoCompleteSourceModal";
import { ChatPopup } from "../chat/ChatPopup";
import {
FetchAssistantsResponse,
@@ -32,10 +38,6 @@ import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs";
import { LLMProviderDescriptor } from "../admin/configuration/llm/interfaces";
import { AssistantsProvider } from "@/components/context/AssistantsContext";
import { headers } from "next/headers";
import {
hasCompletedWelcomeFlowSS,
WelcomeModal,
} from "@/components/initialSetup/welcome/WelcomeModalWrapper";
export default async function Home({
searchParams,
@@ -168,6 +170,14 @@ export default async function Home({
ccPairs.length === 0 &&
!shouldShowWelcomeModal;
const shouldDisplaySourcesIncompleteModal =
!ccPairs.some(
(ccPair) => ccPair.has_successful_run && ccPair.docs_indexed > 0
) &&
!shouldDisplayNoSourcesModal &&
!shouldShowWelcomeModal &&
(!user || user.role == "admin");
const sidebarToggled = cookies().get(SIDEBAR_TOGGLED_COOKIE_NAME);
const agenticSearchToggle = cookies().get(AGENTIC_SEARCH_TYPE_COOKIE_NAME);
@@ -182,8 +192,12 @@ export default async function Home({
return (
<>
<HealthCheckBanner />
<InstantSSRAutoRefresh />
{shouldShowWelcomeModal && <WelcomeModal user={user} />}
<InstantSSRAutoRefresh />
{shouldDisplayNoSourcesModal && <NoSourcesModal />}
{shouldDisplaySourcesIncompleteModal && (
<NoCompleteSourcesModal ccPairs={ccPairs} />
)}
{/* 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 />

View File

@@ -115,63 +115,3 @@ export function DraggableAssistantCard(props: {
</div>
);
}
export function DisplayAssistantCard({
selectedPersona,
}: {
selectedPersona: Persona;
}) {
return (
<div className="p-4 bg-white/90 backdrop-blur-sm rounded-lg shadow-md border border-border/50 max-w-md w-full mx-auto transition-all duration-300 ease-in-out hover:shadow-lg">
<div className="flex items-center mb-3">
<AssistantIcon
disableToolip
size="medium"
assistant={selectedPersona}
/>
<h2 className="ml-3 text-xl font-semibold text-text-900">
{selectedPersona.name}
</h2>
</div>
<p className="text-sm text-text-600 mb-3 leading-relaxed">
{selectedPersona.description}
</p>
{selectedPersona.tools.length > 0 ||
selectedPersona.llm_relevance_filter ||
selectedPersona.llm_filter_extraction ? (
<div className="space-y-2">
<h3 className="text-base font-medium text-text-900">Capabilities:</h3>
<ul className="space-y-.5">
{/* display all tools */}
{selectedPersona.tools.map((tool, index) => (
<li
key={index}
className="flex items-center text-sm text-text-700"
>
<span className="mr-2 text-text-500 opacity-70"></span>{" "}
{tool.display_name}
</li>
))}
{/* Built in capabilities */}
{selectedPersona.llm_relevance_filter && (
<li className="flex items-center text-sm text-text-700">
<span className="mr-2 text-text-500 opacity-70"></span>{" "}
Advanced Relevance Filtering
</li>
)}
{selectedPersona.llm_filter_extraction && (
<li className="flex items-center text-sm text-text-700">
<span className="mr-2 text-text-500 opacity-70"></span> Smart
Information Extraction
</li>
)}
</ul>
</div>
) : (
<p className="text-sm text-text-600 italic">
No specific capabilities listed for this assistant.
</p>
)}
</div>
);
}

View File

@@ -23,38 +23,22 @@ export function AssistantIcon({
assistant,
size,
border,
disableToolip,
}: {
assistant: Persona;
size?: "small" | "medium" | "large" | "header";
size?: "small" | "medium" | "large";
border?: boolean;
disableToolip?: boolean;
}) {
const color = darkerGenerateColorFromId(assistant.id.toString());
return (
<CustomTooltip
disabled={disableToolip}
showTick
line
wrap
content={assistant.description}
>
<CustomTooltip showTick line wrap content={assistant.description}>
{
// Prioritization order: image, graph, defaults
assistant.uploaded_image_id ? (
<img
alt={assistant.name}
className={`object-cover object-center rounded-sm overflow-hidden transition-opacity duration-300 opacity-100
${
size === "large"
? "w-10 h-10"
: size === "header"
? "w-14 h-14"
: size === "medium"
? "w-8 h-8"
: "w-6 h-6"
}`}
${size === "large" ? "w-8 h-8" : "w-6 h-6"}`}
src={buildImgUrl(assistant.uploaded_image_id)}
loading="lazy"
/>
@@ -62,36 +46,20 @@ export function AssistantIcon({
<div
className={`flex-none
${border && "ring ring-[1px] ring-border-strong "}
${
size === "large"
? "w-10 h-10"
: size === "header"
? "w-14 h-14"
: size === "medium"
? "w-8 h-8"
: "w-6 h-6"
} `}
${size === "large" ? "w-10 h-10" : "w-6 h-6"} `}
>
{createSVG(
{ encodedGrid: assistant.icon_shape, filledSquares: 0 },
assistant.icon_color,
size === "large"
? 40
: size === "header"
? 56
: size === "medium"
? 32
: 24
size == "large" ? 36 : 24
)}
</div>
) : (
<div
className={`flex-none rounded-sm overflow-hidden
${border && "border border-.5 border-border-strong "}
${size === "large" ? "w-10 h-10" : ""}
${size === "header" ? "w-14 h-14" : ""}
${size === "medium" ? "w-8 h-8" : ""}
${!size || size === "small" ? "w-6 h-6" : ""} `}
${size === "large" && "w-12 h-12"}
${(!size || size === "small") && "w-6 h-6"} `}
style={{ backgroundColor: color }}
/>
)

View File

@@ -2,41 +2,26 @@ import { useProviderStatus } from "./ProviderContext";
export default function CredentialNotConfigured({
showConfigureAPIKey,
noSources,
}: {
showConfigureAPIKey: () => void;
noSources?: boolean;
}) {
const { shouldShowConfigurationNeeded } = useProviderStatus();
if (!shouldShowConfigurationNeeded) {
return null;
}
return (
<>
{noSources ? (
<p className="text-base text-center w-full text-subtle">
You have not yet added any sources. Please add{" "}
<a
href="/admin/add-connector"
className="text-link hover:underline cursor-pointer"
>
a source
</a>{" "}
to continue.
</p>
) : (
shouldShowConfigurationNeeded && (
<p className="text-base text-center w-full text-subtle">
Please note that you have not yet configured an LLM provider. You
can configure one{" "}
<button
onClick={showConfigureAPIKey}
className="text-link hover:underline cursor-pointer"
>
here
</button>
.
</p>
)
)}
</>
<p className="text-base text-center w-full text-subtle">
Please note that you have not yet configured an LLM provider. You can
configure one{" "}
<button
onClick={showConfigureAPIKey}
className="text-link hover:underline cursor-pointer"
>
here
</button>
.
</p>
);
}

View File

@@ -5,11 +5,9 @@ import { ProviderContextProvider } from "../chat_search/ProviderContext";
import { SettingsProvider } from "../settings/SettingsProvider";
import { AssistantsProvider } from "./AssistantsContext";
import { Persona } from "@/app/admin/assistants/interfaces";
import { User } from "@/lib/types";
interface AppProviderProps {
children: React.ReactNode;
user: User | null;
settings: CombinedSettings;
assistants: Persona[];
hasAnyConnectors: boolean;
@@ -18,14 +16,13 @@ interface AppProviderProps {
export const AppProvider = ({
children,
user,
settings,
assistants,
hasAnyConnectors,
hasImageCompatibleModel,
}: AppProviderProps) => {
return (
<UserProvider user={user}>
<UserProvider>
<ProviderContextProvider>
<SettingsProvider settings={settings}>
<AssistantsProvider

View File

@@ -21,7 +21,6 @@ interface AssistantsContextProps {
finalAssistants: Persona[];
ownedButHiddenAssistants: Persona[];
refreshAssistants: () => Promise<void>;
isImageGenerationAvailable: boolean;
// Admin only
editablePersonas: Persona[];
@@ -48,54 +47,51 @@ export const AssistantsProvider: React.FC<{
);
const { user, isLoadingUser, isAdmin } = useUser();
const [editablePersonas, setEditablePersonas] = useState<Persona[]>([]);
const [allAssistants, setAllAssistants] = useState<Persona[]>([]);
const [isImageGenerationAvailable, setIsImageGenerationAvailable] =
useState<boolean>(false);
useEffect(() => {
const checkImageGenerationAvailability = async () => {
try {
const response = await fetch("/api/persona/image-generation-tool");
if (response.ok) {
const { is_available } = await response.json();
setIsImageGenerationAvailable(is_available);
}
} catch (error) {
console.error("Error checking image generation availability:", error);
}
};
checkImageGenerationAvailability();
}, []);
useEffect(() => {
const fetchPersonas = async () => {
const fetchEditablePersonas = async () => {
if (!isAdmin) {
return;
}
try {
const [editableResponse, allResponse] = await Promise.all([
fetch("/api/admin/persona?get_editable=true"),
fetch("/api/admin/persona"),
]);
if (editableResponse.ok) {
const editablePersonas = await editableResponse.json();
setEditablePersonas(editablePersonas);
}
if (allResponse.ok) {
const allPersonas = await allResponse.json();
setAllAssistants(allPersonas);
const response = await fetch("/api/admin/persona?get_editable=true");
if (!response.ok) {
console.error("Failed to fetch editable personas");
return;
}
const personas = await response.json();
setEditablePersonas(personas);
} catch (error) {
console.error("Error fetching personas:", error);
console.error("Error fetching editable personas:", error);
}
};
fetchPersonas();
fetchEditablePersonas();
}, [isAdmin]);
const [allAssistants, setAllAssistants] = useState<Persona[]>([]);
useEffect(() => {
const fetchAllAssistants = async () => {
if (!isAdmin) {
return;
}
try {
const response = await fetch("/api/admin/persona");
if (!response.ok) {
console.error("Failed to fetch all personas");
return;
}
const personas = await response.json();
setAllAssistants(personas);
} catch (error) {
console.error("Error fetching all personas:", error);
}
};
fetchAllAssistants();
}, [isAdmin]);
const refreshAssistants = async () => {
@@ -166,7 +162,6 @@ export const AssistantsProvider: React.FC<{
refreshAssistants,
editablePersonas,
allAssistants,
isImageGenerationAvailable,
}}
>
{children}

View File

@@ -62,6 +62,7 @@ import OCIStorageSVG from "../../../public/OCI.svg";
import googleCloudStorageIcon from "../../../public/GoogleCloudStorage.png";
import guruIcon from "../../../public/Guru.svg";
import gongIcon from "../../../public/Gong.png";
import requestTrackerIcon from "../../../public/RequestTracker.png";
import zulipIcon from "../../../public/Zulip.png";
import linearIcon from "../../../public/Linear.png";
import hubSpotIcon from "../../../public/HubSpot.png";
@@ -1177,6 +1178,13 @@ export const GuruIcon = ({
className = defaultTailwindCSS,
}: IconProps) => <LogoIcon size={size} className={className} src={guruIcon} />;
export const RequestTrackerIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => (
<LogoIcon size={size} className={className} src={requestTrackerIcon} />
);
export const SalesforceIcon = ({
size = 16,
className = defaultTailwindCSS,

View File

@@ -0,0 +1,55 @@
"use client";
import { Button, Divider, Text } from "@tremor/react";
import { Modal } from "../../Modal";
import Link from "next/link";
import { FiMessageSquare, FiShare2 } from "react-icons/fi";
import { useContext, useState } from "react";
import { SettingsContext } from "@/components/settings/SettingsProvider";
export function NoSourcesModal() {
const settings = useContext(SettingsContext);
const [isHidden, setIsHidden] = useState(
!settings?.settings.search_page_enabled
);
if (isHidden) {
return null;
}
return (
<Modal
width="max-w-3xl w-full"
title="🧐 No sources connected"
onOutsideClick={() => setIsHidden(true)}
>
<div className="text-base">
<div>
<Text>
Before using Search you&apos;ll need to connect at least one source.
Without any connected knowledge sources, there isn&apos;t anything
to search over.
</Text>
<Link href="/admin/add-connector">
<Button className="mt-3" size="xs" icon={FiShare2}>
Connect a Source!
</Button>
</Link>
<Divider />
<div>
<Text>
Or, if you&apos;re looking for a pure ChatGPT-like experience
without any organization specific knowledge, then you can head
over to the Chat page and start chatting with Danswer right away!
</Text>
<Link href="/chat">
<Button className="mt-3" size="xs" icon={FiMessageSquare}>
Start Chatting!
</Button>
</Link>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -58,10 +58,6 @@ export function _WelcomeModal({ user }: { user: User | null }) {
{popup}
<Modal
onOutsideClick={() => {
setWelcomeFlowComplete();
router.refresh();
}}
title={"Welcome to Danswer!"}
width="w-full max-h-[900px] overflow-y-scroll max-w-3xl"
>

View File

@@ -26,17 +26,18 @@ export const ApiKeyModal = ({
}
return (
<Modal
title="Configure a Generative AI Model"
title="Set an API Key!"
width="max-w-3xl w-full"
onOutsideClick={() => hide()}
>
<>
<div className="mb-5 text-sm text-gray-700">
Please provide an API Key you can always change this or switch
models later.
Please provide an API Key below in order to start using Danswer you
can always change this later.
<br />
If you would rather look around first, you can{" "}
If you&apos;d rather look around first, you can
<strong onClick={() => hide()} className="text-link cursor-pointer">
{" "}
skip this step
</strong>
.

View File

@@ -44,7 +44,6 @@ import UnconfiguredProviderText from "../chat_search/UnconfiguredProviderText";
import { DateRangePickerValue } from "@tremor/react";
import { Tag } from "@/lib/types";
import { isEqual } from "lodash";
import { WelcomeModal } from "../initialSetup/welcome/WelcomeModalWrapper";
export type searchState =
| "input"
@@ -784,7 +783,6 @@ export const SearchSection = ({
</div>
<UnconfiguredProviderText
noSources={shouldDisplayNoSources}
showConfigureAPIKey={() => setShowApiKeyModal(true)}
/>

View File

@@ -124,9 +124,9 @@ export const CustomTooltip = ({
!disabled &&
createPortal(
<div
className={`min-w-8 fixed z-[1000] ${
citation ? "max-w-[350px]" : "w-40"
} ${large ? (medium ? "w-88" : "w-96") : line && "max-w-64 w-auto"}
className={`min-w-8 fixed z-[1000] ${citation ? "max-w-[350px]" : "w-40"} ${
large ? (medium ? "w-88" : "w-96") : line && "max-w-64 w-auto"
}
transform -translate-x-1/2 text-sm
${
light

View File

@@ -3,7 +3,6 @@
import React, { createContext, useContext, useState, useEffect } from "react";
import { User, UserRole } from "@/lib/types";
import { getCurrentUser } from "@/lib/user";
import { usePostHog } from "posthog-js/react";
interface UserContextType {
user: User | null;
@@ -15,39 +14,20 @@ interface UserContextType {
const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({
children,
user,
}: {
children: React.ReactNode;
user: User | null;
}) {
const [upToDateUser, setUpToDateUser] = useState<User | null>(user);
const [isLoadingUser, setIsLoadingUser] = useState(false);
const posthog = usePostHog();
useEffect(() => {
if (!posthog) return;
if (user?.id) {
const identifyData: Record<string, any> = {
email: user.email,
};
if (user.organization_name) {
identifyData.organization_name = user.organization_name;
}
posthog.identify(user.id, identifyData);
} else {
posthog.reset();
}
}, [posthog, user]);
export function UserProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoadingUser, setIsLoadingUser] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [isCurator, setIsCurator] = useState(false);
const fetchUser = async () => {
try {
setIsLoadingUser(true);
const currentUser = await getCurrentUser();
setUpToDateUser(currentUser);
const user = await getCurrentUser();
setUser(user);
setIsAdmin(user?.role === UserRole.ADMIN);
setIsCurator(
user?.role === UserRole.CURATOR || user?.role == UserRole.GLOBAL_CURATOR
);
} catch (error) {
console.error("Error fetching current user:", error);
} finally {
@@ -55,19 +35,17 @@ export function UserProvider({
}
};
useEffect(() => {
fetchUser();
}, []);
const refreshUser = async () => {
await fetchUser();
};
return (
<UserContext.Provider
value={{
user: upToDateUser,
isLoadingUser,
refreshUser,
isAdmin: upToDateUser?.role === UserRole.ADMIN,
isCurator: upToDateUser?.role === UserRole.CURATOR,
}}
value={{ user, isLoadingUser, isAdmin, refreshUser, isCurator }}
>
{children}
</UserContext.Provider>

View File

@@ -552,6 +552,11 @@ For example, specifying .*-support.* as a "channel" will cause the connector to
],
advanced_values: [],
},
requesttracker: {
description: "Configure HubSpot connector",
values: [],
advanced_values: [],
},
hubspot: {
description: "Configure HubSpot connector",
values: [],
@@ -1111,6 +1116,8 @@ export interface NotionConfig {
export interface HubSpotConfig {}
export interface RequestTrackerConfig {}
export interface Document360Config {
workspace: string;
categories?: string[];

View File

@@ -106,6 +106,12 @@ export interface HubSpotCredentialJson {
hubspot_access_token: string;
}
export interface RequestTrackerCredentialJson {
requesttracker_username: string;
requesttracker_password: string;
requesttracker_base_url: string;
}
export interface Document360CredentialJson {
portal_id: string;
document360_api_token: string;
@@ -218,6 +224,11 @@ export const credentialTemplates: Record<ValidSources, any> = {
portal_id: "",
document360_api_token: "",
} as Document360CredentialJson,
requesttracker: {
requesttracker_username: "",
requesttracker_password: "",
requesttracker_base_url: "",
} as RequestTrackerCredentialJson,
loopio: {
loopio_subdomain: "",
loopio_client_id: "",
@@ -360,6 +371,12 @@ export const credentialDisplayNames: Record<string, string> = {
// HubSpot
hubspot_access_token: "HubSpot Access Token",
// Request Tracker
requesttracker_username: "Request Tracker Username",
requesttracker_password: "Request Tracker Password",
requesttracker_base_url: "Request Tracker Base URL",
// Document360
portal_id: "Document360 Portal ID",
document360_api_token: "Document360 API Token",

View File

@@ -21,6 +21,7 @@ import {
LoopioIcon,
NotionIcon,
ProductboardIcon,
RequestTrackerIcon,
R2Icon,
SalesforceIcon,
SharepointIcon,
@@ -242,6 +243,12 @@ const SOURCE_METADATA_MAP: SourceMap = {
category: SourceCategory.Wiki,
docs: "https://docs.danswer.dev/connectors/mediawiki",
},
requesttracker: {
icon: RequestTrackerIcon,
displayName: "Request Tracker",
category: SourceCategory.CustomerSupport,
docs: "https://docs.danswer.dev/connectors/requesttracker",
},
clickup: {
icon: ClickupIcon,
displayName: "Clickup",

View File

@@ -42,7 +42,6 @@ export interface User {
current_token_created_at?: Date;
current_token_expiry_length?: number;
oidc_expiry?: Date;
organization_name: string | null;
}
export interface MinimalUserSnapshot {
@@ -242,6 +241,7 @@ const validSources = [
"linear",
"hubspot",
"document360",
"requesttracker",
"file",
"google_sites",
"loopio",

View File

@@ -12,9 +12,7 @@ const eePaths = [
"/admin/whitelabeling/:path*",
"/admin/performance/custom-analytics/:path*",
"/admin/standard-answer/:path*",
...(process.env.NEXT_PUBLIC_CLOUD_ENABLED
? ["/admin/cloud-settings/:path*"]
: []),
"/admin/cloud-settings/:path*",
];
// removes the "/:path*" from the end
@@ -46,5 +44,14 @@ export async function middleware(request: NextRequest) {
// Specify the paths that the middleware should run for
export const config = {
matcher: eePaths,
matcher: [
"/admin/groups/:path*",
"/admin/api-key/:path*",
"/admin/performance/usage/:path*",
"/admin/performance/query-history/:path*",
"/admin/whitelabeling/:path*",
"/admin/performance/custom-analytics/:path*",
"/admin/standard-answer/:path*",
"/admin/cloud-settings/:path*",
],
};