Compare commits

..

1 Commits

Author SHA1 Message Date
pablodanswer
8fed028cc1 k 2025-02-10 17:12:22 -08:00
89 changed files with 958 additions and 2046 deletions

View File

@@ -4,9 +4,6 @@ on:
push:
tags:
- "*"
paths:
- 'backend/model_server/**'
- 'backend/Dockerfile.model_server'
env:
REGISTRY_IMAGE: ${{ contains(github.ref_name, 'cloud') && 'onyxdotapp/onyx-model-server-cloud' || 'onyxdotapp/onyx-model-server' }}

View File

@@ -1,32 +0,0 @@
"""set built in to default
Revision ID: 2cdeff6d8c93
Revises: f5437cc136c5
Create Date: 2025-02-11 14:57:51.308775
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "2cdeff6d8c93"
down_revision = "f5437cc136c5"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Prior to this migration / point in the codebase history,
# built in personas were implicitly treated as default personas (with no option to change this)
# This migration makes that explicit
op.execute(
"""
UPDATE persona
SET is_default_persona = TRUE
WHERE builtin_persona = TRUE
"""
)
def downgrade() -> None:
pass

View File

@@ -3,44 +3,42 @@ from typing import Any
from onyx.background.celery.tasks.beat_schedule import BEAT_EXPIRES_DEFAULT
from onyx.background.celery.tasks.beat_schedule import (
beat_system_tasks as base_beat_system_tasks,
cloud_tasks_to_schedule as base_cloud_tasks_to_schedule,
)
from onyx.background.celery.tasks.beat_schedule import (
beat_task_templates as base_beat_task_templates,
)
from onyx.background.celery.tasks.beat_schedule import generate_cloud_tasks
from onyx.background.celery.tasks.beat_schedule import (
get_tasks_to_schedule as base_get_tasks_to_schedule,
tasks_to_schedule as base_tasks_to_schedule,
)
from onyx.configs.constants import ONYX_CLOUD_CELERY_TASK_PREFIX
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryTask
from shared_configs.configs import MULTI_TENANT
ee_beat_system_tasks: list[dict] = []
ee_beat_task_templates: list[dict] = []
ee_beat_task_templates.extend(
[
{
"name": "autogenerate-usage-report",
"task": OnyxCeleryTask.AUTOGENERATE_USAGE_REPORT_TASK,
"schedule": timedelta(days=30),
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
},
ee_cloud_tasks_to_schedule = [
{
"name": f"{ONYX_CLOUD_CELERY_TASK_PREFIX}_autogenerate-usage-report",
"task": OnyxCeleryTask.CLOUD_BEAT_TASK_GENERATOR,
"schedule": timedelta(days=30),
"options": {
"priority": OnyxCeleryPriority.HIGHEST,
"expires": BEAT_EXPIRES_DEFAULT,
},
{
"name": "check-ttl-management",
"task": OnyxCeleryTask.CHECK_TTL_MANAGEMENT_TASK,
"schedule": timedelta(hours=1),
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
},
"kwargs": {
"task_name": OnyxCeleryTask.AUTOGENERATE_USAGE_REPORT_TASK,
},
]
)
},
{
"name": f"{ONYX_CLOUD_CELERY_TASK_PREFIX}_check-ttl-management",
"task": OnyxCeleryTask.CLOUD_BEAT_TASK_GENERATOR,
"schedule": timedelta(hours=1),
"options": {
"priority": OnyxCeleryPriority.HIGHEST,
"expires": BEAT_EXPIRES_DEFAULT,
},
"kwargs": {
"task_name": OnyxCeleryTask.CHECK_TTL_MANAGEMENT_TASK,
},
},
]
ee_tasks_to_schedule: list[dict] = []
@@ -67,14 +65,9 @@ if not MULTI_TENANT:
]
def get_cloud_tasks_to_schedule(beat_multiplier: float) -> list[dict[str, Any]]:
beat_system_tasks = ee_beat_system_tasks + base_beat_system_tasks
beat_task_templates = ee_beat_task_templates + base_beat_task_templates
cloud_tasks = generate_cloud_tasks(
beat_system_tasks, beat_task_templates, beat_multiplier
)
return cloud_tasks
def get_cloud_tasks_to_schedule() -> list[dict[str, Any]]:
return ee_cloud_tasks_to_schedule + base_cloud_tasks_to_schedule
def get_tasks_to_schedule() -> list[dict[str, Any]]:
return ee_tasks_to_schedule + base_get_tasks_to_schedule()
return ee_tasks_to_schedule + base_tasks_to_schedule

View File

@@ -77,5 +77,3 @@ POSTHOG_HOST = os.environ.get("POSTHOG_HOST") or "https://us.i.posthog.com"
HUBSPOT_TRACKING_URL = os.environ.get("HUBSPOT_TRACKING_URL")
ANONYMOUS_USER_COOKIE_NAME = "onyx_anonymous_user"
GATED_TENANTS_KEY = "gated_tenants"

View File

@@ -15,9 +15,6 @@ def make_persona_private(
group_ids: list[int] | None,
db_session: Session,
) -> None:
"""NOTE(rkuo): This function batches all updates into a single commit. If we don't
dedupe the inputs, the commit will exception."""
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
).delete(synchronize_session="fetch")
@@ -26,22 +23,19 @@ def make_persona_private(
).delete(synchronize_session="fetch")
if user_ids:
user_ids_set = set(user_ids)
for user_id in user_ids_set:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_id))
for user_uuid in user_ids:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_uuid))
create_notification(
user_id=user_id,
user_id=user_uuid,
notif_type=NotificationType.PERSONA_SHARED,
db_session=db_session,
additional_data=PersonaSharedNotificationData(
persona_id=persona_id,
).model_dump(),
)
if group_ids:
group_ids_set = set(group_ids)
for group_id in group_ids_set:
for group_id in group_ids:
db_session.add(
Persona__UserGroup(persona_id=persona_id, user_group_id=group_id)
)

View File

@@ -18,16 +18,11 @@ from ee.onyx.server.tenants.anonymous_user_path import (
from ee.onyx.server.tenants.anonymous_user_path import modify_anonymous_user_path
from ee.onyx.server.tenants.anonymous_user_path import validate_anonymous_user_path
from ee.onyx.server.tenants.billing import fetch_billing_information
from ee.onyx.server.tenants.billing import fetch_stripe_checkout_session
from ee.onyx.server.tenants.billing import fetch_tenant_stripe_information
from ee.onyx.server.tenants.models import AnonymousUserPath
from ee.onyx.server.tenants.models import BillingInformation
from ee.onyx.server.tenants.models import ImpersonateRequest
from ee.onyx.server.tenants.models import ProductGatingRequest
from ee.onyx.server.tenants.models import ProductGatingResponse
from ee.onyx.server.tenants.models import SubscriptionSessionResponse
from ee.onyx.server.tenants.models import SubscriptionStatusResponse
from ee.onyx.server.tenants.product_gating import store_product_gating
from ee.onyx.server.tenants.provisioning import delete_user_from_control_plane
from ee.onyx.server.tenants.user_mapping import get_tenant_id_for_email
from ee.onyx.server.tenants.user_mapping import remove_all_users_from_tenant
@@ -44,9 +39,12 @@ from onyx.db.auth import get_user_count
from onyx.db.engine import get_current_tenant_id
from onyx.db.engine import get_session
from onyx.db.engine import get_session_with_tenant
from onyx.db.notification import create_notification
from onyx.db.users import delete_user_from_db
from onyx.db.users import get_user_by_email
from onyx.server.manage.models import UserByEmail
from onyx.server.settings.store import load_settings
from onyx.server.settings.store import store_settings
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
@@ -128,29 +126,37 @@ async def login_as_anonymous_user(
@router.post("/product-gating")
def gate_product(
product_gating_request: ProductGatingRequest, _: None = Depends(control_plane_dep)
) -> ProductGatingResponse:
) -> None:
"""
Gating the product means that the product is not available to the tenant.
They will be directed to the billing page.
We gate the product when their subscription has ended.
We gate the product when
1) User has ended free trial without adding payment method
2) User's card has declined
"""
try:
store_product_gating(
product_gating_request.tenant_id, product_gating_request.application_status
)
return ProductGatingResponse(updated=True, error=None)
tenant_id = product_gating_request.tenant_id
token = CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
except Exception as e:
logger.exception("Failed to gate product")
return ProductGatingResponse(updated=False, error=str(e))
settings = load_settings()
settings.product_gating = product_gating_request.product_gating
store_settings(settings)
if product_gating_request.notification:
with get_session_with_tenant(tenant_id) as db_session:
create_notification(None, product_gating_request.notification, db_session)
if token is not None:
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
@router.get("/billing-information")
@router.get("/billing-information", response_model=BillingInformation)
async def billing_information(
_: User = Depends(current_admin_user),
) -> BillingInformation | SubscriptionStatusResponse:
) -> BillingInformation:
logger.info("Fetching billing information")
return fetch_billing_information(CURRENT_TENANT_ID_CONTEXTVAR.get())
return BillingInformation(
**fetch_billing_information(CURRENT_TENANT_ID_CONTEXTVAR.get())
)
@router.post("/create-customer-portal-session")
@@ -163,10 +169,9 @@ async def create_customer_portal_session(_: User = Depends(current_admin_user))
if not stripe_customer_id:
raise HTTPException(status_code=400, detail="Stripe customer ID not found")
logger.info(stripe_customer_id)
portal_session = stripe.billing_portal.Session.create(
customer=stripe_customer_id,
return_url=f"{WEB_DOMAIN}/admin/billing",
return_url=f"{WEB_DOMAIN}/admin/cloud-settings",
)
logger.info(portal_session)
return {"url": portal_session.url}
@@ -175,20 +180,6 @@ async def create_customer_portal_session(_: User = Depends(current_admin_user))
raise HTTPException(status_code=500, detail=str(e))
@router.post("/create-subscription-session")
async def create_subscription_session(
_: User = Depends(current_admin_user),
) -> SubscriptionSessionResponse:
try:
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
session_id = fetch_stripe_checkout_session(tenant_id)
return SubscriptionSessionResponse(sessionId=session_id)
except Exception as e:
logger.exception("Failed to create resubscription session")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/impersonate")
async def impersonate_user(
impersonate_request: ImpersonateRequest,

View File

@@ -6,7 +6,6 @@ import stripe
from ee.onyx.configs.app_configs import STRIPE_PRICE_ID
from ee.onyx.configs.app_configs import STRIPE_SECRET_KEY
from ee.onyx.server.tenants.access import generate_data_plane_token
from ee.onyx.server.tenants.models import BillingInformation
from onyx.configs.app_configs import CONTROL_PLANE_API_BASE_URL
from onyx.utils.logger import setup_logger
@@ -15,19 +14,6 @@ stripe.api_key = STRIPE_SECRET_KEY
logger = setup_logger()
def fetch_stripe_checkout_session(tenant_id: str) -> str:
token = generate_data_plane_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
url = f"{CONTROL_PLANE_API_BASE_URL}/create-checkout-session"
params = {"tenant_id": tenant_id}
response = requests.post(url, headers=headers, params=params)
response.raise_for_status()
return response.json()["sessionId"]
def fetch_tenant_stripe_information(tenant_id: str) -> dict:
token = generate_data_plane_token()
headers = {
@@ -41,7 +27,7 @@ def fetch_tenant_stripe_information(tenant_id: str) -> dict:
return response.json()
def fetch_billing_information(tenant_id: str) -> BillingInformation:
def fetch_billing_information(tenant_id: str) -> dict:
logger.info("Fetching billing information")
token = generate_data_plane_token()
headers = {
@@ -52,7 +38,7 @@ def fetch_billing_information(tenant_id: str) -> BillingInformation:
params = {"tenant_id": tenant_id}
response = requests.get(url, headers=headers, params=params)
response.raise_for_status()
billing_info = BillingInformation(**response.json())
billing_info = response.json()
return billing_info

View File

@@ -1,8 +1,7 @@
from datetime import datetime
from pydantic import BaseModel
from onyx.server.settings.models import ApplicationStatus
from onyx.configs.constants import NotificationType
from onyx.server.settings.models import GatingType
class CheckoutSessionCreationRequest(BaseModel):
@@ -16,24 +15,15 @@ class CreateTenantRequest(BaseModel):
class ProductGatingRequest(BaseModel):
tenant_id: str
application_status: ApplicationStatus
class SubscriptionStatusResponse(BaseModel):
subscribed: bool
product_gating: GatingType
notification: NotificationType | None = None
class BillingInformation(BaseModel):
stripe_subscription_id: str
status: str
current_period_start: datetime
current_period_end: datetime
number_of_seats: int
cancel_at_period_end: bool
canceled_at: datetime | None
trial_start: datetime | None
trial_end: datetime | None
seats: int
subscription_status: str
billing_start: str
billing_end: str
payment_method_enabled: bool
@@ -58,12 +48,3 @@ class TenantDeletionPayload(BaseModel):
class AnonymousUserPath(BaseModel):
anonymous_user_path: str | None
class ProductGatingResponse(BaseModel):
updated: bool
error: str | None
class SubscriptionSessionResponse(BaseModel):
sessionId: str

View File

@@ -1,50 +0,0 @@
from typing import cast
from ee.onyx.configs.app_configs import GATED_TENANTS_KEY
from onyx.configs.constants import ONYX_CLOUD_TENANT_ID
from onyx.redis.redis_pool import get_redis_replica_client
from onyx.server.settings.models import ApplicationStatus
from onyx.server.settings.store import load_settings
from onyx.server.settings.store import store_settings
from onyx.setup import setup_logger
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
logger = setup_logger()
def update_tenant_gating(tenant_id: str, status: ApplicationStatus) -> None:
redis_client = get_redis_replica_client(tenant_id=ONYX_CLOUD_TENANT_ID)
# Store the full status
status_key = f"tenant:{tenant_id}:status"
redis_client.set(status_key, status.value)
# Maintain the GATED_ACCESS set
if status == ApplicationStatus.GATED_ACCESS:
redis_client.sadd(GATED_TENANTS_KEY, tenant_id)
else:
redis_client.srem(GATED_TENANTS_KEY, tenant_id)
def store_product_gating(tenant_id: str, application_status: ApplicationStatus) -> None:
try:
token = CURRENT_TENANT_ID_CONTEXTVAR.set(tenant_id)
settings = load_settings()
settings.application_status = application_status
store_settings(settings)
# Store gated tenant information in Redis
update_tenant_gating(tenant_id, application_status)
if token is not None:
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
except Exception:
logger.exception("Failed to gate product")
raise
def get_gated_tenants() -> set[str]:
redis_client = get_redis_replica_client(tenant_id=ONYX_CLOUD_TENANT_ID)
return cast(set[str], redis_client.smembers(GATED_TENANTS_KEY))

View File

@@ -28,9 +28,3 @@ class EmbeddingModelTextType:
@staticmethod
def get_type(provider: EmbeddingProvider, text_type: EmbedTextType) -> str:
return EmbeddingModelTextType.PROVIDER_TEXT_TYPE_MAP[provider][text_type]
class GPUStatus:
CUDA = "cuda"
MAC_MPS = "mps"
NONE = "none"

View File

@@ -12,7 +12,6 @@ import voyageai # type: ignore
from cohere import AsyncClient as CohereAsyncClient
from fastapi import APIRouter
from fastapi import HTTPException
from fastapi import Request
from google.oauth2 import service_account # type: ignore
from litellm import aembedding
from litellm.exceptions import RateLimitError
@@ -321,7 +320,6 @@ async def embed_text(
prefix: str | None,
api_url: str | None,
api_version: str | None,
gpu_type: str = "UNKNOWN",
) -> list[Embedding]:
if not all(texts):
logger.error("Empty strings provided for embedding")
@@ -375,11 +373,8 @@ async def embed_text(
elapsed = time.monotonic() - start
logger.info(
f"event=embedding_provider "
f"texts={len(texts)} "
f"chars={total_chars} "
f"provider={provider_type} "
f"elapsed={elapsed:.2f}"
f"Successfully embedded {len(texts)} texts with {total_chars} total characters "
f"with provider {provider_type} in {elapsed:.2f}"
)
elif model_name is not None:
logger.info(
@@ -408,14 +403,6 @@ async def embed_text(
f"Successfully embedded {len(texts)} texts with {total_chars} total characters "
f"with local model {model_name} in {elapsed:.2f}"
)
logger.info(
f"event=embedding_model "
f"texts={len(texts)} "
f"chars={total_chars} "
f"model={model_name} "
f"gpu={gpu_type} "
f"elapsed={elapsed:.2f}"
)
else:
logger.error("Neither model name nor provider specified for embedding")
raise ValueError(
@@ -468,15 +455,8 @@ async def litellm_rerank(
@router.post("/bi-encoder-embed")
async def route_bi_encoder_embed(
request: Request,
embed_request: EmbedRequest,
) -> EmbedResponse:
return await process_embed_request(embed_request, request.app.state.gpu_type)
async def process_embed_request(
embed_request: EmbedRequest, gpu_type: str = "UNKNOWN"
embed_request: EmbedRequest,
) -> EmbedResponse:
if not embed_request.texts:
raise HTTPException(status_code=400, detail="No texts to be embedded")
@@ -504,7 +484,6 @@ async def process_embed_request(
api_url=embed_request.api_url,
api_version=embed_request.api_version,
prefix=prefix,
gpu_type=gpu_type,
)
return EmbedResponse(embeddings=embeddings)
except RateLimitError as e:

View File

@@ -16,7 +16,6 @@ from model_server.custom_models import router as custom_models_router
from model_server.custom_models import warm_up_intent_model
from model_server.encoders import router as encoders_router
from model_server.management_endpoints import router as management_router
from model_server.utils import get_gpu_type
from onyx import __version__
from onyx.utils.logger import setup_logger
from shared_configs.configs import INDEXING_ONLY
@@ -59,10 +58,12 @@ def _move_files_recursively(source: Path, dest: Path, overwrite: bool = False) -
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
gpu_type = get_gpu_type()
logger.notice(f"Torch GPU Detection: gpu_type={gpu_type}")
app.state.gpu_type = gpu_type
if torch.cuda.is_available():
logger.notice("CUDA GPU is available")
elif torch.backends.mps.is_available():
logger.notice("Mac MPS is available")
else:
logger.notice("GPU is not available, using CPU")
if TEMP_HF_CACHE_PATH.is_dir():
logger.notice("Moving contents of temp_huggingface to huggingface cache.")

View File

@@ -1,9 +1,7 @@
import torch
from fastapi import APIRouter
from fastapi import Response
from model_server.constants import GPUStatus
from model_server.utils import get_gpu_type
router = APIRouter(prefix="/api")
@@ -13,7 +11,10 @@ async def healthcheck() -> Response:
@router.get("/gpu-status")
async def route_gpu_status() -> dict[str, bool | str]:
gpu_type = get_gpu_type()
gpu_available = gpu_type != GPUStatus.NONE
return {"gpu_available": gpu_available, "type": gpu_type}
async def gpu_status() -> dict[str, bool | str]:
if torch.cuda.is_available():
return {"gpu_available": True, "type": "cuda"}
elif torch.backends.mps.is_available():
return {"gpu_available": True, "type": "mps"}
else:
return {"gpu_available": False, "type": "none"}

View File

@@ -8,9 +8,6 @@ from typing import Any
from typing import cast
from typing import TypeVar
import torch
from model_server.constants import GPUStatus
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -61,12 +58,3 @@ def simple_log_function_time(
return cast(F, wrapped_sync_func)
return decorator
def get_gpu_type() -> str:
if torch.cuda.is_available():
return GPUStatus.CUDA
if torch.backends.mps.is_available():
return GPUStatus.MAC_MPS
return GPUStatus.NONE

View File

@@ -1,56 +1,41 @@
from datetime import timedelta
from typing import Any
from typing import cast
from celery import Celery
from celery import signals
from celery.beat import PersistentScheduler # type: ignore
from celery.signals import beat_init
from celery.utils.log import get_task_logger
import onyx.background.celery.apps.app_base as app_base
from onyx.background.celery.tasks.beat_schedule import CLOUD_BEAT_MULTIPLIER_DEFAULT
from onyx.configs.constants import ONYX_CLOUD_REDIS_RUNTIME
from onyx.configs.constants import ONYX_CLOUD_TENANT_ID
from onyx.configs.constants import POSTGRES_CELERY_BEAT_APP_NAME
from onyx.db.engine import get_all_tenant_ids
from onyx.db.engine import SqlEngine
from onyx.redis.redis_pool import get_redis_replica_client
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_versioned_implementation
from shared_configs.configs import IGNORED_SYNCING_TENANT_LIST
from shared_configs.configs import MULTI_TENANT
task_logger = get_task_logger(__name__)
logger = setup_logger(__name__)
celery_app = Celery(__name__)
celery_app.config_from_object("onyx.background.celery.configs.beat")
class DynamicTenantScheduler(PersistentScheduler):
"""This scheduler is useful because we can dynamically adjust task generation rates
through it."""
RELOAD_INTERVAL = 60
def __init__(self, *args: Any, **kwargs: Any) -> None:
logger.info("Initializing DynamicTenantScheduler")
super().__init__(*args, **kwargs)
self.last_beat_multiplier = CLOUD_BEAT_MULTIPLIER_DEFAULT
self._reload_interval = timedelta(
seconds=DynamicTenantScheduler.RELOAD_INTERVAL
)
self._reload_interval = timedelta(minutes=2)
self._last_reload = self.app.now() - self._reload_interval
# Let the parent class handle store initialization
self.setup_schedule()
self._try_updating_schedule()
task_logger.info(
f"DynamicTenantScheduler initialized: reload_interval={self._reload_interval}"
)
logger.info(f"Set reload interval to {self._reload_interval}")
def setup_schedule(self) -> None:
logger.info("Setting up initial schedule")
super().setup_schedule()
logger.info("Initial schedule setup complete")
def tick(self) -> float:
retval = super().tick()
@@ -59,35 +44,36 @@ class DynamicTenantScheduler(PersistentScheduler):
self._last_reload is None
or (now - self._last_reload) > self._reload_interval
):
task_logger.debug("Reload interval reached, initiating task update")
logger.info("Reload interval reached, initiating task update")
try:
self._try_updating_schedule()
except (AttributeError, KeyError):
task_logger.exception("Failed to process task configuration")
except Exception:
task_logger.exception("Unexpected error updating tasks")
except (AttributeError, KeyError) as e:
logger.exception(f"Failed to process task configuration: {str(e)}")
except Exception as e:
logger.exception(f"Unexpected error updating tasks: {str(e)}")
self._last_reload = now
logger.info("Task update completed, reset reload timer")
return retval
def _generate_schedule(
self, tenant_ids: list[str] | list[None], beat_multiplier: float
self, tenant_ids: list[str] | list[None]
) -> dict[str, dict[str, Any]]:
"""Given a list of tenant id's, generates a new beat schedule for celery."""
logger.info("Fetching tasks to schedule")
new_schedule: dict[str, dict[str, Any]] = {}
if MULTI_TENANT:
# cloud tasks are system wide and thus only need to be on the beat schedule
# once for all tenants
# cloud tasks only need the single task beat across all tenants
get_cloud_tasks_to_schedule = fetch_versioned_implementation(
"onyx.background.celery.tasks.beat_schedule",
"get_cloud_tasks_to_schedule",
)
cloud_tasks_to_schedule: list[dict[str, Any]] = get_cloud_tasks_to_schedule(
beat_multiplier
)
cloud_tasks_to_schedule: list[
dict[str, Any]
] = get_cloud_tasks_to_schedule()
for task in cloud_tasks_to_schedule:
task_name = task["name"]
cloud_task = {
@@ -96,14 +82,11 @@ class DynamicTenantScheduler(PersistentScheduler):
"kwargs": task.get("kwargs", {}),
}
if options := task.get("options"):
task_logger.debug(f"Adding options to task {task_name}: {options}")
logger.debug(f"Adding options to task {task_name}: {options}")
cloud_task["options"] = options
new_schedule[task_name] = cloud_task
# regular task beats are multiplied across all tenants
# note that currently this just schedules for a single tenant in self hosted
# and doesn't do anything in the cloud because it's much more scalable
# to schedule a single cloud beat task to dispatch per tenant tasks.
get_tasks_to_schedule = fetch_versioned_implementation(
"onyx.background.celery.tasks.beat_schedule", "get_tasks_to_schedule"
)
@@ -112,7 +95,7 @@ class DynamicTenantScheduler(PersistentScheduler):
for tenant_id in tenant_ids:
if IGNORED_SYNCING_TENANT_LIST and tenant_id in IGNORED_SYNCING_TENANT_LIST:
task_logger.debug(
logger.info(
f"Skipping tenant {tenant_id} as it is in the ignored syncing list"
)
continue
@@ -121,14 +104,14 @@ class DynamicTenantScheduler(PersistentScheduler):
task_name = task["name"]
tenant_task_name = f"{task['name']}-{tenant_id}"
task_logger.debug(f"Creating task configuration for {tenant_task_name}")
logger.debug(f"Creating task configuration for {tenant_task_name}")
tenant_task = {
"task": task["task"],
"schedule": task["schedule"],
"kwargs": {"tenant_id": tenant_id},
}
if options := task.get("options"):
task_logger.debug(
logger.debug(
f"Adding options to task {tenant_task_name}: {options}"
)
tenant_task["options"] = options
@@ -138,57 +121,44 @@ class DynamicTenantScheduler(PersistentScheduler):
def _try_updating_schedule(self) -> None:
"""Only updates the actual beat schedule on the celery app when it changes"""
do_update = False
r = get_redis_replica_client(tenant_id=ONYX_CLOUD_TENANT_ID)
task_logger.debug("_try_updating_schedule starting")
logger.info("_try_updating_schedule starting")
tenant_ids = get_all_tenant_ids()
task_logger.debug(f"Found {len(tenant_ids)} IDs")
logger.info(f"Found {len(tenant_ids)} IDs")
# get current schedule and extract current tenants
current_schedule = self.schedule.items()
# get potential new state
beat_multiplier = CLOUD_BEAT_MULTIPLIER_DEFAULT
beat_multiplier_raw = r.get(f"{ONYX_CLOUD_REDIS_RUNTIME}:beat_multiplier")
if beat_multiplier_raw is not None:
try:
beat_multiplier_bytes = cast(bytes, beat_multiplier_raw)
beat_multiplier = float(beat_multiplier_bytes.decode())
except ValueError:
task_logger.error(
f"Invalid beat_multiplier value: {beat_multiplier_raw}"
)
# there are no more per tenant beat tasks, so comment this out
# NOTE: we may not actualy need this scheduler any more and should
# test reverting to a regular beat schedule implementation
new_schedule = self._generate_schedule(tenant_ids, beat_multiplier)
# current_tenants = set()
# for task_name, _ in current_schedule:
# task_name = cast(str, task_name)
# if task_name.startswith(ONYX_CLOUD_CELERY_TASK_PREFIX):
# continue
# if the schedule or beat multiplier has changed, update
while True:
if beat_multiplier != self.last_beat_multiplier:
do_update = True
break
# if "_" in task_name:
# # example: "check-for-condition-tenant_12345678-abcd-efgh-ijkl-12345678"
# # -> "12345678-abcd-efgh-ijkl-12345678"
# current_tenants.add(task_name.split("_")[-1])
# logger.info(f"Found {len(current_tenants)} existing items in schedule")
if not DynamicTenantScheduler._compare_schedules(
current_schedule, new_schedule
):
do_update = True
break
# for tenant_id in tenant_ids:
# if tenant_id not in current_tenants:
# logger.info(f"Processing new tenant: {tenant_id}")
break
new_schedule = self._generate_schedule(tenant_ids)
if not do_update:
# exit early if nothing changed
task_logger.info(
f"_try_updating_schedule - Schedule unchanged: "
f"tasks={len(new_schedule)} "
f"beat_multiplier={beat_multiplier}"
if DynamicTenantScheduler._compare_schedules(current_schedule, new_schedule):
logger.info(
"_try_updating_schedule: Current schedule is up to date, no changes needed"
)
return
# schedule needs updating
task_logger.debug(
logger.info(
"Schedule update required",
extra={
"new_tasks": len(new_schedule),
@@ -215,19 +185,11 @@ class DynamicTenantScheduler(PersistentScheduler):
# Ensure changes are persisted
self.sync()
task_logger.info(
f"_try_updating_schedule - Schedule updated: "
f"prev_num_tasks={len(current_schedule)} "
f"prev_beat_multiplier={self.last_beat_multiplier} "
f"tasks={len(new_schedule)} "
f"beat_multiplier={beat_multiplier}"
)
self.last_beat_multiplier = beat_multiplier
logger.info("_try_updating_schedule: Schedule updated successfully")
@staticmethod
def _compare_schedules(schedule1: dict, schedule2: dict) -> bool:
"""Compare schedules by task name only to determine if an update is needed.
"""Compare schedules to determine if an update is needed.
True if equivalent, False if not."""
current_tasks = set(name for name, _ in schedule1)
new_tasks = set(schedule2.keys())
@@ -239,7 +201,7 @@ class DynamicTenantScheduler(PersistentScheduler):
@beat_init.connect
def on_beat_init(sender: Any, **kwargs: Any) -> None:
task_logger.info("beat_init signal received.")
logger.info("beat_init signal received.")
# Celery beat shouldn't touch the db at all. But just setting a low minimum here.
SqlEngine.set_app_name(POSTGRES_CELERY_BEAT_APP_NAME)

View File

@@ -1,4 +1,3 @@
import copy
from datetime import timedelta
from typing import Any
@@ -19,7 +18,7 @@ BEAT_EXPIRES_DEFAULT = 15 * 60 # 15 minutes (in seconds)
# hack to slow down task dispatch in the cloud until
# we have a better implementation (backpressure, etc)
CLOUD_BEAT_MULTIPLIER_DEFAULT = 8.0
CLOUD_BEAT_SCHEDULE_MULTIPLIER = 8
# tasks that run in either self-hosted on cloud
beat_task_templates: list[dict] = []
@@ -122,7 +121,7 @@ def make_cloud_generator_task(task: dict[str, Any]) -> dict[str, Any]:
# constant options for cloud beat task generators
task_schedule: timedelta = task["schedule"]
cloud_task["schedule"] = task_schedule
cloud_task["schedule"] = task_schedule * CLOUD_BEAT_SCHEDULE_MULTIPLIER
cloud_task["options"] = {}
cloud_task["options"]["priority"] = OnyxCeleryPriority.HIGHEST
cloud_task["options"]["expires"] = BEAT_EXPIRES_DEFAULT
@@ -142,9 +141,9 @@ def make_cloud_generator_task(task: dict[str, Any]) -> dict[str, Any]:
# tasks that only run in the cloud
# the name attribute must start with ONYX_CLOUD_CELERY_TASK_PREFIX = "cloud" to be seen
# by the DynamicTenantScheduler as system wide task and not a per tenant task
beat_system_tasks: list[dict] = [
# the name attribute must start with ONYX_CLOUD_CELERY_TASK_PREFIX = "cloud" to be filtered
# by the DynamicTenantScheduler
cloud_tasks_to_schedule: list[dict] = [
# cloud specific tasks
{
"name": f"{ONYX_CLOUD_CELERY_TASK_PREFIX}_check-alembic",
@@ -158,45 +157,18 @@ beat_system_tasks: list[dict] = [
},
]
# generate our cloud and self-hosted beat tasks from the templates
for beat_task_template in beat_task_templates:
cloud_task = make_cloud_generator_task(beat_task_template)
cloud_tasks_to_schedule.append(cloud_task)
tasks_to_schedule: list[dict] = []
if not MULTI_TENANT:
tasks_to_schedule = beat_task_templates
def generate_cloud_tasks(
beat_tasks: list[dict], beat_templates: list[dict], beat_multiplier: float
) -> list[dict[str, Any]]:
"""
beat_tasks: system wide tasks that can be sent as is
beat_templates: task templates that will be transformed into per tenant tasks via
the cloud_beat_task_generator
beat_multiplier: a multiplier that can be applied on top of the task schedule
to speed up or slow down the task generation rate. useful in production.
Returns a list of cloud tasks, which consists of incoming tasks + tasks generated
from incoming templates.
"""
if beat_multiplier <= 0:
raise ValueError("beat_multiplier must be positive!")
# start with the incoming beat tasks
cloud_tasks: list[dict] = copy.deepcopy(beat_tasks)
# generate our cloud tasks from the templates
for beat_template in beat_templates:
cloud_task = make_cloud_generator_task(beat_template)
cloud_tasks.append(cloud_task)
# factor in the cloud multiplier
for cloud_task in cloud_tasks:
cloud_task["schedule"] = cloud_task["schedule"] * beat_multiplier
return cloud_tasks
def get_cloud_tasks_to_schedule(beat_multiplier: float) -> list[dict[str, Any]]:
return generate_cloud_tasks(beat_system_tasks, beat_task_templates, beat_multiplier)
def get_cloud_tasks_to_schedule() -> list[dict[str, Any]]:
return cloud_tasks_to_schedule
def get_tasks_to_schedule() -> list[dict[str, Any]]:

View File

@@ -8,7 +8,6 @@ from celery.exceptions import SoftTimeLimitExceeded
from redis.lock import Lock as RedisLock
from tenacity import RetryError
from ee.onyx.server.tenants.product_gating import get_gated_tenants
from onyx.access.access import get_access_for_document
from onyx.background.celery.apps.app_base import task_logger
from onyx.background.celery.tasks.beat_schedule import BEAT_EXPIRES_DEFAULT
@@ -253,11 +252,7 @@ def cloud_beat_task_generator(
try:
tenant_ids = get_all_tenant_ids()
gated_tenants = get_gated_tenants()
for tenant_id in tenant_ids:
if tenant_id in gated_tenants:
continue
current_time = time.monotonic()
if current_time - last_lock_time >= (CELERY_GENERIC_BEAT_LOCK_TIMEOUT / 4):
lock_beat.reacquire()

View File

@@ -346,9 +346,6 @@ ONYX_CLOUD_CELERY_TASK_PREFIX = "cloud"
# the tenant id we use for system level redis operations
ONYX_CLOUD_TENANT_ID = "cloud"
# the redis namespace for runtime variables
ONYX_CLOUD_REDIS_RUNTIME = "runtime"
class OnyxCeleryTask:
DEFAULT = "celery"

View File

@@ -65,25 +65,10 @@ class AirtableConnector(LoadConnector):
base_id: str,
table_name_or_id: str,
treat_all_non_attachment_fields_as_metadata: bool = False,
view_id: str | None = None,
share_id: str | None = None,
batch_size: int = INDEX_BATCH_SIZE,
) -> None:
"""Initialize an AirtableConnector.
Args:
base_id: The ID of the Airtable base to connect to
table_name_or_id: The name or ID of the table to index
treat_all_non_attachment_fields_as_metadata: If True, all fields except attachments will be treated as metadata.
If False, only fields with types in DEFAULT_METADATA_FIELD_TYPES will be treated as metadata.
view_id: Optional ID of a specific view to use
share_id: Optional ID of a "share" to use for generating record URLs (https://airtable.com/developers/web/api/list-shares)
batch_size: Number of records to process in each batch
"""
self.base_id = base_id
self.table_name_or_id = table_name_or_id
self.view_id = view_id
self.share_id = share_id
self.batch_size = batch_size
self._airtable_client: AirtableApi | None = None
self.treat_all_non_attachment_fields_as_metadata = (
@@ -100,39 +85,6 @@ class AirtableConnector(LoadConnector):
raise AirtableClientNotSetUpError()
return self._airtable_client
@classmethod
def _get_record_url(
cls,
base_id: str,
table_id: str,
record_id: str,
share_id: str | None,
view_id: str | None,
field_id: str | None = None,
attachment_id: str | None = None,
) -> str:
"""Constructs the URL for a record, optionally including field and attachment IDs
Full possible structure is:
https://airtable.com/BASE_ID/SHARE_ID/TABLE_ID/VIEW_ID/RECORD_ID/FIELD_ID/ATTACHMENT_ID
"""
# If we have a shared link, use that view for better UX
if share_id:
base_url = f"https://airtable.com/{base_id}/{share_id}/{table_id}"
else:
base_url = f"https://airtable.com/{base_id}/{table_id}"
if view_id:
base_url = f"{base_url}/{view_id}"
base_url = f"{base_url}/{record_id}"
if field_id and attachment_id:
return f"{base_url}/{field_id}/{attachment_id}?blocks=hide"
return base_url
def _extract_field_values(
self,
field_id: str,
@@ -158,10 +110,8 @@ class AirtableConnector(LoadConnector):
if field_type == "multipleRecordLinks":
return []
# Get the base URL for this record
default_link = self._get_record_url(
base_id, table_id, record_id, self.share_id, self.view_id or view_id
)
# default link to use for non-attachment fields
default_link = f"https://airtable.com/{base_id}/{table_id}/{record_id}"
if field_type == "multipleAttachments":
attachment_texts: list[tuple[str, str]] = []
@@ -215,16 +165,17 @@ class AirtableConnector(LoadConnector):
extension=file_ext,
)
if attachment_text:
# Use the helper method to construct attachment URLs
attachment_link = self._get_record_url(
base_id,
table_id,
record_id,
self.share_id,
self.view_id or view_id,
field_id,
attachment_id,
)
# slightly nicer loading experience if we can specify the view ID
if view_id:
attachment_link = (
f"https://airtable.com/{base_id}/{table_id}/{view_id}/{record_id}"
f"/{field_id}/{attachment_id}?blocks=hide"
)
else:
attachment_link = (
f"https://airtable.com/{base_id}/{table_id}/{record_id}"
f"/{field_id}/{attachment_id}?blocks=hide"
)
attachment_texts.append(
(f"{filename}:\n{attachment_text}", attachment_link)
)

View File

@@ -204,14 +204,6 @@ def create_update_persona(
if not all_prompt_ids:
raise ValueError("No prompt IDs provided")
# Default persona validation
if create_persona_request.is_default_persona:
if not create_persona_request.is_public:
raise ValueError("Cannot make a default persona non public")
if user and user.role != UserRole.ADMIN:
raise ValueError("Only admins can make a default persona")
persona = upsert_persona(
persona_id=persona_id,
user=user,
@@ -518,7 +510,6 @@ def upsert_persona(
existing_persona.is_visible = is_visible
existing_persona.search_start_date = search_start_date
existing_persona.labels = labels or []
existing_persona.is_default_persona = is_default_persona
# Do not delete any associations manually added unless
# a new updated list is provided
if document_sets is not None:
@@ -599,23 +590,6 @@ def delete_old_default_personas(
db_session.commit()
def update_persona_is_default(
persona_id: int,
is_default: bool,
db_session: Session,
user: User | None = None,
) -> None:
persona = fetch_persona_by_id_for_user(
db_session=db_session, persona_id=persona_id, user=user, get_editable=True
)
if not persona.is_public:
persona.is_public = True
persona.is_default_persona = is_default
db_session.commit()
def update_persona_visibility(
persona_id: int,
is_visible: bool,

View File

@@ -146,23 +146,6 @@ def _index_vespa_chunk(
title = document.get_title_for_document_index()
metadata_json = document.metadata
cleaned_metadata_json: dict[str, str | list[str]] = {}
for key, value in metadata_json.items():
cleaned_key = remove_invalid_unicode_chars(key)
if isinstance(value, list):
cleaned_metadata_json[cleaned_key] = [
remove_invalid_unicode_chars(item) for item in value
]
else:
cleaned_metadata_json[cleaned_key] = remove_invalid_unicode_chars(value)
metadata_list = document.get_metadata_str_attributes()
if metadata_list:
metadata_list = [
remove_invalid_unicode_chars(metadata) for metadata in metadata_list
]
vespa_document_fields = {
DOCUMENT_ID: document.id,
CHUNK_ID: chunk.chunk_id,
@@ -183,10 +166,10 @@ def _index_vespa_chunk(
SEMANTIC_IDENTIFIER: remove_invalid_unicode_chars(document.semantic_identifier),
SECTION_CONTINUATION: chunk.section_continuation,
LARGE_CHUNK_REFERENCE_IDS: chunk.large_chunk_reference_ids,
METADATA: json.dumps(cleaned_metadata_json),
METADATA: json.dumps(document.metadata),
# Save as a list for efficient extraction as an Attribute
METADATA_LIST: metadata_list,
METADATA_SUFFIX: remove_invalid_unicode_chars(chunk.metadata_suffix_keyword),
METADATA_LIST: chunk.source_document.get_metadata_str_attributes(),
METADATA_SUFFIX: chunk.metadata_suffix_keyword,
EMBEDDINGS: embeddings_name_vector_map,
TITLE_EMBEDDING: chunk.title_embedding,
DOC_UPDATED_AT: _vespa_get_updated_at_attribute(document.doc_updated_at),

View File

@@ -32,7 +32,6 @@ from onyx.db.persona import get_personas_for_user
from onyx.db.persona import mark_persona_as_deleted
from onyx.db.persona import mark_persona_as_not_deleted
from onyx.db.persona import update_all_personas_display_priority
from onyx.db.persona import update_persona_is_default
from onyx.db.persona import update_persona_label
from onyx.db.persona import update_persona_public_status
from onyx.db.persona import update_persona_shared_users
@@ -57,6 +56,7 @@ from onyx.tools.utils import is_image_generation_available
from onyx.utils.logger import setup_logger
from onyx.utils.telemetry import create_milestone_and_report
logger = setup_logger()
@@ -72,10 +72,6 @@ class IsPublicRequest(BaseModel):
is_public: bool
class IsDefaultRequest(BaseModel):
is_default_persona: bool
@admin_router.patch("/{persona_id}/visible")
def patch_persona_visibility(
persona_id: int,
@@ -110,25 +106,6 @@ def patch_user_presona_public_status(
raise HTTPException(status_code=403, detail=str(e))
@admin_router.patch("/{persona_id}/default")
def patch_persona_default_status(
persona_id: int,
is_default_request: IsDefaultRequest,
user: User | None = Depends(current_curator_or_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
update_persona_is_default(
persona_id=persona_id,
is_default=is_default_request.is_default_persona,
db_session=db_session,
user=user,
)
except ValueError as e:
logger.exception("Failed to update persona default status")
raise HTTPException(status_code=403, detail=str(e))
@admin_router.put("/display-priority")
def patch_persona_display_priority(
display_priority_request: DisplayPriorityRequest,

View File

@@ -12,10 +12,10 @@ class PageType(str, Enum):
SEARCH = "search"
class ApplicationStatus(str, Enum):
PAYMENT_REMINDER = "payment_reminder"
GATED_ACCESS = "gated_access"
ACTIVE = "active"
class GatingType(str, Enum):
FULL = "full" # Complete restriction of access to the product or service
PARTIAL = "partial" # Full access but warning (no credit card on file)
NONE = "none" # No restrictions, full access to all features
class Notification(BaseModel):
@@ -43,7 +43,7 @@ class Settings(BaseModel):
maximum_chat_retention_days: int | None = None
gpu_enabled: bool | None = None
application_status: ApplicationStatus = ApplicationStatus.ACTIVE
product_gating: GatingType = GatingType.NONE
anonymous_user_enabled: bool | None = None
pro_search_disabled: bool | None = None
auto_scroll: bool | None = None

View File

@@ -86,10 +86,7 @@ def run_functions_in_parallel(
Executes a list of FunctionCalls in parallel and stores the results in a dictionary where the keys
are the result_id of the FunctionCall and the values are the results of the call.
"""
results: dict[str, Any] = {}
if len(function_calls) == 0:
return results
results = {}
with ThreadPoolExecutor(max_workers=len(function_calls)) as executor:
future_to_id = {

View File

@@ -9,8 +9,6 @@ from onyx.connectors.airtable.airtable_connector import AirtableConnector
from onyx.connectors.models import Document
from onyx.connectors.models import Section
BASE_VIEW_ID = "viwVUEJjWPd8XYjh8"
class AirtableConfig(BaseModel):
base_id: str
@@ -48,8 +46,6 @@ def create_test_document(
days_since_status_change: int | None,
attachments: list[tuple[str, str]] | None = None,
all_fields_as_metadata: bool = False,
share_id: str | None = None,
view_id: str | None = None,
) -> Document:
base_id = os.environ.get("AIRTABLE_TEST_BASE_ID")
table_id = os.environ.get("AIRTABLE_TEST_TABLE_ID")
@@ -64,13 +60,7 @@ def create_test_document(
f"Required environment variables not set: {', '.join(missing_vars)}. "
"These variables are required to run Airtable connector tests."
)
link_base = f"https://airtable.com/{base_id}"
if share_id:
link_base = f"{link_base}/{share_id}"
link_base = f"{link_base}/{table_id}"
if view_id:
link_base = f"{link_base}/{view_id}"
link_base = f"https://airtable.com/{base_id}/{table_id}"
sections = []
if not all_fields_as_metadata:
@@ -224,7 +214,6 @@ def test_airtable_connector_basic(
assignee="Chris Weaver (chris@onyx.app)",
submitted_by="Chris Weaver (chris@onyx.app)",
all_fields_as_metadata=False,
view_id=BASE_VIEW_ID,
),
create_test_document(
id="reccSlIA4pZEFxPBg",
@@ -245,7 +234,6 @@ def test_airtable_connector_basic(
)
],
all_fields_as_metadata=False,
view_id=BASE_VIEW_ID,
),
]
@@ -297,81 +285,6 @@ def test_airtable_connector_all_metadata(
)
],
all_fields_as_metadata=True,
view_id=BASE_VIEW_ID,
),
]
# Compare documents using the utility function
compare_documents(doc_batch, expected_docs)
def test_airtable_connector_with_share_and_view(
mock_get_unstructured_api_key: MagicMock, airtable_config: AirtableConfig
) -> None:
"""Test behavior when using share_id and view_id for URL generation."""
SHARE_ID = "shrkfjEzDmLaDtK83"
connector = AirtableConnector(
base_id=airtable_config.base_id,
table_name_or_id=airtable_config.table_identifier,
treat_all_non_attachment_fields_as_metadata=False,
share_id=SHARE_ID,
view_id=BASE_VIEW_ID,
)
connector.load_credentials(
{
"airtable_access_token": airtable_config.access_token,
}
)
doc_batch_generator = connector.load_from_state()
doc_batch = next(doc_batch_generator)
with pytest.raises(StopIteration):
next(doc_batch_generator)
assert len(doc_batch) == 2
expected_docs = [
create_test_document(
id="rec8BnxDLyWeegOuO",
title="Slow Internet",
description="The internet connection is very slow.",
priority="Medium",
status="In Progress",
ticket_id="2",
created_time="2024-12-24T21:02:49.000Z",
status_last_changed="2024-12-24T21:02:49.000Z",
days_since_status_change=0,
assignee="Chris Weaver (chris@onyx.app)",
submitted_by="Chris Weaver (chris@onyx.app)",
all_fields_as_metadata=False,
share_id=SHARE_ID,
view_id=BASE_VIEW_ID,
),
create_test_document(
id="reccSlIA4pZEFxPBg",
title="Printer Issue",
description="The office printer is not working.",
priority="High",
status="Open",
ticket_id="1",
created_time="2024-12-24T21:02:49.000Z",
status_last_changed="2024-12-24T21:02:49.000Z",
days_since_status_change=0,
assignee="Chris Weaver (chris@onyx.app)",
submitted_by="Chris Weaver (chris@onyx.app)",
attachments=[
(
"Test.pdf:\ntesting!!!",
(
f"https://airtable.com/{airtable_config.base_id}/{SHARE_ID}/"
f"{os.environ['AIRTABLE_TEST_TABLE_ID']}/{BASE_VIEW_ID}/reccSlIA4pZEFxPBg/"
"fld1u21zkJACIvAEF/attlj2UBWNEDZngCc?blocks=hide"
),
)
],
all_fields_as_metadata=False,
share_id=SHARE_ID,
view_id=BASE_VIEW_ID,
),
]

View File

@@ -66,7 +66,7 @@ class PersonaManager:
response = requests.post(
f"{API_SERVER_URL}/persona",
json=persona_creation_request.model_dump(mode="json"),
json=persona_creation_request.model_dump(),
headers=user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS,
@@ -119,7 +119,6 @@ class PersonaManager:
) -> DATestPersona:
system_prompt = system_prompt or f"System prompt for {persona.name}"
task_prompt = task_prompt or f"Task prompt for {persona.name}"
persona_update_request = PersonaUpsertRequest(
name=name or persona.name,
description=description or persona.description,
@@ -147,7 +146,7 @@ class PersonaManager:
response = requests.patch(
f"{API_SERVER_URL}/persona/{persona.id}",
json=persona_update_request.model_dump(mode="json"),
json=persona_update_request.model_dump(),
headers=user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS,

View File

@@ -58,7 +58,6 @@ def test_persona_permissions(reset: None) -> None:
description="A persona created by basic user",
is_public=False,
groups=[],
users=[admin_user.id],
user_performing_action=basic_user,
)
PersonaManager.verify(basic_user_persona, user_performing_action=basic_user)
@@ -140,14 +139,9 @@ def test_persona_permissions(reset: None) -> None:
"""Test admin permissions"""
# Admin can edit any persona
# the persona was shared with the admin user on creation
# this edit call will simulate having the same user in the list twice.
# The server side should dedupe and handle this correctly (prior bug)
PersonaManager.edit(
persona=basic_user_persona,
description="Updated by admin 2",
users=[admin_user.id, admin_user.id],
description="Updated by admin",
user_performing_action=admin_user,
)
PersonaManager.verify(basic_user_persona, user_performing_action=admin_user)

View File

@@ -23,12 +23,12 @@ _Note:_ if you are having problems accessing the ^, try setting the `WEB_DOMAIN`
`http://127.0.0.1:3000` and accessing it there.
## Testing
This testing process will reset your application into a clean state.
This testing process will reset your application into a clean state.
Don't run these tests if you don't want to do this!
Bring up the entire application.
1. Reset the instance
```cd backend
@@ -59,4 +59,4 @@ may use this for local troubleshooting and testing.
```
cd web
npx chromatic --playwright --project-token={your token here}
```
```

View File

@@ -3,13 +3,7 @@
import React from "react";
import { Option } from "@/components/Dropdown";
import { generateRandomIconShape } from "@/lib/assistantIconUtils";
import {
CCPairBasicInfo,
DocumentSet,
User,
UserGroup,
UserRole,
} from "@/lib/types";
import { CCPairBasicInfo, DocumentSet, User, UserGroup } from "@/lib/types";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { ArrayHelpers, FieldArray, Form, Formik, FormikProps } from "formik";
@@ -39,8 +33,9 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { FiInfo } from "react-icons/fi";
import * as Yup from "yup";
import CollapsibleSection from "./CollapsibleSection";
import { SuccessfulPersonaUpdateRedirectType } from "./enums";
@@ -76,11 +71,11 @@ import {
Option as DropdownOption,
} from "@/components/Dropdown";
import { SourceChip } from "@/app/chat/input/ChatInputBar";
import { TagIcon, UserIcon, XIcon, InfoIcon } from "lucide-react";
import { TagIcon, UserIcon, XIcon } from "lucide-react";
import { LLMSelector } from "@/components/llm/LLMSelector";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import Title from "@/components/ui/title";
import { SEARCH_TOOL_ID } from "@/app/chat/tools/constants";
@@ -132,8 +127,6 @@ export function AssistantEditor({
}) {
const { refreshAssistants, isImageGenerationAvailable } = useAssistants();
const router = useRouter();
const searchParams = useSearchParams();
const isAdminPage = searchParams.get("admin") === "true";
const { popup, setPopup } = usePopup();
const { labels, refreshLabels, createLabel, updateLabel, deleteLabel } =
@@ -223,8 +216,6 @@ export function AssistantEditor({
enabledToolsMap[tool.id] = personaCurrentToolIds.includes(tool.id);
});
const [showVisibilityWarning, setShowVisibilityWarning] = useState(false);
const initialValues = {
name: existingPersona?.name ?? "",
description: existingPersona?.description ?? "",
@@ -261,7 +252,6 @@ export function AssistantEditor({
(u) => u.id !== existingPersona.owner?.id
) ?? [],
selectedGroups: existingPersona?.groups ?? [],
is_default_persona: existingPersona?.is_default_persona ?? false,
};
interface AssistantPrompt {
@@ -318,12 +308,24 @@ export function AssistantEditor({
const [isRequestSuccessful, setIsRequestSuccessful] = useState(false);
const { data: userGroups } = useUserGroups();
// const { data: allUsers } = useUsers({ includeApiKeys: false }) as {
// data: MinimalUserSnapshot[] | undefined;
// };
const { data: users } = useSWR<MinimalUserSnapshot[]>(
"/api/users",
errorHandlingFetcher
);
const mapUsersToMinimalSnapshot = (users: any): MinimalUserSnapshot[] => {
if (!users || !Array.isArray(users.users)) return [];
return users.users.map((user: any) => ({
id: user.id,
name: user.name,
email: user.email,
}));
};
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
if (!labels) {
@@ -344,7 +346,9 @@ export function AssistantEditor({
if (response.ok) {
await refreshAssistants();
router.push(
isAdminPage ? `/admin/assistants?u=${Date.now()}` : `/chat`
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat`
);
} else {
setPopup({
@@ -370,9 +374,8 @@ export function AssistantEditor({
<BackButton />
</div>
)}
{labelToDelete && (
<ConfirmEntityModal
<DeleteEntityModal
entityType="label"
entityName={labelToDelete.name}
onClose={() => setLabelToDelete(null)}
@@ -395,7 +398,7 @@ export function AssistantEditor({
/>
)}
{deleteModalOpen && existingPersona && (
<ConfirmEntityModal
<DeleteEntityModal
entityType="Persona"
entityName={existingPersona.name}
onClose={closeDeleteModal}
@@ -436,7 +439,6 @@ export function AssistantEditor({
label_ids: Yup.array().of(Yup.number()),
selectedUsers: Yup.array().of(Yup.object()),
selectedGroups: Yup.array().of(Yup.number()),
is_default_persona: Yup.boolean().required(),
})
.test(
"system-prompt-or-task-prompt",
@@ -457,19 +459,6 @@ export function AssistantEditor({
"Must provide either Instructions or Reminders (Advanced)",
});
}
)
.test(
"default-persona-public",
"Default persona must be public",
function (values) {
if (values.is_default_persona && !values.is_public) {
return this.createError({
path: "is_public",
message: "Default persona must be public",
});
}
return true;
}
)}
onSubmit={async (values, formikHelpers) => {
if (
@@ -510,6 +499,7 @@ export function AssistantEditor({
const submissionData: PersonaUpsertParameters = {
...values,
existing_prompt_id: existingPrompt?.id ?? null,
is_default_persona: admin!,
starter_messages: starterMessages,
groups: groups,
users: values.is_public
@@ -573,9 +563,8 @@ export function AssistantEditor({
}
await refreshAssistants();
router.push(
isAdminPage
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat?assistantId=${assistantId}`
);
@@ -1016,22 +1005,6 @@ export function AssistantEditor({
{showAdvancedOptions && (
<>
<div className="max-w-4xl w-full">
{user?.role == UserRole.ADMIN && (
<BooleanFormField
onChange={(checked) => {
if (checked) {
setFieldValue("is_public", true);
setFieldValue("is_default_persona", true);
}
}}
name="is_default_persona"
label="Featured Assistant"
subtext="If set, this assistant will be pinned for all new users and appear in the Featured list in the assistant explorer. This also makes the assistant public."
/>
)}
<Separator />
<div className="flex gap-x-2 items-center ">
<div className="block font-medium text-sm">Access</div>
</div>
@@ -1041,60 +1014,22 @@ export function AssistantEditor({
<div className="min-h-[100px]">
<div className="flex items-center mb-2">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<div>
<SwitchField
name="is_public"
size="md"
onCheckedChange={(checked) => {
if (values.is_default_persona && !checked) {
setShowVisibilityWarning(true);
} else {
setFieldValue("is_public", checked);
if (!checked) {
// Even though this code path should not be possible,
// we set the default persona to false to be safe
setFieldValue(
"is_default_persona",
false
);
}
if (checked) {
setFieldValue("selectedUsers", []);
setFieldValue("selectedGroups", []);
}
}
}}
disabled={values.is_default_persona}
/>
</div>
</TooltipTrigger>
{values.is_default_persona && (
<TooltipContent side="top" align="center">
Default persona must be public. Set
&quot;Default Persona&quot; to false to change
visibility.
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<SwitchField
name="is_public"
size="md"
onCheckedChange={(checked) => {
setFieldValue("is_public", checked);
if (checked) {
setFieldValue("selectedUsers", []);
setFieldValue("selectedGroups", []);
}
}}
/>
<span className="text-sm ml-2">
{values.is_public ? "Public" : "Private"}
</span>
</div>
{showVisibilityWarning && (
<div className="flex items-center text-warning mt-2">
<InfoIcon size={16} className="mr-2" />
<span className="text-sm">
Default persona must be public. Visibility has been
automatically set to public.
</span>
</div>
)}
{values.is_public ? (
<p className="text-sm text-text-dark">
Anyone from your organization can view and use this

View File

@@ -11,14 +11,13 @@ import { DraggableTable } from "@/components/table/DraggableTable";
import {
deletePersona,
personaComparator,
togglePersonaDefault,
togglePersonaVisibility,
} from "./lib";
import { FiEdit2 } from "react-icons/fi";
import { TrashIcon } from "@/components/icons/icons";
import { useUser } from "@/components/user/UserProvider";
import { useAssistants } from "@/components/context/AssistantsContext";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
if (persona.builtin_persona) {
@@ -57,9 +56,6 @@ export function PersonasTable() {
const [finalPersonas, setFinalPersonas] = useState<Persona[]>([]);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [personaToDelete, setPersonaToDelete] = useState<Persona | null>(null);
const [defaultModalOpen, setDefaultModalOpen] = useState(false);
const [personaToToggleDefault, setPersonaToToggleDefault] =
useState<Persona | null>(null);
useEffect(() => {
const editable = editablePersonas.sort(personaComparator);
@@ -130,39 +126,11 @@ export function PersonasTable() {
}
};
const openDefaultModal = (persona: Persona) => {
setPersonaToToggleDefault(persona);
setDefaultModalOpen(true);
};
const closeDefaultModal = () => {
setDefaultModalOpen(false);
setPersonaToToggleDefault(null);
};
const handleToggleDefault = async () => {
if (personaToToggleDefault) {
const response = await togglePersonaDefault(
personaToToggleDefault.id,
personaToToggleDefault.is_default_persona
);
if (response.ok) {
await refreshAssistants();
closeDefaultModal();
} else {
setPopup({
type: "error",
message: `Failed to update persona - ${await response.text()}`,
});
}
}
};
return (
<div>
{popup}
{deleteModalOpen && personaToDelete && (
<ConfirmEntityModal
<DeleteEntityModal
entityType="Persona"
entityName={personaToDelete.name}
onClose={closeDeleteModal}
@@ -170,35 +138,8 @@ export function PersonasTable() {
/>
)}
{defaultModalOpen && personaToToggleDefault && (
<ConfirmEntityModal
variant="action"
entityType="Assistant"
entityName={personaToToggleDefault.name}
onClose={closeDefaultModal}
onSubmit={handleToggleDefault}
actionButtonText={
personaToToggleDefault.is_default_persona
? "Remove Featured"
: "Set as Featured"
}
additionalDetails={
personaToToggleDefault.is_default_persona
? `Removing "${personaToToggleDefault.name}" as a featured assistant will not affect its visibility or accessibility.`
: `Setting "${personaToToggleDefault.name}" as a featured assistant will make it public and visible to all users. This action cannot be undone.`
}
/>
)}
<DraggableTable
headers={[
"Name",
"Description",
"Type",
"Featured Assistant",
"Is Visible",
"Delete",
]}
headers={["Name", "Description", "Type", "Is Visible", "Delete"]}
isAdmin={isAdmin}
rows={finalPersonas.map((persona) => {
const isEditable = editablePersonas.includes(persona);
@@ -211,9 +152,7 @@ export function PersonasTable() {
className="mr-1 my-auto cursor-pointer"
onClick={() =>
router.push(
`/assistants/edit/${
persona.id
}?u=${Date.now()}&admin=true`
`/admin/assistants/${persona.id}?u=${Date.now()}`
)
}
/>
@@ -229,30 +168,6 @@ export function PersonasTable() {
{persona.description}
</p>,
<PersonaTypeDisplay key={persona.id} persona={persona} />,
<div
key="is_default_persona"
onClick={() => {
if (isEditable) {
openDefaultModal(persona);
}
}}
className={`px-1 py-0.5 rounded flex ${
isEditable
? "hover:bg-accent-background-hovered cursor-pointer"
: ""
} select-none w-fit`}
>
<div className="my-auto flex-none w-22">
{!persona.is_default_persona ? (
<div className="text-error">Not Featured</div>
) : (
"Featured"
)}
</div>
<div className="ml-1 my-auto">
<CustomCheckbox checked={persona.is_default_persona} />
</div>
</div>,
<div
key="is_visible"
onClick={async () => {

View File

@@ -0,0 +1,36 @@
"use client";
import { Button } from "@/components/ui/button";
import { deletePersona } from "../lib";
import { useRouter } from "next/navigation";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
export function DeletePersonaButton({
personaId,
redirectType,
}: {
personaId: number;
redirectType: SuccessfulPersonaUpdateRedirectType;
}) {
const router = useRouter();
return (
<Button
variant="destructive"
onClick={async () => {
const response = await deletePersona(personaId);
if (response.ok) {
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat`
);
} else {
alert(`Failed to delete persona - ${await response.text()}`);
}
}}
>
Delete
</Button>
);
}

View File

@@ -0,0 +1,43 @@
import { ErrorCallout } from "@/components/ErrorCallout";
import { AssistantEditor } from "../AssistantEditor";
import { BackButton } from "@/components/BackButton";
import { DeletePersonaButton } from "./DeletePersonaButton";
import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
import { RobotIcon } from "@/components/icons/icons";
import { AdminPageTitle } from "@/components/admin/Title";
import CardSection from "@/components/admin/CardSection";
import Title from "@/components/ui/title";
export default async function Page(props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const [values, error] = await fetchAssistantEditorInfoSS(params.id);
let body;
if (!values) {
body = (
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
);
} else {
body = (
<>
<CardSection className="!border-none !bg-transparent !ring-none">
<AssistantEditor
{...values}
admin
defaultPublic={true}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
</CardSection>
</>
);
}
return (
<div className="w-full">
<AdminPageTitle title="Edit Assistant" icon={<RobotIcon size={32} />} />
{body}
</div>
);
}

View File

@@ -261,22 +261,6 @@ export function personaComparator(a: Persona, b: Persona) {
return closerToZeroNegativesFirstComparator(a.id, b.id);
}
export const togglePersonaDefault = async (
personaId: number,
isDefault: boolean
) => {
const response = await fetch(`/api/admin/persona/${personaId}/default`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
is_default_persona: !isDefault,
}),
});
return response;
};
export const togglePersonaVisibility = async (
personaId: number,
isVisible: boolean

View File

@@ -0,0 +1,25 @@
import { AssistantEditor } from "../AssistantEditor";
import { ErrorCallout } from "@/components/ErrorCallout";
import { fetchAssistantEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
export default async function Page() {
const [values, error] = await fetchAssistantEditorInfoSS();
if (!values) {
return (
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
);
} else {
return (
<div className="w-full">
<AssistantEditor
{...values}
admin
defaultPublic={true}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
</div>
);
}
}

View File

@@ -29,7 +29,7 @@ export default async function Page() {
<Separator />
<Title>Create an Assistant</Title>
<CreateButton href="/assistants/new?admin=true" text="New Assistant" />
<CreateButton href="/admin/assistants/new" text="New Assistant" />
<Separator />

View File

@@ -100,7 +100,7 @@ export function SlackChannelConfigsTable({
slackChannelConfig.persona
) ? (
<Link
href={`/assistants/${slackChannelConfig.persona.id}`}
href={`/admin/assistants/${slackChannelConfig.persona.id}`}
className="text-primary hover:underline"
>
{slackChannelConfig.persona.name}

View File

@@ -19,7 +19,7 @@ import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { CustomEmbeddingModelForm } from "@/components/embedding/CustomEmbeddingModelForm";
import { deleteSearchSettings } from "./utils";
import { usePopup } from "@/components/admin/connectors/Popup";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { AdvancedSearchConfiguration } from "../interfaces";
import CardSection from "@/components/admin/CardSection";
@@ -456,7 +456,7 @@ export function CloudModelCard({
>
{popup}
{showDeleteModel && (
<ConfirmEntityModal
<DeleteEntityModal
entityName={model.model_name}
entityType="embedding model configuration"
onSubmit={() => deleteModel()}

View File

@@ -1,7 +1,7 @@
export enum ApplicationStatus {
PAYMENT_REMINDER = "payment_reminder",
GATED_ACCESS = "gated_access",
ACTIVE = "active",
export enum GatingType {
FULL = "full",
PARTIAL = "partial",
NONE = "none",
}
export interface Settings {
@@ -11,7 +11,7 @@ export interface Settings {
needs_reindexing: boolean;
gpu_enabled: boolean;
pro_search_disabled: boolean | null;
application_status: ApplicationStatus;
product_gating: GatingType;
auto_scroll: boolean;
}

View File

@@ -33,7 +33,7 @@ export default function SidebarWrapper<T extends object>({
size = "sm",
children,
}: SidebarWrapperProps<T>) {
const [sidebarVisible, setSidebarVisible] = useState(initiallyToggled);
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
const [showDocSidebar, setShowDocSidebar] = useState(false); // State to track if sidebar is open
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
const [untoggled, setUntoggled] = useState(false);
@@ -41,13 +41,13 @@ export default function SidebarWrapper<T extends object>({
const toggleSidebar = useCallback(() => {
Cookies.set(
SIDEBAR_TOGGLED_COOKIE_NAME,
String(!sidebarVisible).toLocaleLowerCase()
String(!toggledSidebar).toLocaleLowerCase()
),
{
path: "/",
};
setSidebarVisible((sidebarVisible) => !sidebarVisible);
}, [sidebarVisible]);
setToggledSidebar((toggledSidebar) => !toggledSidebar);
}, [toggledSidebar]);
const sidebarElementRef = useRef<HTMLDivElement>(null);
const { folders, openedFolders, chatSessions } = useChatContext();
@@ -63,7 +63,7 @@ export default function SidebarWrapper<T extends object>({
const settings = useContext(SettingsContext);
useSidebarVisibility({
sidebarVisible,
toggledSidebar,
sidebarElementRef,
showDocSidebar,
setShowDocSidebar,
@@ -94,7 +94,7 @@ export default function SidebarWrapper<T extends object>({
duration-300
ease-in-out
${
!untoggled && (showDocSidebar || sidebarVisible)
!untoggled && (showDocSidebar || toggledSidebar)
? "opacity-100 w-[250px] translate-x-0"
: "opacity-0 w-[200px] pointer-events-none -translate-x-10"
}`}
@@ -107,7 +107,7 @@ export default function SidebarWrapper<T extends object>({
explicitlyUntoggle={explicitlyUntoggle}
ref={sidebarElementRef}
toggleSidebar={toggleSidebar}
toggled={sidebarVisible}
toggled={toggledSidebar}
existingChats={chatSessions}
currentChatSession={null}
folders={folders}
@@ -117,7 +117,7 @@ export default function SidebarWrapper<T extends object>({
<div className="absolute px-2 left-0 w-full top-0">
<FunctionalHeader
sidebarToggled={sidebarVisible}
sidebarToggled={toggledSidebar}
toggleSidebar={toggleSidebar}
page="chat"
/>
@@ -132,7 +132,7 @@ export default function SidebarWrapper<T extends object>({
bg-opacity-80
duration-300
ease-in-out
${sidebarVisible ? "w-[250px]" : "w-[0px]"}`}
${toggledSidebar ? "w-[250px]" : "w-[0px]"}`}
/>
<div
@@ -144,7 +144,7 @@ export default function SidebarWrapper<T extends object>({
</div>
</div>
</div>
<FixedLogo backgroundToggled={sidebarVisible || showDocSidebar} />
<FixedLogo backgroundToggled={toggledSidebar || showDocSidebar} />
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useRef, useLayoutEffect } from "react";
import React, { useContext, useState, useRef, useLayoutEffect } from "react";
import { useRouter } from "next/navigation";
import {
FiMoreHorizontal,
@@ -8,7 +8,7 @@ import {
FiLock,
FiUnlock,
} from "react-icons/fi";
import { FaHashtag } from "react-icons/fa";
import {
Popover,
PopoverTrigger,
@@ -26,12 +26,14 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PinnedIcon } from "@/components/icons/icons";
import { deletePersona } from "@/app/admin/assistants/lib";
import {
deletePersona,
togglePersonaPublicStatus,
} from "@/app/admin/assistants/lib";
import { PencilIcon } from "lucide-react";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { truncateString } from "@/lib/utils";
import { usePopup } from "@/components/admin/connectors/Popup";
import { Button } from "@/components/ui/button";
export const AssistantBadge = ({
text,
@@ -61,7 +63,6 @@ const AssistantCard: React.FC<{
const { user, toggleAssistantPinnedStatus } = useUser();
const router = useRouter();
const { refreshAssistants, pinnedAssistants } = useAssistants();
const { popup, setPopup } = usePopup();
const isOwnedByUser = checkUserOwnsAssistant(user, persona);
@@ -71,34 +72,7 @@ const AssistantCard: React.FC<{
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const [isDeleteConfirmation, setIsDeleteConfirmation] = useState(false);
const handleDelete = () => {
setIsDeleteConfirmation(true);
};
const confirmDelete = async () => {
const response = await deletePersona(persona.id);
if (response.ok) {
await refreshAssistants();
setActivePopover(null);
setIsDeleteConfirmation(false);
setPopup({
message: `${persona.name} has been successfully deleted.`,
type: "success",
});
} else {
setPopup({
message: `Failed to delete assistant - ${await response.text()}`,
type: "error",
});
}
};
const cancelDelete = () => {
setIsDeleteConfirmation(false);
};
const handleDelete = () => setActivePopover("delete");
const handleEdit = () => {
router.push(`/assistants/edit/${persona.id}`);
setActivePopover(null);
@@ -126,7 +100,6 @@ const AssistantCard: React.FC<{
return (
<div className="w-full text-text-800 p-2 overflow-visible pb-4 pt-3 bg-transparent dark:bg-neutral-800/80 rounded shadow-[0px_0px_4px_0px_rgba(0,0,0,0.25)] flex flex-col">
{popup}
<div className="w-full flex">
<div className="ml-2 flex-none mr-2 mt-1 w-10 h-10">
<AssistantIcon assistant={persona} size="large" />
@@ -184,84 +157,55 @@ const AssistantCard: React.FC<{
<FiMoreHorizontal size={16} />
</button>
</PopoverTrigger>
<PopoverContent
className={`${
isDeleteConfirmation ? "w-64" : "w-32"
} z-[10000] p-2`}
>
{!isDeleteConfirmation ? (
<div className="flex flex-col text-sm space-y-1">
<PopoverContent className={`w-32 z-[10000] p-2`}>
<div className="flex flex-col text-sm space-y-1">
<button
onClick={isOwnedByUser ? handleEdit : undefined}
className={`w-full flex items-center text-left px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral-700"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiEdit size={12} className="inline mr-2" />
Edit
</button>
{isPaidEnterpriseFeaturesEnabled && isOwnedByUser && (
<button
onClick={isOwnedByUser ? handleEdit : undefined}
className={`w-full flex items-center text-left px-2 py-1 rounded ${
onClick={
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral-700"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiEdit size={12} className="inline mr-2" />
Edit
</button>
{isPaidEnterpriseFeaturesEnabled && isOwnedByUser && (
<button
onClick={
isOwnedByUser
? () => {
router.push(
`/assistants/stats/${persona.id}`
);
closePopover();
}
: undefined
}
className={`w-full text-left items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral-800"
: "opacity-50 cursor-not-allowed"
}`}
>
<FiBarChart size={12} className="inline mr-2" />
Stats
</button>
)}
<button
onClick={isOwnedByUser ? handleDelete : undefined}
? () => {
router.push(
`/assistants/stats/${persona.id}`
);
closePopover();
}
: undefined
}
className={`w-full text-left items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral- text-red-600 dark:text-red-400"
: "opacity-50 cursor-not-allowed text-red-300 dark:text-red-500"
? "hover:bg-neutral-200 dark:hover:bg-neutral-800"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiTrash size={12} className="inline mr-2" />
Delete
<FiBarChart size={12} className="inline mr-2" />
Stats
</button>
</div>
) : (
<div className="w-full">
<p className="text-sm mb-3">
Are you sure you want to delete assistant{" "}
<b>{persona.name}</b>?
</p>
<div className="flex justify-center gap-2">
<Button
variant="secondary"
size="sm"
onClick={cancelDelete}
>
Cancel
</Button>
<Button
variant="destructive"
size="sm"
onClick={confirmDelete}
>
Delete
</Button>
</div>
</div>
)}
)}
<button
onClick={isOwnedByUser ? handleDelete : undefined}
className={`w-full text-left items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-200 dark:hover:bg-neutral- text-red-600 dark:text-red-400"
: "opacity-50 cursor-not-allowed text-red-300 dark:text-red-500"
}`}
disabled={!isOwnedByUser}
>
<FiTrash size={12} className="inline mr-2" />
Delete
</button>
</div>
</PopoverContent>
</Popover>
</div>

View File

@@ -5,8 +5,10 @@ import { useRouter } from "next/navigation";
import AssistantCard from "./AssistantCard";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useUser } from "@/components/user/UserProvider";
import { FilterIcon, XIcon } from "lucide-react";
import { FilterIcon } from "lucide-react";
import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Modal } from "@/components/Modal";
export const AssistantBadgeSelector = ({
text,
@@ -22,8 +24,8 @@ export const AssistantBadgeSelector = ({
className={`
select-none ${
selected
? "bg-background-900 text-white"
: "bg-transparent text-text-900"
? "bg-neutral-900 text-white"
: "bg-transparent text-neutral-900"
} w-12 h-5 text-center px-1 py-0.5 rounded-lg cursor-pointer text-[12px] font-normal leading-[10px] border border-black justify-center items-center gap-1 inline-flex`}
onClick={toggleFilter}
>
@@ -107,20 +109,16 @@ export function AssistantModal({
const featuredAssistants = [
...memoizedCurrentlyVisibleAssistants.filter(
(assistant) => assistant.is_default_persona
(assistant) => assistant.builtin_persona || assistant.is_default_persona
),
];
const allAssistants = memoizedCurrentlyVisibleAssistants.filter(
(assistant) => !assistant.is_default_persona
(assistant) => !assistant.builtin_persona && !assistant.is_default_persona
);
return (
<div
onClick={hideModal}
className="fixed inset-0 bg-neutral-950/80 bg-opacity-50 flex items-center justify-center z-50"
>
<div className="fixed inset-0 bg-neutral-950/80 bg-opacity-50 flex items-center justify-center z-50">
<div
onClick={(e) => e.stopPropagation()}
className="p-0 max-w-4xl overflow-hidden max-h-[80vh] w-[95%] bg-background rounded-md shadow-2xl transform transition-all duration-300 ease-in-out relative w-11/12 max-w-4xl pt-10 pb-10 px-10 overflow-hidden flex flex-col"
style={{
position: "fixed",
@@ -130,142 +128,129 @@ export function AssistantModal({
margin: 0,
}}
>
<div className="absolute top-2 right-2">
<button
onClick={hideModal}
className="cursor-pointer text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-300 transition-colors duration-200 p-2"
aria-label="Close modal"
>
<XIcon className="w-5 h-5" />
</button>
</div>
<div className="flex overflow-hidden flex-col h-full">
<div className="flex overflow-hidden flex-col h-full">
<div className="flex flex-col sticky top-0 z-10">
<div className="flex px-2 justify-between items-center gap-x-2 mb-0">
<div className="h-12 w-full rounded-lg flex-col justify-center items-start gap-2.5 inline-flex">
<div className="h-12 rounded-md w-full shadow-[0px_0px_2px_0px_rgba(0,0,0,0.25)] border border-background-300 flex items-center px-3">
{!isSearchFocused && (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-text-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)}
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
type="text"
className="w-full h-full bg-transparent outline-none text-black"
<div className="flex flex-col sticky top-0 z-10">
<div className="flex px-2 justify-between items-center gap-x-2 mb-0">
<div className="h-12 w-full rounded-lg flex-col justify-center items-start gap-2.5 inline-flex">
<div className="h-12 rounded-md w-full shadow-[0px_0px_2px_0px_rgba(0,0,0,0.25)] border border-[#dcdad4] flex items-center px-3">
{!isSearchFocused && (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
)}
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
type="text"
className="w-full h-full bg-transparent outline-none text-black"
/>
</div>
</div>
<button
onClick={() => router.push("/assistants/new")}
className="h-10 cursor-pointer px-6 py-3 bg-black rounded-md border border-black justify-center items-center gap-2.5 inline-flex"
>
<div className="text-[#fffcf4] text-lg font-normal leading-normal">
Create
</div>
</button>
</div>
<div className="px-2 flex py-4 items-center gap-x-2 flex-wrap">
<FilterIcon size={16} />
<AssistantBadgeSelector
text="Pinned"
selected={assistantFilters[AssistantFilter.Pinned]}
toggleFilter={() =>
toggleAssistantFilter(AssistantFilter.Pinned)
}
/>
<AssistantBadgeSelector
text="Mine"
selected={assistantFilters[AssistantFilter.Mine]}
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Mine)}
/>
<AssistantBadgeSelector
text="Private"
selected={assistantFilters[AssistantFilter.Private]}
toggleFilter={() =>
toggleAssistantFilter(AssistantFilter.Private)
}
/>
<AssistantBadgeSelector
text="Public"
selected={assistantFilters[AssistantFilter.Public]}
toggleFilter={() =>
toggleAssistantFilter(AssistantFilter.Public)
}
/>
</div>
<div className="w-full border-t border-neutral-200" />
</div>
<div className="flex-grow overflow-y-auto">
<h2 className="text-2xl font-semibold text-gray-800 mb-2 px-4 py-2">
Featured Assistants
</h2>
<div className="w-full px-2 pb-2 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
{featuredAssistants.length > 0 ? (
featuredAssistants.map((assistant, index) => (
<div key={index}>
<AssistantCard
pinned={pinnedAssistants
.map((a) => a.id)
.includes(assistant.id)}
persona={assistant}
closeModal={hideModal}
/>
</div>
))
) : (
<div className="col-span-2 text-center text-gray-500">
No featured assistants match filters
</div>
<button
onClick={() => router.push("/assistants/new")}
className="h-10 cursor-pointer px-6 py-3 bg-background-800 hover:bg-black rounded-md border border-black justify-center items-center gap-2.5 inline-flex"
>
<div className="text-text-50 text-lg font-normal leading-normal">
Create
</div>
</button>
</div>
<div className="px-2 flex py-4 items-center gap-x-2 flex-wrap">
<FilterIcon className="text-text-800" size={16} />
<AssistantBadgeSelector
text="Pinned"
selected={assistantFilters[AssistantFilter.Pinned]}
toggleFilter={() =>
toggleAssistantFilter(AssistantFilter.Pinned)
}
/>
<AssistantBadgeSelector
text="Mine"
selected={assistantFilters[AssistantFilter.Mine]}
toggleFilter={() =>
toggleAssistantFilter(AssistantFilter.Mine)
}
/>
<AssistantBadgeSelector
text="Private"
selected={assistantFilters[AssistantFilter.Private]}
toggleFilter={() =>
toggleAssistantFilter(AssistantFilter.Private)
}
/>
<AssistantBadgeSelector
text="Public"
selected={assistantFilters[AssistantFilter.Public]}
toggleFilter={() =>
toggleAssistantFilter(AssistantFilter.Public)
}
/>
</div>
<div className="w-full border-t border-background-200" />
</div>
<div className="flex-grow overflow-y-auto">
<h2 className="text-2xl font-semibold text-text-800 mb-2 px-4 py-2">
Featured Assistants
</h2>
<div className="w-full px-2 pb-10 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
{featuredAssistants.length > 0 ? (
featuredAssistants.map((assistant, index) => (
<div key={index}>
<AssistantCard
pinned={pinnedAssistants
.map((a) => a.id)
.includes(assistant.id)}
persona={assistant}
closeModal={hideModal}
/>
</div>
))
) : (
<div className="col-span-2 text-center text-text-500">
No featured assistants match filters
</div>
)}
</div>
{allAssistants && allAssistants.length > 0 && (
<>
<h2 className="text-2xl font-semibold text-text-800 mt-4 mb-2 px-4 py-2">
All Assistants
</h2>
<div className="w-full mt-2 px-2 pb-2 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
{allAssistants
.sort((a, b) => b.id - a.id)
.map((assistant, index) => (
<div key={index}>
<AssistantCard
pinned={
user?.preferences?.pinned_assistants?.includes(
assistant.id
) ?? false
}
persona={assistant}
closeModal={hideModal}
/>
</div>
))}
</div>
</>
)}
</div>
{allAssistants && allAssistants.length > 0 && (
<>
<h2 className="text-2xl font-semibold text-gray-800 mt-4 mb-2 px-4 py-2">
All Assistants
</h2>
<div className="w-full mt-2 px-2 pb-2 grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-6">
{allAssistants
.sort((a, b) => b.id - a.id)
.map((assistant, index) => (
<div key={index}>
<AssistantCard
pinned={
user?.preferences?.pinned_assistants?.includes(
assistant.id
) ?? false
}
persona={assistant}
closeModal={hideModal}
/>
</div>
))}
</div>
</>
)}
</div>
</div>
</div>

View File

@@ -97,6 +97,7 @@ import {
} from "@/components/resizable/constants";
import FixedLogo from "../../components/logo/FixedLogo";
import { DeleteEntityModal } from "../../components/modals/DeleteEntityModal";
import { MinimalMarkdown } from "@/components/chat/MinimalMarkdown";
import ExceptionTraceModal from "@/components/modals/ExceptionTraceModal";
@@ -129,7 +130,6 @@ import {
useSidebarShortcut,
} from "@/lib/browserUtilities";
import { Button } from "@/components/ui/button";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
const TEMP_USER_MESSAGE_ID = -1;
const TEMP_ASSISTANT_MESSAGE_ID = -2;
@@ -138,12 +138,12 @@ const SYSTEM_MESSAGE_ID = -3;
export function ChatPage({
toggle,
documentSidebarInitialWidth,
sidebarVisible,
toggledSidebar,
firstMessage,
}: {
toggle: (toggled?: boolean) => void;
documentSidebarInitialWidth?: number;
sidebarVisible: boolean;
toggledSidebar: boolean;
firstMessage?: string;
}) {
const router = useRouter();
@@ -204,7 +204,7 @@ export function ChatPage({
const settings = useContext(SettingsContext);
const enterpriseSettings = settings?.enterpriseSettings;
const [documentSidebarVisible, setDocumentSidebarVisible] = useState(false);
const [documentSidebarToggled, setDocumentSidebarToggled] = useState(false);
const [proSearchEnabled, setProSearchEnabled] = useState(proSearchToggled);
const [streamingAllowed, setStreamingAllowed] = useState(false);
const toggleProSearch = () => {
@@ -243,7 +243,7 @@ export function ChatPage({
if (user?.is_anonymous_user) {
Cookies.set(
SIDEBAR_TOGGLED_COOKIE_NAME,
String(!sidebarVisible).toLocaleLowerCase()
String(!toggledSidebar).toLocaleLowerCase()
);
toggle(false);
}
@@ -1024,10 +1024,10 @@ export function ChatPage({
if (
(!personaIncludesRetrieval &&
(!selectedDocuments || selectedDocuments.length === 0) &&
documentSidebarVisible) ||
documentSidebarToggled) ||
chatSessionIdRef.current == undefined
) {
setDocumentSidebarVisible(false);
setDocumentSidebarToggled(false);
}
clientScrollToBottom();
}, [chatSessionIdRef.current]);
@@ -1122,7 +1122,6 @@ export function ChatPage({
"Continue Generating (pick up exactly where you left off)",
});
};
const [gener, setFinishedStreaming] = useState(false);
const onSubmit = async ({
messageIdToResend,
@@ -1273,7 +1272,6 @@ export function ChatPage({
let finalMessage: BackendMessage | null = null;
let toolCall: ToolCallMetadata | null = null;
let isImprovement: boolean | undefined = undefined;
let isStreamingQuestions = true;
let initialFetchDetails: null | {
user_message_id: number;
@@ -1444,22 +1442,11 @@ export function ChatPage({
Object.hasOwn(packet, "stop_reason") &&
Object.hasOwn(packet, "level_question_num")
) {
if ((packet as StreamStopInfo).stream_type == "main_answer") {
setFinishedStreaming(true);
updateChatState("streaming", frozenSessionId);
}
if (
(packet as StreamStopInfo).stream_type == "sub_questions" &&
(packet as StreamStopInfo).level_question_num == undefined
) {
isStreamingQuestions = false;
}
sub_questions = constructSubQuestions(
sub_questions,
packet as StreamStopInfo
);
} else if (Object.hasOwn(packet, "sub_question")) {
updateChatState("toolBuilding", frozenSessionId);
is_generating = true;
sub_questions = constructSubQuestions(
sub_questions,
@@ -1619,7 +1606,6 @@ export function ChatPage({
latestChildMessageId: initialFetchDetails.assistant_message_id,
},
{
isStreamingQuestions: isStreamingQuestions,
is_generating: is_generating,
isImprovement: isImprovement,
messageId: initialFetchDetails.assistant_message_id!,
@@ -1819,7 +1805,7 @@ export function ChatPage({
}
Cookies.set(
SIDEBAR_TOGGLED_COOKIE_NAME,
String(!sidebarVisible).toLocaleLowerCase()
String(!toggledSidebar).toLocaleLowerCase()
),
{
path: "/",
@@ -1836,7 +1822,7 @@ export function ChatPage({
const sidebarElementRef = useRef<HTMLDivElement>(null);
useSidebarVisibility({
sidebarVisible,
toggledSidebar,
sidebarElementRef,
showDocSidebar: showHistorySidebar,
setShowDocSidebar: setShowHistorySidebar,
@@ -2017,7 +2003,7 @@ export function ChatPage({
useEffect(() => {
if (!retrievalEnabled) {
setDocumentSidebarVisible(false);
setDocumentSidebarToggled(false);
}
}, [retrievalEnabled]);
@@ -2082,10 +2068,10 @@ export function ChatPage({
const [showAssistantsModal, setShowAssistantsModal] = useState(false);
const toggleDocumentSidebar = () => {
if (!documentSidebarVisible) {
setDocumentSidebarVisible(true);
if (!documentSidebarToggled) {
setDocumentSidebarToggled(true);
} else {
setDocumentSidebarVisible(false);
setDocumentSidebarToggled(false);
}
};
@@ -2136,7 +2122,7 @@ export function ChatPage({
<ChatPopup />
{showDeleteAllModal && (
<ConfirmEntityModal
<DeleteEntityModal
entityType="All Chats"
entityName="all your chat sessions"
onClose={() => setShowDeleteAllModal(false)}
@@ -2192,11 +2178,11 @@ export function ChatPage({
/>
)}
{retrievalEnabled && documentSidebarVisible && settings?.isMobile && (
{retrievalEnabled && documentSidebarToggled && settings?.isMobile && (
<div className="md:hidden">
<Modal
hideDividerForTitle
onOutsideClick={() => setDocumentSidebarVisible(false)}
onOutsideClick={() => setDocumentSidebarToggled(false)}
title="Sources"
>
<DocumentResults
@@ -2212,7 +2198,7 @@ export function ChatPage({
modal={true}
ref={innerSidebarElementRef}
closeSidebar={() => {
setDocumentSidebarVisible(false);
setDocumentSidebarToggled(false);
}}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
@@ -2292,21 +2278,21 @@ export function ChatPage({
duration-300
ease-in-out
${
!untoggled && (showHistorySidebar || sidebarVisible)
!untoggled && (showHistorySidebar || toggledSidebar)
? "opacity-100 w-[250px] translate-x-0"
: "opacity-0 w-[250px] pointer-events-none -translate-x-10"
}`}
>
<div className="w-full relative">
<HistorySidebar
liveAssistant={liveAssistant}
setShowAssistantsModal={setShowAssistantsModal}
explicitlyUntoggle={explicitlyUntoggle}
reset={() => setMessage("")}
page="chat"
ref={innerSidebarElementRef}
toggleSidebar={toggleSidebar}
toggled={sidebarVisible}
toggled={toggledSidebar}
currentAssistantId={liveAssistant?.id}
existingChats={chatSessions}
currentChatSession={selectedChatSession}
folders={folders}
@@ -2328,7 +2314,7 @@ export function ChatPage({
duration-300
ease-in-out
${
documentSidebarVisible &&
documentSidebarToggled &&
!settings?.isMobile &&
"opacity-100 w-[350px]"
}`}
@@ -2353,7 +2339,7 @@ export function ChatPage({
ease-in-out
h-full
${
documentSidebarVisible && !settings?.isMobile
documentSidebarToggled && !settings?.isMobile
? "w-[400px]"
: "w-[0px]"
}
@@ -2372,7 +2358,7 @@ export function ChatPage({
modal={false}
ref={innerSidebarElementRef}
closeSidebar={() =>
setTimeout(() => setDocumentSidebarVisible(false), 300)
setTimeout(() => setDocumentSidebarToggled(false), 300)
}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
@@ -2381,12 +2367,12 @@ export function ChatPage({
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
initialWidth={400}
isOpen={documentSidebarVisible && !settings?.isMobile}
isOpen={documentSidebarToggled && !settings?.isMobile}
/>
</div>
<BlurBackground
visible={!untoggled && (showHistorySidebar || sidebarVisible)}
visible={!untoggled && (showHistorySidebar || toggledSidebar)}
onClick={() => toggleSidebar()}
/>
@@ -2401,7 +2387,7 @@ export function ChatPage({
{liveAssistant && (
<FunctionalHeader
toggleUserSettings={() => setUserSettingsToggled(true)}
sidebarToggled={sidebarVisible}
sidebarToggled={toggledSidebar}
reset={() => setMessage("")}
page="chat"
setSharingModalVisible={
@@ -2409,8 +2395,8 @@ export function ChatPage({
? setSharingModalVisible
: undefined
}
documentSidebarVisible={
documentSidebarVisible && !settings?.isMobile
documentSidebarToggled={
documentSidebarToggled && !settings?.isMobile
}
toggleSidebar={toggleSidebar}
currentChatSession={selectedChatSession}
@@ -2438,7 +2424,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${sidebarVisible ? "w-[200px]" : "w-[0px]"}
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
`}
></div>
)}
@@ -2464,7 +2450,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${sidebarVisible ? "w-[200px]" : "w-[0px]"}
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
`}
></div>
)}
@@ -2649,14 +2635,8 @@ export function ChatPage({
{message.sub_questions &&
message.sub_questions.length > 0 ? (
<AgenticMessage
isStreamingQuestions={
message.isStreamingQuestions ?? false
}
isGenerating={
message.is_generating ?? false
}
docSidebarToggled={
documentSidebarVisible &&
documentSidebarToggled &&
(selectedMessageForDocDisplay ==
message.messageId ||
selectedMessageForDocDisplay ==
@@ -2752,8 +2732,7 @@ export function ChatPage({
setMessageAsLatest(messageId);
}}
isActive={
messageHistory.length - 1 == i ||
messageHistory.length - 2 == i
messageHistory.length - 1 == i
}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={(
@@ -2761,8 +2740,8 @@ export function ChatPage({
) => {
if (
(!second &&
!documentSidebarVisible) ||
(documentSidebarVisible &&
!documentSidebarToggled) ||
(documentSidebarToggled &&
selectedMessageForDocDisplay ===
message.messageId)
) {
@@ -2770,8 +2749,8 @@ export function ChatPage({
}
if (
(second &&
!documentSidebarVisible) ||
(documentSidebarVisible &&
!documentSidebarToggled) ||
(documentSidebarToggled &&
selectedMessageForDocDisplay ===
secondLevelMessage?.messageId)
) {
@@ -2872,8 +2851,8 @@ export function ChatPage({
selectedDocuments={selectedDocuments}
toggleDocumentSelection={() => {
if (
!documentSidebarVisible ||
(documentSidebarVisible &&
!documentSidebarToggled ||
(documentSidebarToggled &&
selectedMessageForDocDisplay ===
message.messageId)
) {
@@ -3091,7 +3070,7 @@ export function ChatPage({
<div className="mx-auto w-fit !pointer-events-none flex sticky justify-center">
<button
onClick={() => clientScrollToBottom()}
className="p-1 pointer-events-auto text-neutral-700 dark:text-neutral-800 rounded-2xl bg-neutral-200 border border-border mx-auto "
className="p-1 pointer-events-auto rounded-2xl bg-background-strong border border-border mx-auto "
>
<FiArrowDown size={18} />
</button>
@@ -3168,7 +3147,7 @@ export function ChatPage({
ease-in-out
h-full
${
documentSidebarVisible && !settings?.isMobile
documentSidebarToggled && !settings?.isMobile
? "w-[350px]"
: "w-[0px]"
}
@@ -3183,7 +3162,7 @@ export function ChatPage({
style={{ transition: "width 0.30s ease-out" }}
className={`flex-none bg-transparent transition-all bg-opacity-80 duration-300 ease-in-out h-full
${
sidebarVisible && !settings?.isMobile
toggledSidebar && !settings?.isMobile
? "w-[250px] "
: "w-[0px]"
}`}
@@ -3195,7 +3174,7 @@ export function ChatPage({
)}
</div>
</div>
<FixedLogo backgroundToggled={sidebarVisible || showHistorySidebar} />
<FixedLogo backgroundToggled={toggledSidebar || showHistorySidebar} />
</div>
{/* Right Sidebar - DocumentSidebar */}
</div>

View File

@@ -115,61 +115,21 @@ export function RefinemenetBadge({
const isDone = displayedPhases.includes(StreamingPhase.COMPLETE);
// Expand/collapse, hover states
const [expanded] = useState(true);
const [expanded, setExpanded] = useState(true);
const [toolTipHoveredInternal, setToolTipHoveredInternal] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [shouldShow, setShouldShow] = useState(true);
// Refs for bounding area checks
const containerRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
// Keep the tooltip open if hovered on container or tooltip
// Remove the old onMouseLeave calls and rely on bounding area checks
useEffect(() => {
function handleMouseMove(e: MouseEvent) {
if (!containerRef.current || !tooltipRef.current) return;
const containerRect = containerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const [x, y] = [e.clientX, e.clientY];
const inContainer =
x >= containerRect.left &&
x <= containerRect.right &&
y >= containerRect.top &&
y <= containerRect.bottom;
const inTooltip =
x >= tooltipRect.left &&
x <= tooltipRect.right &&
y >= tooltipRect.top &&
y <= tooltipRect.bottom;
// If not hovering in either region, close tooltip
if (!inContainer && !inTooltip) {
setToolTipHoveredInternal(false);
setToolTipHovered(false);
setIsHovered(false);
}
}
window.addEventListener("mousemove", handleMouseMove);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
};
}, [setToolTipHovered]);
// Once "done", hide after a short delay if not hovered
useEffect(() => {
if (isDone) {
const timer = setTimeout(() => {
setShouldShow(false);
setCanShowResponse(true);
}, 800);
}, 800); // e.g. 0.8s
return () => clearTimeout(timer);
}
}, [isDone, isHovered, setCanShowResponse]);
}, [isDone, isHovered]);
if (!shouldShow) {
return null; // entire box disappears
@@ -177,22 +137,13 @@ export function RefinemenetBadge({
return (
<TooltipProvider delayDuration={0}>
{/*
IMPORTANT: We rely on open={ isHovered || toolTipHoveredInternal }
to keep the tooltip visible if either the badge or tooltip is hovered.
*/}
<Tooltip open={isHovered || toolTipHoveredInternal}>
<div
className="relative w-fit max-w-sm"
ref={containerRef}
// onMouseEnter keeps the tooltip open
onMouseEnter={() => {
setIsHovered(true);
setToolTipHoveredInternal(true);
setToolTipHovered(true);
}}
// Remove the explicit onMouseLeave the global bounding check will close it
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Original snippet's tooltip usage */}
<TooltipTrigger asChild>
<div className="flex items-center gap-x-1 text-black text-sm font-medium cursor-pointer hover:text-blue-600 transition-colors duration-200">
<p className="text-sm loading-text font-medium">
@@ -208,32 +159,36 @@ export function RefinemenetBadge({
</TooltipTrigger>
{expanded && (
<TooltipContent
ref={tooltipRef}
// onMouseEnter keeps the tooltip open when cursor enters tooltip
onMouseEnter={() => {
setToolTipHoveredInternal(true);
setToolTipHovered(true);
}}
// Remove onMouseLeave and rely on bounding box logic to close
onMouseLeave={() => {
setToolTipHoveredInternal(false);
}}
side="bottom"
align="start"
width="w-fit"
className=" -mt-1 p-4 bg-[#fff] dark:bg-[#000] border-2 border-border dark:border-neutral-800 shadow-lg rounded-md"
className="w-fit -mt-1 p-4 bg-white border-2 border-border shadow-lg rounded-md"
>
{/* If not done, show the "Refining" box + a chevron */}
{/* Expanded area: each displayed phase in order */}
<div className="items-start flex flex-col gap-y-2">
{currentState !== StreamingPhase.WAITING ? (
Array.from(new Set(displayedPhases)).map((phase, index) => {
const phaseIndex = displayedPhases.indexOf(phase);
// The last displayed item is "running" if not COMPLETE
let status = ToggleState.Done;
if (
index ===
Array.from(new Set(displayedPhases)).length - 1 &&
phase !== StreamingPhase.COMPLETE
Array.from(new Set(displayedPhases)).length - 1
) {
status = ToggleState.InProgress;
}
if (phase === StreamingPhase.COMPLETE) {
status = ToggleState.Done;
}
return (
<div
@@ -383,7 +338,6 @@ export function StatusRefinement({
onMouseLeave={() => setToolTipHovered(false)}
side="bottom"
align="start"
width="w-fit"
className="w-fit p-4 bg-[#fff] border-2 border-border dark:border-neutral-800 shadow-lg rounded-md"
>
{/* If not done, show the "Refining" box + a chevron */}
@@ -401,6 +355,7 @@ export function StatusRefinement({
</div>
<span className="text-neutral-800 text-sm font-medium">
{StreamingPhaseText[phase]}
LLL
</span>
</div>
))}

View File

@@ -5,22 +5,18 @@ import FunctionalWrapper from "../../components/chat/FunctionalWrapper";
export default function WrappedChat({
firstMessage,
defaultSidebarOff,
}: {
firstMessage?: string;
// This is required for the chrome extension side panel
// we don't want to show the sidebar by default when the user opens the side panel
defaultSidebarOff?: boolean;
}) {
const { sidebarInitiallyVisible } = useChatContext();
const { toggledSidebar } = useChatContext();
return (
<FunctionalWrapper
initiallyVisible={sidebarInitiallyVisible && !defaultSidebarOff}
content={(sidebarVisible, toggle) => (
initiallyToggled={toggledSidebar}
content={(toggledSidebar, toggle) => (
<ChatPage
toggle={toggle}
sidebarVisible={sidebarVisible}
toggledSidebar={toggledSidebar}
firstMessage={firstMessage}
/>
)}

View File

@@ -819,17 +819,27 @@ export function ChatInputBar({
chatState == "toolBuilding" ||
chatState == "loading"
? chatState != "streaming"
? "bg-neutral-500 dark:bg-neutral-400 "
: "bg-neutral-900 dark:bg-neutral-50"
: "bg-red-200"
? "bg-neutral-900 dark:bg-neutral-400 "
: "bg-neutral-500 dark:bg-neutral-50"
: ""
} h-[22px] w-[22px] rounded-full`}
onClick={() => {
if (chatState == "streaming") {
if (
chatState == "streaming" ||
chatState == "toolBuilding" ||
chatState == "loading"
) {
stopGenerating();
} else if (message) {
onSubmit();
}
}}
disabled={
(chatState == "streaming" ||
chatState == "toolBuilding" ||
chatState == "loading") &&
chatState != "streaming"
}
>
{chatState == "streaming" ||
chatState == "toolBuilding" ||

View File

@@ -110,7 +110,6 @@ export interface Message {
second_level_message?: string;
second_level_subquestions?: SubQuestionDetail[] | null;
isImprovement?: boolean | null;
isStreamingQuestions?: boolean;
}
export interface BackendChatSession {
@@ -220,7 +219,6 @@ export interface SubQuestionDetail extends BaseQuestionIdentifier {
context_docs?: { top_documents: OnyxDocument[] } | null;
is_complete?: boolean;
is_stopped?: boolean;
answer_streaming?: boolean;
}
export interface SubQueryDetail {
@@ -247,6 +245,9 @@ export const constructSubQuestions = (
}
const updatedSubQuestions = [...subQuestions];
// .filter(
// (sq) => sq.level_question_num !== 0
// );
if ("stop_reason" in newDetail) {
const { level, level_question_num } = newDetail;
@@ -254,12 +255,8 @@ export const constructSubQuestions = (
(sq) => sq.level === level && sq.level_question_num === level_question_num
);
if (subQuestion) {
if (newDetail.stream_type == "sub_answer") {
subQuestion.answer_streaming = false;
} else {
subQuestion.is_complete = true;
subQuestion.is_stopped = true;
}
subQuestion.is_complete = true;
subQuestion.is_stopped = true;
}
} else if ("top_documents" in newDetail) {
const { level, level_question_num, top_documents } = newDetail;

View File

@@ -31,7 +31,7 @@ export default async function Layout({
llmProviders,
folders,
openedFolders,
sidebarInitiallyVisible,
toggleSidebar,
defaultAssistantId,
shouldShowWelcomeModal,
ccPairs,
@@ -47,7 +47,7 @@ export default async function Layout({
proSearchToggled,
inputPrompts,
chatSessions,
sidebarInitiallyVisible,
toggledSidebar: toggleSidebar,
availableSources,
ccPairs,
documentSets,

View File

@@ -48,10 +48,9 @@ import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import SubQuestionsDisplay from "./SubQuestionsDisplay";
import { StatusRefinement } from "../Refinement";
import SubQuestionProgress from "./SubQuestionProgress";
export const AgenticMessage = ({
isStreamingQuestions,
isGenerating,
docSidebarToggled,
isImprovement,
secondLevelAssistantMessage,
@@ -82,8 +81,6 @@ export const AgenticMessage = ({
secondLevelSubquestions,
toggleDocDisplay,
}: {
isStreamingQuestions: boolean;
isGenerating: boolean;
docSidebarToggled?: boolean;
isImprovement?: boolean | null;
secondLevelSubquestions?: SubQuestionDetail[] | null;
@@ -233,13 +230,6 @@ export const AgenticMessage = ({
);
const [currentlyOpenQuestion, setCurrentlyOpenQuestion] =
useState<BaseQuestionIdentifier | null>(null);
const [finishedGenerating, setFinishedGenerating] = useState(!isGenerating);
useEffect(() => {
if (streamedContent.length == finalContent.length && !isGenerating) {
setFinishedGenerating(true);
}
}, [streamedContent, finalContent, isGenerating]);
const openQuestion = useCallback(
(question: SubQuestionDetail) => {
@@ -410,10 +400,12 @@ export const AgenticMessage = ({
<div className="w-full desktop:ml-4">
{subQuestions && subQuestions.length > 0 && (
<SubQuestionsDisplay
isStreamingQuestions={isStreamingQuestions}
allowDocuments={() => setAllowDocuments(true)}
docSidebarToggled={docSidebarToggled || false}
finishedGenerating={finishedGenerating}
finishedGenerating={
finalContent.length > 2 &&
streamedContent.length == finalContent.length
}
overallAnswerGenerating={
!!(
secondLevelSubquestions &&

View File

@@ -185,7 +185,7 @@ export const AIMessage = ({
onMessageSelection,
setPresentingDocument,
index,
documentSidebarVisible,
toggledDocumentSidebar,
}: {
index?: number;
shared?: boolean;
@@ -205,7 +205,7 @@ export const AIMessage = ({
citedDocuments?: [string, OnyxDocument][] | null;
toolCall?: ToolCallMetadata | null;
isComplete?: boolean;
documentSidebarVisible?: boolean;
toggledDocumentSidebar?: boolean;
hasDocs?: boolean;
handleFeedback?: (feedbackType: FeedbackType) => void;
handleSearchQueryEdit?: (query: string) => void;
@@ -508,7 +508,7 @@ export const AIMessage = ({
/>
))}
<SeeMoreBlock
toggled={documentSidebarVisible!}
toggled={toggledDocumentSidebar!}
toggleDocumentSelection={toggleDocumentSelection!}
docs={docs}
webSourceDomains={webSourceDomains}
@@ -957,7 +957,7 @@ export const HumanMessage = ({
min-h-[38px]
py-2
px-3
hover:bg-agent-hovered
hover:bg-accent-hover
`}
onClick={handleEditSubmit}
>

View File

@@ -56,19 +56,12 @@ const DOC_DELAY_MS = 100;
export const useStreamingMessages = (
subQuestions: SubQuestionDetail[],
allowStreaming: () => void,
onComplete: () => void,
isStreamingQuestions: boolean
onComplete: () => void
) => {
const [dynamicSubQuestions, setDynamicSubQuestions] = useState<
SubQuestionDetail[]
>([]);
const isStreamingQuestionsRef = useRef(isStreamingQuestions);
useEffect(() => {
isStreamingQuestionsRef.current = isStreamingQuestions;
}, [isStreamingQuestions]);
const subQuestionsRef = useRef<SubQuestionDetail[]>(subQuestions);
useEffect(() => {
subQuestionsRef.current = subQuestions;
@@ -128,7 +121,6 @@ export const useStreamingMessages = (
// Stream high-level questions sequentially
let didStreamQuestion = false;
let allQuestionsComplete = true;
for (let i = 0; i < actualSubQs.length; i++) {
const sq = actualSubQs[i];
const p = progressRef.current[i];
@@ -146,8 +138,6 @@ export const useStreamingMessages = (
p.questionDone = true;
}
didStreamQuestion = true;
allQuestionsComplete = false;
// Break after streaming one question to ensure sequential behavior
break;
}
@@ -159,11 +149,7 @@ export const useStreamingMessages = (
}
}
if (
allQuestionsComplete &&
!didStreamQuestion &&
!isStreamingQuestionsRef.current
) {
if (allQuestionsComplete && !didStreamQuestion) {
onComplete();
}
@@ -177,8 +163,6 @@ export const useStreamingMessages = (
for (let i = 0; i < actualSubQs.length; i++) {
const sq = actualSubQs[i];
const dynSQ = dynamicSubQuestionsRef.current[i];
dynSQ.answer_streaming = sq.answer_streaming;
const p = progressRef.current[i];
// Wait for subquestion #0 or the previous subquestion's progress

View File

@@ -65,7 +65,6 @@ export interface TemporaryDisplay {
tinyQuestion: string;
}
interface SubQuestionsDisplayProps {
isStreamingQuestions: boolean;
docSidebarToggled: boolean;
finishedGenerating: boolean;
currentlyOpenQuestion?: BaseQuestionIdentifier | null;
@@ -153,8 +152,7 @@ const SubQuestionDisplay: React.FC<{
content = content.replace(/\]\](?!\()/g, "]]()");
return (
preprocessLaTeX(content) +
(subQuestion?.answer_streaming ? " [*]() " : "")
preprocessLaTeX(content) + (!subQuestion?.is_complete ? " [*]() " : "")
);
};
@@ -463,7 +461,6 @@ const SubQuestionDisplay: React.FC<{
};
const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
isStreamingQuestions,
finishedGenerating,
subQuestions,
allowStreaming,
@@ -480,29 +477,23 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
const [showSummarizing, setShowSummarizing] = useState(
finishedGenerating && !overallAnswerGenerating
);
const [initiallyFinishedGenerating, setInitiallyFinishedGenerating] =
useState(finishedGenerating);
// const []
const { dynamicSubQuestions } = useStreamingMessages(
subQuestions,
() => {},
() => {
setShowSummarizing(true);
},
isStreamingQuestions
}
);
const { dynamicSubQuestions: dynamicSecondLevelQuestions } =
useStreamingMessages(
secondLevelQuestions || [],
() => {},
() => {},
false
() => {}
);
const memoizedSubQuestions = useMemo(() => {
return initiallyFinishedGenerating ? subQuestions : dynamicSubQuestions;
}, [initiallyFinishedGenerating, dynamicSubQuestions, subQuestions]);
return finishedGenerating ? subQuestions : dynamicSubQuestions;
}, [finishedGenerating, dynamicSubQuestions, subQuestions]);
// const memoizedSubQuestions = dynamicSubQuestions;
const memoizedSecondLevelQuestions = useMemo(() => {
return overallAnswerGenerating
? dynamicSecondLevelQuestions
@@ -518,6 +509,12 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
(subQuestion) => (subQuestion?.sub_queries || [])?.length > 0
).length == 0;
const overallAnswer =
memoizedSubQuestions.length > 0 &&
memoizedSubQuestions.filter(
(subQuestion) => subQuestion?.answer.length > 10
).length == memoizedSubQuestions.length;
const [streamedText, setStreamedText] = useState(
finishedGenerating ? "Summarize findings" : ""
);
@@ -527,15 +524,12 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
const [shownDocuments, setShownDocuments] = useState(documents);
useEffect(() => {
if (canShowSummarizing && documents && documents.length > 0) {
setTimeout(
() => {
setShownDocuments(documents);
},
finishedGenerating ? 0 : 800
);
if (documents && documents.length > 0) {
setTimeout(() => {
setShownDocuments(documents);
}, 800);
}
}, [documents, canShowSummarizing]);
}, [documents]);
useEffect(() => {
if (

View File

@@ -39,6 +39,7 @@ export function UserSettingsModal({
onClose: () => void;
defaultModel: string | null;
}) {
const { inputPrompts, refreshInputPrompts } = useChatContext();
const {
refreshUser,
user,

View File

@@ -1,4 +1,3 @@
import { SEARCH_PARAMS } from "@/lib/extension/constants";
import WrappedChat from "./WrappedChat";
export default async function Page(props: {
@@ -6,13 +5,6 @@ export default async function Page(props: {
}) {
const searchParams = await props.searchParams;
const firstMessage = searchParams.firstMessage;
const defaultSidebarOff =
searchParams[SEARCH_PARAMS.DEFAULT_SIDEBAR_OFF] === "true";
return (
<WrappedChat
firstMessage={firstMessage}
defaultSidebarOff={defaultSidebarOff}
/>
);
return <WrappedChat firstMessage={firstMessage} />;
}

View File

@@ -50,12 +50,10 @@ import {
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CirclePlus, CircleX, PinIcon } from "lucide-react";
import { CircleX } from "lucide-react";
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
import { turborepoTraceAccess } from "next/dist/build/turborepo-access-trace";
interface HistorySidebarProps {
liveAssistant?: Persona | null;
page: pageType;
existingChats?: ChatSession[];
currentChatSession?: ChatSession | null | undefined;
@@ -68,23 +66,22 @@ interface HistorySidebarProps {
showDeleteModal?: (chatSession: ChatSession) => void;
explicitlyUntoggle: () => void;
showDeleteAllModal?: () => void;
currentAssistantId?: number | null;
setShowAssistantsModal: (show: boolean) => void;
}
interface SortableAssistantProps {
assistant: Persona;
active: boolean;
currentAssistantId: number | null | undefined;
onClick: () => void;
onPinAction: (e: React.MouseEvent) => void;
pinned?: boolean;
onUnpin: (e: React.MouseEvent) => void;
}
const SortableAssistant: React.FC<SortableAssistantProps> = ({
assistant,
active,
currentAssistantId,
onClick,
onPinAction,
pinned = true,
onUnpin,
}) => {
const {
attributes,
@@ -129,9 +126,7 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
>
<DragHandle
size={16}
className={`w-3 ml-[2px] mr-[2px] group-hover:visible invisible flex-none cursor-grab ${
!pinned ? "opacity-0" : ""
}`}
className="w-3 ml-[2px] mr-[2px] group-hover:visible invisible flex-none cursor-grab"
/>
<div
data-testid={`assistant-[${assistant.id}]`}
@@ -142,7 +137,9 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
}
}}
className={`cursor-pointer w-full group hover:bg-background-chat-hover ${
active ? "bg-accent-background-selected" : ""
currentAssistantId === assistant.id
? "bg-background-chat-hover/60"
: ""
} relative flex items-center gap-x-2 py-1 px-2 rounded-md`}
>
<AssistantIcon assistant={assistant} size={16} className="flex-none" />
@@ -167,36 +164,15 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
>
{assistant.name}
</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onPinAction(e);
}}
className="group-hover:block hidden absolute right-2"
>
{pinned ? (
<CircleX
size={16}
className="text-text-history-sidebar-button"
/>
) : (
<PinIcon
size={16}
className="text-text-history-sidebar-button"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent>
{pinned
? "Unpin this assistant from the sidebar"
: "Pin this assistant to the sidebar"}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button
onClick={(e) => {
e.stopPropagation();
onUnpin(e);
}}
className="group-hover:block hidden absolute right-2"
>
<CircleX size={16} className="text-text-history-sidebar-button" />
</button>
</div>
</div>
);
@@ -205,7 +181,6 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
(
{
liveAssistant,
reset = () => null,
setShowAssistantsModal = () => null,
toggled,
@@ -219,6 +194,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
showShareModal,
showDeleteModal,
showDeleteAllModal,
currentAssistantId,
},
ref: ForwardedRef<HTMLDivElement>
) => {
@@ -377,13 +353,13 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
<SortableAssistant
key={assistant.id === 0 ? "assistant-0" : assistant.id}
assistant={assistant}
active={assistant.id === liveAssistant?.id}
currentAssistantId={currentAssistantId}
onClick={() => {
router.push(
buildChatUrl(searchParams, null, assistant.id)
);
}}
onPinAction={async (e: React.MouseEvent) => {
onUnpin={async (e: React.MouseEvent) => {
e.stopPropagation();
await toggleAssistantPinnedStatus(
pinnedAssistants.map((a) => a.id),
@@ -397,31 +373,6 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
</div>
</SortableContext>
</DndContext>
{!pinnedAssistants.some((a) => a.id === liveAssistant?.id) &&
liveAssistant && (
<div className="w-full mt-1 pr-4">
<SortableAssistant
pinned={false}
assistant={liveAssistant}
active={liveAssistant.id === liveAssistant?.id}
onClick={() => {
router.push(
buildChatUrl(searchParams, null, liveAssistant.id)
);
}}
onPinAction={async (e: React.MouseEvent) => {
e.stopPropagation();
await toggleAssistantPinnedStatus(
[...pinnedAssistants.map((a) => a.id)],
liveAssistant.id,
true
);
await refreshAssistants();
}}
/>
</div>
)}
<div className="w-full px-4">
<button
onClick={() => setShowAssistantsModal(true)}

View File

@@ -21,13 +21,13 @@ import TextView from "@/components/chat/TextView";
import { DocumentResults } from "../../documentSidebar/DocumentResults";
import { Modal } from "@/components/Modal";
import FunctionalHeader from "@/components/chat/Header";
import FixedLogo from "@/components/logo/FixedLogo";
import FixedLogo from "../../../../components/logo/FixedLogo";
import { useRouter } from "next/navigation";
function BackToOnyxButton({
documentSidebarVisible,
documentSidebarToggled,
}: {
documentSidebarVisible: boolean;
documentSidebarToggled: boolean;
}) {
const router = useRouter();
const enterpriseSettings = useContext(SettingsContext)?.enterpriseSettings;
@@ -47,7 +47,7 @@ function BackToOnyxButton({
transition-all
duration-300
ease-in-out
${documentSidebarVisible ? "w-[400px]" : "w-[0px]"}
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
></div>
</div>
@@ -62,7 +62,7 @@ export function SharedChatDisplay({
persona: Persona;
}) {
const settings = useContext(SettingsContext);
const [documentSidebarVisible, setDocumentSidebarVisible] = useState(false);
const [documentSidebarToggled, setDocumentSidebarToggled] = useState(false);
const [selectedMessageForDocDisplay, setSelectedMessageForDocDisplay] =
useState<number | null>(null);
const [isReady, setIsReady] = useState(false);
@@ -70,7 +70,7 @@ export function SharedChatDisplay({
useState<OnyxDocument | null>(null);
const toggleDocumentSidebar = () => {
setDocumentSidebarVisible(!documentSidebarVisible);
setDocumentSidebarToggled(!documentSidebarToggled);
};
useEffect(() => {
@@ -85,7 +85,7 @@ export function SharedChatDisplay({
Did not find a shared chat with the specified ID.
</Callout>
</div>
<BackToOnyxButton documentSidebarVisible={documentSidebarVisible} />
<BackToOnyxButton documentSidebarToggled={documentSidebarToggled} />
</div>
);
}
@@ -102,7 +102,7 @@ export function SharedChatDisplay({
onClose={() => setPresentingDocument(null)}
/>
)}
{documentSidebarVisible && settings?.isMobile && (
{documentSidebarToggled && settings?.isMobile && (
<div className="md:hidden">
<Modal noPadding noScroll>
<DocumentResults
@@ -117,7 +117,7 @@ export function SharedChatDisplay({
: null
}
toggleDocumentSelection={() => {
setDocumentSidebarVisible(true);
setDocumentSidebarToggled(true);
}}
selectedDocuments={[]}
clearSelectedDocuments={() => {}}
@@ -128,7 +128,7 @@ export function SharedChatDisplay({
setPresentingDocument={setPresentingDocument}
modal={true}
closeSidebar={() => {
setDocumentSidebarVisible(false);
setDocumentSidebarToggled(false);
}}
/>
</Modal>
@@ -158,7 +158,7 @@ export function SharedChatDisplay({
duration-300
ease-in-out
h-full
${documentSidebarVisible ? "w-[400px]" : "w-[0px]"}
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
>
<DocumentResults
@@ -174,7 +174,7 @@ export function SharedChatDisplay({
: null
}
toggleDocumentSelection={() => {
setDocumentSidebarVisible(true);
setDocumentSidebarToggled(true);
}}
clearSelectedDocuments={() => {}}
selectedDocumentTokens={0}
@@ -183,7 +183,7 @@ export function SharedChatDisplay({
isOpen={true}
setPresentingDocument={setPresentingDocument}
closeSidebar={() => {
setDocumentSidebarVisible(false);
setDocumentSidebarToggled(false);
}}
selectedDocuments={[]}
/>
@@ -214,7 +214,7 @@ export function SharedChatDisplay({
bg-gradient-to-b via-50% z-[-1] from-background via-background to-background/10 flex
transition-all duration-300 ease-in-out
${
documentSidebarVisible
documentSidebarToggled
? "left-[200px] transform -translate-x-[calc(50%+100px)]"
: "left-1/2 transform -translate-x-1/2"
}
@@ -263,8 +263,6 @@ export function SharedChatDisplay({
) {
return (
<AgenticMessage
isStreamingQuestions={false}
isGenerating={false}
shared
key={message.messageId}
isImprovement={message.isImprovement}
@@ -313,13 +311,13 @@ export function SharedChatDisplay({
selectedDocuments={[]}
toggleDocumentSelection={() => {
if (
!documentSidebarVisible ||
(documentSidebarVisible &&
!documentSidebarToggled ||
(documentSidebarToggled &&
selectedMessageForDocDisplay ===
message.messageId)
) {
setDocumentSidebarVisible(
!documentSidebarVisible
setDocumentSidebarToggled(
!documentSidebarToggled
);
}
setSelectedMessageForDocDisplay(
@@ -353,13 +351,13 @@ export function SharedChatDisplay({
selectedDocuments={[]}
toggleDocumentSelection={() => {
if (
!documentSidebarVisible ||
(documentSidebarVisible &&
!documentSidebarToggled ||
(documentSidebarToggled &&
selectedMessageForDocDisplay ===
message.messageId)
) {
setDocumentSidebarVisible(
!documentSidebarVisible
setDocumentSidebarToggled(
!documentSidebarToggled
);
}
setSelectedMessageForDocDisplay(
@@ -375,8 +373,6 @@ export function SharedChatDisplay({
<div key={message.messageId}>
<AgenticMessage
shared
isStreamingQuestions={false}
isGenerating={false}
subQuestions={message.sub_questions || []}
currentPersona={persona}
messageId={message.messageId}
@@ -408,7 +404,7 @@ export function SharedChatDisplay({
transition-all
duration-300
ease-in-out
${documentSidebarVisible ? "w-[400px]" : "w-[0px]"}
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
></div>
)}
@@ -416,7 +412,7 @@ export function SharedChatDisplay({
</div>
<FixedLogo backgroundToggled={false} />
<BackToOnyxButton documentSidebarVisible={documentSidebarVisible} />
<BackToOnyxButton documentSidebarToggled={documentSidebarToggled} />
</div>
</div>
</>

View File

@@ -1,65 +0,0 @@
"use client";
import React, { ReactNode, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
export default function FunctionalWrapper({
sidebarInitiallyVisible,
content,
}: {
content: (
sidebarVisible: boolean,
toggle: (toggled?: boolean) => void
) => ReactNode;
sidebarInitiallyVisible: boolean;
}) {
const router = useRouter();
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.metaKey || event.ctrlKey) {
const newPage = event.shiftKey;
switch (event.key.toLowerCase()) {
case "d":
event.preventDefault();
if (newPage) {
window.open("/chat", "_blank");
} else {
router.push("/chat");
}
break;
case "s":
event.preventDefault();
if (newPage) {
window.open("/search", "_blank");
} else {
router.push("/search");
}
break;
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [router]);
const [sidebarVisible, setSidebarVisible] = useState(sidebarInitiallyVisible);
const toggle = (value?: boolean) => {
setSidebarVisible((sidebarVisible) =>
value !== undefined ? value : !sidebarVisible
);
};
return (
<>
{" "}
<div className="overscroll-y-contain overflow-y-scroll overscroll-contain left-0 top-0 w-full h-svh">
{content(sidebarVisible, toggle)}
</div>
</>
);
}

View File

@@ -1,73 +0,0 @@
import React from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { CircleAlert, Info } from "lucide-react";
import { BillingInformation, BillingStatus } from "./interfaces";
export function BillingAlerts({
billingInformation,
}: {
billingInformation: BillingInformation;
}) {
const isTrialing = billingInformation.status === BillingStatus.TRIALING;
const isCancelled = billingInformation.cancel_at_period_end;
const isExpired =
new Date(billingInformation.current_period_end) < new Date();
const noPaymentMethod = !billingInformation.payment_method_enabled;
const messages: string[] = [];
if (isExpired) {
messages.push(
"Your subscription has expired. Please resubscribe to continue using the service."
);
}
if (isCancelled && !isExpired) {
messages.push(
`Your subscription will cancel on ${new Date(
billingInformation.current_period_end
).toLocaleDateString()}. You can resubscribe before this date to remain uninterrupted.`
);
}
if (isTrialing) {
messages.push(
`You're currently on a trial. Your trial ends on ${
billingInformation.trial_end
? new Date(billingInformation.trial_end).toLocaleDateString()
: "N/A"
}.`
);
}
if (noPaymentMethod) {
messages.push(
"You currently have no payment method on file. Please add one to avoid service interruption."
);
}
const variant = isExpired || noPaymentMethod ? "destructive" : "default";
if (messages.length === 0) return null;
return (
<Alert variant={variant}>
<AlertTitle className="flex items-center space-x-2">
{variant === "destructive" ? (
<CircleAlert className="h-4 w-4" />
) : (
<Info className="h-4 w-4" />
)}
<span>
{variant === "destructive"
? "Important Subscription Notice"
: "Subscription Notice"}
</span>
</AlertTitle>
<AlertDescription>
<ul className="list-disc list-inside space-y-1 mt-2">
{messages.map((msg, idx) => (
<li key={idx}>{msg}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}

View File

@@ -1,21 +1,18 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { usePopup } from "@/components/admin/connectors/Popup";
import { fetchCustomerPortal, useBillingInformation } from "./utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { CreditCard, ArrowFatUp } from "@phosphor-icons/react";
import { SubscriptionSummary } from "./SubscriptionSummary";
import { BillingAlerts } from "./BillingAlerts";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { loadStripe } from "@stripe/stripe-js";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SettingsIcon } from "@/components/icons/icons";
import {
updateSubscriptionQuantity,
fetchCustomerPortal,
statusToDisplay,
useBillingInformation,
} from "./utils";
import { useEffect } from "react";
export default function BillingInformationPage() {
const router = useRouter();
@@ -27,6 +24,9 @@ export default function BillingInformationPage() {
isLoading,
} = useBillingInformation();
if (error) {
console.error("Failed to fetch billing information:", error);
}
useEffect(() => {
const url = new URL(window.location.href);
if (url.searchParams.has("session_id")) {
@@ -35,33 +35,22 @@ export default function BillingInformationPage() {
"Congratulations! Your subscription has been updated successfully.",
type: "success",
});
// Remove the session_id from the URL
url.searchParams.delete("session_id");
window.history.replaceState({}, "", url.toString());
// You might want to refresh the billing information here
// by calling an API endpoint to get the latest data
}
}, [setPopup]);
if (isLoading) {
return <div className="text-center py-8">Loading...</div>;
}
if (error) {
console.error("Failed to fetch billing information:", error);
return (
<div className="text-center py-8 text-red-500">
Error loading billing information. Please try again later.
</div>
);
}
if (!billingInformation) {
return (
<div className="text-center py-8">No billing information available.</div>
);
return <div>Loading...</div>;
}
const handleManageSubscription = async () => {
try {
const response = await fetchCustomerPortal();
if (!response.ok) {
const errorData = await response.json();
throw new Error(
@@ -72,9 +61,11 @@ export default function BillingInformationPage() {
}
const { url } = await response.json();
if (!url) {
throw new Error("No portal URL returned from the server");
}
router.push(url);
} catch (error) {
console.error("Error creating customer portal session:", error);
@@ -84,39 +75,138 @@ export default function BillingInformationPage() {
});
}
};
if (!billingInformation) {
return <div>Loading...</div>;
}
return (
<div className="space-y-8">
{popup}
<Card className="shadow-md">
<CardHeader>
<CardTitle className="text-2xl font-bold flex items-center">
<CreditCard className="mr-4 text-muted-foreground" size={24} />
Subscription Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<SubscriptionSummary billingInformation={billingInformation} />
<BillingAlerts billingInformation={billingInformation} />
</CardContent>
</Card>
<div className="bg-background-50 rounded-lg p-8 border border-background-200">
{popup}
<Card className="shadow-md">
<CardHeader>
<CardTitle className="text-xl font-semibold">
Manage Subscription
</CardTitle>
<CardDescription>
View your plan, update payment, or change subscription
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={handleManageSubscription} className="w-full">
<ArrowFatUp className="mr-2" size={16} />
Manage Subscription
</Button>
</CardContent>
</Card>
<h2 className="text-2xl font-bold mb-6 text-text-800 flex items-center">
{/* <CreditCard className="mr-4 text-text-600" size={24} /> */}
Subscription Details
</h2>
<div className="space-y-4">
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">Seats</p>
<p className="text-sm text-text-500">
Number of licensed users
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{billingInformation.seats}
</p>
</div>
</div>
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">
Subscription Status
</p>
<p className="text-sm text-text-500">
Current state of your subscription
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{statusToDisplay(billingInformation.subscription_status)}
</p>
</div>
</div>
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">
Billing Start
</p>
<p className="text-sm text-text-500">
Start date of current billing cycle
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{new Date(
billingInformation.billing_start
).toLocaleDateString()}
</p>
</div>
</div>
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center">
<div>
<p className="text-lg font-medium text-text-700">Billing End</p>
<p className="text-sm text-text-500">
End date of current billing cycle
</p>
</div>
<p className="text-xl font-semibold text-text-900">
{new Date(billingInformation.billing_end).toLocaleDateString()}
</p>
</div>
</div>
</div>
{!billingInformation.payment_method_enabled && (
<div className="mt-4 p-4 bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700">
<p className="font-bold">Notice:</p>
<p>
You&apos;ll need to add a payment method before your trial ends to
continue using the service.
</p>
</div>
)}
{billingInformation.subscription_status === "trialing" ? (
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md mt-8">
<p className="text-lg font-medium text-text-700">
No cap on users during trial
</p>
</div>
) : (
<div className="flex items-center space-x-4 mt-8">
<div className="flex items-center space-x-4">
<p className="text-lg font-medium text-text-700">
Current Seats:
</p>
<p className="text-xl font-semibold text-text-900">
{billingInformation.seats}
</p>
</div>
<p className="text-sm text-text-500">
Seats automatically update based on adding, removing, or inviting
users.
</p>
</div>
)}
</div>
<div className="bg-white p-5 rounded-lg shadow-sm transition-all duration-300 hover:shadow-md">
<div className="flex justify-between items-center mb-4">
<div>
<p className="text-lg font-medium text-text-700">
Manage Subscription
</p>
<p className="text-sm text-text-500">
View your plan, update payment, or change subscription
</p>
</div>
<SettingsIcon className="text-text-600" size={20} />
</div>
<button
onClick={handleManageSubscription}
className="bg-background-600 text-white px-4 py-2 rounded-md hover:bg-background-700 transition duration-300 ease-in-out focus:outline-none focus:ring-2 focus:ring-text-500 focus:ring-opacity-50 font-medium shadow-sm text-sm flex items-center justify-center"
>
<ArrowFatUp className="mr-2" size={16} />
Manage Subscription
</button>
</div>
</div>
);
}

View File

@@ -1,17 +0,0 @@
import React from "react";
interface InfoItemProps {
title: string;
value: string;
}
export function InfoItem({ title, value }: InfoItemProps) {
return (
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm font-medium text-muted-foreground mb-1">{title}</p>
<p className="text-lg font-semibold text-foreground dark:text-white">
{value}
</p>
</div>
);
}

View File

@@ -1,33 +0,0 @@
import React from "react";
import { InfoItem } from "./InfoItem";
import { statusToDisplay } from "./utils";
interface SubscriptionSummaryProps {
billingInformation: any;
}
export function SubscriptionSummary({
billingInformation,
}: SubscriptionSummaryProps) {
return (
<div className="grid grid-cols-2 gap-4">
<InfoItem
title="Subscription Status"
value={statusToDisplay(billingInformation.status)}
/>
<InfoItem title="Seats" value={billingInformation.seats.toString()} />
<InfoItem
title="Billing Start"
value={new Date(
billingInformation.current_period_start
).toLocaleDateString()}
/>
<InfoItem
title="Billing End"
value={new Date(
billingInformation.current_period_end
).toLocaleDateString()}
/>
</div>
);
}

View File

@@ -1,19 +0,0 @@
export interface BillingInformation {
status: string;
trial_end: Date | null;
current_period_end: Date;
payment_method_enabled: boolean;
cancel_at_period_end: boolean;
current_period_start: Date;
number_of_seats: number;
canceled_at: Date | null;
trial_start: Date | null;
seats: number;
}
export enum BillingStatus {
TRIALING = "trialing",
ACTIVE = "active",
CANCELLED = "cancelled",
EXPIRED = "expired",
}

View File

@@ -3,16 +3,10 @@ import BillingInformationPage from "./BillingInformationPage";
import { MdOutlineCreditCard } from "react-icons/md";
export interface BillingInformation {
stripe_subscription_id: string;
status: string;
current_period_start: Date;
current_period_end: Date;
number_of_seats: number;
cancel_at_period_end: boolean;
canceled_at: Date | null;
trial_start: Date | null;
trial_end: Date | null;
seats: number;
subscription_status: string;
billing_start: Date;
billing_end: Date;
payment_method_enabled: boolean;
}

View File

@@ -35,16 +35,9 @@ export const statusToDisplay = (status: string) => {
export const useBillingInformation = () => {
const url = "/api/tenants/billing-information";
const swrResponse = useSWR<BillingInformation>(url, async (url: string) => {
const res = await fetch(url);
if (!res.ok) {
const errorData = await res.json();
throw new Error(
errorData.message || "Failed to fetch billing information"
);
}
return res.json();
});
const swrResponse = useSWR<BillingInformation>(url, (url: string) =>
fetch(url).then((res) => res.json())
);
return {
...swrResponse,

View File

@@ -26,7 +26,7 @@ export default async function GalleryPage(props: {
chatSessions,
folders,
openedFolders,
sidebarInitiallyVisible,
toggleSidebar,
shouldShowWelcomeModal,
availableSources,
ccPairs,
@@ -43,8 +43,8 @@ export default async function GalleryPage(props: {
value={{
inputPrompts,
chatSessions,
toggledSidebar: toggleSidebar,
proSearchToggled,
sidebarInitiallyVisible,
availableSources,
ccPairs,
documentSets,

View File

@@ -70,7 +70,6 @@
--accent-foreground: 0 0% 9%;
--accent-background: #f0eee8;
--accent-background-hovered: #e5e3dd;
--accent-background-selected: #eae8e2;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--ring: 0 0% 3.9%;
@@ -121,7 +120,6 @@
/* agent references */
--agent-sidebar: #be5d0e;
--agent-hovered: #d16b10;
--agent: #e47011;
--lighter-agent: #f59e0b;
@@ -249,7 +247,6 @@
--accent-background: #333333;
--accent-background-hovered: #2f2f2f;
--accent-background-selected: #222222;
--text-darker: #f0f0f0;
@@ -342,7 +339,6 @@
/* Agent references */
--agent-sidebar: #be5d0e; /* You can keep or lighten/darken if desired */
--agent-hovered: #f07c13;
--agent: #e47011;
--lighter-agent: #f59e0b;
}
@@ -660,11 +656,6 @@ ul > li > p {
color: white;
}
.dark li,
.dark h1,
.dark h2,
.dark h3,
.dark h4,
.dark h5 {
.dark li {
color: #e5e5e5;
}

View File

@@ -13,10 +13,7 @@ import {
import { Metadata } from "next";
import { buildClientUrl } from "@/lib/utilsSS";
import { Inter } from "next/font/google";
import {
EnterpriseSettings,
ApplicationStatus,
} from "./admin/settings/interfaces";
import { EnterpriseSettings, GatingType } from "./admin/settings/interfaces";
import { fetchAssistantData } from "@/lib/chat/fetchAssistantdata";
import { AppProvider } from "@/components/context/AppProvider";
import { PHProvider } from "./providers";
@@ -31,7 +28,6 @@ import { WebVitals } from "./web-vitals";
import { ThemeProvider } from "next-themes";
import CloudError from "@/components/errorPages/CloudErrorPage";
import Error from "@/components/errorPages/ErrorPage";
import AccessRestrictedPage from "@/components/errorPages/AccessRestrictedPage";
const inter = Inter({
subsets: ["latin"],
@@ -79,7 +75,7 @@ export default async function RootLayout({
]);
const productGating =
combinedSettings?.settings.application_status ?? ApplicationStatus.ACTIVE;
combinedSettings?.settings.product_gating ?? GatingType.NONE;
const getPageContent = async (content: React.ReactNode) => (
<html
@@ -134,16 +130,40 @@ export default async function RootLayout({
</html>
);
if (productGating === ApplicationStatus.GATED_ACCESS) {
return getPageContent(<AccessRestrictedPage />);
}
if (!combinedSettings) {
return getPageContent(
NEXT_PUBLIC_CLOUD_ENABLED ? <CloudError /> : <Error />
);
}
if (productGating === GatingType.FULL) {
return getPageContent(
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="mb-2 flex items-center max-w-[175px]">
<LogoType />
</div>
<CardSection className="w-full max-w-md">
<h1 className="text-2xl font-bold mb-4 text-error">
Access Restricted
</h1>
<p className="text-text-500 mb-4">
We regret to inform you that your access to Onyx has been
temporarily suspended due to a lapse in your subscription.
</p>
<p className="text-text-500 mb-4">
To reinstate your access and continue benefiting from Onyx&apos;s
powerful features, please update your payment information.
</p>
<p className="text-text-500">
If you&apos;re an admin, you can resolve this by visiting the
billing section. For other users, please reach out to your
administrator to address this matter.
</p>
</CardSection>
</div>
);
}
const { assistants, hasAnyConnectors, hasImageCompatibleModel } =
assistantsData;

View File

@@ -74,7 +74,7 @@ export function IndexAttemptStatus({
);
} else if (status === "not_started") {
badge = (
<Badge variant="not_started" icon={FiClock}>
<Badge variant="purple" icon={FiClock}>
Scheduled
</Badge>
);

View File

@@ -33,9 +33,6 @@ import { MdOutlineCreditCard } from "react-icons/md";
import { UserSettingsModal } from "@/app/chat/modal/UserSettingsModal";
import { usePopup } from "./connectors/Popup";
import { useChatContext } from "../context/ChatContext";
import { ApplicationStatus } from "@/app/admin/settings/interfaces";
import Link from "next/link";
import { Button } from "../ui/button";
export function ClientLayout({
user,
@@ -77,23 +74,6 @@ export function ClientLayout({
defaultModel={user?.preferences?.default_model!}
/>
)}
{settings?.settings.application_status ===
ApplicationStatus.PAYMENT_REMINDER && (
<div className="fixed top-2 left-1/2 transform -translate-x-1/2 bg-amber-400 dark:bg-amber-500 text-gray-900 dark:text-gray-100 p-4 rounded-lg shadow-lg z-50 max-w-md text-center">
<strong className="font-bold">Warning:</strong> Your trial ends in
less than 2 days and no payment method has been added.
<div className="mt-2">
<Link href="/admin/billing">
<Button
variant="default"
className="bg-amber-600 hover:bg-amber-700 text-white"
>
Update Billing Information
</Button>
</Link>
</div>
</div>
)}
<div className="default-scrollbar flex-none text-text-settings-sidebar bg-background-sidebar dark:bg-[#000] w-[250px] overflow-x-hidden z-20 pt-2 pb-8 h-full border-r border-border dark:border-none miniscroll overflow-auto">
<AdminSidebar

View File

@@ -57,7 +57,7 @@ export async function Layout({ children }: { children: React.ReactNode }) {
llmProviders,
folders,
openedFolders,
sidebarInitiallyVisible,
toggleSidebar,
defaultAssistantId,
shouldShowWelcomeModal,
ccPairs,
@@ -71,7 +71,7 @@ export async function Layout({ children }: { children: React.ReactNode }) {
inputPrompts,
chatSessions,
proSearchToggled,
sidebarInitiallyVisible,
toggledSidebar: toggleSidebar,
availableSources,
ccPairs,
documentSets,

View File

@@ -4,7 +4,7 @@ import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
import useSWRMutation from "swr/mutation";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
const DeleteUserButton = ({
user,
@@ -38,7 +38,7 @@ const DeleteUserButton = ({
return (
<>
{showDeleteModal && (
<ConfirmEntityModal
<DeleteEntityModal
entityType="user"
entityName={user.email}
onClose={() => setShowDeleteModal(false)}

View File

@@ -4,7 +4,7 @@ import userMutationFetcher from "@/lib/admin/users/userMutationFetcher";
import useSWRMutation from "swr/mutation";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { useRouter } from "next/navigation";
export const LeaveOrganizationButton = ({
@@ -46,9 +46,8 @@ export const LeaveOrganizationButton = ({
return (
<>
{showLeaveModal && (
<ConfirmEntityModal
variant="action"
actionButtonText="Leave"
<DeleteEntityModal
deleteButtonText="Leave"
entityType="organization"
entityName="your organization"
onClose={() => setShowLeaveModal(false)}

View File

@@ -48,6 +48,7 @@ export function StarterMessages({
flex-col gap-2 rounded-md
text-input-text hover:text-text
border
dark:bg-transparent
dark:border-neutral-700
dark:hover:bg-background-150
@@ -57,16 +58,11 @@ export function StarterMessages({
text-[15px] shadow-xs transition
enabled:hover:bg-background-dark/75
disabled:cursor-not-allowed
overflow-hidden
break-words
truncate
text-ellipsis
line-clamp-3
`}
style={{ height: "5.6rem" }}
style={{ height: "5.4rem" }}
>
<div className="overflow-hidden text-ellipsis line-clamp-3 pr-1 pb-1">
{starterMessage.name}
</div>
{starterMessage.name}
</button>
</div>
))}

View File

@@ -3,29 +3,27 @@
import React, { ReactNode, useState } from "react";
export default function FunctionalWrapper({
initiallyVisible,
initiallyToggled,
content,
}: {
content: (
sidebarVisible: boolean,
toggle: (visible?: boolean) => void
toggledSidebar: boolean,
toggle: (toggled?: boolean) => void
) => ReactNode;
initiallyVisible?: boolean;
initiallyToggled: boolean;
}) {
const [sidebarVisible, setSidebarVisible] = useState(
initiallyVisible || false
);
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
const toggle = (value?: boolean) => {
setSidebarVisible((sidebarVisible) =>
value !== undefined ? value : !sidebarVisible
setToggledSidebar((toggledSidebar) =>
value !== undefined ? value : !toggledSidebar
);
};
return (
<>
<div className="overscroll-y-contain overflow-y-scroll overscroll-contain left-0 top-0 w-full h-svh">
{content(sidebarVisible, toggle)}
{content(toggledSidebar, toggle)}
</div>
</>
);

View File

@@ -17,7 +17,7 @@ export default function FunctionalHeader({
currentChatSession,
setSharingModalVisible,
toggleSidebar = () => null,
documentSidebarVisible,
documentSidebarToggled,
reset = () => null,
sidebarToggled,
toggleUserSettings,
@@ -31,7 +31,7 @@ export default function FunctionalHeader({
toggleSidebar?: () => void;
toggleUserSettings?: () => void;
hideUserDropdown?: boolean;
documentSidebarVisible?: boolean;
documentSidebarToggled?: boolean;
}) {
const settings = useContext(SettingsContext);
useEffect(() => {
@@ -89,7 +89,6 @@ export default function FunctionalHeader({
duration-300
ease-in-out
h-full
${sidebarToggled ? "w-[250px]" : "w-[0px]"}
`}
/>
@@ -98,19 +97,19 @@ export default function FunctionalHeader({
className={`
absolute
${
documentSidebarVisible &&
documentSidebarToggled &&
sidebarToggled &&
"left-[calc(50%-75px)]"
}
${
documentSidebarVisible && !sidebarToggled
documentSidebarToggled && !sidebarToggled
? "left-[calc(50%-175px)]"
: !documentSidebarVisible && sidebarToggled
: !documentSidebarToggled && sidebarToggled
? "left-[calc(50%+100px)]"
: "left-1/2"
}
${
documentSidebarVisible || sidebarToggled
documentSidebarToggled || sidebarToggled
? "mobile:w-[40vw] max-w-[40vw]"
: "mobile:w-[50vw] max-w-[60vw]"
}
@@ -191,7 +190,7 @@ export default function FunctionalHeader({
duration-300
ease-in-out
h-full
${documentSidebarVisible ? "w-[400px]" : "w-[0px]"}
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
/>
</div>

View File

@@ -1,7 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useRef } from "react";
interface UseSidebarVisibilityProps {
sidebarVisible: boolean;
toggledSidebar: boolean;
sidebarElementRef: React.RefObject<HTMLElement>;
showDocSidebar: boolean;
setShowDocSidebar: Dispatch<SetStateAction<boolean>>;
@@ -11,7 +11,7 @@ interface UseSidebarVisibilityProps {
}
export const useSidebarVisibility = ({
sidebarVisible,
toggledSidebar,
sidebarElementRef,
setShowDocSidebar,
setToggled,
@@ -55,7 +55,7 @@ export const useSidebarVisibility = ({
currentXPosition > 100 &&
showDocSidebar &&
!isWithinSidebar &&
!sidebarVisible
!toggledSidebar
) {
setTimeout(() => {
setShowDocSidebar((showDocSidebar) => {
@@ -88,7 +88,7 @@ export const useSidebarVisibility = ({
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showDocSidebar, sidebarVisible, sidebarElementRef, mobile]);
}, [showDocSidebar, toggledSidebar, sidebarElementRef, mobile]);
return { showDocSidebar };
};

View File

@@ -60,7 +60,7 @@ export const AssistantsProvider: React.FC<{
.map((id) => assistants.find((assistant) => assistant.id === id))
.filter((assistant): assistant is Persona => assistant !== undefined);
} else {
return assistants.filter((a) => a.is_default_persona);
return assistants.filter((a) => a.builtin_persona);
}
});
@@ -71,7 +71,7 @@ export const AssistantsProvider: React.FC<{
.map((id) => assistants.find((assistant) => assistant.id === id))
.filter((assistant): assistant is Persona => assistant !== undefined);
} else {
return assistants.filter((a) => a.is_default_persona);
return assistants.filter((a) => a.builtin_persona);
}
});
}, [user?.preferences?.pinned_assistants, assistants]);

View File

@@ -16,7 +16,7 @@ import { useRouter } from "next/navigation";
interface ChatContextProps {
chatSessions: ChatSession[];
sidebarInitiallyVisible: boolean;
toggledSidebar: boolean;
availableSources: ValidSources[];
ccPairs: CCPairBasicInfo[];
tags: Tag[];

View File

@@ -1,148 +0,0 @@
"use client";
import { FiLock } from "react-icons/fi";
import ErrorPageLayout from "./ErrorPageLayout";
import { fetchCustomerPortal } from "@/app/ee/admin/billing/utils";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { logout } from "@/lib/user";
import { loadStripe } from "@stripe/stripe-js";
import { NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY } from "@/lib/constants";
const fetchResubscriptionSession = async () => {
const response = await fetch("/api/tenants/create-subscription-session", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error("Failed to create resubscription session");
}
return response.json();
};
export default function AccessRestricted() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleManageSubscription = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetchCustomerPortal();
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to create customer portal session: ${
errorData.message || response.statusText
}`
);
}
const { url } = await response.json();
if (!url) {
throw new Error("No portal URL returned from the server");
}
router.push(url);
} catch (error) {
console.error("Error creating customer portal session:", error);
setError("Error opening customer portal. Please try again later.");
} finally {
setIsLoading(false);
}
};
const handleResubscribe = async () => {
setIsLoading(true);
setError(null);
if (!NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
setError("Stripe public key not found");
setIsLoading(false);
return;
}
try {
const { sessionId } = await fetchResubscriptionSession();
const stripe = await loadStripe(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
if (stripe) {
await stripe.redirectToCheckout({ sessionId });
} else {
throw new Error("Stripe failed to load");
}
} catch (error) {
console.error("Error creating resubscription session:", error);
setError("Error opening resubscription page. Please try again later.");
} finally {
setIsLoading(false);
}
};
return (
<ErrorPageLayout>
<h1 className="text-2xl font-semibold flex items-center gap-2 mb-4 text-gray-800 dark:text-gray-200">
<p>Access Restricted</p>
<FiLock className="text-error inline-block" />
</h1>
<div className="space-y-4 text-gray-600 dark:text-gray-300">
<p>
We regret to inform you that your access to Onyx has been temporarily
suspended due to a lapse in your subscription.
</p>
<p>
To reinstate your access and continue benefiting from Onyx&apos;s
powerful features, please update your payment information.
</p>
<p>
If you&apos;re an admin, you can manage your subscription by clicking
the button below. For other users, please reach out to your
administrator to address this matter.
</p>
<div className="flex flex-col space-y-4 sm:flex-row sm:space-y-0 sm:space-x-4">
<Button
onClick={handleResubscribe}
disabled={isLoading}
className="w-full sm:w-auto"
>
{isLoading ? "Loading..." : "Resubscribe"}
</Button>
<Button
variant="outline"
onClick={handleManageSubscription}
disabled={isLoading}
className="w-full sm:w-auto"
>
Manage Existing Subscription
</Button>
<Button
variant="outline"
onClick={async () => {
await logout();
window.location.reload();
}}
className="w-full sm:w-auto"
>
Log out
</Button>
</div>
{error && <p className="text-error">{error}</p>}
<p>
Need help? Join our{" "}
<a
className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
href="https://join.slack.com/t/danswer/shared_invite/zt-1w76msxmd-HJHLe3KNFIAIzk_0dSOKaQ"
target="_blank"
rel="noopener noreferrer"
>
Slack community
</a>{" "}
for support.
</p>
</div>
</ErrorPageLayout>
);
}

View File

@@ -1,67 +0,0 @@
import { Modal } from "../Modal";
import { Button } from "../ui/button";
export const ConfirmEntityModal = ({
onClose,
onSubmit,
entityType,
entityName,
additionalDetails,
actionButtonText,
includeCancelButton = true,
variant = "delete",
}: {
entityType: string;
entityName: string;
onClose: () => void;
onSubmit: () => void;
additionalDetails?: string;
actionButtonText?: string;
includeCancelButton?: boolean;
variant?: "delete" | "action";
}) => {
const isDeleteVariant = variant === "delete";
const defaultButtonText = isDeleteVariant ? "Delete" : "Confirm";
const buttonText = actionButtonText || defaultButtonText;
const getActionText = () => {
if (isDeleteVariant) {
return "delete";
}
switch (entityType) {
case "Default Persona":
return "change the default status of";
default:
return "modify";
}
};
return (
<Modal width="rounded max-w-sm w-full" onOutsideClick={onClose}>
<>
<div className="flex mb-4">
<h2 className="my-auto text-2xl font-bold">
{buttonText} {entityType}
</h2>
</div>
<p className="mb-4">
Are you sure you want to {getActionText()} <b>{entityName}</b>?
</p>
{additionalDetails && <p className="mb-4">{additionalDetails}</p>}
<div className="flex justify-end gap-2">
{includeCancelButton && (
<Button onClick={onClose} variant="outline">
Cancel
</Button>
)}
<Button
onClick={onSubmit}
variant={isDeleteVariant ? "destructive" : "default"}
>
{buttonText}
</Button>
</div>
</>
</Modal>
);
};

View File

@@ -0,0 +1,51 @@
import { FiTrash, FiX } from "react-icons/fi";
import { BasicClickable } from "@/components/BasicClickable";
import { Modal } from "../Modal";
import { Button } from "../ui/button";
export const DeleteEntityModal = ({
onClose,
onSubmit,
entityType,
entityName,
additionalDetails,
deleteButtonText,
includeCancelButton = true,
}: {
entityType: string;
entityName: string;
onClose: () => void;
onSubmit: () => void;
additionalDetails?: string;
deleteButtonText?: string;
includeCancelButton?: boolean;
}) => {
return (
<Modal width="rounded max-w-sm w-full" onOutsideClick={onClose}>
<>
<div className="flex mb-4">
<h2 className="my-auto text-2xl font-bold">
{deleteButtonText || `Delete`} {entityType}
</h2>
</div>
<p className="mb-4">
Are you sure you want to {deleteButtonText || "delete"}{" "}
<b>{entityName}</b>?
</p>
{additionalDetails && <p className="mb-4">{additionalDetails}</p>}
<div className="flex items-end justify-end">
<div className="flex gap-x-2">
{includeCancelButton && (
<Button variant="outline" onClick={onClose}>
<div className="flex mx-2">Cancel</div>
</Button>
)}
<Button size="sm" variant="destructive" onClick={onSubmit}>
<div className="flex mx-2">{deleteButtonText || "Delete"}</div>
</Button>
</div>
</div>
</>
</Modal>
);
};

View File

@@ -65,7 +65,7 @@ export function Citation({
</span>
</TooltipTrigger>
<TooltipContent
className="border border-neutral-300 hover:text-neutral-900 bg-neutral-100 dark:!bg-[#000] dark:border-neutral-700"
className="dark:border dark:!bg-[#000] border-neutral-700"
width="mb-2 max-w-lg"
>
{document_info?.document ? (

View File

@@ -1,7 +1,7 @@
import {
CombinedSettings,
EnterpriseSettings,
ApplicationStatus,
GatingType,
Settings,
} from "@/app/admin/settings/interfaces";
import {
@@ -45,7 +45,7 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
if (results[0].status === 403 || results[0].status === 401) {
settings = {
auto_scroll: true,
application_status: ApplicationStatus.ACTIVE,
product_gating: GatingType.NONE,
gpu_enabled: false,
maximum_chat_retention_days: null,
notifications: [],

View File

@@ -37,7 +37,7 @@ const badgeVariants = cva(
destructive:
"border-red-200 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900 dark:text-neutral-50",
not_started:
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-900 dark:text-purple-100",
"border-neutral-200 bg-neutral-50 text-neutral-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100",
},
},
defaultVariants: {

View File

@@ -42,7 +42,7 @@ interface FetchChatDataResult {
folders: Folder[];
openedFolders: Record<string, boolean>;
defaultAssistantId?: number;
sidebarInitiallyVisible: boolean;
toggleSidebar: boolean;
finalDocumentSidebarInitialWidth?: number;
shouldShowWelcomeModal: boolean;
inputPrompts: InputPrompt[];
@@ -182,7 +182,7 @@ export async function fetchChatData(searchParams: {
"true";
// IF user is an anoymous user, we don't want to show the sidebar (they have no access to chat history)
const sidebarInitiallyVisible =
const toggleSidebar =
!user?.is_anonymous_user &&
(sidebarToggled
? sidebarToggled.value.toLocaleLowerCase() == "true" || false
@@ -230,7 +230,7 @@ export async function fetchChatData(searchParams: {
openedFolders,
defaultAssistantId,
finalDocumentSidebarInitialWidth,
sidebarInitiallyVisible,
toggleSidebar,
shouldShowWelcomeModal,
inputPrompts,
proSearchToggled,

View File

@@ -1115,24 +1115,7 @@ For example, specifying .*-support.* as a "channel" will cause the connector to
optional: false,
},
],
advanced_values: [
{
type: "text",
label: "View ID",
name: "view_id",
optional: true,
description:
"If you need to link to a specific View, put that ID here e.g. viwVUEJjWPd8XYjh8.",
},
{
type: "text",
label: "Share ID",
name: "share_id",
optional: true,
description:
"If you need to link to a specific Share, put that ID here e.g. shrkfjEzDmLaDtK83.",
},
],
advanced_values: [],
overrideDefaultFreq: 60 * 60 * 24,
},
};

View File

@@ -91,6 +91,3 @@ export const NEXT_PUBLIC_ENABLE_CHROME_EXTENSION =
export const NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK =
process.env.NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK?.toLowerCase() ===
"true";
export const NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY =
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;

View File

@@ -31,7 +31,3 @@ export const LocalStorageKeys = {
SHOW_SHORTCUTS: "showShortcuts",
USE_ONYX_AS_NEW_TAB: "useOnyxAsDefaultNewTab",
};
export const SEARCH_PARAMS = {
DEFAULT_SIDEBAR_OFF: "defaultSidebarOff",
};

View File

@@ -76,7 +76,6 @@ export interface StreamStopInfo {
stop_reason: StreamStopReason;
level?: number;
level_question_num?: number;
stream_type?: "sub_answer" | "sub_questions" | "main_answer";
}
export interface ErrorMessagePacket {

View File

@@ -108,7 +108,6 @@ module.exports = {
"input-option-hover": "var(--input-option-hover)",
"accent-background": "var(--accent-background)",
"accent-background-hovered": "var(--accent-background-hovered)",
"accent-background-selected": "var(--accent-background-selected)",
"background-dark": "var(--off-white)",
"background-100": "var(--neutral-100-border-light)",
"background-125": "var(--neutral-125)",
@@ -263,7 +262,7 @@ module.exports = {
"agent-sidebar": "var(--agent-sidebar)",
agent: "var(--agent)",
"lighter-agent": "var(--lighter-agent)",
"agent-hovered": "var(--agent-hovered)",
// hover
"hover-light": "var(--hover-light)",
"hover-lightish": "var(--neutral-125)",