mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-11 10:42:39 +00:00
Compare commits
7 Commits
nikg/admin
...
chore/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fbfc2337f | ||
|
|
cda30d9304 | ||
|
|
919cb7a266 | ||
|
|
87f9cfe4d0 | ||
|
|
7479230db3 | ||
|
|
fdfed5f20a | ||
|
|
116a110c65 |
2
.github/workflows/storybook-deploy.yml
vendored
2
.github/workflows/storybook-deploy.yml
vendored
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Deploy to Vercel (Production)
|
||||
working-directory: web
|
||||
run: npx --yes "$VERCEL_CLI" deploy storybook-static/ --prod --yes --token="$VERCEL_TOKEN"
|
||||
run: npx --yes "$VERCEL_CLI" deploy storybook-static/ --prod --yes
|
||||
|
||||
notify-slack-on-failure:
|
||||
needs: Deploy-Storybook
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""add timestamps to user table
|
||||
|
||||
Revision ID: 27fb147a843f
|
||||
Revises: a3b8d9e2f1c4
|
||||
Create Date: 2026-03-08 17:18:40.828644
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "27fb147a843f"
|
||||
down_revision = "a3b8d9e2f1c4"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"user",
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("user", "updated_at")
|
||||
op.drop_column("user", "created_at")
|
||||
@@ -258,10 +258,6 @@ class SharepointConnectorCheckpoint(ConnectorCheckpoint):
|
||||
# Track yielded hierarchy nodes by their raw_node_id (URLs) to avoid duplicates
|
||||
seen_hierarchy_node_raw_ids: set[str] = Field(default_factory=set)
|
||||
|
||||
# Track yielded document IDs to avoid processing the same document twice.
|
||||
# The Microsoft Graph delta API can return the same item on multiple pages.
|
||||
seen_document_ids: set[str] = Field(default_factory=set)
|
||||
|
||||
|
||||
class SharepointAuthMethod(Enum):
|
||||
CLIENT_SECRET = "client_secret"
|
||||
@@ -1561,7 +1557,6 @@ class SharepointConnector(
|
||||
checkpoint.current_drive_id = None
|
||||
checkpoint.current_drive_web_url = None
|
||||
checkpoint.current_drive_delta_next_link = None
|
||||
checkpoint.seen_document_ids.clear()
|
||||
|
||||
def _fetch_slim_documents_from_sharepoint(self) -> GenerateSlimDocumentOutput:
|
||||
site_descriptors = self.site_descriptors or self.fetch_sites()
|
||||
@@ -2142,14 +2137,6 @@ class SharepointConnector(
|
||||
item_count = 0
|
||||
for driveitem in driveitems:
|
||||
item_count += 1
|
||||
|
||||
if driveitem.id and driveitem.id in checkpoint.seen_document_ids:
|
||||
logger.debug(
|
||||
f"Skipping duplicate document {driveitem.id} "
|
||||
f"({driveitem.name})"
|
||||
)
|
||||
continue
|
||||
|
||||
driveitem_extension = get_file_ext(driveitem.name)
|
||||
if driveitem_extension not in OnyxFileExtensions.ALL_ALLOWED_EXTENSIONS:
|
||||
logger.warning(
|
||||
@@ -2202,13 +2189,11 @@ class SharepointConnector(
|
||||
|
||||
if isinstance(doc_or_failure, Document):
|
||||
if doc_or_failure.sections:
|
||||
checkpoint.seen_document_ids.add(doc_or_failure.id)
|
||||
yield doc_or_failure
|
||||
elif should_yield_if_empty:
|
||||
doc_or_failure.sections = [
|
||||
TextSection(link=driveitem.web_url, text="")
|
||||
]
|
||||
checkpoint.seen_document_ids.add(doc_or_failure.id)
|
||||
yield doc_or_failure
|
||||
else:
|
||||
logger.warning(
|
||||
|
||||
@@ -25,7 +25,6 @@ from onyx.server.manage.embedding.models import CloudEmbeddingProvider
|
||||
from onyx.server.manage.embedding.models import CloudEmbeddingProviderCreationRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.manage.llm.models import SyncModelEntry
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.enums import EmbeddingProvider
|
||||
|
||||
@@ -370,9 +369,9 @@ def upsert_llm_provider(
|
||||
def sync_model_configurations(
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
models: list[SyncModelEntry],
|
||||
models: list[dict],
|
||||
) -> int:
|
||||
"""Sync model configurations for a dynamic provider (OpenRouter, Bedrock, Ollama, etc.).
|
||||
"""Sync model configurations for a dynamic provider (OpenRouter, Bedrock, Ollama).
|
||||
|
||||
This inserts NEW models from the source API without overwriting existing ones.
|
||||
User preferences (is_visible, max_input_tokens) are preserved for existing models.
|
||||
@@ -380,7 +379,7 @@ def sync_model_configurations(
|
||||
Args:
|
||||
db_session: Database session
|
||||
provider_name: Name of the LLM provider
|
||||
models: List of SyncModelEntry objects describing the fetched models
|
||||
models: List of model dicts with keys: name, display_name, max_input_tokens, supports_image_input
|
||||
|
||||
Returns:
|
||||
Number of new models added
|
||||
@@ -394,20 +393,21 @@ def sync_model_configurations(
|
||||
|
||||
new_count = 0
|
||||
for model in models:
|
||||
if model.name not in existing_names:
|
||||
model_name = model["name"]
|
||||
if model_name not in existing_names:
|
||||
# Insert new model with is_visible=False (user must explicitly enable)
|
||||
supported_flows = [LLMModelFlowType.CHAT]
|
||||
if model.supports_image_input:
|
||||
if model.get("supports_image_input", False):
|
||||
supported_flows.append(LLMModelFlowType.VISION)
|
||||
|
||||
insert_new_model_configuration__no_commit(
|
||||
db_session=db_session,
|
||||
llm_provider_id=provider.id,
|
||||
model_name=model.name,
|
||||
model_name=model_name,
|
||||
supported_flows=supported_flows,
|
||||
is_visible=False,
|
||||
max_input_tokens=model.max_input_tokens,
|
||||
display_name=model.display_name,
|
||||
max_input_tokens=model.get("max_input_tokens"),
|
||||
display_name=model.get("display_name"),
|
||||
)
|
||||
new_count += 1
|
||||
|
||||
|
||||
@@ -163,8 +163,6 @@ class _EncryptedBase(TypeDecorator):
|
||||
|
||||
|
||||
class EncryptedString(_EncryptedBase):
|
||||
# Must redeclare cache_ok in this child class since we explicitly redeclare _is_json
|
||||
cache_ok = True
|
||||
_is_json: bool = False
|
||||
|
||||
def process_bind_param(
|
||||
@@ -191,7 +189,6 @@ class EncryptedString(_EncryptedBase):
|
||||
|
||||
|
||||
class EncryptedJson(_EncryptedBase):
|
||||
cache_ok = True
|
||||
_is_json: bool = True
|
||||
|
||||
def process_bind_param(
|
||||
@@ -339,16 +336,6 @@ class User(SQLAlchemyBaseUserTableUUID, Base):
|
||||
TIMESTAMPAware(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
default_model: Mapped[str] = mapped_column(Text, nullable=True)
|
||||
# organized in typical structured fashion
|
||||
# formatted as `displayName__provider__modelName`
|
||||
|
||||
@@ -11,7 +11,6 @@ from sqlalchemy.orm import Session
|
||||
from sqlalchemy.sql import expression
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.elements import KeyedColumnElement
|
||||
from sqlalchemy.sql.expression import or_
|
||||
|
||||
from onyx.auth.invited_users import remove_user_from_invited_users
|
||||
from onyx.auth.schemas import UserRole
|
||||
@@ -25,7 +24,6 @@ from onyx.db.models import Persona__User
|
||||
from onyx.db.models import SamlAccount
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import User__UserGroup
|
||||
from onyx.db.models import UserGroup
|
||||
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
|
||||
|
||||
|
||||
@@ -164,13 +162,7 @@ def _get_accepted_user_where_clause(
|
||||
where_clause.append(User.role != UserRole.EXT_PERM_USER)
|
||||
|
||||
if email_filter_string is not None:
|
||||
personal_name_col: KeyedColumnElement[Any] = User.__table__.c.personal_name
|
||||
where_clause.append(
|
||||
or_(
|
||||
email_col.ilike(f"%{email_filter_string}%"),
|
||||
personal_name_col.ilike(f"%{email_filter_string}%"),
|
||||
)
|
||||
)
|
||||
where_clause.append(email_col.ilike(f"%{email_filter_string}%"))
|
||||
|
||||
if roles_filter:
|
||||
where_clause.append(User.role.in_(roles_filter))
|
||||
@@ -181,30 +173,6 @@ def _get_accepted_user_where_clause(
|
||||
return where_clause
|
||||
|
||||
|
||||
def get_all_accepted_users(
|
||||
db_session: Session,
|
||||
include_external: bool = False,
|
||||
) -> Sequence[User]:
|
||||
"""Returns all accepted users without pagination.
|
||||
Uses the same filtering as the paginated endpoint but without
|
||||
search, role, or active filters."""
|
||||
stmt = select(User)
|
||||
where_clause = _get_accepted_user_where_clause(
|
||||
include_external=include_external,
|
||||
)
|
||||
stmt = stmt.where(*where_clause)
|
||||
return db_session.scalars(stmt).unique().all()
|
||||
|
||||
|
||||
_USER_SORTABLE_COLUMNS: dict[str, KeyedColumnElement[Any]] = {
|
||||
"email": User.email,
|
||||
"role": User.role,
|
||||
"is_active": User.is_active,
|
||||
"created_at": User.created_at,
|
||||
"updated_at": User.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def get_page_of_filtered_users(
|
||||
db_session: Session,
|
||||
page_size: int,
|
||||
@@ -213,8 +181,6 @@ def get_page_of_filtered_users(
|
||||
is_active_filter: bool | None = None,
|
||||
roles_filter: list[UserRole] = [],
|
||||
include_external: bool = False,
|
||||
sort_by: str | None = None,
|
||||
sort_dir: str | None = None,
|
||||
) -> Sequence[User]:
|
||||
users_stmt = select(User)
|
||||
|
||||
@@ -224,18 +190,10 @@ def get_page_of_filtered_users(
|
||||
include_external=include_external,
|
||||
is_active_filter=is_active_filter,
|
||||
)
|
||||
# Apply filtering
|
||||
users_stmt = users_stmt.where(*where_clause)
|
||||
|
||||
# Apply sorting
|
||||
col = _USER_SORTABLE_COLUMNS.get(sort_by) if sort_by else None
|
||||
if col is not None:
|
||||
users_stmt = users_stmt.order_by(
|
||||
col.desc() if sort_dir == "desc" else col.asc()
|
||||
)
|
||||
|
||||
# Apply pagination
|
||||
users_stmt = users_stmt.offset((page_num) * page_size).limit(page_size)
|
||||
# Apply filtering
|
||||
users_stmt = users_stmt.where(*where_clause)
|
||||
|
||||
return db_session.scalars(users_stmt).unique().all()
|
||||
|
||||
@@ -260,36 +218,6 @@ def get_total_filtered_users_count(
|
||||
return db_session.scalar(total_count_stmt) or 0
|
||||
|
||||
|
||||
def get_user_counts_by_role_and_status(
|
||||
db_session: Session,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Returns user counts grouped by role and by active/inactive status.
|
||||
|
||||
Excludes API key users, anonymous users, and no-auth placeholder users.
|
||||
"""
|
||||
base_where = _get_accepted_user_where_clause()
|
||||
|
||||
# Counts by role
|
||||
role_col = User.__table__.c.role
|
||||
role_stmt = select(role_col, func.count()).where(*base_where).group_by(role_col)
|
||||
role_counts: dict[str, int] = {}
|
||||
for role_val, count in db_session.execute(role_stmt).all():
|
||||
key = role_val.value if hasattr(role_val, "value") else str(role_val)
|
||||
role_counts[key] = count
|
||||
|
||||
# Counts by is_active
|
||||
is_active_col = User.__table__.c.is_active
|
||||
status_stmt = (
|
||||
select(is_active_col, func.count()).where(*base_where).group_by(is_active_col)
|
||||
)
|
||||
status_counts: dict[str, int] = {}
|
||||
for is_active_val, count in db_session.execute(status_stmt).all():
|
||||
key = "active" if is_active_val else "inactive"
|
||||
status_counts[key] = count
|
||||
|
||||
return {"role_counts": role_counts, "status_counts": status_counts}
|
||||
|
||||
|
||||
def get_user_by_email(email: str, db_session: Session) -> User | None:
|
||||
user = (
|
||||
db_session.query(User)
|
||||
@@ -430,28 +358,3 @@ def delete_user_from_db(
|
||||
# NOTE: edge case may exist with race conditions
|
||||
# with this `invited user` scheme generally.
|
||||
remove_user_from_invited_users(user_to_delete.email)
|
||||
|
||||
|
||||
def batch_get_user_groups(
|
||||
db_session: Session,
|
||||
user_ids: list[UUID],
|
||||
) -> dict[UUID, list[tuple[int, str]]]:
|
||||
"""Fetch group memberships for a batch of users in a single query.
|
||||
Returns a mapping of user_id -> list of (group_id, group_name) tuples."""
|
||||
if not user_ids:
|
||||
return {}
|
||||
|
||||
rows = db_session.execute(
|
||||
select(
|
||||
User__UserGroup.user_id,
|
||||
UserGroup.id,
|
||||
UserGroup.name,
|
||||
)
|
||||
.join(UserGroup, UserGroup.id == User__UserGroup.user_group_id)
|
||||
.where(User__UserGroup.user_id.in_(user_ids))
|
||||
).all()
|
||||
|
||||
result: dict[UUID, list[tuple[int, str]]] = {uid: [] for uid in user_ids}
|
||||
for user_id, group_id, group_name in rows:
|
||||
result[user_id].append((group_id, group_name))
|
||||
return result
|
||||
|
||||
@@ -433,16 +433,12 @@ class OpenSearchOldDocumentIndex(OldDocumentIndex):
|
||||
hidden=fields.hidden if fields else None,
|
||||
project_ids=(
|
||||
set(user_fields.user_projects)
|
||||
# NOTE: Empty user_projects is semantically different from None
|
||||
# user_projects.
|
||||
if user_fields and user_fields.user_projects is not None
|
||||
if user_fields and user_fields.user_projects
|
||||
else None
|
||||
),
|
||||
persona_ids=(
|
||||
set(user_fields.personas)
|
||||
# NOTE: Empty personas is semantically different from None
|
||||
# personas.
|
||||
if user_fields and user_fields.personas is not None
|
||||
if user_fields and user_fields.personas
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
@@ -255,12 +255,8 @@ class DocumentQuery:
|
||||
f"result window ({DEFAULT_OPENSEARCH_MAX_RESULT_WINDOW})."
|
||||
)
|
||||
|
||||
# TODO(andrei, yuhong): We can tune this more dynamically based on
|
||||
# num_hits.
|
||||
max_results_per_subquery = DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES
|
||||
|
||||
hybrid_search_subqueries = DocumentQuery._get_hybrid_search_subqueries(
|
||||
query_text, query_vector, vector_candidates=max_results_per_subquery
|
||||
query_text, query_vector
|
||||
)
|
||||
hybrid_search_filters = DocumentQuery._get_search_filters(
|
||||
tenant_state=tenant_state,
|
||||
@@ -295,7 +291,7 @@ class DocumentQuery:
|
||||
# Sources:
|
||||
# https://docs.opensearch.org/latest/vector-search/ai-search/hybrid-search/pagination/
|
||||
# https://opensearch.org/blog/navigating-pagination-in-hybrid-queries-with-the-pagination_depth-parameter/
|
||||
"pagination_depth": max_results_per_subquery,
|
||||
"pagination_depth": DEFAULT_NUM_HYBRID_SEARCH_CANDIDATES,
|
||||
# Applied to all the sub-queries independently (this avoids
|
||||
# subqueries having a lot of results thrown out during
|
||||
# aggregation).
|
||||
@@ -738,13 +734,14 @@ class DocumentQuery:
|
||||
# document's metadata list.
|
||||
filter_clauses.append(_get_tag_filter(tags))
|
||||
|
||||
# Knowledge scope: explicit knowledge attachments restrict what an
|
||||
# assistant can see. When none are set the assistant searches
|
||||
# everything.
|
||||
# Knowledge scope: explicit knowledge attachments restrict what
|
||||
# an assistant can see. When none are set the assistant
|
||||
# searches everything.
|
||||
#
|
||||
# project_id / persona_id are additive: they make overflowing user files
|
||||
# findable but must NOT trigger the restriction on their own (an agent
|
||||
# with no explicit knowledge should search everything).
|
||||
# project_id / persona_id are additive: they make overflowing
|
||||
# user files findable but must NOT trigger the restriction on
|
||||
# their own (an agent with no explicit knowledge should search
|
||||
# everything).
|
||||
has_knowledge_scope = (
|
||||
attached_document_ids
|
||||
or hierarchy_node_ids
|
||||
@@ -772,8 +769,9 @@ class DocumentQuery:
|
||||
knowledge_filter["bool"]["should"].append(
|
||||
_get_document_set_filter(document_sets)
|
||||
)
|
||||
# Additive: widen scope to also cover overflowing user files, but
|
||||
# only when an explicit restriction is already in effect.
|
||||
# Additive: widen scope to also cover overflowing user
|
||||
# files, but only when an explicit restriction is already
|
||||
# in effect.
|
||||
if project_id is not None:
|
||||
knowledge_filter["bool"]["should"].append(
|
||||
_get_user_project_filter(project_id)
|
||||
|
||||
@@ -690,12 +690,9 @@ class VespaIndex(DocumentIndex):
|
||||
)
|
||||
|
||||
project_ids: set[int] | None = None
|
||||
# NOTE: Empty user_projects is semantically different from None
|
||||
# user_projects.
|
||||
if user_fields is not None and user_fields.user_projects is not None:
|
||||
project_ids = set(user_fields.user_projects)
|
||||
persona_ids: set[int] | None = None
|
||||
# NOTE: Empty personas is semantically different from None personas.
|
||||
if user_fields is not None and user_fields.personas is not None:
|
||||
persona_ids = set(user_fields.personas)
|
||||
update_request = MetadataUpdateRequest(
|
||||
|
||||
@@ -43,7 +43,6 @@ WELL_KNOWN_PROVIDER_NAMES = [
|
||||
LlmProviderNames.AZURE,
|
||||
LlmProviderNames.OLLAMA_CHAT,
|
||||
LlmProviderNames.LM_STUDIO,
|
||||
LlmProviderNames.LITELLM_PROXY,
|
||||
]
|
||||
|
||||
|
||||
@@ -60,7 +59,6 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
||||
"ollama": "Ollama",
|
||||
LlmProviderNames.OLLAMA_CHAT: "Ollama",
|
||||
LlmProviderNames.LM_STUDIO: "LM Studio",
|
||||
LlmProviderNames.LITELLM_PROXY: "LiteLLM Proxy",
|
||||
"groq": "Groq",
|
||||
"anyscale": "Anyscale",
|
||||
"deepseek": "DeepSeek",
|
||||
@@ -111,7 +109,6 @@ AGGREGATOR_PROVIDERS: set[str] = {
|
||||
LlmProviderNames.LM_STUDIO,
|
||||
LlmProviderNames.VERTEX_AI,
|
||||
LlmProviderNames.AZURE,
|
||||
LlmProviderNames.LITELLM_PROXY,
|
||||
}
|
||||
|
||||
# Model family name mappings for display name generation
|
||||
|
||||
@@ -11,8 +11,6 @@ OLLAMA_API_KEY_CONFIG_KEY = "OLLAMA_API_KEY"
|
||||
LM_STUDIO_PROVIDER_NAME = "lm_studio"
|
||||
LM_STUDIO_API_KEY_CONFIG_KEY = "LM_STUDIO_API_KEY"
|
||||
|
||||
LITELLM_PROXY_PROVIDER_NAME = "litellm_proxy"
|
||||
|
||||
# Providers that use optional Bearer auth from custom_config
|
||||
PROVIDERS_WITH_SPECIAL_API_KEY_HANDLING: dict[str, str] = {
|
||||
LlmProviderNames.OLLAMA_CHAT: OLLAMA_API_KEY_CONFIG_KEY,
|
||||
|
||||
@@ -15,7 +15,6 @@ from onyx.llm.well_known_providers.auto_update_service import (
|
||||
from onyx.llm.well_known_providers.constants import ANTHROPIC_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import AZURE_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import BEDROCK_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import LITELLM_PROXY_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import LM_STUDIO_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import OLLAMA_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import OPENAI_PROVIDER_NAME
|
||||
@@ -48,7 +47,6 @@ def _get_provider_to_models_map() -> dict[str, list[str]]:
|
||||
OLLAMA_PROVIDER_NAME: [], # Dynamic - fetched from Ollama API
|
||||
LM_STUDIO_PROVIDER_NAME: [], # Dynamic - fetched from LM Studio API
|
||||
OPENROUTER_PROVIDER_NAME: [], # Dynamic - fetched from OpenRouter API
|
||||
LITELLM_PROXY_PROVIDER_NAME: [], # Dynamic - fetched from LiteLLM proxy API
|
||||
}
|
||||
|
||||
|
||||
@@ -333,7 +331,6 @@ def get_provider_display_name(provider_name: str) -> str:
|
||||
BEDROCK_PROVIDER_NAME: "Amazon Bedrock",
|
||||
VERTEXAI_PROVIDER_NAME: "Google Vertex AI",
|
||||
OPENROUTER_PROVIDER_NAME: "OpenRouter",
|
||||
LITELLM_PROXY_PROVIDER_NAME: "LiteLLM Proxy",
|
||||
}
|
||||
|
||||
if provider_name in _ONYX_PROVIDER_DISPLAY_NAMES:
|
||||
|
||||
@@ -7424,9 +7424,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.7",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||
"integrity": "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
|
||||
@@ -58,9 +58,6 @@ from onyx.llm.well_known_providers.llm_provider_options import (
|
||||
from onyx.server.manage.llm.models import BedrockFinalModelResponse
|
||||
from onyx.server.manage.llm.models import BedrockModelsRequest
|
||||
from onyx.server.manage.llm.models import DefaultModel
|
||||
from onyx.server.manage.llm.models import LitellmFinalModelResponse
|
||||
from onyx.server.manage.llm.models import LitellmModelDetails
|
||||
from onyx.server.manage.llm.models import LitellmModelsRequest
|
||||
from onyx.server.manage.llm.models import LLMCost
|
||||
from onyx.server.manage.llm.models import LLMProviderDescriptor
|
||||
from onyx.server.manage.llm.models import LLMProviderResponse
|
||||
@@ -75,7 +72,6 @@ from onyx.server.manage.llm.models import OllamaModelsRequest
|
||||
from onyx.server.manage.llm.models import OpenRouterFinalModelResponse
|
||||
from onyx.server.manage.llm.models import OpenRouterModelDetails
|
||||
from onyx.server.manage.llm.models import OpenRouterModelsRequest
|
||||
from onyx.server.manage.llm.models import SyncModelEntry
|
||||
from onyx.server.manage.llm.models import TestLLMRequest
|
||||
from onyx.server.manage.llm.models import VisionProviderResponse
|
||||
from onyx.server.manage.llm.utils import generate_bedrock_display_name
|
||||
@@ -102,34 +98,6 @@ def _mask_string(value: str) -> str:
|
||||
return value[:4] + "****" + value[-4:]
|
||||
|
||||
|
||||
def _sync_fetched_models(
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
models: list[SyncModelEntry],
|
||||
source_label: str,
|
||||
) -> None:
|
||||
"""Sync fetched models to DB for the given provider.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
provider_name: Name of the LLM provider
|
||||
models: List of SyncModelEntry objects describing the fetched models
|
||||
source_label: Human-readable label for log messages (e.g. "Bedrock", "LiteLLM")
|
||||
"""
|
||||
try:
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=provider_name,
|
||||
models=models,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new {source_label} models to provider '{provider_name}'"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync {source_label} models to DB: {e}")
|
||||
|
||||
|
||||
# Keys in custom_config that contain sensitive credentials
|
||||
_SENSITIVE_CONFIG_KEYS = {
|
||||
"vertex_credentials",
|
||||
@@ -995,20 +963,27 @@ def get_bedrock_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
try:
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in results
|
||||
],
|
||||
source_label="Bedrock",
|
||||
)
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new Bedrock models to provider '{request.provider_name}'"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync Bedrock models to DB: {e}")
|
||||
|
||||
return results
|
||||
|
||||
@@ -1126,20 +1101,27 @@ def get_ollama_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
try:
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="Ollama",
|
||||
)
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new Ollama models to provider '{request.provider_name}'"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync Ollama models to DB: {e}")
|
||||
|
||||
return sorted_results
|
||||
|
||||
@@ -1228,20 +1210,27 @@ def get_openrouter_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
try:
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="OpenRouter",
|
||||
)
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new OpenRouter models to provider '{request.provider_name}'"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync OpenRouter models to DB: {e}")
|
||||
|
||||
return sorted_results
|
||||
|
||||
@@ -1335,119 +1324,26 @@ def get_lm_studio_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="LM Studio",
|
||||
)
|
||||
|
||||
return sorted_results
|
||||
|
||||
|
||||
@admin_router.post("/litellm/available-models")
|
||||
def get_litellm_available_models(
|
||||
request: LitellmModelsRequest,
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[LitellmFinalModelResponse]:
|
||||
"""Fetch available models from Litellm proxy /v1/models endpoint."""
|
||||
response_json = _get_litellm_models_response(
|
||||
api_key=request.api_key, api_base=request.api_base
|
||||
)
|
||||
|
||||
models = response_json.get("data", [])
|
||||
if not isinstance(models, list) or len(models) == 0:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"No models found from your Litellm endpoint",
|
||||
)
|
||||
|
||||
results: list[LitellmFinalModelResponse] = []
|
||||
for model in models:
|
||||
try:
|
||||
model_details = LitellmModelDetails.model_validate(model)
|
||||
|
||||
results.append(
|
||||
LitellmFinalModelResponse(
|
||||
provider_name=model_details.owned_by,
|
||||
model_name=model_details.id,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse Litellm model entry",
|
||||
extra={"error": str(e), "item": str(model)[:1000]},
|
||||
)
|
||||
|
||||
if not results:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"No compatible models found from Litellm",
|
||||
)
|
||||
|
||||
sorted_results = sorted(results, key=lambda m: m.model_name.lower())
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.model_name,
|
||||
display_name=r.model_name,
|
||||
)
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="LiteLLM",
|
||||
)
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new LM Studio models to provider '{request.provider_name}'"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync LM Studio models to DB: {e}")
|
||||
|
||||
return sorted_results
|
||||
|
||||
|
||||
def _get_litellm_models_response(api_key: str, api_base: str) -> dict:
|
||||
"""Perform GET to Litellm proxy /api/v1/models and return parsed JSON."""
|
||||
cleaned_api_base = api_base.strip().rstrip("/")
|
||||
url = f"{cleaned_api_base}/v1/models"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"HTTP-Referer": "https://onyx.app",
|
||||
"X-Title": "Onyx",
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.get(url, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Authentication failed: invalid or missing API key for LiteLLM proxy.",
|
||||
)
|
||||
elif e.response.status_code == 404:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
f"LiteLLM models endpoint not found at {url}. "
|
||||
"Please verify the API base URL.",
|
||||
)
|
||||
else:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY,
|
||||
f"Failed to fetch LiteLLM models: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY,
|
||||
f"Failed to fetch LiteLLM models: {e}",
|
||||
)
|
||||
|
||||
@@ -420,32 +420,3 @@ class LLMProviderResponse(BaseModel, Generic[T]):
|
||||
default_text=default_text,
|
||||
default_vision=default_vision,
|
||||
)
|
||||
|
||||
|
||||
class SyncModelEntry(BaseModel):
|
||||
"""Typed model for syncing fetched models to the DB."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
max_input_tokens: int | None = None
|
||||
supports_image_input: bool = False
|
||||
|
||||
|
||||
class LitellmModelsRequest(BaseModel):
|
||||
api_key: str
|
||||
api_base: str
|
||||
provider_name: str | None = None # Optional: to save models to existing provider
|
||||
|
||||
|
||||
class LitellmModelDetails(BaseModel):
|
||||
"""Response model for Litellm proxy /api/v1/models endpoint"""
|
||||
|
||||
id: str # Model ID (e.g. "gpt-4o")
|
||||
object: str # "model"
|
||||
created: int # Unix timestamp in seconds
|
||||
owned_by: str # Provider name (e.g. "openai")
|
||||
|
||||
|
||||
class LitellmFinalModelResponse(BaseModel):
|
||||
provider_name: str # Provider name (e.g. "openai")
|
||||
model_name: str # Model ID (e.g. "gpt-4o")
|
||||
|
||||
@@ -5,7 +5,6 @@ from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from email_validator import EmailNotValidError
|
||||
@@ -19,7 +18,6 @@ from fastapi import Query
|
||||
from fastapi import Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.anonymous_user import fetch_anonymous_user_info
|
||||
@@ -69,14 +67,11 @@ from onyx.db.user_preferences import update_user_role
|
||||
from onyx.db.user_preferences import update_user_shortcut_enabled
|
||||
from onyx.db.user_preferences import update_user_temperature_override_enabled
|
||||
from onyx.db.user_preferences import update_user_theme_preference
|
||||
from onyx.db.users import batch_get_user_groups
|
||||
from onyx.db.users import delete_user_from_db
|
||||
from onyx.db.users import get_all_accepted_users
|
||||
from onyx.db.users import get_all_users
|
||||
from onyx.db.users import get_page_of_filtered_users
|
||||
from onyx.db.users import get_total_filtered_users_count
|
||||
from onyx.db.users import get_user_by_email
|
||||
from onyx.db.users import get_user_counts_by_role_and_status
|
||||
from onyx.db.users import validate_user_role_update
|
||||
from onyx.key_value_store.factory import get_kv_store
|
||||
from onyx.redis.redis_pool import get_raw_redis_client
|
||||
@@ -103,7 +98,6 @@ from onyx.server.manage.models import UserSpecificAssistantPreferences
|
||||
from onyx.server.models import FullUserSnapshot
|
||||
from onyx.server.models import InvitedUserSnapshot
|
||||
from onyx.server.models import MinimalUserSnapshot
|
||||
from onyx.server.models import UserGroupInfo
|
||||
from onyx.server.usage_limits import is_tenant_on_trial_fn
|
||||
from onyx.server.utils import BasicAuthenticationError
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -183,8 +177,6 @@ def list_accepted_users(
|
||||
page_size: int = Query(10, ge=1, le=1000),
|
||||
roles: list[UserRole] = Query(default=[]),
|
||||
is_active: bool | None = Query(default=None),
|
||||
sort_by: str | None = Query(default=None),
|
||||
sort_dir: str | None = Query(default=None),
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> PaginatedReturn[FullUserSnapshot]:
|
||||
@@ -195,8 +187,6 @@ def list_accepted_users(
|
||||
email_filter_string=q,
|
||||
is_active_filter=is_active,
|
||||
roles_filter=roles,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
)
|
||||
|
||||
total_accepted_users_count = get_total_filtered_users_count(
|
||||
@@ -213,75 +203,14 @@ def list_accepted_users(
|
||||
total_items=0,
|
||||
)
|
||||
|
||||
user_ids = [user.id for user in filtered_accepted_users]
|
||||
groups_by_user = batch_get_user_groups(db_session, user_ids)
|
||||
|
||||
# Batch-fetch SCIM mappings to mark synced users
|
||||
scim_synced_ids: set[UUID] = set()
|
||||
try:
|
||||
from onyx.db.models import ScimUserMapping
|
||||
|
||||
scim_mappings = db_session.scalars(
|
||||
select(ScimUserMapping.user_id).where(ScimUserMapping.user_id.in_(user_ids))
|
||||
).all()
|
||||
scim_synced_ids = set(scim_mappings)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to fetch SCIM mappings; marking all users as non-synced",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return PaginatedReturn(
|
||||
items=[
|
||||
FullUserSnapshot.from_user_model(
|
||||
user,
|
||||
groups=[
|
||||
UserGroupInfo(id=gid, name=gname)
|
||||
for gid, gname in groups_by_user.get(user.id, [])
|
||||
],
|
||||
is_scim_synced=user.id in scim_synced_ids,
|
||||
)
|
||||
for user in filtered_accepted_users
|
||||
FullUserSnapshot.from_user_model(user) for user in filtered_accepted_users
|
||||
],
|
||||
total_items=total_accepted_users_count,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/manage/users/accepted/all", tags=PUBLIC_API_TAGS)
|
||||
def list_all_accepted_users(
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[FullUserSnapshot]:
|
||||
"""Returns all accepted users without pagination.
|
||||
Used by the admin Users page for client-side filtering/sorting."""
|
||||
users = get_all_accepted_users(db_session=db_session)
|
||||
|
||||
if not users:
|
||||
return []
|
||||
|
||||
user_ids = [user.id for user in users]
|
||||
groups_by_user = batch_get_user_groups(db_session, user_ids)
|
||||
|
||||
return [
|
||||
FullUserSnapshot.from_user_model(
|
||||
user,
|
||||
groups=[
|
||||
UserGroupInfo(id=gid, name=gname)
|
||||
for gid, gname in groups_by_user.get(user.id, [])
|
||||
],
|
||||
)
|
||||
for user in users
|
||||
]
|
||||
|
||||
|
||||
@router.get("/manage/users/counts")
|
||||
def get_user_counts(
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> dict[str, dict[str, int]]:
|
||||
return get_user_counts_by_role_and_status(db_session)
|
||||
|
||||
|
||||
@router.get("/manage/users/invited", tags=PUBLIC_API_TAGS)
|
||||
def list_invited_users(
|
||||
_: User = Depends(current_admin_user),
|
||||
@@ -340,10 +269,24 @@ def list_all_users(
|
||||
if accepted_page is None or invited_page is None or slack_users_page is None:
|
||||
return AllUsersResponse(
|
||||
accepted=[
|
||||
FullUserSnapshot.from_user_model(user) for user in accepted_users
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in accepted_users
|
||||
],
|
||||
slack_users=[
|
||||
FullUserSnapshot.from_user_model(user) for user in slack_users
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in slack_users
|
||||
],
|
||||
invited=[InvitedUserSnapshot(email=email) for email in invited_emails],
|
||||
accepted_pages=1,
|
||||
@@ -353,10 +296,26 @@ def list_all_users(
|
||||
|
||||
# Otherwise, return paginated results
|
||||
return AllUsersResponse(
|
||||
accepted=[FullUserSnapshot.from_user_model(user) for user in accepted_users][
|
||||
accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE
|
||||
],
|
||||
slack_users=[FullUserSnapshot.from_user_model(user) for user in slack_users][
|
||||
accepted=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in accepted_users
|
||||
][accepted_page * USERS_PAGE_SIZE : (accepted_page + 1) * USERS_PAGE_SIZE],
|
||||
slack_users=[
|
||||
FullUserSnapshot(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
)
|
||||
for user in slack_users
|
||||
][
|
||||
slack_users_page
|
||||
* USERS_PAGE_SIZE : (slack_users_page + 1)
|
||||
* USERS_PAGE_SIZE
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import datetime
|
||||
from typing import Generic
|
||||
from typing import Optional
|
||||
from typing import TypeVar
|
||||
@@ -32,41 +31,21 @@ class MinimalUserSnapshot(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
class UserGroupInfo(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class FullUserSnapshot(BaseModel):
|
||||
id: UUID
|
||||
email: str
|
||||
role: UserRole
|
||||
is_active: bool
|
||||
password_configured: bool
|
||||
personal_name: str | None
|
||||
created_at: datetime.datetime
|
||||
updated_at: datetime.datetime
|
||||
groups: list[UserGroupInfo]
|
||||
is_scim_synced: bool
|
||||
|
||||
@classmethod
|
||||
def from_user_model(
|
||||
cls,
|
||||
user: User,
|
||||
groups: list[UserGroupInfo] | None = None,
|
||||
is_scim_synced: bool = False,
|
||||
) -> "FullUserSnapshot":
|
||||
def from_user_model(cls, user: User) -> "FullUserSnapshot":
|
||||
return cls(
|
||||
id=user.id,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
password_configured=user.password_configured,
|
||||
personal_name=user.personal_name,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
groups=groups or [],
|
||||
is_scim_synced=is_scim_synced,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ referencing==0.36.2
|
||||
# jsonschema-specifications
|
||||
regex==2025.11.3
|
||||
# via tiktoken
|
||||
release-tag==0.5.2
|
||||
release-tag==0.4.3
|
||||
# via onyx
|
||||
reorder-python-imports-black==3.14.0
|
||||
# via onyx
|
||||
|
||||
@@ -1,398 +0,0 @@
|
||||
"""External dependency tests for the old DocumentIndex interface.
|
||||
|
||||
These tests assume Vespa and OpenSearch are running.
|
||||
|
||||
TODO(ENG-3764)(andrei): Consolidate some of these test fixtures.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from onyx.access.models import DocumentAccess
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.context.search.models import IndexFilters
|
||||
from onyx.db.enums import EmbeddingPrecision
|
||||
from onyx.document_index.interfaces import DocumentIndex
|
||||
from onyx.document_index.interfaces import IndexBatchParams
|
||||
from onyx.document_index.interfaces import VespaChunkRequest
|
||||
from onyx.document_index.interfaces import VespaDocumentUserFields
|
||||
from onyx.document_index.opensearch.client import wait_for_opensearch_with_timeout
|
||||
from onyx.document_index.opensearch.opensearch_document_index import (
|
||||
OpenSearchOldDocumentIndex,
|
||||
)
|
||||
from onyx.document_index.vespa.index import VespaIndex
|
||||
from onyx.document_index.vespa.shared_utils.utils import get_vespa_http_client
|
||||
from onyx.document_index.vespa.shared_utils.utils import wait_for_vespa_with_timeout
|
||||
from onyx.indexing.models import ChunkEmbedding
|
||||
from onyx.indexing.models import DocMetadataAwareIndexChunk
|
||||
from shared_configs.configs import MULTI_TENANT
|
||||
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
|
||||
from shared_configs.contextvars import get_current_tenant_id
|
||||
from tests.external_dependency_unit.constants import TEST_TENANT_ID
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def opensearch_available() -> Generator[None, None, None]:
|
||||
"""Verifies OpenSearch is running, fails the test if not."""
|
||||
if not wait_for_opensearch_with_timeout():
|
||||
pytest.fail("OpenSearch is not available.")
|
||||
yield # Test runs here.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def test_index_name() -> Generator[str, None, None]:
|
||||
yield f"test_index_{uuid.uuid4().hex[:8]}" # Test runs here.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tenant_context() -> Generator[None, None, None]:
|
||||
"""Sets up tenant context for testing."""
|
||||
token = CURRENT_TENANT_ID_CONTEXTVAR.set(TEST_TENANT_ID)
|
||||
try:
|
||||
yield # Test runs here.
|
||||
finally:
|
||||
# Reset the tenant context after the test
|
||||
CURRENT_TENANT_ID_CONTEXTVAR.reset(token)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def httpx_client() -> Generator[httpx.Client, None, None]:
|
||||
client = get_vespa_http_client()
|
||||
try:
|
||||
yield client
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vespa_document_index(
|
||||
httpx_client: httpx.Client,
|
||||
tenant_context: None, # noqa: ARG001
|
||||
test_index_name: str,
|
||||
) -> Generator[VespaIndex, None, None]:
|
||||
vespa_index = VespaIndex(
|
||||
index_name=test_index_name,
|
||||
secondary_index_name=None,
|
||||
large_chunks_enabled=False,
|
||||
secondary_large_chunks_enabled=None,
|
||||
multitenant=MULTI_TENANT,
|
||||
httpx_client=httpx_client,
|
||||
)
|
||||
backend_dir = os.path.abspath(
|
||||
os.path.join(os.path.dirname(__file__), "..", "..", "..")
|
||||
)
|
||||
with patch("os.getcwd", return_value=backend_dir):
|
||||
vespa_index.ensure_indices_exist(
|
||||
primary_embedding_dim=128,
|
||||
primary_embedding_precision=EmbeddingPrecision.FLOAT,
|
||||
secondary_index_embedding_dim=None,
|
||||
secondary_index_embedding_precision=None,
|
||||
)
|
||||
# Verify Vespa is running, fails the test if not. Try 90 seconds for testing
|
||||
# in CI. We have to do this here because this endpoint only becomes live
|
||||
# once we create an index.
|
||||
if not wait_for_vespa_with_timeout(wait_limit=90):
|
||||
pytest.fail("Vespa is not available.")
|
||||
|
||||
# Wait until the schema is actually ready for writes on content nodes. We
|
||||
# probe by attempting a PUT; 200 means the schema is live, 400 means not
|
||||
# yet. This is so scuffed but running the test is really flakey otherwise;
|
||||
# this is only temporary until we entirely move off of Vespa.
|
||||
probe_doc = {
|
||||
"fields": {
|
||||
"document_id": "__probe__",
|
||||
"chunk_id": 0,
|
||||
"blurb": "",
|
||||
"title": "",
|
||||
"skip_title": True,
|
||||
"content": "",
|
||||
"content_summary": "",
|
||||
"source_type": "file",
|
||||
"source_links": "null",
|
||||
"semantic_identifier": "",
|
||||
"section_continuation": False,
|
||||
"large_chunk_reference_ids": [],
|
||||
"metadata": "{}",
|
||||
"metadata_list": [],
|
||||
"metadata_suffix": "",
|
||||
"chunk_context": "",
|
||||
"doc_summary": "",
|
||||
"embeddings": {"full_chunk": [1.0] + [0.0] * 127},
|
||||
"access_control_list": {},
|
||||
"document_sets": {},
|
||||
"image_file_name": None,
|
||||
"user_project": [],
|
||||
"personas": [],
|
||||
"boost": 0.0,
|
||||
"aggregated_chunk_boost_factor": 0.0,
|
||||
"primary_owners": [],
|
||||
"secondary_owners": [],
|
||||
}
|
||||
}
|
||||
schema_ready = False
|
||||
probe_url = (
|
||||
f"http://localhost:8081/document/v1/default/{test_index_name}/docid/__probe__"
|
||||
)
|
||||
for _ in range(60):
|
||||
resp = httpx_client.post(probe_url, json=probe_doc)
|
||||
if resp.status_code == 200:
|
||||
schema_ready = True
|
||||
# Clean up the probe document.
|
||||
httpx_client.delete(probe_url)
|
||||
break
|
||||
time.sleep(1)
|
||||
if not schema_ready:
|
||||
pytest.fail(f"Vespa schema '{test_index_name}' did not become ready in time.")
|
||||
|
||||
yield vespa_index # Test runs here.
|
||||
|
||||
# TODO(ENG-3765)(andrei): Explicitly cleanup index. Not immediately
|
||||
# pressing; in CI we should be using fresh instances of dependencies each
|
||||
# time anyway.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def opensearch_document_index(
|
||||
opensearch_available: None, # noqa: ARG001
|
||||
tenant_context: None, # noqa: ARG001
|
||||
test_index_name: str,
|
||||
) -> Generator[OpenSearchOldDocumentIndex, None, None]:
|
||||
opensearch_index = OpenSearchOldDocumentIndex(
|
||||
index_name=test_index_name,
|
||||
embedding_dim=128,
|
||||
embedding_precision=EmbeddingPrecision.FLOAT,
|
||||
secondary_index_name=None,
|
||||
secondary_embedding_dim=None,
|
||||
secondary_embedding_precision=None,
|
||||
large_chunks_enabled=False,
|
||||
secondary_large_chunks_enabled=None,
|
||||
multitenant=MULTI_TENANT,
|
||||
)
|
||||
opensearch_index.ensure_indices_exist(
|
||||
primary_embedding_dim=128,
|
||||
primary_embedding_precision=EmbeddingPrecision.FLOAT,
|
||||
secondary_index_embedding_dim=None,
|
||||
secondary_index_embedding_precision=None,
|
||||
)
|
||||
|
||||
yield opensearch_index # Test runs here.
|
||||
|
||||
# TODO(ENG-3765)(andrei): Explicitly cleanup index. Not immediately
|
||||
# pressing; in CI we should be using fresh instances of dependencies each
|
||||
# time anyway.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def document_indices(
|
||||
vespa_document_index: VespaIndex,
|
||||
opensearch_document_index: OpenSearchOldDocumentIndex,
|
||||
) -> Generator[list[DocumentIndex], None, None]:
|
||||
# Ideally these are parametrized; doing so with pytest fixtures is tricky.
|
||||
yield [opensearch_document_index, vespa_document_index] # Test runs here.
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def chunks(
|
||||
tenant_context: None, # noqa: ARG001
|
||||
) -> Generator[list[DocMetadataAwareIndexChunk], None, None]:
|
||||
result = []
|
||||
chunk_count = 5
|
||||
doc_id = "test_doc"
|
||||
tenant_id = get_current_tenant_id()
|
||||
access = DocumentAccess.build(
|
||||
user_emails=[],
|
||||
user_groups=[],
|
||||
external_user_emails=[],
|
||||
external_user_group_ids=[],
|
||||
is_public=True,
|
||||
)
|
||||
document_sets: set[str] = set()
|
||||
user_project: list[int] = list()
|
||||
personas: list[int] = list()
|
||||
boost = 0
|
||||
blurb = "blurb"
|
||||
content = "content"
|
||||
title_prefix = ""
|
||||
doc_summary = ""
|
||||
chunk_context = ""
|
||||
title_embedding = [1.0] + [0] * 127
|
||||
# Full 0 vectors are not supported for cos similarity.
|
||||
embeddings = ChunkEmbedding(
|
||||
full_embedding=[1.0] + [0] * 127, mini_chunk_embeddings=[]
|
||||
)
|
||||
source_document = Document(
|
||||
id=doc_id,
|
||||
semantic_identifier="semantic identifier",
|
||||
source=DocumentSource.FILE,
|
||||
sections=[],
|
||||
metadata={},
|
||||
title="title",
|
||||
)
|
||||
metadata_suffix_keyword = ""
|
||||
image_file_id = None
|
||||
source_links: dict[int, str] = {0: ""}
|
||||
ancestor_hierarchy_node_ids: list[int] = []
|
||||
for i in range(chunk_count):
|
||||
result.append(
|
||||
DocMetadataAwareIndexChunk(
|
||||
tenant_id=tenant_id,
|
||||
access=access,
|
||||
document_sets=document_sets,
|
||||
user_project=user_project,
|
||||
personas=personas,
|
||||
boost=boost,
|
||||
aggregated_chunk_boost_factor=0,
|
||||
ancestor_hierarchy_node_ids=ancestor_hierarchy_node_ids,
|
||||
embeddings=embeddings,
|
||||
title_embedding=title_embedding,
|
||||
source_document=source_document,
|
||||
title_prefix=title_prefix,
|
||||
metadata_suffix_keyword=metadata_suffix_keyword,
|
||||
metadata_suffix_semantic="",
|
||||
contextual_rag_reserved_tokens=0,
|
||||
doc_summary=doc_summary,
|
||||
chunk_context=chunk_context,
|
||||
mini_chunk_texts=None,
|
||||
large_chunk_id=None,
|
||||
chunk_id=i,
|
||||
blurb=blurb,
|
||||
content=content,
|
||||
source_links=source_links,
|
||||
image_file_id=image_file_id,
|
||||
section_continuation=False,
|
||||
)
|
||||
)
|
||||
yield result # Test runs here.
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def index_batch_params(
|
||||
tenant_context: None, # noqa: ARG001
|
||||
) -> Generator[IndexBatchParams, None, None]:
|
||||
# WARNING: doc_id_to_previous_chunk_cnt={"test_doc": 0} is hardcoded to 0,
|
||||
# which is only correct on the very first index call. The document_indices
|
||||
# fixture is scope="module", meaning the same OpenSearch and Vespa backends
|
||||
# persist across all test functions in this module. When a second test
|
||||
# function uses this fixture and calls document_index.index(...), the
|
||||
# backend already has 5 chunks for "test_doc" from the previous test run,
|
||||
# but the batch params still claim 0 prior chunks exist. This can lead to
|
||||
# orphaned/duplicate chunks that make subsequent assertions incorrect.
|
||||
# TODO: Whenever adding a second test, either change this or cleanup the
|
||||
# index between test cases.
|
||||
yield IndexBatchParams(
|
||||
doc_id_to_previous_chunk_cnt={"test_doc": 0},
|
||||
doc_id_to_new_chunk_cnt={"test_doc": 5},
|
||||
tenant_id=get_current_tenant_id(),
|
||||
large_chunks_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
class TestDocumentIndexOld:
|
||||
"""Tests the old DocumentIndex interface."""
|
||||
|
||||
def test_update_single_can_clear_user_projects_and_personas(
|
||||
self,
|
||||
document_indices: list[DocumentIndex],
|
||||
# This test case assumes all these chunks correspond to one document.
|
||||
chunks: list[DocMetadataAwareIndexChunk],
|
||||
index_batch_params: IndexBatchParams,
|
||||
) -> None:
|
||||
"""
|
||||
Tests that update_single can clear user_projects and personas.
|
||||
"""
|
||||
for document_index in document_indices:
|
||||
# Precondition.
|
||||
# Ensure there is some non-empty value for user project and
|
||||
# personas.
|
||||
for chunk in chunks:
|
||||
chunk.user_project = [1]
|
||||
chunk.personas = [2]
|
||||
document_index.index(chunks, index_batch_params)
|
||||
|
||||
# Ensure that we can get chunks as expected with filters.
|
||||
doc_id = chunks[0].source_document.id
|
||||
chunk_count = len(chunks)
|
||||
tenant_id = get_current_tenant_id()
|
||||
# We need to specify the chunk index range and specify
|
||||
# batch_retrieval=True below to trigger the codepath for Vespa's
|
||||
# search API, which uses the expected additive filtering for
|
||||
# project_id and persona_id. Otherwise we would use the codepath for
|
||||
# the visit API, which does not have this kind of filtering
|
||||
# implemented.
|
||||
chunk_request = VespaChunkRequest(
|
||||
document_id=doc_id, min_chunk_ind=0, max_chunk_ind=chunk_count - 1
|
||||
)
|
||||
project_persona_filters = IndexFilters(
|
||||
access_control_list=None,
|
||||
tenant_id=tenant_id,
|
||||
project_id=1,
|
||||
persona_id=2,
|
||||
# We need this even though none of the chunks belong to a
|
||||
# document set because project_id and persona_id are only
|
||||
# additive filters in the event the agent has knowledge scope;
|
||||
# if the agent does not, it is implied that it can see
|
||||
# everything it is allowed to.
|
||||
document_set=["1"],
|
||||
)
|
||||
# Not best practice here but the API for refreshing the index to
|
||||
# ensure that the latest data is present is not exposed in this
|
||||
# class and is not the same for Vespa and OpenSearch, so we just
|
||||
# tolerate a sleep for now. As a consequence the number of tests in
|
||||
# this suite should be small. We only need to tolerate this for as
|
||||
# long as we continue to use Vespa, we can consider exposing
|
||||
# something for OpenSearch later.
|
||||
time.sleep(1)
|
||||
inference_chunks = document_index.id_based_retrieval(
|
||||
chunk_requests=[chunk_request],
|
||||
filters=project_persona_filters,
|
||||
batch_retrieval=True,
|
||||
)
|
||||
assert len(inference_chunks) == chunk_count
|
||||
# Sort by chunk id to easily test if we have all chunks.
|
||||
for i, inference_chunk in enumerate(
|
||||
sorted(inference_chunks, key=lambda x: x.chunk_id)
|
||||
):
|
||||
assert inference_chunk.chunk_id == i
|
||||
assert inference_chunk.document_id == doc_id
|
||||
|
||||
# Under test.
|
||||
# Explicitly set empty fields here.
|
||||
user_fields = VespaDocumentUserFields(user_projects=[], personas=[])
|
||||
document_index.update_single(
|
||||
doc_id=doc_id,
|
||||
chunk_count=chunk_count,
|
||||
tenant_id=tenant_id,
|
||||
fields=None,
|
||||
user_fields=user_fields,
|
||||
)
|
||||
|
||||
# Postcondition.
|
||||
filters = IndexFilters(access_control_list=None, tenant_id=tenant_id)
|
||||
# We should expect to get back all expected chunks with no filters.
|
||||
# Again, not best practice here.
|
||||
time.sleep(1)
|
||||
inference_chunks = document_index.id_based_retrieval(
|
||||
chunk_requests=[chunk_request], filters=filters, batch_retrieval=True
|
||||
)
|
||||
assert len(inference_chunks) == chunk_count
|
||||
# Sort by chunk id to easily test if we have all chunks.
|
||||
for i, inference_chunk in enumerate(
|
||||
sorted(inference_chunks, key=lambda x: x.chunk_id)
|
||||
):
|
||||
assert inference_chunk.chunk_id == i
|
||||
assert inference_chunk.document_id == doc_id
|
||||
# Now, we should expect to not get any chunks if we specify the user
|
||||
# project and personas filters.
|
||||
inference_chunks = document_index.id_based_retrieval(
|
||||
chunk_requests=[chunk_request],
|
||||
filters=project_persona_filters,
|
||||
batch_retrieval=True,
|
||||
)
|
||||
assert len(inference_chunks) == 0
|
||||
@@ -239,8 +239,6 @@ def full_deployment_setup() -> Generator[None, None, None]:
|
||||
NOTE: We deliberately duplicate this logic from
|
||||
backend/tests/external_dependency_unit/conftest.py because we need to set
|
||||
opensearch_available just for this module, not the entire test session.
|
||||
|
||||
TODO(ENG-3764)(andrei): Consolidate some of these test fixtures.
|
||||
"""
|
||||
# Patch ENABLE_OPENSEARCH_INDEXING_FOR_ONYX just for this test because we
|
||||
# don't yet want that enabled for all tests.
|
||||
|
||||
@@ -6,7 +6,6 @@ Validates that:
|
||||
- Crash + resume skips already-processed pages
|
||||
- BFS (folder-scoped) drives process all items in one call
|
||||
- 410 Gone triggers a full-resync URL in the checkpoint
|
||||
- Duplicate document IDs across delta pages are deduplicated
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -458,228 +457,3 @@ class TestDeltaPageFetchFailure:
|
||||
assert final_cp.current_drive_name is None
|
||||
assert final_cp.current_drive_id is None
|
||||
assert final_cp.current_drive_delta_next_link is None
|
||||
|
||||
|
||||
class TestDeltaDuplicateDocumentDedup:
|
||||
"""The Microsoft Graph delta API can return the same item on multiple
|
||||
pages. Documents already yielded should be skipped via
|
||||
checkpoint.seen_document_ids."""
|
||||
|
||||
def test_duplicate_across_pages_is_skipped(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Item 'dup' appears on both page 1 and page 2. It should only be
|
||||
yielded once."""
|
||||
connector = _setup_connector(monkeypatch)
|
||||
_mock_convert(monkeypatch)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_fetch_page(
|
||||
self: SharepointConnector, # noqa: ARG001
|
||||
page_url: str, # noqa: ARG001
|
||||
drive_id: str, # noqa: ARG001
|
||||
start: datetime | None = None, # noqa: ARG001
|
||||
end: datetime | None = None, # noqa: ARG001
|
||||
page_size: int = 200, # noqa: ARG001
|
||||
) -> tuple[list[DriveItemData], str | None]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return [_make_item("a"), _make_item("dup")], "https://next2"
|
||||
return [_make_item("dup"), _make_item("b")], None
|
||||
|
||||
monkeypatch.setattr(
|
||||
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
|
||||
)
|
||||
|
||||
checkpoint = _build_ready_checkpoint()
|
||||
|
||||
# Page 1: yields a, dup
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, checkpoint, include_permissions=False
|
||||
)
|
||||
yielded, checkpoint = _consume_generator(gen)
|
||||
docs = _docs_from(yielded)
|
||||
assert [d.id for d in docs] == ["a", "dup"]
|
||||
assert "dup" in checkpoint.seen_document_ids
|
||||
|
||||
# Page 2: dup should be skipped, only b yielded
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, checkpoint, include_permissions=False
|
||||
)
|
||||
yielded, checkpoint = _consume_generator(gen)
|
||||
docs = _docs_from(yielded)
|
||||
assert [d.id for d in docs] == ["b"]
|
||||
|
||||
def test_duplicate_within_same_page_is_skipped(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""If the same item appears twice on a single delta page, only the
|
||||
first occurrence should be yielded."""
|
||||
connector = _setup_connector(monkeypatch)
|
||||
_mock_convert(monkeypatch)
|
||||
|
||||
def fake_fetch_page(
|
||||
self: SharepointConnector, # noqa: ARG001
|
||||
page_url: str, # noqa: ARG001
|
||||
drive_id: str, # noqa: ARG001
|
||||
start: datetime | None = None, # noqa: ARG001
|
||||
end: datetime | None = None, # noqa: ARG001
|
||||
page_size: int = 200, # noqa: ARG001
|
||||
) -> tuple[list[DriveItemData], str | None]:
|
||||
return [_make_item("x"), _make_item("x"), _make_item("y")], None
|
||||
|
||||
monkeypatch.setattr(
|
||||
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
|
||||
)
|
||||
|
||||
checkpoint = _build_ready_checkpoint()
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, checkpoint, include_permissions=False
|
||||
)
|
||||
yielded, checkpoint = _consume_generator(gen)
|
||||
docs = _docs_from(yielded)
|
||||
assert [d.id for d in docs] == ["x", "y"]
|
||||
|
||||
def test_seen_ids_survive_checkpoint_serialization(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""seen_document_ids must survive JSON serialization so that
|
||||
dedup works across crash + resume."""
|
||||
connector = _setup_connector(monkeypatch)
|
||||
_mock_convert(monkeypatch)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def fake_fetch_page(
|
||||
self: SharepointConnector, # noqa: ARG001
|
||||
page_url: str, # noqa: ARG001
|
||||
drive_id: str, # noqa: ARG001
|
||||
start: datetime | None = None, # noqa: ARG001
|
||||
end: datetime | None = None, # noqa: ARG001
|
||||
page_size: int = 200, # noqa: ARG001
|
||||
) -> tuple[list[DriveItemData], str | None]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return [_make_item("a")], "https://next2"
|
||||
return [_make_item("a"), _make_item("b")], None
|
||||
|
||||
monkeypatch.setattr(
|
||||
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
|
||||
)
|
||||
|
||||
checkpoint = _build_ready_checkpoint()
|
||||
|
||||
# Page 1
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, checkpoint, include_permissions=False
|
||||
)
|
||||
_, checkpoint = _consume_generator(gen)
|
||||
assert "a" in checkpoint.seen_document_ids
|
||||
|
||||
# Simulate crash: round-trip through JSON
|
||||
restored = SharepointConnectorCheckpoint.model_validate_json(
|
||||
checkpoint.model_dump_json()
|
||||
)
|
||||
assert "a" in restored.seen_document_ids
|
||||
|
||||
# Page 2 with restored checkpoint: 'a' should be skipped
|
||||
connector2 = _setup_connector(monkeypatch)
|
||||
_mock_convert(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
|
||||
)
|
||||
|
||||
gen = connector2._load_from_checkpoint(
|
||||
_START_TS, _END_TS, restored, include_permissions=False
|
||||
)
|
||||
yielded, final_cp = _consume_generator(gen)
|
||||
docs = _docs_from(yielded)
|
||||
assert [d.id for d in docs] == ["b"]
|
||||
|
||||
def test_no_dedup_across_separate_indexing_runs(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A fresh checkpoint (new indexing run) should have an empty
|
||||
seen_document_ids, so previously-indexed docs are re-processed."""
|
||||
connector = _setup_connector(monkeypatch)
|
||||
_mock_convert(monkeypatch)
|
||||
|
||||
def fake_fetch_page(
|
||||
self: SharepointConnector, # noqa: ARG001
|
||||
page_url: str, # noqa: ARG001
|
||||
drive_id: str, # noqa: ARG001
|
||||
start: datetime | None = None, # noqa: ARG001
|
||||
end: datetime | None = None, # noqa: ARG001
|
||||
page_size: int = 200, # noqa: ARG001
|
||||
) -> tuple[list[DriveItemData], str | None]:
|
||||
return [_make_item("a")], None
|
||||
|
||||
monkeypatch.setattr(
|
||||
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
|
||||
)
|
||||
|
||||
# First run
|
||||
cp1 = _build_ready_checkpoint()
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, cp1, include_permissions=False
|
||||
)
|
||||
yielded, _ = _consume_generator(gen)
|
||||
assert len(_docs_from(yielded)) == 1
|
||||
|
||||
# Second run with a fresh checkpoint — same doc should appear again
|
||||
cp2 = _build_ready_checkpoint()
|
||||
assert len(cp2.seen_document_ids) == 0
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, cp2, include_permissions=False
|
||||
)
|
||||
yielded, _ = _consume_generator(gen)
|
||||
assert len(_docs_from(yielded)) == 1
|
||||
|
||||
def test_same_id_across_drives_not_skipped(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Graph item IDs are only unique within a drive. An item in drive B
|
||||
that happens to share an ID with an item already seen in drive A must
|
||||
NOT be skipped."""
|
||||
connector = _setup_connector(monkeypatch)
|
||||
_mock_convert(monkeypatch)
|
||||
|
||||
def fake_fetch_page(
|
||||
self: SharepointConnector, # noqa: ARG001
|
||||
page_url: str, # noqa: ARG001
|
||||
drive_id: str, # noqa: ARG001
|
||||
start: datetime | None = None, # noqa: ARG001
|
||||
end: datetime | None = None, # noqa: ARG001
|
||||
page_size: int = 200, # noqa: ARG001
|
||||
) -> tuple[list[DriveItemData], str | None]:
|
||||
return [_make_item("shared-id")], None
|
||||
|
||||
monkeypatch.setattr(
|
||||
SharepointConnector, "_fetch_one_delta_page", fake_fetch_page
|
||||
)
|
||||
|
||||
checkpoint = _build_ready_checkpoint(drive_names=["DriveA", "DriveB"])
|
||||
|
||||
# Drive A: yields the item
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, checkpoint, include_permissions=False
|
||||
)
|
||||
yielded, checkpoint = _consume_generator(gen)
|
||||
docs = _docs_from(yielded)
|
||||
assert len(docs) == 1
|
||||
assert docs[0].id == "shared-id"
|
||||
|
||||
# seen_document_ids should have been cleared when drive A finished
|
||||
assert len(checkpoint.seen_document_ids) == 0
|
||||
|
||||
# Drive B: same ID must be yielded again (different drive)
|
||||
gen = connector._load_from_checkpoint(
|
||||
_START_TS, _END_TS, checkpoint, include_permissions=False
|
||||
)
|
||||
yielded, checkpoint = _consume_generator(gen)
|
||||
docs = _docs_from(yielded)
|
||||
assert len(docs) == 1
|
||||
assert docs[0].id == "shared-id"
|
||||
|
||||
@@ -7,7 +7,6 @@ import pytest
|
||||
|
||||
from onyx.db.llm import sync_model_configurations
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
from onyx.server.manage.llm.models import SyncModelEntry
|
||||
|
||||
|
||||
class TestSyncModelConfigurations:
|
||||
@@ -26,18 +25,18 @@ class TestSyncModelConfigurations:
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
models = [
|
||||
SyncModelEntry(
|
||||
name="gpt-4",
|
||||
display_name="GPT-4",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
SyncModelEntry(
|
||||
name="gpt-4o",
|
||||
display_name="GPT-4o",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
{
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
{
|
||||
"name": "gpt-4o",
|
||||
"display_name": "GPT-4o",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
@@ -68,18 +67,18 @@ class TestSyncModelConfigurations:
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
models = [
|
||||
SyncModelEntry(
|
||||
name="gpt-4", # Existing - should be skipped
|
||||
display_name="GPT-4",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
SyncModelEntry(
|
||||
name="gpt-4o", # New - should be inserted
|
||||
display_name="GPT-4o",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
{
|
||||
"name": "gpt-4", # Existing - should be skipped
|
||||
"display_name": "GPT-4",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
{
|
||||
"name": "gpt-4o", # New - should be inserted
|
||||
"display_name": "GPT-4o",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
@@ -106,12 +105,12 @@ class TestSyncModelConfigurations:
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
models = [
|
||||
SyncModelEntry(
|
||||
name="gpt-4", # Already exists
|
||||
display_name="GPT-4",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
{
|
||||
"name": "gpt-4", # Already exists
|
||||
"display_name": "GPT-4",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
@@ -132,7 +131,7 @@ class TestSyncModelConfigurations:
|
||||
sync_model_configurations(
|
||||
db_session=mock_session,
|
||||
provider_name="nonexistent",
|
||||
models=[SyncModelEntry(name="model", display_name="Model")],
|
||||
models=[{"name": "model", "display_name": "Model"}],
|
||||
)
|
||||
|
||||
def test_handles_missing_optional_fields(self) -> None:
|
||||
@@ -146,12 +145,12 @@ class TestSyncModelConfigurations:
|
||||
with patch(
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
# Model with only required fields (max_input_tokens and supports_image_input default)
|
||||
# Model with only required fields
|
||||
models = [
|
||||
SyncModelEntry(
|
||||
name="model-1",
|
||||
display_name="Model 1",
|
||||
),
|
||||
{
|
||||
"name": "model-1",
|
||||
# No display_name, max_input_tokens, or supports_image_input
|
||||
},
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
"""Tests for LLM model fetch endpoints.
|
||||
|
||||
These tests verify the full request/response flow for fetching models
|
||||
from dynamic providers (Ollama, OpenRouter, Litellm), including the
|
||||
from dynamic providers (Ollama, OpenRouter), including the
|
||||
sync-to-DB behavior when provider_name is specified.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.manage.llm.models import LitellmFinalModelResponse
|
||||
from onyx.server.manage.llm.models import LitellmModelsRequest
|
||||
from onyx.server.manage.llm.models import LMStudioFinalModelResponse
|
||||
from onyx.server.manage.llm.models import LMStudioModelsRequest
|
||||
from onyx.server.manage.llm.models import OllamaFinalModelResponse
|
||||
@@ -618,283 +614,3 @@ class TestGetLMStudioAvailableModels:
|
||||
request = LMStudioModelsRequest(api_base="http://localhost:1234")
|
||||
with pytest.raises(OnyxError):
|
||||
get_lm_studio_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
|
||||
class TestGetLitellmAvailableModels:
|
||||
"""Tests for the Litellm proxy model fetch endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_litellm_response(self) -> dict:
|
||||
"""Mock response from Litellm /v1/models endpoint."""
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
},
|
||||
{
|
||||
"id": "claude-3-5-sonnet",
|
||||
"object": "model",
|
||||
"created": 1700000001,
|
||||
"owned_by": "anthropic",
|
||||
},
|
||||
{
|
||||
"id": "gemini-pro",
|
||||
"object": "model",
|
||||
"created": 1700000002,
|
||||
"owned_by": "google",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def test_returns_model_list(self, mock_litellm_response: dict) -> None:
|
||||
"""Test that endpoint returns properly formatted model list."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(isinstance(r, LitellmFinalModelResponse) for r in results)
|
||||
|
||||
def test_model_fields_parsed_correctly(self, mock_litellm_response: dict) -> None:
|
||||
"""Test that provider_name and model_name are correctly extracted."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
gpt = next(r for r in results if r.model_name == "gpt-4o")
|
||||
assert gpt.provider_name == "openai"
|
||||
|
||||
claude = next(r for r in results if r.model_name == "claude-3-5-sonnet")
|
||||
assert claude.provider_name == "anthropic"
|
||||
|
||||
def test_results_sorted_by_model_name(self, mock_litellm_response: dict) -> None:
|
||||
"""Test that results are alphabetically sorted by model_name."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
model_names = [r.model_name for r in results]
|
||||
assert model_names == sorted(model_names, key=str.lower)
|
||||
|
||||
def test_empty_data_raises_onyx_error(self) -> None:
|
||||
"""Test that empty model list raises OnyxError."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"data": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="No models found"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_missing_data_key_raises_onyx_error(self) -> None:
|
||||
"""Test that response without 'data' key raises OnyxError."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_skips_unparseable_entries(self) -> None:
|
||||
"""Test that malformed model entries are skipped without failing."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
response_with_bad_entry = {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
},
|
||||
# Missing required fields
|
||||
{"bad_field": "bad_value"},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = response_with_bad_entry
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].model_name == "gpt-4o"
|
||||
|
||||
def test_all_entries_unparseable_raises_onyx_error(self) -> None:
|
||||
"""Test that OnyxError is raised when all entries fail to parse."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
response_all_bad = {
|
||||
"data": [
|
||||
{"bad_field": "bad_value"},
|
||||
{"another_bad": 123},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = response_all_bad
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="No compatible models"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_api_base_trailing_slash_handled(self) -> None:
|
||||
"""Test that trailing slashes in api_base are handled correctly."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_litellm_response = {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000/",
|
||||
api_key="test-key",
|
||||
)
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
# Should call /v1/models without double slashes
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[0][0] == "http://localhost:4000/v1/models"
|
||||
|
||||
def test_connection_failure_raises_onyx_error(self) -> None:
|
||||
"""Test that connection failures are wrapped in OnyxError."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_get.side_effect = Exception("Connection refused")
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="Failed to fetch LiteLLM models"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_401_raises_authentication_error(self) -> None:
|
||||
"""Test that a 401 response raises OnyxError with authentication message."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_get.side_effect = httpx.HTTPStatusError(
|
||||
"Unauthorized", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="bad-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="Authentication failed"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_404_raises_not_found_error(self) -> None:
|
||||
"""Test that a 404 response raises OnyxError with endpoint not found message."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_get.side_effect = httpx.HTTPStatusError(
|
||||
"Not Found", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="endpoint not found"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import datetime
|
||||
from unittest.mock import MagicMock
|
||||
from uuid import uuid4
|
||||
|
||||
from onyx.auth.schemas import UserRole
|
||||
from onyx.server.models import FullUserSnapshot
|
||||
from onyx.server.models import UserGroupInfo
|
||||
|
||||
|
||||
def _mock_user(
|
||||
personal_name: str | None = "Test User",
|
||||
created_at: datetime.datetime | None = None,
|
||||
updated_at: datetime.datetime | None = None,
|
||||
) -> MagicMock:
|
||||
user = MagicMock()
|
||||
user.id = uuid4()
|
||||
user.email = "test@example.com"
|
||||
user.role = UserRole.BASIC
|
||||
user.is_active = True
|
||||
user.password_configured = True
|
||||
user.personal_name = personal_name
|
||||
user.created_at = created_at or datetime.datetime(
|
||||
2025, 1, 1, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
user.updated_at = updated_at or datetime.datetime(
|
||||
2025, 6, 15, tzinfo=datetime.timezone.utc
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def test_from_user_model_includes_new_fields() -> None:
|
||||
user = _mock_user(personal_name="Alice")
|
||||
groups = [UserGroupInfo(id=1, name="Engineering")]
|
||||
|
||||
snapshot = FullUserSnapshot.from_user_model(user, groups=groups)
|
||||
|
||||
assert snapshot.personal_name == "Alice"
|
||||
assert snapshot.created_at == user.created_at
|
||||
assert snapshot.updated_at == user.updated_at
|
||||
assert snapshot.groups == groups
|
||||
|
||||
|
||||
def test_from_user_model_defaults_groups_to_empty() -> None:
|
||||
user = _mock_user()
|
||||
snapshot = FullUserSnapshot.from_user_model(user)
|
||||
|
||||
assert snapshot.groups == []
|
||||
|
||||
|
||||
def test_from_user_model_personal_name_none() -> None:
|
||||
user = _mock_user(personal_name=None)
|
||||
snapshot = FullUserSnapshot.from_user_model(user)
|
||||
|
||||
assert snapshot.personal_name is None
|
||||
@@ -15,8 +15,9 @@
|
||||
# -f docker-compose.dev.yml up -d --wait
|
||||
#
|
||||
# This overlay:
|
||||
# - Moves Vespa (index), both model servers, code-interpreter, Redis (cache),
|
||||
# and the background worker to profiles so they do not start by default
|
||||
# - Moves Vespa (index), both model servers, code-interpreter, OpenSearch,
|
||||
# MinIO, Redis (cache), and the background worker to profiles so they do
|
||||
# not start by default
|
||||
# - Makes depends_on references to removed services optional
|
||||
# - Sets DISABLE_VECTOR_DB=true on the api_server
|
||||
# - Uses PostgreSQL for caching and auth instead of Redis
|
||||
@@ -27,6 +28,8 @@
|
||||
# --profile inference Inference model server
|
||||
# --profile background Background worker (Celery) — also needs redis
|
||||
# --profile redis Redis cache
|
||||
# --profile opensearch OpenSearch
|
||||
# --profile s3-filestore MinIO (S3-compatible file store)
|
||||
# --profile code-interpreter Code interpreter
|
||||
# =============================================================================
|
||||
|
||||
@@ -38,6 +41,9 @@ services:
|
||||
index:
|
||||
condition: service_started
|
||||
required: false
|
||||
opensearch:
|
||||
condition: service_started
|
||||
required: false
|
||||
cache:
|
||||
condition: service_started
|
||||
required: false
|
||||
@@ -84,4 +90,13 @@ services:
|
||||
inference_model_server:
|
||||
profiles: ["inference"]
|
||||
|
||||
code-interpreter: {}
|
||||
# OpenSearch is not needed in lite mode (no indexing).
|
||||
opensearch:
|
||||
profiles: ["opensearch"]
|
||||
|
||||
# MinIO is not needed in lite mode (Postgres handles file storage).
|
||||
minio:
|
||||
profiles: ["s3-filestore"]
|
||||
|
||||
code-interpreter:
|
||||
profiles: ["code-interpreter"]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
# Expected resource requirements
|
||||
# Expected resource requirements (overridden below if --lite)
|
||||
EXPECTED_DOCKER_RAM_GB=10
|
||||
EXPECTED_DISK_GB=32
|
||||
|
||||
@@ -10,6 +10,10 @@ EXPECTED_DISK_GB=32
|
||||
SHUTDOWN_MODE=false
|
||||
DELETE_DATA_MODE=false
|
||||
INCLUDE_CRAFT=false # Disabled by default, use --include-craft to enable
|
||||
LITE_MODE=false # Disabled by default, use --lite to enable
|
||||
NO_PROMPT=false
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
@@ -25,6 +29,22 @@ while [[ $# -gt 0 ]]; do
|
||||
INCLUDE_CRAFT=true
|
||||
shift
|
||||
;;
|
||||
--lite)
|
||||
LITE_MODE=true
|
||||
shift
|
||||
;;
|
||||
--no-prompt)
|
||||
NO_PROMPT=true
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Onyx Installation Script"
|
||||
echo ""
|
||||
@@ -32,15 +52,21 @@ while [[ $# -gt 0 ]]; do
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --include-craft Enable Onyx Craft (AI-powered web app building)"
|
||||
echo " --lite Deploy Onyx Lite (no Vespa, Redis, or model servers)"
|
||||
echo " --shutdown Stop (pause) Onyx containers"
|
||||
echo " --delete-data Remove all Onyx data (containers, volumes, and files)"
|
||||
echo " --no-prompt Run non-interactively with defaults (for CI/automation)"
|
||||
echo " --dry-run Show what would be done without making changes"
|
||||
echo " --verbose Show detailed output for debugging"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Install Onyx"
|
||||
echo " $0 --lite # Install Onyx Lite (minimal deployment)"
|
||||
echo " $0 --include-craft # Install Onyx with Craft enabled"
|
||||
echo " $0 --shutdown # Pause Onyx services"
|
||||
echo " $0 --delete-data # Completely remove Onyx and all data"
|
||||
echo " $0 --no-prompt # Non-interactive install with defaults"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
@@ -51,8 +77,102 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$VERBOSE" = true ]]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
|
||||
echo "ERROR: --lite and --include-craft cannot be used together."
|
||||
echo "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# When --lite is passed as a flag, lower resource thresholds early (before the
|
||||
# resource check). When lite is chosen interactively, the thresholds are adjusted
|
||||
# inside the new-deployment flow, after the resource check has already passed
|
||||
# with the standard thresholds — which is the safer direction.
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
EXPECTED_DOCKER_RAM_GB=4
|
||||
EXPECTED_DISK_GB=16
|
||||
fi
|
||||
|
||||
INSTALL_ROOT="${INSTALL_PREFIX:-onyx_data}"
|
||||
|
||||
LITE_COMPOSE_FILE="docker-compose.onyx-lite.yml"
|
||||
|
||||
# Build the -f flags for docker compose.
|
||||
# Pass "true" as $1 to auto-detect a previously-downloaded lite overlay
|
||||
# (used by shutdown/delete-data so users don't need to remember --lite).
|
||||
# Without the argument, the lite overlay is only included when --lite was
|
||||
# explicitly passed — preventing install/start from silently staying in
|
||||
# lite mode just because the file exists on disk from a prior run.
|
||||
compose_file_args() {
|
||||
local auto_detect="${1:-false}"
|
||||
local args="-f docker-compose.yml"
|
||||
if [[ "$LITE_MODE" = true ]] || { [[ "$auto_detect" = true ]] && [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; }; then
|
||||
args="$args -f ${LITE_COMPOSE_FILE}"
|
||||
fi
|
||||
echo "$args"
|
||||
}
|
||||
|
||||
# --- Downloader detection (curl with wget fallback) ---
|
||||
DOWNLOADER=""
|
||||
detect_downloader() {
|
||||
if command -v curl &> /dev/null; then
|
||||
DOWNLOADER="curl"
|
||||
return 0
|
||||
fi
|
||||
if command -v wget &> /dev/null; then
|
||||
DOWNLOADER="wget"
|
||||
return 0
|
||||
fi
|
||||
echo "ERROR: Neither curl nor wget found. Please install one and retry."
|
||||
exit 1
|
||||
}
|
||||
detect_downloader
|
||||
|
||||
download_file() {
|
||||
local url="$1"
|
||||
local output="$2"
|
||||
if [[ "$DOWNLOADER" == "curl" ]]; then
|
||||
curl -fsSL --retry 3 --retry-delay 2 --retry-connrefused -o "$output" "$url"
|
||||
else
|
||||
wget -q --tries=3 --timeout=20 -O "$output" "$url"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Interactive prompt helpers ---
|
||||
is_interactive() {
|
||||
[[ "$NO_PROMPT" = false ]] && [[ -t 0 ]]
|
||||
}
|
||||
|
||||
prompt_or_default() {
|
||||
local prompt_text="$1"
|
||||
local default_value="$2"
|
||||
if is_interactive; then
|
||||
read -p "$prompt_text" -r REPLY
|
||||
if [[ -z "$REPLY" ]]; then
|
||||
REPLY="$default_value"
|
||||
fi
|
||||
else
|
||||
REPLY="$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
prompt_yn_or_default() {
|
||||
local prompt_text="$1"
|
||||
local default_value="$2"
|
||||
if is_interactive; then
|
||||
read -p "$prompt_text" -n 1 -r
|
||||
echo ""
|
||||
if [[ -z "$REPLY" ]]; then
|
||||
REPLY="$default_value"
|
||||
fi
|
||||
else
|
||||
REPLY="$default_value"
|
||||
fi
|
||||
}
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
@@ -111,7 +231,7 @@ if [ "$SHUTDOWN_MODE" = true ]; then
|
||||
fi
|
||||
|
||||
# Stop containers (without removing them)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml stop)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) stop)
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Onyx containers stopped (paused)"
|
||||
else
|
||||
@@ -140,12 +260,17 @@ if [ "$DELETE_DATA_MODE" = true ]; then
|
||||
echo " • All downloaded files and configurations"
|
||||
echo " • All user data and documents"
|
||||
echo ""
|
||||
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
|
||||
echo ""
|
||||
|
||||
if [ "$REPLY" != "DELETE" ]; then
|
||||
print_info "Operation cancelled."
|
||||
exit 0
|
||||
if is_interactive; then
|
||||
read -p "Are you sure you want to continue? Type 'DELETE' to confirm: " -r
|
||||
echo ""
|
||||
if [ "$REPLY" != "DELETE" ]; then
|
||||
print_info "Operation cancelled."
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
print_error "Cannot confirm destructive operation in non-interactive mode."
|
||||
print_info "Run interactively or remove the ${INSTALL_ROOT} directory manually."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_info "Removing Onyx containers and volumes..."
|
||||
@@ -164,7 +289,7 @@ if [ "$DELETE_DATA_MODE" = true ]; then
|
||||
fi
|
||||
|
||||
# Stop and remove containers with volumes
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml down -v)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) down -v)
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Onyx containers and volumes removed"
|
||||
else
|
||||
@@ -209,8 +334,7 @@ echo "2. Check your system resources (Docker, memory, disk space)"
|
||||
echo "3. Guide you through deployment options (version, authentication)"
|
||||
echo ""
|
||||
|
||||
# Only prompt for acknowledgment if running interactively
|
||||
if [ -t 0 ]; then
|
||||
if is_interactive; then
|
||||
echo -e "${YELLOW}${BOLD}Please acknowledge and press Enter to continue...${NC}"
|
||||
read -r
|
||||
echo ""
|
||||
@@ -219,6 +343,25 @@ else
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Detect OS (including WSL)
|
||||
IS_WSL=false
|
||||
if [[ -n "${WSL_DISTRO_NAME:-}" ]] || grep -qi microsoft /proc/version 2>/dev/null; then
|
||||
IS_WSL=true
|
||||
fi
|
||||
|
||||
# Dry-run: show plan and exit
|
||||
if [[ "$DRY_RUN" = true ]]; then
|
||||
print_info "Dry run mode — showing what would happen:"
|
||||
echo " • Install root: ${INSTALL_ROOT}"
|
||||
echo " • Lite mode: ${LITE_MODE}"
|
||||
echo " • Include Craft: ${INCLUDE_CRAFT}"
|
||||
echo " • OS type: ${OSTYPE:-unknown} (WSL: ${IS_WSL})"
|
||||
echo " • Downloader: ${DOWNLOADER}"
|
||||
echo ""
|
||||
print_success "Dry run complete (no changes made)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# GitHub repo base URL - using main branch
|
||||
GITHUB_RAW_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deployment/docker_compose"
|
||||
|
||||
@@ -260,41 +403,35 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to compare version numbers
|
||||
# Returns 0 if $1 <= $2, 1 if $1 > $2
|
||||
# Handles missing or non-numeric parts gracefully (treats them as 0)
|
||||
version_compare() {
|
||||
# Returns 0 if $1 <= $2, 1 if $1 > $2
|
||||
local version1=$1
|
||||
local version2=$2
|
||||
local version1="${1:-0.0.0}"
|
||||
local version2="${2:-0.0.0}"
|
||||
|
||||
# Split versions into components
|
||||
local v1_major=$(echo $version1 | cut -d. -f1)
|
||||
local v1_minor=$(echo $version1 | cut -d. -f2)
|
||||
local v1_patch=$(echo $version1 | cut -d. -f3)
|
||||
local v1_major v1_minor v1_patch v2_major v2_minor v2_patch
|
||||
v1_major=$(echo "$version1" | cut -d. -f1)
|
||||
v1_minor=$(echo "$version1" | cut -d. -f2)
|
||||
v1_patch=$(echo "$version1" | cut -d. -f3)
|
||||
v2_major=$(echo "$version2" | cut -d. -f1)
|
||||
v2_minor=$(echo "$version2" | cut -d. -f2)
|
||||
v2_patch=$(echo "$version2" | cut -d. -f3)
|
||||
|
||||
local v2_major=$(echo $version2 | cut -d. -f1)
|
||||
local v2_minor=$(echo $version2 | cut -d. -f2)
|
||||
local v2_patch=$(echo $version2 | cut -d. -f3)
|
||||
# Default non-numeric or empty parts to 0
|
||||
[[ "$v1_major" =~ ^[0-9]+$ ]] || v1_major=0
|
||||
[[ "$v1_minor" =~ ^[0-9]+$ ]] || v1_minor=0
|
||||
[[ "$v1_patch" =~ ^[0-9]+$ ]] || v1_patch=0
|
||||
[[ "$v2_major" =~ ^[0-9]+$ ]] || v2_major=0
|
||||
[[ "$v2_minor" =~ ^[0-9]+$ ]] || v2_minor=0
|
||||
[[ "$v2_patch" =~ ^[0-9]+$ ]] || v2_patch=0
|
||||
|
||||
# Compare major version
|
||||
if [ "$v1_major" -lt "$v2_major" ]; then
|
||||
return 0
|
||||
elif [ "$v1_major" -gt "$v2_major" ]; then
|
||||
return 1
|
||||
fi
|
||||
if [ "$v1_major" -lt "$v2_major" ]; then return 0
|
||||
elif [ "$v1_major" -gt "$v2_major" ]; then return 1; fi
|
||||
|
||||
# Compare minor version
|
||||
if [ "$v1_minor" -lt "$v2_minor" ]; then
|
||||
return 0
|
||||
elif [ "$v1_minor" -gt "$v2_minor" ]; then
|
||||
return 1
|
||||
fi
|
||||
if [ "$v1_minor" -lt "$v2_minor" ]; then return 0
|
||||
elif [ "$v1_minor" -gt "$v2_minor" ]; then return 1; fi
|
||||
|
||||
# Compare patch version
|
||||
if [ "$v1_patch" -le "$v2_patch" ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
[ "$v1_patch" -le "$v2_patch" ]
|
||||
}
|
||||
|
||||
# Check Docker daemon
|
||||
@@ -369,10 +506,10 @@ fi
|
||||
|
||||
if [ "$RESOURCE_WARNING" = true ]; then
|
||||
echo ""
|
||||
print_warning "Onyx recommends at least ${EXPECTED_DOCKER_RAM_GB}GB RAM and ${EXPECTED_DISK_GB}GB disk space for optimal performance."
|
||||
echo ""
|
||||
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
|
||||
print_warning "Onyx recommends at least ${EXPECTED_DOCKER_RAM_GB}GB RAM and ${EXPECTED_DISK_GB}GB disk space for optimal performance in standard mode."
|
||||
print_warning "Lite mode requires less resources, but does not include a vector database."
|
||||
echo ""
|
||||
prompt_yn_or_default "Do you want to continue anyway? (Y/n): " "y"
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_info "Installation cancelled. Please allocate more resources and try again."
|
||||
exit 1
|
||||
@@ -397,6 +534,9 @@ print_info "This step downloads all necessary configuration files from GitHub...
|
||||
echo ""
|
||||
print_info "Downloading the following files:"
|
||||
echo " • docker-compose.yml - Main Docker Compose configuration"
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
echo " • ${LITE_COMPOSE_FILE} - Lite mode overlay"
|
||||
fi
|
||||
echo " • env.template - Environment variables template"
|
||||
echo " • nginx/app.conf.template - Nginx web server configuration"
|
||||
echo " • nginx/run-nginx.sh - Nginx startup script"
|
||||
@@ -406,7 +546,7 @@ echo ""
|
||||
# Download Docker Compose file
|
||||
COMPOSE_FILE="${INSTALL_ROOT}/deployment/docker-compose.yml"
|
||||
print_info "Downloading docker-compose.yml..."
|
||||
if curl -fsSL -o "$COMPOSE_FILE" "${GITHUB_RAW_URL}/docker-compose.yml" 2>/dev/null; then
|
||||
if download_file "${GITHUB_RAW_URL}/docker-compose.yml" "$COMPOSE_FILE" 2>/dev/null; then
|
||||
print_success "Docker Compose file downloaded successfully"
|
||||
|
||||
# Check if Docker Compose version is older than 2.24.0 and show warning
|
||||
@@ -431,8 +571,7 @@ if curl -fsSL -o "$COMPOSE_FILE" "${GITHUB_RAW_URL}/docker-compose.yml" 2>/dev/n
|
||||
echo ""
|
||||
print_warning "The installation will continue, but may fail if Docker Compose cannot parse the file."
|
||||
echo ""
|
||||
read -p "Do you want to continue anyway? (y/N): " -n 1 -r
|
||||
echo ""
|
||||
prompt_yn_or_default "Do you want to continue anyway? (Y/n): " "y"
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_info "Installation cancelled. Please upgrade Docker Compose or manually edit the docker-compose.yml file."
|
||||
exit 1
|
||||
@@ -445,10 +584,40 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download lite overlay if --lite was requested, otherwise remove any stale
|
||||
# overlay from a previous lite install so shutdown/delete-data auto-detection
|
||||
# doesn't mistakenly include it later.
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
LITE_FILE="${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
|
||||
print_info "Downloading ${LITE_COMPOSE_FILE} (lite overlay)..."
|
||||
if download_file "${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "$LITE_FILE" 2>/dev/null; then
|
||||
print_success "Lite overlay downloaded successfully"
|
||||
else
|
||||
print_error "Failed to download lite overlay"
|
||||
print_info "Please ensure you have internet connection and try again"
|
||||
exit 1
|
||||
fi
|
||||
elif [[ -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}" ]]; then
|
||||
if [[ -f "${INSTALL_ROOT}/deployment/.env" ]]; then
|
||||
print_warning "Existing lite overlay found but --lite was not passed."
|
||||
prompt_yn_or_default "Remove lite overlay and switch to standard mode? (y/N): " "n"
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
print_info "Keeping existing lite overlay. Pass --lite to keep using lite mode."
|
||||
LITE_MODE=true
|
||||
else
|
||||
rm -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
|
||||
print_info "Removed lite overlay (switching to standard mode)"
|
||||
fi
|
||||
else
|
||||
rm -f "${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
|
||||
print_info "Removed previous lite overlay (switching to standard mode)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Download env.template file
|
||||
ENV_TEMPLATE="${INSTALL_ROOT}/deployment/env.template"
|
||||
print_info "Downloading env.template..."
|
||||
if curl -fsSL -o "$ENV_TEMPLATE" "${GITHUB_RAW_URL}/env.template" 2>/dev/null; then
|
||||
if download_file "${GITHUB_RAW_URL}/env.template" "$ENV_TEMPLATE" 2>/dev/null; then
|
||||
print_success "Environment template downloaded successfully"
|
||||
else
|
||||
print_error "Failed to download env.template"
|
||||
@@ -462,7 +631,7 @@ NGINX_BASE_URL="https://raw.githubusercontent.com/onyx-dot-app/onyx/main/deploym
|
||||
# Download app.conf.template
|
||||
NGINX_CONFIG="${INSTALL_ROOT}/data/nginx/app.conf.template"
|
||||
print_info "Downloading nginx configuration template..."
|
||||
if curl -fsSL -o "$NGINX_CONFIG" "$NGINX_BASE_URL/app.conf.template" 2>/dev/null; then
|
||||
if download_file "$NGINX_BASE_URL/app.conf.template" "$NGINX_CONFIG" 2>/dev/null; then
|
||||
print_success "Nginx configuration template downloaded"
|
||||
else
|
||||
print_error "Failed to download nginx configuration template"
|
||||
@@ -473,7 +642,7 @@ fi
|
||||
# Download run-nginx.sh script
|
||||
NGINX_RUN_SCRIPT="${INSTALL_ROOT}/data/nginx/run-nginx.sh"
|
||||
print_info "Downloading nginx startup script..."
|
||||
if curl -fsSL -o "$NGINX_RUN_SCRIPT" "$NGINX_BASE_URL/run-nginx.sh" 2>/dev/null; then
|
||||
if download_file "$NGINX_BASE_URL/run-nginx.sh" "$NGINX_RUN_SCRIPT" 2>/dev/null; then
|
||||
chmod +x "$NGINX_RUN_SCRIPT"
|
||||
print_success "Nginx startup script downloaded and made executable"
|
||||
else
|
||||
@@ -485,7 +654,7 @@ fi
|
||||
# Download README file
|
||||
README_FILE="${INSTALL_ROOT}/README.md"
|
||||
print_info "Downloading README.md..."
|
||||
if curl -fsSL -o "$README_FILE" "${GITHUB_RAW_URL}/README.md" 2>/dev/null; then
|
||||
if download_file "${GITHUB_RAW_URL}/README.md" "$README_FILE" 2>/dev/null; then
|
||||
print_success "README.md downloaded successfully"
|
||||
else
|
||||
print_error "Failed to download README.md"
|
||||
@@ -513,7 +682,7 @@ if [ -d "${INSTALL_ROOT}/deployment" ] && [ -f "${INSTALL_ROOT}/deployment/docke
|
||||
|
||||
if [ -n "$COMPOSE_CMD" ]; then
|
||||
# Check if any containers are running
|
||||
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null | wc -l)
|
||||
RUNNING_CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args true) ps -q 2>/dev/null | wc -l)
|
||||
if [ "$RUNNING_CONTAINERS" -gt 0 ]; then
|
||||
print_error "Onyx services are currently running!"
|
||||
echo ""
|
||||
@@ -534,7 +703,7 @@ if [ -f "$ENV_FILE" ]; then
|
||||
echo "• Press Enter to restart with current configuration"
|
||||
echo "• Type 'update' to update to a newer version"
|
||||
echo ""
|
||||
read -p "Choose an option [default: restart]: " -r
|
||||
prompt_or_default "Choose an option [default: restart]: " ""
|
||||
echo ""
|
||||
|
||||
if [ "$REPLY" = "update" ]; then
|
||||
@@ -543,26 +712,30 @@ if [ -f "$ENV_FILE" ]; then
|
||||
echo "• Press Enter for latest (recommended)"
|
||||
echo "• Type a specific tag (e.g., v0.1.0)"
|
||||
echo ""
|
||||
# If --include-craft was passed, default to craft-latest
|
||||
if [ "$INCLUDE_CRAFT" = true ]; then
|
||||
read -p "Enter tag [default: craft-latest]: " -r VERSION
|
||||
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
|
||||
VERSION="$REPLY"
|
||||
else
|
||||
read -p "Enter tag [default: latest]: " -r VERSION
|
||||
prompt_or_default "Enter tag [default: latest]: " "latest"
|
||||
VERSION="$REPLY"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
if [ "$INCLUDE_CRAFT" = true ]; then
|
||||
VERSION="craft-latest"
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
else
|
||||
VERSION="latest"
|
||||
print_info "Selected: Latest version"
|
||||
fi
|
||||
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
elif [ "$VERSION" = "latest" ]; then
|
||||
print_info "Selected: Latest version"
|
||||
else
|
||||
print_info "Selected: $VERSION"
|
||||
fi
|
||||
|
||||
# Reject craft image tags when running in lite mode
|
||||
if [[ "$LITE_MODE" = true ]] && [[ "${VERSION:-}" == craft-* ]]; then
|
||||
print_error "Cannot use a craft image tag (${VERSION}) with --lite."
|
||||
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Update .env file with new version
|
||||
print_info "Updating configuration for version $VERSION..."
|
||||
if grep -q "^IMAGE_TAG=" "$ENV_FILE"; then
|
||||
@@ -581,13 +754,73 @@ if [ -f "$ENV_FILE" ]; then
|
||||
fi
|
||||
print_success "Configuration updated for upgrade"
|
||||
else
|
||||
# Reject restarting a craft deployment in lite mode
|
||||
EXISTING_TAG=$(grep "^IMAGE_TAG=" "$ENV_FILE" | head -1 | cut -d'=' -f2 | tr -d ' "'"'"'')
|
||||
if [[ "$LITE_MODE" = true ]] && [[ "${EXISTING_TAG:-}" == craft-* ]]; then
|
||||
print_error "Cannot restart a craft deployment (${EXISTING_TAG}) with --lite."
|
||||
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_info "Keeping existing configuration..."
|
||||
print_success "Will restart with current settings"
|
||||
fi
|
||||
|
||||
# Ensure COMPOSE_PROFILES is cleared when running in lite mode on an
|
||||
# existing .env (the template ships with s3-filestore enabled).
|
||||
if [[ "$LITE_MODE" = true ]] && grep -q "^COMPOSE_PROFILES=.*s3-filestore" "$ENV_FILE" 2>/dev/null; then
|
||||
sed -i.bak 's/^COMPOSE_PROFILES=.*/COMPOSE_PROFILES=/' "$ENV_FILE" 2>/dev/null || true
|
||||
print_success "Cleared COMPOSE_PROFILES for lite mode"
|
||||
fi
|
||||
else
|
||||
print_info "No existing .env file found. Setting up new deployment..."
|
||||
echo ""
|
||||
|
||||
# Ask for deployment mode (standard vs lite) unless already set via --lite flag
|
||||
if [[ "$LITE_MODE" = false ]]; then
|
||||
print_info "Which deployment mode would you like?"
|
||||
echo ""
|
||||
echo " 1) Standard - Full deployment with search, connectors, and RAG"
|
||||
echo " 2) Lite - Minimal deployment (no Vespa, Redis, or model servers)"
|
||||
echo " LLM chat, tools, file uploads, and Projects still work"
|
||||
echo ""
|
||||
prompt_or_default "Choose a mode (1 or 2) [default: 1]: " "1"
|
||||
echo ""
|
||||
|
||||
case "$REPLY" in
|
||||
2)
|
||||
LITE_MODE=true
|
||||
print_info "Selected: Lite mode"
|
||||
LITE_FILE="${INSTALL_ROOT}/deployment/${LITE_COMPOSE_FILE}"
|
||||
print_info "Downloading ${LITE_COMPOSE_FILE} (lite overlay)..."
|
||||
if download_file "${GITHUB_RAW_URL}/${LITE_COMPOSE_FILE}" "$LITE_FILE"; then
|
||||
print_success "Lite overlay downloaded successfully"
|
||||
else
|
||||
print_error "Failed to download lite overlay"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
print_info "Selected: Standard mode"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
print_info "Deployment mode: Lite (set via --lite flag)"
|
||||
fi
|
||||
|
||||
# Validate lite + craft combination (could now be set interactively)
|
||||
if [[ "$LITE_MODE" = true ]] && [[ "$INCLUDE_CRAFT" = true ]]; then
|
||||
print_error "--include-craft cannot be used with Lite mode."
|
||||
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Adjust resource expectations for lite mode
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
EXPECTED_DOCKER_RAM_GB=4
|
||||
EXPECTED_DISK_GB=16
|
||||
fi
|
||||
|
||||
# Ask for version
|
||||
print_info "Which tag would you like to deploy?"
|
||||
echo ""
|
||||
@@ -595,23 +828,21 @@ else
|
||||
echo "• Press Enter for craft-latest (recommended for Craft)"
|
||||
echo "• Type a specific tag (e.g., craft-v1.0.0)"
|
||||
echo ""
|
||||
read -p "Enter tag [default: craft-latest]: " -r VERSION
|
||||
prompt_or_default "Enter tag [default: craft-latest]: " "craft-latest"
|
||||
VERSION="$REPLY"
|
||||
else
|
||||
echo "• Press Enter for latest (recommended)"
|
||||
echo "• Type a specific tag (e.g., v0.1.0)"
|
||||
echo ""
|
||||
read -p "Enter tag [default: latest]: " -r VERSION
|
||||
prompt_or_default "Enter tag [default: latest]: " "latest"
|
||||
VERSION="$REPLY"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ -z "$VERSION" ]; then
|
||||
if [ "$INCLUDE_CRAFT" = true ]; then
|
||||
VERSION="craft-latest"
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
else
|
||||
VERSION="latest"
|
||||
print_info "Selected: Latest tag"
|
||||
fi
|
||||
if [ "$INCLUDE_CRAFT" = true ] && [ "$VERSION" = "craft-latest" ]; then
|
||||
print_info "Selected: craft-latest (Craft enabled)"
|
||||
elif [ "$VERSION" = "latest" ]; then
|
||||
print_info "Selected: Latest tag"
|
||||
else
|
||||
print_info "Selected: $VERSION"
|
||||
fi
|
||||
@@ -645,6 +876,13 @@ else
|
||||
# Use basic auth by default
|
||||
AUTH_SCHEMA="basic"
|
||||
|
||||
# Reject craft image tags when running in lite mode (must check before writing .env)
|
||||
if [[ "$LITE_MODE" = true ]] && [[ "${VERSION:-}" == craft-* ]]; then
|
||||
print_error "Cannot use a craft image tag (${VERSION}) with --lite."
|
||||
print_info "Craft requires services (Vespa, Redis, background workers) that lite mode disables."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create .env file from template
|
||||
print_info "Creating .env file with your selections..."
|
||||
cp "$ENV_TEMPLATE" "$ENV_FILE"
|
||||
@@ -654,6 +892,13 @@ else
|
||||
sed -i.bak "s/^IMAGE_TAG=.*/IMAGE_TAG=$VERSION/" "$ENV_FILE"
|
||||
print_success "IMAGE_TAG set to $VERSION"
|
||||
|
||||
# In lite mode, clear COMPOSE_PROFILES so profiled services (MinIO, etc.)
|
||||
# stay disabled — the template ships with s3-filestore enabled by default.
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
sed -i.bak 's/^COMPOSE_PROFILES=.*/COMPOSE_PROFILES=/' "$ENV_FILE" 2>/dev/null || true
|
||||
print_success "Cleared COMPOSE_PROFILES for lite mode"
|
||||
fi
|
||||
|
||||
# Configure basic authentication (default)
|
||||
sed -i.bak 's/^AUTH_TYPE=.*/AUTH_TYPE=basic/' "$ENV_FILE" 2>/dev/null || true
|
||||
print_success "Basic authentication enabled in configuration"
|
||||
@@ -774,7 +1019,7 @@ print_step "Pulling Docker images"
|
||||
print_info "This may take several minutes depending on your internet connection..."
|
||||
echo ""
|
||||
print_info "Downloading Docker images (this may take a while)..."
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml pull --quiet)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) pull --quiet)
|
||||
if [ $? -eq 0 ]; then
|
||||
print_success "Docker images downloaded successfully"
|
||||
else
|
||||
@@ -788,9 +1033,9 @@ print_info "Launching containers..."
|
||||
echo ""
|
||||
if [ "$USE_LATEST" = true ]; then
|
||||
print_info "Force pulling latest images and recreating containers..."
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d --pull always --force-recreate)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d --pull always --force-recreate)
|
||||
else
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml up -d)
|
||||
(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) up -d)
|
||||
fi
|
||||
if [ $? -ne 0 ]; then
|
||||
print_error "Failed to start Onyx services"
|
||||
@@ -812,7 +1057,7 @@ echo ""
|
||||
# Check for restart loops
|
||||
print_info "Checking container health status..."
|
||||
RESTART_ISSUES=false
|
||||
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD -f docker-compose.yml ps -q 2>/dev/null)
|
||||
CONTAINERS=$(cd "${INSTALL_ROOT}/deployment" && $COMPOSE_CMD $(compose_file_args) ps -q 2>/dev/null)
|
||||
|
||||
for CONTAINER in $CONTAINERS; do
|
||||
PROJECT_NAME="$(basename "$INSTALL_ROOT")_deployment_"
|
||||
@@ -841,7 +1086,7 @@ if [ "$RESTART_ISSUES" = true ]; then
|
||||
print_error "Some containers are experiencing issues!"
|
||||
echo ""
|
||||
print_info "Please check the logs for more information:"
|
||||
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD -f docker-compose.yml logs)"
|
||||
echo " (cd \"${INSTALL_ROOT}/deployment\" && $COMPOSE_CMD $(compose_file_args) logs)"
|
||||
|
||||
echo ""
|
||||
print_info "If the issue persists, please contact: founders@onyx.app"
|
||||
@@ -860,8 +1105,12 @@ check_onyx_health() {
|
||||
echo ""
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
# Check for successful HTTP responses (200, 301, 302, etc.)
|
||||
local http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port")
|
||||
local http_code=""
|
||||
if [[ "$DOWNLOADER" == "curl" ]]; then
|
||||
http_code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:$port" 2>/dev/null || echo "000")
|
||||
else
|
||||
http_code=$(wget -q --spider -S "http://localhost:$port" 2>&1 | grep "HTTP/" | tail -1 | awk '{print $2}' || echo "000")
|
||||
fi
|
||||
if echo "$http_code" | grep -qE "^(200|301|302|303|307|308)$"; then
|
||||
return 0
|
||||
fi
|
||||
@@ -917,6 +1166,18 @@ print_info "If authentication is enabled, you can create your admin account here
|
||||
echo " • Visit http://localhost:${HOST_PORT}/auth/signup to create your admin account"
|
||||
echo " • The first user created will automatically have admin privileges"
|
||||
echo ""
|
||||
if [[ "$LITE_MODE" = true ]]; then
|
||||
echo ""
|
||||
print_info "Running in Lite mode — the following services are NOT started:"
|
||||
echo " • Vespa (vector database)"
|
||||
echo " • Redis (cache)"
|
||||
echo " • Model servers (embedding/inference)"
|
||||
echo " • Background workers (Celery)"
|
||||
echo ""
|
||||
print_info "Connectors and RAG search are disabled. LLM chat, tools, user file"
|
||||
print_info "uploads, Projects, Agent knowledge, and code interpreter still work."
|
||||
fi
|
||||
echo ""
|
||||
print_info "Refer to the README in the ${INSTALL_ROOT} directory for more information."
|
||||
echo ""
|
||||
print_info "For help or issues, contact: founders@onyx.app"
|
||||
|
||||
@@ -153,7 +153,7 @@ dev = [
|
||||
"pytest-repeat==0.9.4",
|
||||
"pytest-xdist==3.8.0",
|
||||
"pytest==8.3.5",
|
||||
"release-tag==0.5.2",
|
||||
"release-tag==0.4.3",
|
||||
"reorder-python-imports-black==3.14.0",
|
||||
"ruff==0.12.0",
|
||||
"types-beautifulsoup4==4.12.0.3",
|
||||
|
||||
18
uv.lock
generated
18
uv.lock
generated
@@ -4485,7 +4485,7 @@ requires-dist = [
|
||||
{ name = "pywikibot", marker = "extra == 'backend'", specifier = "==9.0.0" },
|
||||
{ name = "rapidfuzz", marker = "extra == 'backend'", specifier = "==3.13.0" },
|
||||
{ name = "redis", marker = "extra == 'backend'", specifier = "==5.0.8" },
|
||||
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.5.2" },
|
||||
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.4.3" },
|
||||
{ name = "reorder-python-imports-black", marker = "extra == 'dev'", specifier = "==3.14.0" },
|
||||
{ name = "requests", marker = "extra == 'backend'", specifier = "==2.32.5" },
|
||||
{ name = "requests-oauthlib", marker = "extra == 'backend'", specifier = "==1.3.1" },
|
||||
@@ -6338,16 +6338,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "release-tag"
|
||||
version = "0.5.2"
|
||||
version = "0.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/92/01192a540b29cfadaa23850c8f6a2041d541b83a3fa1dc52a5f55212b3b6/release_tag-0.5.2-py3-none-any.whl", hash = "sha256:1e9ca7618bcfc63ad7a0728c84bbad52ef82d07586c4cc11365b44ea8f588069", size = 1264752, upload-time = "2026-03-11T00:27:18.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/77/81fb42a23cd0de61caf84266f7aac1950b1c324883788b7c48e5344f61ae/release_tag-0.5.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8fbc61ff7bac2b96fab09566ec45c6508c201efc3f081f57702e1761bbc178d5", size = 1255075, upload-time = "2026-03-11T00:27:24.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e6/769f8be94304529c1a531e995f2f3ac83f3c54738ce488b0abde75b20851/release_tag-0.5.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa3d7e495a0c516858a81878d03803539712677a3d6e015503de21cce19bea5e", size = 1163627, upload-time = "2026-03-11T00:27:26.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/68/7543e9daa0dfd41c487bf140d91fd5879327bb7c001a96aa5264667c30a1/release_tag-0.5.2-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e8b60453218d6926da1fdcb99c2e17c851be0d7ab1975e97951f0bff5f32b565", size = 1140133, upload-time = "2026-03-11T00:27:20.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/30/9087825696271012d889d136310dbdf0811976ae2b2f5a490f4e437903e1/release_tag-0.5.2-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:0e302ed60c2bf8b7ba5634842be28a27d83cec995869e112b0348b3f01a84ff5", size = 1264767, upload-time = "2026-03-11T00:27:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a3/5b51b0cbdbf2299f545124beab182cfdfe01bf5b615efbc94aee3a64ea67/release_tag-0.5.2-py3-none-win_amd64.whl", hash = "sha256:e3c0629d373a16b9a3da965e89fca893640ce9878ec548865df3609b70989a89", size = 1340816, upload-time = "2026-03-11T00:27:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/832c2023a8bd8414c93452bd8b43bf61cedfa5b9575f70c06fb911e51a29/release_tag-0.5.2-py3-none-win_arm64.whl", hash = "sha256:5f26b008e0be0c7a122acd8fcb1bb5c822f38e77fed0c0bf6c550cc226c6bf14", size = 1203191, upload-time = "2026-03-11T00:27:29.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/18/c1d17d973f73f0aa7e2c45f852839ab909756e1bd9727d03babe400fcef0/release_tag-0.4.3-py3-none-any.whl", hash = "sha256:4206f4fa97df930c8176bfee4d3976a7385150ed14b317bd6bae7101ac8b66dd", size = 1181112, upload-time = "2025-12-03T00:18:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c7/ecc443953840ac313856b2181f55eb8d34fa2c733cdd1edd0bcceee0938d/release_tag-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a347a9ad3d2af16e5367e52b451fbc88a0b7b666850758e8f9a601554a8fb13", size = 1170517, upload-time = "2025-12-03T00:18:11.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/2f6ffa0d87c792364ca9958433fe088c8acc3d096ac9734040049c6ad506/release_tag-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d1603aa37d8e4f5df63676bbfddc802fbc108a744ba28288ad25c997981c164", size = 1101663, upload-time = "2025-12-03T00:18:15.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ed/9e4ebe400fc52e38dda6e6a45d9da9decd4535ab15e170b8d9b229a66730/release_tag-0.4.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6db7b81a198e3ba6a87496a554684912c13f9297ea8db8600a80f4f971709d37", size = 1079322, upload-time = "2025-12-03T00:18:16.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/64/9e0ce6119e091ef9211fa82b9593f564eeec8bdd86eff6a97fe6e2fcb20f/release_tag-0.4.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:d79a9cf191dd2c29e1b3a35453fa364b08a7aadd15aeb2c556a7661c6cf4d5ad", size = 1181129, upload-time = "2025-12-03T00:18:15.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/09/d96acf18f0773b6355080a568ba48931faa9dbe91ab1abefc6f8c4df04a8/release_tag-0.4.3-py3-none-win_amd64.whl", hash = "sha256:3958b880375f2241d0cc2b9882363bf54b1d4d7ca8ffc6eecc63ab92f23307f0", size = 1260773, upload-time = "2025-12-03T00:18:14.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/da/ecb6346df1ffb0752fe213e25062f802c10df2948717f0d5f9816c2df914/release_tag-0.4.3-py3-none-win_arm64.whl", hash = "sha256:7d5b08000e6e398d46f05a50139031046348fba6d47909f01e468bb7600c19df", size = 1142155, upload-time = "2025-12-03T00:18:20.647Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -143,9 +143,7 @@ module.exports = {
|
||||
"**/src/app/**/utils/*.test.ts",
|
||||
"**/src/app/**/hooks/*.test.ts", // Pure packet processor tests
|
||||
"**/src/refresh-components/**/*.test.ts",
|
||||
"**/src/refresh-pages/**/*.test.ts",
|
||||
"**/src/sections/**/*.test.ts",
|
||||
"**/src/components/**/*.test.ts",
|
||||
// Add more patterns here as you add more unit tests
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { OpenButton } from "@opal/components";
|
||||
import { Disabled as DisabledProvider } from "@opal/core";
|
||||
import { SvgSettings } from "@opal/icons";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
@@ -33,9 +32,16 @@ export const WithIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
selected: true,
|
||||
children: "Selected",
|
||||
},
|
||||
};
|
||||
|
||||
export const Open: Story = {
|
||||
args: {
|
||||
interaction: "hover",
|
||||
transient: true,
|
||||
children: "Open state",
|
||||
},
|
||||
};
|
||||
@@ -47,27 +53,18 @@ export const Disabled: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Foldable: Story = {
|
||||
export const LightProminence: Story = {
|
||||
args: {
|
||||
foldable: true,
|
||||
icon: SvgSettings,
|
||||
children: "Settings",
|
||||
prominence: "light",
|
||||
children: "Light prominence",
|
||||
},
|
||||
};
|
||||
|
||||
export const FoldableDisabled: Story = {
|
||||
export const HeavyProminence: Story = {
|
||||
args: {
|
||||
foldable: true,
|
||||
icon: SvgSettings,
|
||||
children: "Settings",
|
||||
prominence: "heavy",
|
||||
children: "Heavy prominence",
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<DisabledProvider disabled>
|
||||
<Story />
|
||||
</DisabledProvider>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
@@ -81,12 +78,3 @@ export const Sizes: Story = {
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithTooltip: Story = {
|
||||
args: {
|
||||
icon: SvgSettings,
|
||||
children: "Settings",
|
||||
tooltip: "Open settings",
|
||||
tooltipSide: "bottom",
|
||||
},
|
||||
};
|
||||
@@ -17,9 +17,7 @@ OpenButton is a **tighter, specialized use-case** of SelectButton:
|
||||
- It hardcodes `variant="select-heavy"` (SelectButton exposes `variant`)
|
||||
- It adds a built-in chevron with CSS-driven rotation (SelectButton has no chevron)
|
||||
- It auto-detects Radix `data-state="open"` to derive `interaction` (SelectButton has no Radix awareness)
|
||||
- It does not support `rightIcon` (SelectButton does)
|
||||
|
||||
Both components support `foldable` using the same pattern: `interactive-foldable-host` class + `Interactive.Foldable` wrapper around the label and trailing icon. When foldable, the left icon stays visible while the rest collapses. If you change the foldable implementation in one, update the other to match.
|
||||
- It does not support `foldable` or `rightIcon` (SelectButton does)
|
||||
|
||||
If you need a general-purpose stateful toggle, use `SelectButton`. If you need a popover/dropdown trigger with a chevron, use `OpenButton`.
|
||||
|
||||
@@ -28,12 +26,10 @@ If you need a general-purpose stateful toggle, use `SelectButton`. If you need a
|
||||
```
|
||||
Interactive.Stateful <- variant="select-heavy", interaction, state, disabled, onClick
|
||||
└─ Interactive.Container <- height, rounding, padding (from `size`)
|
||||
└─ div.opal-button.interactive-foreground [.interactive-foldable-host]
|
||||
└─ div.opal-button.interactive-foreground
|
||||
├─ div > Icon? (interactive-foreground-icon)
|
||||
├─ [Foldable]? (wraps label + chevron when foldable)
|
||||
│ ├─ <span>? .opal-button-label
|
||||
│ └─ div > ChevronIcon .opal-open-button-chevron
|
||||
└─ <span>? / ChevronIcon (non-foldable)
|
||||
├─ <span>? .opal-button-label
|
||||
└─ div > ChevronIcon .opal-open-button-chevron (interactive-foreground-icon)
|
||||
```
|
||||
|
||||
- **`interaction` controls both the chevron and the hover visual state.** When `interaction` is `"hover"` (explicitly or via Radix `data-state="open"`), the chevron rotates 180° and the hover background activates.
|
||||
@@ -48,7 +44,6 @@ Interactive.Stateful <- variant="select-heavy", interaction, state, di
|
||||
| `interaction` | `"rest" \| "hover" \| "active"` | auto | JS-controlled interaction override. Falls back to Radix `data-state="open"` when omitted. |
|
||||
| `icon` | `IconFunctionComponent` | — | Left icon component |
|
||||
| `children` | `string` | — | Content between icon and chevron |
|
||||
| `foldable` | `boolean` | `false` | When `true`, requires both `icon` and `children`; the left icon stays visible while the label + chevron collapse when not hovered. If `tooltip` is omitted on a disabled foldable button, the label text is used as the tooltip. |
|
||||
| `size` | `SizeVariant` | `"lg"` | Size preset controlling height, rounding, and padding |
|
||||
| `width` | `WidthVariant` | — | Width preset |
|
||||
| `tooltip` | `string` | — | Tooltip text shown on hover |
|
||||
|
||||
@@ -2,7 +2,6 @@ import "@opal/components/buttons/open-button/styles.css";
|
||||
import "@opal/components/tooltip.css";
|
||||
import {
|
||||
Interactive,
|
||||
useDisabled,
|
||||
type InteractiveStatefulProps,
|
||||
type InteractiveStatefulInteraction,
|
||||
} from "@opal/core";
|
||||
@@ -31,53 +30,27 @@ function ChevronIcon({ className, ...props }: IconProps) {
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Content props — a discriminated union on `foldable` that enforces:
|
||||
*
|
||||
* - `foldable: true` → `icon` and `children` are required (icon stays visible,
|
||||
* label + chevron fold away)
|
||||
* - `foldable?: false` → at least one of `icon` or `children` must be provided
|
||||
*/
|
||||
type OpenButtonContentProps =
|
||||
| {
|
||||
foldable: true;
|
||||
icon: IconFunctionComponent;
|
||||
children: string;
|
||||
}
|
||||
| {
|
||||
foldable?: false;
|
||||
icon?: IconFunctionComponent;
|
||||
children: string;
|
||||
}
|
||||
| {
|
||||
foldable?: false;
|
||||
icon: IconFunctionComponent;
|
||||
children?: string;
|
||||
};
|
||||
type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
|
||||
/** Left icon. */
|
||||
icon?: IconFunctionComponent;
|
||||
|
||||
type OpenButtonProps = InteractiveStatefulProps &
|
||||
OpenButtonContentProps & {
|
||||
/**
|
||||
* Size preset — controls gap, text size, and Container height/rounding.
|
||||
*/
|
||||
size?: SizeVariant;
|
||||
/** Button label text. */
|
||||
children?: string;
|
||||
|
||||
/** Width preset. */
|
||||
width?: WidthVariant;
|
||||
/**
|
||||
* Size preset — controls gap, text size, and Container height/rounding.
|
||||
*/
|
||||
size?: SizeVariant;
|
||||
|
||||
/**
|
||||
* Content justify mode. When `"between"`, icon+label group left and
|
||||
* chevron pushes to the right edge. Default keeps all items in a
|
||||
* tight `gap-1` row.
|
||||
*/
|
||||
justifyContent?: "between";
|
||||
/** Width preset. */
|
||||
width?: WidthVariant;
|
||||
|
||||
/** Tooltip text shown on hover. */
|
||||
tooltip?: string;
|
||||
/** Tooltip text shown on hover. */
|
||||
tooltip?: string;
|
||||
|
||||
/** Which side the tooltip appears on. */
|
||||
tooltipSide?: TooltipSide;
|
||||
};
|
||||
/** Which side the tooltip appears on. */
|
||||
tooltipSide?: TooltipSide;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenButton
|
||||
@@ -87,17 +60,12 @@ function OpenButton({
|
||||
icon: Icon,
|
||||
children,
|
||||
size = "lg",
|
||||
foldable,
|
||||
width,
|
||||
justifyContent,
|
||||
tooltip,
|
||||
tooltipSide = "top",
|
||||
interaction,
|
||||
variant = "select-heavy",
|
||||
...statefulProps
|
||||
}: OpenButtonProps) {
|
||||
const { isDisabled } = useDisabled();
|
||||
|
||||
// Derive open state: explicit prop → Radix data-state (injected via Slot chain)
|
||||
const dataState = (statefulProps as Record<string, unknown>)["data-state"] as
|
||||
| string
|
||||
@@ -107,20 +75,9 @@ function OpenButton({
|
||||
|
||||
const isLarge = size === "lg";
|
||||
|
||||
const labelEl = children ? (
|
||||
<span
|
||||
className={cn(
|
||||
"opal-button-label whitespace-nowrap",
|
||||
isLarge ? "font-main-ui-body" : "font-secondary-body"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
const button = (
|
||||
<Interactive.Stateful
|
||||
variant={variant}
|
||||
variant="select-heavy"
|
||||
interaction={resolvedInteraction}
|
||||
{...statefulProps}
|
||||
>
|
||||
@@ -132,45 +89,25 @@ function OpenButton({
|
||||
isLarge ? "default" : size === "2xs" ? "mini" : "compact"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"opal-button interactive-foreground flex flex-row items-center",
|
||||
justifyContent === "between" ? "w-full justify-between" : "gap-1",
|
||||
foldable && "interactive-foldable-host"
|
||||
)}
|
||||
>
|
||||
{justifyContent === "between" ? (
|
||||
<>
|
||||
<span className="flex flex-row items-center gap-1">
|
||||
{iconWrapper(Icon, size, !foldable && !!children)}
|
||||
{labelEl}
|
||||
</span>
|
||||
{iconWrapper(ChevronIcon, size, !!children)}
|
||||
</>
|
||||
) : foldable ? (
|
||||
<>
|
||||
{iconWrapper(Icon, size, !foldable && !!children)}
|
||||
<Interactive.Foldable>
|
||||
{labelEl}
|
||||
{iconWrapper(ChevronIcon, size, !!children)}
|
||||
</Interactive.Foldable>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{iconWrapper(Icon, size, !foldable && !!children)}
|
||||
{labelEl}
|
||||
{iconWrapper(ChevronIcon, size, !!children)}
|
||||
</>
|
||||
<div className="opal-button interactive-foreground flex flex-row items-center gap-1">
|
||||
{iconWrapper(Icon, size, false)}
|
||||
{children && (
|
||||
<span
|
||||
className={cn(
|
||||
"opal-button-label whitespace-nowrap",
|
||||
isLarge ? "font-main-ui-body" : "font-secondary-body"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
{iconWrapper(ChevronIcon, size, false)}
|
||||
</div>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateful>
|
||||
);
|
||||
|
||||
const resolvedTooltip =
|
||||
tooltip ?? (foldable && isDisabled && children ? children : undefined);
|
||||
|
||||
if (!resolvedTooltip) return button;
|
||||
if (!tooltip) return button;
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Root>
|
||||
@@ -181,7 +118,7 @@ function OpenButton({
|
||||
side={tooltipSide}
|
||||
sideOffset={4}
|
||||
>
|
||||
{resolvedTooltip}
|
||||
{tooltip}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
|
||||
@@ -17,9 +17,7 @@ Interactive.Stateful → Interactive.Container → content row (icon + label + t
|
||||
- OpenButton hardcodes `variant="select-heavy"` (SelectButton exposes `variant`)
|
||||
- OpenButton adds a built-in chevron with CSS-driven rotation (SelectButton has no chevron)
|
||||
- OpenButton auto-detects Radix `data-state="open"` to derive `interaction` (SelectButton has no Radix awareness)
|
||||
- OpenButton does not support `rightIcon` (SelectButton does)
|
||||
|
||||
Both components support `foldable` using the same pattern: `interactive-foldable-host` class + `Interactive.Foldable` wrapper around the label and trailing icon. When foldable, the left icon stays visible while the rest collapses. If you change the foldable implementation in one, update the other to match.
|
||||
- OpenButton does not support `foldable` or `rightIcon` (SelectButton does)
|
||||
|
||||
Use SelectButton for general-purpose stateful toggles. Use `OpenButton` for popover/dropdown triggers with a chevron.
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import { cn } from "@opal/utils";
|
||||
|
||||
type TagColor = "green" | "purple" | "blue" | "gray" | "amber";
|
||||
|
||||
type TagSize = "sm" | "md";
|
||||
|
||||
interface TagProps {
|
||||
/** Optional icon component. */
|
||||
icon?: IconFunctionComponent;
|
||||
@@ -20,9 +18,6 @@ interface TagProps {
|
||||
|
||||
/** Color variant. Default: `"gray"`. */
|
||||
color?: TagColor;
|
||||
|
||||
/** Size variant. Default: `"sm"`. */
|
||||
size?: TagSize;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -41,11 +36,11 @@ const COLOR_CONFIG: Record<TagColor, { bg: string; text: string }> = {
|
||||
// Tag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
|
||||
function Tag({ icon: Icon, title, color = "gray" }: TagProps) {
|
||||
const config = COLOR_CONFIG[color];
|
||||
|
||||
return (
|
||||
<div className={cn("opal-auxiliary-tag", config.bg)} data-size={size}>
|
||||
<div className={cn("opal-auxiliary-tag", config.bg)}>
|
||||
{Icon && (
|
||||
<div className="opal-auxiliary-tag-icon-container">
|
||||
<Icon className={cn("opal-auxiliary-tag-icon", config.text)} />
|
||||
@@ -53,8 +48,7 @@ function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"opal-auxiliary-tag-title px-[2px]",
|
||||
size === "md" ? "font-secondary-body" : "font-figure-small-value",
|
||||
"opal-auxiliary-tag-title px-[2px] font-figure-small-value",
|
||||
config.text
|
||||
)}
|
||||
>
|
||||
@@ -64,4 +58,4 @@ function Tag({ icon: Icon, title, color = "gray", size = "sm" }: TagProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export { Tag, type TagProps, type TagColor, type TagSize };
|
||||
export { Tag, type TagProps, type TagColor };
|
||||
|
||||
@@ -13,12 +13,6 @@
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.opal-auxiliary-tag[data-size="md"] {
|
||||
height: 1.375rem;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.opal-auxiliary-tag-icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Interactive, Disabled } from "@opal/core";
|
||||
import { Interactive } from "@opal/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variant / Prominence mappings for the matrix story
|
||||
@@ -9,6 +9,8 @@ const VARIANT_PROMINENCE_MAP: Record<string, string[]> = {
|
||||
default: ["primary", "secondary", "tertiary", "internal"],
|
||||
action: ["primary", "secondary", "tertiary", "internal"],
|
||||
danger: ["primary", "secondary", "tertiary", "internal"],
|
||||
select: ["light", "heavy"],
|
||||
sidebar: ["light"],
|
||||
none: [],
|
||||
};
|
||||
|
||||
@@ -33,39 +35,39 @@ export default meta;
|
||||
// Stories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Basic Interactive.Stateless + Container with text content. */
|
||||
/** Basic Interactive.Base + Container with text content. */
|
||||
export const Default: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center" }}>
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Secondary</span>
|
||||
<span>Secondary</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="primary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Primary</span>
|
||||
<span>Primary</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="tertiary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Tertiary</span>
|
||||
<span>Tertiary</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -89,13 +91,11 @@ export const VariantMatrix: StoryObj = {
|
||||
</div>
|
||||
|
||||
{prominences.length === 0 ? (
|
||||
<Interactive.Stateless variant="none" onClick={() => {}}>
|
||||
<Interactive.Base variant="none" onClick={() => {}}>
|
||||
<Interactive.Container border>
|
||||
<span style={{ color: "var(--text-01)" }}>
|
||||
none (no prominence)
|
||||
</span>
|
||||
<span>none (no prominence)</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
) : (
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{prominences.map((prominence) => (
|
||||
@@ -108,18 +108,16 @@ export const VariantMatrix: StoryObj = {
|
||||
gap: "0.25rem",
|
||||
}}
|
||||
>
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
// Cast required because the discriminated union can't be
|
||||
// resolved from dynamic strings at the type level.
|
||||
{...({ variant, prominence } as any)}
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">
|
||||
{prominence}
|
||||
</span>
|
||||
<span>{prominence}</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "0.625rem",
|
||||
@@ -143,16 +141,16 @@ export const Sizes: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||
{SIZE_VARIANTS.map((size) => (
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
key={size}
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border heightVariant={size}>
|
||||
<span className="interactive-foreground">{size}</span>
|
||||
<span>{size}</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
@@ -162,15 +160,15 @@ export const Sizes: StoryObj = {
|
||||
export const WidthFull: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ width: 400 }}>
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border widthVariant="full">
|
||||
<span className="interactive-foreground">Full width container</span>
|
||||
<span>Full width container</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -180,86 +178,73 @@ export const Rounding: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
{ROUNDING_VARIANTS.map((rounding) => (
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
key={rounding}
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border roundingVariant={rounding}>
|
||||
<span className="interactive-foreground">{rounding}</span>
|
||||
<span>{rounding}</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Disabled state prevents clicks and shows disabled styling. */
|
||||
export const DisabledStory: StoryObj = {
|
||||
name: "Disabled",
|
||||
export const Disabled: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<Disabled disabled>
|
||||
<Interactive.Stateless
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Disabled</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Disabled>
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
disabled
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span>Disabled</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Enabled</span>
|
||||
<span>Enabled</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** Interaction override forces the hover/active visual state. */
|
||||
export const Interaction: StoryObj = {
|
||||
/** Transient prop forces the hover/active visual state. */
|
||||
export const Transient: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
interaction="hover"
|
||||
onClick={() => {}}
|
||||
transient
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Forced hover</span>
|
||||
<span>Forced hover</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateless
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
interaction="active"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Forced active</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Normal (rest)</span>
|
||||
<span>Normal</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -268,25 +253,25 @@ export const Interaction: StoryObj = {
|
||||
export const WithBorder: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">With border</span>
|
||||
<span>With border</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateless
|
||||
<Interactive.Base
|
||||
variant="default"
|
||||
prominence="secondary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container>
|
||||
<span className="interactive-foreground">Without border</span>
|
||||
<span>Without border</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -294,57 +279,51 @@ export const WithBorder: StoryObj = {
|
||||
/** Using href to render as a link. */
|
||||
export const AsLink: StoryObj = {
|
||||
render: () => (
|
||||
<Interactive.Stateless variant="action" href="/settings">
|
||||
<Interactive.Base variant="action" href="/settings">
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Go to Settings</span>
|
||||
<span>Go to Settings</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateless>
|
||||
</Interactive.Base>
|
||||
),
|
||||
};
|
||||
|
||||
/** Stateful select variant with selected and unselected states. */
|
||||
/** Select variant with selected and unselected states. */
|
||||
export const SelectVariant: StoryObj = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||
<Interactive.Stateful
|
||||
variant="select-light"
|
||||
state="selected"
|
||||
<Interactive.Base
|
||||
variant="select"
|
||||
prominence="light"
|
||||
selected
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Selected (light)</span>
|
||||
<span>Selected (light)</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateful>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateful
|
||||
variant="select-light"
|
||||
state="empty"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Base variant="select" prominence="light" onClick={() => {}}>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Unselected (light)</span>
|
||||
<span>Unselected (light)</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateful>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateful
|
||||
variant="select-heavy"
|
||||
state="selected"
|
||||
<Interactive.Base
|
||||
variant="select"
|
||||
prominence="heavy"
|
||||
selected
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Selected (heavy)</span>
|
||||
<span>Selected (heavy)</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateful>
|
||||
</Interactive.Base>
|
||||
|
||||
<Interactive.Stateful
|
||||
variant="select-heavy"
|
||||
state="empty"
|
||||
onClick={() => {}}
|
||||
>
|
||||
<Interactive.Base variant="select" prominence="heavy" onClick={() => {}}>
|
||||
<Interactive.Container border>
|
||||
<span className="interactive-foreground">Unselected (heavy)</span>
|
||||
<span>Unselected (heavy)</span>
|
||||
</Interactive.Container>
|
||||
</Interactive.Stateful>
|
||||
</Interactive.Base>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -10,11 +10,7 @@ import type { WithoutStyles } from "@opal/types";
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type InteractiveStatefulVariant =
|
||||
| "select-light"
|
||||
| "select-heavy"
|
||||
| "select-tinted"
|
||||
| "sidebar";
|
||||
type InteractiveStatefulVariant = "select-light" | "select-heavy" | "sidebar";
|
||||
type InteractiveStatefulState = "empty" | "filled" | "selected";
|
||||
type InteractiveStatefulInteraction = "rest" | "hover" | "active";
|
||||
|
||||
|
||||
@@ -211,22 +211,6 @@
|
||||
--interactive-foreground-icon: var(--action-link-03);
|
||||
}
|
||||
|
||||
/* Select-Tinted — Select-Heavy with tinted rest background */
|
||||
.interactive[data-interactive-variant="select-tinted"] {
|
||||
@apply bg-background-tint-01;
|
||||
--interactive-foreground: var(--text-04);
|
||||
--interactive-foreground-icon: var(--text-03);
|
||||
}
|
||||
.interactive[data-interactive-variant="select-tinted"]:hover:not(
|
||||
[data-disabled]
|
||||
),
|
||||
.interactive[data-interactive-variant="select-tinted"][data-interaction="hover"]:not(
|
||||
[data-disabled]
|
||||
) {
|
||||
@apply bg-background-tint-02;
|
||||
--interactive-foreground-icon: var(--text-04);
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
Sidebar
|
||||
=========================================================================== */
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
const SvgFilterPlus = ({ size, ...props }: IconProps) => (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M9.5 12.5L6.83334 11.1667V7.80667L1.5 1.5H14.8333L12.1667 4.65333M12.1667 7V9.5M12.1667 9.5V12M12.1667 9.5H9.66667M12.1667 9.5H14.6667"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
export default SvgFilterPlus;
|
||||
@@ -72,7 +72,6 @@ export { default as SvgFileChartPie } from "@opal/icons/file-chart-pie";
|
||||
export { default as SvgFileSmall } from "@opal/icons/file-small";
|
||||
export { default as SvgFileText } from "@opal/icons/file-text";
|
||||
export { default as SvgFilter } from "@opal/icons/filter";
|
||||
export { default as SvgFilterPlus } from "@opal/icons/filter-plus";
|
||||
export { default as SvgFold } from "@opal/icons/fold";
|
||||
export { default as SvgFolder } from "@opal/icons/folder";
|
||||
export { default as SvgFolderIn } from "@opal/icons/folder-in";
|
||||
@@ -90,7 +89,7 @@ export { default as SvgHistory } from "@opal/icons/history";
|
||||
export { default as SvgHourglass } from "@opal/icons/hourglass";
|
||||
export { default as SvgImage } from "@opal/icons/image";
|
||||
export { default as SvgImageSmall } from "@opal/icons/image-small";
|
||||
export { default as SvgImport } from "@opal/icons/import-icon";
|
||||
export { default as SvgImport } from "@opal/icons/import";
|
||||
export { default as SvgInfo } from "@opal/icons/info";
|
||||
export { default as SvgInfoSmall } from "@opal/icons/info-small";
|
||||
export { default as SvgKey } from "@opal/icons/key";
|
||||
|
||||
@@ -8,65 +8,8 @@ import SvgAlertTriangle from "@opal/icons/alert-triangle";
|
||||
import SvgEdit from "@opal/icons/edit";
|
||||
import SvgXOctagon from "@opal/icons/x-octagon";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import "@opal/components/tooltip.css";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@opal/utils";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Overflow tooltip helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Returns a ref + boolean indicating whether the element's text is clipped. */
|
||||
function useIsOverflowing<T extends HTMLElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [overflowing, setOverflowing] = useState(false);
|
||||
|
||||
const check = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
check();
|
||||
window.addEventListener("resize", check);
|
||||
return () => window.removeEventListener("resize", check);
|
||||
}, [check]);
|
||||
|
||||
return { ref, overflowing, check };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps children in a Radix tooltip that only appears when the element is
|
||||
* overflowing (text truncated). Uses the same opal-tooltip styling as Button.
|
||||
*/
|
||||
function OverflowTooltip({
|
||||
text,
|
||||
overflowing,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
overflowing: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!overflowing) return children;
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Root>
|
||||
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
className="opal-tooltip"
|
||||
side="top"
|
||||
sideOffset={4}
|
||||
>
|
||||
{text}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
}
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -199,8 +142,6 @@ function ContentMd({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const titleOverflow = useIsOverflowing<HTMLSpanElement>();
|
||||
const descOverflow = useIsOverflowing<HTMLDivElement>();
|
||||
|
||||
const config = CONTENT_MD_PRESETS[sizePreset];
|
||||
|
||||
@@ -270,24 +211,18 @@ function ContentMd({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<OverflowTooltip
|
||||
text={title}
|
||||
overflowing={titleOverflow.overflowing}
|
||||
<span
|
||||
className={cn(
|
||||
"opal-content-md-title",
|
||||
config.titleFont,
|
||||
"text-text-04",
|
||||
editable && "cursor-pointer"
|
||||
)}
|
||||
onClick={editable ? startEditing : undefined}
|
||||
style={{ height: config.lineHeight }}
|
||||
>
|
||||
<span
|
||||
ref={titleOverflow.ref}
|
||||
className={cn(
|
||||
"opal-content-md-title",
|
||||
config.titleFont,
|
||||
"text-text-04",
|
||||
editable && "cursor-pointer"
|
||||
)}
|
||||
onClick={editable ? startEditing : undefined}
|
||||
style={{ height: config.lineHeight }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</OverflowTooltip>
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{optional && (
|
||||
@@ -340,17 +275,9 @@ function ContentMd({
|
||||
</div>
|
||||
|
||||
{description && (
|
||||
<OverflowTooltip
|
||||
text={description}
|
||||
overflowing={descOverflow.overflowing}
|
||||
>
|
||||
<div
|
||||
ref={descOverflow.ref}
|
||||
className="opal-content-md-description font-secondary-body text-text-03"
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
</OverflowTooltip>
|
||||
<div className="opal-content-md-description font-secondary-body text-text-03">
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +224,7 @@
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.opal-content-md {
|
||||
@apply flex flex-row items-start min-w-0;
|
||||
@apply flex flex-row items-start;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -311,7 +311,7 @@
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
.opal-content-md-description {
|
||||
@apply text-left w-full truncate;
|
||||
@apply text-left w-full;
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
QwenIcon,
|
||||
OllamaIcon,
|
||||
LMStudioIcon,
|
||||
LiteLLMIcon,
|
||||
ZAIIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import {
|
||||
@@ -22,14 +21,12 @@ import {
|
||||
OpenRouterModelResponse,
|
||||
BedrockModelResponse,
|
||||
LMStudioModelResponse,
|
||||
LiteLLMProxyModelResponse,
|
||||
ModelConfiguration,
|
||||
LLMProviderName,
|
||||
BedrockFetchParams,
|
||||
OllamaFetchParams,
|
||||
LMStudioFetchParams,
|
||||
OpenRouterFetchParams,
|
||||
LiteLLMProxyFetchParams,
|
||||
} from "@/interfaces/llm";
|
||||
import { SvgAws, SvgOpenrouter } from "@opal/icons";
|
||||
|
||||
@@ -40,7 +37,6 @@ export const AGGREGATOR_PROVIDERS = new Set([
|
||||
"openrouter",
|
||||
"ollama_chat",
|
||||
"lm_studio",
|
||||
"litellm_proxy",
|
||||
"vertex_ai",
|
||||
]);
|
||||
|
||||
@@ -77,7 +73,6 @@ export const getProviderIcon = (
|
||||
bedrock: SvgAws,
|
||||
bedrock_converse: SvgAws,
|
||||
openrouter: SvgOpenrouter,
|
||||
litellm_proxy: LiteLLMIcon,
|
||||
vertex_ai: GeminiIcon,
|
||||
};
|
||||
|
||||
@@ -343,65 +338,6 @@ export const fetchLMStudioModels = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches LiteLLM Proxy models directly without any form state dependencies.
|
||||
* Uses snake_case params to match API structure.
|
||||
*/
|
||||
export const fetchLiteLLMProxyModels = async (
|
||||
params: LiteLLMProxyFetchParams
|
||||
): Promise<{ models: ModelConfiguration[]; error?: string }> => {
|
||||
const apiBase = params.api_base;
|
||||
const apiKey = params.api_key;
|
||||
if (!apiBase) {
|
||||
return { models: [], error: "API Base is required" };
|
||||
}
|
||||
if (!apiKey) {
|
||||
return { models: [], error: "API Key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/llm/litellm/available-models", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_base: apiBase,
|
||||
api_key: apiKey,
|
||||
provider_name: params.provider_name,
|
||||
}),
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to fetch models";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
return { models: [], error: errorMessage };
|
||||
}
|
||||
|
||||
const data: LiteLLMProxyModelResponse[] = await response.json();
|
||||
const models: ModelConfiguration[] = data.map((modelData) => ({
|
||||
name: modelData.model_name,
|
||||
display_name: modelData.model_name,
|
||||
is_visible: true,
|
||||
max_input_tokens: null,
|
||||
supports_image_input: false,
|
||||
supports_reasoning: false,
|
||||
}));
|
||||
|
||||
return { models };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return { models: [], error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches models for a provider. Accepts form values directly and maps them
|
||||
* to the expected fetch params format internally.
|
||||
@@ -449,13 +385,6 @@ export const fetchModels = async (
|
||||
api_key: formValues.api_key,
|
||||
provider_name: formValues.name,
|
||||
});
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return fetchLiteLLMProxyModels({
|
||||
api_base: formValues.api_base,
|
||||
api_key: formValues.api_key,
|
||||
provider_name: formValues.name,
|
||||
signal,
|
||||
});
|
||||
default:
|
||||
return { models: [], error: `Unknown provider: ${providerName}` };
|
||||
}
|
||||
@@ -468,7 +397,6 @@ export function canProviderFetchModels(providerName?: string) {
|
||||
case LLMProviderName.OLLAMA_CHAT:
|
||||
case LLMProviderName.LM_STUDIO:
|
||||
case LLMProviderName.OPENROUTER:
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -1 +1,342 @@
|
||||
export { default } from "@/refresh-pages/admin/UsersPage";
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import SimpleTabs from "@/refresh-components/SimpleTabs";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import InvitedUserTable from "@/components/admin/users/InvitedUserTable";
|
||||
import SignedUpUserTable from "@/components/admin/users/SignedUpUserTable";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import useSWR, { mutate } from "swr";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import BulkAdd, { EmailInviteStatus } from "@/components/admin/users/BulkAdd";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { InvitedUserSnapshot } from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import PendingUsersTable from "@/components/admin/users/PendingUsersTable";
|
||||
import CreateButton from "@/refresh-components/buttons/CreateButton";
|
||||
import { Button } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { SvgDownloadCloud, SvgUserPlus } from "@opal/icons";
|
||||
import { ADMIN_ROUTE_CONFIG, ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.USERS]!;
|
||||
|
||||
interface CountDisplayProps {
|
||||
label: string;
|
||||
value: number | null;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function CountDisplay({ label, value, isLoading }: CountDisplayProps) {
|
||||
const displayValue = isLoading
|
||||
? "..."
|
||||
: value === null
|
||||
? "-"
|
||||
: value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1 py-0.5 rounded-06">
|
||||
<Text as="p" mainUiMuted text03>
|
||||
{label}
|
||||
</Text>
|
||||
<Text as="p" headingH3 text05>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersTables({
|
||||
q,
|
||||
isDownloadingUsers,
|
||||
setIsDownloadingUsers,
|
||||
}: {
|
||||
q: string;
|
||||
isDownloadingUsers: boolean;
|
||||
setIsDownloadingUsers: (loading: boolean) => void;
|
||||
}) {
|
||||
const [currentUsersCount, setCurrentUsersCount] = useState<number | null>(
|
||||
null
|
||||
);
|
||||
const [currentUsersLoading, setCurrentUsersLoading] = useState<boolean>(true);
|
||||
|
||||
const downloadAllUsers = async () => {
|
||||
setIsDownloadingUsers(true);
|
||||
const startTime = Date.now();
|
||||
const minDurationMsForSpinner = 1000;
|
||||
try {
|
||||
const response = await fetch("/api/manage/users/download");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to download all users");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const anchor_tag = document.createElement("a");
|
||||
anchor_tag.href = url;
|
||||
anchor_tag.download = "users.csv";
|
||||
document.body.appendChild(anchor_tag);
|
||||
anchor_tag.click();
|
||||
//Clean up URL after download to avoid memory leaks
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(anchor_tag);
|
||||
} catch (error) {
|
||||
toast.error(`Failed to download all users - ${error}`);
|
||||
} finally {
|
||||
//Ensure spinner is visible for at least 1 second
|
||||
//This is to avoid the spinner disappearing too quickly
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, minDurationMsForSpinner - duration)
|
||||
);
|
||||
setIsDownloadingUsers(false);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
data: invitedUsers,
|
||||
error: invitedUsersError,
|
||||
isLoading: invitedUsersLoading,
|
||||
mutate: invitedUsersMutate,
|
||||
} = useSWR<InvitedUserSnapshot[]>(
|
||||
"/api/manage/users/invited",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const { data: validDomains, error: domainsError } = useSWR<string[]>(
|
||||
"/api/manage/admin/valid-domains",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const {
|
||||
data: pendingUsers,
|
||||
error: pendingUsersError,
|
||||
isLoading: pendingUsersLoading,
|
||||
mutate: pendingUsersMutate,
|
||||
} = useSWR<InvitedUserSnapshot[]>(
|
||||
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const invitedUsersCount =
|
||||
invitedUsers === undefined ? null : invitedUsers.length;
|
||||
const pendingUsersCount =
|
||||
pendingUsers === undefined ? null : pendingUsers.length;
|
||||
// Show loading animation only during the initial data fetch
|
||||
if (!validDomains) {
|
||||
return <ThreeDotsLoader />;
|
||||
}
|
||||
|
||||
if (domainsError) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Error loading valid domains"
|
||||
errorMsg={domainsError?.info?.detail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = SimpleTabs.generateTabs({
|
||||
current: {
|
||||
name: "Current Users",
|
||||
content: (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center gap-1">
|
||||
<CardTitle>Current Users</CardTitle>
|
||||
<Disabled disabled={isDownloadingUsers}>
|
||||
<Button
|
||||
icon={SvgDownloadCloud}
|
||||
onClick={() => downloadAllUsers()}
|
||||
>
|
||||
{isDownloadingUsers ? "Downloading..." : "Download CSV"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SignedUpUserTable
|
||||
invitedUsers={invitedUsers || []}
|
||||
q={q}
|
||||
invitedUsersMutate={invitedUsersMutate}
|
||||
countDisplay={
|
||||
<CountDisplay
|
||||
label="Total users"
|
||||
value={currentUsersCount}
|
||||
isLoading={currentUsersLoading}
|
||||
/>
|
||||
}
|
||||
onTotalItemsChange={(count) => setCurrentUsersCount(count)}
|
||||
onLoadingChange={(loading) => {
|
||||
setCurrentUsersLoading(loading);
|
||||
if (loading) {
|
||||
setCurrentUsersCount(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
invited: {
|
||||
name: "Invited Users",
|
||||
content: (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center gap-1">
|
||||
<CardTitle>Invited Users</CardTitle>
|
||||
<CountDisplay
|
||||
label="Total invited"
|
||||
value={invitedUsersCount}
|
||||
isLoading={invitedUsersLoading}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<InvitedUserTable
|
||||
users={invitedUsers || []}
|
||||
mutate={invitedUsersMutate}
|
||||
error={invitedUsersError}
|
||||
isLoading={invitedUsersLoading}
|
||||
q={q}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
...(NEXT_PUBLIC_CLOUD_ENABLED && {
|
||||
pending: {
|
||||
name: "Pending Users",
|
||||
content: (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center gap-1">
|
||||
<CardTitle>Pending Users</CardTitle>
|
||||
<CountDisplay
|
||||
label="Total pending"
|
||||
value={pendingUsersCount}
|
||||
isLoading={pendingUsersLoading}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PendingUsersTable
|
||||
users={pendingUsers || []}
|
||||
mutate={pendingUsersMutate}
|
||||
error={pendingUsersError}
|
||||
isLoading={pendingUsersLoading}
|
||||
q={q}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return <SimpleTabs tabs={tabs} defaultValue="current" />;
|
||||
}
|
||||
|
||||
function SearchableTables() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [isDownloadingUsers, setIsDownloadingUsers] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isDownloadingUsers && <Spinner />}
|
||||
<div className="flex flex-col gap-y-4">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<InputTypeIn
|
||||
placeholder="Search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<AddUserButton />
|
||||
</div>
|
||||
<UsersTables
|
||||
q={query}
|
||||
isDownloadingUsers={isDownloadingUsers}
|
||||
setIsDownloadingUsers={setIsDownloadingUsers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddUserButton() {
|
||||
const [bulkAddUsersModal, setBulkAddUsersModal] = useState(false);
|
||||
|
||||
const onSuccess = (emailInviteStatus: EmailInviteStatus) => {
|
||||
mutate(
|
||||
(key) => typeof key === "string" && key.startsWith("/api/manage/users")
|
||||
);
|
||||
setBulkAddUsersModal(false);
|
||||
if (emailInviteStatus === "NOT_CONFIGURED") {
|
||||
toast.warning(
|
||||
"Users added, but no email notification was sent. There is no SMTP server set up for email sending."
|
||||
);
|
||||
} else if (emailInviteStatus === "SEND_FAILED") {
|
||||
toast.warning(
|
||||
"Users added, but email sending failed. Check your SMTP configuration and try again."
|
||||
);
|
||||
} else {
|
||||
toast.success("Users invited!");
|
||||
}
|
||||
};
|
||||
|
||||
const onFailure = async (res: Response) => {
|
||||
const error = (await res.json()).detail;
|
||||
toast.error(`Failed to invite users - ${error}`);
|
||||
};
|
||||
|
||||
const handleInviteClick = () => {
|
||||
setBulkAddUsersModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateButton primary onClick={handleInviteClick}>
|
||||
Invite Users
|
||||
</CreateButton>
|
||||
|
||||
{bulkAddUsersModal && (
|
||||
<Modal open onOpenChange={() => setBulkAddUsersModal(false)}>
|
||||
<Modal.Content>
|
||||
<Modal.Header
|
||||
icon={SvgUserPlus}
|
||||
title="Bulk Add Users"
|
||||
onClose={() => setBulkAddUsersModal(false)}
|
||||
/>
|
||||
<Modal.Body>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Text as="p">
|
||||
Add the email addresses to import, separated by whitespaces.
|
||||
Invited users will be able to login to this domain with their
|
||||
email address.
|
||||
</Text>
|
||||
<BulkAdd onSuccess={onSuccess} onFailure={onFailure} />
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header title={route.title} icon={route.icon} separator />
|
||||
<SettingsLayouts.Body>
|
||||
<SearchableTables />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -249,7 +249,6 @@ export default function MessageToolbar({
|
||||
<SelectButton
|
||||
icon={SvgThumbsUp}
|
||||
onClick={() => handleFeedbackClick("like")}
|
||||
variant="select-light"
|
||||
state={isFeedbackTransient("like") ? "selected" : "empty"}
|
||||
tooltip={
|
||||
currentFeedback === "like" ? "Remove Like" : "Good Response"
|
||||
@@ -259,7 +258,6 @@ export default function MessageToolbar({
|
||||
<SelectButton
|
||||
icon={SvgThumbsDown}
|
||||
onClick={() => handleFeedbackClick("dislike")}
|
||||
variant="select-light"
|
||||
state={isFeedbackTransient("dislike") ? "selected" : "empty"}
|
||||
tooltip={
|
||||
currentFeedback === "dislike"
|
||||
@@ -285,7 +283,7 @@ export default function MessageToolbar({
|
||||
});
|
||||
regenerator(llmDescriptor);
|
||||
}}
|
||||
foldable
|
||||
folded
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Button } from "@opal/components";
|
||||
import { SvgBubbleText, SvgSearchMenu, SvgSidebar } from "@opal/icons";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import type { AppMode } from "@/providers/QueryControllerProvider";
|
||||
import { AppMode, useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
@@ -58,15 +58,15 @@ const footerMarkdownComponents = {
|
||||
*/
|
||||
export default function NRFChrome() {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { state, setAppMode } = useQueryController();
|
||||
const { appMode, setAppMode } = useAppMode();
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
const appFocus = useAppFocus();
|
||||
const { classification } = useQueryController();
|
||||
const [modePopoverOpen, setModePopoverOpen] = useState(false);
|
||||
|
||||
const effectiveMode: AppMode =
|
||||
appFocus.isNewSession() && state.phase === "idle" ? state.appMode : "chat";
|
||||
const effectiveMode: AppMode = appFocus.isNewSession() ? appMode : "chat";
|
||||
|
||||
const customFooterContent =
|
||||
settings?.enterpriseSettings?.custom_lower_disclaimer_content ||
|
||||
@@ -78,7 +78,7 @@ export default function NRFChrome() {
|
||||
isPaidEnterpriseFeaturesEnabled &&
|
||||
settings.isSearchModeAvailable &&
|
||||
appFocus.isNewSession() &&
|
||||
state.phase === "idle";
|
||||
!classification;
|
||||
|
||||
const showHeader = isMobile || showModeToggle;
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
const isStreaming = currentChatState === "streaming";
|
||||
|
||||
// Query controller for search/chat classification (EE feature)
|
||||
const { submit: submitQuery, state } = useQueryController();
|
||||
const { submit: submitQuery, classification } = useQueryController();
|
||||
|
||||
// Determine if retrieval (search) is enabled based on the agent
|
||||
const retrievalEnabled = useMemo(() => {
|
||||
@@ -186,8 +186,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
}, [liveAgent]);
|
||||
|
||||
// Check if we're in search mode
|
||||
const isSearch =
|
||||
state.phase === "searching" || state.phase === "search-results";
|
||||
const isSearch = classification === "search";
|
||||
|
||||
// Anchor for scroll positioning (matches ChatPage pattern)
|
||||
const anchorMessage = messageHistory.at(-2) ?? messageHistory[0];
|
||||
@@ -318,7 +317,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
|
||||
};
|
||||
|
||||
// Use submitQuery which will classify the query and either:
|
||||
// - Route to search (sets phase to "searching"/"search-results" and shows SearchUI)
|
||||
// - Route to search (sets classification to "search" and shows SearchUI)
|
||||
// - Route to chat (calls onChat callback)
|
||||
await submitQuery(submittedMessage, onChat);
|
||||
},
|
||||
|
||||
@@ -60,28 +60,27 @@ const CsvContent: React.FC<ContentComponentProps> = ({
|
||||
}
|
||||
|
||||
const csvData = await response.text();
|
||||
const rows = parseCSV(csvData.trim());
|
||||
const rows = csvData.trim().split("\n");
|
||||
const firstRow = rows[0];
|
||||
if (!firstRow) {
|
||||
throw new Error("CSV file is empty");
|
||||
}
|
||||
const parsedHeaders = firstRow;
|
||||
const parsedHeaders = firstRow.split(",");
|
||||
setHeaders(parsedHeaders);
|
||||
|
||||
const parsedData: Record<string, string>[] = rows
|
||||
.slice(1)
|
||||
.map((fields) => {
|
||||
return parsedHeaders.reduce<Record<string, string>>(
|
||||
(obj, header, index) => {
|
||||
const val = fields[index];
|
||||
if (val !== undefined) {
|
||||
obj[header] = val;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
const parsedData: Record<string, string>[] = rows.slice(1).map((row) => {
|
||||
const values = row.split(",");
|
||||
return parsedHeaders.reduce<Record<string, string>>(
|
||||
(obj, header, index) => {
|
||||
const val = values[index];
|
||||
if (val !== undefined) {
|
||||
obj[header] = val;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
setData(parsedData);
|
||||
csvCache.set(id, { headers: parsedHeaders, data: parsedData });
|
||||
} catch (error) {
|
||||
@@ -174,53 +173,3 @@ const csvCache = new Map<
|
||||
string,
|
||||
{ headers: string[]; data: Record<string, string>[] }
|
||||
>();
|
||||
|
||||
export function parseCSV(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let field = "";
|
||||
let fields: string[] = [];
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === ",") {
|
||||
fields.push(field);
|
||||
field = "";
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
if (char === "\r" && i + 1 < text.length && text[i + 1] === "\n") {
|
||||
i++;
|
||||
}
|
||||
fields.push(field);
|
||||
field = "";
|
||||
rows.push(fields);
|
||||
fields = [];
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (inQuotes) {
|
||||
throw new Error("Malformed CSV: unterminated quoted field");
|
||||
}
|
||||
|
||||
if (field.length > 0 || fields.length > 0) {
|
||||
fields.push(field);
|
||||
rows.push(fields);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { parseCSV } from "./CSVContent";
|
||||
|
||||
describe("parseCSV", () => {
|
||||
it("parses simple comma-separated rows", () => {
|
||||
expect(parseCSV("a,b,c\n1,2,3")).toEqual([
|
||||
["a", "b", "c"],
|
||||
["1", "2", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves commas inside quoted fields", () => {
|
||||
expect(parseCSV('name,address\nAlice,"123 Main St, Apt 4"')).toEqual([
|
||||
["name", "address"],
|
||||
["Alice", "123 Main St, Apt 4"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles escaped double quotes inside quoted fields", () => {
|
||||
expect(parseCSV('a,b\n"say ""hello""",world')).toEqual([
|
||||
["a", "b"],
|
||||
['say "hello"', "world"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles newlines inside quoted fields", () => {
|
||||
expect(parseCSV('a,b\n"line1\nline2",val')).toEqual([
|
||||
["a", "b"],
|
||||
["line1\nline2", "val"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
expect(parseCSV("a,b\r\n1,2\r\n3,4")).toEqual([
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles empty fields", () => {
|
||||
expect(parseCSV("a,b,c\n1,,3")).toEqual([
|
||||
["a", "b", "c"],
|
||||
["1", "", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a single element", () => {
|
||||
expect(parseCSV("a")).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("handles a single row with no newline", () => {
|
||||
expect(parseCSV("a,b,c")).toEqual([["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("handles quoted fields that are entirely empty", () => {
|
||||
expect(parseCSV('a,b\n"",val')).toEqual([
|
||||
["a", "b"],
|
||||
["", "val"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles multiple quoted fields with commas", () => {
|
||||
expect(parseCSV('"foo, bar","baz, qux"\n"1, 2","3, 4"')).toEqual([
|
||||
["foo, bar", "baz, qux"],
|
||||
["1, 2", "3, 4"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws on unterminated quoted field", () => {
|
||||
expect(() => parseCSV('a,b\n"foo,bar')).toThrow(
|
||||
"Malformed CSV: unterminated quoted field"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on unterminated quote at end of input", () => {
|
||||
expect(() => parseCSV('"unterminated')).toThrow(
|
||||
"Malformed CSV: unterminated quoted field"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(parseCSV("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
55
web/src/ee/providers/AppModeProvider.tsx
Normal file
55
web/src/ee/providers/AppModeProvider.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { AppModeContext, AppMode } from "@/providers/AppModeProvider";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
|
||||
export interface AppModeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider for application mode (Search/Chat).
|
||||
*
|
||||
* This controls how user queries are handled:
|
||||
* - **search**: Forces search mode - quick document lookup
|
||||
* - **chat**: Forces chat mode - conversation with follow-up questions
|
||||
*
|
||||
* The initial mode is read from the user's persisted `default_app_mode` preference.
|
||||
* When search mode is unavailable (admin setting or no connectors), the mode is locked to "chat".
|
||||
*/
|
||||
export function AppModeProvider({ children }: AppModeProviderProps) {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { user } = useUser();
|
||||
const { isSearchModeAvailable } = useSettingsContext();
|
||||
|
||||
const persistedMode = user?.preferences?.default_app_mode;
|
||||
const [appMode, setAppModeState] = useState<AppMode>("chat");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) {
|
||||
setAppModeState("chat");
|
||||
return;
|
||||
}
|
||||
|
||||
if (persistedMode) {
|
||||
setAppModeState(persistedMode.toLowerCase() as AppMode);
|
||||
}
|
||||
}, [isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable, persistedMode]);
|
||||
|
||||
const setAppMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
if (!isPaidEnterpriseFeaturesEnabled || !isSearchModeAvailable) return;
|
||||
setAppModeState(mode);
|
||||
},
|
||||
[isPaidEnterpriseFeaturesEnabled, isSearchModeAvailable]
|
||||
);
|
||||
|
||||
return (
|
||||
<AppModeContext.Provider value={{ appMode, setAppMode }}>
|
||||
{children}
|
||||
</AppModeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -8,15 +8,14 @@ import {
|
||||
SearchFullResponse,
|
||||
} from "@/lib/search/interfaces";
|
||||
import { classifyQuery, searchDocuments } from "@/ee/lib/search/svc";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import {
|
||||
QueryControllerContext,
|
||||
QueryClassification,
|
||||
QueryControllerValue,
|
||||
QueryState,
|
||||
AppMode,
|
||||
} from "@/providers/QueryControllerProvider";
|
||||
|
||||
interface QueryControllerProviderProps {
|
||||
@@ -26,53 +25,19 @@ interface QueryControllerProviderProps {
|
||||
export function QueryControllerProvider({
|
||||
children,
|
||||
}: QueryControllerProviderProps) {
|
||||
const { appMode, setAppMode } = useAppMode();
|
||||
const appFocus = useAppFocus();
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const settings = useSettingsContext();
|
||||
const { isSearchModeAvailable: searchUiEnabled } = settings;
|
||||
const { user } = useUser();
|
||||
|
||||
// ── Merged query state (discriminated union) ──────────────────────────
|
||||
const [state, setState] = useState<QueryState>({
|
||||
phase: "idle",
|
||||
appMode: "chat",
|
||||
});
|
||||
|
||||
// Persistent app-mode preference — survives phase transitions and is
|
||||
// used to restore the correct mode when resetting back to idle.
|
||||
const appModeRef = useRef<AppMode>("chat");
|
||||
|
||||
// ── App mode sync from user preferences ───────────────────────────────
|
||||
const persistedMode = user?.preferences?.default_app_mode;
|
||||
|
||||
useEffect(() => {
|
||||
let mode: AppMode = "chat";
|
||||
if (isPaidEnterpriseFeaturesEnabled && searchUiEnabled && persistedMode) {
|
||||
const lower = persistedMode.toLowerCase();
|
||||
mode = (["auto", "search", "chat"] as const).includes(lower as AppMode)
|
||||
? (lower as AppMode)
|
||||
: "chat";
|
||||
}
|
||||
appModeRef.current = mode;
|
||||
setState((prev) =>
|
||||
prev.phase === "idle" ? { phase: "idle", appMode: mode } : prev
|
||||
);
|
||||
}, [isPaidEnterpriseFeaturesEnabled, searchUiEnabled, persistedMode]);
|
||||
|
||||
const setAppMode = useCallback(
|
||||
(mode: AppMode) => {
|
||||
if (!isPaidEnterpriseFeaturesEnabled || !searchUiEnabled) return;
|
||||
setState((prev) => {
|
||||
if (prev.phase !== "idle") return prev;
|
||||
appModeRef.current = mode;
|
||||
return { phase: "idle", appMode: mode };
|
||||
});
|
||||
},
|
||||
[isPaidEnterpriseFeaturesEnabled, searchUiEnabled]
|
||||
);
|
||||
|
||||
// ── Ancillary state ───────────────────────────────────────────────────
|
||||
// Query state
|
||||
const [query, setQuery] = useState<string | null>(null);
|
||||
const [classification, setClassification] =
|
||||
useState<QueryClassification>(null);
|
||||
const [isClassifying, setIsClassifying] = useState(false);
|
||||
|
||||
// Search state
|
||||
const [searchResults, setSearchResults] = useState<SearchDocWithContent[]>(
|
||||
[]
|
||||
);
|
||||
@@ -86,7 +51,7 @@ export function QueryControllerProvider({
|
||||
const searchAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
/**
|
||||
* Perform document search (pure data-fetching, no phase side effects)
|
||||
* Perform document search
|
||||
*/
|
||||
const performSearch = useCallback(
|
||||
async (searchQuery: string, filters?: BaseFilters): Promise<void> => {
|
||||
@@ -120,15 +85,19 @@ export function QueryControllerProvider({
|
||||
setLlmSelectedDocIds(response.llm_selected_doc_ids ?? null);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
throw err;
|
||||
return;
|
||||
}
|
||||
|
||||
setError("Document search failed. Please try again.");
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
} finally {
|
||||
// After we've performed a search, we automatically switch to "search" mode.
|
||||
// This is a "sticky" implementation; on purpose.
|
||||
setAppMode("search");
|
||||
}
|
||||
},
|
||||
[]
|
||||
[setAppMode]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -143,6 +112,8 @@ export function QueryControllerProvider({
|
||||
const controller = new AbortController();
|
||||
classifyAbortRef.current = controller;
|
||||
|
||||
setIsClassifying(true);
|
||||
|
||||
try {
|
||||
const response: SearchFlowClassificationResponse = await classifyQuery(
|
||||
classifyQueryText,
|
||||
@@ -158,6 +129,8 @@ export function QueryControllerProvider({
|
||||
|
||||
setError("Query classification failed. Falling back to chat.");
|
||||
return "chat";
|
||||
} finally {
|
||||
setIsClassifying(false);
|
||||
}
|
||||
},
|
||||
[]
|
||||
@@ -175,51 +148,62 @@ export function QueryControllerProvider({
|
||||
setQuery(submitQuery);
|
||||
setError(null);
|
||||
|
||||
const currentAppMode = appModeRef.current;
|
||||
|
||||
// Always route through chat if:
|
||||
// 1. Not Enterprise Enabled
|
||||
// 2. Admin has disabled the Search UI
|
||||
// 3. Not in the "New Session" tab
|
||||
// 4. In "New Session" tab but app-mode is "Chat"
|
||||
// 1.
|
||||
// We always route through chat if we're not Enterprise Enabled.
|
||||
//
|
||||
// 2.
|
||||
// We always route through chat if the admin has disabled the Search UI.
|
||||
//
|
||||
// 3.
|
||||
// We only go down the classification route if we're in the "New Session" tab.
|
||||
// Everywhere else, we always use the chat-flow.
|
||||
//
|
||||
// 4.
|
||||
// If we're in the "New Session" tab and the app-mode is "Chat", we continue with the chat-flow anyways.
|
||||
if (
|
||||
!isPaidEnterpriseFeaturesEnabled ||
|
||||
!searchUiEnabled ||
|
||||
!appFocus.isNewSession() ||
|
||||
currentAppMode === "chat"
|
||||
appMode === "chat"
|
||||
) {
|
||||
setState({ phase: "chat" });
|
||||
setClassification("chat");
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
onChat(submitQuery);
|
||||
return;
|
||||
}
|
||||
|
||||
// Search mode: immediately show SearchUI with loading state
|
||||
if (currentAppMode === "search") {
|
||||
setState({ phase: "searching" });
|
||||
try {
|
||||
await performSearch(submitQuery, filters);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
throw err;
|
||||
}
|
||||
setState({ phase: "search-results" });
|
||||
if (appMode === "search") {
|
||||
await performSearch(submitQuery, filters);
|
||||
setClassification("search");
|
||||
return;
|
||||
}
|
||||
|
||||
// # Note (@raunakab)
|
||||
//
|
||||
// Interestingly enough, for search, we do:
|
||||
// 1. setClassification("search")
|
||||
// 2. performSearch
|
||||
//
|
||||
// But for chat, we do:
|
||||
// 1. performChat
|
||||
// 2. setClassification("chat")
|
||||
//
|
||||
// The ChatUI has a nice loading UI, so it's fine for us to prematurely set the
|
||||
// classification-state before the chat has finished loading.
|
||||
//
|
||||
// However, the SearchUI does not. Prematurely setting the classification-state
|
||||
// will lead to a slightly ugly UI.
|
||||
|
||||
// Auto mode: classify first, then route
|
||||
setState({ phase: "classifying" });
|
||||
try {
|
||||
const result = await performClassification(submitQuery);
|
||||
|
||||
if (result === "search") {
|
||||
setState({ phase: "searching" });
|
||||
await performSearch(submitQuery, filters);
|
||||
setState({ phase: "search-results" });
|
||||
appModeRef.current = "search";
|
||||
setClassification("search");
|
||||
} else {
|
||||
setState({ phase: "chat" });
|
||||
setClassification("chat");
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
onChat(submitQuery);
|
||||
@@ -229,13 +213,14 @@ export function QueryControllerProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ phase: "chat" });
|
||||
setClassification("chat");
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
onChat(submitQuery);
|
||||
}
|
||||
},
|
||||
[
|
||||
appMode,
|
||||
appFocus,
|
||||
performClassification,
|
||||
performSearch,
|
||||
@@ -250,14 +235,7 @@ export function QueryControllerProvider({
|
||||
const refineSearch = useCallback(
|
||||
async (filters: BaseFilters): Promise<void> => {
|
||||
if (!query) return;
|
||||
setState({ phase: "searching" });
|
||||
try {
|
||||
await performSearch(query, filters);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") return;
|
||||
throw err;
|
||||
}
|
||||
setState({ phase: "search-results" });
|
||||
await performSearch(query, filters);
|
||||
},
|
||||
[query, performSearch]
|
||||
);
|
||||
@@ -276,7 +254,7 @@ export function QueryControllerProvider({
|
||||
}
|
||||
|
||||
setQuery(null);
|
||||
setState({ phase: "idle", appMode: appModeRef.current });
|
||||
setClassification(null);
|
||||
setSearchResults([]);
|
||||
setLlmSelectedDocIds(null);
|
||||
setError(null);
|
||||
@@ -284,8 +262,8 @@ export function QueryControllerProvider({
|
||||
|
||||
const value: QueryControllerValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
setAppMode,
|
||||
classification,
|
||||
isClassifying,
|
||||
searchResults,
|
||||
llmSelectedDocIds,
|
||||
error,
|
||||
@@ -294,8 +272,8 @@ export function QueryControllerProvider({
|
||||
reset,
|
||||
}),
|
||||
[
|
||||
state,
|
||||
setAppMode,
|
||||
classification,
|
||||
isClassifying,
|
||||
searchResults,
|
||||
llmSelectedDocIds,
|
||||
error,
|
||||
@@ -305,7 +283,7 @@ export function QueryControllerProvider({
|
||||
]
|
||||
);
|
||||
|
||||
// Sync state with navigation context
|
||||
// Sync classification state with navigation context
|
||||
useEffect(reset, [appFocus, reset]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -56,7 +56,7 @@ export default function SearchCard({
|
||||
|
||||
return (
|
||||
<Interactive.Stateless onClick={handleClick} prominence="secondary">
|
||||
<Interactive.Container heightVariant="fit" widthVariant="full">
|
||||
<Interactive.Container heightVariant="fit">
|
||||
<Section alignItems="start" gap={0} padding={0.25}>
|
||||
{/* Title Row */}
|
||||
<Section
|
||||
|
||||
@@ -18,17 +18,16 @@ import { getTimeFilterDate, TimeFilter } from "@/lib/time";
|
||||
import useTags from "@/hooks/useTags";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import { SvgCheck, SvgClock, SvgTag } from "@opal/icons";
|
||||
import FilterButton from "@/refresh-components/buttons/FilterButton";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import useFilter from "@/hooks/useFilter";
|
||||
import { LineItemButton } from "@opal/components";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -52,17 +51,22 @@ const TIME_FILTER_OPTIONS: { value: TimeFilter; label: string }[] = [
|
||||
{ value: "year", label: "Past year" },
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// SearchResults Component (default export)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Component for displaying search results with source filter sidebar.
|
||||
*/
|
||||
export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
// Available tags from backend
|
||||
const { tags: availableTags } = useTags();
|
||||
const {
|
||||
state,
|
||||
searchResults: results,
|
||||
llmSelectedDocIds,
|
||||
error,
|
||||
refineSearch: onRefineSearch,
|
||||
} = useQueryController();
|
||||
|
||||
const prevErrorRef = useRef<string | null>(null);
|
||||
|
||||
// Show a toast notification when a new error occurs
|
||||
@@ -193,15 +197,6 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
|
||||
const showEmpty = !error && results.length === 0;
|
||||
|
||||
// Show a centered spinner while search is in-flight (after all hooks)
|
||||
if (state.phase === "searching") {
|
||||
return (
|
||||
<div className="flex-1 min-h-0 w-full flex items-center justify-center">
|
||||
<SimpleLoader />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 w-full flex flex-col gap-3">
|
||||
{/* ── Top row: Filters + Result count ── */}
|
||||
@@ -231,19 +226,18 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
<Popover.Content align="start" width="md">
|
||||
<PopoverMenu>
|
||||
{TIME_FILTER_OPTIONS.map((opt) => (
|
||||
<LineItemButton
|
||||
<LineItem
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setTimeFilter(opt.value);
|
||||
setTimeFilterOpen(false);
|
||||
onRefineSearch(buildFilters({ time: opt.value }));
|
||||
}}
|
||||
state={timeFilter === opt.value ? "selected" : "empty"}
|
||||
selected={timeFilter === opt.value}
|
||||
icon={timeFilter === opt.value ? SvgCheck : SvgClock}
|
||||
title={opt.label}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
>
|
||||
{opt.label}
|
||||
</LineItem>
|
||||
))}
|
||||
</PopoverMenu>
|
||||
</Popover.Content>
|
||||
@@ -284,7 +278,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
t.tag_value === tag.tag_value
|
||||
);
|
||||
return (
|
||||
<LineItemButton
|
||||
<LineItem
|
||||
key={`${tag.tag_key}=${tag.tag_value}`}
|
||||
onClick={() => {
|
||||
const next = isSelected
|
||||
@@ -297,12 +291,11 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
setSelectedTags(next);
|
||||
onRefineSearch(buildFilters({ tags: next }));
|
||||
}}
|
||||
state={isSelected ? "selected" : "empty"}
|
||||
selected={isSelected}
|
||||
icon={isSelected ? SvgCheck : SvgTag}
|
||||
title={tag.tag_value}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
/>
|
||||
>
|
||||
{tag.tag_value}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</PopoverMenu>
|
||||
@@ -364,7 +357,7 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 px-1">
|
||||
<Section gap={0.25} height="fit">
|
||||
{sourcesWithMeta.map(({ source, meta, count }) => (
|
||||
<LineItemButton
|
||||
<LineItem
|
||||
key={source}
|
||||
icon={(props) => (
|
||||
<SourceIcon
|
||||
@@ -374,15 +367,12 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
/>
|
||||
)}
|
||||
onClick={() => handleSourceToggle(source)}
|
||||
state={
|
||||
selectedSources.includes(source) ? "selected" : "empty"
|
||||
}
|
||||
title={meta.displayName}
|
||||
selectVariant="select-heavy"
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
selected={selectedSources.includes(source)}
|
||||
emphasized
|
||||
rightChildren={<Text text03>{count}</Text>}
|
||||
/>
|
||||
>
|
||||
{meta.displayName}
|
||||
</LineItem>
|
||||
))}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import { UserStatus } from "@/lib/types";
|
||||
import type { UserRole, InvitedUserSnapshot } from "@/lib/types";
|
||||
import type {
|
||||
UserRow,
|
||||
UserGroupInfo,
|
||||
} from "@/refresh-pages/admin/UsersPage/interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backend response shape (GET /manage/users/accepted/all)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FullUserSnapshot {
|
||||
id: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
is_active: boolean;
|
||||
password_configured: boolean;
|
||||
personal_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
groups: UserGroupInfo[];
|
||||
is_scim_synced: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Converters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toUserRow(snapshot: FullUserSnapshot): UserRow {
|
||||
return {
|
||||
id: snapshot.id,
|
||||
email: snapshot.email,
|
||||
role: snapshot.role,
|
||||
status: snapshot.is_active ? UserStatus.ACTIVE : UserStatus.INACTIVE,
|
||||
is_active: snapshot.is_active,
|
||||
is_scim_synced: snapshot.is_scim_synced,
|
||||
personal_name: snapshot.personal_name,
|
||||
created_at: snapshot.created_at,
|
||||
updated_at: snapshot.updated_at,
|
||||
groups: snapshot.groups,
|
||||
};
|
||||
}
|
||||
|
||||
function emailToUserRow(
|
||||
email: string,
|
||||
status: UserStatus.INVITED | UserStatus.REQUESTED
|
||||
): UserRow {
|
||||
return {
|
||||
id: null,
|
||||
email,
|
||||
role: null,
|
||||
status,
|
||||
is_active: false,
|
||||
is_scim_synced: false,
|
||||
personal_name: null,
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
groups: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function useAdminUsers() {
|
||||
const {
|
||||
data: acceptedData,
|
||||
isLoading: acceptedLoading,
|
||||
mutate: acceptedMutate,
|
||||
} = useSWR<FullUserSnapshot[]>(
|
||||
"/api/manage/users/accepted/all",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const {
|
||||
data: invitedData,
|
||||
isLoading: invitedLoading,
|
||||
mutate: invitedMutate,
|
||||
} = useSWR<InvitedUserSnapshot[]>(
|
||||
"/api/manage/users/invited",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const {
|
||||
data: requestedData,
|
||||
isLoading: requestedLoading,
|
||||
mutate: requestedMutate,
|
||||
} = useSWR<InvitedUserSnapshot[]>(
|
||||
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const acceptedRows = (acceptedData ?? []).map(toUserRow);
|
||||
const invitedRows = (invitedData ?? []).map((u) =>
|
||||
emailToUserRow(u.email, UserStatus.INVITED)
|
||||
);
|
||||
const requestedRows = (requestedData ?? []).map((u) =>
|
||||
emailToUserRow(u.email, UserStatus.REQUESTED)
|
||||
);
|
||||
|
||||
const users = [...invitedRows, ...requestedRows, ...acceptedRows];
|
||||
|
||||
const isLoading = acceptedLoading || invitedLoading || requestedLoading;
|
||||
|
||||
function refresh() {
|
||||
acceptedMutate();
|
||||
invitedMutate();
|
||||
requestedMutate();
|
||||
}
|
||||
|
||||
return { users, isLoading, refresh };
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
//
|
||||
// This is useful in determining what `SidebarTab` should be active, for example.
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
@@ -67,25 +66,31 @@ export default function useAppFocus(): AppFocus {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const chatId = searchParams.get(SEARCH_PARAM_NAMES.CHAT_ID);
|
||||
const agentId = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
|
||||
const projectId = searchParams.get(SEARCH_PARAM_NAMES.PROJECT_ID);
|
||||
// Check if we're viewing a shared chat
|
||||
if (pathname.startsWith("/app/shared/")) {
|
||||
return new AppFocus("shared-chat");
|
||||
}
|
||||
|
||||
// Memoize on the values that determine which AppFocus is constructed.
|
||||
// AppFocus is immutable, so same inputs → same instance.
|
||||
return useMemo(() => {
|
||||
if (pathname.startsWith("/app/shared/")) {
|
||||
return new AppFocus("shared-chat");
|
||||
}
|
||||
if (pathname.startsWith("/app/settings")) {
|
||||
return new AppFocus("user-settings");
|
||||
}
|
||||
if (pathname.startsWith("/app/agents")) {
|
||||
return new AppFocus("more-agents");
|
||||
}
|
||||
if (chatId) return new AppFocus({ type: "chat", id: chatId });
|
||||
if (agentId) return new AppFocus({ type: "agent", id: agentId });
|
||||
if (projectId) return new AppFocus({ type: "project", id: projectId });
|
||||
return new AppFocus("new-session");
|
||||
}, [pathname, chatId, agentId, projectId]);
|
||||
// Check if we're on the user settings page
|
||||
if (pathname.startsWith("/app/settings")) {
|
||||
return new AppFocus("user-settings");
|
||||
}
|
||||
|
||||
// Check if we're on the agents page
|
||||
if (pathname.startsWith("/app/agents")) {
|
||||
return new AppFocus("more-agents");
|
||||
}
|
||||
|
||||
// Check search params for chat, agent, or project
|
||||
const chatId = searchParams.get(SEARCH_PARAM_NAMES.CHAT_ID);
|
||||
if (chatId) return new AppFocus({ type: "chat", id: chatId });
|
||||
|
||||
const agentId = searchParams.get(SEARCH_PARAM_NAMES.PERSONA_ID);
|
||||
if (agentId) return new AppFocus({ type: "agent", id: agentId });
|
||||
|
||||
const projectId = searchParams.get(SEARCH_PARAM_NAMES.PROJECT_ID);
|
||||
if (projectId) return new AppFocus({ type: "project", id: projectId });
|
||||
|
||||
// No search params means we're on a new session
|
||||
return new AppFocus("new-session");
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function measure(el: HTMLElement): { x: number; y: number } | null {
|
||||
*/
|
||||
export default function useContainerCenter(): ContainerCenter {
|
||||
const pathname = usePathname();
|
||||
const { isMediumScreen } = useScreenSize();
|
||||
const { isSmallScreen } = useScreenSize();
|
||||
const [center, setCenter] = useState<{ x: number | null; y: number | null }>(
|
||||
() => {
|
||||
if (typeof document === "undefined") return NULL_CENTER;
|
||||
@@ -68,9 +68,9 @@ export default function useContainerCenter(): ContainerCenter {
|
||||
}, [pathname]);
|
||||
|
||||
return {
|
||||
centerX: isMediumScreen ? null : center.x,
|
||||
centerY: isMediumScreen ? null : center.y,
|
||||
hasContainerCenter: isMediumScreen
|
||||
centerX: isSmallScreen ? null : center.x,
|
||||
centerY: isSmallScreen ? null : center.y,
|
||||
hasContainerCenter: isSmallScreen
|
||||
? false
|
||||
: center.x !== null && center.y !== null,
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {
|
||||
DESKTOP_SMALL_BREAKPOINT_PX,
|
||||
DESKTOP_MEDIUM_BREAKPOINT_PX,
|
||||
MOBILE_SIDEBAR_BREAKPOINT_PX,
|
||||
} from "@/lib/constants";
|
||||
import { useState, useCallback } from "react";
|
||||
@@ -13,7 +12,6 @@ export interface ScreenSize {
|
||||
width: number;
|
||||
isMobile: boolean;
|
||||
isSmallScreen: boolean;
|
||||
isMediumScreen: boolean;
|
||||
}
|
||||
|
||||
export default function useScreenSize(): ScreenSize {
|
||||
@@ -36,13 +34,11 @@ export default function useScreenSize(): ScreenSize {
|
||||
|
||||
const isMobile = sizes.width <= MOBILE_SIDEBAR_BREAKPOINT_PX;
|
||||
const isSmall = sizes.width <= DESKTOP_SMALL_BREAKPOINT_PX;
|
||||
const isMedium = sizes.width <= DESKTOP_MEDIUM_BREAKPOINT_PX;
|
||||
|
||||
return {
|
||||
height: sizes.height,
|
||||
width: sizes.width,
|
||||
isMobile: isMounted && isMobile,
|
||||
isSmallScreen: isMounted && isSmall,
|
||||
isMediumScreen: isMounted && isMedium,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useSWR from "swr";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import type { InvitedUserSnapshot } from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
|
||||
type UserCountsResponse = {
|
||||
role_counts: Record<string, number>;
|
||||
status_counts: Record<string, number>;
|
||||
};
|
||||
|
||||
type UserCounts = {
|
||||
activeCount: number | null;
|
||||
invitedCount: number | null;
|
||||
pendingCount: number | null;
|
||||
roleCounts: Record<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
refreshCounts: () => void;
|
||||
};
|
||||
|
||||
export default function useUserCounts(): UserCounts {
|
||||
const { data: countsData, mutate: refreshCounts } =
|
||||
useSWR<UserCountsResponse>(
|
||||
"/api/manage/users/counts",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const { data: invitedUsers } = useSWR<InvitedUserSnapshot[]>(
|
||||
"/api/manage/users/invited",
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const { data: pendingUsers } = useSWR<InvitedUserSnapshot[]>(
|
||||
NEXT_PUBLIC_CLOUD_ENABLED ? "/api/tenants/users/pending" : null,
|
||||
errorHandlingFetcher
|
||||
);
|
||||
|
||||
const activeCount = countsData?.status_counts?.active ?? null;
|
||||
const inactiveCount = countsData?.status_counts?.inactive ?? null;
|
||||
|
||||
return {
|
||||
activeCount,
|
||||
invitedCount: invitedUsers?.length ?? null,
|
||||
pendingCount: pendingUsers?.length ?? null,
|
||||
roleCounts: countsData?.role_counts ?? {},
|
||||
statusCounts: {
|
||||
...(activeCount !== null ? { active: activeCount } : {}),
|
||||
...(inactiveCount !== null ? { inactive: inactiveCount } : {}),
|
||||
...(invitedUsers ? { invited: invitedUsers.length } : {}),
|
||||
...(pendingUsers ? { requested: pendingUsers.length } : {}),
|
||||
} as Record<string, number>,
|
||||
refreshCounts,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,6 @@ export enum LLMProviderName {
|
||||
OPENROUTER = "openrouter",
|
||||
VERTEX_AI = "vertex_ai",
|
||||
BEDROCK = "bedrock",
|
||||
LITELLM_PROXY = "litellm_proxy",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
@@ -145,18 +144,6 @@ export interface OpenRouterFetchParams {
|
||||
provider_name?: string;
|
||||
}
|
||||
|
||||
export interface LiteLLMProxyFetchParams {
|
||||
api_base?: string;
|
||||
api_key?: string;
|
||||
provider_name?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LiteLLMProxyModelResponse {
|
||||
provider_name: string;
|
||||
model_name: string;
|
||||
}
|
||||
|
||||
export interface VertexAIFetchParams {
|
||||
model_configurations?: ModelConfiguration[];
|
||||
}
|
||||
@@ -166,5 +153,4 @@ export type FetchModelsParams =
|
||||
| OllamaFetchParams
|
||||
| LMStudioFetchParams
|
||||
| OpenRouterFetchParams
|
||||
| LiteLLMProxyFetchParams
|
||||
| VertexAIFetchParams;
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
} from "@opal/icons";
|
||||
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
|
||||
import { useSettingsContext } from "@/providers/SettingsProvider";
|
||||
import type { AppMode } from "@/providers/QueryControllerProvider";
|
||||
import { AppMode, useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
@@ -82,7 +82,7 @@ import useBrowserInfo from "@/hooks/useBrowserInfo";
|
||||
*/
|
||||
function Header() {
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
const { state, setAppMode } = useQueryController();
|
||||
const { appMode, setAppMode } = useAppMode();
|
||||
const settings = useSettingsContext();
|
||||
const { isMobile } = useScreenSize();
|
||||
const { setFolded } = useAppSidebarContext();
|
||||
@@ -108,6 +108,7 @@ function Header() {
|
||||
useChatSessions();
|
||||
const router = useRouter();
|
||||
const appFocus = useAppFocus();
|
||||
const { classification } = useQueryController();
|
||||
|
||||
const customHeaderContent =
|
||||
settings?.enterpriseSettings?.custom_header_content;
|
||||
@@ -116,8 +117,7 @@ function Header() {
|
||||
// without this content still use.
|
||||
const pageWithHeaderContent = appFocus.isChat() || appFocus.isNewSession();
|
||||
|
||||
const effectiveMode: AppMode =
|
||||
appFocus.isNewSession() && state.phase === "idle" ? state.appMode : "chat";
|
||||
const effectiveMode: AppMode = appFocus.isNewSession() ? appMode : "chat";
|
||||
|
||||
const availableProjects = useMemo(() => {
|
||||
if (!projects) return [];
|
||||
@@ -323,7 +323,7 @@ function Header() {
|
||||
{isPaidEnterpriseFeaturesEnabled &&
|
||||
settings.isSearchModeAvailable &&
|
||||
appFocus.isNewSession() &&
|
||||
state.phase === "idle" && (
|
||||
!classification && (
|
||||
<Popover open={modePopoverOpen} onOpenChange={setModePopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<OpenButton
|
||||
|
||||
@@ -230,7 +230,7 @@ function SettingsHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Spacer vertical rem={2.5} />
|
||||
<Spacer vertical rem={1} />
|
||||
|
||||
<div className="flex flex-col gap-6 px-4">
|
||||
<div className="flex w-full justify-between">
|
||||
|
||||
@@ -187,7 +187,7 @@ export const ADMIN_ROUTE_CONFIG: Record<string, AdminRouteConfig> = {
|
||||
},
|
||||
[ADMIN_PATHS.USERS]: {
|
||||
icon: SvgUser,
|
||||
title: "Users & Requests",
|
||||
title: "Manage Users",
|
||||
sidebarLabel: "Users",
|
||||
},
|
||||
[ADMIN_PATHS.API_KEYS]: {
|
||||
|
||||
@@ -123,7 +123,6 @@ export const MAX_FILES_TO_SHOW = 3;
|
||||
// SIZES
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 640;
|
||||
export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
|
||||
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
|
||||
export const DEFAULT_AGENT_AVATAR_SIZE_PX = 18;
|
||||
export const HORIZON_DISTANCE_PX = 800;
|
||||
export const LOGO_FOLDED_SIZE_PX = 24;
|
||||
|
||||
@@ -22,7 +22,6 @@ const PROVIDER_ICONS: Record<string, IconFunctionComponent> = {
|
||||
[LLMProviderName.BEDROCK]: SvgAws,
|
||||
[LLMProviderName.AZURE]: SvgAzure,
|
||||
litellm: SvgLitellm,
|
||||
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
|
||||
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
|
||||
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
|
||||
[LLMProviderName.LM_STUDIO]: SvgLmStudio,
|
||||
@@ -38,7 +37,6 @@ const PROVIDER_PRODUCT_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.BEDROCK]: "Amazon Bedrock",
|
||||
[LLMProviderName.AZURE]: "Azure OpenAI",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
[LLMProviderName.LM_STUDIO]: "LM Studio",
|
||||
@@ -54,7 +52,6 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.BEDROCK]: "AWS",
|
||||
[LLMProviderName.AZURE]: "Microsoft Azure",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
[LLMProviderName.LM_STUDIO]: "LM Studio",
|
||||
|
||||
@@ -68,20 +68,6 @@ export const USER_ROLE_LABELS: Record<UserRole, string> = {
|
||||
[UserRole.SLACK_USER]: "Slack User",
|
||||
};
|
||||
|
||||
export enum UserStatus {
|
||||
ACTIVE = "active",
|
||||
INACTIVE = "inactive",
|
||||
INVITED = "invited",
|
||||
REQUESTED = "requested",
|
||||
}
|
||||
|
||||
export const USER_STATUS_LABELS: Record<UserStatus, string> = {
|
||||
[UserStatus.ACTIVE]: "Active",
|
||||
[UserStatus.INACTIVE]: "Inactive",
|
||||
[UserStatus.INVITED]: "Invite Pending",
|
||||
[UserStatus.REQUESTED]: "Request to Join",
|
||||
};
|
||||
|
||||
export const INVALID_ROLE_HOVER_TEXT: Partial<Record<UserRole, string>> = {
|
||||
[UserRole.BASIC]: "Basic users can't perform any admin actions",
|
||||
[UserRole.ADMIN]: "Admin users can perform all admin actions",
|
||||
|
||||
23
web/src/providers/AppModeProvider.tsx
Normal file
23
web/src/providers/AppModeProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { eeGated } from "@/ce";
|
||||
import { AppModeProvider as EEAppModeProvider } from "@/ee/providers/AppModeProvider";
|
||||
|
||||
export type AppMode = "auto" | "search" | "chat";
|
||||
|
||||
interface AppModeContextValue {
|
||||
appMode: AppMode;
|
||||
setAppMode: (mode: AppMode) => void;
|
||||
}
|
||||
|
||||
export const AppModeContext = createContext<AppModeContextValue>({
|
||||
appMode: "chat",
|
||||
setAppMode: () => undefined,
|
||||
});
|
||||
|
||||
export function useAppMode(): AppModeContextValue {
|
||||
return useContext(AppModeContext);
|
||||
}
|
||||
|
||||
export const AppModeProvider = eeGated(EEAppModeProvider);
|
||||
@@ -24,7 +24,7 @@
|
||||
* 4. **ProviderContextProvider** - LLM provider configuration
|
||||
* 5. **ModalProvider** - Global modal state management
|
||||
* 6. **AppSidebarProvider** - Sidebar open/closed state
|
||||
* 7. **QueryControllerProvider** - Search/Chat mode + query lifecycle
|
||||
* 7. **AppModeProvider** - Search/Chat mode selection
|
||||
*
|
||||
* ## Usage
|
||||
*
|
||||
@@ -40,7 +40,7 @@
|
||||
* - `useSettingsContext()` - from SettingsProvider
|
||||
* - `useUser()` - from UserProvider
|
||||
* - `useAppBackground()` - from AppBackgroundProvider
|
||||
* - `useQueryController()` - from QueryControllerProvider (includes appMode)
|
||||
* - `useAppMode()` - from AppModeProvider
|
||||
* - etc.
|
||||
*
|
||||
* @TODO(@raunakab): The providers wrapped by this component are currently
|
||||
@@ -65,6 +65,7 @@ import { User } from "@/lib/types";
|
||||
import { ModalProvider } from "@/components/context/ModalContext";
|
||||
import { AuthTypeMetadata } from "@/lib/userSS";
|
||||
import { AppSidebarProvider } from "@/providers/AppSidebarProvider";
|
||||
import { AppModeProvider } from "@/providers/AppModeProvider";
|
||||
import { AppBackgroundProvider } from "@/providers/AppBackgroundProvider";
|
||||
import { QueryControllerProvider } from "@/providers/QueryControllerProvider";
|
||||
import ToastProvider from "@/providers/ToastProvider";
|
||||
@@ -95,9 +96,11 @@ export default function AppProvider({
|
||||
<ProviderContextProvider>
|
||||
<ModalProvider user={user}>
|
||||
<AppSidebarProvider folded={!!folded}>
|
||||
<QueryControllerProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryControllerProvider>
|
||||
<AppModeProvider>
|
||||
<QueryControllerProvider>
|
||||
<ToastProvider>{children}</ToastProvider>
|
||||
</QueryControllerProvider>
|
||||
</AppModeProvider>
|
||||
</AppSidebarProvider>
|
||||
</ModalProvider>
|
||||
</ProviderContextProvider>
|
||||
|
||||
@@ -5,20 +5,13 @@ import { eeGated } from "@/ce";
|
||||
import { QueryControllerProvider as EEQueryControllerProvider } from "@/ee/providers/QueryControllerProvider";
|
||||
import { SearchDocWithContent, BaseFilters } from "@/lib/search/interfaces";
|
||||
|
||||
export type AppMode = "auto" | "search" | "chat";
|
||||
|
||||
export type QueryState =
|
||||
| { phase: "idle"; appMode: AppMode }
|
||||
| { phase: "classifying" }
|
||||
| { phase: "searching" }
|
||||
| { phase: "search-results" }
|
||||
| { phase: "chat" };
|
||||
export type QueryClassification = "search" | "chat" | null;
|
||||
|
||||
export interface QueryControllerValue {
|
||||
/** Single state variable encoding both the query lifecycle phase and (when idle) the user's mode selection. */
|
||||
state: QueryState;
|
||||
/** Update the app mode. Only takes effect when idle. No-op in CE or when search is unavailable. */
|
||||
setAppMode: (mode: AppMode) => void;
|
||||
/** Classification state: null (idle), "search", or "chat" */
|
||||
classification: QueryClassification;
|
||||
/** Whether or not the currently submitted query is being actively classified by the backend */
|
||||
isClassifying: boolean;
|
||||
/** Search results (empty if chat or not yet searched) */
|
||||
searchResults: SearchDocWithContent[];
|
||||
/** Document IDs selected by the LLM as most relevant */
|
||||
@@ -38,8 +31,8 @@ export interface QueryControllerValue {
|
||||
}
|
||||
|
||||
export const QueryControllerContext = createContext<QueryControllerValue>({
|
||||
state: { phase: "idle", appMode: "chat" },
|
||||
setAppMode: () => undefined,
|
||||
classification: null,
|
||||
isClassifying: false,
|
||||
searchResults: [],
|
||||
llmSelectedDocIds: null,
|
||||
error: null,
|
||||
|
||||
@@ -6,9 +6,6 @@ import type { IconProps } from "@opal/types";
|
||||
export interface ChipProps {
|
||||
children?: string;
|
||||
icon?: React.FunctionComponent<IconProps>;
|
||||
/** Icon rendered after the label (e.g. a warning indicator) */
|
||||
rightIcon?: React.FunctionComponent<IconProps>;
|
||||
rightIconClassName?: string;
|
||||
onRemove?: () => void;
|
||||
smallLabel?: boolean;
|
||||
}
|
||||
@@ -27,8 +24,6 @@ export interface ChipProps {
|
||||
export default function Chip({
|
||||
children,
|
||||
icon: Icon,
|
||||
rightIcon: RightIcon,
|
||||
rightIconClassName,
|
||||
onRemove,
|
||||
smallLabel = true,
|
||||
}: ChipProps) {
|
||||
@@ -40,9 +35,6 @@ export default function Chip({
|
||||
{children}
|
||||
</Text>
|
||||
)}
|
||||
{RightIcon && (
|
||||
<RightIcon size={14} className={rightIconClassName ?? "text-text-03"} />
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -2,8 +2,6 @@ import React from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import ButtonRenaming from "./ButtonRenaming";
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const meta: Meta<typeof ButtonRenaming> = {
|
||||
title: "refresh-components/buttons/ButtonRenaming",
|
||||
component: ButtonRenaming,
|
||||
@@ -30,23 +28,35 @@ type Story = StoryObj<typeof ButtonRenaming>;
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
initialName: "My Chat Session",
|
||||
onRename: async () => {},
|
||||
onClose: noop,
|
||||
onRename: async (name: string) => {
|
||||
console.log("Renamed to:", name);
|
||||
},
|
||||
onClose: () => {
|
||||
console.log("Closed");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyName: Story = {
|
||||
args: {
|
||||
initialName: null,
|
||||
onRename: async () => {},
|
||||
onClose: noop,
|
||||
onRename: async (name: string) => {
|
||||
console.log("Renamed to:", name);
|
||||
},
|
||||
onClose: () => {
|
||||
console.log("Closed");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongName: Story = {
|
||||
args: {
|
||||
initialName: "This is a very long chat session name that should overflow",
|
||||
onRename: async () => {},
|
||||
onClose: noop,
|
||||
onRename: async (name: string) => {
|
||||
console.log("Renamed to:", name);
|
||||
},
|
||||
onClose: () => {
|
||||
console.log("Closed");
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,14 +9,11 @@ import {
|
||||
Variants,
|
||||
wrapperClasses,
|
||||
} from "@/refresh-components/inputs/styles";
|
||||
import { SvgAlertTriangle } from "@opal/icons";
|
||||
import type { IconProps } from "@opal/types";
|
||||
|
||||
export interface ChipItem {
|
||||
id: string;
|
||||
label: string;
|
||||
/** When true the chip shows a warning icon */
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface InputChipFieldProps {
|
||||
@@ -91,46 +88,36 @@ function InputChipField({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 p-1.5 rounded-08 cursor-text w-full",
|
||||
"flex flex-row items-center flex-wrap gap-1 p-1.5 rounded-08 cursor-text w-full",
|
||||
wrapperClasses[variant],
|
||||
className
|
||||
)}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{chips.length > 0 && (
|
||||
<div className="flex flex-row items-center flex-wrap gap-1">
|
||||
{chips.map((chip) => (
|
||||
<Chip
|
||||
key={chip.id}
|
||||
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
|
||||
rightIcon={chip.error ? SvgAlertTriangle : undefined}
|
||||
rightIconClassName={
|
||||
chip.error ? "text-status-warning-text" : undefined
|
||||
}
|
||||
smallLabel={false}
|
||||
>
|
||||
{chip.label}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
{Icon && <Icon size={16} className="text-text-04 shrink-0" />}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"flex-1 min-w-[80px] h-[1.5rem] bg-transparent p-0.5 focus:outline-none",
|
||||
innerClasses[variant],
|
||||
textClasses[variant]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{Icon && <Icon size={16} className="text-text-04 shrink-0" />}
|
||||
{chips.map((chip) => (
|
||||
<Chip
|
||||
key={chip.id}
|
||||
onRemove={disabled ? undefined : () => onRemoveChip(chip.id)}
|
||||
smallLabel={false}
|
||||
>
|
||||
{chip.label}
|
||||
</Chip>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={chips.length === 0 ? placeholder : undefined}
|
||||
className={cn(
|
||||
"flex-1 min-w-[80px] h-[1.5rem] bg-transparent p-0.5 focus:outline-none",
|
||||
innerClasses[variant],
|
||||
textClasses[variant]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { LLMOption, LLMOptionGroup } from "./interfaces";
|
||||
export interface LLMPopoverProps {
|
||||
llmManager: LlmManager;
|
||||
requiresImageInput?: boolean;
|
||||
foldable?: boolean;
|
||||
folded?: boolean;
|
||||
onSelect?: (value: string) => void;
|
||||
currentModelName?: string;
|
||||
disabled?: boolean;
|
||||
@@ -142,7 +142,7 @@ export function groupLlmOptions(
|
||||
export default function LLMPopover({
|
||||
llmManager,
|
||||
requiresImageInput,
|
||||
foldable,
|
||||
folded,
|
||||
onSelect,
|
||||
currentModelName,
|
||||
disabled = false,
|
||||
@@ -359,14 +359,13 @@ export default function LLMPopover({
|
||||
<Disabled disabled={disabled}>
|
||||
<OpenButton
|
||||
icon={
|
||||
foldable
|
||||
folded
|
||||
? SvgRefreshCw
|
||||
: getProviderIcon(
|
||||
llmManager.currentLlm.provider,
|
||||
llmManager.currentLlm.modelName
|
||||
)
|
||||
}
|
||||
foldable={foldable}
|
||||
>
|
||||
{currentLlmDisplayName}
|
||||
</OpenButton>
|
||||
|
||||
@@ -273,7 +273,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
leftExtra={footerConfig.leftExtra}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -302,25 +301,7 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
: undefined),
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
width={
|
||||
Object.keys(columnWidths).length > 0
|
||||
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<colgroup>
|
||||
{table.getAllLeafColumns().map((col) => (
|
||||
<col
|
||||
key={col.id}
|
||||
style={
|
||||
columnWidths[col.id] != null
|
||||
? { width: columnWidths[col.id] }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
|
||||
@@ -61,8 +61,6 @@ interface FooterSummaryModeProps {
|
||||
totalPages: number;
|
||||
/** Called when the user navigates to a different page. */
|
||||
onPageChange: (page: number) => void;
|
||||
/** Optional extra element rendered after the summary text (e.g. a download icon). */
|
||||
leftExtra?: React.ReactNode;
|
||||
/** Controls overall footer sizing. `"regular"` (default) or `"small"`. */
|
||||
size?: TableSize;
|
||||
className?: string;
|
||||
@@ -117,15 +115,12 @@ export default function Footer(props: FooterProps) {
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<SummaryLeft
|
||||
rangeStart={props.rangeStart}
|
||||
rangeEnd={props.rangeEnd}
|
||||
totalItems={props.totalItems}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
{props.leftExtra}
|
||||
</>
|
||||
<SummaryLeft
|
||||
rangeStart={props.rangeStart}
|
||||
rangeEnd={props.rangeEnd}
|
||||
totalItems={props.totalItems}
|
||||
isSmall={isSmall}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ export default function TableCell({
|
||||
const resolvedSize = size ?? contextSize;
|
||||
return (
|
||||
<td
|
||||
className="tbl-cell overflow-hidden"
|
||||
className="tbl-cell"
|
||||
data-size={resolvedSize}
|
||||
style={width != null ? { width } : undefined}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn("tbl-cell-inner", "flex items-center overflow-hidden")}
|
||||
className={cn("tbl-cell-inner", "flex items-center")}
|
||||
data-size={resolvedSize}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -114,16 +114,11 @@ function TableQualifier({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full bg-background-neutral-inverted-00",
|
||||
"flex items-center justify-center rounded-full bg-text-05",
|
||||
resolvedSize === "regular" ? "h-7 w-7" : "h-6 w-6"
|
||||
)}
|
||||
>
|
||||
<Text
|
||||
inverted
|
||||
secondaryAction
|
||||
text05
|
||||
className="select-none uppercase"
|
||||
>
|
||||
<Text secondaryAction textLight05 className="select-none uppercase">
|
||||
{initials}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -141,8 +141,6 @@ export interface DataTableFooterSelection {
|
||||
|
||||
export interface DataTableFooterSummary {
|
||||
mode: "summary";
|
||||
/** Optional extra element rendered after the summary text (e.g. a download icon). */
|
||||
leftExtra?: ReactNode;
|
||||
}
|
||||
|
||||
export type DataTableFooterConfig =
|
||||
|
||||
@@ -72,6 +72,7 @@ import { eeGated } from "@/ce";
|
||||
import EESearchUI from "@/ee/sections/SearchUI";
|
||||
const SearchUI = eeGated(EESearchUI);
|
||||
import { motion, AnimatePresence } from "motion/react";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
|
||||
interface FadeProps {
|
||||
show: boolean;
|
||||
@@ -128,6 +129,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
type: "success",
|
||||
},
|
||||
});
|
||||
const { setAppMode } = useAppMode();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Use SWR hooks for data fetching
|
||||
@@ -483,7 +485,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
finishOnboarding,
|
||||
]
|
||||
);
|
||||
const { submit: submitQuery, state, setAppMode } = useQueryController();
|
||||
const { submit: submitQuery, classification } = useQueryController();
|
||||
|
||||
const defaultAppMode =
|
||||
(user?.preferences?.default_app_mode?.toLowerCase() as "chat" | "search") ??
|
||||
@@ -491,15 +493,12 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const isNewSession = appFocus.isNewSession();
|
||||
|
||||
const isSearch =
|
||||
state.phase === "searching" || state.phase === "search-results";
|
||||
|
||||
// 1. Reset the app-mode back to the user's default when navigating back to the "New Sessions" tab.
|
||||
// 2. If we're navigating away from the "New Session" tab after performing a search, we reset the app-input-bar.
|
||||
useEffect(() => {
|
||||
if (isNewSession) setAppMode(defaultAppMode);
|
||||
if (!isNewSession && isSearch) resetInputBar();
|
||||
}, [isNewSession, defaultAppMode, isSearch, resetInputBar, setAppMode]);
|
||||
if (!isNewSession && classification === "search") resetInputBar();
|
||||
}, [isNewSession, defaultAppMode, classification, resetInputBar, setAppMode]);
|
||||
|
||||
const handleSearchDocumentClick = useCallback(
|
||||
(doc: MinimalOnyxDocument) => setPresentingDocument(doc),
|
||||
@@ -608,6 +607,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const hasStarterMessages = (liveAgent?.starter_messages?.length ?? 0) > 0;
|
||||
|
||||
const isSearch = classification === "search";
|
||||
const gridStyle = {
|
||||
gridTemplateColumns: "1fr",
|
||||
gridTemplateRows: isSearch
|
||||
@@ -735,7 +735,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
<Fade
|
||||
show={
|
||||
(appFocus.isNewSession() || appFocus.isAgent()) &&
|
||||
(state.phase === "idle" || state.phase === "classifying")
|
||||
!classification
|
||||
}
|
||||
className="w-full flex-1 flex flex-col items-center justify-end"
|
||||
>
|
||||
@@ -764,8 +764,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
{/* OnboardingUI */}
|
||||
{(appFocus.isNewSession() || appFocus.isAgent()) &&
|
||||
(state.phase === "idle" ||
|
||||
state.phase === "classifying") &&
|
||||
!classification &&
|
||||
(showOnboarding || !user?.personalization?.name) &&
|
||||
!onboardingDismissed && (
|
||||
<OnboardingFlow
|
||||
@@ -800,7 +799,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
<div
|
||||
className={cn(
|
||||
"transition-all duration-150 ease-in-out overflow-hidden",
|
||||
isSearch ? "h-[14px]" : "h-0"
|
||||
classification === "search" ? "h-[14px]" : "h-0"
|
||||
)}
|
||||
/>
|
||||
<AppInputBar
|
||||
|
||||
@@ -44,7 +44,6 @@ import { VertexAIModal } from "@/sections/modals/llmConfig/VertexAIModal";
|
||||
import { OpenRouterModal } from "@/sections/modals/llmConfig/OpenRouterModal";
|
||||
import { CustomModal } from "@/sections/modals/llmConfig/CustomModal";
|
||||
import { LMStudioForm } from "@/sections/modals/llmConfig/LMStudioForm";
|
||||
import { LiteLLMProxyModal } from "@/sections/modals/llmConfig/LiteLLMProxyModal";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.LLM_MODELS]!;
|
||||
@@ -117,13 +116,6 @@ const PROVIDER_MODAL_MAP: Record<
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
litellm_proxy: (d, open, onOpenChange) => (
|
||||
<LiteLLMProxyModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { SvgUser, SvgUserPlus } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { useScimToken } from "@/hooks/useScimToken";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import useUserCounts from "@/hooks/useUserCounts";
|
||||
import { UserStatus } from "@/lib/types";
|
||||
import type { StatusFilter } from "./UsersPage/interfaces";
|
||||
|
||||
import UsersSummary from "./UsersPage/UsersSummary";
|
||||
import UsersTable from "./UsersPage/UsersTable";
|
||||
import InviteUsersModal from "./UsersPage/InviteUsersModal";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users page content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UsersContent() {
|
||||
const isEe = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const { data: scimToken } = useScimToken();
|
||||
const showScim = isEe && !!scimToken;
|
||||
|
||||
const { activeCount, invitedCount, pendingCount, roleCounts, statusCounts } =
|
||||
useUserCounts();
|
||||
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<StatusFilter>([]);
|
||||
|
||||
const toggleStatus = (target: UserStatus) => {
|
||||
setSelectedStatuses((prev) =>
|
||||
prev.includes(target)
|
||||
? prev.filter((s) => s !== target)
|
||||
: [...prev, target]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<UsersSummary
|
||||
activeUsers={activeCount}
|
||||
pendingInvites={invitedCount}
|
||||
requests={pendingCount}
|
||||
showScim={showScim}
|
||||
onFilterActive={() => toggleStatus(UserStatus.ACTIVE)}
|
||||
onFilterInvites={() => toggleStatus(UserStatus.INVITED)}
|
||||
onFilterRequests={() => toggleStatus(UserStatus.REQUESTED)}
|
||||
/>
|
||||
|
||||
<UsersTable
|
||||
selectedStatuses={selectedStatuses}
|
||||
onStatusesChange={setSelectedStatuses}
|
||||
roleCounts={roleCounts}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UsersPage() {
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root width="lg">
|
||||
<SettingsLayouts.Header
|
||||
title="Users & Requests"
|
||||
icon={SvgUser}
|
||||
rightChildren={
|
||||
<Button icon={SvgUserPlus} onClick={() => setInviteOpen(true)}>
|
||||
Invite Users
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<UsersContent />
|
||||
</SettingsLayouts.Body>
|
||||
|
||||
<InviteUsersModal open={inviteOpen} onOpenChange={setInviteOpen} />
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,314 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useCallback } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgUsers, SvgUser, SvgLogOut, SvgCheck } from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
import ShadowDiv from "@/refresh-components/ShadowDiv";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { UserRole } from "@/lib/types";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import { addUserToGroup, removeUserFromGroup, setUserRole } from "./svc";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ASSIGNABLE_ROLES: { value: UserRole; label: string }[] = [
|
||||
{ value: UserRole.ADMIN, label: "Admin" },
|
||||
{ value: UserRole.GLOBAL_CURATOR, label: "Global Curator" },
|
||||
{ value: UserRole.BASIC, label: "Basic" },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EditGroupsModalProps {
|
||||
user: UserRow;
|
||||
onClose: () => void;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function EditGroupsModal({
|
||||
user,
|
||||
onClose,
|
||||
onMutate,
|
||||
}: EditGroupsModalProps) {
|
||||
const { data: allGroups, isLoading: groupsLoading } = useGroups();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
// Delay to allow click events on dropdown items to fire before closing
|
||||
setTimeout(() => {
|
||||
if (!containerRef.current?.contains(document.activeElement)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}, 0);
|
||||
}, []);
|
||||
const [selectedRole, setSelectedRole] = useState<string>(user.role ?? "");
|
||||
|
||||
const initialMemberGroupIds = useMemo(
|
||||
() => new Set(user.groups.map((g) => g.id)),
|
||||
[user.groups]
|
||||
);
|
||||
const [memberGroupIds, setMemberGroupIds] = useState<Set<number>>(
|
||||
() => new Set(initialMemberGroupIds)
|
||||
);
|
||||
|
||||
// Dropdown shows all groups filtered by search term
|
||||
const dropdownGroups = useMemo(() => {
|
||||
if (!allGroups) return [];
|
||||
if (searchTerm.length === 0) return allGroups;
|
||||
const lower = searchTerm.toLowerCase();
|
||||
return allGroups.filter((g) => g.name.toLowerCase().includes(lower));
|
||||
}, [allGroups, searchTerm]);
|
||||
|
||||
// Joined groups shown in the modal body
|
||||
const joinedGroups = useMemo(() => {
|
||||
if (!allGroups) return [];
|
||||
return allGroups.filter((g) => memberGroupIds.has(g.id));
|
||||
}, [allGroups, memberGroupIds]);
|
||||
|
||||
const hasGroupChanges = useMemo(() => {
|
||||
if (memberGroupIds.size !== initialMemberGroupIds.size) return true;
|
||||
return Array.from(memberGroupIds).some(
|
||||
(id) => !initialMemberGroupIds.has(id)
|
||||
);
|
||||
}, [memberGroupIds, initialMemberGroupIds]);
|
||||
|
||||
const hasRoleChange = user.role !== null && selectedRole !== user.role;
|
||||
const hasChanges = hasGroupChanges || hasRoleChange;
|
||||
|
||||
const toggleGroup = (groupId: number) => {
|
||||
setMemberGroupIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
const toAdd = Array.from(memberGroupIds).filter(
|
||||
(id) => !initialMemberGroupIds.has(id)
|
||||
);
|
||||
const toRemove = Array.from(initialMemberGroupIds).filter(
|
||||
(id) => !memberGroupIds.has(id)
|
||||
);
|
||||
|
||||
if (user.id) {
|
||||
for (const groupId of toAdd) {
|
||||
promises.push(addUserToGroup(groupId, user.id));
|
||||
}
|
||||
for (const groupId of toRemove) {
|
||||
const group = allGroups?.find((g) => g.id === groupId);
|
||||
if (group) {
|
||||
const currentUserIds = group.users.map((u) => u.id);
|
||||
promises.push(
|
||||
removeUserFromGroup(groupId, currentUserIds, user.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasRoleChange) {
|
||||
promises.push(setUserRole(user.email, selectedRole));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
onMutate();
|
||||
toast.success("User updated");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = user.personal_name ?? user.email;
|
||||
|
||||
return (
|
||||
<Modal open onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Modal.Content width="sm">
|
||||
<Modal.Header
|
||||
icon={SvgUsers}
|
||||
title="Edit User's Groups & Roles"
|
||||
description={
|
||||
user.personal_name
|
||||
? `${user.personal_name} (${user.email})`
|
||||
: user.email
|
||||
}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Modal.Body twoTone>
|
||||
<Section
|
||||
gap={1}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
{/* Subsection: white card behind search + groups */}
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-2 bg-background-neutral-00 rounded-12" />
|
||||
<Section
|
||||
gap={0.5}
|
||||
height="auto"
|
||||
alignItems="stretch"
|
||||
justifyContent="start"
|
||||
>
|
||||
<div ref={containerRef} className="relative">
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
if (!dropdownOpen) setDropdownOpen(true);
|
||||
}}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
onBlur={closeDropdown}
|
||||
placeholder="Search groups to join..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-background-neutral-00 border border-border-02 rounded-12 shadow-md p-1">
|
||||
{groupsLoading ? (
|
||||
<Text as="p" text03 secondaryBody className="px-3 py-2">
|
||||
Loading groups...
|
||||
</Text>
|
||||
) : dropdownGroups.length === 0 ? (
|
||||
<Text as="p" text03 secondaryBody className="px-3 py-2">
|
||||
No groups found
|
||||
</Text>
|
||||
) : (
|
||||
<ShadowDiv className="max-h-[200px] flex flex-col gap-1">
|
||||
{dropdownGroups.map((group) => {
|
||||
const isMember = memberGroupIds.has(group.id);
|
||||
return (
|
||||
<LineItem
|
||||
key={group.id}
|
||||
icon={isMember ? SvgCheck : SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
selected={isMember}
|
||||
emphasized={isMember}
|
||||
onMouseDown={(e: React.MouseEvent) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</ShadowDiv>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{joinedGroups.length === 0 ? (
|
||||
<LineItem
|
||||
icon={SvgUsers}
|
||||
description={`${displayName} is not in any groups.`}
|
||||
muted
|
||||
>
|
||||
No groups found
|
||||
</LineItem>
|
||||
) : (
|
||||
<ShadowDiv className="flex flex-col gap-1 max-h-[200px]">
|
||||
{joinedGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className="bg-background-tint-01 rounded-08"
|
||||
>
|
||||
<LineItem
|
||||
icon={SvgUsers}
|
||||
description={`${group.users.length} ${
|
||||
group.users.length === 1 ? "user" : "users"
|
||||
}`}
|
||||
rightChildren={
|
||||
<SvgLogOut className="w-4 h-4 text-text-03" />
|
||||
}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
</div>
|
||||
))}
|
||||
</ShadowDiv>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
{user.role && (
|
||||
<>
|
||||
<Separator noPadding />
|
||||
|
||||
<ContentAction
|
||||
title="User Role"
|
||||
description="This controls their general permissions."
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<InputSelect
|
||||
value={selectedRole}
|
||||
onValueChange={setSelectedRole}
|
||||
>
|
||||
<InputSelect.Trigger />
|
||||
<InputSelect.Content>
|
||||
{ASSIGNABLE_ROLES.map(({ value, label }) => (
|
||||
<InputSelect.Item
|
||||
key={value}
|
||||
value={value}
|
||||
icon={SvgUser}
|
||||
>
|
||||
{label}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button prominence="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Disabled disabled={isSubmitting || !hasChanges}>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</Disabled>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useLayoutEffect, useCallback } from "react";
|
||||
import { SvgEdit } from "@opal/icons";
|
||||
import { Tag } from "@opal/components";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
|
||||
import EditGroupsModal from "./EditGroupsModal";
|
||||
import type { UserRow, UserGroupInfo } from "./interfaces";
|
||||
|
||||
interface GroupsCellProps {
|
||||
groups: UserGroupInfo[];
|
||||
user: UserRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures how many Tag pills fit in the container, accounting for a "+N"
|
||||
* overflow counter when not all tags are visible. Uses a two-phase render:
|
||||
* first renders all tags (clipped by overflow:hidden) for measurement, then
|
||||
* re-renders with only the visible subset + "+N".
|
||||
*
|
||||
* Hovering the cell shows a tooltip with ALL groups. Clicking opens the
|
||||
* edit groups modal.
|
||||
*/
|
||||
export default function GroupsCell({
|
||||
groups,
|
||||
user,
|
||||
onMutate,
|
||||
}: GroupsCellProps) {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const computeVisibleCount = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || groups.length <= 1) {
|
||||
setVisibleCount(groups.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = container.querySelectorAll<HTMLElement>("[data-group-tag]");
|
||||
if (tags.length === 0) return;
|
||||
|
||||
const containerWidth = container.clientWidth;
|
||||
const gap = 4; // gap-1
|
||||
const counterWidth = 32; // "+N" Tag approximate width
|
||||
|
||||
let used = 0;
|
||||
let count = 0;
|
||||
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
const tagWidth = tags[i]!.offsetWidth;
|
||||
const gapBefore = count > 0 ? gap : 0;
|
||||
const hasMore = i < tags.length - 1;
|
||||
const reserve = hasMore ? gap + counterWidth : 0;
|
||||
|
||||
if (used + gapBefore + tagWidth + reserve <= containerWidth) {
|
||||
used += gapBefore + tagWidth;
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setVisibleCount(Math.max(1, count));
|
||||
}, [groups]);
|
||||
|
||||
// Reset to measurement phase when groups change
|
||||
useLayoutEffect(() => {
|
||||
setVisibleCount(null);
|
||||
}, [groups]);
|
||||
|
||||
// Measure after the "show all" render
|
||||
useLayoutEffect(() => {
|
||||
if (visibleCount !== null) return;
|
||||
computeVisibleCount();
|
||||
}, [visibleCount, computeVisibleCount]);
|
||||
|
||||
// Re-measure on container resize
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
setVisibleCount(null);
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const isMeasuring = visibleCount === null;
|
||||
const effectiveVisible = visibleCount ?? groups.length;
|
||||
const overflowCount = groups.length - effectiveVisible;
|
||||
const hasOverflow = !isMeasuring && overflowCount > 0;
|
||||
|
||||
const allGroupsTooltip = (
|
||||
<div className="flex flex-wrap gap-1 max-w-[14rem]">
|
||||
{groups.map((g) => (
|
||||
<div key={g.id} className="max-w-[10rem]">
|
||||
<Tag title={g.name} size="md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const tagsContent = (
|
||||
<>
|
||||
{(isMeasuring ? groups : groups.slice(0, effectiveVisible)).map((g) => (
|
||||
<div key={g.id} data-group-tag className="flex-shrink-0">
|
||||
<Tag title={g.name} size="md" />
|
||||
</div>
|
||||
))}
|
||||
{hasOverflow && (
|
||||
<div className="flex-shrink-0">
|
||||
<Tag title={`+${overflowCount}`} size="md" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`group/groups relative flex items-center w-full min-w-0 ${
|
||||
user.id ? "cursor-pointer" : ""
|
||||
}`}
|
||||
onClick={user.id ? () => setShowModal(true) : undefined}
|
||||
>
|
||||
{groups.length === 0 ? (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
|
||||
>
|
||||
<Text as="span" secondaryBody text03>
|
||||
—
|
||||
</Text>
|
||||
</div>
|
||||
) : hasOverflow ? (
|
||||
<SimpleTooltip
|
||||
side="bottom"
|
||||
align="start"
|
||||
tooltip={allGroupsTooltip}
|
||||
className="bg-background-neutral-01 border border-border-01 shadow-sm"
|
||||
delayDuration={200}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
|
||||
>
|
||||
{tagsContent}
|
||||
</div>
|
||||
</SimpleTooltip>
|
||||
) : (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex items-center gap-1 overflow-hidden flex-nowrap min-w-0 pr-7"
|
||||
>
|
||||
{tagsContent}
|
||||
</div>
|
||||
)}
|
||||
{user.id && (
|
||||
<IconButton
|
||||
tertiary
|
||||
icon={SvgEdit}
|
||||
tooltip="Edit"
|
||||
toolTipPosition="left"
|
||||
tooltipSize="sm"
|
||||
className="absolute right-0 opacity-0 group-hover/groups:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowModal(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showModal && user.id && (
|
||||
<EditGroupsModal
|
||||
user={user}
|
||||
onClose={() => setShowModal(false)}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { SvgUsers, SvgUser } from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import Modal, { BasicModalFooter } from "@/refresh-components/Modal";
|
||||
import InputChipField from "@/refresh-components/inputs/InputChipField";
|
||||
import type { ChipItem } from "@/refresh-components/inputs/InputChipField";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
|
||||
import { inviteUsers } from "./svc";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
/** Roles available for invite — excludes curator-specific and system roles */
|
||||
const INVITE_ROLES = [
|
||||
UserRole.BASIC,
|
||||
UserRole.ADMIN,
|
||||
UserRole.GLOBAL_CURATOR,
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface InviteUsersModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function InviteUsersModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: InviteUsersModalProps) {
|
||||
const [chips, setChips] = useState<ChipItem[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [role, setRole] = useState<string>(UserRole.BASIC);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
function addEmail(value: string) {
|
||||
// Split on commas so pasted lists like "a@b.com, c@d.com" still work
|
||||
const entries = value
|
||||
.split(",")
|
||||
.map((e) => e.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const newChips: ChipItem[] = [];
|
||||
for (const email of entries) {
|
||||
const alreadyAdded = chips.some((c) => c.label === email);
|
||||
if (!alreadyAdded) {
|
||||
newChips.push({
|
||||
id: email,
|
||||
label: email,
|
||||
error: !EMAIL_REGEX.test(email),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newChips.length > 0) {
|
||||
setChips((prev) => [...prev, ...newChips]);
|
||||
}
|
||||
setInputValue("");
|
||||
}
|
||||
|
||||
function removeChip(id: string) {
|
||||
setChips((prev) => prev.filter((c) => c.id !== id));
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
onOpenChange(false);
|
||||
// Reset state after close animation
|
||||
setTimeout(() => {
|
||||
setChips([]);
|
||||
setInputValue("");
|
||||
setRole(UserRole.BASIC);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
async function handleInvite() {
|
||||
const validEmails = chips
|
||||
.map((c) => c.label)
|
||||
.filter((e) => EMAIL_REGEX.test(e));
|
||||
|
||||
if (validEmails.length === 0) {
|
||||
toast.error("Please add at least one valid email address");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await inviteUsers(validEmails);
|
||||
toast.success(
|
||||
`Invited ${validEmails.length} user${validEmails.length > 1 ? "s" : ""}`
|
||||
);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : "Failed to invite users"
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<Modal.Content width="sm" height="fit">
|
||||
<Modal.Header
|
||||
icon={SvgUsers}
|
||||
title="Invite Users"
|
||||
onClose={handleClose}
|
||||
/>
|
||||
|
||||
<Modal.Body>
|
||||
<InputChipField
|
||||
chips={chips}
|
||||
onRemoveChip={removeChip}
|
||||
onAdd={addEmail}
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
placeholder="Add emails to invite, comma separated"
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between w-full gap-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Text as="p" mainUiAction text04>
|
||||
User Role
|
||||
</Text>
|
||||
<Text as="p" secondaryBody text03>
|
||||
Invite new users as
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="w-[200px]">
|
||||
<InputSelect value={role} onValueChange={setRole}>
|
||||
<InputSelect.Trigger />
|
||||
<InputSelect.Content>
|
||||
{INVITE_ROLES.map((r) => (
|
||||
<InputSelect.Item key={r} value={r} icon={SvgUser}>
|
||||
{USER_ROLE_LABELS[r]}
|
||||
</InputSelect.Item>
|
||||
))}
|
||||
</InputSelect.Content>
|
||||
</InputSelect>
|
||||
</div>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<BasicModalFooter
|
||||
cancel={
|
||||
<Button prominence="tertiary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting || chips.length === 0}>
|
||||
<Button onClick={handleInvite}>Invite</Button>
|
||||
</Disabled>
|
||||
}
|
||||
/>
|
||||
</Modal.Footer>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
SvgCheck,
|
||||
SvgSlack,
|
||||
SvgUser,
|
||||
SvgUserManage,
|
||||
SvgUsers,
|
||||
} from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import FilterButton from "@/refresh-components/buttons/FilterButton";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import ShadowDiv from "@/refresh-components/ShadowDiv";
|
||||
import {
|
||||
UserRole,
|
||||
UserStatus,
|
||||
USER_ROLE_LABELS,
|
||||
USER_STATUS_LABELS,
|
||||
} from "@/lib/types";
|
||||
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
|
||||
import type { GroupOption, StatusFilter } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const VISIBLE_FILTER_ROLES: UserRole[] = [
|
||||
UserRole.ADMIN,
|
||||
UserRole.GLOBAL_CURATOR,
|
||||
UserRole.BASIC,
|
||||
UserRole.SLACK_USER,
|
||||
];
|
||||
|
||||
const FILTERABLE_ROLES = VISIBLE_FILTER_ROLES.map(
|
||||
(role) => [role, USER_ROLE_LABELS[role]] as [UserRole, string]
|
||||
);
|
||||
|
||||
const FILTERABLE_STATUSES = (
|
||||
Object.entries(USER_STATUS_LABELS) as [UserStatus, string][]
|
||||
).filter(
|
||||
([value]) => value !== UserStatus.REQUESTED || NEXT_PUBLIC_CLOUD_ENABLED
|
||||
);
|
||||
|
||||
const ROLE_ICONS: Partial<Record<UserRole, IconFunctionComponent>> = {
|
||||
[UserRole.ADMIN]: SvgUserManage,
|
||||
[UserRole.SLACK_USER]: SvgSlack,
|
||||
};
|
||||
|
||||
/** Map UserStatus enum values to the keys returned by the counts endpoint. */
|
||||
const STATUS_COUNT_KEY: Record<UserStatus, string> = {
|
||||
[UserStatus.ACTIVE]: "active",
|
||||
[UserStatus.INACTIVE]: "inactive",
|
||||
[UserStatus.INVITED]: "invited",
|
||||
[UserStatus.REQUESTED]: "requested",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CountBadge({ count }: { count: number | undefined }) {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
{count ?? 0}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UserFiltersProps {
|
||||
selectedRoles: UserRole[];
|
||||
onRolesChange: (roles: UserRole[]) => void;
|
||||
selectedGroups: number[];
|
||||
onGroupsChange: (groupIds: number[]) => void;
|
||||
groups: GroupOption[];
|
||||
selectedStatuses: StatusFilter;
|
||||
onStatusesChange: (statuses: StatusFilter) => void;
|
||||
roleCounts: Record<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
export default function UserFilters({
|
||||
selectedRoles,
|
||||
onRolesChange,
|
||||
selectedGroups,
|
||||
onGroupsChange,
|
||||
groups,
|
||||
selectedStatuses,
|
||||
onStatusesChange,
|
||||
roleCounts,
|
||||
statusCounts,
|
||||
}: UserFiltersProps) {
|
||||
const hasRoleFilter = selectedRoles.length > 0;
|
||||
const hasGroupFilter = selectedGroups.length > 0;
|
||||
const hasStatusFilter = selectedStatuses.length > 0;
|
||||
const [groupSearch, setGroupSearch] = useState("");
|
||||
|
||||
const toggleRole = (role: UserRole) => {
|
||||
if (selectedRoles.includes(role)) {
|
||||
onRolesChange(selectedRoles.filter((r) => r !== role));
|
||||
} else {
|
||||
onRolesChange([...selectedRoles, role]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (groupId: number) => {
|
||||
if (selectedGroups.includes(groupId)) {
|
||||
onGroupsChange(selectedGroups.filter((id) => id !== groupId));
|
||||
} else {
|
||||
onGroupsChange([...selectedGroups, groupId]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleStatus = (status: UserStatus) => {
|
||||
if (selectedStatuses.includes(status)) {
|
||||
onStatusesChange(selectedStatuses.filter((s) => s !== status));
|
||||
} else {
|
||||
onStatusesChange([...selectedStatuses, status]);
|
||||
}
|
||||
};
|
||||
|
||||
const roleLabel = hasRoleFilter
|
||||
? FILTERABLE_ROLES.filter(([role]) => selectedRoles.includes(role))
|
||||
.map(([, label]) => label)
|
||||
.slice(0, 2)
|
||||
.join(", ") +
|
||||
(selectedRoles.length > 2 ? `, +${selectedRoles.length - 2}` : "")
|
||||
: "All Account Types";
|
||||
|
||||
const groupLabel = hasGroupFilter
|
||||
? groups
|
||||
.filter((g) => selectedGroups.includes(g.id))
|
||||
.map((g) => g.name)
|
||||
.slice(0, 2)
|
||||
.join(", ") +
|
||||
(selectedGroups.length > 2 ? `, +${selectedGroups.length - 2}` : "")
|
||||
: "All Groups";
|
||||
|
||||
const statusLabel = hasStatusFilter
|
||||
? FILTERABLE_STATUSES.filter(([status]) =>
|
||||
selectedStatuses.includes(status)
|
||||
)
|
||||
.map(([, label]) => label)
|
||||
.slice(0, 2)
|
||||
.join(", ") +
|
||||
(selectedStatuses.length > 2 ? `, +${selectedStatuses.length - 2}` : "")
|
||||
: "All Status";
|
||||
|
||||
const filteredGroups = groupSearch
|
||||
? groups.filter((g) =>
|
||||
g.name.toLowerCase().includes(groupSearch.toLowerCase())
|
||||
)
|
||||
: groups;
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
{/* Role filter */}
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
leftIcon={SvgUsers}
|
||||
active={hasRoleFilter}
|
||||
onClear={() => onRolesChange([])}
|
||||
>
|
||||
{roleLabel}
|
||||
</FilterButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
|
||||
<LineItem
|
||||
icon={!hasRoleFilter ? SvgCheck : SvgUsers}
|
||||
selected={!hasRoleFilter}
|
||||
emphasized={!hasRoleFilter}
|
||||
onClick={() => onRolesChange([])}
|
||||
>
|
||||
All Account Types
|
||||
</LineItem>
|
||||
{FILTERABLE_ROLES.map(([role, label]) => {
|
||||
const isSelected = selectedRoles.includes(role);
|
||||
const roleIcon = ROLE_ICONS[role] ?? SvgUser;
|
||||
return (
|
||||
<LineItem
|
||||
key={role}
|
||||
icon={isSelected ? SvgCheck : roleIcon}
|
||||
selected={isSelected}
|
||||
emphasized={isSelected}
|
||||
onClick={() => toggleRole(role)}
|
||||
rightChildren={<CountBadge count={roleCounts[role]} />}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{/* Groups filter */}
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
leftIcon={SvgUsers}
|
||||
active={hasGroupFilter}
|
||||
onClear={() => onGroupsChange([])}
|
||||
>
|
||||
{groupLabel}
|
||||
</FilterButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
|
||||
<InputTypeIn
|
||||
value={groupSearch}
|
||||
onChange={(e) => setGroupSearch(e.target.value)}
|
||||
placeholder="Search groups..."
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
/>
|
||||
<LineItem
|
||||
icon={!hasGroupFilter ? SvgCheck : SvgUsers}
|
||||
selected={!hasGroupFilter}
|
||||
emphasized={!hasGroupFilter}
|
||||
onClick={() => onGroupsChange([])}
|
||||
>
|
||||
All Groups
|
||||
</LineItem>
|
||||
<ShadowDiv className="flex flex-col gap-1 max-h-[240px]">
|
||||
{filteredGroups.map((group) => {
|
||||
const isSelected = selectedGroups.includes(group.id);
|
||||
return (
|
||||
<LineItem
|
||||
key={group.id}
|
||||
icon={isSelected ? SvgCheck : SvgUsers}
|
||||
selected={isSelected}
|
||||
emphasized={isSelected}
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
rightChildren={<CountBadge count={group.memberCount} />}
|
||||
>
|
||||
{group.name}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
{filteredGroups.length === 0 && (
|
||||
<Text as="span" secondaryBody text03 className="px-2 py-1.5">
|
||||
No groups found
|
||||
</Text>
|
||||
)}
|
||||
</ShadowDiv>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{/* Status filter */}
|
||||
<Popover>
|
||||
<Popover.Trigger asChild>
|
||||
<FilterButton
|
||||
leftIcon={SvgUsers}
|
||||
active={hasStatusFilter}
|
||||
onClear={() => onStatusesChange([])}
|
||||
>
|
||||
{statusLabel}
|
||||
</FilterButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-1 min-w-[200px]">
|
||||
<LineItem
|
||||
icon={!hasStatusFilter ? SvgCheck : SvgUser}
|
||||
selected={!hasStatusFilter}
|
||||
emphasized={!hasStatusFilter}
|
||||
onClick={() => onStatusesChange([])}
|
||||
>
|
||||
All Status
|
||||
</LineItem>
|
||||
{FILTERABLE_STATUSES.map(([status, label]) => {
|
||||
const isSelected = selectedStatuses.includes(status);
|
||||
const countKey = STATUS_COUNT_KEY[status];
|
||||
return (
|
||||
<LineItem
|
||||
key={status}
|
||||
icon={isSelected ? SvgCheck : SvgUser}
|
||||
selected={isSelected}
|
||||
emphasized={isSelected}
|
||||
onClick={() => toggleStatus(status)}
|
||||
rightChildren={<CountBadge count={statusCounts[countKey]} />}
|
||||
>
|
||||
{label}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserRole, USER_ROLE_LABELS } from "@/lib/types";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import { OpenButton } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import GenericConfirmModal from "@/components/modals/GenericConfirmModal";
|
||||
import {
|
||||
SvgCheck,
|
||||
SvgGlobe,
|
||||
SvgUser,
|
||||
SvgSlack,
|
||||
SvgUserManage,
|
||||
} from "@opal/icons";
|
||||
import type { IconFunctionComponent } from "@opal/types";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { setUserRole } from "./svc";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
const ROLE_ICONS: Record<string, IconFunctionComponent> = {
|
||||
[UserRole.ADMIN]: SvgUserManage,
|
||||
[UserRole.GLOBAL_CURATOR]: SvgGlobe,
|
||||
[UserRole.SLACK_USER]: SvgSlack,
|
||||
};
|
||||
|
||||
const SELECTABLE_ROLES = [
|
||||
UserRole.ADMIN,
|
||||
UserRole.GLOBAL_CURATOR,
|
||||
UserRole.BASIC,
|
||||
] as const;
|
||||
|
||||
interface UserRoleCellProps {
|
||||
user: UserRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export default function UserRoleCell({ user, onMutate }: UserRoleCellProps) {
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [pendingRole, setPendingRole] = useState<string | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
if (!user.role) {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const applyRole = async (newRole: string) => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await setUserRole(user.email, newRole);
|
||||
onMutate();
|
||||
} catch {
|
||||
onMutate();
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (role: UserRole) => {
|
||||
if (role === user.role) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
if (user.role === UserRole.CURATOR) {
|
||||
setPendingRole(role);
|
||||
setShowConfirmModal(true);
|
||||
} else {
|
||||
applyRole(role);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (pendingRole) {
|
||||
applyRole(pendingRole);
|
||||
}
|
||||
setShowConfirmModal(false);
|
||||
setPendingRole(null);
|
||||
};
|
||||
|
||||
const currentIcon = ROLE_ICONS[user.role] ?? SvgUser;
|
||||
|
||||
return (
|
||||
<>
|
||||
{showConfirmModal && (
|
||||
<GenericConfirmModal
|
||||
title="Change Curator Role"
|
||||
message={`Warning: Switching roles from Curator to ${
|
||||
USER_ROLE_LABELS[pendingRole as UserRole] ??
|
||||
USER_ROLE_LABELS[user.role]
|
||||
} will remove their status as individual curators from all groups.`}
|
||||
confirmText={`Switch Role to ${
|
||||
USER_ROLE_LABELS[pendingRole as UserRole] ??
|
||||
USER_ROLE_LABELS[user.role]
|
||||
}`}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Disabled disabled={isUpdating}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<OpenButton
|
||||
icon={currentIcon}
|
||||
variant="select-tinted"
|
||||
width="full"
|
||||
justifyContent="between"
|
||||
>
|
||||
{USER_ROLE_LABELS[user.role]}
|
||||
</OpenButton>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start">
|
||||
<div className="flex flex-col gap-1 p-1 min-w-[160px]">
|
||||
{SELECTABLE_ROLES.map((role) => {
|
||||
if (
|
||||
role === UserRole.GLOBAL_CURATOR &&
|
||||
!isPaidEnterpriseFeaturesEnabled
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const isSelected = user.role === role;
|
||||
const icon = ROLE_ICONS[role] ?? SvgUser;
|
||||
return (
|
||||
<LineItem
|
||||
key={role}
|
||||
icon={isSelected ? SvgCheck : icon}
|
||||
selected={isSelected}
|
||||
emphasized={isSelected}
|
||||
onClick={() => handleSelect(role)}
|
||||
>
|
||||
{USER_ROLE_LABELS[role]}
|
||||
</LineItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
</Disabled>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import {
|
||||
SvgMoreHorizontal,
|
||||
SvgUsers,
|
||||
SvgXCircle,
|
||||
SvgTrash,
|
||||
SvgCheck,
|
||||
} from "@opal/icons";
|
||||
import { Disabled } from "@opal/core";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { UserStatus } from "@/lib/types";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import {
|
||||
deactivateUser,
|
||||
activateUser,
|
||||
deleteUser,
|
||||
cancelInvite,
|
||||
approveRequest,
|
||||
} from "./svc";
|
||||
import EditGroupsModal from "./EditGroupsModal";
|
||||
import type { UserRow } from "./interfaces";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ModalType =
|
||||
| "deactivate"
|
||||
| "activate"
|
||||
| "delete"
|
||||
| "cancelInvite"
|
||||
| "editGroups"
|
||||
| null;
|
||||
|
||||
interface UserRowActionsProps {
|
||||
user: UserRow;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function UserRowActions({
|
||||
user,
|
||||
onMutate,
|
||||
}: UserRowActionsProps) {
|
||||
const [modal, setModal] = useState<ModalType>(null);
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
async function handleAction(
|
||||
action: () => Promise<void>,
|
||||
successMessage: string
|
||||
) {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await action();
|
||||
onMutate();
|
||||
toast.success(successMessage);
|
||||
setModal(null);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "An error occurred");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const openModal = (type: ModalType) => {
|
||||
setPopoverOpen(false);
|
||||
setModal(type);
|
||||
};
|
||||
|
||||
// Status-aware action menus
|
||||
const actionButtons = (() => {
|
||||
switch (user.status) {
|
||||
case UserStatus.INVITED:
|
||||
return (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgXCircle}
|
||||
onClick={() => openModal("cancelInvite")}
|
||||
>
|
||||
Cancel Invite
|
||||
</Button>
|
||||
);
|
||||
|
||||
case UserStatus.REQUESTED:
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgCheck}
|
||||
onClick={() => {
|
||||
setPopoverOpen(false);
|
||||
handleAction(
|
||||
() => approveRequest(user.email),
|
||||
"Request approved"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgXCircle}
|
||||
onClick={() => openModal("cancelInvite")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case UserStatus.ACTIVE:
|
||||
return (
|
||||
<>
|
||||
{user.id && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUsers}
|
||||
onClick={() => openModal("editGroups")}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgXCircle}
|
||||
onClick={() => openModal("deactivate")}
|
||||
>
|
||||
Deactivate User
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
case UserStatus.INACTIVE:
|
||||
return (
|
||||
<>
|
||||
{user.id && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgUsers}
|
||||
onClick={() => openModal("editGroups")}
|
||||
>
|
||||
Groups
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgCheck}
|
||||
onClick={() => openModal("activate")}
|
||||
>
|
||||
Activate User
|
||||
</Button>
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
variant="danger"
|
||||
icon={SvgTrash}
|
||||
onClick={() => openModal("delete")}
|
||||
>
|
||||
Delete User
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end">
|
||||
<div className="flex flex-col gap-0.5 p-1">
|
||||
{actionButtons}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
|
||||
{modal === "editGroups" && user.id && (
|
||||
<EditGroupsModal
|
||||
user={user}
|
||||
onClose={() => setModal(null)}
|
||||
onMutate={onMutate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modal === "cancelInvite" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgXCircle}
|
||||
title={
|
||||
user.status === UserStatus.REQUESTED
|
||||
? "Reject Request"
|
||||
: "Cancel Invite"
|
||||
}
|
||||
onClose={() => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
handleAction(
|
||||
() => cancelInvite(user.email),
|
||||
user.status === UserStatus.REQUESTED
|
||||
? "Request rejected"
|
||||
: "Invite cancelled"
|
||||
);
|
||||
}}
|
||||
>
|
||||
{user.status === UserStatus.REQUESTED ? "Reject" : "Cancel"}
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
{user.status === UserStatus.REQUESTED
|
||||
? "will be removed from the pending requests list."
|
||||
: "will no longer be able to join Onyx with this invite."}
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "deactivate" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgXCircle}
|
||||
title="Deactivate User"
|
||||
onClose={() => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
handleAction(
|
||||
() => deactivateUser(user.email),
|
||||
"User deactivated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Deactivate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will immediately lose access to Onyx. Their sessions and agents will
|
||||
be preserved. Their license seat will be freed. You can reactivate
|
||||
this account later.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "activate" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgCheck}
|
||||
title="Activate User"
|
||||
onClose={() => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleAction(
|
||||
() => activateUser(user.email),
|
||||
"User activated"
|
||||
);
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will regain access to Onyx.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
|
||||
{modal === "delete" && (
|
||||
<ConfirmationModalLayout
|
||||
icon={SvgTrash}
|
||||
title="Delete User"
|
||||
onClose={() => setModal(null)}
|
||||
submit={
|
||||
<Disabled disabled={isSubmitting}>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
handleAction(() => deleteUser(user.email), "User deleted");
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Disabled>
|
||||
}
|
||||
>
|
||||
<Text as="p" text03>
|
||||
<Text as="span" text05>
|
||||
{user.email}
|
||||
</Text>{" "}
|
||||
will be permanently removed from Onyx. All of their session history
|
||||
will be deleted. Deletion cannot be undone.
|
||||
</Text>
|
||||
</ConfirmationModalLayout>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { SvgArrowUpRight, SvgFilterPlus, SvgUserSync } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { Button } from "@opal/components";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import Link from "next/link";
|
||||
import { ADMIN_PATHS } from "@/lib/admin-routes";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats cell — number + label + hover filter icon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type StatCellProps = {
|
||||
value: number | null;
|
||||
label: string;
|
||||
onFilter?: () => void;
|
||||
};
|
||||
|
||||
function StatCell({ value, label, onFilter }: StatCellProps) {
|
||||
const display = value === null ? "\u2014" : value.toLocaleString();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/stat relative flex flex-col items-start gap-0.5 w-full p-2 rounded-08 transition-colors cursor-pointer hover:bg-background-tint-02"
|
||||
onClick={onFilter}
|
||||
>
|
||||
<Text as="span" mainUiAction text04>
|
||||
{display}
|
||||
</Text>
|
||||
<Text as="span" secondaryBody text03>
|
||||
{label}
|
||||
</Text>
|
||||
<IconButton
|
||||
tertiary
|
||||
icon={SvgFilterPlus}
|
||||
tooltip="Add Filter"
|
||||
toolTipPosition="left"
|
||||
tooltipSize="sm"
|
||||
className="absolute right-1 top-1 opacity-0 group-hover/stat:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFilter?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SCIM card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ScimCard() {
|
||||
return (
|
||||
<Card gap={0.5} padding={0.75}>
|
||||
<ContentAction
|
||||
icon={SvgUserSync}
|
||||
title="SCIM Sync"
|
||||
description="Users are synced from your identity provider."
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Link href={ADMIN_PATHS.SCIM}>
|
||||
<Button prominence="tertiary" rightIcon={SvgArrowUpRight} size="sm">
|
||||
Manage
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stats bar — layout varies by SCIM status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type UsersSummaryProps = {
|
||||
activeUsers: number | null;
|
||||
pendingInvites: number | null;
|
||||
requests: number | null;
|
||||
showScim: boolean;
|
||||
onFilterActive?: () => void;
|
||||
onFilterInvites?: () => void;
|
||||
onFilterRequests?: () => void;
|
||||
};
|
||||
|
||||
export default function UsersSummary({
|
||||
activeUsers,
|
||||
pendingInvites,
|
||||
requests,
|
||||
showScim,
|
||||
onFilterActive,
|
||||
onFilterInvites,
|
||||
onFilterRequests,
|
||||
}: UsersSummaryProps) {
|
||||
const showRequests = requests !== null && requests > 0;
|
||||
|
||||
const statsCard = (
|
||||
<Card padding={0.5}>
|
||||
<Section flexDirection="row" gap={0}>
|
||||
<StatCell
|
||||
value={activeUsers}
|
||||
label="active users"
|
||||
onFilter={onFilterActive}
|
||||
/>
|
||||
<StatCell
|
||||
value={pendingInvites}
|
||||
label="pending invites"
|
||||
onFilter={onFilterInvites}
|
||||
/>
|
||||
{showRequests && (
|
||||
<StatCell
|
||||
value={requests}
|
||||
label="requests to join"
|
||||
onFilter={onFilterRequests}
|
||||
/>
|
||||
)}
|
||||
</Section>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (showScim) {
|
||||
return (
|
||||
<Section
|
||||
flexDirection="row"
|
||||
justifyContent="start"
|
||||
alignItems="stretch"
|
||||
gap={0.5}
|
||||
>
|
||||
{statsCard}
|
||||
<ScimCard />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// No SCIM — each stat gets its own card
|
||||
return (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
<Card padding={0.5}>
|
||||
<StatCell
|
||||
value={activeUsers}
|
||||
label="active users"
|
||||
onFilter={onFilterActive}
|
||||
/>
|
||||
</Card>
|
||||
<Card padding={0.5}>
|
||||
<StatCell
|
||||
value={pendingInvites}
|
||||
label="pending invites"
|
||||
onFilter={onFilterInvites}
|
||||
/>
|
||||
</Card>
|
||||
{showRequests && (
|
||||
<Card padding={0.5}>
|
||||
<StatCell
|
||||
value={requests}
|
||||
label="requests to join"
|
||||
onFilter={onFilterRequests}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import { Content } from "@opal/layouts";
|
||||
import SvgNoResult from "@opal/illustrations/no-result";
|
||||
import { IllustrationContent } from "@opal/layouts";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import { UserRole, UserStatus, USER_STATUS_LABELS } from "@/lib/types";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import useAdminUsers from "@/hooks/useAdminUsers";
|
||||
import useGroups from "@/hooks/useGroups";
|
||||
import UserFilters from "./UserFilters";
|
||||
import UserRowActions from "./UserRowActions";
|
||||
import UserRoleCell from "./UserRoleCell";
|
||||
import type {
|
||||
UserRow,
|
||||
UserGroupInfo,
|
||||
GroupOption,
|
||||
StatusFilter,
|
||||
} from "./interfaces";
|
||||
import { getInitials } from "./utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Column renderers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderNameColumn(email: string, row: UserRow) {
|
||||
return (
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
title={row.personal_name ?? email}
|
||||
description={row.personal_name ? email : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroupsColumn(groups: UserGroupInfo[]) {
|
||||
if (!groups.length) {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
{"\u2014"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const visible = groups.slice(0, 2);
|
||||
const overflow = groups.length - visible.length;
|
||||
return (
|
||||
<div className="flex items-center gap-1 flex-nowrap overflow-hidden min-w-0">
|
||||
{visible.map((g) => (
|
||||
<span
|
||||
key={g.id}
|
||||
className="inline-flex items-center flex-shrink-0 rounded-md bg-background-tint-02 px-2 py-0.5 whitespace-nowrap"
|
||||
>
|
||||
<Text as="span" secondaryBody text03>
|
||||
{g.name}
|
||||
</Text>
|
||||
</span>
|
||||
))}
|
||||
{overflow > 0 && (
|
||||
<Text as="span" secondaryBody text03>
|
||||
+{overflow}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatusColumn(value: UserStatus, row: UserRow) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Text as="span" mainUiBody text03>
|
||||
{USER_STATUS_LABELS[value] ?? value}
|
||||
</Text>
|
||||
{row.is_scim_synced && (
|
||||
<Text as="span" secondaryBody text03>
|
||||
SCIM synced
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLastUpdatedColumn(value: string | null) {
|
||||
return (
|
||||
<Text as="span" secondaryBody text03>
|
||||
{value ? timeAgo(value) ?? "\u2014" : "\u2014"}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Columns
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const tc = createTableColumns<UserRow>();
|
||||
|
||||
function buildColumns(onMutate: () => void) {
|
||||
return [
|
||||
tc.qualifier({
|
||||
content: "avatar-user",
|
||||
getInitials: (row) => getInitials(row.personal_name, row.email),
|
||||
selectable: false,
|
||||
}),
|
||||
tc.column("email", {
|
||||
header: "Name",
|
||||
weight: 22,
|
||||
minWidth: 140,
|
||||
cell: renderNameColumn,
|
||||
}),
|
||||
tc.column("groups", {
|
||||
header: "Groups",
|
||||
weight: 24,
|
||||
minWidth: 200,
|
||||
enableSorting: false,
|
||||
cell: renderGroupsColumn,
|
||||
}),
|
||||
tc.column("role", {
|
||||
header: "Account Type",
|
||||
weight: 16,
|
||||
minWidth: 180,
|
||||
cell: (_value, row) => <UserRoleCell user={row} onMutate={onMutate} />,
|
||||
}),
|
||||
tc.column("status", {
|
||||
header: "Status",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderStatusColumn,
|
||||
}),
|
||||
tc.column("updated_at", {
|
||||
header: "Last Updated",
|
||||
weight: 14,
|
||||
minWidth: 100,
|
||||
cell: renderLastUpdatedColumn,
|
||||
}),
|
||||
tc.actions({
|
||||
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
interface UsersTableProps {
|
||||
selectedStatuses: StatusFilter;
|
||||
onStatusesChange: (statuses: StatusFilter) => void;
|
||||
roleCounts: Record<string, number>;
|
||||
statusCounts: Record<string, number>;
|
||||
}
|
||||
|
||||
export default function UsersTable({
|
||||
selectedStatuses,
|
||||
onStatusesChange,
|
||||
roleCounts,
|
||||
statusCounts,
|
||||
}: UsersTableProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedRoles, setSelectedRoles] = useState<UserRole[]>([]);
|
||||
const [selectedGroups, setSelectedGroups] = useState<number[]>([]);
|
||||
|
||||
const { data: allGroups } = useGroups();
|
||||
|
||||
const groupOptions: GroupOption[] = useMemo(
|
||||
() =>
|
||||
(allGroups ?? []).map((g) => ({
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
memberCount: g.users.length,
|
||||
})),
|
||||
[allGroups]
|
||||
);
|
||||
|
||||
const { users, isLoading, refresh } = useAdminUsers();
|
||||
|
||||
const columns = useMemo(() => buildColumns(() => refresh()), [refresh]);
|
||||
|
||||
// Client-side filtering
|
||||
const filteredUsers = useMemo(() => {
|
||||
let result = users;
|
||||
|
||||
if (selectedRoles.length > 0) {
|
||||
result = result.filter(
|
||||
(u) => u.role !== null && selectedRoles.includes(u.role)
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedStatuses.length > 0) {
|
||||
result = result.filter((u) => selectedStatuses.includes(u.status));
|
||||
}
|
||||
|
||||
if (selectedGroups.length > 0) {
|
||||
result = result.filter((u) =>
|
||||
u.groups.some((g) => selectedGroups.includes(g.id))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [users, selectedRoles, selectedStatuses, selectedGroups]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<SimpleLoader className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
leftSearchIcon
|
||||
/>
|
||||
<UserFilters
|
||||
selectedRoles={selectedRoles}
|
||||
onRolesChange={setSelectedRoles}
|
||||
selectedGroups={selectedGroups}
|
||||
onGroupsChange={setSelectedGroups}
|
||||
groups={groupOptions}
|
||||
selectedStatuses={selectedStatuses}
|
||||
onStatusesChange={onStatusesChange}
|
||||
roleCounts={roleCounts}
|
||||
statusCounts={statusCounts}
|
||||
/>
|
||||
{filteredUsers.length === 0 ? (
|
||||
<IllustrationContent
|
||||
illustration={SvgNoResult}
|
||||
title="No users found"
|
||||
description={
|
||||
searchTerm
|
||||
? "Try a different search term or adjust your filters."
|
||||
: "No users match the current filters."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<DataTable
|
||||
data={filteredUsers}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id ?? row.email}
|
||||
pageSize={PAGE_SIZE}
|
||||
searchTerm={searchTerm}
|
||||
footer={{ mode: "summary" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { UserRole, UserStatus } from "@/lib/types";
|
||||
|
||||
export interface UserGroupInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserRow {
|
||||
id: string | null;
|
||||
email: string;
|
||||
role: UserRole | null;
|
||||
status: UserStatus;
|
||||
is_active: boolean;
|
||||
is_scim_synced: boolean;
|
||||
personal_name: string | null;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
groups: UserGroupInfo[];
|
||||
}
|
||||
|
||||
export interface GroupOption {
|
||||
id: number;
|
||||
name: string;
|
||||
memberCount?: number;
|
||||
}
|
||||
|
||||
/** Empty array = no filter (show all). */
|
||||
export type StatusFilter = UserStatus[];
|
||||
@@ -1,135 +0,0 @@
|
||||
export async function deactivateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/deactivate-user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to deactivate user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function activateUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/activate-user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to activate user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUser(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/delete-user", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to delete user");
|
||||
}
|
||||
}
|
||||
|
||||
export async function setUserRole(
|
||||
email: string,
|
||||
newRole: string
|
||||
): Promise<void> {
|
||||
const res = await fetch("/api/manage/set-user-role", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email, new_role: newRole }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to update user role");
|
||||
}
|
||||
}
|
||||
|
||||
export async function addUserToGroup(
|
||||
groupId: number,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/manage/admin/user-group/${groupId}/add-users`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_ids: [userId] }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to add user to group");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeUserFromGroup(
|
||||
groupId: number,
|
||||
currentUserIds: string[],
|
||||
userIdToRemove: string
|
||||
): Promise<void> {
|
||||
const res = await fetch(`/api/manage/admin/user-group/${groupId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
user_ids: currentUserIds.filter((id) => id !== userIdToRemove),
|
||||
cc_pair_ids: [],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to remove user from group");
|
||||
}
|
||||
}
|
||||
|
||||
export async function cancelInvite(email: string): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/remove-invited-user", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_email: email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to cancel invite");
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveRequest(email: string): Promise<void> {
|
||||
const res = await fetch("/api/tenants/users/invite/approve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to approve request");
|
||||
}
|
||||
}
|
||||
|
||||
export async function inviteUsers(emails: string[]): Promise<void> {
|
||||
const res = await fetch("/api/manage/admin/users", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ emails }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to invite users");
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadUsersCsv(): Promise<void> {
|
||||
const res = await fetch("/api/manage/users/download");
|
||||
if (!res.ok) {
|
||||
const detail = (await res.json()).detail;
|
||||
throw new Error(detail ?? "Failed to download users CSV");
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "onyx_users.csv";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { getInitials } from "./utils";
|
||||
|
||||
describe("getInitials", () => {
|
||||
it("returns first letters of first two name parts", () => {
|
||||
expect(getInitials("Alice Smith", "alice@example.com")).toBe("AS");
|
||||
});
|
||||
|
||||
it("returns first two chars of a single-word name", () => {
|
||||
expect(getInitials("Alice", "alice@example.com")).toBe("AL");
|
||||
});
|
||||
|
||||
it("handles three-word names (uses first two)", () => {
|
||||
expect(getInitials("Alice B. Smith", "alice@example.com")).toBe("AB");
|
||||
});
|
||||
|
||||
it("falls back to email local part with dot separator", () => {
|
||||
expect(getInitials(null, "alice.smith@example.com")).toBe("AS");
|
||||
});
|
||||
|
||||
it("falls back to email local part with underscore separator", () => {
|
||||
expect(getInitials(null, "alice_smith@example.com")).toBe("AS");
|
||||
});
|
||||
|
||||
it("falls back to email local part with hyphen separator", () => {
|
||||
expect(getInitials(null, "alice-smith@example.com")).toBe("AS");
|
||||
});
|
||||
|
||||
it("uses first two chars of email local if no separator", () => {
|
||||
expect(getInitials(null, "alice@example.com")).toBe("AL");
|
||||
});
|
||||
|
||||
it("returns ? for empty email local part", () => {
|
||||
expect(getInitials(null, "@example.com")).toBe("?");
|
||||
});
|
||||
|
||||
it("uppercases the result", () => {
|
||||
expect(getInitials("john doe", "jd@test.com")).toBe("JD");
|
||||
});
|
||||
|
||||
it("trims whitespace from name", () => {
|
||||
expect(getInitials(" Alice Smith ", "a@test.com")).toBe("AS");
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Derive display initials from a user's name or email.
|
||||
*
|
||||
* - If a name is provided, uses the first letter of the first two words.
|
||||
* - Falls back to the email local part, splitting on `.`, `_`, or `-`.
|
||||
* - Returns at most 2 uppercase characters.
|
||||
*/
|
||||
export function getInitials(name: string | null, email: string): string {
|
||||
if (name) {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
const local = email.split("@")[0];
|
||||
if (!local) return "?";
|
||||
const parts = local.split(/[._-]/);
|
||||
if (parts.length >= 2) {
|
||||
return ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
}
|
||||
return local.slice(0, 2).toUpperCase();
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { ChatState } from "@/app/app/interfaces";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import { useAppMode } from "@/providers/AppModeProvider";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { cn, isImageFile } from "@/lib/utils";
|
||||
import { Disabled } from "@opal/core";
|
||||
@@ -119,10 +120,7 @@ const AppInputBar = React.memo(
|
||||
const filesContentRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { user } = useUser();
|
||||
const { state } = useQueryController();
|
||||
const isClassifying = state.phase === "classifying";
|
||||
const isSearchActive =
|
||||
state.phase === "searching" || state.phase === "search-results";
|
||||
const { isClassifying, classification } = useQueryController();
|
||||
|
||||
// Expose reset and focus methods to parent via ref
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
@@ -142,10 +140,12 @@ const AppInputBar = React.memo(
|
||||
setMessage(initialMessage);
|
||||
}
|
||||
}, [initialMessage]);
|
||||
|
||||
const { appMode } = useAppMode();
|
||||
const appFocus = useAppFocus();
|
||||
const appMode = state.phase === "idle" ? state.appMode : undefined;
|
||||
const isSearchMode =
|
||||
(appFocus.isNewSession() && appMode === "search") || isSearchActive;
|
||||
(appFocus.isNewSession() && appMode === "search") ||
|
||||
classification === "search";
|
||||
|
||||
const { forcedToolIds, setForcedToolIds } = useForcedTools();
|
||||
const { currentMessageFiles, setCurrentMessageFiles, currentProjectId } =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user