Compare commits

..

24 Commits

Author SHA1 Message Date
pablodanswer
547fefb306 nit 2025-02-13 15:10:29 -08:00
pablodanswer
2c36dd162d update 2025-02-13 14:55:53 -08:00
pablodanswer
e0f1ca974e quick nit 2025-02-13 14:55:53 -08:00
pablodanswer
737c6118a4 reduce errors in workers 2025-02-13 14:55:53 -08:00
Yuhong Sun
c87261cda7 Fix edge case with run functions in parallel 2025-02-12 17:57:39 -08:00
pablonyx
e030b0a6fc Address (#3955) 2025-02-12 13:53:13 -08:00
Yuhong Sun
61136975ad Don't build model server every night (#3973) 2025-02-12 13:08:05 -08:00
Weves
0c74bbf9ed Clean illegal chars in metadata 2025-02-12 11:49:16 -08:00
pablonyx
12b2126e69 Update assistants visibility, minor UX, .. (#3965)
* update assistant logic

* quick nit

* k

* fix "featured" logic

* Small tweaks

* k

---------

Co-authored-by: Weves <chrisweaver101@gmail.com>
2025-02-12 00:43:20 +00:00
Chris Weaver
037943c6ff Support share/view IDs for Airtable (#3967) 2025-02-11 16:19:38 -08:00
pablonyx
f9485b1325 Ensure sidepanel defaults sidebar off (#3844)
* ensure sidepanel defaults sidepanel off

* address comment

* reformat

* initial visible
2025-02-11 22:22:56 +00:00
rkuo-danswer
552a0630fe Merge pull request #3948 from onyx-dot-app/feature/beat_rtvar
refactoring and update multiplier in real time
2025-02-11 14:05:14 -08:00
Richard Kuo (Danswer)
5bf520d8b8 comments 2025-02-11 14:04:49 -08:00
Weves
7dc5a77946 Improve starter message splitting 2025-02-11 11:10:13 -08:00
rkuo-danswer
03abd4a1bc Merge pull request #3938 from onyx-dot-app/feature/model_server_logs
improve gpu detection functions and logging in model server
2025-02-11 09:43:25 -08:00
Richard Kuo (Danswer)
16d6d708f6 update logging 2025-02-11 09:15:39 -08:00
Richard Kuo
9740ed32b5 fix reading redis values as floats 2025-02-10 20:48:55 -08:00
rkuo-danswer
b56877cc2e Bugfix/dedupe ids (#3952)
* dedupe make_private_persona and update test

* add comment

* comments, and just have duplicate user id's for the test instead of modifying edit

* found the magic word

---------

Co-authored-by: Richard Kuo (Danswer) <rkuo@onyx.app>
2025-02-11 02:27:55 +00:00
pablodanswer
da5c83a96d k 2025-02-10 17:45:00 -08:00
Weves
818225c60e Fix starter message overflow 2025-02-10 17:17:31 -08:00
Richard Kuo (Danswer)
5a4d007cf9 comments 2025-02-10 15:03:59 -08:00
Richard Kuo (Danswer)
5e32f9d922 refactoring and update multiplier in real time 2025-02-10 11:20:38 -08:00
Richard Kuo (Danswer)
fb931ee4de fixes 2025-02-07 17:28:17 -08:00
Richard Kuo (Danswer)
bc2c56dfb6 improve gpu detection functions and logging in model server 2025-02-07 16:59:02 -08:00
74 changed files with 1549 additions and 760 deletions

View File

@@ -4,6 +4,9 @@ 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

@@ -0,0 +1,32 @@
"""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,42 +3,44 @@ from typing import Any
from onyx.background.celery.tasks.beat_schedule import BEAT_EXPIRES_DEFAULT
from onyx.background.celery.tasks.beat_schedule import (
cloud_tasks_to_schedule as base_cloud_tasks_to_schedule,
beat_system_tasks as base_beat_system_tasks,
)
from onyx.background.celery.tasks.beat_schedule import (
tasks_to_schedule as base_tasks_to_schedule,
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,
)
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_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,
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,
},
},
"kwargs": {
"task_name": OnyxCeleryTask.AUTOGENERATE_USAGE_REPORT_TASK,
{
"name": "check-ttl-management",
"task": OnyxCeleryTask.CHECK_TTL_MANAGEMENT_TASK,
"schedule": timedelta(hours=1),
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
},
},
},
{
"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] = []
@@ -65,9 +67,14 @@ if not MULTI_TENANT:
]
def get_cloud_tasks_to_schedule() -> list[dict[str, Any]]:
return ee_cloud_tasks_to_schedule + base_cloud_tasks_to_schedule
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_tasks_to_schedule() -> list[dict[str, Any]]:
return ee_tasks_to_schedule + base_tasks_to_schedule
return ee_tasks_to_schedule + base_get_tasks_to_schedule()

View File

@@ -15,6 +15,9 @@ 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")
@@ -23,19 +26,22 @@ def make_persona_private(
).delete(synchronize_session="fetch")
if user_ids:
for user_uuid in user_ids:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_uuid))
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))
create_notification(
user_id=user_uuid,
user_id=user_id,
notif_type=NotificationType.PERSONA_SHARED,
db_session=db_session,
additional_data=PersonaSharedNotificationData(
persona_id=persona_id,
).model_dump(),
)
if group_ids:
for group_id in group_ids:
group_ids_set = set(group_ids)
for group_id in group_ids_set:
db_session.add(
Persona__UserGroup(persona_id=persona_id, user_group_id=group_id)
)

View File

@@ -28,3 +28,9 @@ 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,6 +12,7 @@ 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
@@ -320,6 +321,7 @@ 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")
@@ -373,8 +375,11 @@ async def embed_text(
elapsed = time.monotonic() - start
logger.info(
f"Successfully embedded {len(texts)} texts with {total_chars} total characters "
f"with provider {provider_type} in {elapsed:.2f}"
f"event=embedding_provider "
f"texts={len(texts)} "
f"chars={total_chars} "
f"provider={provider_type} "
f"elapsed={elapsed:.2f}"
)
elif model_name is not None:
logger.info(
@@ -403,6 +408,14 @@ 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(
@@ -455,8 +468,15 @@ async def litellm_rerank(
@router.post("/bi-encoder-embed")
async def process_embed_request(
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"
) -> EmbedResponse:
if not embed_request.texts:
raise HTTPException(status_code=400, detail="No texts to be embedded")
@@ -484,6 +504,7 @@ 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,6 +16,7 @@ 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
@@ -58,12 +59,10 @@ def _move_files_recursively(source: Path, dest: Path, overwrite: bool = False) -
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
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")
gpu_type = get_gpu_type()
logger.notice(f"Torch GPU Detection: gpu_type={gpu_type}")
app.state.gpu_type = gpu_type
if TEMP_HF_CACHE_PATH.is_dir():
logger.notice("Moving contents of temp_huggingface to huggingface cache.")

View File

@@ -1,7 +1,9 @@
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")
@@ -11,10 +13,7 @@ async def healthcheck() -> Response:
@router.get("/gpu-status")
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"}
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}

View File

@@ -8,6 +8,9 @@ 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()
@@ -58,3 +61,12 @@ 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,41 +1,56 @@
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.utils.logger import setup_logger
from onyx.redis.redis_pool import get_redis_replica_client
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
logger = setup_logger(__name__)
task_logger = get_task_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._reload_interval = timedelta(minutes=2)
self.last_beat_multiplier = CLOUD_BEAT_MULTIPLIER_DEFAULT
self._reload_interval = timedelta(
seconds=DynamicTenantScheduler.RELOAD_INTERVAL
)
self._last_reload = self.app.now() - self._reload_interval
# Let the parent class handle store initialization
self.setup_schedule()
self._try_updating_schedule()
logger.info(f"Set reload interval to {self._reload_interval}")
task_logger.info(
f"DynamicTenantScheduler initialized: reload_interval={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()
@@ -44,36 +59,35 @@ class DynamicTenantScheduler(PersistentScheduler):
self._last_reload is None
or (now - self._last_reload) > self._reload_interval
):
logger.info("Reload interval reached, initiating task update")
task_logger.debug("Reload interval reached, initiating task update")
try:
self._try_updating_schedule()
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)}")
except (AttributeError, KeyError):
task_logger.exception("Failed to process task configuration")
except Exception:
task_logger.exception("Unexpected error updating tasks")
self._last_reload = now
logger.info("Task update completed, reset reload timer")
return retval
def _generate_schedule(
self, tenant_ids: list[str] | list[None]
self, tenant_ids: list[str] | list[None], beat_multiplier: float
) -> 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 only need the single task beat across all tenants
# cloud tasks are system wide and thus only need to be on the beat schedule
# once for 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()
cloud_tasks_to_schedule: list[dict[str, Any]] = get_cloud_tasks_to_schedule(
beat_multiplier
)
for task in cloud_tasks_to_schedule:
task_name = task["name"]
cloud_task = {
@@ -82,11 +96,14 @@ class DynamicTenantScheduler(PersistentScheduler):
"kwargs": task.get("kwargs", {}),
}
if options := task.get("options"):
logger.debug(f"Adding options to task {task_name}: {options}")
task_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"
)
@@ -95,7 +112,7 @@ class DynamicTenantScheduler(PersistentScheduler):
for tenant_id in tenant_ids:
if IGNORED_SYNCING_TENANT_LIST and tenant_id in IGNORED_SYNCING_TENANT_LIST:
logger.info(
task_logger.debug(
f"Skipping tenant {tenant_id} as it is in the ignored syncing list"
)
continue
@@ -104,14 +121,14 @@ class DynamicTenantScheduler(PersistentScheduler):
task_name = task["name"]
tenant_task_name = f"{task['name']}-{tenant_id}"
logger.debug(f"Creating task configuration for {tenant_task_name}")
task_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"):
logger.debug(
task_logger.debug(
f"Adding options to task {tenant_task_name}: {options}"
)
tenant_task["options"] = options
@@ -121,44 +138,57 @@ 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
logger.info("_try_updating_schedule starting")
r = get_redis_replica_client(tenant_id=ONYX_CLOUD_TENANT_ID)
task_logger.debug("_try_updating_schedule starting")
tenant_ids = get_all_tenant_ids()
logger.info(f"Found {len(tenant_ids)} IDs")
task_logger.debug(f"Found {len(tenant_ids)} IDs")
# get current schedule and extract current tenants
current_schedule = self.schedule.items()
# 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
# 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}"
)
# 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
new_schedule = self._generate_schedule(tenant_ids, beat_multiplier)
# 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 the schedule or beat multiplier has changed, update
while True:
if beat_multiplier != self.last_beat_multiplier:
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}")
if not DynamicTenantScheduler._compare_schedules(
current_schedule, new_schedule
):
do_update = True
break
new_schedule = self._generate_schedule(tenant_ids)
break
if DynamicTenantScheduler._compare_schedules(current_schedule, new_schedule):
logger.info(
"_try_updating_schedule: Current schedule is up to date, no changes needed"
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}"
)
return
logger.info(
# schedule needs updating
task_logger.debug(
"Schedule update required",
extra={
"new_tasks": len(new_schedule),
@@ -185,11 +215,19 @@ class DynamicTenantScheduler(PersistentScheduler):
# Ensure changes are persisted
self.sync()
logger.info("_try_updating_schedule: Schedule updated successfully")
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
@staticmethod
def _compare_schedules(schedule1: dict, schedule2: dict) -> bool:
"""Compare schedules to determine if an update is needed.
"""Compare schedules by task name only 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())
@@ -201,7 +239,7 @@ class DynamicTenantScheduler(PersistentScheduler):
@beat_init.connect
def on_beat_init(sender: Any, **kwargs: Any) -> None:
logger.info("beat_init signal received.")
task_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,3 +1,4 @@
import copy
from datetime import timedelta
from typing import Any
@@ -18,7 +19,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_SCHEDULE_MULTIPLIER = 8
CLOUD_BEAT_MULTIPLIER_DEFAULT = 8.0
# tasks that run in either self-hosted on cloud
beat_task_templates: list[dict] = []
@@ -121,7 +122,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_BEAT_SCHEDULE_MULTIPLIER
cloud_task["schedule"] = task_schedule
cloud_task["options"] = {}
cloud_task["options"]["priority"] = OnyxCeleryPriority.HIGHEST
cloud_task["options"]["expires"] = BEAT_EXPIRES_DEFAULT
@@ -141,9 +142,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 filtered
# by the DynamicTenantScheduler
cloud_tasks_to_schedule: list[dict] = [
# 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] = [
# cloud specific tasks
{
"name": f"{ONYX_CLOUD_CELERY_TASK_PREFIX}_check-alembic",
@@ -157,18 +158,45 @@ cloud_tasks_to_schedule: 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 get_cloud_tasks_to_schedule() -> list[dict[str, Any]]:
return cloud_tasks_to_schedule
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_tasks_to_schedule() -> list[dict[str, Any]]:

View File

@@ -459,14 +459,15 @@ def update_external_document_permissions_task(
)
doc_id = document_external_access.doc_id
external_access = document_external_access.external_access
try:
with get_session_with_tenant(tenant_id) as db_session:
# Add the users to the DB if they don't exist
batch_add_ext_perm_user_if_not_exists(
db_session=db_session,
emails=list(external_access.external_user_emails),
continue_on_error=True,
)
# Then we upsert the document's external permissions in postgres
# Then upsert the document's external permissions
created_new_doc = upsert_document_external_perms(
db_session=db_session,
doc_id=doc_id,
@@ -490,11 +491,11 @@ def update_external_document_permissions_task(
f"action=update_permissions "
f"elapsed={elapsed:.2f}"
)
except Exception:
task_logger.exception(
f"Exception in update_external_document_permissions_task: "
f"connector_id={connector_id} "
f"doc_id={doc_id}"
f"connector_id={connector_id} doc_id={doc_id}"
)
return False

View File

@@ -420,6 +420,7 @@ def _collect_sync_metrics(db_session: Session, redis_std: Redis) -> list[Metric]
- Throughput (docs/min) (only if success)
- Raw start/end times for each sync
"""
one_hour_ago = get_db_current_time(db_session) - timedelta(hours=1)
# Get all sync records that ended in the last hour
@@ -587,6 +588,10 @@ def _collect_sync_metrics(db_session: Session, redis_std: Redis) -> list[Metric]
entity = db_session.scalar(
select(UserGroup).where(UserGroup.id == sync_record.entity_id)
)
else:
# Only user groups and document set sync records have
# an associated entity we can use for latency metrics
continue
if entity is None:
task_logger.error(
@@ -777,7 +782,7 @@ def cloud_check_alembic() -> bool | None:
tenant_to_revision[tenant_id] = result_scalar
except Exception:
task_logger.warning(f"Tenant {tenant_id} has no revision!")
task_logger.error(f"Tenant {tenant_id} has no revision!")
tenant_to_revision[tenant_id] = ALEMBIC_NULL_REVISION
# get the total count of each revision

View File

@@ -346,6 +346,9 @@ 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,10 +65,25 @@ 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 = (
@@ -85,6 +100,39 @@ 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,
@@ -110,8 +158,10 @@ class AirtableConnector(LoadConnector):
if field_type == "multipleRecordLinks":
return []
# default link to use for non-attachment fields
default_link = f"https://airtable.com/{base_id}/{table_id}/{record_id}"
# 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
)
if field_type == "multipleAttachments":
attachment_texts: list[tuple[str, str]] = []
@@ -165,17 +215,16 @@ class AirtableConnector(LoadConnector):
extension=file_ext,
)
if attachment_text:
# 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"
)
# 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,
)
attachment_texts.append(
(f"{filename}:\n{attachment_text}", attachment_link)
)

View File

@@ -39,19 +39,6 @@ def get_message_link(
return permalink
def _make_slack_api_call_logged(
call: Callable[..., SlackResponse],
) -> Callable[..., SlackResponse]:
@wraps(call)
def logged_call(**kwargs: Any) -> SlackResponse:
logger.debug(f"Making call to Slack API '{call.__name__}' with args '{kwargs}'")
result = call(**kwargs)
logger.debug(f"Call to Slack API '{call.__name__}' returned '{result}'")
return result
return logged_call
def _make_slack_api_call_paginated(
call: Callable[..., SlackResponse],
) -> Callable[..., Generator[dict[str, Any], None, None]]:
@@ -127,18 +114,14 @@ def make_slack_api_rate_limited(
def make_slack_api_call_w_retries(
call: Callable[..., SlackResponse], **kwargs: Any
) -> SlackResponse:
return basic_retry_wrapper(
make_slack_api_rate_limited(_make_slack_api_call_logged(call))
)(**kwargs)
return basic_retry_wrapper(make_slack_api_rate_limited(call))(**kwargs)
def make_paginated_slack_api_call_w_retries(
call: Callable[..., SlackResponse], **kwargs: Any
) -> Generator[dict[str, Any], None, None]:
return _make_slack_api_call_paginated(
basic_retry_wrapper(
make_slack_api_rate_limited(_make_slack_api_call_logged(call))
)
basic_retry_wrapper(make_slack_api_rate_limited(call))
)(**kwargs)

View File

@@ -204,6 +204,14 @@ 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,
@@ -510,6 +518,7 @@ 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:
@@ -590,6 +599,23 @@ 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

@@ -6,6 +6,7 @@ from fastapi import HTTPException
from fastapi_users.password import PasswordHelper
from sqlalchemy import func
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql import expression
from sqlalchemy.sql.elements import ColumnElement
@@ -274,7 +275,7 @@ def _generate_ext_permissioned_user(email: str) -> User:
def batch_add_ext_perm_user_if_not_exists(
db_session: Session, emails: list[str]
db_session: Session, emails: list[str], continue_on_error: bool = False
) -> list[User]:
lower_emails = [email.lower() for email in emails]
found_users, missing_lower_emails = _get_users_by_emails(db_session, lower_emails)
@@ -283,10 +284,23 @@ def batch_add_ext_perm_user_if_not_exists(
for email in missing_lower_emails:
new_users.append(_generate_ext_permissioned_user(email=email))
db_session.add_all(new_users)
db_session.commit()
return found_users + new_users
try:
db_session.add_all(new_users)
db_session.commit()
except IntegrityError:
db_session.rollback()
if not continue_on_error:
raise
for user in new_users:
try:
db_session.add(user)
db_session.commit()
except IntegrityError:
db_session.rollback()
continue
# Fetch all users again to ensure we have the most up-to-date list
all_users, _ = _get_users_by_emails(db_session, lower_emails)
return all_users
def delete_user_from_db(

View File

@@ -17,6 +17,7 @@ from uuid import UUID
import httpx # type: ignore
import requests # type: ignore
from retry import retry
from onyx.configs.chat_configs import DOC_TIME_DECAY
from onyx.configs.chat_configs import NUM_RETURNED_HITS
@@ -549,6 +550,11 @@ class VespaIndex(DocumentIndex):
time.monotonic() - update_start,
)
@retry(
tries=3,
delay=1,
backoff=2,
)
def _update_single_chunk(
self,
doc_chunk_id: UUID,
@@ -559,6 +565,7 @@ class VespaIndex(DocumentIndex):
) -> None:
"""
Update a single "chunk" (document) in Vespa using its chunk ID.
Retries if we encounter transient HTTPStatusError (e.g., overload).
"""
update_dict: dict[str, dict] = {"fields": {}}
@@ -567,13 +574,11 @@ class VespaIndex(DocumentIndex):
update_dict["fields"][BOOST] = {"assign": fields.boost}
if fields.document_sets is not None:
# WeightedSet<string> needs a map { item: weight, ... }
update_dict["fields"][DOCUMENT_SETS] = {
"assign": {document_set: 1 for document_set in fields.document_sets}
}
if fields.access is not None:
# Similar to above
update_dict["fields"][ACCESS_CONTROL_LIST] = {
"assign": {acl_entry: 1 for acl_entry in fields.access.to_acl()}
}
@@ -585,7 +590,10 @@ class VespaIndex(DocumentIndex):
logger.error("Update request received but nothing to update.")
return
vespa_url = f"{DOCUMENT_ID_ENDPOINT.format(index_name=index_name)}/{doc_chunk_id}?create=true"
vespa_url = (
f"{DOCUMENT_ID_ENDPOINT.format(index_name=index_name)}/{doc_chunk_id}"
"?create=true"
)
try:
resp = http_client.put(
@@ -595,8 +603,11 @@ class VespaIndex(DocumentIndex):
)
resp.raise_for_status()
except httpx.HTTPStatusError as e:
error_message = f"Failed to update doc chunk {doc_chunk_id} (doc_id={doc_id}). Details: {e.response.text}"
logger.error(error_message)
logger.error(
f"Failed to update doc chunk {doc_chunk_id} (doc_id={doc_id}). "
f"Details: {e.response.text}"
)
# Re-raise so the @retry decorator will catch and retry
raise
def update_single(

View File

@@ -146,6 +146,23 @@ 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,
@@ -166,10 +183,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(document.metadata),
METADATA: json.dumps(cleaned_metadata_json),
# Save as a list for efficient extraction as an Attribute
METADATA_LIST: chunk.source_document.get_metadata_str_attributes(),
METADATA_SUFFIX: chunk.metadata_suffix_keyword,
METADATA_LIST: metadata_list,
METADATA_SUFFIX: remove_invalid_unicode_chars(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,6 +32,7 @@ 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
@@ -56,7 +57,6 @@ 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,6 +72,10 @@ 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,
@@ -106,6 +110,25 @@ 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

@@ -86,7 +86,10 @@ 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 = {}
results: dict[str, Any] = {}
if len(function_calls) == 0:
return results
with ThreadPoolExecutor(max_workers=len(function_calls)) as executor:
future_to_id = {

View File

@@ -9,6 +9,8 @@ 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
@@ -46,6 +48,8 @@ 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")
@@ -60,7 +64,13 @@ 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}/{table_id}"
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}"
sections = []
if not all_fields_as_metadata:
@@ -214,6 +224,7 @@ 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",
@@ -234,6 +245,7 @@ def test_airtable_connector_basic(
)
],
all_fields_as_metadata=False,
view_id=BASE_VIEW_ID,
),
]
@@ -285,6 +297,81 @@ 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(),
json=persona_creation_request.model_dump(mode="json"),
headers=user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS,
@@ -119,6 +119,7 @@ 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,
@@ -146,7 +147,7 @@ class PersonaManager:
response = requests.patch(
f"{API_SERVER_URL}/persona/{persona.id}",
json=persona_update_request.model_dump(),
json=persona_update_request.model_dump(mode="json"),
headers=user_performing_action.headers
if user_performing_action
else GENERAL_HEADERS,

View File

@@ -58,6 +58,7 @@ 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)
@@ -139,9 +140,14 @@ 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",
description="Updated by admin 2",
users=[admin_user.id, admin_user.id],
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,7 +3,13 @@
import React from "react";
import { Option } from "@/components/Dropdown";
import { generateRandomIconShape } from "@/lib/assistantIconUtils";
import { CCPairBasicInfo, DocumentSet, User, UserGroup } from "@/lib/types";
import {
CCPairBasicInfo,
DocumentSet,
User,
UserGroup,
UserRole,
} from "@/lib/types";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { ArrayHelpers, FieldArray, Form, Formik, FormikProps } from "formik";
@@ -33,9 +39,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } 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";
@@ -71,11 +76,11 @@ import {
Option as DropdownOption,
} from "@/components/Dropdown";
import { SourceChip } from "@/app/chat/input/ChatInputBar";
import { TagIcon, UserIcon, XIcon } from "lucide-react";
import { TagIcon, UserIcon, XIcon, InfoIcon } from "lucide-react";
import { LLMSelector } from "@/components/llm/LLMSelector";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import Title from "@/components/ui/title";
import { SEARCH_TOOL_ID } from "@/app/chat/tools/constants";
@@ -127,6 +132,8 @@ 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 } =
@@ -216,6 +223,8 @@ export function AssistantEditor({
enabledToolsMap[tool.id] = personaCurrentToolIds.includes(tool.id);
});
const [showVisibilityWarning, setShowVisibilityWarning] = useState(false);
const initialValues = {
name: existingPersona?.name ?? "",
description: existingPersona?.description ?? "",
@@ -252,6 +261,7 @@ export function AssistantEditor({
(u) => u.id !== existingPersona.owner?.id
) ?? [],
selectedGroups: existingPersona?.groups ?? [],
is_default_persona: existingPersona?.is_default_persona ?? false,
};
interface AssistantPrompt {
@@ -308,24 +318,12 @@ 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) {
@@ -346,9 +344,7 @@ export function AssistantEditor({
if (response.ok) {
await refreshAssistants();
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat`
isAdminPage ? `/admin/assistants?u=${Date.now()}` : `/chat`
);
} else {
setPopup({
@@ -374,8 +370,9 @@ export function AssistantEditor({
<BackButton />
</div>
)}
{labelToDelete && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="label"
entityName={labelToDelete.name}
onClose={() => setLabelToDelete(null)}
@@ -398,7 +395,7 @@ export function AssistantEditor({
/>
)}
{deleteModalOpen && existingPersona && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="Persona"
entityName={existingPersona.name}
onClose={closeDeleteModal}
@@ -439,6 +436,7 @@ 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",
@@ -459,6 +457,19 @@ 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 (
@@ -499,7 +510,6 @@ 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
@@ -563,8 +573,9 @@ export function AssistantEditor({
}
await refreshAssistants();
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
isAdminPage
? `/admin/assistants?u=${Date.now()}`
: `/chat?assistantId=${assistantId}`
);
@@ -1005,6 +1016,22 @@ 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>
@@ -1014,22 +1041,60 @@ export function AssistantEditor({
<div className="min-h-[100px]">
<div className="flex items-center mb-2">
<SwitchField
name="is_public"
size="md"
onCheckedChange={(checked) => {
setFieldValue("is_public", checked);
if (checked) {
setFieldValue("selectedUsers", []);
setFieldValue("selectedGroups", []);
}
}}
/>
<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>
<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,13 +11,14 @@ 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 { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
if (persona.builtin_persona) {
@@ -56,6 +57,9 @@ 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);
@@ -126,11 +130,39 @@ 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 && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="Persona"
entityName={personaToDelete.name}
onClose={closeDeleteModal}
@@ -138,8 +170,35 @@ 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", "Is Visible", "Delete"]}
headers={[
"Name",
"Description",
"Type",
"Featured Assistant",
"Is Visible",
"Delete",
]}
isAdmin={isAdmin}
rows={finalPersonas.map((persona) => {
const isEditable = editablePersonas.includes(persona);
@@ -152,7 +211,9 @@ export function PersonasTable() {
className="mr-1 my-auto cursor-pointer"
onClick={() =>
router.push(
`/admin/assistants/${persona.id}?u=${Date.now()}`
`/assistants/edit/${
persona.id
}?u=${Date.now()}&admin=true`
)
}
/>
@@ -168,6 +229,30 @@ 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

@@ -1,36 +0,0 @@
"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

@@ -1,43 +0,0 @@
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,6 +261,22 @@ 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

@@ -1,25 +0,0 @@
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="/admin/assistants/new" text="New Assistant" />
<CreateButton href="/assistants/new?admin=true" text="New Assistant" />
<Separator />

View File

@@ -100,7 +100,7 @@ export function SlackChannelConfigsTable({
slackChannelConfig.persona
) ? (
<Link
href={`/admin/assistants/${slackChannelConfig.persona.id}`}
href={`/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 { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { AdvancedSearchConfiguration } from "../interfaces";
import CardSection from "@/components/admin/CardSection";
@@ -456,7 +456,7 @@ export function CloudModelCard({
>
{popup}
{showDeleteModel && (
<DeleteEntityModal
<ConfirmEntityModal
entityName={model.model_name}
entityType="embedding model configuration"
onSubmit={() => deleteModel()}

View File

@@ -33,7 +33,7 @@ export default function SidebarWrapper<T extends object>({
size = "sm",
children,
}: SidebarWrapperProps<T>) {
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
const [sidebarVisible, setSidebarVisible] = 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(!toggledSidebar).toLocaleLowerCase()
String(!sidebarVisible).toLocaleLowerCase()
),
{
path: "/",
};
setToggledSidebar((toggledSidebar) => !toggledSidebar);
}, [toggledSidebar]);
setSidebarVisible((sidebarVisible) => !sidebarVisible);
}, [sidebarVisible]);
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({
toggledSidebar,
sidebarVisible,
sidebarElementRef,
showDocSidebar,
setShowDocSidebar,
@@ -94,7 +94,7 @@ export default function SidebarWrapper<T extends object>({
duration-300
ease-in-out
${
!untoggled && (showDocSidebar || toggledSidebar)
!untoggled && (showDocSidebar || sidebarVisible)
? "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={toggledSidebar}
toggled={sidebarVisible}
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={toggledSidebar}
sidebarToggled={sidebarVisible}
toggleSidebar={toggleSidebar}
page="chat"
/>
@@ -132,7 +132,7 @@ export default function SidebarWrapper<T extends object>({
bg-opacity-80
duration-300
ease-in-out
${toggledSidebar ? "w-[250px]" : "w-[0px]"}`}
${sidebarVisible ? "w-[250px]" : "w-[0px]"}`}
/>
<div
@@ -144,7 +144,7 @@ export default function SidebarWrapper<T extends object>({
</div>
</div>
</div>
<FixedLogo backgroundToggled={toggledSidebar || showDocSidebar} />
<FixedLogo backgroundToggled={sidebarVisible || showDocSidebar} />
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useContext, useState, useRef, useLayoutEffect } from "react";
import React, { 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,14 +26,12 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PinnedIcon } from "@/components/icons/icons";
import {
deletePersona,
togglePersonaPublicStatus,
} from "@/app/admin/assistants/lib";
import { deletePersona } 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,
@@ -63,6 +61,7 @@ const AssistantCard: React.FC<{
const { user, toggleAssistantPinnedStatus } = useUser();
const router = useRouter();
const { refreshAssistants, pinnedAssistants } = useAssistants();
const { popup, setPopup } = usePopup();
const isOwnedByUser = checkUserOwnsAssistant(user, persona);
@@ -72,7 +71,34 @@ const AssistantCard: React.FC<{
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const handleDelete = () => setActivePopover("delete");
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 handleEdit = () => {
router.push(`/assistants/edit/${persona.id}`);
setActivePopover(null);
@@ -100,6 +126,7 @@ 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" />
@@ -157,55 +184,84 @@ const AssistantCard: React.FC<{
<FiMoreHorizontal size={16} />
</button>
</PopoverTrigger>
<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 && (
<PopoverContent
className={`${
isDeleteConfirmation ? "w-64" : "w-32"
} z-[10000] p-2`}
>
{!isDeleteConfirmation ? (
<div className="flex flex-col text-sm space-y-1">
<button
onClick={
onClick={isOwnedByUser ? handleEdit : undefined}
className={`w-full flex items-center text-left px-2 py-1 rounded ${
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"
? "hover:bg-neutral-200 dark:hover:bg-neutral-700"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiBarChart size={12} className="inline mr-2" />
Stats
<FiEdit size={12} className="inline mr-2" />
Edit
</button>
)}
<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>
{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}
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>
) : (
<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>
)}
</PopoverContent>
</Popover>
</div>

View File

@@ -5,10 +5,8 @@ import { useRouter } from "next/navigation";
import AssistantCard from "./AssistantCard";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useUser } from "@/components/user/UserProvider";
import { FilterIcon } from "lucide-react";
import { FilterIcon, XIcon } 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,
@@ -24,8 +22,8 @@ export const AssistantBadgeSelector = ({
className={`
select-none ${
selected
? "bg-neutral-900 text-white"
: "bg-transparent text-neutral-900"
? "bg-background-900 text-white"
: "bg-transparent text-text-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}
>
@@ -109,16 +107,20 @@ export function AssistantModal({
const featuredAssistants = [
...memoizedCurrentlyVisibleAssistants.filter(
(assistant) => assistant.builtin_persona || assistant.is_default_persona
(assistant) => assistant.is_default_persona
),
];
const allAssistants = memoizedCurrentlyVisibleAssistants.filter(
(assistant) => !assistant.builtin_persona && !assistant.is_default_persona
(assistant) => !assistant.is_default_persona
);
return (
<div className="fixed inset-0 bg-neutral-950/80 bg-opacity-50 flex items-center justify-center z-50">
<div
onClick={hideModal}
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",
@@ -128,129 +130,142 @@ 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 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 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>
))
) : (
<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>
{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="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 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 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>
</div>
</div>
</div>

View File

@@ -97,7 +97,6 @@ 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";
@@ -130,6 +129,7 @@ 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,
toggledSidebar,
sidebarVisible,
firstMessage,
}: {
toggle: (toggled?: boolean) => void;
documentSidebarInitialWidth?: number;
toggledSidebar: boolean;
sidebarVisible: boolean;
firstMessage?: string;
}) {
const router = useRouter();
@@ -204,7 +204,7 @@ export function ChatPage({
const settings = useContext(SettingsContext);
const enterpriseSettings = settings?.enterpriseSettings;
const [documentSidebarToggled, setDocumentSidebarToggled] = useState(false);
const [documentSidebarVisible, setDocumentSidebarVisible] = 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(!toggledSidebar).toLocaleLowerCase()
String(!sidebarVisible).toLocaleLowerCase()
);
toggle(false);
}
@@ -1024,10 +1024,10 @@ export function ChatPage({
if (
(!personaIncludesRetrieval &&
(!selectedDocuments || selectedDocuments.length === 0) &&
documentSidebarToggled) ||
documentSidebarVisible) ||
chatSessionIdRef.current == undefined
) {
setDocumentSidebarToggled(false);
setDocumentSidebarVisible(false);
}
clientScrollToBottom();
}, [chatSessionIdRef.current]);
@@ -1122,6 +1122,7 @@ export function ChatPage({
"Continue Generating (pick up exactly where you left off)",
});
};
const [gener, setFinishedStreaming] = useState(false);
const onSubmit = async ({
messageIdToResend,
@@ -1272,6 +1273,7 @@ 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;
@@ -1442,11 +1444,22 @@ 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,
@@ -1606,6 +1619,7 @@ export function ChatPage({
latestChildMessageId: initialFetchDetails.assistant_message_id,
},
{
isStreamingQuestions: isStreamingQuestions,
is_generating: is_generating,
isImprovement: isImprovement,
messageId: initialFetchDetails.assistant_message_id!,
@@ -1805,7 +1819,7 @@ export function ChatPage({
}
Cookies.set(
SIDEBAR_TOGGLED_COOKIE_NAME,
String(!toggledSidebar).toLocaleLowerCase()
String(!sidebarVisible).toLocaleLowerCase()
),
{
path: "/",
@@ -1822,7 +1836,7 @@ export function ChatPage({
const sidebarElementRef = useRef<HTMLDivElement>(null);
useSidebarVisibility({
toggledSidebar,
sidebarVisible,
sidebarElementRef,
showDocSidebar: showHistorySidebar,
setShowDocSidebar: setShowHistorySidebar,
@@ -2003,7 +2017,7 @@ export function ChatPage({
useEffect(() => {
if (!retrievalEnabled) {
setDocumentSidebarToggled(false);
setDocumentSidebarVisible(false);
}
}, [retrievalEnabled]);
@@ -2068,10 +2082,10 @@ export function ChatPage({
const [showAssistantsModal, setShowAssistantsModal] = useState(false);
const toggleDocumentSidebar = () => {
if (!documentSidebarToggled) {
setDocumentSidebarToggled(true);
if (!documentSidebarVisible) {
setDocumentSidebarVisible(true);
} else {
setDocumentSidebarToggled(false);
setDocumentSidebarVisible(false);
}
};
@@ -2122,7 +2136,7 @@ export function ChatPage({
<ChatPopup />
{showDeleteAllModal && (
<DeleteEntityModal
<ConfirmEntityModal
entityType="All Chats"
entityName="all your chat sessions"
onClose={() => setShowDeleteAllModal(false)}
@@ -2178,11 +2192,11 @@ export function ChatPage({
/>
)}
{retrievalEnabled && documentSidebarToggled && settings?.isMobile && (
{retrievalEnabled && documentSidebarVisible && settings?.isMobile && (
<div className="md:hidden">
<Modal
hideDividerForTitle
onOutsideClick={() => setDocumentSidebarToggled(false)}
onOutsideClick={() => setDocumentSidebarVisible(false)}
title="Sources"
>
<DocumentResults
@@ -2198,7 +2212,7 @@ export function ChatPage({
modal={true}
ref={innerSidebarElementRef}
closeSidebar={() => {
setDocumentSidebarToggled(false);
setDocumentSidebarVisible(false);
}}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
@@ -2277,22 +2291,24 @@ export function ChatPage({
bg-opacity-80
duration-300
ease-in-out
${
!untoggled && (showHistorySidebar || toggledSidebar)
!untoggled && (showHistorySidebar || sidebarVisible)
? "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={toggledSidebar}
currentAssistantId={liveAssistant?.id}
toggled={sidebarVisible}
existingChats={chatSessions}
currentChatSession={selectedChatSession}
folders={folders}
@@ -2314,7 +2330,7 @@ export function ChatPage({
duration-300
ease-in-out
${
documentSidebarToggled &&
documentSidebarVisible &&
!settings?.isMobile &&
"opacity-100 w-[350px]"
}`}
@@ -2339,7 +2355,7 @@ export function ChatPage({
ease-in-out
h-full
${
documentSidebarToggled && !settings?.isMobile
documentSidebarVisible && !settings?.isMobile
? "w-[400px]"
: "w-[0px]"
}
@@ -2358,7 +2374,7 @@ export function ChatPage({
modal={false}
ref={innerSidebarElementRef}
closeSidebar={() =>
setTimeout(() => setDocumentSidebarToggled(false), 300)
setTimeout(() => setDocumentSidebarVisible(false), 300)
}
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
@@ -2367,12 +2383,12 @@ export function ChatPage({
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
initialWidth={400}
isOpen={documentSidebarToggled && !settings?.isMobile}
isOpen={documentSidebarVisible && !settings?.isMobile}
/>
</div>
<BlurBackground
visible={!untoggled && (showHistorySidebar || toggledSidebar)}
visible={!untoggled && (showHistorySidebar || sidebarVisible)}
onClick={() => toggleSidebar()}
/>
@@ -2387,7 +2403,7 @@ export function ChatPage({
{liveAssistant && (
<FunctionalHeader
toggleUserSettings={() => setUserSettingsToggled(true)}
sidebarToggled={toggledSidebar}
sidebarToggled={sidebarVisible}
reset={() => setMessage("")}
page="chat"
setSharingModalVisible={
@@ -2395,8 +2411,8 @@ export function ChatPage({
? setSharingModalVisible
: undefined
}
documentSidebarToggled={
documentSidebarToggled && !settings?.isMobile
documentSidebarVisible={
documentSidebarVisible && !settings?.isMobile
}
toggleSidebar={toggleSidebar}
currentChatSession={selectedChatSession}
@@ -2424,7 +2440,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
${sidebarVisible ? "w-[200px]" : "w-[0px]"}
`}
></div>
)}
@@ -2450,7 +2466,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${toggledSidebar ? "w-[200px]" : "w-[0px]"}
${sidebarVisible ? "w-[200px]" : "w-[0px]"}
`}
></div>
)}
@@ -2635,8 +2651,14 @@ export function ChatPage({
{message.sub_questions &&
message.sub_questions.length > 0 ? (
<AgenticMessage
isStreamingQuestions={
message.isStreamingQuestions ?? false
}
isGenerating={
message.is_generating ?? false
}
docSidebarToggled={
documentSidebarToggled &&
documentSidebarVisible &&
(selectedMessageForDocDisplay ==
message.messageId ||
selectedMessageForDocDisplay ==
@@ -2732,7 +2754,8 @@ export function ChatPage({
setMessageAsLatest(messageId);
}}
isActive={
messageHistory.length - 1 == i
messageHistory.length - 1 == i ||
messageHistory.length - 2 == i
}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={(
@@ -2740,8 +2763,8 @@ export function ChatPage({
) => {
if (
(!second &&
!documentSidebarToggled) ||
(documentSidebarToggled &&
!documentSidebarVisible) ||
(documentSidebarVisible &&
selectedMessageForDocDisplay ===
message.messageId)
) {
@@ -2749,8 +2772,8 @@ export function ChatPage({
}
if (
(second &&
!documentSidebarToggled) ||
(documentSidebarToggled &&
!documentSidebarVisible) ||
(documentSidebarVisible &&
selectedMessageForDocDisplay ===
secondLevelMessage?.messageId)
) {
@@ -2851,8 +2874,8 @@ export function ChatPage({
selectedDocuments={selectedDocuments}
toggleDocumentSelection={() => {
if (
!documentSidebarToggled ||
(documentSidebarToggled &&
!documentSidebarVisible ||
(documentSidebarVisible &&
selectedMessageForDocDisplay ===
message.messageId)
) {
@@ -3070,7 +3093,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 rounded-2xl bg-background-strong border border-border mx-auto "
className="p-1 pointer-events-auto text-neutral-700 dark:text-neutral-800 rounded-2xl bg-neutral-200 border border-border mx-auto "
>
<FiArrowDown size={18} />
</button>
@@ -3147,7 +3170,7 @@ export function ChatPage({
ease-in-out
h-full
${
documentSidebarToggled && !settings?.isMobile
documentSidebarVisible && !settings?.isMobile
? "w-[350px]"
: "w-[0px]"
}
@@ -3162,7 +3185,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
${
toggledSidebar && !settings?.isMobile
sidebarVisible && !settings?.isMobile
? "w-[250px] "
: "w-[0px]"
}`}
@@ -3174,7 +3197,7 @@ export function ChatPage({
)}
</div>
</div>
<FixedLogo backgroundToggled={toggledSidebar || showHistorySidebar} />
<FixedLogo backgroundToggled={sidebarVisible || showHistorySidebar} />
</div>
{/* Right Sidebar - DocumentSidebar */}
</div>

View File

@@ -115,21 +115,61 @@ export function RefinemenetBadge({
const isDone = displayedPhases.includes(StreamingPhase.COMPLETE);
// Expand/collapse, hover states
const [expanded, setExpanded] = useState(true);
const [expanded] = 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); // e.g. 0.8s
}, 800);
return () => clearTimeout(timer);
}
}, [isDone, isHovered]);
}, [isDone, isHovered, setCanShowResponse]);
if (!shouldShow) {
return null; // entire box disappears
@@ -137,13 +177,22 @@ 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"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
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
>
{/* 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">
@@ -159,36 +208,32 @@ export function RefinemenetBadge({
</TooltipTrigger>
{expanded && (
<TooltipContent
ref={tooltipRef}
// onMouseEnter keeps the tooltip open when cursor enters tooltip
onMouseEnter={() => {
setToolTipHoveredInternal(true);
setToolTipHovered(true);
}}
onMouseLeave={() => {
setToolTipHoveredInternal(false);
}}
// Remove onMouseLeave and rely on bounding box logic to close
side="bottom"
align="start"
className="w-fit -mt-1 p-4 bg-white border-2 border-border shadow-lg rounded-md"
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"
>
{/* 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
Array.from(new Set(displayedPhases)).length - 1 &&
phase !== StreamingPhase.COMPLETE
) {
status = ToggleState.InProgress;
}
if (phase === StreamingPhase.COMPLETE) {
status = ToggleState.Done;
}
return (
<div
@@ -338,6 +383,7 @@ 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 */}
@@ -355,7 +401,6 @@ export function StatusRefinement({
</div>
<span className="text-neutral-800 text-sm font-medium">
{StreamingPhaseText[phase]}
LLL
</span>
</div>
))}

View File

@@ -5,18 +5,22 @@ 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 { toggledSidebar } = useChatContext();
const { sidebarInitiallyVisible } = useChatContext();
return (
<FunctionalWrapper
initiallyToggled={toggledSidebar}
content={(toggledSidebar, toggle) => (
initiallyVisible={sidebarInitiallyVisible && !defaultSidebarOff}
content={(sidebarVisible, toggle) => (
<ChatPage
toggle={toggle}
toggledSidebar={toggledSidebar}
sidebarVisible={sidebarVisible}
firstMessage={firstMessage}
/>
)}

View File

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

View File

@@ -110,6 +110,7 @@ export interface Message {
second_level_message?: string;
second_level_subquestions?: SubQuestionDetail[] | null;
isImprovement?: boolean | null;
isStreamingQuestions?: boolean;
}
export interface BackendChatSession {
@@ -219,6 +220,7 @@ export interface SubQuestionDetail extends BaseQuestionIdentifier {
context_docs?: { top_documents: OnyxDocument[] } | null;
is_complete?: boolean;
is_stopped?: boolean;
answer_streaming?: boolean;
}
export interface SubQueryDetail {
@@ -245,9 +247,6 @@ export const constructSubQuestions = (
}
const updatedSubQuestions = [...subQuestions];
// .filter(
// (sq) => sq.level_question_num !== 0
// );
if ("stop_reason" in newDetail) {
const { level, level_question_num } = newDetail;
@@ -255,8 +254,12 @@ export const constructSubQuestions = (
(sq) => sq.level === level && sq.level_question_num === level_question_num
);
if (subQuestion) {
subQuestion.is_complete = true;
subQuestion.is_stopped = true;
if (newDetail.stream_type == "sub_answer") {
subQuestion.answer_streaming = false;
} else {
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,
toggleSidebar,
sidebarInitiallyVisible,
defaultAssistantId,
shouldShowWelcomeModal,
ccPairs,
@@ -47,7 +47,7 @@ export default async function Layout({
proSearchToggled,
inputPrompts,
chatSessions,
toggledSidebar: toggleSidebar,
sidebarInitiallyVisible,
availableSources,
ccPairs,
documentSets,

View File

@@ -48,9 +48,10 @@ 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,
@@ -81,6 +82,8 @@ export const AgenticMessage = ({
secondLevelSubquestions,
toggleDocDisplay,
}: {
isStreamingQuestions: boolean;
isGenerating: boolean;
docSidebarToggled?: boolean;
isImprovement?: boolean | null;
secondLevelSubquestions?: SubQuestionDetail[] | null;
@@ -230,6 +233,13 @@ 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) => {
@@ -400,12 +410,10 @@ export const AgenticMessage = ({
<div className="w-full desktop:ml-4">
{subQuestions && subQuestions.length > 0 && (
<SubQuestionsDisplay
isStreamingQuestions={isStreamingQuestions}
allowDocuments={() => setAllowDocuments(true)}
docSidebarToggled={docSidebarToggled || false}
finishedGenerating={
finalContent.length > 2 &&
streamedContent.length == finalContent.length
}
finishedGenerating={finishedGenerating}
overallAnswerGenerating={
!!(
secondLevelSubquestions &&

View File

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

View File

@@ -56,12 +56,19 @@ const DOC_DELAY_MS = 100;
export const useStreamingMessages = (
subQuestions: SubQuestionDetail[],
allowStreaming: () => void,
onComplete: () => void
onComplete: () => void,
isStreamingQuestions: boolean
) => {
const [dynamicSubQuestions, setDynamicSubQuestions] = useState<
SubQuestionDetail[]
>([]);
const isStreamingQuestionsRef = useRef(isStreamingQuestions);
useEffect(() => {
isStreamingQuestionsRef.current = isStreamingQuestions;
}, [isStreamingQuestions]);
const subQuestionsRef = useRef<SubQuestionDetail[]>(subQuestions);
useEffect(() => {
subQuestionsRef.current = subQuestions;
@@ -121,6 +128,7 @@ 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];
@@ -138,6 +146,8 @@ export const useStreamingMessages = (
p.questionDone = true;
}
didStreamQuestion = true;
allQuestionsComplete = false;
// Break after streaming one question to ensure sequential behavior
break;
}
@@ -149,7 +159,11 @@ export const useStreamingMessages = (
}
}
if (allQuestionsComplete && !didStreamQuestion) {
if (
allQuestionsComplete &&
!didStreamQuestion &&
!isStreamingQuestionsRef.current
) {
onComplete();
}
@@ -163,6 +177,8 @@ 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,6 +65,7 @@ export interface TemporaryDisplay {
tinyQuestion: string;
}
interface SubQuestionsDisplayProps {
isStreamingQuestions: boolean;
docSidebarToggled: boolean;
finishedGenerating: boolean;
currentlyOpenQuestion?: BaseQuestionIdentifier | null;
@@ -152,7 +153,8 @@ const SubQuestionDisplay: React.FC<{
content = content.replace(/\]\](?!\()/g, "]]()");
return (
preprocessLaTeX(content) + (!subQuestion?.is_complete ? " [*]() " : "")
preprocessLaTeX(content) +
(subQuestion?.answer_streaming ? " [*]() " : "")
);
};
@@ -461,6 +463,7 @@ const SubQuestionDisplay: React.FC<{
};
const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
isStreamingQuestions,
finishedGenerating,
subQuestions,
allowStreaming,
@@ -477,23 +480,29 @@ 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 finishedGenerating ? subQuestions : dynamicSubQuestions;
}, [finishedGenerating, dynamicSubQuestions, subQuestions]);
// const memoizedSubQuestions = dynamicSubQuestions;
return initiallyFinishedGenerating ? subQuestions : dynamicSubQuestions;
}, [initiallyFinishedGenerating, dynamicSubQuestions, subQuestions]);
const memoizedSecondLevelQuestions = useMemo(() => {
return overallAnswerGenerating
? dynamicSecondLevelQuestions
@@ -509,12 +518,6 @@ 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" : ""
);
@@ -524,12 +527,15 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
const [shownDocuments, setShownDocuments] = useState(documents);
useEffect(() => {
if (documents && documents.length > 0) {
setTimeout(() => {
setShownDocuments(documents);
}, 800);
if (canShowSummarizing && documents && documents.length > 0) {
setTimeout(
() => {
setShownDocuments(documents);
},
finishedGenerating ? 0 : 800
);
}
}, [documents]);
}, [documents, canShowSummarizing]);
useEffect(() => {
if (

View File

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

View File

@@ -1,3 +1,4 @@
import { SEARCH_PARAMS } from "@/lib/extension/constants";
import WrappedChat from "./WrappedChat";
export default async function Page(props: {
@@ -5,6 +6,13 @@ 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} />;
return (
<WrappedChat
firstMessage={firstMessage}
defaultSidebarOff={defaultSidebarOff}
/>
);
}

View File

@@ -50,10 +50,12 @@ import {
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { CircleX } from "lucide-react";
import { CirclePlus, CircleX, PinIcon } 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;
@@ -66,22 +68,23 @@ interface HistorySidebarProps {
showDeleteModal?: (chatSession: ChatSession) => void;
explicitlyUntoggle: () => void;
showDeleteAllModal?: () => void;
currentAssistantId?: number | null;
setShowAssistantsModal: (show: boolean) => void;
}
interface SortableAssistantProps {
assistant: Persona;
currentAssistantId: number | null | undefined;
active: boolean;
onClick: () => void;
onUnpin: (e: React.MouseEvent) => void;
onPinAction: (e: React.MouseEvent) => void;
pinned?: boolean;
}
const SortableAssistant: React.FC<SortableAssistantProps> = ({
assistant,
currentAssistantId,
active,
onClick,
onUnpin,
onPinAction,
pinned = true,
}) => {
const {
attributes,
@@ -126,7 +129,9 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
>
<DragHandle
size={16}
className="w-3 ml-[2px] mr-[2px] group-hover:visible invisible flex-none cursor-grab"
className={`w-3 ml-[2px] mr-[2px] group-hover:visible invisible flex-none cursor-grab ${
!pinned ? "opacity-0" : ""
}`}
/>
<div
data-testid={`assistant-[${assistant.id}]`}
@@ -137,9 +142,7 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
}
}}
className={`cursor-pointer w-full group hover:bg-background-chat-hover ${
currentAssistantId === assistant.id
? "bg-background-chat-hover/60"
: ""
active ? "bg-accent-background-selected" : ""
} relative flex items-center gap-x-2 py-1 px-2 rounded-md`}
>
<AssistantIcon assistant={assistant} size={16} className="flex-none" />
@@ -164,15 +167,36 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
>
{assistant.name}
</span>
<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>
<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>
</div>
</div>
);
@@ -181,6 +205,7 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
(
{
liveAssistant,
reset = () => null,
setShowAssistantsModal = () => null,
toggled,
@@ -194,7 +219,6 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
showShareModal,
showDeleteModal,
showDeleteAllModal,
currentAssistantId,
},
ref: ForwardedRef<HTMLDivElement>
) => {
@@ -353,13 +377,13 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
<SortableAssistant
key={assistant.id === 0 ? "assistant-0" : assistant.id}
assistant={assistant}
currentAssistantId={currentAssistantId}
active={assistant.id === liveAssistant?.id}
onClick={() => {
router.push(
buildChatUrl(searchParams, null, assistant.id)
);
}}
onUnpin={async (e: React.MouseEvent) => {
onPinAction={async (e: React.MouseEvent) => {
e.stopPropagation();
await toggleAssistantPinnedStatus(
pinnedAssistants.map((a) => a.id),
@@ -373,6 +397,31 @@ 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({
documentSidebarToggled,
documentSidebarVisible,
}: {
documentSidebarToggled: boolean;
documentSidebarVisible: boolean;
}) {
const router = useRouter();
const enterpriseSettings = useContext(SettingsContext)?.enterpriseSettings;
@@ -47,7 +47,7 @@ function BackToOnyxButton({
transition-all
duration-300
ease-in-out
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
${documentSidebarVisible ? "w-[400px]" : "w-[0px]"}
`}
></div>
</div>
@@ -62,7 +62,7 @@ export function SharedChatDisplay({
persona: Persona;
}) {
const settings = useContext(SettingsContext);
const [documentSidebarToggled, setDocumentSidebarToggled] = useState(false);
const [documentSidebarVisible, setDocumentSidebarVisible] = 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 = () => {
setDocumentSidebarToggled(!documentSidebarToggled);
setDocumentSidebarVisible(!documentSidebarVisible);
};
useEffect(() => {
@@ -85,7 +85,7 @@ export function SharedChatDisplay({
Did not find a shared chat with the specified ID.
</Callout>
</div>
<BackToOnyxButton documentSidebarToggled={documentSidebarToggled} />
<BackToOnyxButton documentSidebarVisible={documentSidebarVisible} />
</div>
);
}
@@ -102,7 +102,7 @@ export function SharedChatDisplay({
onClose={() => setPresentingDocument(null)}
/>
)}
{documentSidebarToggled && settings?.isMobile && (
{documentSidebarVisible && settings?.isMobile && (
<div className="md:hidden">
<Modal noPadding noScroll>
<DocumentResults
@@ -117,7 +117,7 @@ export function SharedChatDisplay({
: null
}
toggleDocumentSelection={() => {
setDocumentSidebarToggled(true);
setDocumentSidebarVisible(true);
}}
selectedDocuments={[]}
clearSelectedDocuments={() => {}}
@@ -128,7 +128,7 @@ export function SharedChatDisplay({
setPresentingDocument={setPresentingDocument}
modal={true}
closeSidebar={() => {
setDocumentSidebarToggled(false);
setDocumentSidebarVisible(false);
}}
/>
</Modal>
@@ -158,7 +158,7 @@ export function SharedChatDisplay({
duration-300
ease-in-out
h-full
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
${documentSidebarVisible ? "w-[400px]" : "w-[0px]"}
`}
>
<DocumentResults
@@ -174,7 +174,7 @@ export function SharedChatDisplay({
: null
}
toggleDocumentSelection={() => {
setDocumentSidebarToggled(true);
setDocumentSidebarVisible(true);
}}
clearSelectedDocuments={() => {}}
selectedDocumentTokens={0}
@@ -183,7 +183,7 @@ export function SharedChatDisplay({
isOpen={true}
setPresentingDocument={setPresentingDocument}
closeSidebar={() => {
setDocumentSidebarToggled(false);
setDocumentSidebarVisible(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
${
documentSidebarToggled
documentSidebarVisible
? "left-[200px] transform -translate-x-[calc(50%+100px)]"
: "left-1/2 transform -translate-x-1/2"
}
@@ -263,6 +263,8 @@ export function SharedChatDisplay({
) {
return (
<AgenticMessage
isStreamingQuestions={false}
isGenerating={false}
shared
key={message.messageId}
isImprovement={message.isImprovement}
@@ -311,13 +313,13 @@ export function SharedChatDisplay({
selectedDocuments={[]}
toggleDocumentSelection={() => {
if (
!documentSidebarToggled ||
(documentSidebarToggled &&
!documentSidebarVisible ||
(documentSidebarVisible &&
selectedMessageForDocDisplay ===
message.messageId)
) {
setDocumentSidebarToggled(
!documentSidebarToggled
setDocumentSidebarVisible(
!documentSidebarVisible
);
}
setSelectedMessageForDocDisplay(
@@ -351,13 +353,13 @@ export function SharedChatDisplay({
selectedDocuments={[]}
toggleDocumentSelection={() => {
if (
!documentSidebarToggled ||
(documentSidebarToggled &&
!documentSidebarVisible ||
(documentSidebarVisible &&
selectedMessageForDocDisplay ===
message.messageId)
) {
setDocumentSidebarToggled(
!documentSidebarToggled
setDocumentSidebarVisible(
!documentSidebarVisible
);
}
setSelectedMessageForDocDisplay(
@@ -373,6 +375,8 @@ export function SharedChatDisplay({
<div key={message.messageId}>
<AgenticMessage
shared
isStreamingQuestions={false}
isGenerating={false}
subQuestions={message.sub_questions || []}
currentPersona={persona}
messageId={message.messageId}
@@ -404,7 +408,7 @@ export function SharedChatDisplay({
transition-all
duration-300
ease-in-out
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
${documentSidebarVisible ? "w-[400px]" : "w-[0px]"}
`}
></div>
)}
@@ -412,7 +416,7 @@ export function SharedChatDisplay({
</div>
<FixedLogo backgroundToggled={false} />
<BackToOnyxButton documentSidebarToggled={documentSidebarToggled} />
<BackToOnyxButton documentSidebarVisible={documentSidebarVisible} />
</div>
</div>
</>

View File

@@ -0,0 +1,65 @@
"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

@@ -26,7 +26,7 @@ export default async function GalleryPage(props: {
chatSessions,
folders,
openedFolders,
toggleSidebar,
sidebarInitiallyVisible,
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,6 +70,7 @@
--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%;
@@ -120,6 +121,7 @@
/* agent references */
--agent-sidebar: #be5d0e;
--agent-hovered: #d16b10;
--agent: #e47011;
--lighter-agent: #f59e0b;
@@ -247,6 +249,7 @@
--accent-background: #333333;
--accent-background-hovered: #2f2f2f;
--accent-background-selected: #222222;
--text-darker: #f0f0f0;
@@ -339,6 +342,7 @@
/* Agent references */
--agent-sidebar: #be5d0e; /* You can keep or lighten/darken if desired */
--agent-hovered: #f07c13;
--agent: #e47011;
--lighter-agent: #f59e0b;
}
@@ -656,6 +660,11 @@ ul > li > p {
color: white;
}
.dark li {
.dark li,
.dark h1,
.dark h2,
.dark h3,
.dark h4,
.dark h5 {
color: #e5e5e5;
}

View File

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

View File

@@ -57,7 +57,7 @@ export async function Layout({ children }: { children: React.ReactNode }) {
llmProviders,
folders,
openedFolders,
toggleSidebar,
sidebarInitiallyVisible,
defaultAssistantId,
shouldShowWelcomeModal,
ccPairs,
@@ -71,7 +71,7 @@ export async function Layout({ children }: { children: React.ReactNode }) {
inputPrompts,
chatSessions,
proSearchToggled,
toggledSidebar: toggleSidebar,
sidebarInitiallyVisible,
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 { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
const DeleteUserButton = ({
user,
@@ -38,7 +38,7 @@ const DeleteUserButton = ({
return (
<>
{showDeleteModal && (
<DeleteEntityModal
<ConfirmEntityModal
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 { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { ConfirmEntityModal } from "@/components/modals/ConfirmEntityModal";
import { useRouter } from "next/navigation";
export const LeaveOrganizationButton = ({
@@ -46,8 +46,9 @@ export const LeaveOrganizationButton = ({
return (
<>
{showLeaveModal && (
<DeleteEntityModal
deleteButtonText="Leave"
<ConfirmEntityModal
variant="action"
actionButtonText="Leave"
entityType="organization"
entityName="your organization"
onClose={() => setShowLeaveModal(false)}

View File

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

View File

@@ -3,27 +3,29 @@
import React, { ReactNode, useState } from "react";
export default function FunctionalWrapper({
initiallyToggled,
initiallyVisible,
content,
}: {
content: (
toggledSidebar: boolean,
toggle: (toggled?: boolean) => void
sidebarVisible: boolean,
toggle: (visible?: boolean) => void
) => ReactNode;
initiallyToggled: boolean;
initiallyVisible?: boolean;
}) {
const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled);
const [sidebarVisible, setSidebarVisible] = useState(
initiallyVisible || false
);
const toggle = (value?: boolean) => {
setToggledSidebar((toggledSidebar) =>
value !== undefined ? value : !toggledSidebar
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(toggledSidebar, toggle)}
{content(sidebarVisible, toggle)}
</div>
</>
);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
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

@@ -1,51 +0,0 @@
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="dark:border dark:!bg-[#000] border-neutral-700"
className="border border-neutral-300 hover:text-neutral-900 bg-neutral-100 dark:!bg-[#000] dark:border-neutral-700"
width="mb-2 max-w-lg"
>
{document_info?.document ? (

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-neutral-200 bg-neutral-50 text-neutral-600 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100",
"border-purple-200 bg-purple-50 text-purple-700 dark:border-purple-700 dark:bg-purple-900 dark:text-purple-100",
},
},
defaultVariants: {

View File

@@ -42,7 +42,7 @@ interface FetchChatDataResult {
folders: Folder[];
openedFolders: Record<string, boolean>;
defaultAssistantId?: number;
toggleSidebar: boolean;
sidebarInitiallyVisible: 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 toggleSidebar =
const sidebarInitiallyVisible =
!user?.is_anonymous_user &&
(sidebarToggled
? sidebarToggled.value.toLocaleLowerCase() == "true" || false
@@ -230,7 +230,7 @@ export async function fetchChatData(searchParams: {
openedFolders,
defaultAssistantId,
finalDocumentSidebarInitialWidth,
toggleSidebar,
sidebarInitiallyVisible,
shouldShowWelcomeModal,
inputPrompts,
proSearchToggled,

View File

@@ -1115,7 +1115,24 @@ For example, specifying .*-support.* as a "channel" will cause the connector to
optional: false,
},
],
advanced_values: [],
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.",
},
],
overrideDefaultFreq: 60 * 60 * 24,
},
};

View File

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

View File

@@ -76,6 +76,7 @@ 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,6 +108,7 @@ 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)",
@@ -262,7 +263,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)",