Compare commits

..

2 Commits

Author SHA1 Message Date
pablodanswer
25b38212e9 nit 2025-01-19 09:50:35 -08:00
pablodanswer
3096b0b2a7 add linear check 2025-01-19 09:49:26 -08:00
92 changed files with 3271 additions and 2047 deletions

View File

@@ -11,4 +11,5 @@
Note: You have to check that the action passes, otherwise resolve the conflicts manually and tag the patches.
- [ ] This PR should be backported (make sure to check that the backport attempt succeeds)
- [ ] I have included a link to a Linear ticket in my description.
- [ ] [Optional] Override Linear Check

View File

@@ -119,7 +119,7 @@ There are two editions of Onyx:
- Whitelabeling
- API key authentication
- Encryption of secrets
- And many more! Checkout [our website](https://www.onyx.app/) for the latest.
- Any many more! Checkout [our website](https://www.onyx.app/) for the latest.
To try the Onyx Enterprise Edition:

View File

@@ -23,6 +23,7 @@ def load_no_auth_user_preferences(store: KeyValueStore) -> UserPreferences:
preferences_data = cast(
Mapping[str, Any], store.load(KV_NO_AUTH_USER_PREFERENCES_KEY)
)
print("preferences_data", preferences_data)
return UserPreferences(**preferences_data)
except KvKeyNotFoundError:
return UserPreferences(

View File

@@ -55,7 +55,6 @@ from onyx.auth.invited_users import get_invited_users
from onyx.auth.schemas import UserCreate
from onyx.auth.schemas import UserRole
from onyx.auth.schemas import UserUpdate
from onyx.configs.app_configs import AUTH_COOKIE_EXPIRE_TIME_SECONDS
from onyx.configs.app_configs import AUTH_TYPE
from onyx.configs.app_configs import DISABLE_AUTH
from onyx.configs.app_configs import EMAIL_CONFIGURED
@@ -210,7 +209,6 @@ def verify_email_domain(email: str) -> None:
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret = USER_AUTH_SECRET
verification_token_secret = USER_AUTH_SECRET
verification_token_lifetime_seconds = AUTH_COOKIE_EXPIRE_TIME_SECONDS
user_db: SQLAlchemyUserDatabase[User, uuid.UUID]

View File

@@ -92,12 +92,6 @@ OAUTH_CLIENT_SECRET = (
USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "")
# Duration (in seconds) for which the FastAPI Users JWT token remains valid in the user's browser.
# By default, this is set to match the Redis expiry time for consistency.
AUTH_COOKIE_EXPIRE_TIME_SECONDS = int(
os.environ.get("AUTH_COOKIE_EXPIRE_TIME_SECONDS") or 86400 * 7
) # 7 days
# for basic auth
REQUIRE_EMAIL_VERIFICATION = (
os.environ.get("REQUIRE_EMAIL_VERIFICATION", "").lower() == "true"

View File

@@ -71,20 +71,10 @@ class AirtableConnector(LoadConnector):
self.airtable_client = AirtableApi(credentials["airtable_access_token"])
return None
@staticmethod
def _extract_field_values(
field_id: str,
field_info: Any,
field_type: str,
base_id: str,
table_id: str,
view_id: str | None,
record_id: str,
) -> list[tuple[str, str]]:
def _get_field_value(self, field_info: Any, field_type: str) -> list[str]:
"""
Extract value(s) + links from a field regardless of its type.
Attachments are represented as multiple sections, and therefore
returned as a list of tuples (value, link).
Extract value(s) from a field regardless of its type.
Returns either a single string or list of strings for attachments.
"""
if field_info is None:
return []
@@ -95,11 +85,8 @@ 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}"
if field_type == "multipleAttachments":
attachment_texts: list[tuple[str, str]] = []
attachment_texts: list[str] = []
for attachment in field_info:
url = attachment.get("url")
filename = attachment.get("filename", "")
@@ -122,7 +109,6 @@ class AirtableConnector(LoadConnector):
if attachment_content:
try:
file_ext = get_file_ext(filename)
attachment_id = attachment["id"]
attachment_text = extract_file_text(
BytesIO(attachment_content),
filename,
@@ -130,20 +116,7 @@ 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"
)
attachment_texts.append(
(f"{filename}:\n{attachment_text}", attachment_link)
)
attachment_texts.append(f"{filename}:\n{attachment_text}")
except Exception as e:
logger.warning(
f"Failed to process attachment {filename}: {str(e)}"
@@ -158,12 +131,12 @@ class AirtableConnector(LoadConnector):
combined.append(collab_name)
if collab_email:
combined.append(f"({collab_email})")
return [(" ".join(combined) if combined else str(field_info), default_link)]
return [" ".join(combined) if combined else str(field_info)]
if isinstance(field_info, list):
return [(item, default_link) for item in field_info]
return [str(item) for item in field_info]
return [(str(field_info), default_link)]
return [str(field_info)]
def _should_be_metadata(self, field_type: str) -> bool:
"""Determine if a field type should be treated as metadata."""
@@ -171,12 +144,10 @@ class AirtableConnector(LoadConnector):
def _process_field(
self,
field_id: str,
field_name: str,
field_info: Any,
field_type: str,
table_id: str,
view_id: str | None,
record_id: str,
) -> tuple[list[Section], dict[str, Any]]:
"""
@@ -194,21 +165,12 @@ class AirtableConnector(LoadConnector):
return [], {}
# Get the value(s) for the field
field_value_and_links = self._extract_field_values(
field_id=field_id,
field_info=field_info,
field_type=field_type,
base_id=self.base_id,
table_id=table_id,
view_id=view_id,
record_id=record_id,
)
if len(field_value_and_links) == 0:
field_values = self._get_field_value(field_info, field_type)
if len(field_values) == 0:
return [], {}
# Determine if it should be metadata or a section
if self._should_be_metadata(field_type):
field_values = [value for value, _ in field_value_and_links]
if len(field_values) > 1:
return [], {field_name: field_values}
return [], {field_name: field_values[0]}
@@ -216,7 +178,7 @@ class AirtableConnector(LoadConnector):
# Otherwise, create relevant sections
sections = [
Section(
link=link,
link=f"https://airtable.com/{self.base_id}/{table_id}/{record_id}",
text=(
f"{field_name}:\n"
"------------------------\n"
@@ -224,7 +186,7 @@ class AirtableConnector(LoadConnector):
"------------------------"
),
)
for text, link in field_value_and_links
for text in field_values
]
return sections, {}
@@ -257,7 +219,6 @@ class AirtableConnector(LoadConnector):
primary_field_value = (
fields.get(primary_field_name) if primary_field_name else None
)
view_id = table_schema.views[0].id if table_schema.views else None
for field_schema in table_schema.fields:
field_name = field_schema.name
@@ -265,12 +226,10 @@ class AirtableConnector(LoadConnector):
field_type = field_schema.type
field_sections, field_metadata = self._process_field(
field_id=field_schema.id,
field_name=field_name,
field_info=field_val,
field_type=field_type,
table_id=table_id,
view_id=view_id,
record_id=record_id,
)

View File

@@ -2,7 +2,7 @@ from onyx.key_value_store.interface import KeyValueStore
from onyx.key_value_store.store import PgRedisKVStore
def get_kv_store(tenant_id: str | None = None) -> KeyValueStore:
def get_kv_store() -> KeyValueStore:
# In the Multi Tenant case, the tenant context is picked up automatically, it does not need to be passed in
# It's read from the global thread level variable
return PgRedisKVStore(tenant_id=tenant_id)
return PgRedisKVStore()

View File

@@ -31,27 +31,27 @@ class PgRedisKVStore(KeyValueStore):
def __init__(
self, redis_client: Redis | None = None, tenant_id: str | None = None
) -> None:
self.tenant_id = tenant_id or CURRENT_TENANT_ID_CONTEXTVAR.get()
# If no redis_client is provided, fall back to the context var
if redis_client is not None:
self.redis_client = redis_client
else:
self.redis_client = get_redis_client(tenant_id=self.tenant_id)
tenant_id = tenant_id or CURRENT_TENANT_ID_CONTEXTVAR.get()
self.redis_client = get_redis_client(tenant_id=tenant_id)
@contextmanager
def _get_session(self) -> Iterator[Session]:
def get_session(self) -> Iterator[Session]:
engine = get_sqlalchemy_engine()
with Session(engine, expire_on_commit=False) as session:
if MULTI_TENANT:
if self.tenant_id == POSTGRES_DEFAULT_SCHEMA:
tenant_id = CURRENT_TENANT_ID_CONTEXTVAR.get()
if tenant_id == POSTGRES_DEFAULT_SCHEMA:
raise HTTPException(
status_code=401, detail="User must authenticate"
)
if not is_valid_schema_name(self.tenant_id):
if not is_valid_schema_name(tenant_id):
raise HTTPException(status_code=400, detail="Invalid tenant ID")
# Set the search_path to the tenant's schema
session.execute(text(f'SET search_path = "{self.tenant_id}"'))
session.execute(text(f'SET search_path = "{tenant_id}"'))
yield session
def store(self, key: str, val: JSON_ro, encrypt: bool = False) -> None:
@@ -66,7 +66,7 @@ class PgRedisKVStore(KeyValueStore):
encrypted_val = val if encrypt else None
plain_val = val if not encrypt else None
with self._get_session() as session:
with self.get_session() as session:
obj = session.query(KVStore).filter_by(key=key).first()
if obj:
obj.value = plain_val
@@ -88,7 +88,7 @@ class PgRedisKVStore(KeyValueStore):
except Exception as e:
logger.error(f"Failed to get value from Redis for key '{key}': {str(e)}")
with self._get_session() as session:
with self.get_session() as session:
obj = session.query(KVStore).filter_by(key=key).first()
if not obj:
raise KvKeyNotFoundError
@@ -113,7 +113,7 @@ class PgRedisKVStore(KeyValueStore):
except Exception as e:
logger.error(f"Failed to delete value from Redis for key '{key}': {str(e)}")
with self._get_session() as session:
with self.get_session() as session:
result = session.query(KVStore).filter_by(key=key).delete() # type: ignore
if result == 0:
raise KvKeyNotFoundError

View File

@@ -275,22 +275,17 @@ class DefaultMultiLLM(LLM):
# addtional kwargs (and some kwargs MUST be passed in rather than set as
# env variables)
if custom_config:
# Specifically pass in "vertex_credentials" / "vertex_location" as a
# model_kwarg to the completion call for vertex AI. More details here:
# Specifically pass in "vertex_credentials" as a model_kwarg to the
# completion call for vertex AI. More details here:
# https://docs.litellm.ai/docs/providers/vertex
vertex_credentials_key = "vertex_credentials"
vertex_location_key = "vertex_location"
for k, v in custom_config.items():
if model_provider == "vertex_ai":
if k == vertex_credentials_key:
model_kwargs[k] = v
continue
elif k == vertex_location_key:
model_kwargs[k] = v
continue
# for all values, set them as env variables
os.environ[k] = v
vertex_credentials = custom_config.get(vertex_credentials_key)
if vertex_credentials and model_provider == "vertex_ai":
model_kwargs[vertex_credentials_key] = vertex_credentials
else:
# standard case
for k, v in custom_config.items():
os.environ[k] = v
if extra_headers:
model_kwargs.update({"extra_headers": extra_headers})

View File

@@ -212,7 +212,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
if not MULTI_TENANT:
# We cache this at the beginning so there is no delay in the first telemetry
get_or_generate_uuid(tenant_id=None)
get_or_generate_uuid()
# If we are multi-tenant, we need to only set up initial public tables
with Session(engine) as db_session:

View File

@@ -14,7 +14,6 @@ from typing import Set
from prometheus_client import Gauge
from prometheus_client import start_http_server
from redis.lock import Lock
from slack_sdk import WebClient
from slack_sdk.socket_mode.request import SocketModeRequest
from slack_sdk.socket_mode.response import SocketModeResponse
@@ -123,9 +122,6 @@ class SlackbotHandler:
self.socket_clients: Dict[tuple[str | None, int], TenantSocketModeClient] = {}
self.slack_bot_tokens: Dict[tuple[str | None, int], SlackBotTokens] = {}
# Store Redis lock objects here so we can release them properly
self.redis_locks: Dict[str | None, Lock] = {}
self.running = True
self.pod_id = self.get_pod_id()
self._shutdown_event = Event()
@@ -163,15 +159,10 @@ class SlackbotHandler:
while not self._shutdown_event.is_set():
try:
self.acquire_tenants()
# After we finish acquiring and managing Slack bots,
# set the gauge to the number of active tenants (those with Slack bots).
active_tenants_gauge.labels(namespace=POD_NAMESPACE, pod=POD_NAME).set(
len(self.tenant_ids)
)
logger.debug(
f"Current active tenants with Slack bots: {len(self.tenant_ids)}"
)
logger.debug(f"Current active tenants: {len(self.tenant_ids)}")
except Exception as e:
logger.exception(f"Error in Slack acquisition: {e}")
self._shutdown_event.wait(timeout=TENANT_ACQUISITION_INTERVAL)
@@ -180,9 +171,7 @@ class SlackbotHandler:
while not self._shutdown_event.is_set():
try:
self.send_heartbeats()
logger.debug(
f"Sent heartbeats for {len(self.tenant_ids)} active tenants"
)
logger.debug(f"Sent heartbeats for {len(self.tenant_ids)} tenants")
except Exception as e:
logger.exception(f"Error in heartbeat loop: {e}")
self._shutdown_event.wait(timeout=TENANT_HEARTBEAT_INTERVAL)
@@ -190,21 +179,17 @@ class SlackbotHandler:
def _manage_clients_per_tenant(
self, db_session: Session, tenant_id: str | None, bot: SlackBot
) -> None:
"""
- If the tokens are missing or empty, close the socket client and remove them.
- If the tokens have changed, close the existing socket client and reconnect.
- If the tokens are new, warm up the model and start a new socket client.
"""
slack_bot_tokens = SlackBotTokens(
bot_token=bot.bot_token,
app_token=bot.app_token,
)
tenant_bot_pair = (tenant_id, bot.id)
# If the tokens are missing or empty, close the socket client and remove them.
# If the tokens are not set, we need to close the socket client and delete the tokens
# for the tenant and app
if not slack_bot_tokens:
logger.debug(
f"No Slack bot tokens found for tenant={tenant_id}, bot {bot.id}"
f"No Slack bot token found for tenant {tenant_id}, bot {bot.id}"
)
if tenant_bot_pair in self.socket_clients:
asyncio.run(self.socket_clients[tenant_bot_pair].close())
@@ -219,10 +204,9 @@ class SlackbotHandler:
if not tokens_exist or tokens_changed:
if tokens_exist:
logger.info(
f"Slack Bot tokens changed for tenant={tenant_id}, bot {bot.id}; reconnecting"
f"Slack Bot tokens have changed for tenant {tenant_id}, bot {bot.id} - reconnecting"
)
else:
# Warm up the model if needed
search_settings = get_current_search_settings(db_session)
embedding_model = EmbeddingModel.from_db_model(
search_settings=search_settings,
@@ -233,168 +217,77 @@ class SlackbotHandler:
self.slack_bot_tokens[tenant_bot_pair] = slack_bot_tokens
# Close any existing connection first
if tenant_bot_pair in self.socket_clients:
asyncio.run(self.socket_clients[tenant_bot_pair].close())
self.start_socket_client(bot.id, tenant_id, slack_bot_tokens)
def acquire_tenants(self) -> None:
"""
- Attempt to acquire a Redis lock for each tenant.
- If acquired, check if that tenant actually has Slack bots.
- If yes, store them in self.tenant_ids and manage the socket connections.
- If a tenant in self.tenant_ids no longer has Slack bots, remove it (and release the lock in this scope).
"""
all_tenants = get_all_tenant_ids()
tenant_ids = get_all_tenant_ids()
# 1) Try to acquire locks for new tenants
for tenant_id in all_tenants:
for tenant_id in tenant_ids:
if (
DISALLOWED_SLACK_BOT_TENANT_LIST is not None
and tenant_id in DISALLOWED_SLACK_BOT_TENANT_LIST
):
logger.debug(f"Tenant {tenant_id} is disallowed; skipping.")
logger.debug(f"Tenant {tenant_id} is in the disallowed list, skipping")
continue
# Already acquired in a previous loop iteration?
if tenant_id in self.tenant_ids:
logger.debug(f"Tenant {tenant_id} already in self.tenant_ids")
continue
# Respect max tenant limit per pod
if len(self.tenant_ids) >= MAX_TENANTS_PER_POD:
logger.info(
f"Max tenants per pod reached ({MAX_TENANTS_PER_POD}); not acquiring more."
f"Max tenants per pod reached ({MAX_TENANTS_PER_POD}) Not acquiring any more tenants"
)
break
redis_client = get_redis_client(tenant_id=tenant_id)
# Acquire a Redis lock (non-blocking)
rlock = redis_client.lock(
OnyxRedisLocks.SLACK_BOT_LOCK, timeout=TENANT_LOCK_EXPIRATION
pod_id = self.pod_id
acquired = redis_client.set(
OnyxRedisLocks.SLACK_BOT_LOCK,
pod_id,
nx=True,
ex=TENANT_LOCK_EXPIRATION,
)
lock_acquired = rlock.acquire(blocking=False)
if not lock_acquired and not DEV_MODE:
logger.debug(
f"Another pod holds the lock for tenant {tenant_id}, skipping."
)
if not acquired and not DEV_MODE:
logger.debug(f"Another pod holds the lock for tenant {tenant_id}")
continue
if lock_acquired:
logger.debug(f"Acquired lock for tenant {tenant_id}.")
self.redis_locks[tenant_id] = rlock
else:
# DEV_MODE will skip the lock acquisition guard
logger.debug(
f"Running in DEV_MODE. Not enforcing lock for {tenant_id}."
)
logger.debug(f"Acquired lock for tenant {tenant_id}")
# Now check if this tenant actually has Slack bots
self.tenant_ids.add(tenant_id)
for tenant_id in self.tenant_ids:
token = CURRENT_TENANT_ID_CONTEXTVAR.set(
tenant_id or POSTGRES_DEFAULT_SCHEMA
)
try:
with get_session_with_tenant(tenant_id) as db_session:
bots: list[SlackBot] = []
try:
bots = list(fetch_slack_bots(db_session=db_session))
except KvKeyNotFoundError:
# No Slackbot tokens, pass
pass
except Exception as e:
logger.exception(
f"Error fetching Slack bots for tenant {tenant_id}: {e}"
)
if bots:
# Mark as active tenant
self.tenant_ids.add(tenant_id)
bots = fetch_slack_bots(db_session=db_session)
for bot in bots:
self._manage_clients_per_tenant(
db_session=db_session,
tenant_id=tenant_id,
bot=bot,
)
else:
# If no Slack bots, release lock immediately (unless in DEV_MODE)
if lock_acquired and not DEV_MODE:
rlock.release()
del self.redis_locks[tenant_id]
logger.debug(
f"No Slack bots for tenant {tenant_id}; lock released (if held)."
)
finally:
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
# 2) Make sure tenants we're handling still have Slack bots
for tenant_id in list(self.tenant_ids):
token = CURRENT_TENANT_ID_CONTEXTVAR.set(
tenant_id or POSTGRES_DEFAULT_SCHEMA
)
redis_client = get_redis_client(tenant_id=tenant_id)
try:
with get_session_with_tenant(tenant_id) as db_session:
# Attempt to fetch Slack bots
try:
bots = list(fetch_slack_bots(db_session=db_session))
except KvKeyNotFoundError:
# No Slackbot tokens, pass (and remove below)
bots = []
logger.debug(f"Missing Slack Bot tokens for tenant {tenant_id}")
if (tenant_id, bot.id) in self.socket_clients:
asyncio.run(self.socket_clients[tenant_id, bot.id].close())
del self.socket_clients[tenant_id, bot.id]
del self.slack_bot_tokens[tenant_id, bot.id]
except Exception as e:
logger.exception(f"Error handling tenant {tenant_id}: {e}")
bots = []
if not bots:
logger.info(
f"Tenant {tenant_id} no longer has Slack bots. Removing."
)
self._remove_tenant(tenant_id)
# NOTE: We release the lock here (in the same scope it was acquired)
if tenant_id in self.redis_locks and not DEV_MODE:
try:
self.redis_locks[tenant_id].release()
del self.redis_locks[tenant_id]
logger.info(f"Released lock for tenant {tenant_id}")
except Exception as e:
logger.error(
f"Error releasing lock for tenant {tenant_id}: {e}"
)
else:
# Manage or reconnect Slack bot sockets
for bot in bots:
self._manage_clients_per_tenant(
db_session=db_session,
tenant_id=tenant_id,
bot=bot,
)
finally:
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
def _remove_tenant(self, tenant_id: str | None) -> None:
"""
Helper to remove a tenant from `self.tenant_ids` and close any socket clients.
(Lock release now happens in `acquire_tenants()`, not here.)
"""
# Close all socket clients for this tenant
for (t_id, slack_bot_id), client in list(self.socket_clients.items()):
if t_id == tenant_id:
asyncio.run(client.close())
del self.socket_clients[(t_id, slack_bot_id)]
del self.slack_bot_tokens[(t_id, slack_bot_id)]
logger.info(
f"Stopped SocketModeClient for tenant: {t_id}, app: {slack_bot_id}"
)
# Remove from active set
if tenant_id in self.tenant_ids:
self.tenant_ids.remove(tenant_id)
def send_heartbeats(self) -> None:
current_time = int(time.time())
logger.debug(f"Sending heartbeats for {len(self.tenant_ids)} active tenants")
logger.debug(f"Sending heartbeats for {len(self.tenant_ids)} tenants")
for tenant_id in self.tenant_ids:
redis_client = get_redis_client(tenant_id=tenant_id)
heartbeat_key = f"{OnyxRedisLocks.SLACK_BOT_HEARTBEAT_PREFIX}:{self.pod_id}"
@@ -422,7 +315,6 @@ class SlackbotHandler:
)
socket_client.connect()
self.socket_clients[tenant_id, slack_bot_id] = socket_client
# Ensure tenant is tracked as active
self.tenant_ids.add(tenant_id)
logger.info(
f"Started SocketModeClient for tenant: {tenant_id}, app: {slack_bot_id}"
@@ -430,7 +322,7 @@ class SlackbotHandler:
def stop_socket_clients(self) -> None:
logger.info(f"Stopping {len(self.socket_clients)} socket clients")
for (tenant_id, slack_bot_id), client in list(self.socket_clients.items()):
for (tenant_id, slack_bot_id), client in self.socket_clients.items():
asyncio.run(client.close())
logger.info(
f"Stopped SocketModeClient for tenant: {tenant_id}, app: {slack_bot_id}"
@@ -448,19 +340,17 @@ class SlackbotHandler:
logger.info(f"Stopping {len(self.socket_clients)} socket clients")
self.stop_socket_clients()
# Release locks for all tenants we currently hold
# Release locks for all tenants
logger.info(f"Releasing locks for {len(self.tenant_ids)} tenants")
for tenant_id in list(self.tenant_ids):
if tenant_id in self.redis_locks:
try:
self.redis_locks[tenant_id].release()
logger.info(f"Released lock for tenant {tenant_id}")
except Exception as e:
logger.error(f"Error releasing lock for tenant {tenant_id}: {e}")
finally:
del self.redis_locks[tenant_id]
for tenant_id in self.tenant_ids:
try:
redis_client = get_redis_client(tenant_id=tenant_id)
redis_client.delete(OnyxRedisLocks.SLACK_BOT_LOCK)
logger.info(f"Released lock for tenant {tenant_id}")
except Exception as e:
logger.error(f"Error releasing lock for tenant {tenant_id}: {e}")
# Wait for background threads to finish (with a timeout)
# Wait for background threads to finish (with timeout)
logger.info("Waiting for background threads to finish...")
self.acquire_thread.join(timeout=5)
self.heartbeat_thread.join(timeout=5)

View File

@@ -7,7 +7,6 @@ from fastapi import HTTPException
from fastapi import Query
from fastapi import UploadFile
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from onyx.auth.users import current_admin_user
@@ -278,14 +277,8 @@ def create_label(
_: User | None = Depends(current_user),
) -> PersonaLabelResponse:
"""Create a new assistant label"""
try:
label_model = create_assistant_label(name=label.name, db_session=db)
return PersonaLabelResponse.from_model(label_model)
except IntegrityError:
raise HTTPException(
status_code=400,
detail=f"Label with name '{label.name}' already exists. Please choose a different name.",
)
label_model = create_assistant_label(name=label.name, db_session=db)
return PersonaLabelResponse.from_model(label_model)
@admin_router.patch("/label/{label_id}")

View File

@@ -714,6 +714,7 @@ def update_user_pinned_assistants(
store = get_kv_store()
no_auth_user = fetch_no_auth_user(store)
no_auth_user.preferences.pinned_assistants = ordered_assistant_ids
print("ordered_assistant_ids", ordered_assistant_ids)
set_no_auth_user_preferences(store, no_auth_user.preferences)
return
else:

View File

@@ -11,7 +11,7 @@ from onyx.configs.app_configs import ENTERPRISE_EDITION_ENABLED
from onyx.configs.constants import KV_CUSTOMER_UUID_KEY
from onyx.configs.constants import KV_INSTANCE_DOMAIN_KEY
from onyx.configs.constants import MilestoneRecordType
from onyx.db.engine import get_session_with_tenant
from onyx.db.engine import get_sqlalchemy_engine
from onyx.db.milestone import create_milestone_if_not_exists
from onyx.db.models import User
from onyx.key_value_store.factory import get_kv_store
@@ -41,7 +41,7 @@ def _get_or_generate_customer_id_mt(tenant_id: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_X500, tenant_id))
def get_or_generate_uuid(tenant_id: str | None) -> str:
def get_or_generate_uuid(tenant_id: str | None = None) -> str:
# TODO: split out the whole "instance UUID" generation logic into a separate
# utility function. Telemetry should not be aware at all of how the UUID is
# generated/stored.
@@ -52,7 +52,7 @@ def get_or_generate_uuid(tenant_id: str | None) -> str:
if _CACHED_UUID is not None:
return _CACHED_UUID
kv_store = get_kv_store(tenant_id=tenant_id)
kv_store = get_kv_store()
try:
_CACHED_UUID = cast(str, kv_store.load(KV_CUSTOMER_UUID_KEY))
@@ -63,18 +63,18 @@ def get_or_generate_uuid(tenant_id: str | None) -> str:
return _CACHED_UUID
def _get_or_generate_instance_domain(tenant_id: str | None = None) -> str | None: #
def _get_or_generate_instance_domain() -> str | None: #
global _CACHED_INSTANCE_DOMAIN
if _CACHED_INSTANCE_DOMAIN is not None:
return _CACHED_INSTANCE_DOMAIN
kv_store = get_kv_store(tenant_id=tenant_id)
kv_store = get_kv_store()
try:
_CACHED_INSTANCE_DOMAIN = cast(str, kv_store.load(KV_INSTANCE_DOMAIN_KEY))
except KvKeyNotFoundError:
with get_session_with_tenant(tenant_id=tenant_id) as db_session:
with Session(get_sqlalchemy_engine()) as db_session:
first_user = db_session.query(User).first()
if first_user:
_CACHED_INSTANCE_DOMAIN = first_user.email.split("@")[-1]
@@ -94,16 +94,16 @@ def optional_telemetry(
if DISABLE_TELEMETRY:
return
tenant_id = tenant_id or CURRENT_TENANT_ID_CONTEXTVAR.get()
try:
def telemetry_logic() -> None:
try:
customer_uuid = (
_get_or_generate_customer_id_mt(tenant_id)
_get_or_generate_customer_id_mt(
tenant_id or CURRENT_TENANT_ID_CONTEXTVAR.get()
)
if MULTI_TENANT
else get_or_generate_uuid(tenant_id)
else get_or_generate_uuid()
)
payload = {
"data": data,
@@ -115,9 +115,7 @@ def optional_telemetry(
"is_cloud": MULTI_TENANT,
}
if ENTERPRISE_EDITION_ENABLED:
payload["instance_domain"] = _get_or_generate_instance_domain(
tenant_id
)
payload["instance_domain"] = _get_or_generate_instance_domain()
requests.post(
_DANSWER_TELEMETRY_ENDPOINT,
headers={"Content-Type": "application/json"},

View File

@@ -45,7 +45,7 @@ def create_test_document(
submitted_by: str,
assignee: str,
days_since_status_change: int | None,
attachments: list[tuple[str, str]] | None = None,
attachments: list | None = None,
) -> Document:
link_base = f"https://airtable.com/{os.environ['AIRTABLE_TEST_BASE_ID']}/{os.environ['AIRTABLE_TEST_TABLE_ID']}"
sections = [
@@ -60,11 +60,11 @@ def create_test_document(
]
if attachments:
for attachment_text, attachment_link in attachments:
for attachment in attachments:
sections.append(
Section(
text=f"Attachment:\n------------------------\n{attachment_text}\n------------------------",
link=attachment_link,
text=f"Attachment:\n------------------------\n{attachment}\n------------------------",
link=f"{link_base}/{id}",
),
)
@@ -142,13 +142,7 @@ def test_airtable_connector_basic(
days_since_status_change=0,
assignee="Chris Weaver (chris@onyx.app)",
submitted_by="Chris Weaver (chris@onyx.app)",
attachments=[
(
"Test.pdf:\ntesting!!!",
# hard code link for now
"https://airtable.com/appCXJqDFS4gea8tn/tblRxFQsTlBBZdRY1/viwVUEJjWPd8XYjh8/reccSlIA4pZEFxPBg/fld1u21zkJACIvAEF/attlj2UBWNEDZngCc?blocks=hide",
)
],
attachments=["Test.pdf:\ntesting!!!"],
),
]

View File

@@ -38,12 +38,6 @@ def get_credentials() -> dict[str, str]:
}
@pytest.mark.xfail(
reason=(
"Cannot get Zendesk developer account to ensure zendesk account does not "
"expire after 2 weeks"
)
)
@pytest.mark.parametrize(
"connector_fixture", ["zendesk_article_connector", "zendesk_ticket_connector"]
)
@@ -102,12 +96,6 @@ def test_zendesk_connector_basic(
)
@pytest.mark.xfail(
reason=(
"Cannot get Zendesk developer account to ensure zendesk account does not "
"expire after 2 weeks"
)
)
def test_zendesk_connector_slim(zendesk_article_connector: ZendeskConnector) -> None:
# Get full doc IDs
all_full_doc_ids = set()

View File

@@ -50,7 +50,7 @@ def test_create_llm_provider_without_display_model_names(
def test_update_llm_provider_model_names(admin_user: DATestUser) -> None:
"""Test updating an LLM provider's model_namesds"""
"""Test updating an LLM provider's model_names"""
# First create provider without model_names
name = str(uuid.uuid4())
response = requests.put(

View File

@@ -22,7 +22,6 @@ const cspHeader = `
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
output: "standalone",
publicRuntimeConfig: {
version,

133
web/package-lock.json generated
View File

@@ -21,7 +21,6 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
@@ -4540,138 +4539,6 @@
}
}
},
"node_modules/@radix-ui/react-scroll-area": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.2.tgz",
"integrity": "sha512-EFI1N/S3YxZEW/lJ/H1jY3njlvTd8tBmgKEn4GHi51+aMm94i6NmAJstsm5cu3yJwYqYc93gpCPm21FeAbFk6g==",
"license": "MIT",
"dependencies": {
"@radix-ui/number": "1.1.0",
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-context": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz",
"integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-select": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.2.tgz",

View File

@@ -24,7 +24,6 @@
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-radio-group": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",

View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useCallback } from "react";
import { Option } from "@/components/Dropdown";
import { generateRandomIconShape } from "@/lib/assistantIconUtils";
import { CCPairBasicInfo, DocumentSet, User, UserGroup } from "@/lib/types";
@@ -35,7 +35,7 @@ import {
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { FiInfo } from "react-icons/fi";
import { FiInfo, FiRefreshCcw, FiUsers } from "react-icons/fi";
import * as Yup from "yup";
import CollapsibleSection from "./CollapsibleSection";
import { SuccessfulPersonaUpdateRedirectType } from "./enums";
@@ -60,11 +60,10 @@ import { useAssistants } from "@/components/context/AssistantsContext";
import { debounce } from "lodash";
import { FullLLMProvider } from "../configuration/llm/interfaces";
import StarterMessagesList from "./StarterMessageList";
import { Switch, SwitchField } from "@/components/ui/switch";
import { Switch } from "@/components/ui/switch";
import { generateIdenticon } from "@/components/assistants/AssistantIcon";
import { BackButton } from "@/components/BackButton";
import { Checkbox, CheckboxField } from "@/components/ui/checkbox";
import { Checkbox } from "@/components/ui/checkbox";
import { AdvancedOptionsToggle } from "@/components/AdvancedOptionsToggle";
import { MinimalUserSnapshot } from "@/lib/types";
import { useUserGroups } from "@/lib/hooks";
@@ -73,13 +72,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 } 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 { DeletePersonaButton } from "./[id]/DeletePersonaButton";
import Title from "@/components/ui/title";
function findSearchTool(tools: ToolSnapshot[]) {
return tools.find((tool) => tool.in_code_tool_id === "SearchTool");
@@ -131,8 +128,8 @@ export function AssistantEditor({
const router = useRouter();
const { popup, setPopup } = usePopup();
const { labels, refreshLabels, createLabel, updateLabel, deleteLabel } =
useLabels();
const { data, refreshLabels } = useLabels();
const labels = data || [];
const colorOptions = [
"#FF6FBF",
@@ -144,7 +141,11 @@ export function AssistantEditor({
"#6FFFFF",
];
const [showSearchTool, setShowSearchTool] = useState(false);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [hasEditedStarterMessage, setHasEditedStarterMessage] = useState(false);
const [showPersonaLabel, setShowPersonaLabel] = useState(!admin);
// state to persist across formik reformatting
const [defautIconColor, _setDeafultIconColor] = useState(
@@ -329,10 +330,6 @@ export function AssistantEditor({
}));
};
if (!labels) {
return <></>;
}
return (
<div className="mx-auto max-w-4xl">
<style>
@@ -354,7 +351,7 @@ export function AssistantEditor({
entityName={labelToDelete.name}
onClose={() => setLabelToDelete(null)}
onSubmit={async () => {
const response = await deleteLabel(labelToDelete.id);
const response = await deletePersonaLabel(labelToDelete.id);
if (response?.ok) {
setPopup({
message: `Label deleted successfully`,
@@ -578,7 +575,7 @@ export function AssistantEditor({
return (
<Form className="w-full text-text-950 assistant-editor">
{/* Refresh starter messages when name or description changes */}
<p className="text-base font-normal text-2xl">
<p className="text-base font-normal !text-2xl">
{existingPersona ? (
<>
Edit assistant <b>{existingPersona.name}</b>
@@ -747,6 +744,97 @@ export function AssistantEditor({
className="[&_input]:placeholder:text-text-muted/50"
/>
<div className=" w-full max-w-4xl">
<Separator />
<div className="flex gap-x-2 items-center mt-4 ">
<div className="block font-medium text-sm">Labels</div>
</div>
<p
className="text-sm text-subtle"
style={{ color: "rgb(113, 114, 121)" }}
>
Select labels to categorize this assistant
</p>
<div className="mt-3">
<SearchMultiSelectDropdown
onCreateLabel={async (name: string) => {
await createPersonaLabel(name);
const currentLabels = await refreshLabels();
setTimeout(() => {
const newLabelId = currentLabels.find(
(l: { name: string }) => l.name === name
)?.id;
const updatedLabelIds = [
...values.label_ids,
newLabelId as number,
];
setFieldValue("label_ids", updatedLabelIds);
}, 300);
}}
options={Array.from(
new Set(labels.map((label) => label.name))
).map((name) => ({
name,
value: name,
}))}
onSelect={(selected) => {
const newLabelIds = [
...values.label_ids,
labels.find((l) => l.name === selected.value)
?.id as number,
];
setFieldValue("label_ids", newLabelIds);
}}
itemComponent={({ option }) => (
<div
className="flex items-center px-4 py-2.5 text-sm hover:bg-hover cursor-pointer"
onClick={() => {
const label = labels.find(
(l) => l.name === option.value
);
if (label) {
const isSelected = values.label_ids.includes(
label.id
);
const newLabelIds = isSelected
? values.label_ids.filter(
(id: number) => id !== label.id
)
: [...values.label_ids, label.id];
setFieldValue("label_ids", newLabelIds);
}
}}
>
<span className="text-sm font-medium leading-none">
{option.name}
</span>
</div>
)}
/>
<div className="mt-2 flex flex-wrap gap-2">
{values.label_ids.map((labelId: number) => {
const label = labels.find((l) => l.id === labelId);
return label ? (
<SourceChip
key={label.id}
onRemove={() => {
setFieldValue(
"label_ids",
values.label_ids.filter(
(id: number) => id !== label.id
)
);
}}
title={label.name}
icon={<TagIcon size={12} />}
/>
) : null;
})}
</div>
</div>
</div>
<Separator />
<TextFormField
@@ -784,9 +872,10 @@ export function AssistantEditor({
: ""
}`}
>
<SwitchField
<Switch
size="sm"
onCheckedChange={(checked) => {
setShowSearchTool(checked);
setFieldValue("num_chunks", null);
toggleToolInValues(searchTool.id);
}}
@@ -800,7 +889,8 @@ export function AssistantEditor({
<TooltipContent side="top" align="center">
<p className="bg-background-900 max-w-[200px] text-sm rounded-lg p-1.5 text-white">
To use the Knowledge Action, you need to
have at least one Connector configured.
have at least one Connector-Credential
pair configured.
</p>
</TooltipContent>
)}
@@ -820,7 +910,7 @@ export function AssistantEditor({
)}
{ccPairs.length > 0 &&
searchTool &&
values.enabled_tools_map[searchTool.id] &&
showSearchTool &&
!(user?.role != "admin" && documentSets.length === 0) && (
<CollapsibleSection>
<div className="mt-2">
@@ -908,10 +998,14 @@ export function AssistantEditor({
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<CheckboxField
<Checkbox
size="sm"
id={`enabled_tools_map.${imageGenerationTool.id}`}
name={`enabled_tools_map.${imageGenerationTool.id}`}
checked={
values.enabled_tools_map[
imageGenerationTool.id
]
}
onCheckedChange={() => {
if (
currentLLMSupportsImageOutput &&
@@ -967,7 +1061,6 @@ export function AssistantEditor({
onCheckedChange={() => {
toggleToolInValues(internetSearchTool.id);
}}
name={`enabled_tools_map.${internetSearchTool.id}`}
/>
<div className="flex flex-col ml-2">
<span className="text-sm">
@@ -987,7 +1080,6 @@ export function AssistantEditor({
<React.Fragment key={tool.id}>
<div className="flex items-center content-start mb-2">
<Checkbox
size="sm"
id={`enabled_tools_map.${tool.id}`}
checked={values.enabled_tools_map[tool.id]}
onCheckedChange={() => {
@@ -1022,6 +1114,7 @@ export function AssistantEditor({
)
: null
}
userDefault={user?.preferences?.default_model || null}
requiresImageGeneration={
imageGenerationTool
? values.enabled_tools_map[imageGenerationTool.id]
@@ -1043,6 +1136,106 @@ export function AssistantEditor({
/>
</div>
{admin && labels && labels.length > 0 && (
<div className=" max-w-4xl">
<Separator />
<div className="flex gap-x-2 items-center ">
<div className="block font-medium text-sm">
Manage Labels
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<FiInfo size={12} />
</TooltipTrigger>
<TooltipContent side="top" align="center">
Manage existing labels or create new ones to group
similar assistants
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<SubLabel>Edit or delete existing labels</SubLabel>
<div className="grid grid-cols-1 gap-4">
{labels.map((label: PersonaLabel) => (
<div
key={label.id}
className="grid grid-cols-[1fr,2fr,auto] gap-4 items-end"
>
<TextFormField
fontSize="sm"
name={`editLabelName_${label.id}`}
label="Label Name"
value={
values.editLabelId === label.id
? values.editLabelName
: label.name
}
onChange={(e) => {
setFieldValue("editLabelId", label.id);
setFieldValue("editLabelName", e.target.value);
}}
/>
<div className="flex gap-2">
{values.editLabelId === label.id ? (
<>
<Button
onClick={async () => {
const updatedName =
values.editLabelName || label.name;
const response = await updatePersonaLabel(
label.id,
updatedName
);
if (response?.ok) {
setPopup({
message: `Label "${updatedName}" updated successfully`,
type: "success",
});
await refreshLabels();
setFieldValue("editLabelId", null);
setFieldValue("editLabelName", "");
setFieldValue("editLabelDescription", "");
} else {
setPopup({
message: `Failed to update label - ${await response.text()}`,
type: "error",
});
}
}}
>
Save
</Button>
<Button
variant="outline"
onClick={() => {
setFieldValue("editLabelId", null);
setFieldValue("editLabelName", "");
setFieldValue("editLabelDescription", "");
}}
>
Cancel
</Button>
</>
) : (
<>
<Button
variant="destructive"
onClick={async () => {
setLabelToDelete(label);
}}
>
Delete
</Button>
</>
)}
</div>
</div>
))}
</div>
</div>
)}
<Separator />
<AdvancedOptionsToggle
showAdvancedOptions={showAdvancedOptions}
@@ -1060,9 +1253,9 @@ export function AssistantEditor({
<div className="min-h-[100px]">
<div className="flex items-center mb-2">
<SwitchField
name="is_public"
<Switch
size="md"
checked={values.is_public}
onCheckedChange={(checked) => {
setFieldValue("is_public", checked);
if (checked) {
@@ -1204,124 +1397,19 @@ export function AssistantEditor({
autoStarterMessageEnabled={
autoStarterMessageEnabled
}
errors={errors}
isRefreshing={isRefreshing}
values={values.starter_messages}
arrayHelpers={arrayHelpers}
touchStarterMessages={() => {
setHasEditedStarterMessage(true);
}}
setFieldValue={setFieldValue}
/>
)}
/>
</div>
</div>
<div className=" w-full max-w-4xl">
<Separator />
<div className="flex gap-x-2 items-center mt-4 ">
<div className="block font-medium text-sm">Labels</div>
</div>
<p
className="text-sm text-subtle"
style={{ color: "rgb(113, 114, 121)" }}
>
Select labels to categorize this assistant
</p>
<div className="mt-3">
<SearchMultiSelectDropdown
onCreate={async (name: string) => {
await createLabel(name);
const currentLabels = await refreshLabels();
setTimeout(() => {
const newLabelId = currentLabels.find(
(l: { name: string }) => l.name === name
)?.id;
const updatedLabelIds = [
...values.label_ids,
newLabelId as number,
];
setFieldValue("label_ids", updatedLabelIds);
}, 300);
}}
options={Array.from(
new Set(labels.map((label) => label.name))
).map((name) => ({
name,
value: name,
}))}
onSelect={(selected) => {
const newLabelIds = [
...values.label_ids,
labels.find((l) => l.name === selected.value)
?.id as number,
];
setFieldValue("label_ids", newLabelIds);
}}
itemComponent={({ option }) => (
<div className="flex items-center justify-between px-4 py-3 text-sm hover:bg-hover cursor-pointer border-b border-border last:border-b-0">
<div
className="flex-grow"
onClick={() => {
const label = labels.find(
(l) => l.name === option.value
);
if (label) {
const isSelected = values.label_ids.includes(
label.id
);
const newLabelIds = isSelected
? values.label_ids.filter(
(id: number) => id !== label.id
)
: [...values.label_ids, label.id];
setFieldValue("label_ids", newLabelIds);
}
}}
>
<span className="font-normal leading-none">
{option.name}
</span>
</div>
{admin && (
<button
onClick={(e) => {
e.stopPropagation();
const label = labels.find(
(l) => l.name === option.value
);
if (label) {
deleteLabel(label.id);
}
}}
className="ml-2 p-1 hover:bg-background-hover rounded"
>
<TrashIcon size={16} />
</button>
)}
</div>
)}
/>
<div className="mt-2 flex flex-wrap gap-2">
{values.label_ids.map((labelId: number) => {
const label = labels.find((l) => l.id === labelId);
return label ? (
<SourceChip
key={label.id}
onRemove={() => {
setFieldValue(
"label_ids",
values.label_ids.filter(
(id: number) => id !== label.id
)
);
}}
title={label.name}
icon={<TagIcon size={12} />}
/>
) : null;
})}
</div>
</div>
</div>
<Separator />
<div className="flex flex-col gap-y-4">
@@ -1347,6 +1435,7 @@ export function AssistantEditor({
small
subtext="Documents prior to this date will be ignored."
label="[Optional] Knowledge Cutoff Date"
value={values.search_start_date}
name="search_start_date"
/>
@@ -1385,14 +1474,6 @@ export function AssistantEditor({
explanationLink="https://docs.onyx.app/guides/assistants"
className="[&_textarea]:placeholder:text-text-muted/50"
/>
<div className="flex justify-end">
{existingPersona && (
<DeletePersonaButton
personaId={existingPersona!.id}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
)}
</div>
</>
)}

View File

@@ -1,182 +0,0 @@
"use client";
import React from "react";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { SubLabel, TextFormField } from "@/components/admin/connectors/Field";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useLabels } from "@/lib/hooks";
import { PersonaLabel } from "./interfaces";
import { Form, Formik, FormikHelpers } from "formik";
import Title from "@/components/ui/title";
interface FormValues {
newLabelName: string;
editLabelId: number | null;
editLabelName: string;
}
export default function LabelManagement() {
const { labels, createLabel, updateLabel, deleteLabel } = useLabels();
const { setPopup, popup } = usePopup();
if (!labels) return null;
const handleSubmit = async (
values: FormValues,
{ setSubmitting, resetForm }: FormikHelpers<FormValues>
) => {
if (values.newLabelName.trim()) {
const response = await createLabel(values.newLabelName.trim());
if (response.ok) {
setPopup({
message: `Label "${values.newLabelName}" created successfully`,
type: "success",
});
resetForm();
} else {
const errorMsg = (await response.json()).detail;
setPopup({
message: `Failed to create label - ${errorMsg}`,
type: "error",
});
}
}
setSubmitting(false);
};
return (
<div>
{popup}
<div className="max-w-4xl">
<div className="flex gap-x-2 items-center">
<Title size="lg">Manage Labels</Title>
</div>
<Formik<FormValues>
initialValues={{
newLabelName: "",
editLabelId: null,
editLabelName: "",
}}
onSubmit={handleSubmit}
>
{({ values, setFieldValue, isSubmitting }) => (
<Form>
<div className="flex flex-col gap-4 mt-4 mb-6">
<div className="flex flex-col">
<Title className="text-lg">Create New Label</Title>
<SubLabel>
Labels are used to categorize personas. You can create a new
label by entering a name below.
</SubLabel>
</div>
<div className="max-w-3xl w-full justify-start flex gap-4 items-end">
<TextFormField
width="max-w-xs"
fontSize="sm"
name="newLabelName"
label="Label Name"
/>
<Button type="submit" disabled={isSubmitting}>
Create
</Button>
</div>
</div>
<div className="grid grid-cols-1 w-full gap-4">
<div className="flex flex-col">
<Title className="text-lg">Edit Labels</Title>
<SubLabel>
You can edit the name of a label by clicking on the label
name and entering a new name.
</SubLabel>
</div>
{labels.map((label: PersonaLabel) => (
<div key={label.id} className="flex w-full gap-4 items-end">
<TextFormField
fontSize="sm"
width="w-full max-w-xs"
name={`editLabelName_${label.id}`}
label="Label Name"
value={
values.editLabelId === label.id
? values.editLabelName
: label.name
}
onChange={(e) => {
setFieldValue("editLabelId", label.id);
setFieldValue("editLabelName", e.target.value);
}}
/>
<div className="flex gap-2">
{values.editLabelId === label.id ? (
<>
<Button
onClick={async () => {
const updatedName =
values.editLabelName || label.name;
const response = await updateLabel(
label.id,
updatedName
);
if (response.ok) {
setPopup({
message: `Label "${updatedName}" updated successfully`,
type: "success",
});
setFieldValue("editLabelId", null);
setFieldValue("editLabelName", "");
} else {
setPopup({
message: `Failed to update label - ${await response.text()}`,
type: "error",
});
}
}}
>
Save
</Button>
<Button
variant="outline"
onClick={() => {
setFieldValue("editLabelId", null);
setFieldValue("editLabelName", "");
}}
>
Cancel
</Button>
</>
) : (
<Button
variant="destructive"
onClick={async () => {
const response = await deleteLabel(label.id);
if (response.ok) {
setPopup({
message: `Label "${label.name}" deleted successfully`,
type: "success",
});
} else {
setPopup({
message: `Failed to delete label - ${await response.text()}`,
type: "error",
});
}
}}
>
Delete
</Button>
)}
</div>
</div>
))}
</div>
</Form>
)}
</Formik>
</div>
</div>
);
}

View File

@@ -102,6 +102,12 @@ export function PersonasTable() {
<div>
{popup}
<Text className="my-2">
Assistants will be displayed as options on the Chat / Search interfaces
in the order they are displayed below. Assistants marked as hidden will
not be displayed. Editable assistants are shown at the top.
</Text>
<DraggableTable
headers={["Name", "Description", "Type", "Is Visible", "Delete"]}
isAdmin={isAdmin}

View File

@@ -18,20 +18,25 @@ export default function StarterMessagesList({
values,
arrayHelpers,
isRefreshing,
touchStarterMessages,
debouncedRefreshPrompts,
autoStarterMessageEnabled,
errors,
setFieldValue,
}: {
values: StarterMessage[];
arrayHelpers: ArrayHelpers;
isRefreshing: boolean;
touchStarterMessages: () => void;
debouncedRefreshPrompts: () => void;
autoStarterMessageEnabled: boolean;
errors: any;
setFieldValue: any;
}) {
const [tooltipOpen, setTooltipOpen] = useState(false);
const handleInputChange = (index: number, value: string) => {
touchStarterMessages();
setFieldValue(`starter_messages.${index}.message`, value);
if (value && index === values.length - 1 && values.length < 4) {

View File

@@ -29,6 +29,12 @@ export default async function Page(props: { params: Promise<{ id: string }> }) {
defaultPublic={true}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
<Title>Delete Assistant</Title>
<DeletePersonaButton
personaId={values.existingPersona!.id}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
</CardSection>
</>
);

View File

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

View File

@@ -1,4 +1,3 @@
"use client";
import { PersonasTable } from "./PersonaTable";
import { FiPlusSquare } from "react-icons/fi";
import Link from "next/link";
@@ -7,8 +6,6 @@ import Title from "@/components/ui/title";
import { Separator } from "@/components/ui/separator";
import { AssistantsIcon } from "@/components/icons/icons";
import { AdminPageTitle } from "@/components/admin/Title";
import LabelManagement from "./LabelManagement";
import { SubLabel } from "@/components/admin/connectors/Field";
export default async function Page() {
return (
@@ -46,12 +43,6 @@ export default async function Page() {
<Separator />
<Title>Existing Assistants</Title>
<SubLabel>
Assistants will be displayed as options on the Chat / Search
interfaces in the order they are displayed below. Assistants marked as
hidden will not be displayed. Editable assistants are shown at the
top.
</SubLabel>
<PersonasTable />
</div>
</div>

View File

@@ -11,13 +11,13 @@ import { PopupSpec, usePopup } from "@/components/admin/connectors/Popup";
import { getErrorMsg } from "@/lib/fetchUtils";
import { ScoreSection } from "../ScoreEditor";
import { useRouter } from "next/navigation";
import { HorizontalFilters } from "@/components/search/filtering/Filters";
import { useFilters } from "@/lib/hooks";
import { buildFilters } from "@/lib/search/utils";
import { DocumentUpdatedAtBadge } from "@/components/search/DocumentUpdatedAtBadge";
import { DocumentSet } from "@/lib/types";
import { SourceIcon } from "@/components/SourceIcon";
import { Connector } from "@/lib/connectors/connectors";
import { HorizontalFilters } from "@/app/chat/shared_chat_search/Filters";
const DocumentDisplay = ({
document,
@@ -200,9 +200,6 @@ export function Explorer({
availableDocumentSets={documentSets}
existingSources={connectors.map((connector) => connector.source)}
availableTags={[]}
toggleFilters={() => {}}
filtersUntoggled={false}
tagsOnLeft={true}
/>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import InvitedUserTable from "@/components/admin/users/InvitedUserTable";
import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
import { SearchBar } from "@/components/search/SearchBar";
import { FiPlusSquare } from "react-icons/fi";
import { Modal } from "@/components/Modal";
import { ThreeDotsLoader } from "@/components/Loading";
@@ -18,7 +18,6 @@ import { ErrorCallout } from "@/components/ErrorCallout";
import BulkAdd from "@/components/admin/users/BulkAdd";
import Text from "@/components/ui/text";
import { InvitedUserSnapshot } from "@/lib/types";
import { SearchBar } from "@/components/search/SearchBar";
const UsersTables = ({
q,

View File

@@ -117,6 +117,7 @@ export default function SidebarWrapper<T extends object>({
{" "}
<HistorySidebar
setShowAssistantsModal={setShowAssistantsModal}
assistants={assistants}
page={"chat"}
explicitlyUntoggle={explicitlyUntoggle}
ref={sidebarElementRef}
@@ -125,6 +126,7 @@ export default function SidebarWrapper<T extends object>({
existingChats={chatSessions}
currentChatSession={null}
folders={folders}
openedFolders={openedFolders}
/>
</div>
</div>

View File

@@ -1,12 +1,17 @@
import React, { useContext, useState, useRef, useLayoutEffect } from "react";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import {
FiMoreHorizontal,
FiShare2,
FiEye,
FiEyeOff,
FiTrash,
FiEdit,
FiHash,
FiBarChart,
FiLock,
FiUnlock,
FiSearch,
} from "react-icons/fi";
import { FaHashtag } from "react-icons/fa";
import {
@@ -21,38 +26,33 @@ import { Persona } from "@/app/admin/assistants/interfaces";
import { useUser } from "@/components/user/UserProvider";
import { useAssistants } from "@/components/context/AssistantsContext";
import { checkUserOwnsAssistant } from "@/lib/assistants/utils";
import { toggleAssistantPinnedStatus } from "@/lib/assistants/pinnedAssistants";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { PinnedIcon } from "@/components/icons/icons";
import {
deletePersona,
togglePersonaPublicStatus,
} from "@/app/admin/assistants/lib";
import { PencilIcon } from "lucide-react";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { truncateString } from "@/lib/utils";
import { HammerIcon } from "lucide-react";
export const AssistantBadge = ({
text,
className,
maxLength,
}: {
text: string;
className?: string;
maxLength?: number;
}) => {
return (
<div
className={`h-4 px-1.5 py-1 text-[10px] flex-none bg-[#e6e3dd]/50 rounded-lg justify-center items-center gap-1 inline-flex ${className}`}
className={`h-4 px-1.5 py-1 text-[10px] bg-[#e6e3dd]/50 rounded-lg justify-center items-center gap-1 inline-flex ${className}`}
>
<div className="text-[#4a4a4a] font-normal leading-[8px]">
{maxLength ? truncateString(text, maxLength) : text}
</div>
<div className="text-[#4a4a4a] font-normal leading-[8px]">{text}</div>
</div>
);
};
@@ -62,7 +62,7 @@ const AssistantCard: React.FC<{
pinned: boolean;
closeModal: () => void;
}> = ({ persona, pinned, closeModal }) => {
const { user, toggleAssistantPinnedStatus } = useUser();
const { user, refreshUser } = useUser();
const router = useRouter();
const { refreshAssistants } = useAssistants();
@@ -72,8 +72,7 @@ const AssistantCard: React.FC<{
undefined
);
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const handleShare = () => setActivePopover("visibility");
const handleDelete = () => setActivePopover("delete");
const handleEdit = () => {
router.push(`/assistants/edit/${persona.id}`);
@@ -82,24 +81,6 @@ const AssistantCard: React.FC<{
const closePopover = () => setActivePopover(undefined);
const nameRef = useRef<HTMLHeadingElement>(null);
const hiddenNameRef = useRef<HTMLSpanElement>(null);
const [isNameTruncated, setIsNameTruncated] = useState(false);
useLayoutEffect(() => {
const checkTruncation = () => {
if (nameRef.current && hiddenNameRef.current) {
const visibleWidth = nameRef.current.offsetWidth;
const fullTextWidth = hiddenNameRef.current.offsetWidth;
setIsNameTruncated(fullTextWidth > visibleWidth);
}
};
checkTruncation();
window.addEventListener("resize", checkTruncation);
return () => window.removeEventListener("resize", checkTruncation);
}, [persona.name]);
return (
<div className="w-full p-2 overflow-visible pb-4 pt-3 bg-[#fefcf9] rounded shadow-[0px_0px_4px_0px_rgba(0,0,0,0.25)] flex flex-col">
<div className="w-full flex">
@@ -109,47 +90,24 @@ const AssistantCard: React.FC<{
<div className="flex-1 mt-1 flex flex-col">
<div className="flex justify-between items-start mb-1">
<div className="flex items-end gap-x-2 leading-none">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<h3
ref={nameRef}
className={` text-black line-clamp-1 break-all text-ellipsis leading-none font-semibold text-base lg-normal w-full overflow-hidden`}
>
{persona.name}
</h3>
</TooltipTrigger>
{isNameTruncated && (
<TooltipContent>{persona.name}</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
<span
ref={hiddenNameRef}
className="absolute left-0 top-0 invisible whitespace-nowrap"
aria-hidden="true"
>
<h3 className="text-black leading-none font-semibold text-base lg-normal">
{persona.name}
</span>
</h3>
{persona.labels && persona.labels.length > 0 && (
<>
{persona.labels.slice(0, 2).map((label, index) => (
<AssistantBadge
key={index}
text={label.name}
maxLength={10}
/>
{persona.labels.slice(0, 3).map((label, index) => (
<AssistantBadge key={index} text={label.name} />
))}
{persona.labels.length > 2 && (
{persona.labels.length > 3 && (
<AssistantBadge
text={`+${persona.labels.length - 2} more`}
text={`+${persona.labels.length - 3} more`}
/>
)}
</>
)}
</div>
{isOwnedByUser && (
<div className="flex ml-2 items-center gap-x-2">
<div className="flex items-center gap-x-2">
<Popover
open={activePopover !== undefined}
onOpenChange={(open) =>
@@ -183,29 +141,41 @@ const AssistantCard: React.FC<{
<FiEdit size={12} className="inline mr-2" />
Edit
</button>
{isPaidEnterpriseFeaturesEnabled && (
<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-100"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiBarChart size={12} className="inline mr-2" />
Stats
</button>
)}
{/*
<button
onClick={isOwnedByUser ? handleShare : undefined}
className={`w-full text-left flex items-center px-2 py-1 rounded ${
isOwnedByUser
? "hover:bg-neutral-100"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<FiShare2 size={12} className="inline mr-2" />
Share
</button> */}
<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-100"
: "opacity-50 cursor-not-allowed"
}`}
disabled={!isOwnedByUser}
>
<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 ${
@@ -251,33 +221,33 @@ const AssistantCard: React.FC<{
)}
</div>
<p className="text-black font-[350] mt-0 text-sm line-clamp-2 h-[2.7em]">
<p className="text-black font-[350] mt-0 text-sm mb-1 line-clamp-2 h-[2.7em]">
{persona.description || "\u00A0"}
</p>
<div className="flex flex-col ">
<div className="my-1.5">
<p className="flex items-center text-black text-xs opacity-50">
{persona.owner?.email || persona.builtin_persona ? (
<>
<span className="truncate">
By {persona.owner?.email || "Onyx"}
</span>
{/* <div className="mb-1 mt-1">
<div className="flex items-center">
</div>
</div> */}
<span className="mx-2"></span>
<div className="my-1">
<span className="flex items-center text-black text-xs opacity-50">
{(persona.owner?.email || persona.builtin_persona) && "By "}
{persona.owner?.email || (persona.builtin_persona && "Onyx")}
{(persona.owner?.email || persona.builtin_persona) && (
<span className="mx-2"></span>
)}
{persona.tools.length > 0 ? (
<>
{persona.tools.length}
{" Action"}
{persona.tools.length !== 1 ? "s" : ""}
</>
) : null}
<span className="flex-none truncate">
{persona.tools.length > 0 ? (
<>
{persona.tools.length}
{" Action"}
{persona.tools.length !== 1 ? "s" : ""}
</>
) : (
"No Actions"
)}
</span>
) : (
"No Actions"
)}
<span className="mx-2"></span>
{persona.is_public ? (
<>
@@ -290,7 +260,17 @@ const AssistantCard: React.FC<{
Private
</>
)}
</p>
</span>
</div>
<div className="mb-1 flex flex-wrap">
{persona.document_sets.slice(0, 5).map((set, index) => (
<AssistantBadge
className="!text-base"
key={index}
text={set.name}
/>
))}
</div>
</div>
<div className="flex gap-2">
@@ -304,7 +284,7 @@ const AssistantCard: React.FC<{
}}
className="hover:bg-neutral-100 hover:text-text px-2 py-1 gap-x-1 rounded border border-black flex items-center"
>
<PencilIcon size={12} className="flex-none" />
<FaHashtag size={12} className="flex-none" />
<span className="text-xs">Start Chat</span>
</button>
</TooltipTrigger>
@@ -316,25 +296,20 @@ const AssistantCard: React.FC<{
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
<button
onClick={async () => {
await toggleAssistantPinnedStatus(
user?.preferences.pinned_assistants || [],
persona.id,
!pinned
);
await refreshUser();
}}
className="hover:bg-neutral-100 px-2 group cursor-pointer py-1 gap-x-1 relative rounded border border-black flex items-center w-[65px]"
className="hover:bg-neutral-100 px-2 py-1 gap-x-1 rounded border border-black flex items-center w-[65px]"
>
<PinnedIcon size={12} />
{!pinned ? (
<p className="absolute w-full left-0 group-hover:text-black w-full text-center transform text-xs">
Pin
</p>
) : (
<p className="text-xs group-hover:text-black">Unpin</p>
)}
</div>
<p className="text-xs">{pinned ? "Unpin" : "Pin"}</p>
</button>
</TooltipTrigger>
<TooltipContent>
{pinned ? "Remove from" : "Add to"} your pinned list

View File

@@ -1,14 +1,16 @@
"use client";
import React, { useMemo, useState } from "react";
import React, { useMemo, useState, useEffect } from "react";
import { Persona } from "@/app/admin/assistants/interfaces";
import { useRouter } from "next/navigation";
import { Modal } from "@/components/Modal";
import AssistantCard from "./AssistantCard";
import { useAssistants } from "@/components/context/AssistantsContext";
import { useUser } from "@/components/user/UserProvider";
import { FilterIcon } from "lucide-react";
import { checkUserOwnsAssistant } from "@/lib/assistants/checkOwnership";
import { useUser } from "@/components/user/UserProvider";
import { Button } from "@/components/ui/button";
import { useLabels } from "@/lib/hooks";
export const AssistantBadgeSelector = ({
text,
@@ -25,7 +27,7 @@ export const AssistantBadgeSelector = ({
selected
? "bg-neutral-900 text-white"
: "bg-transparent text-neutral-900"
} w-12 h-5 text-center px-1 py-0.5 rounded-lg cursor-pointer text-[12px] font-normal leading-[10px] border border-black justify-center items-center gap-1 inline-flex`}
} h-5 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}
>
{text}
@@ -37,7 +39,6 @@ export enum AssistantFilter {
Pinned = "Pinned",
Public = "Public",
Private = "Private",
Mine = "Mine",
}
const useAssistantFilter = () => {
@@ -47,7 +48,6 @@ const useAssistantFilter = () => {
[AssistantFilter.Pinned]: false,
[AssistantFilter.Public]: false,
[AssistantFilter.Private]: false,
[AssistantFilter.Mine]: false,
});
const toggleAssistantFilter = (filter: AssistantFilter) => {
@@ -67,7 +67,7 @@ export default function AssistantModal({
}) {
const [showAllFeaturedAssistants, setShowAllFeaturedAssistants] =
useState(false);
const { assistants, visibleAssistants } = useAssistants();
const { assistants, visibleAssistants, pinnedAssistants } = useAssistants();
const { assistantFilters, toggleAssistantFilter, setAssistantFilters } =
useAssistantFilter();
const router = useRouter();
@@ -89,21 +89,16 @@ export default function AssistantModal({
!assistantFilters[AssistantFilter.Private] || !assistant.is_public;
const pinnedFilter =
!assistantFilters[AssistantFilter.Pinned] ||
(user?.preferences?.pinned_assistants?.includes(assistant.id) ?? false);
const mineFilter =
!assistantFilters[AssistantFilter.Mine] ||
assistants.map((a: Persona) => checkUserOwnsAssistant(user, a));
pinnedAssistants.map((a: Persona) => a.id).includes(assistant.id);
return (
(nameMatches || labelMatches) &&
publicFilter &&
privateFilter &&
pinnedFilter &&
mineFilter
pinnedFilter
);
});
}, [assistants, searchQuery, assistantFilters]);
}, [assistants, searchQuery, assistantFilters, pinnedAssistants]);
const featuredAssistants = [
...memoizedCurrentlyVisibleAssistants.filter(
@@ -127,10 +122,10 @@ export default function AssistantModal({
heightOverride={`${height}px`}
onOutsideClick={hideModal}
removeBottomPadding
className={`max-w-4xl max-h-[90vh] ${height} w-[95%] overflow-hidden`}
className={`max-w-4xl ${height} w-[95%] overflow-hidden`}
>
<div className="flex flex-col h-full">
<div className="flex bg-background flex-col sticky top-0 z-10">
<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">
@@ -169,18 +164,16 @@ export default function AssistantModal({
</div>
</button>
</div>
<div className="px-2 flex py-4 items-center gap-x-2 flex-wrap">
<FilterIcon size={16} />
<div className="px-2 flex py-2 items-center gap-x-2 mb-2 flex-wrap">
<AssistantBadgeSelector
text="Pinned"
selected={assistantFilters[AssistantFilter.Pinned]}
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Pinned)}
/>
<AssistantBadgeSelector
text="Mine"
selected={assistantFilters[AssistantFilter.Mine]}
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Mine)}
text="Public"
selected={assistantFilters[AssistantFilter.Public]}
toggleFilter={() => toggleAssistantFilter(AssistantFilter.Public)}
/>
<AssistantBadgeSelector
text="Private"
@@ -189,11 +182,6 @@ export default function AssistantModal({
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>
@@ -208,11 +196,7 @@ export default function AssistantModal({
featuredAssistants.map((assistant, index) => (
<div key={index}>
<AssistantCard
pinned={
user?.preferences?.pinned_assistants?.includes(
assistant.id
) ?? false
}
pinned={pinnedAssistants.includes(assistant)}
persona={assistant}
closeModal={hideModal}
/>
@@ -237,11 +221,7 @@ export default function AssistantModal({
.map((assistant, index) => (
<div key={index}>
<AssistantCard
pinned={
user?.preferences?.pinned_assistants?.includes(
assistant.id
) ?? false
}
pinned={pinnedAssistants.includes(assistant)}
persona={assistant}
closeModal={hideModal}
/>

View File

@@ -60,6 +60,7 @@ export function ChatBanner() {
`}
onMouseLeave={handleMouseLeave}
aria-expanded={isExpanded}
role="region"
>
<div className="text-emphasis text-sm w-full">
{/* Padding for consistent spacing */}

View File

@@ -25,6 +25,7 @@ import { HealthCheckBanner } from "@/components/health/healthcheck";
import {
buildChatUrl,
buildLatestMessageChain,
checkAnyAssistantHasSearch,
createChatSession,
deleteAllChatSessions,
getCitedDocumentsFromMessage,
@@ -307,6 +308,7 @@ export function ChatPage({
const {
visibleAssistants: assistants,
recentAssistants,
assistants: allAssistants,
refreshRecentAssistants,
} = useAssistants();
@@ -1436,10 +1438,10 @@ export function ChatPage({
}
}
// on initial message send, we insert a dummy system message
// set this as the parent here if no parent is set
parentMessage =
parentMessage || frozenMessageMap?.get(SYSTEM_MESSAGE_ID)!;
// on initial message send, we insert a dummy system message
// set this as the parent here if no parent is set
const updateFn = (messages: Message[]) => {
const replacementsMap = regenerationRequest
@@ -1888,7 +1890,6 @@ export function ChatPage({
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false);
const currentPersona = alternativeAssistant || liveAssistant;
useEffect(() => {
const handleSlackChatRedirect = async () => {
if (!slackChatId) return;
@@ -2063,9 +2064,9 @@ export function ChatPage({
{retrievalEnabled && documentSidebarToggled && settings?.isMobile && (
<div className="md:hidden">
<Modal
hideDividerForTitle
onOutsideClick={() => setDocumentSidebarToggled(false)}
title="Sources"
noPadding
noScroll
>
<DocumentResults
setPresentingDocument={setPresentingDocument}
@@ -2082,7 +2083,6 @@ export function ChatPage({
maxTokens={maxTokens}
initialWidth={400}
isOpen={true}
removeHeader
/>
</Modal>
</div>
@@ -2160,16 +2160,20 @@ export function ChatPage({
<div className="w-full relative">
<HistorySidebar
setShowAssistantsModal={setShowAssistantsModal}
assistants={assistants}
explicitlyUntoggle={explicitlyUntoggle}
stopGenerating={stopGenerating}
reset={() => setMessage("")}
page="chat"
ref={innerSidebarElementRef}
toggleSidebar={toggleSidebar}
toggled={toggledSidebar}
backgroundToggled={toggledSidebar || showHistorySidebar}
currentAssistantId={liveAssistant?.id}
existingChats={chatSessions}
currentChatSession={selectedChatSession}
folders={folders}
openedFolders={openedFolders}
removeToggle={removeToggle}
showShareModal={showShareModal}
showDeleteAllModal={() => setShowDeleteAllModal(true)}
@@ -2187,11 +2191,7 @@ export function ChatPage({
bg-opacity-80
duration-300
ease-in-out
${
documentSidebarToggled &&
!settings?.isMobile &&
"opacity-100 w-[350px]"
}`}
${documentSidebarToggled && "opacity-100 w-[350px]"}`}
></div>
</div>
</div>
@@ -2212,11 +2212,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${
documentSidebarToggled && !settings?.isMobile
? "w-[400px]"
: "w-[0px]"
}
${documentSidebarToggled ? "w-[400px]" : "w-[0px]"}
`}
>
<DocumentResults
@@ -2233,13 +2229,12 @@ export function ChatPage({
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
initialWidth={400}
isOpen={documentSidebarToggled && !settings?.isMobile}
isOpen={documentSidebarToggled}
/>
</div>
<BlurBackground
visible={!untoggled && (showHistorySidebar || toggledSidebar)}
onClick={() => toggleSidebar()}
/>
<div
@@ -2258,9 +2253,7 @@ export function ChatPage({
? setSharingModalVisible
: undefined
}
documentSidebarToggled={
documentSidebarToggled && !settings?.isMobile
}
documentSidebarToggled={documentSidebarToggled}
toggleSidebar={toggleSidebar}
currentChatSession={selectedChatSession}
hideUserDropdown={user?.is_anonymous_user}
@@ -2326,7 +2319,7 @@ export function ChatPage({
currentSessionChatState == "input" &&
!loadingError &&
!submittedMessage && (
<div className="h-full w-[95%] mx-auto flex flex-col justify-center items-center">
<div className="h-full w-[95%] mx-auto flex flex-col justify-center items-center">
<ChatIntro selectedPersona={liveAssistant} />
<StarterMessages
@@ -2347,10 +2340,9 @@ export function ChatPage({
(settings?.enterpriseSettings
?.two_lines_for_chat_header
? "pt-20 "
: "pt-8 ")
: "pt-8") +
(hasPerformedInitialScroll ? "" : "invisible")
}
// NOTE: temporarily removing this to fix the scroll bug
// (hasPerformedInitialScroll ? "" : "invisible")
>
{(messageHistory.length < BUFFER_COUNT
? messageHistory
@@ -2481,6 +2473,12 @@ export function ChatPage({
setPresentingDocument
}
index={i}
selectedMessageForDocDisplay={
selectedMessageForDocDisplay
}
documentSelectionToggled={
documentSidebarToggled
}
continueGenerating={
i == messageHistory.length - 1 &&
currentCanContinue()
@@ -2600,6 +2598,19 @@ export function ChatPage({
}
: undefined
}
handleShowRetrieved={(messageNumber) => {
if (isShowingRetrieved) {
setSelectedMessageForDocDisplay(null);
} else {
if (messageNumber !== null) {
setSelectedMessageForDocDisplay(
messageNumber
);
} else {
setSelectedMessageForDocDisplay(-1);
}
}
}}
handleForceSearch={() => {
if (
previousMessage &&
@@ -2806,11 +2817,7 @@ export function ChatPage({
duration-300
ease-in-out
h-full
${
documentSidebarToggled && !settings?.isMobile
? "w-[350px]"
: "w-[0px]"
}
${documentSidebarToggled ? "w-[350px]" : "w-[0px]"}
`}
></div>
</div>

View File

@@ -4,7 +4,10 @@ import {
LlmOverride,
useLlmOverride,
} from "@/lib/hooks";
import { StringOrNumberOption } from "@/components/Dropdown";
import {
DefaultDropdownElement,
StringOrNumberOption,
} from "@/components/Dropdown";
import { Persona } from "@/app/admin/assistants/interfaces";
import { destructureValue, getFinalLLM, structureValue } from "@/lib/llm/utils";
@@ -12,7 +15,7 @@ import { useState } from "react";
import { Hoverable } from "@/components/Hoverable";
import { Popover } from "@/components/popover/Popover";
import { IconType } from "react-icons";
import { FiRefreshCw, FiCheck } from "react-icons/fi";
import { FiRefreshCw } from "react-icons/fi";
export function RegenerateDropdown({
options,
@@ -40,33 +43,45 @@ export function RegenerateDropdown({
};
const Dropdown = (
<div className="overflow-y-auto py-2 min-w-fit bg-white dark:bg-gray-800 rounded-md shadow-lg">
<div className="mb-1 flex items-center justify-between px-4 pt-2">
<span className="text-sm text-text-500 dark:text-text-400">
Regenerate with
</span>
</div>
{options.map((option) => (
<div
key={option.value}
role="menuitem"
className={`flex items-center m-1.5 p-1.5 text-sm cursor-pointer focus-visible:outline-0 group relative hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md my-0 px-3 mx-2 gap-2.5 py-3 !pr-3 ${
option.value === selected ? "bg-gray-100 dark:bg-gray-700" : ""
}`}
onClick={() => onSelect(option.value)}
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className="flex items-center gap-3">
<div>{getDisplayNameForModel(option.name)}</div>
</div>
</div>
</div>
{option.value === selected && (
<FiCheck className="text-blue-500 dark:text-blue-400" />
)}
</div>
))}
<div
className={`
border
border
rounded-lg
flex
flex-col
mx-2
bg-background
${maxHeight || "max-h-72"}
overflow-y-auto
overscroll-contain relative`}
>
<p
className="
sticky
top-0
flex
bg-background
font-medium
px-2
text-sm
py-1.5
"
>
Regenerate with
</p>
{options.map((option, ind) => {
const isSelected = option.value === selected;
return (
<DefaultDropdownElement
key={option.value}
name={getDisplayNameForModel(option.name)}
description={option.description}
onSelect={() => onSelect(option.value)}
isSelected={isSelected}
/>
);
})}
</div>
);

View File

@@ -79,9 +79,13 @@ export function ChatDocumentDisplay({
document.updated_at || Object.keys(document.metadata).length > 0;
return (
<div className="desktop:max-w-[400px] opacity-100 w-full">
<div
className={`desktop:max-w-[400px] opacity-100 ${
modal ? "w-[90vw]" : "w-full"
}`}
>
<div
className={`flex relative flex-col px-3 py-2.5 gap-0.5 rounded-xl my-1 ${
className={`flex relative flex-col px-3 py-2.5 gap-0.5 rounded-xl mx-2 my-1 ${
isSelected ? "bg-[#ebe7de]" : "bg- hover:bg-[#ebe7de]/80"
}`}
>

View File

@@ -26,7 +26,6 @@ interface DocumentResultsProps {
isSharedChat?: boolean;
modal: boolean;
setPresentingDocument: Dispatch<SetStateAction<OnyxDocument | null>>;
removeHeader?: boolean;
}
export const DocumentResults = forwardRef<HTMLDivElement, DocumentResultsProps>(
@@ -44,10 +43,10 @@ export const DocumentResults = forwardRef<HTMLDivElement, DocumentResultsProps>(
isSharedChat,
isOpen,
setPresentingDocument,
removeHeader,
},
ref: ForwardedRef<HTMLDivElement>
) => {
const { popup, setPopup } = usePopup();
const [delayedSelectedDocumentCount, setDelayedSelectedDocumentCount] =
useState(0);
@@ -99,29 +98,21 @@ export const DocumentResults = forwardRef<HTMLDivElement, DocumentResultsProps>(
}}
>
<div className="flex flex-col h-full">
{!removeHeader && (
<>
<div className="p-4 flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2">
<h2 className="text-xl font-bold text-text-900">
Sources
</h2>
</div>
<button className="my-auto" onClick={closeSidebar}>
<XIcon size={16} />
</button>
</div>
<div className="border-b border-divider-history-sidebar-bar mx-3" />
</>
)}
<div className="overflow-y-auto h-fit mb-8 pb-8 sm:mx-0 flex-grow gap-y-0 default-scrollbar dark-scrollbar flex flex-col">
{popup}
<div className="p-4 flex items-center justify-between gap-x-2">
<div className="flex items-center gap-x-2">
{/* <SourcesIcon size={32} /> */}
<h2 className="text-xl font-bold text-text-900">Sources</h2>
</div>
<button className="my-auto" onClick={closeSidebar}>
<XIcon size={16} />
</button>
</div>
<div className="border-b border-divider-history-sidebar-bar mx-3" />
<div className="overflow-y-auto h-fit mb-8 pb-8 -mx-1 sm:mx-0 flex-grow gap-y-0 default-scrollbar dark-scrollbar flex flex-col">
{dedupedDocuments.length > 0 ? (
dedupedDocuments.map((document, ind) => (
<div
key={document.document_id}
className={`desktop:px-2 w-full`}
>
<div key={document.document_id} className="w-full">
<ChatDocumentDisplay
setPresentingDocument={setPresentingDocument}
closeSidebar={closeSidebar}

View File

@@ -34,7 +34,6 @@ interface FolderDropdownProps {
onDelete?: (folderId: number) => void;
onDrop?: (folderId: number, chatSessionId: string) => void;
children?: ReactNode;
index: number;
}
export const FolderDropdown = forwardRef<HTMLDivElement, FolderDropdownProps>(
@@ -47,7 +46,6 @@ export const FolderDropdown = forwardRef<HTMLDivElement, FolderDropdownProps>(
onEdit,
onDrop,
children,
index,
},
ref
) => {
@@ -157,123 +155,117 @@ export const FolderDropdown = forwardRef<HTMLDivElement, FolderDropdownProps>(
ref={setNodeRef}
style={style}
{...attributes}
className="overflow-visible mt-2 w-full"
className="overflow-visible w-full"
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div
className="sticky top-0 bg-background-sidebar z-10"
style={{ zIndex: 1000 - index }}
ref={ref}
className="flex overflow-visible items-center w-full text-[#6c6c6c] rounded-md p-1 relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
ref={ref}
className="flex overflow-visible items-center w-full text-text-darker rounded-md p-1 relative bg-background-sidebar sticky top-0"
style={{ zIndex: 10 - index }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
<button
className="flex overflow-hidden items-center flex-grow"
onClick={() => !isEditing && setIsOpen(!isOpen)}
{...(isEditing ? {} : listeners)}
>
<button
className="flex overflow-hidden items-center flex-grow"
onClick={() => !isEditing && setIsOpen(!isOpen)}
{...(isEditing ? {} : listeners)}
>
{isOpen ? (
<Caret size={16} className="mr-1" />
) : (
<Caret size={16} className="-rotate-90 mr-1" />
)}
{isEditing ? (
<div ref={editingRef} className="flex-grow z-[9999] relative">
<input
ref={inputRef}
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="text-sm font-medium bg-transparent outline-none w-full pb-1 border-b border-[#6c6c6c] transition-colors duration-200"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleEdit();
}
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
) : (
<div className="flex items-center">
<span className="text-sm font-[500]">
{folder.folder_name}
</span>
</div>
)}
</button>
{isHovered && !isEditing && folder.folder_id && (
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="ml-auto px-1"
>
<PencilIcon size={14} />
</button>
{isOpen ? (
<Caret size={16} className="mr-1" />
) : (
<Caret size={16} className="-rotate-90 mr-1" />
)}
{(isHovered || isDeletePopoverOpen) &&
!isEditing &&
folder.folder_id && (
<Popover
open={isDeletePopoverOpen}
onOpenChange={setIsDeletePopoverOpen}
content={
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick();
}}
className="px-1"
>
<FiTrash2 size={14} />
</button>
}
popover={
<div className="p-3 w-64 border border-border rounded-lg bg-background z-50">
<p className="text-sm mb-3">
Are you sure you want to delete this folder?
</p>
<div className="flex justify-center gap-2">
<button
className="px-3 py-1 text-sm bg-gray-200 rounded"
onClick={handleCancelDelete}
>
Cancel
</button>
<button
className="px-3 py-1 text-sm bg-red-500 text-white rounded"
onClick={handleConfirmDelete}
>
Delete
</button>
</div>
</div>
}
requiresContentPadding
sideOffset={6}
{isEditing ? (
<div ref={editingRef} className="flex-grow z-[9999] relative">
<input
ref={inputRef}
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
className="text-sm font-medium bg-transparent outline-none w-full pb-1 border-b border-[#6c6c6c] transition-colors duration-200"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleEdit();
}
}}
onClick={(e) => e.stopPropagation()}
/>
)}
{isEditing && (
<div className="flex -my-1 z-[9999]">
<button onClick={handleEdit} className="p-1">
<FiCheck size={14} />
</button>
<button onClick={() => setIsEditing(false)} className="p-1">
<FiX size={14} />
</button>
</div>
) : (
<div className="flex items-center">
<span className="text-sm font-medium">
{folder.folder_name}
</span>
</div>
)}
</div>
{isOpen && (
<div className="overflow-visible mr-3 ml-1 mt-1">{children}</div>
</button>
{isHovered && !isEditing && folder.folder_id && (
<button
onClick={(e) => {
e.stopPropagation();
setIsEditing(true);
}}
className="ml-auto px-1"
>
<PencilIcon size={14} />
</button>
)}
{(isHovered || isDeletePopoverOpen) &&
!isEditing &&
folder.folder_id && (
<Popover
open={isDeletePopoverOpen}
onOpenChange={setIsDeletePopoverOpen}
content={
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick();
}}
className="px-1"
>
<FiTrash2 size={14} />
</button>
}
popover={
<div className="p-3 w-64 border border-border rounded-lg bg-background z-50">
<p className="text-sm mb-3">
Are you sure you want to delete this folder?
</p>
<div className="flex justify-center gap-2">
<button
className="px-3 py-1 text-sm bg-gray-200 rounded"
onClick={handleCancelDelete}
>
Cancel
</button>
<button
className="px-3 py-1 text-sm bg-red-500 text-white rounded"
onClick={handleConfirmDelete}
>
Delete
</button>
</div>
</div>
}
requiresContentPadding
sideOffset={6}
/>
)}
{isEditing && (
<div className="flex -my-1 z-[9999]">
<button onClick={handleEdit} className="p-1">
<FiCheck size={14} />
</button>
<button onClick={() => setIsEditing(false)} className="p-1">
<FiX size={14} />
</button>
</div>
)}
</div>
{isOpen && (
<div className="overflow-visible mr-3 ml-1 mt-1">{children}</div>
)}
</div>
);
}

View File

@@ -268,7 +268,7 @@ export default function InputPrompts() {
<Title>Prompt Shortcuts</Title>
<Text>
Manage and customize prompt shortcuts for your assistants. Use your
prompt shortcuts by starting a new message / in chat.
prompt shortcuts by starting a new message / in chat
</Text>
</div>
</div>

View File

@@ -328,7 +328,6 @@ export function ChatInputBar({
<div className="flex justify-center mx-auto">
<div
className="
max-w-full
w-[800px]
relative
desktop:px-4
@@ -506,10 +505,7 @@ export function ChatInputBar({
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder={`Message ${truncateString(
selectedAssistant.name,
70
)} assistant...`}
placeholder={`Message ${selectedAssistant.name} assistant...`}
value={message}
onKeyDown={(event) => {
if (
@@ -653,7 +649,7 @@ export function ChatInputBar({
</div>
)}
<div className="flex items-center space-x-1 mr-12 overflow-hidden px-4 pb-2">
<div className="flex items-center space-x-1 mr-12 px-4 pb-2">
<ChatInputOption
flexPriority="stiff"
name="File"

View File

@@ -15,7 +15,6 @@ interface ChatInputOptionProps {
tooltipContent?: React.ReactNode;
flexPriority?: "shrink" | "stiff" | "second";
toggle?: boolean;
minimize?: boolean;
}
export const ChatInputOption: React.FC<ChatInputOptionProps> = ({
@@ -27,10 +26,28 @@ export const ChatInputOption: React.FC<ChatInputOptionProps> = ({
tooltipContent,
toggle,
onClick,
minimize,
}) => {
const [isDropupVisible, setDropupVisible] = useState(false);
const [isTooltipVisible, setIsTooltipVisible] = useState(false);
const componentRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
componentRef.current &&
!componentRef.current.contains(event.target as Node)
) {
setIsTooltipVisible(false);
setDropupVisible(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
return (
<TooltipProvider>
<Tooltip>
@@ -69,7 +86,7 @@ export const ChatInputOption: React.FC<ChatInputOptionProps> = ({
size={size}
className="h-4 w-4 my-auto text-[#4a4a4a] group-hover:text-text flex-none"
/>
<div className={`flex items-center ${minimize && "mobile:hidden"}`}>
<div className="flex items-center">
{name && (
<span className="text-sm text-[#4a4a4a] group-hover:text-text break-all line-clamp-1">
{name}

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React from "react";
import {
Popover,
PopoverContent,
@@ -32,7 +32,6 @@ export default function LLMPopover({
requiresImageGeneration,
currentAssistant,
}: LLMPopoverProps) {
const [isOpen, setIsOpen] = useState(false);
const { llmOverride, updateLLMOverride, globalDefault } = llmOverrideManager;
const currentLlm = llmOverride.modelName || globalDefault.modelName;
@@ -82,11 +81,10 @@ export default function LLMPopover({
: null;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<Popover>
<PopoverTrigger asChild>
<button className="focus:outline-none">
<ChatInputOption
minimize
toggle
flexPriority="stiff"
name={getDisplayNameForModel(
@@ -121,10 +119,7 @@ export default function LLMPopover({
? "bg-gray-100 text-text"
: "text-text-darker"
}`}
onClick={() => {
updateLLMOverride(destructureValue(value));
setIsOpen(false);
}}
onClick={() => updateLLMOverride(destructureValue(value))}
>
{icon({ size: 16, className: "flex-none my-auto " })}
<span className="line-clamp-1 ">
@@ -137,7 +132,14 @@ export default function LLMPopover({
(assistant)
</span>
);
} else if (globalDefault.modelName === name) {
return (
<span className="flex-none ml-auto text-xs">
(user default)
</span>
);
}
return null;
})()}
</button>
);

View File

@@ -94,7 +94,7 @@ export function SimplifiedChatInputBar({
rounded-lg
relative
text-text-chatbar
bg-white
bg-background-chatbar
[&:has(textarea:focus)]::ring-1
[&:has(textarea:focus)]::ring-black
"
@@ -146,7 +146,7 @@ export function SimplifiedChatInputBar({
resize-none
rounded-lg
border-0
bg-white
bg-background-chatbar
placeholder:text-text-chatbar-subtle
${
textAreaRef.current &&

View File

@@ -363,8 +363,8 @@ export function groupSessionsByDateRange(chatSessions: ChatSession[]) {
const groups: Record<string, ChatSession[]> = {
Today: [],
"Previous 7 Days": [],
"Previous 30 days": [],
"Over 30 days": [],
"Previous 30 Days": [],
"Over 30 days ago": [],
};
chatSessions.forEach((chatSession) => {
@@ -378,9 +378,9 @@ export function groupSessionsByDateRange(chatSessions: ChatSession[]) {
} else if (diffDays <= 7) {
groups["Previous 7 Days"].push(chatSession);
} else if (diffDays <= 30) {
groups["Previous 30 days"].push(chatSession);
groups["Previous 30 Days"].push(chatSession);
} else {
groups["Over 30 days"].push(chatSession);
groups["Over 30 days ago"].push(chatSession);
}
});
@@ -424,10 +424,9 @@ export function processRawChatHistory(
message: messageInfo.message,
type: messageInfo.message_type as "user" | "assistant",
files: messageInfo.files,
alternateAssistantID:
messageInfo.alternate_assistant_id !== null
? Number(messageInfo.alternate_assistant_id)
: null,
alternateAssistantID: messageInfo.alternate_assistant_id
? Number(messageInfo.alternate_assistant_id)
: null,
// only include these fields if this is an assistant message so that
// this is identical to what is computed at streaming time
...(messageInfo.message_type === "assistant"

View File

@@ -162,6 +162,7 @@ function FileDisplay({
export const AIMessage = ({
regenerate,
overriddenModel,
selectedMessageForDocDisplay,
continueGenerating,
shared,
isActive,
@@ -169,6 +170,7 @@ export const AIMessage = ({
alternativeAssistant,
docs,
messageId,
documentSelectionToggled,
content,
files,
selectedDocuments,
@@ -178,6 +180,7 @@ export const AIMessage = ({
isComplete,
hasDocs,
handleFeedback,
handleShowRetrieved,
handleSearchQueryEdit,
handleForceSearch,
retrievalDisabled,
@@ -189,6 +192,7 @@ export const AIMessage = ({
toggledDocumentSidebar,
}: {
index?: number;
selectedMessageForDocDisplay?: number | null;
shared?: boolean;
isActive?: boolean;
continueGenerating?: () => void;
@@ -201,6 +205,7 @@ export const AIMessage = ({
currentPersona: Persona;
messageId: number | null;
content: string | JSX.Element;
documentSelectionToggled?: boolean;
files?: FileDescriptor[];
query?: string;
citedDocuments?: [string, OnyxDocument][] | null;
@@ -209,6 +214,7 @@ export const AIMessage = ({
toggledDocumentSidebar?: boolean;
hasDocs?: boolean;
handleFeedback?: (feedbackType: FeedbackType) => void;
handleShowRetrieved?: (messageNumber: number | null) => void;
handleSearchQueryEdit?: (query: string) => void;
handleForceSearch?: () => void;
retrievalDisabled?: boolean;
@@ -596,7 +602,7 @@ export const AIMessage = ({
className={`
flex md:flex-row gap-x-0.5 mt-1
transition-transform duration-300 ease-in-out
transform opacity-100 "
transform opacity-100 translate-y-0"
`}
>
<TooltipGroup>
@@ -686,6 +692,10 @@ export const AIMessage = ({
settings?.isMobile) &&
"!opacity-100"
}
translate-y-2 ${
(isHovering || settings?.isMobile) && "!translate-y-0"
}
transition-transform duration-300 ease-in-out
flex md:flex-row gap-x-0.5 bg-background-125/40 -mx-1.5 p-1.5 rounded-lg
`}
>

View File

@@ -183,6 +183,7 @@ export function UserSettingsModal({
checked={user?.preferences?.shortcut_enabled}
onCheckedChange={(checked) => {
updateUserShortcuts(checked);
refreshUser();
}}
/>
<Label className="text-sm">Enable Prompt Shortcuts</Label>
@@ -204,7 +205,6 @@ export function UserSettingsModal({
Scroll to see all options
</div>
<LLMSelector
userSettings
llmProviders={llmProviders}
currentLlm={
defaultModelDestructured
@@ -215,6 +215,7 @@ export function UserSettingsModal({
)
: null
}
userDefault={null}
requiresImageGeneration={false}
onSelect={(selected) => {
if (selected === null) {

View File

@@ -0,0 +1,124 @@
import { useChatContext } from "@/components/context/ChatContext";
import { LlmOverrideManager } from "@/lib/hooks";
import React, { forwardRef, useCallback, useState } from "react";
import { debounce } from "lodash";
import Text from "@/components/ui/text";
import { Persona } from "@/app/admin/assistants/interfaces";
import { destructureValue } from "@/lib/llm/utils";
import { updateModelOverrideForChatSession } from "../../lib";
import { GearIcon } from "@/components/icons/icons";
import { LlmList } from "@/components/llm/LLMList";
import { checkPersonaRequiresImageGeneration } from "@/app/admin/assistants/lib";
interface LlmTabProps {
llmOverrideManager: LlmOverrideManager;
currentLlm: string;
openModelSettings: () => void;
chatSessionId?: string;
close: () => void;
currentAssistant: Persona;
}
export const LlmTab = forwardRef<HTMLDivElement, LlmTabProps>(
(
{
llmOverrideManager,
chatSessionId,
currentLlm,
close,
openModelSettings,
currentAssistant,
},
ref
) => {
const requiresImageGeneration =
checkPersonaRequiresImageGeneration(currentAssistant);
const { llmProviders } = useChatContext();
const { updateLLMOverride, temperature, updateTemperature } =
llmOverrideManager;
const [isTemperatureExpanded, setIsTemperatureExpanded] = useState(false);
return (
<div className="w-full">
<div className="flex w-full justify-between content-center mb-2 gap-x-2">
<label className="block text-sm font-medium">Choose Model</label>
<button
onClick={() => {
close();
openModelSettings();
}}
>
<GearIcon />
</button>
</div>
<LlmList
requiresImageGeneration={requiresImageGeneration}
llmProviders={llmProviders}
currentLlm={currentLlm}
onSelect={(value: string | null) => {
if (value == null) {
return;
}
updateLLMOverride(destructureValue(value));
if (chatSessionId) {
updateModelOverrideForChatSession(chatSessionId, value as string);
}
close();
}}
/>
<div className="mt-4">
<button
className="flex items-center text-sm font-medium transition-colors duration-200"
onClick={() => setIsTemperatureExpanded(!isTemperatureExpanded)}
>
<span className="mr-2 text-xs text-primary">
{isTemperatureExpanded ? "▼" : "►"}
</span>
<span>Temperature</span>
</button>
{isTemperatureExpanded && (
<>
<Text className="mt-2 mb-8">
Adjust the temperature of the LLM. Higher temperatures will make
the LLM generate more creative and diverse responses, while
lower temperature will make the LLM generate more conservative
and focused responses.
</Text>
<div className="relative w-full">
<input
type="range"
onChange={(e) =>
updateTemperature(parseFloat(e.target.value))
}
className="w-full p-2 border border-border rounded-md"
min="0"
max="2"
step="0.01"
value={temperature || 0}
/>
<div
className="absolute text-sm"
style={{
left: `${(temperature || 0) * 50}%`,
transform: `translateX(-${Math.min(
Math.max((temperature || 0) * 50, 10),
90
)}%)`,
top: "-1.5rem",
}}
>
{temperature}
</div>
</div>
</>
)}
</div>
</div>
);
}
);
LlmTab.displayName = "LlmTab";

View File

@@ -26,7 +26,9 @@ import { SettingsContext } from "@/components/settings/SettingsProvider";
import { DragHandle } from "@/components/table/DragHandle";
import { WarningCircle } from "@phosphor-icons/react";
import { CustomTooltip } from "@/components/tooltip/CustomTooltip";
import SlideOverModal from "@/components/ui/SlideOverModal";
import { useChatContext } from "@/components/context/ChatContext";
import { Button } from "@/components/ui/button";
export function ChatSessionDisplay({
chatSession,
@@ -42,6 +44,8 @@ export function ChatSessionDisplay({
chatSession: ChatSession;
isSelected: boolean;
search?: boolean;
// needed when the parent is trying to apply some background effect
// if not set, the gradient will still be applied and cause weirdness
skipGradient?: boolean;
closeSidebar?: () => void;
showShareModal?: (chatSession: ChatSession) => void;
@@ -51,7 +55,11 @@ export function ChatSessionDisplay({
}) {
const router = useRouter();
const [isHovered, setIsHovered] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [isRenamingChat, setIsRenamingChat] = useState(false);
const [isMoreOptionsDropdownOpen, setIsMoreOptionsDropdownOpen] =
useState(false);
const [isShareModalVisible, setIsShareModalVisible] = useState(false);
const [chatName, setChatName] = useState(chatSession.name);
const settings = useContext(SettingsContext);
@@ -61,9 +69,8 @@ export function ChatSessionDisplay({
const inputRef = useRef<HTMLInputElement>(null);
const renamingRef = useRef<HTMLDivElement>(null);
const { refreshChatSessions, refreshFolders } = useChatContext();
const isMobile = settings?.isMobile;
const { refreshChatSessions, reorderFolders, refreshFolders } =
useChatContext();
const handlePopoverOpenChange = useCallback(
(open: boolean) => {
setPopoverOpen(open);
@@ -98,7 +105,7 @@ export function ChatSessionDisplay({
setIsDeleteModalOpen(false);
setPopoverOpen(false);
},
[chatSession, showDeleteModal, refreshChatSessions, refreshFolders]
[chatSession, showDeleteModal]
);
const onRename = useCallback(
@@ -144,34 +151,6 @@ export function ChatSessionDisplay({
settings?.settings
);
const handleDragStart = (event: React.DragEvent<HTMLAnchorElement>) => {
event.dataTransfer.setData(CHAT_SESSION_ID_KEY, chatSession.id.toString());
event.dataTransfer.setData(
FOLDER_ID_KEY,
chatSession.folder_id?.toString() || ""
);
};
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
// Prevent default touch behavior
event.preventDefault();
// Create a custom event to mimic drag start
const customEvent = new Event("dragstart", { bubbles: true });
(customEvent as any).dataTransfer = new DataTransfer();
(customEvent as any).dataTransfer.setData(
CHAT_SESSION_ID_KEY,
chatSession.id.toString()
);
(customEvent as any).dataTransfer.setData(
FOLDER_ID_KEY,
chatSession.folder_id?.toString() || ""
);
// Dispatch the custom event
event.currentTarget.dispatchEvent(customEvent);
};
return (
<>
{isShareModalVisible && (
@@ -188,6 +167,8 @@ export function ChatSessionDisplay({
setIsHovered(true);
}}
onMouseLeave={() => {
setIsMoreOptionsDropdownOpen(false);
setIsHovering(false);
setIsHovered(false);
}}
className="flex group items-center w-full relative"
@@ -203,19 +184,25 @@ export function ChatSessionDisplay({
: `/chat?chatId=${chatSession.id}`
}
scroll={false}
draggable={!isMobile}
onDragStart={!isMobile ? handleDragStart : undefined}
draggable="true"
onDragStart={(event) => {
event.dataTransfer.setData(
CHAT_SESSION_ID_KEY,
chatSession.id.toString()
);
event.dataTransfer.setData(
FOLDER_ID_KEY,
chatSession.folder_id?.toString() || ""
);
}}
>
<div
className={`${
isMobile ? "visible" : "invisible group-hover:visible"
} flex-none`}
onTouchStart={isMobile ? handleTouchStart : undefined}
>
<DragHandle size={16} className="w-3 ml-[4px] mr-[2px]" />
</div>
<DragHandle
size={16}
className={`w-3 ml-[4px] mr-[2px] invisible flex-none ${
foldersExisting ? "group-hover:visible" : "invisible"
}`}
/>
<BasicSelectable
padding="extra"
isHovered={isHovered}
isDragging={isDragging}
fullWidth
@@ -267,7 +254,7 @@ export function ChatSessionDisplay({
</div>
</div>
) : (
<p className="break-all font-normal overflow-hidden whitespace-nowrap w-full mr-3 relative">
<p className="break-all overflow-hidden whitespace-nowrap w-full mr-3 relative">
{chatName || `Unnamed Chat`}
<span
className={`absolute right-0 top-0 h-full w-8 bg-gradient-to-r from-transparent
@@ -308,7 +295,9 @@ export function ChatSessionDisplay({
<div
onClick={(e) => {
e.preventDefault();
setPopoverOpen(!popoverOpen);
setIsMoreOptionsDropdownOpen(
!isMoreOptionsDropdownOpen
);
}}
className="-my-1"
>

View File

@@ -1,5 +1,6 @@
"use client";
import { FiEdit, FiFolderPlus, FiMoreHorizontal, FiPlus } from "react-icons/fi";
import React, {
ForwardedRef,
forwardRef,
@@ -15,7 +16,14 @@ import { Folder } from "../folders/interfaces";
import { usePopup } from "@/components/admin/connectors/Popup";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { DocumentIcon2, NewChatIcon } from "@/components/icons/icons";
import {
AssistantsIconSkeleton,
DocumentIcon2,
NewChatIcon,
OnyxIcon,
PinnedIcon,
PlusIcon,
} from "@/components/icons/icons";
import { PagesTab } from "./PagesTab";
import { pageType } from "./types";
import LogoWithText from "@/components/header/LogoWithText";
@@ -24,7 +32,7 @@ import { DragEndEvent } from "@dnd-kit/core";
import { useAssistants } from "@/components/context/AssistantsContext";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { buildChatUrl } from "../lib";
import { reorderPinnedAssistants } from "@/lib/assistants/updateAssistantPreferences";
import { toggleAssistantPinnedStatus } from "@/lib/assistants/pinnedAssistants";
import { useUser } from "@/components/user/UserProvider";
import { DragHandle } from "@/components/table/DragHandle";
import {
@@ -43,6 +51,7 @@ import {
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { reorderPinnedAssistants } from "@/lib/assistants/pinnedAssistants";
import { CircleX } from "lucide-react";
interface HistorySidebarProps {
@@ -50,14 +59,18 @@ interface HistorySidebarProps {
existingChats?: ChatSession[];
currentChatSession?: ChatSession | null | undefined;
folders?: Folder[];
openedFolders?: { [key: number]: boolean };
toggleSidebar?: () => void;
toggled?: boolean;
removeToggle?: () => void;
reset?: () => void;
showShareModal?: (chatSession: ChatSession) => void;
showDeleteModal?: (chatSession: ChatSession) => void;
stopGenerating?: () => void;
explicitlyUntoggle: () => void;
showDeleteAllModal?: () => void;
backgroundToggled?: boolean;
assistants: Persona[];
currentAssistantId?: number | null;
setShowAssistantsModal: (show: boolean) => void;
}
@@ -116,9 +129,7 @@ const SortableAssistant: React.FC<SortableAssistantProps> = ({
} relative flex items-center gap-x-2 py-1 px-2 rounded-md`}
>
<AssistantIcon assistant={assistant} size={16} className="flex-none" />
<p className="text-base text-left w-fit line-clamp-1 text-ellipsis text-black">
{assistant.name}
</p>
<p className="text-base text-black">{assistant.name}</p>
<button
onClick={(e) => {
e.stopPropagation();
@@ -142,23 +153,32 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
page,
existingChats,
currentChatSession,
assistants,
folders,
openedFolders,
explicitlyUntoggle,
toggleSidebar,
removeToggle,
stopGenerating = () => null,
showShareModal,
showDeleteModal,
showDeleteAllModal,
backgroundToggled,
currentAssistantId,
},
ref: ForwardedRef<HTMLDivElement>
) => {
const searchParams = useSearchParams();
const router = useRouter();
const { user, toggleAssistantPinnedStatus } = useUser();
const { refreshUser, user } = useUser();
const { refreshAssistants, pinnedAssistants, setPinnedAssistants } =
useAssistants();
const { popup, setPopup } = usePopup();
// For determining intial focus state
const [newFolderId, setNewFolderId] = useState<number | null>(null);
const currentChatId = currentChatSession?.id;
const sensors = useSensors(
@@ -215,6 +235,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
return (
<>
{popup}
<div
ref={ref}
className={`
@@ -295,7 +316,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
</div>
)}
<div className="h-full relative overflow-y-auto">
<div>
<div className="flex px-4 font-normal text-sm gap-x-2 leading-normal text-[#6c6c6c]/80 items-center font-normal leading-normal">
Assistants
</div>
@@ -328,6 +349,7 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
assistant.id,
false
);
await refreshUser();
await refreshAssistants();
}}
/>
@@ -343,17 +365,19 @@ export const HistorySidebar = forwardRef<HTMLDivElement, HistorySidebarProps>(
Explore Assistants
</button>
</div>
<PagesTab
showDeleteModal={showDeleteModal}
showShareModal={showShareModal}
closeSidebar={removeToggle}
existingChats={existingChats}
currentChatId={currentChatId}
folders={folders}
showDeleteAllModal={showDeleteAllModal}
/>
</div>
<PagesTab
setNewFolderId={setNewFolderId}
newFolderId={newFolderId}
showDeleteModal={showDeleteModal}
showShareModal={showShareModal}
closeSidebar={removeToggle}
existingChats={existingChats}
currentChatId={currentChatId}
folders={folders}
showDeleteAllModal={showDeleteAllModal}
/>
</div>
</>
);

View File

@@ -9,12 +9,14 @@ import {
import { Folder } from "../folders/interfaces";
import { usePopup } from "@/components/admin/connectors/Popup";
import { useRouter } from "next/navigation";
import { FiPlus, FiTrash2, FiCheck, FiX } from "react-icons/fi";
import { pageType } from "./types";
import { FiPlus, FiTrash2, FiEdit, FiCheck, FiX } from "react-icons/fi";
import { NEXT_PUBLIC_DELETE_ALL_CHATS_ENABLED } from "@/lib/constants";
import { FolderDropdown } from "../folders/FolderDropdown";
import { ChatSessionDisplay } from "./ChatSessionDisplay";
import { useState, useCallback, useRef, useContext } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { Caret } from "@/components/icons/icons";
import { CaretCircleDown } from "@phosphor-icons/react";
import { groupSessionsByDateRange } from "../lib";
import React from "react";
import {
@@ -34,8 +36,8 @@ import {
} from "@dnd-kit/sortable";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { DragHandle } from "@/components/table/DragHandle";
import { useChatContext } from "@/components/context/ChatContext";
import { SettingsContext } from "@/components/settings/SettingsProvider";
interface SortableFolderProps {
folder: Folder;
@@ -47,27 +49,53 @@ interface SortableFolderProps {
onEdit: (folderId: number, newName: string) => void;
onDelete: (folderId: number) => void;
onDrop: (folderId: number, chatSessionId: string) => void;
index: number;
}
const SortableFolder: React.FC<SortableFolderProps> = (props) => {
const settings = useContext(SettingsContext);
const mobile = settings?.isMobile;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id: props.folder.folder_id?.toString() ?? "",
data: {
activationConstraint: {
distance: 8,
},
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props.folder.folder_id?.toString() ?? "",
data: {
activationConstraint: {
distance: 8,
},
disabled: mobile,
});
},
});
const ref = useRef<HTMLDivElement>(null);
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const [isHovering, setIsHovering] = useState(false);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
const isInside =
event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
if (isInside) {
setIsHovering(true);
} else {
setIsHovering(false);
}
}
};
document.addEventListener("mousemove", handleMouseMove);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
};
}, []);
return (
<div
@@ -75,12 +103,7 @@ const SortableFolder: React.FC<SortableFolderProps> = (props) => {
className="pr-3 ml-4 overflow-visible flex items-start"
style={style}
>
<FolderDropdown
ref={ref}
{...props}
{...(mobile ? {} : attributes)}
{...(mobile ? {} : listeners)}
/>
<FolderDropdown ref={ref} {...props} {...attributes} {...listeners} />
</div>
);
};
@@ -90,17 +113,21 @@ export function PagesTab({
currentChatId,
folders,
closeSidebar,
newFolderId,
showShareModal,
showDeleteModal,
showDeleteAllModal,
setNewFolderId,
}: {
existingChats?: ChatSession[];
currentChatId?: string;
folders?: Folder[];
closeSidebar?: () => void;
newFolderId: number | null;
showShareModal?: (chatSession: ChatSession) => void;
showDeleteModal?: (chatSession: ChatSession) => void;
showDeleteAllModal?: () => void;
setNewFolderId: (folderId: number) => void;
}) {
const { setPopup, popup } = usePopup();
const router = useRouter();
@@ -169,8 +196,9 @@ export function PagesTab({
const newFolderName = newFolderInputRef.current?.value;
if (newFolderName) {
try {
await createFolder(newFolderName);
const folderId = await createFolder(newFolderName);
await refreshFolders();
setNewFolderId(folderId);
router.refresh();
setPopup({
message: "Folder created successfully",
@@ -188,7 +216,7 @@ export function PagesTab({
}
setIsCreatingFolder(false);
},
[router, setPopup, refreshFolders]
[router, setNewFolderId, setPopup, refreshFolders]
);
const existingChatsNotinFolders = existingChats?.filter(
@@ -237,11 +265,8 @@ export function PagesTab({
return (
<div
key={chat.id}
className="-ml-4 bg-transparent -mr-2"
className="-ml-4 bg-transparent -mr-2"
draggable
style={{
touchAction: "none",
}}
onDragStart={(e) => {
setIsDraggingSessionId(chat.id);
e.dataTransfer.setData("text/plain", chat.id);
@@ -307,9 +332,9 @@ export function PagesTab({
);
return (
<div className="flex flex-col gap-y-2 flex-grow">
<div className="flex flex-col gap-y-2 overflow-y-auto flex-grow">
{popup}
<div className="px-4 mt-2 group mr-2 bg-background-sidebar z-20">
<div className="px-4 mt-2 group mr-2">
<div className="flex justify-between text-sm gap-x-2 text-[#6c6c6c]/80 items-center font-normal leading-normal">
<p>Chats</p>
<button
@@ -367,35 +392,31 @@ export function PagesTab({
items={folders.map((f) => f.folder_id?.toString() ?? "")}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2">
{folders
.sort(
(a, b) =>
(a.display_priority ?? 0) - (b.display_priority ?? 0)
)
.map((folder, index) => (
<SortableFolder
key={folder.folder_id}
folder={folder}
currentChatId={currentChatId}
showShareModal={showShareModal}
showDeleteModal={showDeleteModal}
closeSidebar={closeSidebar}
onEdit={handleEditFolder}
onDelete={handleDeleteFolder}
onDrop={handleDrop}
index={index}
>
{folder.chat_sessions &&
folder.chat_sessions.map((chat) =>
renderChatSession(
chat,
folders != undefined && folders.length > 0
)
)}
</SortableFolder>
))}
</div>
{folders
.sort(
(a, b) => (a.display_priority ?? 0) - (b.display_priority ?? 0)
)
.map((folder) => (
<SortableFolder
key={folder.folder_id}
folder={folder}
currentChatId={currentChatId}
showShareModal={showShareModal}
showDeleteModal={showDeleteModal}
closeSidebar={closeSidebar}
onEdit={handleEditFolder}
onDelete={handleDeleteFolder}
onDrop={handleDrop}
>
{folder.chat_sessions &&
folder.chat_sessions.map((chat) =>
renderChatSession(
chat,
folders != undefined && folders.length > 0
)
)}
</SortableFolder>
))}
</SortableContext>
</DndContext>
)}
@@ -409,7 +430,7 @@ export function PagesTab({
<>
{Object.entries(groupedChatSesssions)
.filter(([groupName, chats]) => chats.length > 0)
.map(([groupName, chats], index) => (
.map(([groupName, chats]) => (
<FolderDropdown
key={groupName}
folder={{
@@ -422,7 +443,6 @@ export function PagesTab({
closeSidebar={closeSidebar}
onEdit={handleEditFolder}
onDrop={handleDrop}
index={folders ? folders.length + index : index}
>
{chats.map((chat) =>
renderChatSession(

View File

@@ -1,18 +1,7 @@
export default function BlurBackground({
visible,
onClick,
}: {
visible: boolean;
onClick: () => void;
}) {
export default function BlurBackground({ visible }: { visible: boolean }) {
return (
<div
onClick={onClick}
className={`desktop:hidden w-full h-full fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-30 transition-opacity duration-300 ease-in-out ${
visible
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none"
}`}
className={`desktop:hidden w-full h-full fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-30 transition-opacity duration-300 ease-in-out ${visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none"}`}
/>
);
}

View File

@@ -176,6 +176,7 @@ export function SourceSelector({
<SectionTitle>Tags</SectionTitle>
</div>
<TagFilter
showTagsOnLeft={true}
tags={availableTags}
selectedTags={selectedTags}
setSelectedTags={setSelectedTags}
@@ -336,12 +337,11 @@ export function HorizontalFilters({
return (
<div>
<div className="flex gap-x-3">
<div className="w-52">
<div className="w-64">
<DateRangeSelector value={timeRange} onValueChange={setTimeRange} />
</div>
<FilterDropdown
width="w-52"
options={availableSources.map((source) => {
return {
key: source.displayName,
@@ -366,32 +366,30 @@ export function HorizontalFilters({
}
defaultDisplay="All Sources"
/>
{availableDocumentSets.length > 0 && (
<FilterDropdown
width="w-52"
options={availableDocumentSets.map((documentSet) => {
return {
key: documentSet.name,
display: (
<>
<div className="my-auto">
<FiBookmark />
</div>
<span className="ml-2 text-sm">{documentSet.name}</span>
</>
),
};
})}
selected={selectedDocumentSets}
handleSelect={(option) => handleDocumentSetSelect(option.key)}
icon={
<div className="my-auto mr-2 w-[16px] h-[16px]">
<FiBook size={16} />
</div>
}
defaultDisplay="All Document Sets"
/>
)}
<FilterDropdown
options={availableDocumentSets.map((documentSet) => {
return {
key: documentSet.name,
display: (
<>
<div className="my-auto">
<FiBookmark />
</div>
<span className="ml-2 text-sm">{documentSet.name}</span>
</>
),
};
})}
selected={selectedDocumentSets}
handleSelect={(option) => handleDocumentSetSelect(option.key)}
icon={
<div className="my-auto mr-2 w-[16px] h-[16px]">
<FiBook size={16} />
</div>
}
defaultDisplay="All Document Sets"
/>
</div>
<div className="flex pb-4 mt-2 h-12">

View File

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

View File

@@ -1,6 +1,10 @@
"use client";
import { ThreeDotsLoader } from "@/components/Loading";
import { getDatesList } from "@/app/ee/admin/performance/lib";
import Text from "@/components/ui/text";
import Title from "@/components/ui/title";
import CardSection from "@/components/admin/CardSection";
import { AreaChartDisplay } from "@/components/ui/areaChart";
import { useEffect, useState, useMemo } from "react";
import {
DateRangeSelector,
@@ -8,8 +12,7 @@ import {
} from "@/app/ee/admin/performance/DateRangeSelector";
import { useAssistants } from "@/components/context/AssistantsContext";
import { AssistantIcon } from "@/components/assistants/AssistantIcon";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AreaChartDisplay } from "@/components/ui/areaChart";
import { BackButton } from "@/components/BackButton";
type AssistantDailyUsageEntry = {
date: string;
@@ -117,7 +120,7 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
);
} else if (error) {
content = (
<div className="h-80 text-red-600 font-bold flex flex-col">
<div className="h-80 text-red-600 text-bold flex flex-col">
<p className="m-auto">{error}</p>
</div>
);
@@ -136,60 +139,52 @@ export function AssistantStats({ assistantId }: { assistantId: number }) {
data={chartData}
categories={["Messages", "Unique Users"]}
index="Day"
colors={["#4A4A4A", "#A0A0A0"]}
colors={["indigo", "fuchsia"]}
yAxisWidth={60}
/>
);
}
return (
<Card className="w-full">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<p className="text-base font-normal text-2xl">Assistant Analytics</p>
<DateRangeSelector value={dateRange} onValueChange={setDateRange} />
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-4">
{assistant && (
<AssistantIcon
disableToolip
size="large"
assistant={assistant}
/>
)}
<div>
<h3 className="text-lg font-normal">{assistant?.name}</h3>
<p className="text-sm text-gray-500">
{assistant?.description}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm font-medium text-gray-500">
Total Messages
</p>
<p className="text-2xl font-normal">{totalMessages}</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">
Total Unique Users
</p>
<p className="text-2xl font-normal">{totalUniqueUsers}</p>
</div>
</div>
</CardContent>
</Card>
<>
<div className="flex justify-between items-start mb-6">
<div className="flex flex-col gap-2">
<Title>Assistant Analytics</Title>
<Text>
Messages and unique users per day for the assistant{" "}
<b>{assistant?.name}</b>
</Text>
<DateRangeSelector value={dateRange} onValueChange={setDateRange} />
</div>
{content}
</CardContent>
</Card>
{assistant && (
<div className="bg-gray-100 p-4 w-full max-w-64 rounded-lg shadow-sm">
<div className="flex items-center mb-2">
<AssistantIcon
disableToolip
size="medium"
assistant={assistant}
/>
<Title className="text-lg ml-3">{assistant?.name}</Title>
</div>
<Text className="text-gray-600 text-sm">
{assistant?.description}
</Text>
</div>
)}
</div>
<div className="flex flex-col gap-4">
<div className="flex justify-between">
<div>
<Text className="font-semibold">Total Messages</Text>
<Text>{totalMessages}</Text>
</div>
<div>
<Text className="font-semibold">Total Unique Users</Text>
<Text>{totalUniqueUsers}</Text>
</div>
</div>
</div>
{content}
</>
);
}

View File

@@ -1,10 +1,14 @@
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { fetchChatData } from "@/lib/chat/fetchChatData";
import { unstable_noStore as noStore } from "next/cache";
import { redirect } from "next/navigation";
import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper";
import { cookies } from "next/headers";
import { ChatProvider } from "@/components/context/ChatContext";
import WrappedAssistantsStats from "./WrappedAssistantsStats";
import CardSection from "@/components/admin/CardSection";
import { AssistantStats } from "./AssistantStats";
import { BackButton } from "@/components/BackButton";

View File

@@ -23,6 +23,8 @@ import PostHogPageView from "./PostHogPageView";
import Script from "next/script";
import { LogoType } from "@/components/logo/Logo";
import { Hanken_Grotesk } from "next/font/google";
import { fetchChatData } from "@/lib/chat/fetchChatData";
import { redirect } from "next/navigation";
const inter = Inter({
subsets: ["latin"],

View File

@@ -10,6 +10,8 @@ import {
import { ChevronDownIcon, PlusIcon } from "./icons/icons";
import { FiCheck, FiChevronDown } from "react-icons/fi";
import { Popover } from "./popover/Popover";
import { createPortal } from "react-dom";
import { useDropdownPosition } from "@/lib/dropdown";
export interface Option<T> {
name: string;
@@ -50,18 +52,17 @@ export function SearchMultiSelectDropdown({
options,
onSelect,
itemComponent,
onCreate,
onDelete,
onCreateLabel,
}: {
options: StringOrNumberOption[];
onSelect: (selected: StringOrNumberOption) => void;
itemComponent?: FC<{ option: StringOrNumberOption }>;
onCreate?: (name: string) => void;
onDelete?: (name: string) => void;
onCreateLabel?: (name: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownMenuRef = useRef<HTMLDivElement>(null);
const handleSelect = (option: StringOrNumberOption) => {
onSelect(option);
@@ -77,7 +78,9 @@ export function SearchMultiSelectDropdown({
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
!dropdownRef.current.contains(event.target as Node) &&
dropdownMenuRef.current &&
!dropdownMenuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
@@ -89,6 +92,8 @@ export function SearchMultiSelectDropdown({
};
}, []);
useDropdownPosition({ isOpen, dropdownRef, dropdownMenuRef });
return (
<div className="relative text-left w-full" ref={dropdownRef}>
<div>
@@ -105,11 +110,24 @@ export function SearchMultiSelectDropdown({
}
}}
onFocus={() => setIsOpen(true)}
className="inline-flex justify-between w-full px-4 py-2 text-sm bg-background border border-border rounded-md shadow-sm"
className={`inline-flex
justify-between
w-full
px-4
py-2
text-sm
bg-background
border
border-border
rounded-md
shadow-sm
`}
/>
<button
type="button"
className="absolute top-0 right-0 text-sm h-full px-2 border-l border-border"
className={`absolute top-0 right-0
text-sm
h-full px-2 border-l border-border`}
aria-expanded={isOpen}
aria-haspopup="true"
onClick={() => setIsOpen(!isOpen)}
@@ -118,65 +136,78 @@ export function SearchMultiSelectDropdown({
</button>
</div>
{isOpen && (
<div className="absolute z-10 mt-1 w-full rounded-md shadow-lg bg-background border border-border max-h-60 overflow-y-auto">
{isOpen &&
createPortal(
<div
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
ref={dropdownMenuRef}
className={`origin-bottom-right
rounded-md
shadow-lg
bg-background
border
border-border
max-h-80
overflow-y-auto
overscroll-contain`}
>
{filteredOptions.map((option, index) =>
itemComponent ? (
<div
key={option.name}
onClick={() => {
handleSelect(option);
}}
>
{itemComponent({ option })}
</div>
) : (
<StandardDropdownOption
key={index}
option={option}
index={index}
handleSelect={handleSelect}
/>
)
)}
{onCreate &&
searchTerm.trim() !== "" &&
!filteredOptions.some(
(option) =>
option.name.toLowerCase() === searchTerm.toLowerCase()
) && (
<>
<div className="border-t border-border"></div>
<button
className="w-full text-left flex items-center px-4 py-2 text-sm hover:bg-hover"
role="menuitem"
<div
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
{filteredOptions.map((option, index) =>
itemComponent ? (
<div
key={option.name}
onClick={() => {
onCreate(searchTerm);
setIsOpen(false);
setSearchTerm("");
handleSelect(option);
}}
>
<PlusIcon className="w-4 h-4 mr-2" />
Create label &quot;{searchTerm}&quot;
</button>
</>
{itemComponent({ option })}
</div>
) : (
<StandardDropdownOption
key={index}
option={option}
index={index}
handleSelect={handleSelect}
/>
)
)}
{filteredOptions.length === 0 &&
(!onCreate || searchTerm.trim() === "") && (
<div className="px-4 py-2.5 text-sm text-text-muted">
No matches found
</div>
)}
</div>
</div>
)}
{onCreateLabel &&
searchTerm.trim() !== "" &&
!filteredOptions.some(
(option) =>
option.name.toLowerCase() === searchTerm.toLowerCase()
) && (
<>
<div className="border-t border-border"></div>
<button
className="w-full text-left flex items-center px-4 py-2 text-sm hover:bg-hover"
role="menuitem"
onClick={() => {
onCreateLabel(searchTerm);
setIsOpen(false);
setSearchTerm("");
}}
>
<PlusIcon className="w-4 h-4 mr-2" />
Create label &quot;{searchTerm}&quot;
</button>
</>
)}
{filteredOptions.length === 0 &&
(!onCreateLabel || searchTerm.trim() === "") && (
<div className="px-4 py-2.5 text-sm text-text-muted">
No matches found
</div>
)}
</div>
</div>,
document.body
)}
</div>
);
}

View File

@@ -22,7 +22,6 @@ interface ModalProps {
noScroll?: boolean;
heightOverride?: string;
removeBottomPadding?: boolean;
removePadding?: boolean;
}
export function Modal({
@@ -40,7 +39,6 @@ export function Modal({
noScroll,
heightOverride,
removeBottomPadding,
removePadding,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const [isMounted, setIsMounted] = useState(false);
@@ -84,12 +82,11 @@ export function Modal({
duration-300
ease-in-out
relative
overflow-visible
${width ?? "w-11/12 max-w-4xl"}
${noPadding ? "" : removeBottomPadding ? "pt-10 px-10" : "p-10"}
${className || ""}
flex
flex-col
${heightOverride ? `h-${heightOverride}` : "max-h-[90vh]"}
`}
>
{onOutsideClick && !hideCloseButton && (
@@ -103,10 +100,10 @@ export function Modal({
</button>
</div>
)}
<div className="flex-shrink-0">
<div className="w-full overflow-y-auto overflow-x-visible p-1 flex flex-col h-full justify-stretch">
{title && (
<>
<div className="flex">
<div className="flex mb-4">
<h2
className={`my-auto flex content-start gap-x-4 font-bold ${
titleSize || "text-2xl"
@@ -119,9 +116,15 @@ export function Modal({
{!hideDividerForTitle && <Separator />}
</>
)}
</div>
<div className="flex-grow overflow-y-auto overflow-x-hidden">
{children}
<div
style={{ height: heightOverride }}
className={cn(
noScroll ? "overflow-auto" : "overflow-x-visible",
!heightOverride && (height || "max-h-[60vh]")
)}
>
{children}
</div>
</div>
</div>
</div>

View File

@@ -3,16 +3,13 @@
import { ValidSources } from "@/lib/types";
import { SourceIcon } from "./SourceIcon";
import { useState } from "react";
import { OnyxIcon } from "./icons/icons";
export function WebResultIcon({ url }: { url: string }) {
const [error, setError] = useState(false);
const hostname = new URL(url).hostname;
return (
<>
{hostname == "docs.onyx.app" ? (
<OnyxIcon size={18} />
) : !error ? (
{!error ? (
<img
className="my-0 w-5 h-5 rounded-full py-0"
src={`https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${hostname}&size=128`}

View File

@@ -19,6 +19,7 @@ import {
SlackIconSkeleton,
DocumentSetIconSkeleton,
AssistantsIconSkeleton,
ClosedBookIcon,
SearchIcon,
DocumentIcon2,
} from "@/components/icons/icons";
@@ -30,6 +31,7 @@ import { usePathname } from "next/navigation";
import { SettingsContext } from "../settings/SettingsProvider";
import { useContext, useState } from "react";
import { MdOutlineCreditCard } from "react-icons/md";
import { set } from "lodash";
import { UserSettingsModal } from "@/app/chat/modal/UserSettingsModal";
import { usePopup } from "./connectors/Popup";
import { useChatContext } from "../context/ChatContext";
@@ -62,9 +64,9 @@ export function ClientLayout({
return <>{children}</>;
}
// const { user} = useUser
return (
<div className="h-screen overflow-y-hidden">
{popup}
<div className="flex h-full">
{userSettingsOpen && (
<UserSettingsModal
@@ -421,16 +423,15 @@ export function ClientLayout({
]}
/>
</div>
<div className="pb-8 relative h-full overflow-y-hidden w-full">
<div className="pb-8 relative h-full overflow-y-auto w-full">
<div className="fixed left-0 gap-x-4 px-4 top-4 h-8 px-0 mb-auto w-full items-start flex justify-end">
<UserDropdown toggleUserSettings={toggleUserSettings} />
</div>
<div className="pt-20 flex w-full overflow-y-auto overflow-x-hidden h-full px-4 md:px-12">
<div className="pt-20 flex overflow-y-auto overflow-x-hidden h-full px-4 md:px-12">
{children}
</div>
</div>
</div>
</div>
);
// Is there a clean way to add this to some piece of text where we need to enbale for copy-paste in a react app?
}

View File

@@ -151,7 +151,7 @@ export function TextFormField({
vertical,
className,
}: {
value?: string; // Escape hatch for setting the value of the field - conflicts with Formik
value?: string;
name: string;
removeLabel?: boolean;
label: string;
@@ -185,7 +185,7 @@ export function TextFormField({
heightString = "h-28";
}
const [, , helpers] = useField(name);
const [field, , helpers] = useField(name);
const { setValue } = helpers;
const handleChange = (

View File

@@ -1,83 +1,35 @@
import { useRef, useState } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { CheckCircle, XCircle } from "lucide-react";
const popupVariants = cva(
"fixed bottom-4 left-4 p-4 rounded-lg shadow-xl text-white z-[10000] flex items-center space-x-3 transition-all duration-300 ease-in-out",
{
variants: {
type: {
success: "bg-green-500",
error: "bg-red-500",
info: "bg-blue-500",
warning: "bg-yellow-500",
},
},
defaultVariants: {
type: "info",
},
}
);
export interface PopupSpec extends VariantProps<typeof popupVariants> {
export interface PopupSpec {
message: string;
type: "success" | "error";
}
export const Popup: React.FC<PopupSpec> = ({ message, type }) => (
<div className={cn(popupVariants({ type }))}>
{type === "success" ? (
<CheckCircle className="w-6 h-6 animate-pulse" />
) : type === "error" ? (
<XCircle className="w-6 h-6 animate-pulse" />
) : type === "info" ? (
<svg
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
) : (
<svg
className="w-6 h-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
)}
<span className="font-medium">{message}</span>
<div
className={`fixed bottom-4 left-4 p-4 rounded-md shadow-lg text-white z-[10000] ${
type === "success" ? "bg-green-500" : "bg-error"
}`}
>
{message}
</div>
);
export const usePopup = () => {
const [popup, setPopup] = useState<PopupSpec | null>(null);
// using NodeJS.Timeout because setTimeout in NodeJS returns a different type than in browsers
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const setPopupWithExpiration = (popupSpec: PopupSpec | null) => {
// Clear any previous timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
setPopup(popupSpec);
if (popupSpec) {
timeoutRef.current = setTimeout(() => {
setPopup(null);
}, 4000);
}
timeoutRef.current = setTimeout(() => {
setPopup(null);
}, 4000);
};
return {

View File

@@ -15,7 +15,6 @@ export function StarterMessages({
<div
key={-4}
className={`
short:hidden
mx-auto
w-full
${
@@ -56,7 +55,7 @@ export function StarterMessages({
disabled:cursor-not-allowed
line-clamp-3
`}
style={{ height: "5.4rem" }}
style={{ height: "5.2rem" }}
>
{starterMessage.name}
</button>

View File

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

View File

@@ -18,13 +18,13 @@ export default function SourceCard({
onClick={() => openDocument(doc, setPresentingDocument)}
className="cursor-pointer text-left overflow-hidden flex flex-col gap-0.5 rounded-lg px-3 py-2 hover:bg-background-dark/80 bg-background-dark/60 w-[200px]"
>
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center gap-2 text-sm">
<div className="line-clamp-1 font-semibold text-ellipsis text-text-900 flex h-6 items-center gap-2 text-sm">
{doc.is_internet || doc.source_type === "web" ? (
<WebResultIcon url={doc.link} />
) : (
<SourceIcon sourceType={doc.source_type} iconSize={18} />
)}
<p>{truncateString(doc.semantic_identifier || doc.document_id, 16)}</p>
<p>{truncateString(doc.semantic_identifier || doc.document_id, 20)}</p>
</div>
<div className="line-clamp-2 text-sm font-semibold"></div>
<div className="line-clamp-2 text-sm font-normal leading-snug text-text-700">

View File

@@ -23,6 +23,8 @@ interface AssistantsContextProps {
hiddenAssistants: Persona[];
finalAssistants: Persona[];
ownedButHiddenAssistants: Persona[];
pinnedAssistants: Persona[];
setPinnedAssistants: Dispatch<SetStateAction<Persona[]>>;
refreshAssistants: () => Promise<void>;
isImageGenerationAvailable: boolean;
recentAssistants: Persona[];
@@ -30,8 +32,6 @@ interface AssistantsContextProps {
// Admin only
editablePersonas: Persona[];
allAssistants: Persona[];
pinnedAssistants: Persona[];
setPinnedAssistants: Dispatch<SetStateAction<Persona[]>>;
}
const AssistantsContext = createContext<AssistantsContextProps | undefined>(
@@ -52,23 +52,11 @@ export const AssistantsProvider: React.FC<{
const [assistants, setAssistants] = useState<Persona[]>(
initialAssistants || []
);
const [pinnedAssistants, setPinnedAssistants] = useState<Persona[]>([]);
const { user, isAdmin, isCurator } = useUser();
const [editablePersonas, setEditablePersonas] = useState<Persona[]>([]);
const [allAssistants, setAllAssistants] = useState<Persona[]>([]);
const [pinnedAssistants, setPinnedAssistants] = useState<Persona[]>(
assistants.filter((assistant) =>
user?.preferences?.pinned_assistants?.includes(assistant.id)
)
);
useEffect(() => {
setPinnedAssistants(
assistants.filter((assistant) =>
user?.preferences?.pinned_assistants?.includes(assistant.id)
)
);
}, [user?.preferences?.pinned_assistants, assistants]);
const [recentAssistants, setRecentAssistants] = useState<Persona[]>(
user?.preferences.recent_assistants
?.filter((assistantId) =>
@@ -195,6 +183,25 @@ export const AssistantsProvider: React.FC<{
user,
assistants
);
const pinnedAssistants = user?.preferences.pinned_assistants
? visibleAssistants
.filter((assistant) =>
user.preferences.pinned_assistants.includes(assistant.id)
)
.sort((a, b) => {
const indexA = user.preferences.pinned_assistants.indexOf(a.id);
const indexB = user.preferences.pinned_assistants.indexOf(b.id);
return indexA - indexB;
})
: visibleAssistants.filter(
(assistant) =>
assistant.builtin_persona || assistant.is_default_persona
);
setPinnedAssistants(pinnedAssistants);
// Fallback to first 3 assistants if pinnedAssistants is empty
const finalPinnedAssistants =
pinnedAssistants.length > 0 ? pinnedAssistants : assistants.slice(0, 3);
const finalAssistants = user
? orderAssistantsForUser(visibleAssistants, user)
@@ -209,6 +216,7 @@ export const AssistantsProvider: React.FC<{
visibleAssistants,
hiddenAssistants,
finalAssistants,
pinnedAssistants,
ownedButHiddenAssistants,
};
}, [user, assistants]);
@@ -220,6 +228,8 @@ export const AssistantsProvider: React.FC<{
visibleAssistants,
hiddenAssistants,
finalAssistants,
pinnedAssistants,
setPinnedAssistants,
ownedButHiddenAssistants,
refreshAssistants,
editablePersonas,
@@ -227,8 +237,6 @@ export const AssistantsProvider: React.FC<{
isImageGenerationAvailable,
recentAssistants,
refreshRecentAssistants,
setPinnedAssistants,
pinnedAssistants,
}}
>
{children}

View File

@@ -43,9 +43,9 @@ export default function LogoWithText({
} z-[100] ml-2 mt-1 h-8 mb-auto shrink-0 flex gap-x-0 items-center text-xl`}
>
{toggleSidebar && page == "chat" ? (
<div
<button
onClick={() => toggleSidebar()}
className="flex gap-x-2 items-center ml-0 cursor-pointer desktop:hidden "
className="flex gap-x-2 items-center ml-0 desktop:hidden "
>
{!toggled ? (
<Logo className="desktop:hidden -my-2" height={24} width={24} />
@@ -63,7 +63,7 @@ export default function LogoWithText({
toggled && "mobile:hidden"
}`}
/>
</div>
</button>
) : (
<div className="mr-1 invisible mb-auto h-6 w-6">
<Logo height={24} width={24} />

View File

@@ -249,56 +249,111 @@ export const ColorSlackIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={slackIcon} />;
return (
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
>
<Image src={slackIcon} alt="Logo" width="96" height="96" />
</div>
);
};
export const ColorDiscordIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={discordIcon} />;
return (
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
>
<Image src={discordIcon} alt="Logo" width="96" height="96" />
</div>
);
};
export const LiteLLMIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={litellmIcon} />;
return (
<div
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
>
<Image src={litellmIcon} alt="Logo" width="96" height="96" />
</div>
);
};
export const OpenSourceIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={openSourceIcon} />;
return (
<div
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
>
<Image src={openSourceIcon} alt="Logo" width="96" height="96" />
</div>
);
};
export const MixedBreadIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={mixedBreadSVG} />;
return (
<div
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
>
<Image src={mixedBreadSVG} alt="Logo" width="96" height="96" />
</div>
);
};
export const NomicIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={nomicSVG} />;
return (
<div
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
>
<Image src={nomicSVG} alt="Logo" width="96" height="96" />
</div>
);
};
export const MicrosoftIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={microsoftIcon} />;
return (
<div
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
>
<Image src={microsoftIcon} alt="Logo" width="96" height="96" />
</div>
);
};
export const AnthropicIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={anthropicSVG} />;
return (
<div
style={{ width: `${size + 4}px`, height: `${size + 4}px` }}
className={`w-[${size + 4}px] h-[${size + 4}px] -m-0.5 ` + className}
>
<Image src={anthropicSVG} alt="Logo" width="96" height="96" />
</div>
);
};
export const LeftToLineIcon = ({
@@ -1780,6 +1835,7 @@ export const RobotIcon = ({
return <FaRobot size={size} className={className} />;
};
slackIcon;
export const SlackIconSkeleton = ({
size = 16,
className = defaultTailwindCSS,
@@ -2742,14 +2798,28 @@ export const EgnyteIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={egnyteIcon} />;
return (
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
>
<Image src={egnyteIcon} alt="Egnyte" width="96" height="96" />
</div>
);
};
export const AirtableIcon = ({
size = 16,
className = defaultTailwindCSS,
}: IconProps) => {
return <LogoIcon size={size} className={className} src={airtableIcon} />;
return (
<div
style={{ width: `${size}px`, height: `${size}px` }}
className={`w-[${size}px] h-[${size}px] ` + className}
>
<Image src={airtableIcon} alt="Airtable" width="96" height="96" />
</div>
);
};
export const PinnedIcon = ({
@@ -2995,7 +3065,7 @@ export const GeneralAssistantIcon = ({
height="22.7"
rx="11.35"
stroke="black"
strokeWidth="1.3"
stroke-width="1.3"
/>
<path
d="M8.06264 10.3125C8.06253 9.66355 8.22283 9.02463 8.52926 8.45258C8.83569 7.88054 9.27876 7.3931 9.81906 7.03363C10.3594 6.67415 10.9801 6.4538 11.6261 6.39216C12.2722 6.33052 12.9234 6.42951 13.5219 6.68032C14.1204 6.93113 14.6477 7.32598 15.0568 7.82976C15.4659 8.33353 15.7441 8.93061 15.8667 9.56787C15.9893 10.2051 15.9525 10.8628 15.7596 11.4824C15.5667 12.102 15.2236 12.6644 14.7609 13.1194C14.5438 13.3331 14.3525 13.611 14.2603 13.9474L13.8721 15.375H10.1281L9.73889 13.9474C9.64847 13.6321 9.47612 13.3464 9.23939 13.1194C8.86681 12.753 8.57088 12.3161 8.36885 11.8342C8.16682 11.3523 8.06272 10.835 8.06264 10.3125ZM10.4364 16.5H13.5639L13.3715 17.211C13.3389 17.3301 13.2681 17.4351 13.1699 17.5099C13.0717 17.5847 12.9516 17.6252 12.8281 17.625H11.1721C11.0487 17.6252 10.9286 17.5847 10.8304 17.5099C10.7322 17.4351 10.6614 17.3301 10.6288 17.211L10.4364 16.5ZM12.0001 5.25C10.9954 5.25017 10.0134 5.5493 9.17925 6.10932C8.34506 6.66934 7.69637 7.46491 7.31577 8.39477C6.93516 9.32463 6.83985 10.3467 7.04197 11.3309C7.24409 12.3151 7.7345 13.2169 8.45076 13.9215C8.54562 14.0093 8.61549 14.1207 8.65326 14.2444L9.54426 17.5069C9.64173 17.8639 9.85387 18.179 10.148 18.4037C10.4422 18.6283 10.802 18.75 11.1721 18.75H12.8281C13.1983 18.75 13.5581 18.6283 13.8523 18.4037C14.1464 18.179 14.3585 17.8639 14.456 17.5069L15.3459 14.2444C15.384 14.1206 15.4542 14.0092 15.5495 13.9215C16.2658 13.2169 16.7562 12.3151 16.9583 11.3309C17.1604 10.3467 17.0651 9.32463 16.6845 8.39477C16.3039 7.46491 15.6552 6.66934 14.821 6.10932C13.9868 5.5493 13.0049 5.25017 12.0001 5.25Z"
@@ -3024,7 +3094,7 @@ export const SearchAssistantIcon = ({
height="22.7"
rx="11.35"
stroke="black"
strokeWidth="1.3"
stroke-width="1.3"
/>
<path
d="M17.0667 18L12.8667 13.8C12.5333 14.0667 12.15 14.2778 11.7167 14.4333C11.2833 14.5889 10.8222 14.6667 10.3333 14.6667C9.12222 14.6667 8.09733 14.2471 7.25867 13.408C6.42 12.5689 6.00044 11.544 6 10.3333C5.99956 9.12267 6.41911 8.09778 7.25867 7.25867C8.09822 6.41956 9.12311 6 10.3333 6C11.5436 6 12.5687 6.41956 13.4087 7.25867C14.2487 8.09778 14.668 9.12267 14.6667 10.3333C14.6667 10.8222 14.5889 11.2833 14.4333 11.7167C14.2778 12.15 14.0667 12.5333 13.8 12.8667L18 17.0667L17.0667 18ZM10.3333 13.3333C11.1667 13.3333 11.8751 13.0418 12.4587 12.4587C13.0422 11.8756 13.3338 11.1671 13.3333 10.3333C13.3329 9.49956 13.0413 8.79133 12.4587 8.20867C11.876 7.626 11.1676 7.33422 10.3333 7.33333C9.49911 7.33244 8.79089 7.62422 8.20867 8.20867C7.62644 8.79311 7.33467 9.50133 7.33333 10.3333C7.332 11.1653 7.62378 11.8738 8.20867 12.4587C8.79356 13.0436 9.50178 13.3351 10.3333 13.3333Z"

View File

@@ -0,0 +1,136 @@
import React from "react";
import { getDisplayNameForModel } from "@/lib/hooks";
import {
checkLLMSupportsImageInput,
destructureValue,
structureValue,
} from "@/lib/llm/utils";
import {
getProviderIcon,
LLMProviderDescriptor,
} from "@/app/admin/configuration/llm/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
interface LlmListProps {
llmProviders: LLMProviderDescriptor[];
currentLlm: string;
onSelect: (value: string | null) => void;
userDefault?: string | null;
scrollable?: boolean;
hideProviderIcon?: boolean;
requiresImageGeneration?: boolean;
currentAssistant?: Persona;
}
export const LlmList: React.FC<LlmListProps> = ({
currentAssistant,
llmProviders,
currentLlm,
onSelect,
userDefault,
scrollable,
requiresImageGeneration,
}) => {
const llmOptionsByProvider: {
[provider: string]: {
name: string;
value: string;
icon: React.FC<{ size?: number; className?: string }>;
}[];
} = {};
const uniqueModelNames = new Set<string>();
llmProviders.forEach((llmProvider) => {
if (!llmOptionsByProvider[llmProvider.provider]) {
llmOptionsByProvider[llmProvider.provider] = [];
}
(llmProvider.display_model_names || llmProvider.model_names).forEach(
(modelName) => {
if (!uniqueModelNames.has(modelName)) {
uniqueModelNames.add(modelName);
llmOptionsByProvider[llmProvider.provider].push({
name: modelName,
value: structureValue(
llmProvider.name,
llmProvider.provider,
modelName
),
icon: getProviderIcon(llmProvider.provider, modelName),
});
}
}
);
});
const llmOptions = Object.entries(llmOptionsByProvider).flatMap(
([provider, options]) => [...options]
);
return (
<div
className={`${
scrollable
? "max-h-[200px] default-scrollbar overflow-x-hidden"
: "max-h-[300px]"
} bg-background-175 flex flex-col gap-y-2 mt-1 overflow-y-scroll`}
>
{llmOptions.map(({ name, icon, value }, index) => {
if (!requiresImageGeneration || checkLLMSupportsImageInput(name)) {
return (
<button
type="button"
key={index}
className={`w-full items-center flex gap-x-2 text-sm text-left rounded`}
onClick={() => onSelect(value)}
>
<div className="relative flex-shrink-0">
<svg
width="12"
height="12"
viewBox="0 0 20 20"
fill="none"
className={`overflow-hidden rounded-full ${
currentLlm == name ? "bg-accent border-none " : ""
}`}
xmlns="http://www.w3.org/2000/svg"
>
{currentLlm != name && (
<circle
cx="10"
cy="10"
r="9"
stroke="currentColor"
strokeWidth="1.5"
/>
)}
</svg>
</div>
{icon({ size: 16 })}
<p className="text-sm">{getDisplayNameForModel(name)}</p>
{(() => {
if (
currentAssistant?.llm_model_version_override === name &&
userDefault &&
name === destructureValue(userDefault).modelName
) {
return " (assistant + user default)";
} else if (
currentAssistant?.llm_model_version_override === name
) {
return " (assistant)";
} else if (
userDefault &&
name === destructureValue(userDefault).modelName
) {
return " (user default)";
}
return "";
})()}
</button>
);
}
})}
</div>
);
};

View File

@@ -18,48 +18,28 @@ import {
} from "@/components/ui/select";
interface LLMSelectorProps {
userSettings?: boolean;
llmProviders: LLMProviderDescriptor[];
currentLlm: string | null;
onSelect: (value: string | null) => void;
userDefault?: string | null;
requiresImageGeneration?: boolean;
}
export const LLMSelector: React.FC<LLMSelectorProps> = ({
userSettings,
llmProviders,
currentLlm,
onSelect,
userDefault,
requiresImageGeneration,
}) => {
const seenModelNames = new Set();
const llmOptions = llmProviders.flatMap((provider) => {
return (provider.display_model_names || provider.model_names)
.filter((modelName) => {
const displayName = getDisplayNameForModel(modelName);
if (seenModelNames.has(displayName)) {
return false;
}
seenModelNames.add(displayName);
return true;
})
.map((modelName) => ({
name: getDisplayNameForModel(modelName),
value: structureValue(provider.name, provider.provider, modelName),
icon: getProviderIcon(provider.provider, modelName),
}));
});
const defaultProvider = llmProviders.find(
(llmProvider) => llmProvider.is_default_provider
const llmOptions = llmProviders.flatMap((provider) =>
(provider.display_model_names || provider.model_names).map((modelName) => ({
name: getDisplayNameForModel(modelName),
value: structureValue(provider.name, provider.provider, modelName),
icon: getProviderIcon(provider.provider, modelName),
}))
);
const defaultModelName = defaultProvider?.default_model_name;
const defaultModelDisplayName = defaultModelName
? getDisplayNameForModel(defaultModelName)
: null;
const destructuredCurrentValue = currentLlm
? destructureValue(currentLlm)
: null;
@@ -75,19 +55,12 @@ export const LLMSelector: React.FC<LLMSelectorProps> = ({
<SelectValue>
{currentLlmName
? getDisplayNameForModel(currentLlmName)
: userSettings
? "System Default"
: "User Default"}
: "User Default"}
</SelectValue>
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectItem className="flex" hideCheck value="default">
<span>{userSettings ? "System Default" : "User Default"}</span>
{userSettings && (
<span className=" my-auto font-normal ml-1">
({defaultModelDisplayName})
</span>
)}
<SelectItem hideCheck value="default">
User Default
</SelectItem>
{llmOptions.map((option) => {
if (
@@ -96,9 +69,16 @@ export const LLMSelector: React.FC<LLMSelectorProps> = ({
) {
return (
<SelectItem key={option.value} value={option.value}>
<div className="my-1 flex items-center">
<div className="mt-2 flex items-center">
{option.icon && option.icon({ size: 16 })}
<span className="ml-2">{option.name}</span>
{userDefault &&
option.value ===
structureValue(userDefault, "", userDefault) && (
<span className="ml-2 text-sm text-gray-500">
(user default)
</span>
)}
</div>
</SelectItem>
);

View File

@@ -3,6 +3,7 @@ import React from "react";
import {
OnyxDocument,
DocumentRelevance,
LoadedOnyxDocument,
SearchOnyxDocument,
} from "@/lib/search/interfaces";
import { DocumentFeedbackBlock } from "./DocumentFeedbackBlock";
@@ -11,7 +12,7 @@ import { PopupSpec } from "../admin/connectors/Popup";
import { DocumentUpdatedAtBadge } from "./DocumentUpdatedAtBadge";
import { SourceIcon } from "../SourceIcon";
import { MetadataBadge } from "../MetadataBadge";
import { BookIcon, LightBulbIcon } from "../icons/icons";
import { BookIcon, GlobeIcon, LightBulbIcon, SearchIcon } from "../icons/icons";
import { FaStar } from "react-icons/fa";
import { FiTag } from "react-icons/fi";
@@ -19,6 +20,8 @@ import { SettingsContext } from "../settings/SettingsProvider";
import { CustomTooltip, TooltipGroup } from "../tooltip/CustomTooltip";
import { WarningCircle } from "@phosphor-icons/react";
import TextView from "../chat_search/TextView";
import { SearchResultIcon } from "../SearchResultIcon";
import { ValidSources } from "@/lib/types";
import { openDocument } from "@/lib/search/utils";
export const buildDocumentSummaryDisplay = (

View File

@@ -1,5 +1,12 @@
import { Lightbulb } from "@phosphor-icons/react/dist/ssr";
import { PopupSpec } from "../admin/connectors/Popup";
import { ChevronsDownIcon, ChevronsUpIcon } from "../icons/icons";
import {
BookmarkIcon,
ChevronsDownIcon,
ChevronsUpIcon,
LightBulbIcon,
LightSettingsIcon,
} from "../icons/icons";
import { CustomTooltip } from "../tooltip/CustomTooltip";
type DocumentFeedbackType = "endorse" | "reject" | "hide" | "unhide";

View File

@@ -28,8 +28,8 @@ import { SendIcon } from "../icons/icons";
import { Separator } from "@/components/ui/separator";
import { CustomTooltip } from "../tooltip/CustomTooltip";
import KeyboardSymbol from "@/lib/browserUtilities";
import { HorizontalSourceSelector } from "./filtering/Filters";
import { CCPairBasicInfo, DocumentSet, Tag } from "@/lib/types";
import { HorizontalSourceSelector } from "./filtering/HorizontalSourceSelector";
export const AnimatedToggle = ({
isOn,

View File

@@ -0,0 +1,101 @@
import {
FlowType,
SearchDefaultOverrides,
SearchRequestOverrides,
SearchResponse,
SearchType,
} from "@/lib/search/interfaces";
import { BrainIcon } from "../icons/icons";
const CLICKABLE_CLASS_NAME = "text-link cursor-pointer";
const NUM_DOCUMENTS_FED_TO_GPT = 5;
interface Props {
isFetching: boolean;
searchResponse: SearchResponse | null;
selectedSearchType: SearchType;
setSelectedSearchType: (searchType: SearchType) => void;
defaultOverrides: SearchDefaultOverrides;
restartSearch: (overrides?: SearchRequestOverrides) => void;
forceQADisplay: () => void;
setOffset: (offset: number) => void;
}
const getAssistantMessage = ({
isFetching,
searchResponse,
selectedSearchType,
setSelectedSearchType,
defaultOverrides,
restartSearch,
forceQADisplay,
setOffset,
}: Props): string | JSX.Element | null => {
if (!searchResponse || !searchResponse.suggestedFlowType) {
return null;
}
if (
searchResponse.suggestedFlowType === FlowType.SEARCH &&
!defaultOverrides.forceDisplayQA &&
searchResponse.answer
) {
return (
<div>
This doesn&apos;t seem like a question for a Generative AI. Do you still
want to have{" "}
<span className={CLICKABLE_CLASS_NAME} onClick={forceQADisplay}>
GPT give a response?
</span>
</div>
);
}
if (
(searchResponse.suggestedFlowType === FlowType.QUESTION_ANSWER ||
defaultOverrides.forceDisplayQA) &&
!isFetching &&
searchResponse.answer === ""
) {
return (
<div>
GPT was unable to find an answer in the most relevant{" "}
<b>{` ${(defaultOverrides.offset + 1) * NUM_DOCUMENTS_FED_TO_GPT} `}</b>{" "}
documents. Do you want to{" "}
<span
className={CLICKABLE_CLASS_NAME}
onClick={() => {
const newOffset = defaultOverrides.offset + 1;
setOffset(newOffset);
restartSearch({
offset: newOffset,
});
}}
>
keep searching?
</span>
</div>
);
}
return null;
};
export const SearchHelper: React.FC<Props> = (props) => {
const message = getAssistantMessage(props);
if (!message) {
return null;
}
return (
<div className="border border-border rounded p-3 text-sm">
<div className="flex">
<BrainIcon size={20} />
<b className="ml-2 text-strong">AI Assistant</b>
</div>
<div className="mt-1">{message}</div>
</div>
);
};

View File

@@ -64,7 +64,7 @@ export function FilterDropdown({
select-none
cursor-pointer
flex-none
w-full
w-fit
text-emphasis
items-center
gap-x-1

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState } from "react";
import {
Popover,
PopoverTrigger,
@@ -6,6 +6,8 @@ import {
} from "@/components/ui/popover";
import {
FiCalendar,
FiFilter,
FiFolder,
FiTag,
FiChevronLeft,
FiChevronRight,
@@ -19,8 +21,6 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { SourceIcon } from "@/components/SourceIcon";
import { SelectableDropdown, TagFilter } from "./TagFilter";
import { Input } from "@/components/ui/input";
interface FilterPopupProps {
filterManager: FilterManager;
@@ -48,18 +48,6 @@ export function FilterPopup({
FilterCategories.date
);
const [currentDate, setCurrentDate] = useState(new Date());
const [documentSetSearch, setDocumentSetSearch] = useState("");
const [filteredDocumentSets, setFilteredDocumentSets] = useState<
DocumentSet[]
>(availableDocumentSets);
useEffect(() => {
const lowercasedFilter = documentSetSearch.toLowerCase();
const filtered = availableDocumentSets.filter((docSet) =>
docSet.name.toLowerCase().includes(lowercasedFilter)
);
setFilteredDocumentSets(filtered);
}, [documentSetSearch, availableDocumentSets]);
const FilterOption = ({
category,
@@ -210,45 +198,6 @@ export function FilterPopup({
);
};
const toggleAllSources = () => {
if (filterManager.selectedSources.length === availableSources.length) {
filterManager.setSelectedSources([]);
} else {
filterManager.setSelectedSources([...availableSources]);
}
};
const isSourceSelected = (source: SourceMetadata) =>
filterManager.selectedSources.some(
(s) => s.internalName === source.internalName
);
const toggleSource = (source: SourceMetadata) => {
if (isSourceSelected(source)) {
filterManager.setSelectedSources(
filterManager.selectedSources.filter(
(s) => s.internalName !== source.internalName
)
);
} else {
filterManager.setSelectedSources([
...filterManager.selectedSources,
source,
]);
}
};
const isDocumentSetSelected = (docSet: DocumentSet) =>
filterManager.selectedDocumentSets.includes(docSet.id.toString());
const toggleDocumentSet = (docSet: DocumentSet) => {
filterManager.setSelectedDocumentSets((prev) =>
prev.includes(docSet.id.toString())
? prev.filter((id) => id !== docSet.id.toString())
: [...prev, docSet.id.toString()]
);
};
return (
<Popover>
<PopoverTrigger asChild>
@@ -289,9 +238,9 @@ export function FilterPopup({
)}
</ul>
</div>
<div className="w-2/3 overflow-y-auto">
<div className="w-2/3 p-4 overflow-y-auto">
{selectedFilter === FilterCategories.date && (
<div className="p-4">
<div>
{renderCalendar()}
{filterManager.timeRange ? (
<div className="mt-2 text-xs text-gray-600">
@@ -318,68 +267,112 @@ export function FilterPopup({
</div>
)}
{selectedFilter === FilterCategories.sources && (
<div className="p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Sources</h3>
<div className="flex gap-x-2 items-center ">
<p className="text-xs text-text-dark">Select all</p>
<Checkbox
size="sm"
id="select-all-sources"
checked={
filterManager.selectedSources.length ===
availableSources.length
}
onCheckedChange={toggleAllSources}
/>
</div>
</div>
<ul className="space-y-1">
<div>
<h3 className="text-sm font-semibold mb-2">Sources</h3>
<ul className="space-y-2">
{availableSources.map((source) => (
<SelectableDropdown
icon={
<li
key={source.internalName}
className="flex items-center space-x-2"
>
<Checkbox
id={source.internalName}
checked={filterManager.selectedSources.some(
(s) => s.internalName === source.internalName
)}
onCheckedChange={() => {
filterManager.setSelectedSources((prev) =>
prev.some(
(s) => s.internalName === source.internalName
)
? prev.filter(
(s) => s.internalName !== source.internalName
)
: [...prev, source]
);
}}
/>
<div className="flex items-center space-x-1">
<SourceIcon
sourceType={source.internalName}
iconSize={14}
/>
}
key={source.internalName}
value={source.displayName}
selected={isSourceSelected(source)}
toggle={() => toggleSource(source)}
/>
<label
htmlFor={source.internalName}
className="text-sm cursor-pointer"
>
{source.displayName}
</label>
</div>
</li>
))}
</ul>
</div>
)}
{selectedFilter === FilterCategories.documentSets && (
<div className="pt-4 h-full flex flex-col w-full">
<div className="flex pb-2 px-4">
<Input
placeholder="Search document sets..."
value={documentSetSearch}
onChange={(e) => setDocumentSetSearch(e.target.value)}
className="border border-text-subtle w-full"
/>
</div>
<div className="space-y-1 border-t pt-2 border-t-text-subtle px-4 default-scrollbar w-full max-h-64 overflow-y-auto">
{filteredDocumentSets.map((docSet) => (
<SelectableDropdown
key={docSet.id}
value={docSet.name}
selected={isDocumentSetSelected(docSet)}
toggle={() => toggleDocumentSet(docSet)}
/>
<div>
<h3 className="text-sm font-semibold mb-2">Document Sets</h3>
<ul className="space-y-2">
{availableDocumentSets.map((docSet) => (
<li key={docSet.id} className="flex items-center space-x-2">
<Checkbox
id={docSet.id.toString()}
checked={filterManager.selectedDocumentSets.includes(
docSet.id.toString()
)}
onCheckedChange={() => {
filterManager.setSelectedDocumentSets((prev) =>
prev.includes(docSet.id.toString())
? prev.filter((id) => id !== docSet.id.toString())
: [...prev, docSet.id.toString()]
);
}}
/>
<label
htmlFor={docSet.id.toString()}
className="text-sm cursor-pointer"
>
{docSet.name}
</label>
</li>
))}
</div>
</ul>
</div>
)}
{selectedFilter === FilterCategories.tags && (
<TagFilter
tags={availableTags}
selectedTags={filterManager.selectedTags}
setSelectedTags={filterManager.setSelectedTags}
/>
<div>
<h3 className="text-sm font-semibold mb-2">Tags</h3>
<ul className="space-y-2">
{availableTags.map((tag) => (
<li
key={tag.tag_value}
className="flex items-center space-x-2"
>
<Checkbox
id={tag.tag_value}
checked={filterManager.selectedTags.some(
(t) => t.tag_value === tag.tag_value
)}
onCheckedChange={() => {
filterManager.setSelectedTags((prev) =>
prev.some((t) => t.tag_value === tag.tag_value)
? prev.filter(
(t) => t.tag_value !== tag.tag_value
)
: [...prev, tag]
);
}}
/>
<label
htmlFor={tag.tag_value}
className="text-sm cursor-pointer"
>
{tag.tag_value}
</label>
</li>
))}
</ul>
</div>
)}
</div>
</div>

View File

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

View File

@@ -1,95 +1,183 @@
import React, { useState, useEffect } from "react";
import { containsObject, objectsAreEquivalent } from "@/lib/contains";
import { Tag } from "@/lib/types";
import { Input } from "@/components/ui/input";
export const SelectableDropdown = ({
value,
selected,
icon,
toggle,
}: {
value: string;
selected: boolean;
icon?: React.ReactNode;
toggle: () => void;
}) => {
return (
<div
key={value}
className={`p-2 flex gap-x-2 items-center rounded cursor-pointer transition-colors duration-200 ${
selected
? "bg-gray-200 dark:bg-gray-700"
: "hover:bg-gray-100 dark:hover:bg-gray-800"
}`}
onClick={toggle}
>
{icon && <div className="flex-none">{icon}</div>}
<span className="text-sm">{value}</span>
</div>
);
};
import { useEffect, useRef, useState } from "react";
import { FiTag, FiX } from "react-icons/fi";
import debounce from "lodash/debounce";
import { getValidTags } from "@/lib/tags/tagUtils";
export function TagFilter({
modal,
tags,
selectedTags,
setSelectedTags,
showTagsOnLeft = false,
}: {
modal?: boolean;
tags: Tag[];
selectedTags: Tag[];
setSelectedTags: React.Dispatch<React.SetStateAction<Tag[]>>;
showTagsOnLeft?: boolean;
}) {
const [filterValue, setFilterValue] = useState("");
const [tagOptionsAreVisible, setTagOptionsAreVisible] = useState(false);
const [filteredTags, setFilteredTags] = useState<Tag[]>(tags);
const inputRef = useRef<HTMLInputElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const lowercasedFilter = filterValue.toLowerCase();
const filtered = tags.filter(
(tag) =>
tag.tag_key.toLowerCase().includes(lowercasedFilter) ||
tag.tag_value.toLowerCase().includes(lowercasedFilter)
);
setFilteredTags(filtered);
}, [filterValue, tags]);
const toggleTag = (tag: Tag) => {
setSelectedTags((prev) =>
prev.some(
(t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value
)
? prev.filter(
(t) => t.tag_key !== tag.tag_key || t.tag_value !== tag.tag_value
)
: [...prev, tag]
);
const onSelectTag = (tag: Tag) => {
setSelectedTags((prev) => {
if (containsObject(prev, tag)) {
return prev.filter((t) => !objectsAreEquivalent(t, tag));
} else {
return [...prev, tag];
}
});
};
const isTagSelected = (tag: Tag) =>
selectedTags.some(
(t) => t.tag_key === tag.tag_key && t.tag_value === tag.tag_value
);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
popupRef.current &&
!popupRef.current.contains(event.target as Node) &&
inputRef.current &&
!inputRef.current.contains(event.target as Node)
) {
setTagOptionsAreVisible(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const debouncedFetchTags = useRef(
debounce(async (value: string) => {
if (value) {
const fetchedTags = await getValidTags(value);
setFilteredTags(fetchedTags);
} else {
setFilteredTags(tags);
}
}, 50)
).current;
useEffect(() => {
debouncedFetchTags(filterValue);
return () => {
debouncedFetchTags.cancel();
};
}, [filterValue, tags, debouncedFetchTags]);
const handleFilterChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setFilterValue(event.target.value);
};
return (
<div className="pt-4 h-full flex flex-col w-full">
<div className="flex pb-2 px-4">
<Input
placeholder="Search tags..."
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
className="border border-text-subtle w-full"
/>
</div>
<div className="space-y-1 border-t pt-2 border-t-text-subtle px-4 default-scrollbar w-full max-h-64 overflow-y-auto">
{filteredTags
.sort((a, b) => a.tag_key.localeCompare(b.tag_key))
.map((tag, index) => (
<SelectableDropdown
key={index}
value={`${tag.tag_key}=${tag.tag_value}`}
selected={isTagSelected(tag)}
toggle={() => toggleTag(tag)}
/>
))}
</div>
<div className="relative w-full ">
<input
ref={inputRef}
className={` border border-border py-0.5 px-2 rounded text-sm h-8 ${
modal ? "w-[80vw]" : "w-full"
}`}
placeholder="Find a tag"
value={filterValue}
onChange={handleFilterChange}
onFocus={() => setTagOptionsAreVisible(true)}
/>
{selectedTags.length > 0 && (
<div className="mt-2">
<div className="mt-1 flex flex-wrap gap-x-1 gap-y-1">
{selectedTags.map((tag) => (
<div
key={tag.tag_key + tag.tag_value}
onClick={() => onSelectTag(tag)}
className={`
max-w-full
break-all
line-clamp-1
text-ellipsis
flex
text-sm
border
border-border
py-0.5
px-2
rounded
cursor-pointer
bg-background-search-filter
hover:bg-background-search-filter-dropdown
`}
>
{tag.tag_key}
<b>=</b>
{tag.tag_value}
<FiX className="my-auto ml-1" />
</div>
))}
</div>
<div
onClick={() => setSelectedTags([])}
className="pl-0.5 text-xs text-accent cursor-pointer mt-2 w-fit"
>
Clear all
</div>
</div>
)}
{tagOptionsAreVisible && (
<div
className={` absolute z-[100] ${
showTagsOnLeft
? "left-0 top-0 translate-y-[2rem]"
: "right-0 translate-x-[105%] top-0"
} z-40`}
>
<div
ref={popupRef}
className="p-2 border border-border rounded shadow-lg w-72 bg-background-search-filter"
>
<div className="flex border-b border-border font-medium pb-1 text-xs mb-2">
<FiTag className="mr-1 my-auto" />
Tags
</div>
<div className="flex overflow-y-scroll overflow-x-hidden input-scrollbar max-h-96 flex-wrap gap-x-1 gap-y-1">
{filteredTags.length > 0 ? (
filteredTags.map((tag) => (
<div
key={tag.tag_key + tag.tag_value}
onClick={() => onSelectTag(tag)}
className={`
text-sm
max-w-full
border
border-border
py-0.5
px-2
rounded
cursor-pointer
bg-background
hover:bg-hover
${
selectedTags.includes(tag)
? "bg-background-search-filter-dropdown"
: ""
}
`}
>
{tag.tag_key}
<b>=</b>
{tag.tag_value}
</div>
))
) : (
<div className="text-sm px-2 py-2">No matching tags found</div>
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -68,7 +68,6 @@ export const CustomTooltip = ({
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 });
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const triggerRef = useRef<HTMLSpanElement>(null);
const { groupHovered, setGroupHovered, hoverCountRef } =
useContext(TooltipGroupContext);

View File

@@ -3,18 +3,14 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { useField } from "formik";
import { cn } from "@/lib/utils";
interface BaseCheckboxProps
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> {
size?: "sm" | "md" | "lg";
}
export const Checkbox = React.forwardRef<
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
BaseCheckboxProps
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root> & {
size?: "sm" | "md" | "lg";
}
>(({ className, size = "md", ...props }, ref) => {
const sizeClasses = {
sm: "h-3 w-3",
@@ -26,43 +22,20 @@ export const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer shrink-0 rounded-sm border border-neutral-200 bg-white ring-offset-white " +
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 " +
"focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 " +
"data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 " +
"dark:border-neutral-800 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 " +
"dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900",
"peer shrink-0 rounded-sm border border-neutral-200 border-neutral-900 ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 dark:border-neutral-800 dark:border-neutral-50 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900",
sizeClasses[size],
className
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className={sizeClasses[size]} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
});
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
Checkbox.displayName = "Checkbox";
interface CheckboxFieldProps extends Omit<BaseCheckboxProps, "checked"> {
name: string;
}
export const CheckboxField: React.FC<CheckboxFieldProps> = ({
name,
...props
}) => {
const [field, , helpers] = useField<boolean>({ name, type: "checkbox" });
return (
<Checkbox
checked={field.value}
onCheckedChange={(checked) => {
helpers.setValue(Boolean(checked));
}}
{...props}
/>
);
};
export { Checkbox };

View File

@@ -1,48 +0,0 @@
"use client";
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
));
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-neutral-200 dark:bg-neutral-800" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
));
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
export { ScrollArea, ScrollBar };

View File

@@ -39,8 +39,7 @@ const SelectScrollUpButton = React.forwardRef<
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"absolute top-0 left-0 right-0 z-10 flex cursor-default items-center justify-center h-8",
"bg-gradient-to-b from-white to-transparent dark:from-neutral-950 dark:to-transparent",
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
@@ -57,8 +56,7 @@ const SelectScrollDownButton = React.forwardRef<
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"absolute bottom-0 left-0 right-0 z-10 flex cursor-default items-center justify-center h-8",
"bg-gradient-to-t from-white to-transparent dark:from-neutral-950 dark:to-transparent",
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
@@ -68,6 +66,7 @@ const SelectScrollDownButton = React.forwardRef<
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content> & {
@@ -78,7 +77,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white text-neutral-950 shadow-md dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-neutral-200 bg-white text-neutral-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-950 dark:text-neutral-50",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
@@ -89,7 +88,7 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"relative p-1",
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}

View File

@@ -2,19 +2,15 @@
import * as React from "react";
import * as SwitchPrimitives from "@radix-ui/react-switch";
import { useField } from "formik";
import { cn } from "@/lib/utils";
interface BaseSwitchProps
extends React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> {
circleClassName?: string;
size?: "sm" | "md" | "lg";
}
export const Switch = React.forwardRef<
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
BaseSwitchProps
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & {
circleClassName?: string;
size?: "sm" | "md" | "lg";
}
>(({ circleClassName, className, size = "md", ...props }, ref) => {
const sizeClasses = {
sm: "h-4 w-8",
@@ -36,24 +32,17 @@ export const Switch = React.forwardRef<
return (
<SwitchPrimitives.Root
ref={ref}
className={cn(
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full " +
"border-2 border-transparent transition-colors focus-visible:outline-none " +
"focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 " +
"focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 " +
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200 " +
"dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 " +
"dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800",
"peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200 dark:focus-visible:ring-neutral-300 dark:focus-visible:ring-offset-neutral-950 dark:data-[state=checked]:bg-neutral-50 dark:data-[state=unchecked]:bg-neutral-800",
sizeClasses[size],
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform " +
"data-[state=unchecked]:translate-x-0 dark:bg-neutral-950",
"pointer-events-none block rounded-full bg-white shadow-lg ring-0 transition-transform data-[state=unchecked]:translate-x-0 dark:bg-neutral-950",
thumbSizeClasses[size],
translateClasses[size],
circleClassName
@@ -62,28 +51,6 @@ export const Switch = React.forwardRef<
</SwitchPrimitives.Root>
);
});
Switch.displayName = SwitchPrimitives.Root.displayName;
Switch.displayName = "Switch";
interface SwitchFieldProps extends Omit<BaseSwitchProps, "checked"> {
name: string;
}
export const SwitchField: React.FC<SwitchFieldProps> = ({
name,
onCheckedChange,
...props
}) => {
const [field, , helpers] = useField<boolean>({ name, type: "checkbox" });
return (
<Switch
checked={field.value}
onCheckedChange={(checked) => {
helpers.setValue(Boolean(checked));
onCheckedChange?.(checked);
}}
{...props}
/>
);
};
export { Switch };

View File

@@ -3,22 +3,12 @@ import { cn } from "@/lib/utils";
export default function Title({
children,
className,
size = "sm",
}: {
children: React.ReactNode;
className?: string;
size?: "lg" | "md" | "sm";
}) {
return (
<h1
className={cn(
"text-lg text-text-800 font-medium",
size === "lg" && "text-2xl",
size === "md" && "text-xl",
size === "sm" && "text-lg",
className
)}
>
<h1 className={cn("text-lg text-text-800 font-medium", className)}>
{children}
</h1>
);

View File

@@ -9,14 +9,7 @@ const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
>(({ type = "button", ...props }, ref) => (
<TooltipPrimitive.Trigger ref={ref} type={type} {...props} />
));
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {

View File

@@ -13,11 +13,6 @@ interface UserContextType {
isCloudSuperuser: boolean;
updateUserAutoScroll: (autoScroll: boolean | null) => Promise<void>;
updateUserShortcuts: (enabled: boolean) => Promise<void>;
toggleAssistantPinnedStatus: (
currentPinnedAssistantIDs: number[],
assistantId: number,
isPinned: boolean
) => Promise<boolean>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
@@ -59,6 +54,15 @@ export function UserProvider({
};
const updateUserShortcuts = async (enabled: boolean) => {
try {
const response = await fetch(
`/api/shortcut-enabled?shortcut_enabled=${enabled}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
}
);
setUpToDateUser((prevUser) => {
if (prevUser) {
return {
@@ -72,18 +76,7 @@ export function UserProvider({
return prevUser;
});
const response = await fetch(
`/api/shortcut-enabled?shortcut_enabled=${enabled}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
}
);
if (!response.ok) {
await refreshUser();
throw new Error("Failed to update user shortcut setting");
}
} catch (error) {
@@ -123,56 +116,6 @@ export function UserProvider({
}
};
const toggleAssistantPinnedStatus = async (
currentPinnedAssistantIDs: number[],
assistantId: number,
isPinned: boolean
) => {
setUpToDateUser((prevUser) => {
if (!prevUser) return prevUser;
return {
...prevUser,
preferences: {
...prevUser.preferences,
pinned_assistants: isPinned
? [...currentPinnedAssistantIDs, assistantId]
: currentPinnedAssistantIDs.filter((id) => id !== assistantId),
},
};
});
let updatedPinnedAssistantsIds = currentPinnedAssistantIDs;
if (isPinned) {
updatedPinnedAssistantsIds.push(assistantId);
} else {
updatedPinnedAssistantsIds = updatedPinnedAssistantsIds.filter(
(id) => id !== assistantId
);
}
try {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
ordered_assistant_ids: updatedPinnedAssistantsIds,
}),
});
if (!response.ok) {
throw new Error("Failed to update pinned assistants");
}
await refreshUser();
return true;
} catch (error) {
console.error("Error updating pinned assistants:", error);
return false;
}
};
const refreshUser = async () => {
await fetchUser();
};
@@ -184,7 +127,6 @@ export function UserProvider({
refreshUser,
updateUserAutoScroll,
updateUserShortcuts,
toggleAssistantPinnedStatus,
isAdmin: upToDateUser?.role === UserRole.ADMIN,
// Curator status applies for either global or basic curator
isCurator:

View File

@@ -0,0 +1,39 @@
"use client";
export const toggleAssistantPinnedStatus = async (
currentPinnedAssistantIDs: number[],
assistantId: number,
isPinned: boolean
) => {
let updatedPinnedAssistantsIds = currentPinnedAssistantIDs;
if (isPinned) {
updatedPinnedAssistantsIds.push(assistantId);
} else {
updatedPinnedAssistantsIds = updatedPinnedAssistantsIds.filter(
(id) => id !== assistantId
);
}
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ordered_assistant_ids: updatedPinnedAssistantsIds }),
});
return response.ok;
};
export const reorderPinnedAssistants = async (
assistantIds: number[]
): Promise<boolean> => {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ordered_assistant_ids: assistantIds }),
});
return response.ok;
};

View File

@@ -71,16 +71,3 @@ export async function moveAssistantDown(
}
return false;
}
export const reorderPinnedAssistants = async (
assistantIds: number[]
): Promise<boolean> => {
const response = await fetch(`/api/user/pinned-assistants`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ordered_assistant_ids: assistantIds }),
});
return response.ok;
};

64
web/src/lib/dropdown.ts Normal file
View File

@@ -0,0 +1,64 @@
import { RefObject, useCallback, useEffect } from "react";
interface DropdownPositionProps {
isOpen: boolean;
dropdownRef: RefObject<HTMLElement>;
dropdownMenuRef: RefObject<HTMLElement>;
}
// This hook manages the positioning of a dropdown menu relative to its trigger element.
// It ensures the menu is positioned correctly, adjusting for viewport boundaries and scroll position.
// Also adds event listeners for window resize and scroll to update the position dynamically.
export const useDropdownPosition = ({
isOpen,
dropdownRef,
dropdownMenuRef,
}: DropdownPositionProps) => {
const updateMenuPosition = useCallback(() => {
if (isOpen && dropdownRef.current && dropdownMenuRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const menuRect = dropdownMenuRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
let top = rect.bottom + window.scrollY;
let left = rect.left + window.scrollX;
// Check if there's enough space below
if (rect.bottom + menuRect.height <= viewportHeight) {
// Position below the trigger
top = rect.bottom + window.scrollY;
} else if (rect.top - menuRect.height >= 0) {
// Position above the trigger if there's not enough space below
top = rect.top + window.scrollY - menuRect.height;
} else {
// If there's not enough space above or below, position at the bottom of the viewport
top = viewportHeight + window.scrollY - menuRect.height;
}
// Ensure the dropdown doesn't go off the right edge of the screen
if (left + menuRect.width > viewportWidth) {
left = viewportWidth - menuRect.width + window.scrollX;
}
dropdownMenuRef.current.style.position = "absolute";
dropdownMenuRef.current.style.top = `${top}px`;
dropdownMenuRef.current.style.left = `${rect.left + window.scrollX}px`;
dropdownMenuRef.current.style.width = `${rect.width}px`;
dropdownMenuRef.current.style.zIndex = "10000";
}
}, [isOpen, dropdownRef, dropdownMenuRef]);
useEffect(() => {
updateMenuPosition();
window.addEventListener("resize", updateMenuPosition);
window.addEventListener("scroll", updateMenuPosition);
return () => {
window.removeEventListener("resize", updateMenuPosition);
window.removeEventListener("scroll", updateMenuPosition);
};
}, [isOpen, updateMenuPosition]);
return updateMenuPosition;
};

View File

@@ -124,72 +124,19 @@ export const useBasicConnectorStatus = () => {
export const useLabels = () => {
const { mutate } = useSWRConfig();
const { data: labels, error } = useSWR<PersonaLabel[]>(
const swrResponse = useSWR<PersonaLabel[]>(
"/api/persona/labels",
errorHandlingFetcher
);
const refreshLabels = async () => {
return mutate("/api/persona/labels");
};
const createLabel = async (name: string) => {
const response = await fetch("/api/persona/labels", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (response.ok) {
const newLabel = await response.json();
mutate("/api/persona/labels", [...(labels || []), newLabel], false);
}
return response;
};
const updateLabel = async (id: number, name: string) => {
const response = await fetch(`/api/admin/persona/label/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label_name: name }),
});
if (response.ok) {
mutate(
"/api/persona/labels",
labels?.map((label) => (label.id === id ? { ...label, name } : label)),
false
);
}
return response;
};
const deleteLabel = async (id: number) => {
const response = await fetch(`/api/admin/persona/label/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
if (response.ok) {
mutate(
"/api/persona/labels",
labels?.filter((label) => label.id !== id),
false
);
}
return response;
const updatedLabels = await mutate("/api/persona/labels");
return updatedLabels;
};
return {
labels,
error,
...swrResponse,
refreshLabels,
createLabel,
updateLabel,
deleteLabel,
};
};

View File

@@ -7,7 +7,7 @@ interface UserPreferences {
chosen_assistants: number[] | null;
visible_assistants: number[];
hidden_assistants: number[];
pinned_assistants?: number[];
pinned_assistants: number[];
default_model: string | null;
recent_assistants: number[];
auto_scroll: boolean | null;

View File

@@ -47,9 +47,6 @@ module.exports = {
"4xl": "2000px",
mobile: { max: "767px" },
desktop: "768px",
tall: { raw: "(min-height: 800px)" },
short: { raw: "(max-height: 799px)" },
"very-short": { raw: "(max-height: 600px)" },
},
fontFamily: {
sans: ["Hanken Grotesk", "var(--font-inter)", "sans-serif"],